summaryrefslogtreecommitdiffstats
path: root/browser/components/places/content
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/components/places/content
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--browser/components/places/content/bookmarkProperties.js534
-rw-r--r--browser/components/places/content/bookmarkProperties.xhtml52
-rw-r--r--browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml14
-rw-r--r--browser/components/places/content/bookmarksSidebar.js52
-rw-r--r--browser/components/places/content/bookmarksSidebar.xhtml64
-rw-r--r--browser/components/places/content/browserPlacesViews.js2253
-rw-r--r--browser/components/places/content/controller.js1732
-rw-r--r--browser/components/places/content/editBookmark.js1156
-rw-r--r--browser/components/places/content/editBookmarkPanel.inc.xhtml126
-rw-r--r--browser/components/places/content/historySidebar.js173
-rw-r--r--browser/components/places/content/historySidebar.xhtml96
-rw-r--r--browser/components/places/content/instantEditBookmark.js1396
-rw-r--r--browser/components/places/content/places-menupopup.js692
-rw-r--r--browser/components/places/content/places-tree.js861
-rw-r--r--browser/components/places/content/places.css43
-rw-r--r--browser/components/places/content/places.js1499
-rw-r--r--browser/components/places/content/places.xhtml416
-rw-r--r--browser/components/places/content/placesCommands.inc.xhtml52
-rw-r--r--browser/components/places/content/placesContextMenu.inc.xhtml174
-rw-r--r--browser/components/places/content/treeView.js1871
20 files changed, 13256 insertions, 0 deletions
diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js
new file mode 100644
index 0000000000..532813265b
--- /dev/null
+++ b/browser/components/places/content/bookmarkProperties.js
@@ -0,0 +1,534 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/**
+ * The panel is initialized based on data given in the js object passed
+ * as window.arguments[0]. The object must have the following fields set:
+ * @ action (String). Possible values:
+ * - "add" - for adding a new item.
+ * @ type (String). Possible values:
+ * - "bookmark"
+ * - "folder"
+ * @ URIList (Array of nsIURI objects) - optional, list of uris to
+ * be bookmarked under the new folder.
+ * @ uri (nsIURI object) - optional, the default uri for the new item.
+ * The property is not used for the "folder with items" type.
+ * @ title (String) - optional, the default title for the new item.
+ * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the
+ * default insertion point for the new item.
+ * @ keyword (String) - optional, the default keyword for the new item.
+ * @ postData (String) - optional, POST data to accompany the keyword.
+ * @ charSet (String) - optional, character-set to accompany the keyword.
+ * Notes:
+ * 1) If |uri| is set for a bookmark and |title| isn't,
+ * the dialog will query the history tables for the title associated
+ * with the given uri. If the dialog is set to adding a folder with
+ * bookmark items under it (see URIList), a default static title is
+ * used ("[Folder Name]").
+ * 2) The index field of the default insertion point is ignored if
+ * the folder picker is shown.
+ * - "edit" - for editing a bookmark item or a folder.
+ * @ type (String). Possible values:
+ * - "bookmark"
+ * @ node (an nsINavHistoryResultNode object) - a node representing
+ * the bookmark.
+ * - "folder"
+ * @ node (an nsINavHistoryResultNode object) - a node representing
+ * the folder.
+ * @ hiddenRows (Strings array) - optional, list of rows to be hidden
+ * regardless of the item edited or added by the dialog.
+ * Possible values:
+ * - "title"
+ * - "location"
+ * - "keyword"
+ * - "tags"
+ * - "folderPicker" - hides both the tree and the menu.
+ *
+ * window.arguments[0].bookmarkGuid is set to the guid of the item, if the
+ * dialog is accepted.
+ */
+
+/* import-globals-from instantEditBookmark.js */
+
+/* Shared Places Import - change other consumers if you change this: */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PlacesTreeView",
+ "chrome://browser/content/places/treeView.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"],
+ "chrome://browser/content/places/controller.js"
+);
+/* End Shared Places Import */
+
+const BOOKMARK_ITEM = 0;
+const BOOKMARK_FOLDER = 1;
+
+const ACTION_EDIT = 0;
+const ACTION_ADD = 1;
+
+var BookmarkPropertiesPanel = {
+ /** UI Text Strings */
+ __strings: null,
+ get _strings() {
+ if (!this.__strings) {
+ this.__strings = document.getElementById("stringBundle");
+ }
+ return this.__strings;
+ },
+
+ _action: null,
+ _itemType: null,
+ _uri: null,
+ _title: "",
+ _URIs: [],
+ _keyword: "",
+ _postData: null,
+ _charSet: "",
+
+ _defaultInsertionPoint: null,
+ _hiddenRows: [],
+
+ /**
+ * @returns {string}
+ * This method returns the correct label for the dialog's "accept"
+ * button based on the variant of the dialog.
+ */
+ _getAcceptLabel: function BPP__getAcceptLabel() {
+ return this._strings.getString("dialogAcceptLabelSaveItem");
+ },
+
+ /**
+ * @returns {string}
+ * This method returns the correct title for the current variant
+ * of this dialog.
+ */
+ _getDialogTitle: function BPP__getDialogTitle() {
+ if (this._action == ACTION_ADD) {
+ if (this._itemType == BOOKMARK_ITEM) {
+ return this._strings.getString("dialogTitleAddNewBookmark2");
+ }
+
+ // add folder
+ if (this._itemType != BOOKMARK_FOLDER) {
+ throw new Error("Unknown item type");
+ }
+ if (this._URIs.length) {
+ return this._strings.getString("dialogTitleAddMulti");
+ }
+
+ return this._strings.getString("dialogTitleAddBookmarkFolder");
+ }
+ if (this._action == ACTION_EDIT) {
+ if (this._itemType === BOOKMARK_ITEM) {
+ return this._strings.getString("dialogTitleEditBookmark2");
+ }
+
+ return this._strings.getString("dialogTitleEditBookmarkFolder");
+ }
+ return "";
+ },
+
+ /**
+ * Determines the initial data for the item edited or added by this dialog
+ */
+ async _determineItemInfo() {
+ let dialogInfo = window.arguments[0];
+ this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT;
+ this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : [];
+ if (this._action == ACTION_ADD) {
+ if (!("type" in dialogInfo)) {
+ throw new Error("missing type property for add action");
+ }
+
+ if ("title" in dialogInfo) {
+ this._title = dialogInfo.title;
+ }
+
+ if ("defaultInsertionPoint" in dialogInfo) {
+ this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint;
+ } else {
+ let parentGuid = await PlacesUIUtils.defaultParentGuid;
+ this._defaultInsertionPoint = new PlacesInsertionPoint({
+ parentGuid,
+ });
+ }
+
+ switch (dialogInfo.type) {
+ case "bookmark":
+ this._itemType = BOOKMARK_ITEM;
+ if ("uri" in dialogInfo) {
+ if (!(dialogInfo.uri instanceof Ci.nsIURI)) {
+ throw new Error("uri property should be a uri object");
+ }
+ this._uri = dialogInfo.uri;
+ if (typeof this._title != "string") {
+ this._title =
+ (await PlacesUtils.history.fetch(this._uri)) || this._uri.spec;
+ }
+ } else {
+ this._uri = Services.io.newURI("about:blank");
+ this._title = this._strings.getString("newBookmarkDefault");
+ this._dummyItem = true;
+ }
+
+ if ("keyword" in dialogInfo) {
+ this._keyword = dialogInfo.keyword;
+ this._isAddKeywordDialog = true;
+ if ("postData" in dialogInfo) {
+ this._postData = dialogInfo.postData;
+ }
+ if ("charSet" in dialogInfo) {
+ this._charSet = dialogInfo.charSet;
+ }
+ }
+ break;
+
+ case "folder":
+ this._itemType = BOOKMARK_FOLDER;
+ if (!this._title) {
+ if ("URIList" in dialogInfo) {
+ this._title = this._strings.getString("bookmarkAllTabsDefault");
+ this._URIs = dialogInfo.URIList;
+ } else {
+ this._title = this._strings.getString("newFolderDefault");
+ this._dummyItem = true;
+ }
+ }
+ break;
+ }
+ } else {
+ // edit
+ this._node = dialogInfo.node;
+ this._title = this._node.title;
+ if (PlacesUtils.nodeIsFolder(this._node)) {
+ this._itemType = BOOKMARK_FOLDER;
+ } else if (PlacesUtils.nodeIsURI(this._node)) {
+ this._itemType = BOOKMARK_ITEM;
+ }
+ }
+ },
+
+ /**
+ * This method should be called by the onload of the Bookmark Properties
+ * dialog to initialize the state of the panel.
+ */
+ async onDialogLoad() {
+ document.addEventListener("dialogaccept", function() {
+ BookmarkPropertiesPanel.onDialogAccept();
+ });
+ document.addEventListener("dialogcancel", function() {
+ BookmarkPropertiesPanel.onDialogCancel();
+ });
+
+ // Disable the buttons until we have all the information required.
+ let acceptButton = document
+ .getElementById("bookmarkpropertiesdialog")
+ .getButton("accept");
+ acceptButton.disabled = true;
+ await this._determineItemInfo();
+ document.title = this._getDialogTitle();
+
+ // Set adjustable title
+ let title = { raw: document.title };
+ document.documentElement.setAttribute("headertitle", JSON.stringify(title));
+
+ let iconUrl = this._getIconUrl();
+ if (iconUrl) {
+ document.documentElement.style.setProperty(
+ "--icon-url",
+ `url(${iconUrl})`
+ );
+ }
+
+ await this._initDialog();
+ },
+
+ _getIconUrl() {
+ let url = "chrome://browser/skin/bookmark-hollow.svg";
+
+ if (this._action === ACTION_EDIT && this._itemType === BOOKMARK_ITEM) {
+ url = window.arguments[0]?.node?.icon;
+ }
+
+ return url;
+ },
+
+ /**
+ * Initializes the dialog, gathering the required bookmark data. This function
+ * will enable the accept button (if appropraite) when it is complete.
+ */
+ async _initDialog() {
+ let acceptButton = document
+ .getElementById("bookmarkpropertiesdialog")
+ .getButton("accept");
+ acceptButton.label = this._getAcceptLabel();
+ let acceptButtonDisabled = false;
+
+ // Since elements can be unhidden asynchronously, we must observe their
+ // mutations and resize the dialog accordingly.
+ this._mutationObserver = new MutationObserver(mutations => {
+ for (let { target, oldValue } of mutations) {
+ let hidden = target.getAttribute("hidden") == "true";
+ if (
+ target.classList.contains("hideable") &&
+ hidden != (oldValue == "true")
+ ) {
+ // To support both kind of dialogs (window and dialog-box) we need
+ // both resizeBy and sizeToContent, otherwise either the dialog
+ // doesn't resize, or it gets empty unused space.
+ if (hidden) {
+ let diff = this._mutationObserver._heightsById.get(target.id);
+ window.resizeBy(0, -diff);
+ } else {
+ let diff = target.getBoundingClientRect().height;
+ this._mutationObserver._heightsById.set(target.id, diff);
+ window.resizeBy(0, diff);
+ }
+ window.sizeToContent();
+ }
+ }
+ });
+ this._mutationObserver._heightsById = new Map();
+ this._mutationObserver.observe(document, {
+ subtree: true,
+ attributeOldValue: true,
+ attributeFilter: ["hidden"],
+ });
+
+ switch (this._action) {
+ case ACTION_EDIT:
+ await gEditItemOverlay.initPanel({
+ node: this._node,
+ hiddenRows: this._hiddenRows,
+ focusedElement: "first",
+ });
+ acceptButtonDisabled = gEditItemOverlay.readOnly;
+ break;
+ case ACTION_ADD:
+ this._node = await this._promiseNewItem();
+ // Edit the new item
+ await gEditItemOverlay.initPanel({
+ node: this._node,
+ hiddenRows: this._hiddenRows,
+ postData: this._postData,
+ focusedElement: "first",
+ });
+
+ // Empty location field if the uri is about:blank, this way inserting a new
+ // url will be easier for the user, Accept button will be automatically
+ // disabled by the input listener until the user fills the field.
+ let locationField = this._element("locationField");
+ if (locationField.value == "about:blank") {
+ locationField.value = "";
+ }
+
+ // if this is an uri related dialog disable accept button until
+ // the user fills an uri value.
+ if (this._itemType == BOOKMARK_ITEM) {
+ acceptButtonDisabled = !this._inputIsValid();
+ }
+ break;
+ }
+
+ if (!gEditItemOverlay.readOnly) {
+ // Listen on uri fields to enable accept button if input is valid
+ if (this._itemType == BOOKMARK_ITEM) {
+ this._element("locationField").addEventListener("input", this);
+ if (this._isAddKeywordDialog) {
+ this._element("keywordField").addEventListener("input", this);
+ }
+ }
+ }
+ // Only enable the accept button once we've finished everything.
+ acceptButton.disabled = acceptButtonDisabled;
+ },
+
+ // EventListener
+ handleEvent: function BPP_handleEvent(aEvent) {
+ var target = aEvent.target;
+ switch (aEvent.type) {
+ case "input":
+ if (
+ target.id == "editBMPanel_locationField" ||
+ target.id == "editBMPanel_keywordField"
+ ) {
+ // Check uri fields to enable accept button if input is valid
+ document
+ .getElementById("bookmarkpropertiesdialog")
+ .getButton("accept").disabled = !this._inputIsValid();
+ }
+ break;
+ }
+ },
+
+ // nsISupports
+ QueryInterface: ChromeUtils.generateQI([]),
+
+ _element: function BPP__element(aID) {
+ return document.getElementById("editBMPanel_" + aID);
+ },
+
+ onDialogUnload() {
+ // gEditItemOverlay does not exist anymore here, so don't rely on it.
+ this._mutationObserver.disconnect();
+ delete this._mutationObserver;
+
+ // Calling removeEventListener with arguments which do not identify any
+ // currently registered EventListener on the EventTarget has no effect.
+ this._element("locationField").removeEventListener("input", this);
+ this._element("keywordField").removeEventListener("input", this);
+ },
+
+ onDialogAccept() {
+ // We must blur current focused element to save its changes correctly
+ document.commandDispatcher.focusedElement?.blur();
+
+ // Get the states to compare bookmark and editedBookmark
+ window.arguments[0].bookmarkState = gEditItemOverlay._bookmarkState;
+
+ // We have to uninit the panel first, otherwise late changes could force it
+ // to commit more transactions.
+ gEditItemOverlay.uninitPanel(true);
+
+ window.arguments[0].bookmarkGuid = this._node.bookmarkGuid;
+ },
+
+ onDialogCancel() {
+ // We have to uninit the panel first, otherwise late changes could force it
+ // to commit more transactions.
+ gEditItemOverlay.uninitPanel(true);
+ },
+
+ /**
+ * This method checks to see if the input fields are in a valid state.
+ *
+ * @returns {boolean} true if the input is valid, false otherwise
+ */
+ _inputIsValid: function BPP__inputIsValid() {
+ if (
+ this._itemType == BOOKMARK_ITEM &&
+ !this._containsValidURI("locationField")
+ ) {
+ return false;
+ }
+ if (
+ this._isAddKeywordDialog &&
+ !this._element("keywordField").value.length
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Determines whether the input with the given ID contains a
+ * string that can be converted into an nsIURI.
+ *
+ * @param {number} aTextboxID
+ * the ID of the textbox element whose contents we'll test
+ *
+ * @returns {boolean} true if the textbox contains a valid URI string, false otherwise
+ */
+ _containsValidURI: function BPP__containsValidURI(aTextboxID) {
+ try {
+ var value = this._element(aTextboxID).value;
+ if (value) {
+ Services.uriFixup.getFixupURIInfo(value);
+ return true;
+ }
+ } catch (e) {}
+ return false;
+ },
+
+ /**
+ * [New Item Mode] Get the insertion point details for the new item, given
+ * dialog state and opening arguments.
+ *
+ * The container-identifier and insertion-index are returned separately in
+ * the form of [containerIdentifier, insertionIndex]
+ */
+ async _getInsertionPointDetails() {
+ return [
+ await this._defaultInsertionPoint.getIndex(),
+ this._defaultInsertionPoint.guid,
+ ];
+ },
+
+ async _promiseNewItem() {
+ let [index, parentGuid] = await this._getInsertionPointDetails();
+
+ let itemGuid;
+ let info = { parentGuid, index, title: this._title };
+ if (this._itemType == BOOKMARK_ITEM) {
+ info.url = this._uri;
+ if (this._keyword) {
+ info.keyword = this._keyword;
+ }
+ if (this._postData) {
+ info.postData = this._postData;
+ }
+
+ if (this._charSet) {
+ PlacesUIUtils.setCharsetForPage(this._uri, this._charSet, window).catch(
+ Cu.reportError
+ );
+ }
+
+ itemGuid = gEditItemOverlay.delayedApplyEnabled
+ ? PlacesUtils.bookmarks.unsavedGuid
+ : await PlacesTransactions.NewBookmark(info).transact();
+ } else if (this._itemType == BOOKMARK_FOLDER) {
+ // NewFolder requires a url rather than uri.
+ info.children = this._URIs.map(item => {
+ return { url: item.uri, title: item.title };
+ });
+
+ itemGuid = gEditItemOverlay.delayedApplyEnabled
+ ? PlacesUtils.bookmarks.unsavedGuid
+ : await PlacesTransactions.NewFolder(info).transact();
+ } else {
+ throw new Error(`unexpected value for _itemType: ${this._itemType}`);
+ }
+ const itemId = gEditItemOverlay.delayedApplyEnabled
+ ? undefined
+ : await PlacesUtils.promiseItemId(itemGuid);
+ const parentItemId = gEditItemOverlay.delayedApplyEnabled
+ ? undefined
+ : await PlacesUtils.promiseItemId(parentGuid);
+ return Object.freeze({
+ itemId,
+ bookmarkGuid: itemGuid,
+ title: this._title,
+ uri: this._uri ? this._uri.spec : "",
+ type:
+ this._itemType == BOOKMARK_ITEM
+ ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI
+ : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
+ parent: {
+ itemId: parentItemId,
+ bookmarkGuid: parentGuid,
+ type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
+ },
+ });
+ },
+};
+
+document.addEventListener("DOMContentLoaded", function() {
+ // Content initialization is asynchronous, thus set mozSubdialogReady
+ // immediately to properly wait for it.
+ document.mozSubdialogReady = BookmarkPropertiesPanel.onDialogLoad()
+ .catch(ex => console.error(`Failed to initialize dialog: ${ex}`))
+ .then(() => window.sizeToContent());
+});
diff --git a/browser/components/places/content/bookmarkProperties.xhtml b/browser/components/places/content/bookmarkProperties.xhtml
new file mode 100644
index 0000000000..e9e4a4e1ea
--- /dev/null
+++ b/browser/components/places/content/bookmarkProperties.xhtml
@@ -0,0 +1,52 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://global/content/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/commonDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?>
+<?xml-stylesheet href="chrome://browser/content/places/places.css"?>
+
+<!DOCTYPE window>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ id="bookmarkproperties"
+ headerparent="bookmarkpropertiesdialog"
+ neediconheader="true"
+ onunload="BookmarkPropertiesPanel.onDialogUnload();"
+ style="min-width: 40em;">
+<dialog id="bookmarkpropertiesdialog"
+ buttons="accept, cancel">
+
+ <linkset>
+ <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/>
+ </linkset>
+
+ <stringbundleset id="stringbundleset">
+ <stringbundle id="stringBundle"
+ src="chrome://browser/locale/places/bookmarkProperties.properties"/>
+ </stringbundleset>
+
+ <script>
+ if (Services.prefs.getBoolPref("browser.bookmarks.editDialog.delayedApply.enabled", false)) {
+ Services.scriptloader.loadSubScript("chrome://browser/content/places/editBookmark.js", this);
+ } else {
+ Services.scriptloader.loadSubScript("chrome://browser/content/places/instantEditBookmark.js", this);
+ }
+ </script>
+ <script src="chrome://browser/content/places/bookmarkProperties.js"/>
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+ <script src="chrome://browser/content/places/places-tree.js"/>
+ <script src="chrome://global/content/adjustableTitle.js"/>
+
+#include editBookmarkPanel.inc.xhtml
+
+</dialog>
+</window>
diff --git a/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml
new file mode 100644
index 0000000000..445f2cdcb4
--- /dev/null
+++ b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml
@@ -0,0 +1,14 @@
+# 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/.
+
+<!-- Bookmarks and history tooltip -->
+<tooltip id="bhTooltip" noautohide="true"
+ class="places-tooltip"
+ onpopupshowing="return window.top.BookmarksEventHandler.fillInBHTooltip(this, event)"
+ onpopuphiding="this.removeAttribute('position')">
+ <box class="places-tooltip-box">
+ <description class="tooltip-label places-tooltip-title"/>
+ <description crop="center" class="tooltip-label places-tooltip-uri uri-element"/>
+ </box>
+</tooltip>
diff --git a/browser/components/places/content/bookmarksSidebar.js b/browser/components/places/content/bookmarksSidebar.js
new file mode 100644
index 0000000000..2c26219765
--- /dev/null
+++ b/browser/components/places/content/bookmarksSidebar.js
@@ -0,0 +1,52 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* Shared Places Import - change other consumers if you change this: */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PlacesTreeView",
+ "chrome://browser/content/places/treeView.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"],
+ "chrome://browser/content/places/controller.js"
+);
+/* End Shared Places Import */
+
+function init() {
+ let uidensity = window.top.document.documentElement.getAttribute("uidensity");
+ if (uidensity) {
+ document.documentElement.setAttribute("uidensity", uidensity);
+ }
+
+ document.getElementById("bookmarks-view").place =
+ "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY;
+}
+
+function searchBookmarks(aSearchString) {
+ var tree = document.getElementById("bookmarks-view");
+ if (!aSearchString) {
+ // eslint-disable-next-line no-self-assign
+ tree.place = tree.place;
+ } else {
+ tree.applyFilter(aSearchString, PlacesUtils.bookmarks.userContentRoots);
+ }
+}
+
+window.addEventListener("SidebarFocused", () =>
+ document.getElementById("search-box").focus()
+);
diff --git a/browser/components/places/content/bookmarksSidebar.xhtml b/browser/components/places/content/bookmarksSidebar.xhtml
new file mode 100644
index 0000000000..7e0226c3ff
--- /dev/null
+++ b/browser/components/places/content/bookmarksSidebar.xhtml
@@ -0,0 +1,64 @@
+<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- -->
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://browser/content/places/places.css"?>
+<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/sidebar.css"?>
+
+<!DOCTYPE window>
+
+<window id="bookmarksPanel"
+ class="sidebar-panel"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="init();"
+ onunload="PlacesUIUtils.setMouseoverURL('', window);"
+ data-l10n-id="bookmarks-sidebar-content">
+
+ <script src="chrome://browser/content/places/bookmarksSidebar.js"/>
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+ <script src="chrome://browser/content/contentTheme.js"/>
+ <script src="chrome://browser/content/places/places-tree.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/browser.ftl"/>
+ <html:link rel="localization" href="browser/places.ftl"/>
+ </linkset>
+
+#include placesCommands.inc.xhtml
+#include placesContextMenu.inc.xhtml
+#include bookmarksHistoryTooltip.inc.xhtml
+
+ <hbox id="sidebar-search-container" align="center">
+ <search-textbox id="search-box" flex="1"
+ data-l10n-id="places-bookmarks-search"
+ data-l10n-attrs="placeholder"
+ aria-controls="bookmarks-view"
+ oncommand="searchBookmarks(this.value);"/>
+ </hbox>
+
+ <tree id="bookmarks-view"
+ class="sidebar-placesTree"
+ is="places-tree"
+ flex="1"
+ hidecolumnpicker="true"
+ context="placesContext"
+ singleclickopens="true"
+ onkeypress="PlacesUIUtils.onSidebarTreeKeyPress(event);"
+ onclick="PlacesUIUtils.onSidebarTreeClick(event);"
+ onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);"
+ onmouseout="PlacesUIUtils.setMouseoverURL('', window);">
+ <treecols>
+ <treecol id="title" flex="1" primary="true" hideheader="true"/>
+ </treecols>
+ <treechildren view="bookmarks-view"
+ class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/>
+ </tree>
+</window>
diff --git a/browser/components/places/content/browserPlacesViews.js b/browser/components/places/content/browserPlacesViews.js
new file mode 100644
index 0000000000..b2f4a278aa
--- /dev/null
+++ b/browser/components/places/content/browserPlacesViews.js
@@ -0,0 +1,2253 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+/**
+ * The base view implements everything that's common to all the views.
+ * It should not be instanced directly, use a derived class instead.
+ */
+class PlacesViewBase {
+ /**
+ * @param {string} placesUrl
+ * The query string associated with the view.
+ * @param {DOMElement} rootElt
+ * The root element for the view.
+ * @param {DOMElement} viewElt
+ * The view element.
+ */
+ constructor(placesUrl, rootElt, viewElt) {
+ this._rootElt = rootElt;
+ this._viewElt = viewElt;
+ let appendClass = this._rootElt.getAttribute("appendclasstochildren");
+ if (appendClass) {
+ this._appendClassToChildren = appendClass;
+ }
+ // Do initialization in subclass now that `this` exists.
+ this._init?.();
+ this._controller = new PlacesController(this);
+ this.place = placesUrl;
+ this._viewElt.controllers.appendController(this._controller);
+ }
+
+ // The xul element that holds the entire view.
+ _viewElt = null;
+
+ get associatedElement() {
+ return this._viewElt;
+ }
+
+ get controllers() {
+ return this._viewElt.controllers;
+ }
+
+ // The xul element that represents the root container.
+ _rootElt = null;
+
+ // Set to true for views that are represented by native widgets (i.e.
+ // the native mac menu).
+ _nativeView = false;
+
+ static interfaces = [
+ Ci.nsINavHistoryResultObserver,
+ Ci.nsISupportsWeakReference,
+ ];
+
+ QueryInterface = ChromeUtils.generateQI(PlacesViewBase.interfaces);
+
+ _place = "";
+ get place() {
+ return this._place;
+ }
+ set place(val) {
+ this._place = val;
+
+ let history = PlacesUtils.history;
+ let query = {},
+ options = {};
+ history.queryStringToQuery(val, query, options);
+ let result = history.executeQuery(query.value, options.value);
+ result.addObserver(this);
+ }
+
+ _result = null;
+ get result() {
+ return this._result;
+ }
+ set result(val) {
+ if (this._result == val) {
+ return;
+ }
+
+ if (this._result) {
+ this._result.removeObserver(this);
+ this._resultNode.containerOpen = false;
+ }
+
+ if (this._rootElt.localName == "menupopup") {
+ this._rootElt._built = false;
+ }
+
+ this._result = val;
+ if (val) {
+ this._resultNode = val.root;
+ this._rootElt._placesNode = this._resultNode;
+ this._domNodes = new Map();
+ this._domNodes.set(this._resultNode, this._rootElt);
+
+ // This calls _rebuild through invalidateContainer.
+ this._resultNode.containerOpen = true;
+ } else {
+ this._resultNode = null;
+ delete this._domNodes;
+ }
+ }
+
+ /**
+ * Gets the DOM node used for the given places node.
+ *
+ * @param {object} aPlacesNode
+ * a places result node.
+ * @param {boolean} aAllowMissing
+ * whether the node may be missing
+ * @returns {object|null} The associated DOM node.
+ * @throws if there is no DOM node set for aPlacesNode.
+ */
+ _getDOMNodeForPlacesNode(aPlacesNode, aAllowMissing = false) {
+ let node = this._domNodes.get(aPlacesNode, null);
+ if (!node && !aAllowMissing) {
+ throw new Error(
+ "No DOM node set for aPlacesNode.\nnode.type: " +
+ aPlacesNode.type +
+ ". node.parent: " +
+ aPlacesNode
+ );
+ }
+ return node;
+ }
+
+ get controller() {
+ return this._controller;
+ }
+
+ get selType() {
+ return "single";
+ }
+ selectItems() {}
+ selectAll() {}
+
+ get selectedNode() {
+ if (this._contextMenuShown) {
+ let anchor = this._contextMenuShown.triggerNode;
+ if (!anchor) {
+ return null;
+ }
+
+ if (anchor._placesNode) {
+ return this._rootElt == anchor ? null : anchor._placesNode;
+ }
+
+ anchor = anchor.parentNode;
+ return this._rootElt == anchor ? null : anchor._placesNode || null;
+ }
+ return null;
+ }
+
+ get hasSelection() {
+ return this.selectedNode != null;
+ }
+
+ get selectedNodes() {
+ let selectedNode = this.selectedNode;
+ return selectedNode ? [selectedNode] : [];
+ }
+
+ get singleClickOpens() {
+ return true;
+ }
+
+ get removableSelectionRanges() {
+ // On static content the current selectedNode would be the selection's
+ // parent node. We don't want to allow removing a node when the
+ // selection is not explicit.
+ let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
+ if (popupNode && (popupNode == "menupopup" || !popupNode._placesNode)) {
+ return [];
+ }
+
+ return [this.selectedNodes];
+ }
+
+ get draggableSelection() {
+ return [this._draggedElt];
+ }
+
+ get insertionPoint() {
+ // There is no insertion point for history queries, so bail out now and
+ // save a lot of work when updating commands.
+ let resultNode = this._resultNode;
+ if (
+ PlacesUtils.nodeIsQuery(resultNode) &&
+ PlacesUtils.asQuery(resultNode).queryOptions.queryType ==
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ return null;
+ }
+
+ // By default, the insertion point is at the top level, at the end.
+ let index = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ let container = this._resultNode;
+ let orientation = Ci.nsITreeView.DROP_BEFORE;
+ let tagName = null;
+
+ let selectedNode = this.selectedNode;
+ if (selectedNode) {
+ let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
+ if (
+ !popupNode._placesNode ||
+ popupNode._placesNode == this._resultNode ||
+ popupNode._placesNode.itemId == -1 ||
+ !selectedNode.parent
+ ) {
+ // If a static menuitem is selected, or if the root node is selected,
+ // the insertion point is inside the folder, at the end.
+ container = selectedNode;
+ orientation = Ci.nsITreeView.DROP_ON;
+ } else {
+ // In all other cases the insertion point is before that node.
+ container = selectedNode.parent;
+ index = container.getChildIndex(selectedNode);
+ if (PlacesUtils.nodeIsTagQuery(container)) {
+ tagName = PlacesUtils.asQuery(container).query.tags[0];
+ }
+ }
+ }
+
+ if (this.controller.disallowInsertion(container)) {
+ return null;
+ }
+
+ return new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(container),
+ index,
+ orientation,
+ tagName,
+ });
+ }
+
+ buildContextMenu(aPopup) {
+ this._contextMenuShown = aPopup;
+ window.updateCommands("places");
+
+ // Ensure that an existing "Show Other Bookmarks" item is removed before adding it
+ // again.
+ let existingOtherBookmarksItem = aPopup.querySelector(
+ "#show-other-bookmarks_PersonalToolbar"
+ );
+ existingOtherBookmarksItem?.remove();
+
+ let manageBookmarksMenu = aPopup.querySelector(
+ "#placesContext_showAllBookmarks"
+ );
+ // Add the View menu for the Bookmarks Toolbar and "Show Other Bookmarks" menu item
+ // if the click originated from the Bookmarks Toolbar.
+ let existingSubmenu = aPopup.querySelector("#toggle_PersonalToolbar");
+ existingSubmenu?.remove();
+ let bookmarksToolbar = document.getElementById("PersonalToolbar");
+ if (bookmarksToolbar?.contains(aPopup.triggerNode)) {
+ manageBookmarksMenu.removeAttribute("hidden");
+
+ let menu = BookmarkingUI.buildBookmarksToolbarSubmenu(bookmarksToolbar);
+ aPopup.insertBefore(menu, manageBookmarksMenu);
+
+ if (
+ aPopup.triggerNode.id === "OtherBookmarks" ||
+ aPopup.triggerNode.id === "PlacesChevron" ||
+ aPopup.triggerNode.id === "PlacesToolbarItems" ||
+ aPopup.triggerNode.parentNode.id === "PlacesToolbarItems"
+ ) {
+ let otherBookmarksMenuItem = BookmarkingUI.buildShowOtherBookmarksMenuItem();
+
+ if (otherBookmarksMenuItem) {
+ aPopup.insertBefore(otherBookmarksMenuItem, menu.nextElementSibling);
+ }
+ }
+ } else {
+ manageBookmarksMenu.setAttribute("hidden", "true");
+ }
+
+ return this.controller.buildContextMenu(aPopup);
+ }
+
+ destroyContextMenu(aPopup) {
+ this._contextMenuShown = null;
+ }
+
+ clearAllContents(aPopup) {
+ let kid = aPopup.firstElementChild;
+ while (kid) {
+ let next = kid.nextElementSibling;
+ if (!kid.classList.contains("panel-header")) {
+ kid.remove();
+ }
+ kid = next;
+ }
+ aPopup._emptyMenuitem = aPopup._startMarker = aPopup._endMarker = null;
+ }
+
+ _cleanPopup(aPopup, aDelay) {
+ // Ensure markers are here when `invalidateContainer` is called before the
+ // popup is shown, which may the case for panelviews, for example.
+ this._ensureMarkers(aPopup);
+ // Remove Places nodes from the popup.
+ let child = aPopup._startMarker;
+ while (child.nextElementSibling != aPopup._endMarker) {
+ let sibling = child.nextElementSibling;
+ if (sibling._placesNode && !aDelay) {
+ aPopup.removeChild(sibling);
+ } else if (sibling._placesNode && aDelay) {
+ // HACK (bug 733419): the popups originating from the OS X native
+ // menubar don't live-update while open, thus we don't clean it
+ // until the next popupshowing, to avoid zombie menuitems.
+ if (!aPopup._delayedRemovals) {
+ aPopup._delayedRemovals = [];
+ }
+ aPopup._delayedRemovals.push(sibling);
+ child = child.nextElementSibling;
+ } else {
+ child = child.nextElementSibling;
+ }
+ }
+ }
+
+ _rebuildPopup(aPopup) {
+ let resultNode = aPopup._placesNode;
+ if (!resultNode.containerOpen) {
+ return;
+ }
+
+ this._cleanPopup(aPopup);
+
+ let cc = resultNode.childCount;
+ if (cc > 0) {
+ this._setEmptyPopupStatus(aPopup, false);
+ let fragment = document.createDocumentFragment();
+ for (let i = 0; i < cc; ++i) {
+ let child = resultNode.getChild(i);
+ this._insertNewItemToPopup(child, fragment);
+ }
+ aPopup.insertBefore(fragment, aPopup._endMarker);
+ } else {
+ this._setEmptyPopupStatus(aPopup, true);
+ }
+ aPopup._built = true;
+ }
+
+ _removeChild(aChild) {
+ aChild.remove();
+ }
+
+ _setEmptyPopupStatus(aPopup, aEmpty) {
+ if (!aPopup._emptyMenuitem) {
+ let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder");
+ aPopup._emptyMenuitem = document.createXULElement("menuitem");
+ aPopup._emptyMenuitem.setAttribute("label", label);
+ aPopup._emptyMenuitem.setAttribute("disabled", true);
+ aPopup._emptyMenuitem.className = "bookmark-item";
+ if (this._appendClassToChildren) {
+ aPopup._emptyMenuitem.classList.add(this._appendClassToChildren);
+ }
+ }
+
+ if (aEmpty) {
+ aPopup.setAttribute("emptyplacesresult", "true");
+ // Don't add the menuitem if there is static content.
+ if (
+ !aPopup._startMarker.previousElementSibling &&
+ !aPopup._endMarker.nextElementSibling
+ ) {
+ aPopup.insertBefore(aPopup._emptyMenuitem, aPopup._endMarker);
+ }
+ } else {
+ aPopup.removeAttribute("emptyplacesresult");
+ try {
+ aPopup.removeChild(aPopup._emptyMenuitem);
+ } catch (ex) {}
+ }
+ }
+
+ _createDOMNodeForPlacesNode(aPlacesNode) {
+ this._domNodes.delete(aPlacesNode);
+
+ let element;
+ let type = aPlacesNode.type;
+ if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
+ element = document.createXULElement("menuseparator");
+ } else {
+ if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) {
+ element = document.createXULElement("menuitem");
+ element.className =
+ "menuitem-iconic bookmark-item menuitem-with-favicon";
+ element.setAttribute(
+ "scheme",
+ PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)
+ );
+ } else if (PlacesUtils.containerTypes.includes(type)) {
+ element = document.createXULElement("menu");
+ element.setAttribute("container", "true");
+
+ if (aPlacesNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) {
+ element.setAttribute("query", "true");
+ if (PlacesUtils.nodeIsTagQuery(aPlacesNode)) {
+ element.setAttribute("tagContainer", "true");
+ } else if (PlacesUtils.nodeIsDay(aPlacesNode)) {
+ element.setAttribute("dayContainer", "true");
+ } else if (PlacesUtils.nodeIsHost(aPlacesNode)) {
+ element.setAttribute("hostContainer", "true");
+ }
+ }
+
+ let popup = document.createXULElement("menupopup", {
+ is: "places-popup",
+ });
+ popup._placesNode = PlacesUtils.asContainer(aPlacesNode);
+
+ if (!this._nativeView) {
+ popup.setAttribute("placespopup", "true");
+ }
+
+ element.appendChild(popup);
+ element.className = "menu-iconic bookmark-item";
+ if (this._appendClassToChildren) {
+ element.classList.add(this._appendClassToChildren);
+ }
+
+ this._domNodes.set(aPlacesNode, popup);
+ } else {
+ throw new Error("Unexpected node");
+ }
+
+ element.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode));
+
+ let icon = aPlacesNode.icon;
+ if (icon) {
+ element.setAttribute("image", icon);
+ }
+ }
+
+ element._placesNode = aPlacesNode;
+ if (!this._domNodes.has(aPlacesNode)) {
+ this._domNodes.set(aPlacesNode, element);
+ }
+
+ return element;
+ }
+
+ _insertNewItemToPopup(aNewChild, aInsertionNode, aBefore = null) {
+ let element = this._createDOMNodeForPlacesNode(aNewChild);
+
+ if (element.localName == "menuitem" || element.localName == "menu") {
+ if (this._appendClassToChildren) {
+ element.classList.add(this._appendClassToChildren);
+ }
+ }
+
+ aInsertionNode.insertBefore(element, aBefore);
+ return element;
+ }
+
+ toggleCutNode(aPlacesNode, aValue) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // We may get the popup for menus, but we need the menu itself.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+ if (aValue) {
+ elt.setAttribute("cutting", "true");
+ } else {
+ elt.removeAttribute("cutting");
+ }
+ }
+
+ nodeURIChanged(aPlacesNode, aURIString) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ elt.setAttribute(
+ "scheme",
+ PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)
+ );
+ }
+
+ nodeIconChanged(aPlacesNode) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // There's no UI representation for the root node, thus there's nothing to
+ // be done when the icon changes.
+ if (elt == this._rootElt) {
+ return;
+ }
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+ // We must remove and reset the attribute to force an update.
+ elt.removeAttribute("image");
+ elt.setAttribute("image", aPlacesNode.icon);
+ }
+
+ nodeTitleChanged(aPlacesNode, aNewTitle) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // There's no UI representation for the root node, thus there's
+ // nothing to be done when the title changes.
+ if (elt == this._rootElt) {
+ return;
+ }
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ if (!aNewTitle && elt.localName != "toolbarbutton") {
+ // Many users consider toolbars as shortcuts containers, so explicitly
+ // allow empty labels on toolbarbuttons. For any other element try to be
+ // smarter, guessing a title from the uri.
+ elt.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode));
+ } else {
+ elt.setAttribute("label", aNewTitle);
+ }
+ }
+
+ nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) {
+ let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode);
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ if (parentElt._built) {
+ parentElt.removeChild(elt);
+
+ // Figure out if we need to show the "<Empty>" menu-item.
+ // TODO Bug 517701: This doesn't seem to handle the case of an empty
+ // root.
+ if (parentElt._startMarker.nextElementSibling == parentElt._endMarker) {
+ this._setEmptyPopupStatus(parentElt, true);
+ }
+ }
+ }
+
+ // Opt-out of history details updates, since all the views derived from this
+ // are not showing them.
+ observeHistoryDetails = false;
+ nodeHistoryDetailsChanged() {}
+ nodeTagsChanged() {}
+ nodeDateAddedChanged() {}
+ nodeLastModifiedChanged() {}
+ nodeKeywordChanged() {}
+ sortingChanged() {}
+ batching() {}
+
+ nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) {
+ let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode);
+ if (!parentElt._built) {
+ return;
+ }
+
+ let index =
+ Array.prototype.indexOf.call(parentElt.children, parentElt._startMarker) +
+ aIndex +
+ 1;
+ this._insertNewItemToPopup(
+ aPlacesNode,
+ parentElt,
+ parentElt.children[index] || parentElt._endMarker
+ );
+ this._setEmptyPopupStatus(parentElt, false);
+ }
+
+ nodeMoved(
+ aPlacesNode,
+ aOldParentPlacesNode,
+ aOldIndex,
+ aNewParentPlacesNode,
+ aNewIndex
+ ) {
+ // Note: the current implementation of moveItem does not actually
+ // use this notification when the item in question is moved from one
+ // folder to another. Instead, it calls nodeRemoved and nodeInserted
+ // for the two folders. Thus, we can assume old-parent == new-parent.
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ // If our root node is a folder, it might be moved. There's nothing
+ // we need to do in that case.
+ if (elt == this._rootElt) {
+ return;
+ }
+
+ let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode);
+ if (parentElt._built) {
+ // Move the node.
+ parentElt.removeChild(elt);
+ let index =
+ Array.prototype.indexOf.call(
+ parentElt.children,
+ parentElt._startMarker
+ ) +
+ aNewIndex +
+ 1;
+ parentElt.insertBefore(elt, parentElt.children[index]);
+ }
+ }
+
+ containerStateChanged(aPlacesNode, aOldState, aNewState) {
+ if (
+ aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED ||
+ aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED
+ ) {
+ this.invalidateContainer(aPlacesNode);
+ }
+ }
+
+ /**
+ * Checks whether the popup associated with the provided element is open.
+ * This method may be overridden by classes that extend this base class.
+ *
+ * @param {Element} elt
+ * The element to check.
+ * @returns {boolean}
+ */
+ _isPopupOpen(elt) {
+ return !!elt.parentNode.open;
+ }
+
+ invalidateContainer(aPlacesNode) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode);
+ elt._built = false;
+
+ // If the menupopup is open we should live-update it.
+ if (this._isPopupOpen(elt)) {
+ this._rebuildPopup(elt);
+ }
+ }
+
+ uninit() {
+ if (this._result) {
+ this._result.removeObserver(this);
+ this._resultNode.containerOpen = false;
+ this._resultNode = null;
+ this._result = null;
+ }
+
+ if (this._controller) {
+ this._controller.terminate();
+ // Removing the controller will fail if it is already no longer there.
+ // This can happen if the view element was removed/reinserted without
+ // our knowledge. There is no way to check for that having happened
+ // without the possibility of an exception. :-(
+ try {
+ this._viewElt.controllers.removeController(this._controller);
+ } catch (ex) {
+ } finally {
+ this._controller = null;
+ }
+ }
+
+ delete this._viewElt._placesView;
+ }
+
+ get isRTL() {
+ if ("_isRTL" in this) {
+ return this._isRTL;
+ }
+
+ return (this._isRTL =
+ document.defaultView.getComputedStyle(this._viewElt).direction == "rtl");
+ }
+
+ get ownerWindow() {
+ return window;
+ }
+
+ /**
+ * Adds an "Open All in Tabs" menuitem to the bottom of the popup.
+ *
+ * @param {object} aPopup
+ * a Places popup.
+ */
+ _mayAddCommandsItems(aPopup) {
+ // The command items are never added to the root popup.
+ if (aPopup == this._rootElt) {
+ return;
+ }
+
+ let hasMultipleURIs = false;
+
+ // Check if the popup contains at least 2 menuitems with places nodes.
+ // We don't currently support opening multiple uri nodes when they are not
+ // populated by the result.
+ if (aPopup._placesNode.childCount > 0) {
+ let currentChild = aPopup.firstElementChild;
+ let numURINodes = 0;
+ while (currentChild) {
+ if (currentChild.localName == "menuitem" && currentChild._placesNode) {
+ if (++numURINodes == 2) {
+ break;
+ }
+ }
+ currentChild = currentChild.nextElementSibling;
+ }
+ hasMultipleURIs = numURINodes > 1;
+ }
+
+ if (!hasMultipleURIs) {
+ // We don't have to show any option.
+ if (aPopup._endOptOpenAllInTabs) {
+ aPopup.removeChild(aPopup._endOptOpenAllInTabs);
+ aPopup._endOptOpenAllInTabs = null;
+
+ aPopup.removeChild(aPopup._endOptSeparator);
+ aPopup._endOptSeparator = null;
+ }
+ } else if (!aPopup._endOptOpenAllInTabs) {
+ // Create a separator before options.
+ aPopup._endOptSeparator = document.createXULElement("menuseparator");
+ aPopup._endOptSeparator.className = "bookmarks-actions-menuseparator";
+ aPopup.appendChild(aPopup._endOptSeparator);
+
+ // Add the "Open All in Tabs" menuitem.
+ aPopup._endOptOpenAllInTabs = document.createXULElement("menuitem");
+ aPopup._endOptOpenAllInTabs.className = "openintabs-menuitem";
+
+ if (this._appendClassToChildren) {
+ aPopup._endOptOpenAllInTabs.classList.add(this._appendClassToChildren);
+ }
+
+ aPopup._endOptOpenAllInTabs.setAttribute(
+ "oncommand",
+ "PlacesUIUtils.openMultipleLinksInTabs(this.parentNode._placesNode, event, " +
+ "PlacesUIUtils.getViewForNode(this));"
+ );
+ aPopup._endOptOpenAllInTabs.setAttribute(
+ "label",
+ gNavigatorBundle.getString("menuOpenAllInTabs.label")
+ );
+ aPopup.appendChild(aPopup._endOptOpenAllInTabs);
+ }
+ }
+
+ _ensureMarkers(aPopup) {
+ if (aPopup._startMarker) {
+ return;
+ }
+
+ // Places nodes are appended between _startMarker and _endMarker, that
+ // are hidden menuseparators. By default they take the whole panel...
+ aPopup._startMarker = document.createXULElement("menuseparator");
+ aPopup._startMarker.hidden = true;
+ aPopup.insertBefore(aPopup._startMarker, aPopup.firstElementChild);
+ aPopup._endMarker = document.createXULElement("menuseparator");
+ aPopup._endMarker.hidden = true;
+ aPopup.appendChild(aPopup._endMarker);
+
+ // ...but there can be static content before or after the places nodes, thus
+ // we move the markers to the right position, by checking for static content
+ // at the beginning of the view, and for an element with "afterplacescontent"
+ // attribute.
+ // TODO: In the future we should just use a container element.
+ let firstNonStaticNodeFound = false;
+ for (let child of aPopup.children) {
+ if (child.hasAttribute("afterplacescontent")) {
+ aPopup.insertBefore(aPopup._endMarker, child);
+ break;
+ }
+
+ // Check for the first Places node that is not a view.
+ if (child._placesNode && !child._placesView && !firstNonStaticNodeFound) {
+ firstNonStaticNodeFound = true;
+ aPopup.insertBefore(aPopup._startMarker, child);
+ }
+ }
+ if (!firstNonStaticNodeFound) {
+ // Just put the start marker before the end marker.
+ aPopup.insertBefore(aPopup._startMarker, aPopup._endMarker);
+ }
+ }
+
+ _onPopupShowing(aEvent) {
+ // Avoid handling popupshowing of inner views.
+ let popup = aEvent.originalTarget;
+
+ this._ensureMarkers(popup);
+
+ // Remove any delayed element, see _cleanPopup for details.
+ if ("_delayedRemovals" in popup) {
+ while (popup._delayedRemovals.length) {
+ popup.removeChild(popup._delayedRemovals.shift());
+ }
+ }
+
+ if (popup._placesNode && PlacesUIUtils.getViewForNode(popup) == this) {
+ if (!popup._placesNode.containerOpen) {
+ popup._placesNode.containerOpen = true;
+ }
+ if (!popup._built) {
+ this._rebuildPopup(popup);
+ }
+
+ this._mayAddCommandsItems(popup);
+ }
+ }
+
+ _addEventListeners(aObject, aEventNames, aCapturing = false) {
+ for (let i = 0; i < aEventNames.length; i++) {
+ aObject.addEventListener(aEventNames[i], this, aCapturing);
+ }
+ }
+
+ _removeEventListeners(aObject, aEventNames, aCapturing = false) {
+ for (let i = 0; i < aEventNames.length; i++) {
+ aObject.removeEventListener(aEventNames[i], this, aCapturing);
+ }
+ }
+}
+
+/**
+ * Toolbar View implementation.
+ */
+class PlacesToolbar extends PlacesViewBase {
+ constructor(placesUrl, rootElt, viewElt) {
+ let startTime = Date.now();
+ super(placesUrl, rootElt, viewElt);
+ this._addEventListeners(this._dragRoot, this._cbEvents, false);
+ this._addEventListeners(
+ this._rootElt,
+ ["popupshowing", "popuphidden"],
+ true
+ );
+ this._addEventListeners(this._rootElt, ["overflow", "underflow"], true);
+ this._addEventListeners(window, ["resize", "unload"], false);
+
+ // If personal-bookmarks has been dragged to the tabs toolbar,
+ // we have to track addition and removals of tabs, to properly
+ // recalculate the available space for bookmarks.
+ // TODO (bug 734730): Use a performant mutation listener when available.
+ if (
+ this._viewElt.parentNode.parentNode ==
+ document.getElementById("TabsToolbar")
+ ) {
+ this._addEventListeners(
+ gBrowser.tabContainer,
+ ["TabOpen", "TabClose"],
+ false
+ );
+ }
+
+ Services.telemetry
+ .getHistogramById("FX_BOOKMARKS_TOOLBAR_INIT_MS")
+ .add(Date.now() - startTime);
+ }
+
+ // Called by PlacesViewBase so we can init properties that class
+ // initialization depends on. PlacesViewBase will assign this.place which
+ // calls which sets `this.result` through its places observer, which changes
+ // containerOpen, which calls invalidateContainer(), which calls rebuild(),
+ // which needs `_overFolder`, `_chevronPopup` and various other things to
+ // exist.
+ _init() {
+ this._overFolder = {
+ elt: null,
+ openTimer: null,
+ hoverTime: 350,
+ closeTimer: null,
+ };
+
+ // Add some smart getters for our elements.
+ let thisView = this;
+ [
+ ["_dropIndicator", "PlacesToolbarDropIndicator"],
+ ["_chevron", "PlacesChevron"],
+ ["_chevronPopup", "PlacesChevronPopup"],
+ ].forEach(function(elementGlobal) {
+ let [name, id] = elementGlobal;
+ thisView.__defineGetter__(name, function() {
+ let element = document.getElementById(id);
+ if (!element) {
+ return null;
+ }
+
+ delete thisView[name];
+ return (thisView[name] = element);
+ });
+ });
+
+ this._viewElt._placesView = this;
+
+ this._dragRoot = BookmarkingUI.toolbar.contains(this._viewElt)
+ ? BookmarkingUI.toolbar
+ : this._viewElt;
+
+ this._updatingNodesVisibility = false;
+ }
+
+ _cbEvents = [
+ "dragstart",
+ "dragover",
+ "dragleave",
+ "dragend",
+ "drop",
+ "mousemove",
+ "mouseover",
+ "mouseout",
+ "mousedown",
+ ];
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsITimerCallback",
+ ...PlacesViewBase.interfaces,
+ ]);
+
+ uninit() {
+ if (this._dragRoot) {
+ this._removeEventListeners(this._dragRoot, this._cbEvents, false);
+ }
+ this._removeEventListeners(
+ this._rootElt,
+ ["popupshowing", "popuphidden"],
+ true
+ );
+ this._removeEventListeners(this._rootElt, ["overflow", "underflow"], true);
+ this._removeEventListeners(window, ["resize", "unload"], false);
+ this._removeEventListeners(
+ gBrowser.tabContainer,
+ ["TabOpen", "TabClose"],
+ false
+ );
+
+ if (this._chevron._placesView) {
+ this._chevron._placesView.uninit();
+ }
+
+ if (this._otherBookmarks?._placesView) {
+ this._otherBookmarks._placesView.uninit();
+ }
+
+ super.uninit();
+ }
+
+ _openedMenuButton = null;
+ _allowPopupShowing = true;
+
+ get _isAlive() {
+ return this._resultNode && this._rootElt;
+ }
+
+ _runBeforeFrameRender(callback) {
+ return new Promise((resolve, reject) => {
+ window.requestAnimationFrame(() => {
+ try {
+ resolve(callback());
+ } catch (err) {
+ reject(err);
+ }
+ });
+ });
+ }
+
+ async _rebuild() {
+ // Clear out references to existing nodes, since they will be removed
+ // and re-added.
+ if (this._overFolder.elt) {
+ this._clearOverFolder();
+ }
+
+ this._openedMenuButton = null;
+ while (this._rootElt.hasChildNodes()) {
+ this._rootElt.firstChild.remove();
+ }
+
+ let cc = this._resultNode.childCount;
+ if (cc > 0) {
+ // There could be a lot of nodes, but we only want to build the ones that
+ // are more likely to be shown, not all of them.
+ // We also don't want to wait for reflows at every node insertion, to
+ // calculate a precise number of visible items, thus we guess a size from
+ // the first non-separator node (because separators have flexible size).
+ let startIndex = 0;
+ let limit = await this._runBeforeFrameRender(() => {
+ if (!this._isAlive) {
+ return cc;
+ }
+
+ // Look for the first non-separator node.
+ let elt;
+ while (startIndex < cc) {
+ elt = this._insertNewItem(
+ this._resultNode.getChild(startIndex),
+ this._rootElt
+ );
+ ++startIndex;
+ if (elt.localName != "toolbarseparator") {
+ break;
+ }
+ }
+ if (!elt) {
+ return cc;
+ }
+
+ return window.promiseDocumentFlushed(() => {
+ // We assume a button with just the icon will be more or less a square,
+ // then compensate the measurement error by considering a larger screen
+ // width. Moreover the window could be bigger than the screen.
+ let size = elt.clientHeight || 1; // Sanity fallback.
+ return Math.min(cc, parseInt((window.screen.width * 1.5) / size));
+ });
+ });
+
+ if (!this._isAlive) {
+ return;
+ }
+
+ let fragment = document.createDocumentFragment();
+ for (let i = startIndex; i < limit; ++i) {
+ this._insertNewItem(this._resultNode.getChild(i), fragment);
+ }
+ window.requestAnimationFrame(() => {
+ if (this._isAlive) {
+ this._rootElt.appendChild(fragment);
+ this.updateNodesVisibility();
+ }
+ });
+ }
+
+ if (this._chevronPopup.hasAttribute("type")) {
+ // Chevron has already been initialized, but since we are forcing
+ // a rebuild of the toolbar, it has to be rebuilt.
+ // Otherwise, it will be initialized when the toolbar overflows.
+ this._chevronPopup.place = this.place;
+ }
+
+ // Rebuild the "Other Bookmarks" folder if it already exists.
+ let otherBookmarks = document.getElementById("OtherBookmarks");
+ otherBookmarks?.remove();
+
+ BookmarkingUI.maybeShowOtherBookmarksFolder();
+ }
+
+ _insertNewItem(aChild, aInsertionNode, aBefore = null) {
+ this._domNodes.delete(aChild);
+
+ let type = aChild.type;
+ let button;
+ if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
+ button = document.createXULElement("toolbarseparator");
+ } else {
+ button = document.createXULElement("toolbarbutton");
+ button.className = "bookmark-item";
+ button.setAttribute("label", aChild.title || "");
+
+ if (PlacesUtils.containerTypes.includes(type)) {
+ button.setAttribute("type", "menu");
+ button.setAttribute("container", "true");
+
+ if (PlacesUtils.nodeIsQuery(aChild)) {
+ button.setAttribute("query", "true");
+ if (PlacesUtils.nodeIsTagQuery(aChild)) {
+ button.setAttribute("tagContainer", "true");
+ }
+ }
+
+ let popup = document.createXULElement("menupopup", {
+ is: "places-popup",
+ });
+ popup.setAttribute("placespopup", "true");
+ button.appendChild(popup);
+ popup._placesNode = PlacesUtils.asContainer(aChild);
+ popup.setAttribute("context", "placesContext");
+
+ this._domNodes.set(aChild, popup);
+ } else if (PlacesUtils.nodeIsURI(aChild)) {
+ button.setAttribute(
+ "scheme",
+ PlacesUIUtils.guessUrlSchemeForUI(aChild.uri)
+ );
+ }
+ }
+
+ button._placesNode = aChild;
+ let { icon } = button._placesNode;
+ if (icon) {
+ button.setAttribute("image", icon);
+ }
+ if (!this._domNodes.has(aChild)) {
+ this._domNodes.set(aChild, button);
+ }
+
+ if (aBefore) {
+ aInsertionNode.insertBefore(button, aBefore);
+ } else {
+ aInsertionNode.appendChild(button);
+ }
+ return button;
+ }
+
+ _updateChevronPopupNodesVisibility() {
+ // Note the toolbar by default builds less nodes than the chevron popup.
+ for (
+ let toolbarNode = this._rootElt.firstElementChild,
+ node = this._chevronPopup._startMarker.nextElementSibling;
+ toolbarNode && node;
+ toolbarNode = toolbarNode.nextElementSibling,
+ node = node.nextElementSibling
+ ) {
+ node.hidden = toolbarNode.style.visibility != "hidden";
+ }
+ }
+
+ _onChevronPopupShowing(aEvent) {
+ // Handle popupshowing only for the chevron popup, not for nested ones.
+ if (aEvent.target != this._chevronPopup) {
+ return;
+ }
+
+ if (!this._chevron._placesView) {
+ this._chevron._placesView = new PlacesMenu(aEvent, this.place);
+ }
+
+ this._updateChevronPopupNodesVisibility();
+ }
+
+ _onOtherBookmarksPopupShowing(aEvent) {
+ if (aEvent.target != this._otherBookmarksPopup) {
+ return;
+ }
+
+ if (!this._otherBookmarks._placesView) {
+ this._otherBookmarks._placesView = new PlacesMenu(
+ aEvent,
+ "place:parent=" + PlacesUtils.bookmarks.unfiledGuid
+ );
+ }
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.uninit();
+ break;
+ case "resize":
+ // This handler updates nodes visibility in both the toolbar
+ // and the chevron popup when a window resize does not change
+ // the overflow status of the toolbar.
+ if (aEvent.target == aEvent.currentTarget) {
+ this.updateNodesVisibility();
+ }
+ break;
+ case "overflow":
+ if (!this._isOverflowStateEventRelevant(aEvent)) {
+ return;
+ }
+ // Avoid triggering overflow in containers if possible
+ aEvent.stopPropagation();
+ this._onOverflow();
+ break;
+ case "underflow":
+ if (!this._isOverflowStateEventRelevant(aEvent)) {
+ return;
+ }
+ // Avoid triggering underflow in containers if possible
+ aEvent.stopPropagation();
+ this._onUnderflow();
+ break;
+ case "TabOpen":
+ case "TabClose":
+ this.updateNodesVisibility();
+ break;
+ case "dragstart":
+ this._onDragStart(aEvent);
+ break;
+ case "dragover":
+ this._onDragOver(aEvent);
+ break;
+ case "dragleave":
+ this._onDragLeave(aEvent);
+ break;
+ case "dragend":
+ this._onDragEnd(aEvent);
+ break;
+ case "drop":
+ this._onDrop(aEvent);
+ break;
+ case "mouseover":
+ this._onMouseOver(aEvent);
+ break;
+ case "mousemove":
+ this._onMouseMove(aEvent);
+ break;
+ case "mouseout":
+ this._onMouseOut(aEvent);
+ break;
+ case "mousedown":
+ this._onMouseDown(aEvent);
+ break;
+ case "popupshowing":
+ this._onPopupShowing(aEvent);
+ break;
+ case "popuphidden":
+ this._onPopupHidden(aEvent);
+ break;
+ default:
+ throw new Error("Trying to handle unexpected event.");
+ }
+ }
+
+ _isOverflowStateEventRelevant(aEvent) {
+ // Ignore events not aimed at ourselves, as well as purely vertical ones:
+ return aEvent.target == aEvent.currentTarget && aEvent.detail > 0;
+ }
+
+ _onOverflow() {
+ // Attach the popup binding to the chevron popup if it has not yet
+ // been initialized.
+ if (!this._chevronPopup.hasAttribute("type")) {
+ this._chevronPopup.setAttribute("place", this.place);
+ this._chevronPopup.setAttribute("type", "places");
+ }
+ this._chevron.collapsed = false;
+ this.updateNodesVisibility();
+ }
+
+ _onUnderflow() {
+ this.updateNodesVisibility();
+ this._chevron.collapsed = true;
+ }
+
+ updateNodesVisibility() {
+ // Update the chevron on a timer. This will avoid repeated work when
+ // lot of changes happen in a small timeframe.
+ if (this._updateNodesVisibilityTimer) {
+ this._updateNodesVisibilityTimer.cancel();
+ }
+
+ this._updateNodesVisibilityTimer = this._setTimer(100);
+ }
+
+ async _updateNodesVisibilityTimerCallback() {
+ if (this._updatingNodesVisibility || window.closed) {
+ return;
+ }
+ this._updatingNodesVisibility = true;
+
+ let dwu = window.windowUtils;
+
+ let scrollRect = await window.promiseDocumentFlushed(() =>
+ dwu.getBoundsWithoutFlushing(this._rootElt)
+ );
+
+ let childOverflowed = false;
+
+ // We're about to potentially update a bunch of nodes, so we do it
+ // in a requestAnimationFrame so that other JS that's might execute
+ // in the same tick can avoid flushing styles and layout for these
+ // changes.
+ window.requestAnimationFrame(() => {
+ for (let child of this._rootElt.children) {
+ // Once a child overflows, all the next ones will.
+ if (!childOverflowed) {
+ let childRect = dwu.getBoundsWithoutFlushing(child);
+ childOverflowed = this.isRTL
+ ? childRect.left < scrollRect.left
+ : childRect.right > scrollRect.right;
+ }
+
+ if (childOverflowed) {
+ child.removeAttribute("image");
+ child.style.visibility = "hidden";
+ } else {
+ let icon = child._placesNode.icon;
+ if (icon) {
+ child.setAttribute("image", icon);
+ }
+ child.style.removeProperty("visibility");
+ }
+ }
+
+ // We rebuild the chevron on popupShowing, so if it is open
+ // we must update it.
+ if (!this._chevron.collapsed && this._chevron.open) {
+ this._updateChevronPopupNodesVisibility();
+ }
+
+ let event = new CustomEvent("BookmarksToolbarVisibilityUpdated", {
+ bubbles: true,
+ });
+ this._viewElt.dispatchEvent(event);
+ this._updatingNodesVisibility = false;
+ });
+ }
+
+ nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) {
+ let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode);
+ if (parentElt == this._rootElt) {
+ // Node is on the toolbar.
+ let children = this._rootElt.children;
+ // Nothing to do if it's a never-visible node, but note it's possible
+ // we are appending.
+ if (aIndex > children.length) {
+ return;
+ }
+
+ // Note that childCount is already accounting for the node being added,
+ // thus we must subtract one node from it.
+ if (this._resultNode.childCount - 1 > children.length) {
+ if (aIndex == children.length) {
+ // If we didn't build all the nodes and new node is being appended,
+ // we can skip it as well.
+ return;
+ }
+ // Keep the number of built nodes consistent.
+ this._rootElt.removeChild(this._rootElt.lastElementChild);
+ }
+
+ let button = this._insertNewItem(
+ aPlacesNode,
+ this._rootElt,
+ children[aIndex] || null
+ );
+ let prevSiblingOverflowed =
+ aIndex > 0 &&
+ aIndex <= children.length &&
+ children[aIndex - 1].style.visibility == "hidden";
+ if (prevSiblingOverflowed) {
+ button.style.visibility = "hidden";
+ } else {
+ let icon = aPlacesNode.icon;
+ if (icon) {
+ button.setAttribute("image", icon);
+ }
+ this.updateNodesVisibility();
+ }
+ return;
+ }
+
+ super.nodeInserted(aParentPlacesNode, aPlacesNode, aIndex);
+ }
+
+ nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) {
+ let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode);
+ if (parentElt == this._rootElt) {
+ // Node is on the toolbar.
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true);
+ // Nothing to do if it's a never-visible node.
+ if (!elt) {
+ return;
+ }
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ let overflowed = elt.style.visibility == "hidden";
+ this._removeChild(elt);
+ if (this._resultNode.childCount > this._rootElt.children.length) {
+ // A new node should be built to keep a coherent number of children.
+ this._insertNewItem(
+ this._resultNode.getChild(this._rootElt.children.length),
+ this._rootElt
+ );
+ }
+ if (!overflowed) {
+ this.updateNodesVisibility();
+ }
+ return;
+ }
+
+ super.nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex);
+ }
+
+ nodeMoved(
+ aPlacesNode,
+ aOldParentPlacesNode,
+ aOldIndex,
+ aNewParentPlacesNode,
+ aNewIndex
+ ) {
+ let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode);
+ if (parentElt == this._rootElt) {
+ // Node is on the toolbar.
+ // Do nothing if the node will never be visible.
+ let lastBuiltIndex = this._rootElt.children.length - 1;
+ if (aOldIndex > lastBuiltIndex && aNewIndex > lastBuiltIndex + 1) {
+ return;
+ }
+
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true);
+ if (elt) {
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+ this._removeChild(elt);
+ }
+
+ if (aNewIndex > lastBuiltIndex + 1) {
+ if (this._resultNode.childCount > this._rootElt.children.length) {
+ // If the element was built and becomes non built, another node should
+ // be built to keep a coherent number of children.
+ this._insertNewItem(
+ this._resultNode.getChild(this._rootElt.children.length),
+ this._rootElt
+ );
+ }
+ return;
+ }
+
+ if (!elt) {
+ // The node has not been inserted yet, so we must create it.
+ elt = this._insertNewItem(
+ aPlacesNode,
+ this._rootElt,
+ this._rootElt.children[aNewIndex]
+ );
+ let icon = aPlacesNode.icon;
+ if (icon) {
+ elt.setAttribute("image", icon);
+ }
+ } else {
+ this._rootElt.insertBefore(elt, this._rootElt.children[aNewIndex]);
+ }
+
+ // The chevron view may get nodeMoved after the toolbar. In such a case,
+ // we should ensure (by manually swapping menuitems) that the actual nodes
+ // are in the final position before updateNodesVisibility tries to update
+ // their visibility, or the chevron may go out of sync.
+ // Luckily updateNodesVisibility runs on a timer, so, by the time it updates
+ // nodes, the menu has already handled the notification.
+
+ this.updateNodesVisibility();
+ return;
+ }
+
+ super.nodeMoved(
+ aPlacesNode,
+ aOldParentPlacesNode,
+ aOldIndex,
+ aNewParentPlacesNode,
+ aNewIndex
+ );
+ }
+
+ nodeTitleChanged(aPlacesNode, aNewTitle) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true);
+
+ // Nothing to do if it's a never-visible node.
+ if (!elt || elt == this._rootElt) {
+ return;
+ }
+
+ super.nodeTitleChanged(aPlacesNode, aNewTitle);
+
+ // Here we need the <menu>.
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ if (elt.parentNode == this._rootElt) {
+ // Node is on the toolbar.
+ if (elt.style.visibility != "hidden") {
+ this.updateNodesVisibility();
+ }
+ }
+ }
+
+ invalidateContainer(aPlacesNode) {
+ let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true);
+ // Nothing to do if it's a never-visible node.
+ if (!elt) {
+ return;
+ }
+
+ if (elt == this._rootElt) {
+ // Container is the toolbar itself.
+ this._rebuild().catch(Cu.reportError);
+ return;
+ }
+
+ super.invalidateContainer(aPlacesNode);
+ }
+
+ _clearOverFolder() {
+ // The mouse is no longer dragging over the stored menubutton.
+ // Close the menubutton, clear out drag styles, and clear all
+ // timers for opening/closing it.
+ if (this._overFolder.elt && this._overFolder.elt.menupopup) {
+ if (!this._overFolder.elt.menupopup.hasAttribute("dragover")) {
+ this._overFolder.elt.menupopup.hidePopup();
+ }
+ this._overFolder.elt.removeAttribute("dragover");
+ this._overFolder.elt = null;
+ }
+ if (this._overFolder.openTimer) {
+ this._overFolder.openTimer.cancel();
+ this._overFolder.openTimer = null;
+ }
+ if (this._overFolder.closeTimer) {
+ this._overFolder.closeTimer.cancel();
+ this._overFolder.closeTimer = null;
+ }
+ }
+
+ /**
+ * This function returns information about where to drop when dragging over
+ * the toolbar.
+ *
+ * @param {object} aEvent
+ * The associated event.
+ * @returns {object}
+ * - ip: the insertion point for the bookmarks service.
+ * - beforeIndex: child index to drop before, for the drop indicator.
+ * - folderElt: the folder to drop into, if applicable.
+ */
+ _getDropPoint(aEvent) {
+ if (!PlacesUtils.nodeIsFolder(this._resultNode)) {
+ return null;
+ }
+
+ let dropPoint = { ip: null, beforeIndex: null, folderElt: null };
+ let elt = aEvent.target;
+ if (
+ elt._placesNode &&
+ elt != this._rootElt &&
+ elt.localName != "menupopup"
+ ) {
+ let eltRect = elt.getBoundingClientRect();
+ let eltIndex = Array.prototype.indexOf.call(this._rootElt.children, elt);
+ if (
+ PlacesUtils.nodeIsFolder(elt._placesNode) &&
+ !PlacesUIUtils.isFolderReadOnly(elt._placesNode)
+ ) {
+ // This is a folder.
+ // If we are in the middle of it, drop inside it.
+ // Otherwise, drop before it, with regards to RTL mode.
+ let threshold = eltRect.width * 0.25;
+ if (
+ this.isRTL
+ ? aEvent.clientX > eltRect.right - threshold
+ : aEvent.clientX < eltRect.left + threshold
+ ) {
+ // Drop before this folder.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode),
+ index: eltIndex,
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ });
+ dropPoint.beforeIndex = eltIndex;
+ } else if (
+ this.isRTL
+ ? aEvent.clientX > eltRect.left + threshold
+ : aEvent.clientX < eltRect.right - threshold
+ ) {
+ // Drop inside this folder.
+ let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode)
+ ? elt._placesNode.title
+ : null;
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode),
+ tagName,
+ });
+ dropPoint.beforeIndex = eltIndex;
+ dropPoint.folderElt = elt;
+ } else {
+ // Drop after this folder.
+ let beforeIndex =
+ eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1;
+
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode),
+ index: beforeIndex,
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ });
+ dropPoint.beforeIndex = beforeIndex;
+ }
+ } else {
+ // This is a non-folder node or a read-only folder.
+ // Drop before it with regards to RTL mode.
+ let threshold = eltRect.width * 0.5;
+ if (
+ this.isRTL
+ ? aEvent.clientX > eltRect.left + threshold
+ : aEvent.clientX < eltRect.left + threshold
+ ) {
+ // Drop before this bookmark.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode),
+ index: eltIndex,
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ });
+ dropPoint.beforeIndex = eltIndex;
+ } else {
+ // Drop after this bookmark.
+ let beforeIndex =
+ eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1;
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode),
+ index: beforeIndex,
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ });
+ dropPoint.beforeIndex = beforeIndex;
+ }
+ }
+ } else {
+ // We are most likely dragging on the empty area of the
+ // toolbar, we should drop after the last node.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode),
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ });
+ dropPoint.beforeIndex = -1;
+ }
+
+ return dropPoint;
+ }
+
+ _setTimer(aTime) {
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT);
+ return timer;
+ }
+
+ notify(aTimer) {
+ if (aTimer == this._updateNodesVisibilityTimer) {
+ this._updateNodesVisibilityTimer = null;
+ this._updateNodesVisibilityTimerCallback();
+ } else if (aTimer == this._overFolder.openTimer) {
+ // * Timer to open a menubutton that's being dragged over.
+ // Set the autoopen attribute on the folder's menupopup so that
+ // the menu will automatically close when the mouse drags off of it.
+ this._overFolder.elt.menupopup.setAttribute("autoopened", "true");
+ this._overFolder.elt.open = true;
+ this._overFolder.openTimer = null;
+ } else if (aTimer == this._overFolder.closeTimer) {
+ // * Timer to close a menubutton that's been dragged off of.
+ // Close the menubutton if we are not dragging over it or one of
+ // its children. The autoopened attribute will let the menu know to
+ // close later if the menu is still being dragged over.
+ let currentPlacesNode = PlacesControllerDragHelper.currentDropTarget;
+ let inHierarchy = false;
+ while (currentPlacesNode) {
+ if (currentPlacesNode == this._rootElt) {
+ inHierarchy = true;
+ break;
+ }
+ currentPlacesNode = currentPlacesNode.parentNode;
+ }
+ // The _clearOverFolder() function will close the menu for
+ // _overFolder.elt. So null it out if we don't want to close it.
+ if (inHierarchy) {
+ this._overFolder.elt = null;
+ }
+
+ // Clear out the folder and all associated timers.
+ this._clearOverFolder();
+ }
+ }
+
+ _onMouseOver(aEvent) {
+ let button = aEvent.target;
+ if (
+ button.parentNode == this._rootElt &&
+ button._placesNode &&
+ PlacesUtils.nodeIsURI(button._placesNode)
+ ) {
+ window.XULBrowserWindow.setOverLink(aEvent.target._placesNode.uri);
+ }
+ }
+
+ _onMouseOut(aEvent) {
+ window.XULBrowserWindow.setOverLink("");
+ }
+
+ _onMouseDown(aEvent) {
+ let target = aEvent.target;
+ if (
+ aEvent.button == 0 &&
+ target.localName == "toolbarbutton" &&
+ target.getAttribute("type") == "menu"
+ ) {
+ let modifKey = aEvent.shiftKey || aEvent.getModifierState("Accel");
+ if (modifKey) {
+ // Do not open the popup since BEH_onClick is about to
+ // open all child uri nodes in tabs.
+ this._allowPopupShowing = false;
+ }
+ }
+ if (target._placesNode?.uri) {
+ PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window);
+ }
+ }
+
+ _cleanupDragDetails() {
+ // Called on dragend and drop.
+ PlacesControllerDragHelper.currentDropTarget = null;
+ this._draggedElt = null;
+ this._dropIndicator.collapsed = true;
+ }
+
+ _onDragStart(aEvent) {
+ // Sub menus have their own d&d handlers.
+ let draggedElt = aEvent.target;
+ if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) {
+ return;
+ }
+
+ if (
+ draggedElt.localName == "toolbarbutton" &&
+ draggedElt.getAttribute("type") == "menu"
+ ) {
+ // If the drag gesture on a container is toward down we open instead
+ // of dragging.
+ let translateY = this._cachedMouseMoveEvent.clientY - aEvent.clientY;
+ let translateX = this._cachedMouseMoveEvent.clientX - aEvent.clientX;
+ if (translateY >= Math.abs(translateX / 2)) {
+ // Don't start the drag.
+ aEvent.preventDefault();
+ // Open the menu.
+ draggedElt.open = true;
+ return;
+ }
+
+ // If the menu is open, close it.
+ if (draggedElt.open) {
+ draggedElt.menupopup.hidePopup();
+ draggedElt.open = false;
+ }
+ }
+
+ // Activate the view and cache the dragged element.
+ this._draggedElt = draggedElt._placesNode;
+ this._rootElt.focus();
+
+ this._controller.setDataTransfer(aEvent);
+ aEvent.stopPropagation();
+ }
+
+ _onDragOver(aEvent) {
+ // Cache the dataTransfer
+ PlacesControllerDragHelper.currentDropTarget = aEvent.target;
+ let dt = aEvent.dataTransfer;
+
+ let dropPoint = this._getDropPoint(aEvent);
+ if (
+ !dropPoint ||
+ !dropPoint.ip ||
+ !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)
+ ) {
+ this._dropIndicator.collapsed = true;
+ aEvent.stopPropagation();
+ return;
+ }
+
+ if (dropPoint.folderElt || aEvent.originalTarget == this._chevron) {
+ // Dropping over a menubutton or chevron button.
+ // Set styles and timer to open relative menupopup.
+ let overElt = dropPoint.folderElt || this._chevron;
+ if (this._overFolder.elt != overElt) {
+ this._clearOverFolder();
+ this._overFolder.elt = overElt;
+ this._overFolder.openTimer = this._setTimer(this._overFolder.hoverTime);
+ }
+ if (!this._overFolder.elt.hasAttribute("dragover")) {
+ this._overFolder.elt.setAttribute("dragover", "true");
+ }
+
+ this._dropIndicator.collapsed = true;
+ } else {
+ // Dragging over a normal toolbarbutton,
+ // show indicator bar and move it to the appropriate drop point.
+ let ind = this._dropIndicator;
+ ind.parentNode.collapsed = false;
+ let halfInd = ind.clientWidth / 2;
+ let translateX;
+ if (this.isRTL) {
+ halfInd = Math.ceil(halfInd);
+ translateX = 0 - this._rootElt.getBoundingClientRect().right - halfInd;
+ if (this._rootElt.firstElementChild) {
+ if (dropPoint.beforeIndex == -1) {
+ translateX += this._rootElt.lastElementChild.getBoundingClientRect()
+ .left;
+ } else {
+ translateX += this._rootElt.children[
+ dropPoint.beforeIndex
+ ].getBoundingClientRect().right;
+ }
+ }
+ } else {
+ halfInd = Math.floor(halfInd);
+ translateX = 0 - this._rootElt.getBoundingClientRect().left + halfInd;
+ if (this._rootElt.firstElementChild) {
+ if (dropPoint.beforeIndex == -1) {
+ translateX += this._rootElt.lastElementChild.getBoundingClientRect()
+ .right;
+ } else {
+ translateX += this._rootElt.children[
+ dropPoint.beforeIndex
+ ].getBoundingClientRect().left;
+ }
+ }
+ }
+
+ ind.style.transform = "translate(" + Math.round(translateX) + "px)";
+ ind.style.marginInlineStart = -ind.clientWidth + "px";
+ ind.collapsed = false;
+
+ // Clear out old folder information.
+ this._clearOverFolder();
+ }
+
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ }
+
+ _onDrop(aEvent) {
+ PlacesControllerDragHelper.currentDropTarget = aEvent.target;
+
+ let dropPoint = this._getDropPoint(aEvent);
+ if (dropPoint && dropPoint.ip) {
+ PlacesControllerDragHelper.onDrop(
+ dropPoint.ip,
+ aEvent.dataTransfer
+ ).catch(Cu.reportError);
+ aEvent.preventDefault();
+ }
+
+ this._cleanupDragDetails();
+ aEvent.stopPropagation();
+ }
+
+ _onDragLeave(aEvent) {
+ PlacesControllerDragHelper.currentDropTarget = null;
+
+ this._dropIndicator.collapsed = true;
+
+ // If we hovered over a folder, close it now.
+ if (this._overFolder.elt) {
+ this._overFolder.closeTimer = this._setTimer(this._overFolder.hoverTime);
+ }
+ }
+
+ _onDragEnd(aEvent) {
+ this._cleanupDragDetails();
+ }
+
+ _onPopupShowing(aEvent) {
+ if (!this._allowPopupShowing) {
+ this._allowPopupShowing = true;
+ aEvent.preventDefault();
+ return;
+ }
+
+ let parent = aEvent.target.parentNode;
+ if (parent.localName == "toolbarbutton") {
+ this._openedMenuButton = parent;
+ }
+
+ super._onPopupShowing(aEvent);
+ }
+
+ _onPopupHidden(aEvent) {
+ let popup = aEvent.target;
+ let placesNode = popup._placesNode;
+ // Avoid handling popuphidden of inner views
+ if (
+ placesNode &&
+ PlacesUIUtils.getViewForNode(popup) == this &&
+ // UI performance: folder queries are cheap, keep the resultnode open
+ // so we don't rebuild its contents whenever the popup is reopened.
+ !PlacesUtils.nodeIsFolder(placesNode)
+ ) {
+ placesNode.containerOpen = false;
+ }
+
+ let parent = popup.parentNode;
+ if (parent.localName == "toolbarbutton") {
+ this._openedMenuButton = null;
+ // Clear the dragover attribute if present, if we are dragging into a
+ // folder in the hierachy of current opened popup we don't clear
+ // this attribute on clearOverFolder. See Notify for closeTimer.
+ if (parent.hasAttribute("dragover")) {
+ parent.removeAttribute("dragover");
+ }
+ }
+ }
+
+ _onMouseMove(aEvent) {
+ // Used in dragStart to prevent dragging folders when dragging down.
+ this._cachedMouseMoveEvent = aEvent;
+
+ if (
+ this._openedMenuButton == null ||
+ PlacesControllerDragHelper.getSession()
+ ) {
+ return;
+ }
+
+ let target = aEvent.originalTarget;
+ if (
+ this._openedMenuButton != target &&
+ target.localName == "toolbarbutton" &&
+ target.type == "menu"
+ ) {
+ this._openedMenuButton.open = false;
+ target.open = true;
+ }
+ }
+}
+
+/**
+ * View for Places menus. This object should be created during the first
+ * popupshowing that's dispatched on the menu.
+ *
+ */
+class PlacesMenu extends PlacesViewBase {
+ /**
+ *
+ * @param {Event} popupShowingEvent
+ * The event associated with opening the menu.
+ * @param {string} placesUrl
+ * The query associated with the view on the menu.
+ */
+ constructor(popupShowingEvent, placesUrl) {
+ super(
+ placesUrl,
+ popupShowingEvent.target, // <menupopup>
+ popupShowingEvent.target.parentNode // <menu>
+ );
+
+ this._addEventListeners(
+ this._rootElt,
+ ["popupshowing", "popuphidden"],
+ true
+ );
+ this._addEventListeners(window, ["unload"], false);
+ this._addEventListeners(this._rootElt, ["mousedown"], false);
+ if (AppConstants.platform === "macosx") {
+ // Must walk up to support views in sub-menus, like Bookmarks Toolbar menu.
+ for (let elt = this._viewElt.parentNode; elt; elt = elt.parentNode) {
+ if (elt.localName == "menubar") {
+ this._nativeView = true;
+ break;
+ }
+ }
+ }
+
+ this._onPopupShowing(popupShowingEvent);
+ }
+
+ _init() {
+ this._viewElt._placesView = this;
+ }
+
+ _removeChild(aChild) {
+ super._removeChild(aChild);
+ }
+
+ uninit() {
+ this._removeEventListeners(
+ this._rootElt,
+ ["popupshowing", "popuphidden"],
+ true
+ );
+ this._removeEventListeners(window, ["unload"], false);
+ this._removeEventListeners(this._rootElt, ["mousedown"], false);
+
+ super.uninit();
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.uninit();
+ break;
+ case "popupshowing":
+ this._onPopupShowing(aEvent);
+ break;
+ case "popuphidden":
+ this._onPopupHidden(aEvent);
+ break;
+ case "mousedown":
+ this._onMouseDown(aEvent);
+ break;
+ }
+ }
+
+ _onPopupHidden(aEvent) {
+ // Avoid handling popuphidden of inner views.
+ let popup = aEvent.originalTarget;
+ let placesNode = popup._placesNode;
+ if (!placesNode || PlacesUIUtils.getViewForNode(popup) != this) {
+ return;
+ }
+
+ // UI performance: folder queries are cheap, keep the resultnode open
+ // so we don't rebuild its contents whenever the popup is reopened.
+ if (!PlacesUtils.nodeIsFolder(placesNode)) {
+ placesNode.containerOpen = false;
+ }
+
+ // The autoopened attribute is set for folders which have been
+ // automatically opened when dragged over. Turn off this attribute
+ // when the folder closes because it is no longer applicable.
+ popup.removeAttribute("autoopened");
+ popup.removeAttribute("dragstart");
+ }
+
+ // We don't have a facility for catch "mousedown" events on the native
+ // Mac menus because Mac doesn't expose it
+ _onMouseDown(aEvent) {
+ let target = aEvent.target;
+ if (target._placesNode?.uri) {
+ PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window);
+ }
+ }
+}
+
+// This is used from CustomizableWidgets.jsm using a `window` reference,
+// so we have to expose this on the global.
+this.PlacesPanelview = class PlacesPanelview extends PlacesViewBase {
+ constructor(placeUrl, rootElt, viewElt) {
+ super(placeUrl, rootElt, viewElt);
+ this._viewElt._placesView = this;
+ // We're simulating a popup show, because a panelview may only be shown when
+ // its containing popup is already shown.
+ this._onPopupShowing({ originalTarget: this._rootElt });
+ this._addEventListeners(window, ["unload"]);
+ this._rootElt.setAttribute("context", "placesContext");
+ }
+
+ get events() {
+ if (this._events) {
+ return this._events;
+ }
+ return (this._events = [
+ "click",
+ "command",
+ "dragend",
+ "dragstart",
+ "ViewHiding",
+ "ViewShown",
+ ]);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ // For middle clicks, fall through to the command handler.
+ if (event.button != 1) {
+ break;
+ }
+ // fall through
+ case "command":
+ this._onCommand(event);
+ break;
+ case "dragend":
+ this._onDragEnd(event);
+ break;
+ case "dragstart":
+ this._onDragStart(event);
+ break;
+ case "unload":
+ this.uninit(event);
+ break;
+ case "ViewHiding":
+ this._onPopupHidden(event);
+ break;
+ case "ViewShown":
+ this._onViewShown(event);
+ break;
+ }
+ }
+
+ _onCommand(event) {
+ event = getRootEvent(event);
+ let button = event.originalTarget;
+ if (!button._placesNode) {
+ return;
+ }
+
+ let modifKey =
+ AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey;
+ if (!PlacesUIUtils.openInTabClosesMenu && modifKey) {
+ // If 'Recent Bookmarks' in Bookmarks Panel.
+ if (button.parentNode.id == "panelMenu_bookmarksMenu") {
+ button.setAttribute("closemenu", "none");
+ }
+ } else {
+ button.removeAttribute("closemenu");
+ }
+ PlacesUIUtils.openNodeWithEvent(button._placesNode, event);
+ // Unlike left-click, middle-click requires manual menu closing.
+ if (
+ button.parentNode.id != "panelMenu_bookmarksMenu" ||
+ (event.type == "click" &&
+ event.button == 1 &&
+ PlacesUIUtils.openInTabClosesMenu)
+ ) {
+ this.panelMultiView.closest("panel").hidePopup();
+ }
+ }
+
+ _onDragEnd() {
+ this._draggedElt = null;
+ }
+
+ _onDragStart(event) {
+ let draggedElt = event.originalTarget;
+ if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) {
+ return;
+ }
+
+ // Activate the view and cache the dragged element.
+ this._draggedElt = draggedElt._placesNode;
+ this._rootElt.focus();
+
+ this._controller.setDataTransfer(event);
+ event.stopPropagation();
+ }
+
+ uninit(event) {
+ this._removeEventListeners(this.panelMultiView, this.events);
+ this._removeEventListeners(window, ["unload"]);
+ delete this.panelMultiView;
+ super.uninit(event);
+ }
+
+ _createDOMNodeForPlacesNode(placesNode) {
+ this._domNodes.delete(placesNode);
+
+ let element;
+ let type = placesNode.type;
+ if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
+ element = document.createXULElement("toolbarseparator");
+ } else {
+ if (type != Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) {
+ throw new Error("Unexpected node");
+ }
+
+ element = document.createXULElement("toolbarbutton");
+ element.classList.add(
+ "subviewbutton",
+ "subviewbutton-iconic",
+ "bookmark-item"
+ );
+ element.setAttribute(
+ "scheme",
+ PlacesUIUtils.guessUrlSchemeForUI(placesNode.uri)
+ );
+ element.setAttribute("label", PlacesUIUtils.getBestTitle(placesNode));
+
+ let icon = placesNode.icon;
+ if (icon) {
+ element.setAttribute("image", icon);
+ }
+ }
+
+ element._placesNode = placesNode;
+ if (!this._domNodes.has(placesNode)) {
+ this._domNodes.set(placesNode, element);
+ }
+
+ return element;
+ }
+
+ _setEmptyPopupStatus(panelview, empty = false) {
+ if (!panelview._emptyMenuitem) {
+ let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder");
+ panelview._emptyMenuitem = document.createXULElement("toolbarbutton");
+ panelview._emptyMenuitem.setAttribute("label", label);
+ panelview._emptyMenuitem.setAttribute("disabled", true);
+ panelview._emptyMenuitem.className = "subviewbutton";
+ if (this._appendClassToChildren) {
+ panelview._emptyMenuitem.classList.add(this._appendClassToChildren);
+ }
+ }
+
+ if (empty) {
+ panelview.setAttribute("emptyplacesresult", "true");
+ // Don't add the menuitem if there is static content.
+ // We also support external usage for custom crafted panels - which'll have
+ // no markers present.
+ if (
+ !panelview._startMarker ||
+ (!panelview._startMarker.previousElementSibling &&
+ !panelview._endMarker.nextElementSibling)
+ ) {
+ panelview.insertBefore(panelview._emptyMenuitem, panelview._endMarker);
+ }
+ } else {
+ panelview.removeAttribute("emptyplacesresult");
+ try {
+ panelview.removeChild(panelview._emptyMenuitem);
+ } catch (ex) {}
+ }
+ }
+
+ _isPopupOpen() {
+ return PanelView.forNode(this._viewElt).active;
+ }
+
+ _onPopupHidden(event) {
+ let panelview = event.originalTarget;
+ let placesNode = panelview._placesNode;
+ // Avoid handling ViewHiding of inner views
+ if (
+ placesNode &&
+ PlacesUIUtils.getViewForNode(panelview) == this &&
+ // UI performance: folder queries are cheap, keep the resultnode open
+ // so we don't rebuild its contents whenever the popup is reopened.
+ !PlacesUtils.nodeIsFolder(placesNode)
+ ) {
+ placesNode.containerOpen = false;
+ }
+ }
+
+ _onPopupShowing(event) {
+ // If the event came from the root element, this is the first time
+ // we ever get here.
+ if (event.originalTarget == this._rootElt) {
+ // Start listening for events from all panels inside the panelmultiview.
+ this.panelMultiView = this._viewElt.panelMultiView;
+ this._addEventListeners(this.panelMultiView, this.events);
+ }
+ super._onPopupShowing(event);
+ }
+
+ _onViewShown(event) {
+ if (event.originalTarget != this._viewElt) {
+ return;
+ }
+
+ // Because PanelMultiView reparents the panelview internally, the controller
+ // may get lost. In that case we'll append it again, because we certainly
+ // need it later!
+ if (!this.controllers.getControllerCount() && this._controller) {
+ this.controllers.appendController(this._controller);
+ }
+ }
+};
diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js
new file mode 100644
index 0000000000..287337731b
--- /dev/null
+++ b/browser/components/places/content/controller.js
@@ -0,0 +1,1732 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+/* import-globals-from /browser/base/content/utilityOverlay.js */
+/* import-globals-from ./places.js */
+
+/**
+ * Represents an insertion point within a container where we can insert
+ * items.
+ *
+ * @param {object} options an object containing the following properties:
+ * @param {string} options.parentGuid
+ * The unique identifier of the parent container
+ * @param {number} [options.index]
+ * The index within the container where to insert, defaults to appending
+ * @param {number} [options.orientation]
+ * The orientation of the insertion. NOTE: the adjustments to the
+ * insertion point to accommodate the orientation should be done by
+ * the person who constructs the IP, not the user. The orientation
+ * is provided for informational purposes only! Defaults to DROP_ON.
+ * @param {string} [options.tagName]
+ * The tag name if this IP is set to a tag, null otherwise.
+ * @param {*} [options.dropNearNode]
+ * When defined index will be calculated based on this node
+ */
+function PlacesInsertionPoint({
+ parentGuid,
+ index = PlacesUtils.bookmarks.DEFAULT_INDEX,
+ orientation = Ci.nsITreeView.DROP_ON,
+ tagName = null,
+ dropNearNode = null,
+}) {
+ this.guid = parentGuid;
+ this._index = index;
+ this.orientation = orientation;
+ this.tagName = tagName;
+ this.dropNearNode = dropNearNode;
+}
+
+PlacesInsertionPoint.prototype = {
+ set index(val) {
+ this._index = val;
+ },
+
+ async getIndex() {
+ if (this.dropNearNode) {
+ // If dropNearNode is set up we must calculate the index of the item near
+ // which we will drop.
+ let index = (
+ await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid)
+ ).index;
+ return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1;
+ }
+ return this._index;
+ },
+
+ get isTag() {
+ return typeof this.tagName == "string";
+ },
+};
+
+/**
+ * Places Controller
+ */
+
+function PlacesController(aView) {
+ this._view = aView;
+ XPCOMUtils.defineLazyGetter(this, "profileName", function() {
+ return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName;
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "forgetSiteClearByBaseDomain",
+ "places.forgetThisSite.clearByBaseDomain",
+ false
+ );
+ ChromeUtils.defineModuleGetter(
+ this,
+ "ForgetAboutSite",
+ "resource://gre/modules/ForgetAboutSite.jsm"
+ );
+}
+
+PlacesController.prototype = {
+ /**
+ * The places view.
+ */
+ _view: null,
+
+ // This is used in certain views to disable user actions on the places tree
+ // views. This avoids accidental deletion/modification when the user is not
+ // actually organising the trees.
+ disableUserActions: false,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]),
+
+ // nsIClipboardOwner
+ LosingOwnership: function PC_LosingOwnership(aXferable) {
+ this.cutNodes = [];
+ },
+
+ terminate: function PC_terminate() {
+ this._releaseClipboardOwnership();
+ },
+
+ supportsCommand: function PC_supportsCommand(aCommand) {
+ if (this.disableUserActions) {
+ return false;
+ }
+ // Non-Places specific commands that we also support
+ switch (aCommand) {
+ case "cmd_undo":
+ case "cmd_redo":
+ case "cmd_cut":
+ case "cmd_copy":
+ case "cmd_paste":
+ case "cmd_delete":
+ case "cmd_selectAll":
+ return true;
+ }
+
+ // All other Places Commands are prefixed with "placesCmd_" ... this
+ // filters out other commands that we do _not_ support (see 329587).
+ const CMD_PREFIX = "placesCmd_";
+ return aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX;
+ },
+
+ isCommandEnabled: function PC_isCommandEnabled(aCommand) {
+ // Determine whether or not nodes can be inserted.
+ let ip = this._view.insertionPoint;
+ let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag);
+
+ switch (aCommand) {
+ case "cmd_undo":
+ return PlacesTransactions.topUndoEntry != null;
+ case "cmd_redo":
+ return PlacesTransactions.topRedoEntry != null;
+ case "cmd_cut":
+ case "placesCmd_cut":
+ for (let node of this._view.selectedNodes) {
+ // If selection includes history nodes or tags-as-bookmark, disallow
+ // cutting.
+ if (
+ node.itemId == -1 ||
+ (node.parent && PlacesUtils.nodeIsTagQuery(node.parent))
+ ) {
+ return false;
+ }
+ }
+ // Otherwise fall through the cmd_delete check.
+ case "cmd_delete":
+ case "placesCmd_delete":
+ case "placesCmd_deleteDataHost":
+ return this._hasRemovableSelection();
+ case "cmd_copy":
+ case "placesCmd_copy":
+ case "placesCmd_showInFolder":
+ return this._view.hasSelection;
+ case "cmd_paste":
+ case "placesCmd_paste":
+ // If the clipboard contains a Places flavor it is definitely pasteable,
+ // otherwise we also allow pasting "text/unicode" and "text/x-moz-url" data.
+ // We don't check if the data is valid here, because the clipboard may
+ // contain very large blobs that would largely slowdown commands updating.
+ // Of course later paste() should ignore any invalid data.
+ return (
+ canInsert &&
+ Services.clipboard.hasDataMatchingFlavors(
+ [
+ ...PlacesUIUtils.PLACES_FLAVORS,
+ PlacesUtils.TYPE_X_MOZ_URL,
+ PlacesUtils.TYPE_UNICODE,
+ ],
+ Ci.nsIClipboard.kGlobalClipboard
+ )
+ );
+ case "cmd_selectAll":
+ if (this._view.selType != "single") {
+ let rootNode = this._view.result.root;
+ if (rootNode.containerOpen && rootNode.childCount > 0) {
+ return true;
+ }
+ }
+ return false;
+ case "placesCmd_open":
+ case "placesCmd_open:window":
+ case "placesCmd_open:privatewindow":
+ case "placesCmd_open:tab": {
+ let selectedNode = this._view.selectedNode;
+ return selectedNode && PlacesUtils.nodeIsURI(selectedNode);
+ }
+ case "placesCmd_new:folder":
+ return canInsert;
+ case "placesCmd_new:bookmark":
+ return canInsert;
+ case "placesCmd_new:separator":
+ return (
+ canInsert &&
+ !PlacesUtils.asQuery(this._view.result.root).queryOptions
+ .excludeItems &&
+ this._view.result.sortingMode ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ );
+ case "placesCmd_show:info": {
+ let selectedNode = this._view.selectedNode;
+ return (
+ selectedNode &&
+ !PlacesUtils.isRootItem(
+ PlacesUtils.getConcreteItemGuid(selectedNode)
+ ) &&
+ (PlacesUtils.nodeIsTagQuery(selectedNode) ||
+ PlacesUtils.nodeIsBookmark(selectedNode) ||
+ (PlacesUtils.nodeIsFolder(selectedNode) &&
+ !PlacesUtils.isQueryGeneratedFolder(selectedNode)))
+ );
+ }
+ case "placesCmd_sortBy:name": {
+ let selectedNode = this._view.selectedNode;
+ return (
+ selectedNode &&
+ PlacesUtils.nodeIsFolder(selectedNode) &&
+ !PlacesUIUtils.isFolderReadOnly(selectedNode) &&
+ this._view.result.sortingMode ==
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ );
+ }
+ case "placesCmd_createBookmark": {
+ return !this._view.selectedNodes.some(
+ node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1
+ );
+ }
+ default:
+ return false;
+ }
+ },
+
+ doCommand: function PC_doCommand(aCommand) {
+ switch (aCommand) {
+ case "cmd_undo":
+ PlacesTransactions.undo().catch(Cu.reportError);
+ break;
+ case "cmd_redo":
+ PlacesTransactions.redo().catch(Cu.reportError);
+ break;
+ case "cmd_cut":
+ case "placesCmd_cut":
+ this.cut();
+ break;
+ case "cmd_copy":
+ case "placesCmd_copy":
+ this.copy();
+ break;
+ case "cmd_paste":
+ case "placesCmd_paste":
+ this.paste().catch(Cu.reportError);
+ break;
+ case "cmd_delete":
+ case "placesCmd_delete":
+ this.remove("Remove Selection").catch(Cu.reportError);
+ break;
+ case "placesCmd_deleteDataHost":
+ this.forgetAboutThisSite().catch(Cu.reportError);
+ break;
+ case "cmd_selectAll":
+ this.selectAll();
+ break;
+ case "placesCmd_open":
+ PlacesUIUtils.openNodeIn(
+ this._view.selectedNode,
+ "current",
+ this._view
+ );
+ break;
+ case "placesCmd_open:window":
+ PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view);
+ break;
+ case "placesCmd_open:privatewindow":
+ PlacesUIUtils.openNodeIn(
+ this._view.selectedNode,
+ "window",
+ this._view,
+ true
+ );
+ break;
+ case "placesCmd_open:tab":
+ PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view);
+ break;
+ case "placesCmd_new:folder":
+ this.newItem("folder").catch(Cu.reportError);
+ break;
+ case "placesCmd_new:bookmark":
+ this.newItem("bookmark").catch(Cu.reportError);
+ break;
+ case "placesCmd_new:separator":
+ this.newSeparator().catch(Cu.reportError);
+ break;
+ case "placesCmd_show:info":
+ this.showBookmarkPropertiesForSelection();
+ break;
+ case "placesCmd_sortBy:name":
+ this.sortFolderByName().catch(Cu.reportError);
+ break;
+ case "placesCmd_createBookmark": {
+ const nodes = this._view.selectedNodes.map(node => {
+ return {
+ uri: Services.io.newURI(node.uri),
+ title: node.title,
+ };
+ });
+ PlacesUIUtils.showBookmarkPagesDialog(
+ nodes,
+ ["keyword", "location"],
+ window.top
+ );
+ break;
+ }
+ case "placesCmd_showInFolder":
+ this.showInFolder(this._view.selectedNode.bookmarkGuid);
+ break;
+ }
+ },
+
+ onEvent: function PC_onEvent(eventName) {},
+
+ /**
+ * Determine whether or not the selection can be removed, either by the
+ * delete or cut operations based on whether or not any of its contents
+ * are non-removable. We don't need to worry about recursion here since it
+ * is a policy decision that a removable item not be placed inside a non-
+ * removable item.
+ *
+ * @returns {boolean} true if all nodes in the selection can be removed,
+ * false otherwise.
+ */
+ _hasRemovableSelection() {
+ var ranges = this._view.removableSelectionRanges;
+ if (!ranges.length) {
+ return false;
+ }
+
+ var root = this._view.result.root;
+
+ for (var j = 0; j < ranges.length; j++) {
+ var nodes = ranges[j];
+ for (var i = 0; i < nodes.length; ++i) {
+ // Disallow removing the view's root node
+ if (nodes[i] == root) {
+ return false;
+ }
+
+ if (!PlacesUIUtils.canUserRemove(nodes[i])) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ },
+
+ /**
+ * Gathers information about the selected nodes according to the following
+ * rules:
+ * "link" node is a URI
+ * "bookmark" node is a bookmark
+ * "tagChild" node is a child of a tag
+ * "folder" node is a folder
+ * "query" node is a query
+ * "separator" node is a separator line
+ * "host" node is a host
+ *
+ * @returns {Array} an array of objects corresponding the selected nodes. Each
+ * object has each of the properties above set if its corresponding
+ * node matches the rule. In addition, the annotations names for each
+ * node are set on its corresponding object as properties.
+ * Notes:
+ * 1) This can be slow, so don't call it anywhere performance critical!
+ */
+ _buildSelectionMetadata() {
+ return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n));
+ },
+
+ _selectionMetadataForNode(node) {
+ let nodeData = {};
+ // We don't use the nodeIs* methods here to avoid going through the type
+ // property way too often
+ switch (node.type) {
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY:
+ nodeData.query = true;
+ if (node.parent) {
+ switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) {
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY:
+ nodeData.query_host = true;
+ break;
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY:
+ nodeData.query_day = true;
+ break;
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT:
+ nodeData.query_tag = true;
+ }
+ }
+ break;
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER:
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT:
+ nodeData.folder = true;
+ break;
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR:
+ nodeData.separator = true;
+ break;
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI:
+ nodeData.link = true;
+ if (PlacesUtils.nodeIsBookmark(node)) {
+ nodeData.link_bookmark = true;
+ var parentNode = node.parent;
+ if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) {
+ nodeData.link_bookmark_tag = true;
+ }
+ }
+ break;
+ }
+ return nodeData;
+ },
+
+ /**
+ * Determines if a context-menu item should be shown
+ *
+ * @param {object} aMenuItem
+ * the context menu item
+ * @param {object} aMetaData
+ * meta data about the selection
+ * @returns {boolean} true if the conditions (see buildContextMenu) are satisfied
+ * and the item can be displayed, false otherwise.
+ */
+ _shouldShowMenuItem(aMenuItem, aMetaData) {
+ if (
+ aMenuItem.hasAttribute("hide-if-private-browsing") &&
+ !PrivateBrowsingUtils.enabled
+ ) {
+ return false;
+ }
+
+ if (
+ aMenuItem.hasAttribute("hide-if-usercontext-disabled") &&
+ !Services.prefs.getBoolPref("privacy.userContext.enabled", false)
+ ) {
+ return false;
+ }
+
+ let selectiontype =
+ aMenuItem.getAttribute("selection-type") || "single|multiple";
+
+ var selectionTypes = selectiontype.split("|");
+ if (selectionTypes.includes("any")) {
+ return true;
+ }
+ var count = aMetaData.length;
+ if (count > 1 && !selectionTypes.includes("multiple")) {
+ return false;
+ }
+ if (count == 1 && !selectionTypes.includes("single")) {
+ return false;
+ }
+ // If there is no selection and selectionType doesn't include `none`
+ // hide the item, otherwise try to use the root node to extract valid
+ // metadata to compare against.
+ if (count == 0) {
+ if (!selectionTypes.includes("none")) {
+ return false;
+ }
+ aMetaData = [this._selectionMetadataForNode(this._view.result.root)];
+ }
+
+ let attr = aMenuItem.getAttribute("hide-if-node-type");
+ if (attr) {
+ let rules = attr.split("|");
+ if (aMetaData.some(d => rules.some(r => r in d))) {
+ return false;
+ }
+ }
+
+ attr = aMenuItem.getAttribute("hide-if-node-type-is-only");
+ if (attr) {
+ let rules = attr.split("|");
+ if (rules.some(r => aMetaData.every(d => r in d))) {
+ return false;
+ }
+ }
+
+ attr = aMenuItem.getAttribute("node-type");
+ if (!attr) {
+ return true;
+ }
+
+ let anyMatched = false;
+ let rules = attr.split("|");
+ for (let metaData of aMetaData) {
+ if (rules.some(r => r in metaData)) {
+ anyMatched = true;
+ } else {
+ return false;
+ }
+ }
+ return anyMatched;
+ },
+
+ /**
+ * Uses meta-data rules set as attributes on the menuitems, representing the
+ * current selection in the view (see `_buildSelectionMetadata`) and sets the
+ * visibility state for each menuitem according to the following rules:
+ * 1) The visibility state is unchanged if none of the attributes are set.
+ * 2) Attributes should not be set on menuseparators.
+ * 3) The boolean `ignore-item` attribute may be set when this code should
+ * not handle that menuitem.
+ * 4) The `selection-type` attribute may be set to:
+ * - `single` if it should be visible only when there is a single node
+ * selected
+ * - `multiple` if it should be visible only when multiple nodes are
+ * selected
+ * - `none` if it should be visible when there are no selected nodes
+ * - `any` if it should be visible for any kind of selection
+ * - a `|` separated combination of the above.
+ * 5) The `node-type` attribute may be set to values representing the
+ * type of the node triggering the context menu. The menuitem will be
+ * visible when one of the rules (separated by `|`) matches.
+ * In case of multiple selection, the menuitem is visible only if all of
+ * the selected nodes match one of the rule.
+ * 6) The `hide-if-node-type` accepts the same rules as `node-type`, but
+ * hides the menuitem if the nodes match at least one of the rules.
+ * It takes priority over `nodetype`.
+ * 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but
+ * hides the menuitem if any of the rules match all of the nodes.
+ * 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a
+ * menuitem when there's no insertion point. An insertion point represents
+ * a point in the view where a new item can be inserted.
+ * 9) The boolean `hide-if-private-browsing` attribute may be set to hide a
+ * menuitem in private browsing mode
+ * 10) The boolean `hide-if-single-click-opens` attribute may be set to hide a
+ * menuitem in views opening entries with a single click.
+ *
+ * @param {object} aPopup
+ * The menupopup to build children into.
+ * @returns {boolean} true if at least one item is visible, false otherwise.
+ */
+ buildContextMenu(aPopup) {
+ var metadata = this._buildSelectionMetadata();
+ var ip = this._view.insertionPoint;
+ var noIp = !ip || ip.isTag;
+
+ var separator = null;
+ var visibleItemsBeforeSep = false;
+ var usableItemCount = 0;
+ for (var i = 0; i < aPopup.children.length; ++i) {
+ var item = aPopup.children[i];
+ if (item.getAttribute("ignore-item") == "true") {
+ continue;
+ }
+ if (item.localName != "menuseparator") {
+ // We allow pasting into tag containers, so special case that.
+ let hideIfNoIP =
+ item.getAttribute("hide-if-no-insertion-point") == "true" &&
+ noIp &&
+ !(ip && ip.isTag && item.id == "placesContext_paste");
+ let hideIfPrivate =
+ item.getAttribute("hide-if-private-browsing") == "true" &&
+ PrivateBrowsingUtils.isWindowPrivate(window);
+ // Hide `Open` if the primary action on click is opening.
+ let hideIfSingleClickOpens =
+ item.getAttribute("hide-if-single-click-opens") == "true" &&
+ !PlacesUIUtils.loadBookmarksInBackground &&
+ !PlacesUIUtils.loadBookmarksInTabs &&
+ this._view.singleClickOpens;
+ let hideIfNotSearch =
+ item.getAttribute("hide-if-not-search") == "true" &&
+ (!this._view.selectedNode ||
+ !this._view.selectedNode.parent ||
+ !PlacesUtils.nodeIsQuery(this._view.selectedNode.parent));
+
+ let shouldHideItem =
+ hideIfNoIP ||
+ hideIfPrivate ||
+ hideIfSingleClickOpens ||
+ hideIfNotSearch ||
+ !this._shouldShowMenuItem(item, metadata);
+ item.hidden = item.disabled = shouldHideItem;
+
+ if (!item.hidden) {
+ visibleItemsBeforeSep = true;
+ usableItemCount++;
+
+ // Show the separator above the menu-item if any
+ if (separator) {
+ separator.hidden = false;
+ separator = null;
+ }
+ }
+ } else {
+ // menuseparator
+ // Initially hide it. It will be unhidden if there will be at least one
+ // visible menu-item above and below it.
+ item.hidden = true;
+
+ // We won't show the separator at all if no items are visible above it
+ if (visibleItemsBeforeSep) {
+ separator = item;
+ }
+
+ // New separator, count again:
+ visibleItemsBeforeSep = false;
+ }
+
+ if (item.id === "placesContext_deleteBookmark") {
+ document.l10n.setAttributes(item, "places-delete-bookmark", {
+ count: metadata.length,
+ });
+ }
+ if (item.id === "placesContext_deleteFolder") {
+ document.l10n.setAttributes(item, "places-delete-folder", {
+ count: metadata.length,
+ });
+ }
+ }
+
+ // Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible
+ if (usableItemCount > 0) {
+ let openContainerInTabsItem = document.getElementById(
+ "placesContext_openContainer:tabs"
+ );
+ let openBookmarksItem = document.getElementById(
+ "placesContext_openBookmarkContainer:tabs"
+ );
+ for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) {
+ if (!menuItem.hidden) {
+ var containerToUse =
+ this._view.selectedNode || this._view.result.root;
+ if (PlacesUtils.nodeIsContainer(containerToUse)) {
+ if (!PlacesUtils.hasChildURIs(containerToUse)) {
+ menuItem.disabled = true;
+ // Ensure that we don't display the menu if nothing is enabled:
+ usableItemCount--;
+ }
+ }
+ }
+ }
+ }
+
+ // Make sure to display the correct string when multiple pages are selected.
+ let stringId = metadata.length === 1 ? "SinglePage" : "MultiplePages";
+
+ let deleteHistoryItem = document.getElementById(
+ "placesContext_delete_history"
+ );
+ deleteHistoryItem.label = PlacesUIUtils.getString(
+ `cmd.delete${stringId}.label`
+ );
+ deleteHistoryItem.accessKey = PlacesUIUtils.getString(
+ `cmd.delete${stringId}.accesskey`
+ );
+
+ let createBookmarkItem = document.getElementById(
+ "placesContext_createBookmark"
+ );
+ createBookmarkItem.label = PlacesUIUtils.getString(
+ `cmd.bookmark${stringId}2.label`
+ );
+ createBookmarkItem.accessKey = PlacesUIUtils.getString(
+ `cmd.bookmark${stringId}2.accesskey`
+ );
+
+ return usableItemCount > 0;
+ },
+
+ /**
+ * Select all links in the current view.
+ */
+ selectAll: function PC_selectAll() {
+ this._view.selectAll();
+ },
+
+ /**
+ * Opens the bookmark properties for the selected URI Node.
+ */
+ showBookmarkPropertiesForSelection() {
+ let node = this._view.selectedNode;
+ if (!node) {
+ return;
+ }
+
+ PlacesUIUtils.showBookmarkDialog(
+ { action: "edit", node, hiddenRows: ["folderPicker"] },
+ window.top
+ );
+ },
+
+ /**
+ * Opens the links in the selected folder, or the selected links in new tabs.
+ *
+ * @param {object} aEvent
+ * The associated event.
+ */
+ openSelectionInTabs: function PC_openLinksInTabs(aEvent) {
+ var node = this._view.selectedNode;
+ var nodes = this._view.selectedNodes;
+ // In the case of no selection, open the root node:
+ if (!node && !nodes.length) {
+ node = this._view.result.root;
+ }
+ PlacesUIUtils.openMultipleLinksInTabs(
+ node ? node : nodes,
+ aEvent,
+ this._view
+ );
+ },
+
+ /**
+ * Shows the Add Bookmark UI for the current insertion point.
+ *
+ * @param {string} aType
+ * the type of the new item (bookmark/folder)
+ */
+ async newItem(aType) {
+ let ip = this._view.insertionPoint;
+ if (!ip) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+
+ let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog(
+ {
+ action: "add",
+ type: aType,
+ defaultInsertionPoint: ip,
+ hiddenRows: ["folderPicker"],
+ },
+ window.top
+ );
+ if (bookmarkGuid) {
+ this._view.selectItems([bookmarkGuid], false);
+ }
+ },
+
+ /**
+ * Create a new Bookmark separator somewhere.
+ */
+ async newSeparator() {
+ var ip = this._view.insertionPoint;
+ if (!ip) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+
+ let index = await ip.getIndex();
+ let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index });
+ let guid = await txn.transact();
+ // Select the new item.
+ this._view.selectItems([guid], false);
+ },
+
+ /**
+ * Sort the selected folder by name
+ */
+ async sortFolderByName() {
+ let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode);
+ await PlacesTransactions.SortByName(guid).transact();
+ },
+
+ /**
+ * Walk the list of folders we're removing in this delete operation, and
+ * see if the selected node specified is already implicitly being removed
+ * because it is a child of that folder.
+ *
+ * @param {object} node
+ * Node to check for containment.
+ * @param {Array} pastFolders
+ * List of folders the calling function has already traversed
+ * @returns {boolean} true if the node should be skipped, false otherwise.
+ */
+ _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) {
+ /**
+ * Determines if a node is contained by another node within a resultset.
+ *
+ * @param {object} parent
+ * The parent container to check for containment in
+ * @returns {boolean} true if node is a member of parent's children, false otherwise.
+ */
+ function isNodeContainedBy(parent) {
+ var cursor = node.parent;
+ while (cursor) {
+ if (cursor == parent) {
+ return true;
+ }
+ cursor = cursor.parent;
+ }
+ return false;
+ }
+
+ for (var j = 0; j < pastFolders.length; ++j) {
+ if (isNodeContainedBy(pastFolders[j])) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Creates a set of transactions for the removal of a range of items.
+ * A range is an array of adjacent nodes in a view.
+ *
+ * @param {Array} range
+ * An array of nodes to remove. Should all be adjacent.
+ * @param {Array} transactions
+ * An array of transactions (returned)
+ * @param {Array} [removedFolders]
+ * An array of folder nodes that have already been removed.
+ * @returns {number} The total number of items affected.
+ */
+ async _removeRange(range, transactions, removedFolders) {
+ if (!(transactions instanceof Array)) {
+ throw new Error("Must pass a transactions array");
+ }
+ if (!removedFolders) {
+ removedFolders = [];
+ }
+
+ let bmGuidsToRemove = [];
+ let totalItems = 0;
+
+ for (var i = 0; i < range.length; ++i) {
+ var node = range[i];
+ if (this._shouldSkipNode(node, removedFolders)) {
+ continue;
+ }
+
+ totalItems++;
+
+ if (PlacesUtils.nodeIsTagQuery(node.parent)) {
+ // This is a uri node inside a tag container. It needs a special
+ // untag transaction.
+ let tag = node.parent.title || "";
+ if (!tag) {
+ // The parent may be the root node, that doesn't have a title.
+ tag = node.parent.query.tags[0];
+ }
+ transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag }));
+ } else if (
+ PlacesUtils.nodeIsTagQuery(node) &&
+ node.parent &&
+ PlacesUtils.nodeIsQuery(node.parent) &&
+ PlacesUtils.asQuery(node.parent).queryOptions.resultType ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT
+ ) {
+ // This is a tag container.
+ // Untag all URIs tagged with this tag only if the tag container is
+ // child of the "Tags" query in the library, in all other places we
+ // must only remove the query node.
+ let tag = node.title;
+ let urls = new Set();
+ await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b =>
+ urls.add(b.url)
+ );
+ transactions.push(
+ PlacesTransactions.Untag({ tag, urls: Array.from(urls) })
+ );
+ } else if (
+ PlacesUtils.nodeIsURI(node) &&
+ PlacesUtils.nodeIsQuery(node.parent) &&
+ PlacesUtils.asQuery(node.parent).queryOptions.queryType ==
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ // This is a uri node inside an history query.
+ await PlacesUtils.history.remove(node.uri).catch(Cu.reportError);
+ // History deletes are not undoable, so we don't have a transaction.
+ } else if (
+ node.itemId == -1 &&
+ PlacesUtils.nodeIsQuery(node) &&
+ PlacesUtils.asQuery(node).queryOptions.queryType ==
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ // This is a dynamically generated history query, like queries
+ // grouped by site, time or both. Dynamically generated queries don't
+ // have an itemId even if they are descendants of a bookmark.
+ await this._removeHistoryContainer(node).catch(Cu.reportError);
+ // History deletes are not undoable, so we don't have a transaction.
+ } else {
+ // This is a common bookmark item.
+ if (PlacesUtils.nodeIsFolder(node)) {
+ // If this is a folder we add it to our array of folders, used
+ // to skip nodes that are children of an already removed folder.
+ removedFolders.push(node);
+ }
+ bmGuidsToRemove.push(node.bookmarkGuid);
+ }
+ }
+ if (bmGuidsToRemove.length) {
+ transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove }));
+ }
+ return totalItems;
+ },
+
+ async _removeRowsFromBookmarks() {
+ let ranges = this._view.removableSelectionRanges;
+ let transactions = [];
+ let removedFolders = [];
+ let totalItems = 0;
+
+ for (let range of ranges) {
+ totalItems += await this._removeRange(
+ range,
+ transactions,
+ removedFolders
+ );
+ }
+
+ if (transactions.length) {
+ await PlacesUIUtils.batchUpdatesForNode(
+ this._view.result,
+ totalItems,
+ async () => {
+ await PlacesTransactions.batch(transactions);
+ }
+ );
+ }
+ },
+
+ /**
+ * Removes the set of selected ranges from history, asynchronously. History
+ * deletes are not undoable.
+ */
+ async _removeRowsFromHistory() {
+ let nodes = this._view.selectedNodes;
+ let URIs = new Set();
+ for (let i = 0; i < nodes.length; ++i) {
+ let node = nodes[i];
+ if (PlacesUtils.nodeIsURI(node)) {
+ URIs.add(node.uri);
+ } else if (
+ PlacesUtils.nodeIsQuery(node) &&
+ PlacesUtils.asQuery(node).queryOptions.queryType ==
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ await this._removeHistoryContainer(node).catch(Cu.reportError);
+ }
+ }
+
+ if (URIs.size) {
+ await PlacesUIUtils.batchUpdatesForNode(
+ this._view.result,
+ URIs.size,
+ async () => {
+ await PlacesUtils.history.remove([...URIs]);
+ }
+ );
+ }
+ },
+
+ /**
+ * Removes history visits for an history container node. History deletes are
+ * not undoable.
+ *
+ * @param {object} aContainerNode
+ * The container node to remove.
+ */
+ async _removeHistoryContainer(aContainerNode) {
+ if (PlacesUtils.nodeIsHost(aContainerNode)) {
+ // This is a site container.
+ // Check if it's the container for local files (don't be fooled by the
+ // bogus string name, this is "(local files)").
+ let host =
+ "." +
+ (aContainerNode.title == PlacesUtils.getString("localhost")
+ ? ""
+ : aContainerNode.title);
+ // Will update faster if all children hidden before removing
+ aContainerNode.containerOpen = false;
+ await PlacesUtils.history.removeByFilter({ host });
+ } else if (PlacesUtils.nodeIsDay(aContainerNode)) {
+ // This is a day container.
+ let query = aContainerNode.query;
+ let beginTime = query.beginTime;
+ let endTime = query.endTime;
+ if (!query || !beginTime || !endTime) {
+ throw new Error("A valid date container query should exist!");
+ }
+ // Will update faster if all children hidden before removing
+ aContainerNode.containerOpen = false;
+ // We want to exclude beginTime from the removal because
+ // removePagesByTimeframe includes both extremes, while date containers
+ // exclude the lower extreme. So, if we would not exclude it, we would
+ // end up removing more history than requested.
+ await PlacesUtils.history.removeByFilter({
+ beginDate: PlacesUtils.toDate(beginTime + 1000),
+ endDate: PlacesUtils.toDate(endTime),
+ });
+ }
+ },
+
+ /**
+ * Removes the selection
+ */
+ async remove() {
+ if (!this._hasRemovableSelection()) {
+ return;
+ }
+
+ var root = this._view.result.root;
+
+ if (PlacesUtils.nodeIsFolder(root)) {
+ await this._removeRowsFromBookmarks();
+ } else if (PlacesUtils.nodeIsQuery(root)) {
+ var queryType = PlacesUtils.asQuery(root).queryOptions.queryType;
+ if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) {
+ await this._removeRowsFromBookmarks();
+ } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) {
+ await this._removeRowsFromHistory();
+ } else {
+ throw new Error("implement support for QUERY_TYPE_UNIFIED");
+ }
+ } else {
+ throw new Error("unexpected root");
+ }
+ },
+
+ /**
+ * Fills a DataTransfer object with the content of the selection that can be
+ * dropped elsewhere.
+ *
+ * @param {object} aEvent
+ * The dragstart event.
+ */
+ setDataTransfer: function PC_setDataTransfer(aEvent) {
+ let dt = aEvent.dataTransfer;
+
+ let result = this._view.result;
+ let didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = true;
+ }
+
+ function addData(type, index) {
+ let wrapNode = PlacesUtils.wrapNode(node, type);
+ dt.mozSetDataAt(type, wrapNode, index);
+ }
+
+ function addURIData(index) {
+ addData(PlacesUtils.TYPE_X_MOZ_URL, index);
+ addData(PlacesUtils.TYPE_UNICODE, index);
+ addData(PlacesUtils.TYPE_HTML, index);
+ }
+
+ try {
+ let nodes = this._view.draggableSelection;
+ for (let i = 0; i < nodes.length; ++i) {
+ var node = nodes[i];
+
+ // This order is _important_! It controls how this and other
+ // applications select data to be inserted based on type.
+ addData(PlacesUtils.TYPE_X_MOZ_PLACE, i);
+ if (node.uri) {
+ addURIData(i);
+ }
+ }
+ } finally {
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = false;
+ }
+ }
+ },
+
+ get clipboardAction() {
+ let action = {};
+ let actionOwner;
+ try {
+ let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION);
+ Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard);
+ xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action);
+ [action, actionOwner] = action.value
+ .QueryInterface(Ci.nsISupportsString)
+ .data.split(",");
+ } catch (ex) {
+ // Paste from external sources don't have any associated action, just
+ // fallback to a copy action.
+ return "copy";
+ }
+ // For cuts also check who inited the action, since cuts across different
+ // instances should instead be handled as copies (The sources are not
+ // available for this instance).
+ if (action == "cut" && actionOwner != this.profileName) {
+ action = "copy";
+ }
+
+ return action;
+ },
+
+ _releaseClipboardOwnership: function PC__releaseClipboardOwnership() {
+ if (this.cutNodes.length) {
+ // This clears the logical clipboard, doesn't remove data.
+ Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard);
+ }
+ },
+
+ _clearClipboard: function PC__clearClipboard() {
+ let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ // Empty transferables may cause crashes, so just add an unknown type.
+ const TYPE = "text/x-moz-place-empty";
+ xferable.addDataFlavor(TYPE);
+ xferable.setTransferData(TYPE, PlacesUtils.toISupportsString(""));
+ Services.clipboard.setData(
+ xferable,
+ null,
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ },
+
+ _populateClipboard: function PC__populateClipboard(aNodes, aAction) {
+ // This order is _important_! It controls how this and other applications
+ // select data to be inserted based on type.
+ let contents = [
+ { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] },
+ { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] },
+ { type: PlacesUtils.TYPE_HTML, entries: [] },
+ { type: PlacesUtils.TYPE_UNICODE, entries: [] },
+ ];
+
+ // Avoid handling descendants of a copied node, the transactions take care
+ // of them automatically.
+ let copiedFolders = [];
+ aNodes.forEach(function(node) {
+ if (this._shouldSkipNode(node, copiedFolders)) {
+ return;
+ }
+ if (PlacesUtils.nodeIsFolder(node)) {
+ copiedFolders.push(node);
+ }
+
+ contents.forEach(function(content) {
+ content.entries.push(PlacesUtils.wrapNode(node, content.type));
+ });
+ }, this);
+
+ function addData(type, data) {
+ xferable.addDataFlavor(type);
+ xferable.setTransferData(type, PlacesUtils.toISupportsString(data));
+ }
+
+ let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ let hasData = false;
+ // This order matters here! It controls how this and other applications
+ // select data to be inserted based on type.
+ contents.forEach(function(content) {
+ if (content.entries.length) {
+ hasData = true;
+ let glue =
+ content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl;
+ addData(content.type, content.entries.join(glue));
+ }
+ });
+
+ // Track the exected action in the xferable. This must be the last flavor
+ // since it's the least preferred one.
+ // Enqueue a unique instance identifier to distinguish operations across
+ // concurrent instances of the application.
+ addData(
+ PlacesUtils.TYPE_X_MOZ_PLACE_ACTION,
+ aAction + "," + this.profileName
+ );
+
+ if (hasData) {
+ Services.clipboard.setData(
+ xferable,
+ aAction == "cut" ? this : null,
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+ }
+ },
+
+ _cutNodes: [],
+ get cutNodes() {
+ return this._cutNodes;
+ },
+ set cutNodes(aNodes) {
+ let self = this;
+ function updateCutNodes(aValue) {
+ self._cutNodes.forEach(function(aNode) {
+ self._view.toggleCutNode(aNode, aValue);
+ });
+ }
+
+ updateCutNodes(false);
+ this._cutNodes = aNodes;
+ updateCutNodes(true);
+ },
+
+ /**
+ * Copy Bookmarks and Folders to the clipboard
+ */
+ copy: function PC_copy() {
+ let result = this._view.result;
+ let didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = true;
+ }
+ try {
+ this._populateClipboard(this._view.selectedNodes, "copy");
+ } finally {
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = false;
+ }
+ }
+ },
+
+ /**
+ * Cut Bookmarks and Folders to the clipboard
+ */
+ cut: function PC_cut() {
+ let result = this._view.result;
+ let didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = true;
+ }
+ try {
+ this._populateClipboard(this._view.selectedNodes, "cut");
+ this.cutNodes = this._view.selectedNodes;
+ } finally {
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = false;
+ }
+ }
+ },
+
+ /**
+ * Paste Bookmarks and Folders from the clipboard
+ */
+ async paste() {
+ // No reason to proceed if there isn't a valid insertion point.
+ let ip = this._view.insertionPoint;
+ if (!ip) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+
+ let action = this.clipboardAction;
+
+ let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ // This order matters here! It controls the preferred flavors for this
+ // paste operation.
+ [
+ PlacesUtils.TYPE_X_MOZ_PLACE,
+ PlacesUtils.TYPE_X_MOZ_URL,
+ PlacesUtils.TYPE_UNICODE,
+ ].forEach(type => xferable.addDataFlavor(type));
+
+ Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard);
+
+ // Now get the clipboard contents, in the best available flavor.
+ let data = {},
+ type = {},
+ items = [];
+ try {
+ xferable.getAnyTransferData(type, data);
+ data = data.value.QueryInterface(Ci.nsISupportsString).data;
+ type = type.value;
+ items = PlacesUtils.unwrapNodes(data, type);
+ } catch (ex) {
+ // No supported data exists or nodes unwrap failed, just bail out.
+ return;
+ }
+
+ let doCopy = action == "copy";
+ let itemsToSelect = await PlacesUIUtils.handleTransferItems(
+ items,
+ ip,
+ doCopy,
+ this._view
+ );
+
+ // Cut/past operations are not repeatable, so clear the clipboard.
+ if (action == "cut") {
+ this._clearClipboard();
+ }
+
+ if (itemsToSelect.length) {
+ this._view.selectItems(itemsToSelect, false);
+ }
+ },
+
+ /**
+ * Checks if we can insert into a container.
+ *
+ * @param {object} container
+ * The container were we are want to drop
+ * @returns {boolean}
+ */
+ disallowInsertion(container) {
+ if (!container) {
+ throw new Error("empty container");
+ }
+ // Allow dropping into Tag containers and editable folders.
+ return (
+ !PlacesUtils.nodeIsTagQuery(container) &&
+ (!PlacesUtils.nodeIsFolder(container) ||
+ PlacesUIUtils.isFolderReadOnly(container))
+ );
+ },
+
+ /**
+ * Determines if a node can be moved.
+ *
+ * @param {object} node
+ * A nsINavHistoryResultNode node.
+ * @returns {boolean} True if the node can be moved, false otherwise.
+ */
+ canMoveNode(node) {
+ // Only bookmark items are movable.
+ if (node.itemId == -1) {
+ return false;
+ }
+
+ // Once tags and bookmarked are divorced, the tag-query check should be
+ // removed.
+ let parentNode = node.parent;
+ if (!parentNode) {
+ return false;
+ }
+
+ // Once tags and bookmarked are divorced, the tag-query check should be
+ // removed.
+ if (PlacesUtils.nodeIsTagQuery(parentNode)) {
+ return false;
+ }
+
+ return (
+ (PlacesUtils.nodeIsFolder(parentNode) &&
+ !PlacesUIUtils.isFolderReadOnly(parentNode)) ||
+ PlacesUtils.nodeIsQuery(parentNode)
+ );
+ },
+ async forgetAboutThisSite() {
+ let host;
+ if (PlacesUtils.nodeIsHost(this._view.selectedNode)) {
+ host = this._view.selectedNode.query.domain;
+ } else {
+ host = Services.io.newURI(this._view.selectedNode.uri).host;
+ }
+ let baseDomain;
+ try {
+ baseDomain = Services.eTLD.getBaseDomainFromHost(host);
+ } catch (e) {
+ // If there is no baseDomain we fall back to host
+ }
+ const [title, body, forget] = await document.l10n.formatValues([
+ { id: "places-forget-about-this-site-confirmation-title" },
+ {
+ id: "places-forget-about-this-site-confirmation-msg",
+ args: { hostOrBaseDomain: baseDomain ?? host },
+ },
+ { id: "places-forget-about-this-site-forget" },
+ ]);
+
+ const flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
+ Services.prompt.BUTTON_POS_1_DEFAULT;
+
+ let bag = await Services.prompt.asyncConfirmEx(
+ window.browsingContext,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ title,
+ body,
+ flags,
+ forget,
+ null,
+ null,
+ null,
+ false
+ );
+ if (bag.getProperty("buttonNumClicked") !== 0) {
+ return;
+ }
+
+ if (this.forgetSiteClearByBaseDomain) {
+ await this.ForgetAboutSite.removeDataFromBaseDomain(host);
+ } else {
+ await this.ForgetAboutSite.removeDataFromDomain(host);
+ }
+ },
+
+ showInFolder(aBookmarkGuid) {
+ // Open containing folder in left pane/sidebar bookmark tree
+ let documentUrl = document.documentURI.toLowerCase();
+ if (documentUrl.endsWith("browser.xhtml")) {
+ // We're in a menu or a panel.
+ window.SidebarUI._show("viewBookmarksSidebar").then(() => {
+ let theSidebar = document.getElementById("sidebar");
+ theSidebar.contentDocument
+ .getElementById("bookmarks-view")
+ .selectItems([aBookmarkGuid]);
+ });
+ } else if (documentUrl.includes("sidebar")) {
+ // We're in the sidebar - clear the search box first
+ let searchBox = document.getElementById("search-box");
+ searchBox.value = "";
+ searchBox.doCommand();
+
+ // And go to the node
+ this._view.selectItems([aBookmarkGuid], true);
+ } else {
+ // We're in the bookmark library/manager
+ PlacesUtils.bookmarks
+ .fetch(aBookmarkGuid, null, { includePath: true })
+ .then(b => {
+ let containers = b.path.map(obj => {
+ return obj.guid;
+ });
+ // selectLeftPane looks for literal "AllBookmarks" as a "built-in"
+ containers.splice(0, 0, "AllBookmarks");
+ PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers);
+ this._view.selectItems([aBookmarkGuid], false);
+ });
+ }
+ },
+};
+
+/**
+ * Handles drag and drop operations for views. Note that this is view agnostic!
+ * You should not use PlacesController._view within these methods, since
+ * the view that the item(s) have been dropped on was not necessarily active.
+ * Drop functions are passed the view that is being dropped on.
+ */
+var PlacesControllerDragHelper = {
+ /**
+ * For views using DOM nodes like toolbars, menus and panels, this is the DOM
+ * element currently being dragged over. For other views not handling DOM
+ * nodes, like trees, it is a Places result node instead.
+ */
+ currentDropTarget: null,
+
+ /**
+ * Determines if the mouse is currently being dragged over a child node of
+ * this menu. This is necessary so that the menu doesn't close while the
+ * mouse is dragging over one of its submenus
+ *
+ * @param {object} node
+ * The container node
+ * @returns {boolean} true if the user is dragging over a node within the hierarchy of
+ * the container, false otherwise.
+ */
+ draggingOverChildNode: function PCDH_draggingOverChildNode(node) {
+ let currentNode = this.currentDropTarget;
+ while (currentNode) {
+ if (currentNode == node) {
+ return true;
+ }
+ currentNode = currentNode.parentNode;
+ }
+ return false;
+ },
+
+ /**
+ * @returns {object|null} The current active drag session. Returns null if there is none.
+ */
+ getSession: function PCDH__getSession() {
+ return this.dragService.getCurrentSession();
+ },
+
+ /**
+ * Extract the first accepted flavor from a list of flavors.
+ *
+ * @param {DOMStringList} aFlavors
+ * The flavors list.
+ * @returns {string}
+ */
+ getFirstValidFlavor: function PCDH_getFirstValidFlavor(aFlavors) {
+ for (let i = 0; i < aFlavors.length; i++) {
+ if (PlacesUIUtils.SUPPORTED_FLAVORS.includes(aFlavors[i])) {
+ return aFlavors[i];
+ }
+ }
+
+ // If no supported flavor is found, check if data includes text/plain
+ // contents. If so, request them as text/unicode, a conversion will happen
+ // automatically.
+ if (aFlavors.contains("text/plain")) {
+ return PlacesUtils.TYPE_UNICODE;
+ }
+
+ return null;
+ },
+
+ /**
+ * Determines whether or not the data currently being dragged can be dropped
+ * on a places view.
+ *
+ * @param {object} ip
+ * The insertion point where the items should be dropped.
+ * @param {object} dt
+ * The data transfer object.
+ * @returns {boolean}
+ */
+ canDrop: function PCDH_canDrop(ip, dt) {
+ let dropCount = dt.mozItemCount;
+
+ // Check every dragged item.
+ for (let i = 0; i < dropCount; i++) {
+ let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i));
+ if (!flavor) {
+ return false;
+ }
+
+ // Urls can be dropped on any insertionpoint.
+ // XXXmano: remember that this method is called for each dragover event!
+ // Thus we shouldn't use unwrapNodes here at all if possible.
+ // I think it would be OK to accept bogus data here (e.g. text which was
+ // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and
+ // will just case the actual drop to be a no-op), and only rule out valid
+ // expected cases, which are either unsupported flavors, or items which
+ // cannot be dropped in the current insertionpoint. The last case will
+ // likely force us to use unwrapNodes for the private data types of
+ // places.
+ if (flavor == TAB_DROP_TYPE) {
+ continue;
+ }
+
+ let data = dt.mozGetDataAt(flavor, i);
+ let nodes;
+ try {
+ nodes = PlacesUtils.unwrapNodes(data, flavor);
+ } catch (e) {
+ return false;
+ }
+
+ for (let dragged of nodes) {
+ // Only bookmarks and urls can be dropped into tag containers.
+ if (
+ ip.isTag &&
+ dragged.type != PlacesUtils.TYPE_X_MOZ_URL &&
+ (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE ||
+ (dragged.uri && dragged.uri.startsWith("place:")))
+ ) {
+ return false;
+ }
+
+ // Disallow dropping of a folder on itself or any of its descendants.
+ // This check is done to show an appropriate drop indicator, a stricter
+ // check is done later by the bookmarks API.
+ if (
+ dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER ||
+ (dragged.uri && dragged.uri.startsWith("place:"))
+ ) {
+ let dragOverPlacesNode = this.currentDropTarget;
+ if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) {
+ // If it's a DOM node, it should have a _placesNode expando, or it
+ // may be a static element in a places container, like the [empty]
+ // menuitem.
+ dragOverPlacesNode =
+ dragOverPlacesNode._placesNode ??
+ dragOverPlacesNode.parentNode?._placesNode;
+ }
+
+ // If we couldn't get a target Places result node then we can't check
+ // whether the drag is allowed, just let it go through.
+ if (dragOverPlacesNode) {
+ let guid = dragged.concreteGuid ?? dragged.itemGuid;
+ // Dragging over itself.
+ if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) {
+ return false;
+ }
+ // Dragging over a descendant.
+ for (let ancestor of PlacesUtils.nodeAncestors(
+ dragOverPlacesNode
+ )) {
+ if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) {
+ return false;
+ }
+ }
+ }
+ }
+
+ // Disallow the dropping of multiple bookmarks if they include
+ // a javascript: bookmarklet
+ if (
+ !flavor.startsWith("text/x-moz-place") &&
+ (nodes.length > 1 || dropCount > 1) &&
+ nodes.some(n => n.uri?.startsWith("javascript:"))
+ ) {
+ return false;
+ }
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Handles the drop of one or more items onto a view.
+ *
+ * @param {object} insertionPoint The insertion point where the items should
+ * be dropped.
+ * @param {object} dt The dataTransfer information for the drop.
+ * @param {object} [view] The view or the tree element. This allows
+ * batching to take place.
+ */
+ async onDrop(insertionPoint, dt, view) {
+ let doCopy = ["copy", "link"].includes(dt.dropEffect);
+
+ let dropCount = dt.mozItemCount;
+
+ // Following flavors may contain duplicated data.
+ let duplicable = new Map();
+ duplicable.set(PlacesUtils.TYPE_UNICODE, new Set());
+ duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set());
+
+ // Collect all data from the DataTransfer before processing it, as the
+ // DataTransfer is only valid during the synchronous handling of the `drop`
+ // event handler callback.
+ let nodes = [];
+ let externalDrag = false;
+ for (let i = 0; i < dropCount; ++i) {
+ let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i));
+ if (!flavor) {
+ return;
+ }
+
+ let data = dt.mozGetDataAt(flavor, i);
+ if (duplicable.has(flavor)) {
+ let handled = duplicable.get(flavor);
+ if (handled.has(data)) {
+ continue;
+ }
+ handled.add(data);
+ }
+
+ // Check that the drag/drop is not internal
+ if (i == 0 && !flavor.startsWith("text/x-moz-place")) {
+ externalDrag = true;
+ }
+
+ if (flavor != TAB_DROP_TYPE) {
+ nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)];
+ } else if (
+ XULElement.isInstance(data) &&
+ data.localName == "tab" &&
+ data.ownerGlobal.isChromeWindow
+ ) {
+ let uri = data.linkedBrowser.currentURI;
+ let spec = uri ? uri.spec : "about:blank";
+ nodes.push({
+ uri: spec,
+ title: data.label,
+ type: PlacesUtils.TYPE_X_MOZ_URL,
+ });
+ } else {
+ throw new Error("bogus data was passed as a tab");
+ }
+ }
+
+ // If a multiple urls are being dropped from the urlbar or an external source,
+ // and they include javascript url, not bookmark any of them
+ if (
+ externalDrag &&
+ (nodes.length > 1 || dropCount > 1) &&
+ nodes.some(n => n.uri?.startsWith("javascript:"))
+ ) {
+ throw new Error("Javascript bookmarklet passed with uris");
+ }
+
+ // If a single javascript url is being dropped from the urlbar or an external source,
+ // show the bookmark dialog as a speedbump protection against malicious cases.
+ if (
+ nodes.length == 1 &&
+ externalDrag &&
+ nodes[0].uri?.startsWith("javascript")
+ ) {
+ let uri;
+ try {
+ uri = Services.io.newURI(nodes[0].uri);
+ } catch (ex) {
+ // Invalid uri, we skip this code and the entry will be discarded later.
+ }
+
+ if (uri) {
+ let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog(
+ {
+ action: "add",
+ type: "bookmark",
+ defaultInsertionPoint: insertionPoint,
+ hiddenRows: ["folderPicker"],
+ title: nodes[0].title,
+ uri,
+ },
+ BrowserWindowTracker.getTopWindow() // `window` may be the Library.
+ );
+
+ if (bookmarkGuid && view) {
+ view.selectItems([bookmarkGuid], false);
+ }
+
+ return;
+ }
+ }
+
+ await PlacesUIUtils.handleTransferItems(
+ nodes,
+ insertionPoint,
+ doCopy,
+ view
+ );
+ },
+};
+
+XPCOMUtils.defineLazyServiceGetter(
+ PlacesControllerDragHelper,
+ "dragService",
+ "@mozilla.org/widget/dragservice;1",
+ "nsIDragService"
+);
diff --git a/browser/components/places/content/editBookmark.js b/browser/components/places/content/editBookmark.js
new file mode 100644
index 0000000000..9027247828
--- /dev/null
+++ b/browser/components/places/content/editBookmark.js
@@ -0,0 +1,1156 @@
+/* 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/. */
+
+/* global MozXULElement */
+
+// This is defined in browser.js and only used in the star UI.
+/* global setToolbarVisibility */
+
+/* import-globals-from controller.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CustomizableUI: "resource:///modules/CustomizableUI.jsm",
+});
+
+var gEditItemOverlay = {
+ // Array of PlacesTransactions accumulated by internal changes. It can be used
+ // to wait for completion.
+ transactionPromises: null,
+ _staticFoldersListBuilt: false,
+ _didChangeFolder: false,
+ // Tracks bookmark properties changes in the dialog, allowing external consumers
+ // to either confirm or discard them.
+ _bookmarkState: null,
+ _allTags: null,
+ _tagsUpdatePromise: null,
+ _paneInfo: null,
+ _setPaneInfo(aInitInfo) {
+ if (!aInitInfo) {
+ return (this._paneInfo = null);
+ }
+
+ if ("uris" in aInitInfo && "node" in aInitInfo) {
+ throw new Error("ambiguous pane info");
+ }
+ if (!("uris" in aInitInfo) && !("node" in aInitInfo)) {
+ throw new Error("Neither node nor uris set for pane info");
+ }
+
+ // We either pass a node or uris.
+ let node = "node" in aInitInfo ? aInitInfo.node : null;
+
+ // Since there's no true UI for folder shortcuts (they show up just as their target
+ // folders), when the pane shows for them it's opened in read-only mode, showing the
+ // properties of the target folder.
+ let itemId = node ? node.itemId : -1;
+ let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null;
+ let isItem = itemId != -1;
+ let isFolderShortcut =
+ isItem &&
+ node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
+ let isTag = node && PlacesUtils.nodeIsTagQuery(node);
+ let tag = null;
+ if (isTag) {
+ tag =
+ PlacesUtils.asQuery(node).query.tags.length == 1
+ ? node.query.tags[0]
+ : node.title;
+ }
+ let isURI = node && PlacesUtils.nodeIsURI(node);
+ let uri = isURI || isTag ? Services.io.newURI(node.uri) : null;
+ let title = node ? node.title : null;
+ let isBookmark = isItem && isURI;
+ let bulkTagging = !node;
+ let uris = bulkTagging ? aInitInfo.uris : null;
+ let visibleRows = new Set();
+ let isParentReadOnly = false;
+ let postData = aInitInfo.postData;
+ let parentGuid = null;
+
+ if (node && isItem) {
+ if (
+ !node.parent ||
+ (node.parent.itemId > 0 && !node.parent.bookmarkGuid)
+ ) {
+ throw new Error(
+ "Cannot use an incomplete node to initialize the edit bookmark panel"
+ );
+ }
+ let parent = node.parent;
+ isParentReadOnly = !PlacesUtils.nodeIsFolder(parent);
+ parentGuid = parent.bookmarkGuid;
+ }
+
+ let focusedElement = aInitInfo.focusedElement;
+ let onPanelReady = aInitInfo.onPanelReady;
+
+ return (this._paneInfo = {
+ itemId,
+ itemGuid,
+ parentGuid,
+ isItem,
+ isURI,
+ uri,
+ title,
+ isBookmark,
+ isFolderShortcut,
+ isParentReadOnly,
+ bulkTagging,
+ uris,
+ visibleRows,
+ postData,
+ isTag,
+ focusedElement,
+ onPanelReady,
+ tag,
+ });
+ },
+
+ get initialized() {
+ return this._paneInfo != null;
+ },
+
+ // Backwards-compatibility getters
+ get itemId() {
+ if (
+ !this.initialized ||
+ this._paneInfo.isTag ||
+ this._paneInfo.bulkTagging
+ ) {
+ return -1;
+ }
+ return this._paneInfo.itemId;
+ },
+
+ get uri() {
+ if (!this.initialized) {
+ return null;
+ }
+ if (this._paneInfo.bulkTagging) {
+ return this._paneInfo.uris[0];
+ }
+ return this._paneInfo.uri;
+ },
+
+ get multiEdit() {
+ return this.initialized && this._paneInfo.bulkTagging;
+ },
+
+ // Check if the pane is initialized to show only read-only fields.
+ get readOnly() {
+ // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some
+ // quirky implementation details (the most important being the "smart"
+ // semantics of node.title that makes hard to edit the right entry).
+ // This pane is read-only if:
+ // * the panel is not initialized
+ // * the node is a folder shortcut
+ // * the node is not bookmarked and not a tag container
+ // * the node is child of a read-only container and is not a bookmarked
+ // URI nor a tag container
+ return (
+ !this.initialized ||
+ this._paneInfo.isFolderShortcut ||
+ (!this._paneInfo.isItem && !this._paneInfo.isTag) ||
+ (this._paneInfo.isParentReadOnly &&
+ !this._paneInfo.isBookmark &&
+ !this._paneInfo.isTag)
+ );
+ },
+
+ get didChangeFolder() {
+ return this._didChangeFolder;
+ },
+
+ // the first field which was edited after this panel was initialized for
+ // a certain item
+ _firstEditedField: "",
+
+ _initNamePicker() {
+ if (this._paneInfo.bulkTagging) {
+ throw new Error("_initNamePicker called unexpectedly");
+ }
+
+ // title may by null, which, for us, is the same as an empty string.
+ this._initTextField(
+ this._namePicker,
+ this._paneInfo.title || this._paneInfo.tag || ""
+ );
+ },
+
+ _initLocationField() {
+ if (!this._paneInfo.isURI) {
+ throw new Error("_initLocationField called unexpectedly");
+ }
+ this._initTextField(this._locationField, this._paneInfo.uri.spec);
+ },
+
+ async _initKeywordField(newKeyword = "") {
+ if (!this._paneInfo.isBookmark) {
+ throw new Error("_initKeywordField called unexpectedly");
+ }
+
+ // Reset the field status synchronously now, eventually we'll reinit it
+ // later if we find an existing keyword. This way we can ensure to be in a
+ // consistent status when reusing the panel across different bookmarks.
+ this._keyword = newKeyword;
+ this._initTextField(this._keywordField, newKeyword);
+
+ if (!newKeyword) {
+ let entries = [];
+ await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e =>
+ entries.push(e)
+ );
+ if (entries.length) {
+ // We show an existing keyword if either POST data was not provided, or
+ // if the POST data is the same.
+ let existingKeyword = entries[0].keyword;
+ let postData = this._paneInfo.postData;
+ if (postData) {
+ let sameEntry = entries.find(e => e.postData === postData);
+ existingKeyword = sameEntry ? sameEntry.keyword : "";
+ }
+ if (existingKeyword) {
+ this._keyword = existingKeyword;
+ // Update the text field to the existing keyword.
+ this._initTextField(this._keywordField, this._keyword);
+ }
+ }
+ }
+ },
+
+ async _initAllTags() {
+ this._allTags = new Map();
+ const fetchedTags = await PlacesUtils.bookmarks.fetchTags();
+ for (const tag of fetchedTags) {
+ this._allTags?.set(tag.name.toLowerCase(), tag.name);
+ }
+ },
+
+ /**
+ * Initialize the panel.
+ *
+ * @param {object} aInfo
+ * The initialization info.
+ * @param {object} [aInfo.node]
+ * If aInfo.uris is not specified, this must be specified.
+ * Either a result node or a node-like object representing the item to be edited.
+ * A node-like object must have the following properties (with values that
+ * match exactly those a result node would have):
+ * itemId, bookmarkGuid, uri, title, type.
+ * @param {nsIURI[]} [aInfo.uris]
+ * If aInfo.node is not specified, this must be specified.
+ * An array of uris for bulk tagging.
+ * @param {string[]} [aInfo.hiddenRows]
+ * List of rows to be hidden regardless of the item edited. Possible values:
+ * "title", "location", "keyword", "folderPicker".
+ */
+ async initPanel(aInfo) {
+ if (typeof aInfo != "object" || aInfo === null) {
+ throw new Error("aInfo must be an object.");
+ }
+ if ("node" in aInfo) {
+ try {
+ aInfo.node.type;
+ } catch (e) {
+ // If the lazy loader for |type| generates an exception, it means that
+ // this bookmark could not be loaded. This sometimes happens when tests
+ // create a bookmark by clicking the bookmark star, then try to cleanup
+ // before the bookmark panel has finished opening. Either way, if we
+ // cannot retrieve the bookmark information, we cannot open the panel.
+ return;
+ }
+ }
+
+ // For sanity ensure that the implementer has uninited the panel before
+ // trying to init it again, or we could end up leaking due to observers.
+ if (this.initialized) {
+ this.uninitPanel(false);
+ }
+
+ this._didChangeFolder = false;
+ this.transactionPromises = [];
+
+ let {
+ parentGuid,
+ isItem,
+ isURI,
+ isBookmark,
+ bulkTagging,
+ uris,
+ visibleRows,
+ focusedElement,
+ onPanelReady,
+ } = this._setPaneInfo(aInfo);
+
+ // initPanel can be called multiple times in a row,
+ // and awaits Promises. If the reference to `instance`
+ // changes, it must mean another caller has called
+ // initPanel again, so bail out of the initialization.
+ let instance = (this._instance = {});
+
+ // If we're creating a new item on the toolbar, show it:
+ if (
+ aInfo.isNewBookmark &&
+ parentGuid == PlacesUtils.bookmarks.toolbarGuid
+ ) {
+ this._autoshowBookmarksToolbar();
+ }
+
+ let showOrCollapse = (
+ rowId,
+ isAppropriateForInput,
+ nameInHiddenRows = null
+ ) => {
+ let visible = isAppropriateForInput;
+ if (visible && "hiddenRows" in aInfo && nameInHiddenRows) {
+ visible &= !aInfo.hiddenRows.includes(nameInHiddenRows);
+ }
+ if (visible) {
+ visibleRows.add(rowId);
+ }
+ const cells = document.getElementsByClassName("editBMPanel_" + rowId);
+ for (const cell of cells) {
+ cell.hidden = !visible;
+ }
+ return visible;
+ };
+
+ if (showOrCollapse("nameRow", !bulkTagging, "name")) {
+ this._initNamePicker();
+ this._namePicker.readOnly = this.readOnly;
+ }
+
+ // In some cases we want to hide the location field, since it's not
+ // human-readable, but we still want to initialize it.
+ showOrCollapse("locationRow", isURI, "location");
+ if (isURI) {
+ this._initLocationField();
+ this._locationField.readOnly = this.readOnly;
+ }
+
+ if (showOrCollapse("keywordRow", isBookmark, "keyword")) {
+ await this._initKeywordField().catch(Cu.reportError);
+ // paneInfo can be null if paneInfo is uninitialized while
+ // the process above is awaiting initialization
+ if (instance != this._instance || this._paneInfo == null) {
+ return;
+ }
+ this._keywordField.readOnly = this.readOnly;
+ }
+
+ // Collapse the tag selector if the item does not accept tags.
+ if (showOrCollapse("tagsRow", isURI || bulkTagging, "tags")) {
+ this._initTagsField();
+ } else if (!this._element("tagsSelectorRow").hidden) {
+ this.toggleTagsSelector().catch(Cu.reportError);
+ }
+
+ // Folder picker.
+ // Technically we should check that the item is not moveable, but that's
+ // not cheap (we don't always have the parent), and there's no use case for
+ // this (it's only the Star UI that shows the folderPicker)
+ if (showOrCollapse("folderRow", isItem, "folderPicker")) {
+ await this._initFolderMenuList(parentGuid).catch(Cu.reportError);
+ if (instance != this._instance || this._paneInfo == null) {
+ return;
+ }
+ }
+
+ // Selection count.
+ if (showOrCollapse("selectionCount", bulkTagging)) {
+ this._element(
+ "itemsCountText"
+ ).value = PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ uris.length,
+ [uris.length]
+ );
+ }
+
+ let focusElement = () => {
+ // The focusedElement possible values are:
+ // * preferred: focus the field that the user touched first the last
+ // time the pane was shown (either namePicker or tagsField)
+ // * first: focus the first non hidden input
+ // Note: since all controls are hidden by default, we don't get the
+ // default XUL dialog behavior, that selects the first control, so we set
+ // the focus explicitly.
+ let elt;
+ if (focusedElement === "preferred") {
+ elt = this._element(
+ Services.prefs.getCharPref(
+ "browser.bookmarks.editDialog.firstEditField"
+ )
+ );
+ } else if (focusedElement === "first") {
+ elt = document.querySelector('input:not([hidden="true"])');
+ }
+ if (elt) {
+ elt.focus({ preventScroll: true });
+ elt.select();
+ }
+ };
+
+ if (onPanelReady) {
+ onPanelReady(focusElement);
+ } else {
+ focusElement();
+ }
+
+ if (this._tagsUpdatePromise) {
+ await this._tagsUpdatePromise;
+ }
+
+ this._bookmarkState = this.makeNewStateObject();
+ if (isBookmark || bulkTagging) {
+ await this._initAllTags();
+ await this._rebuildTagsSelectorList();
+ }
+ },
+
+ /**
+ * Finds tags that are in common among this._currentInfo.uris;
+ *
+ * @returns {string[]}
+ */
+ _getCommonTags() {
+ if ("_cachedCommonTags" in this._paneInfo) {
+ return this._paneInfo._cachedCommonTags;
+ }
+
+ let uris = [...this._paneInfo.uris];
+ let firstURI = uris.shift();
+ let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI));
+ if (commonTags.size == 0) {
+ return (this._cachedCommonTags = []);
+ }
+
+ for (let uri of uris) {
+ let curentURITags = PlacesUtils.tagging.getTagsForURI(uri);
+ for (let tag of commonTags) {
+ if (!curentURITags.includes(tag)) {
+ commonTags.delete(tag);
+ if (commonTags.size == 0) {
+ return (this._paneInfo.cachedCommonTags = []);
+ }
+ }
+ }
+ }
+ return (this._paneInfo._cachedCommonTags = [...commonTags]);
+ },
+
+ _initTextField(aElement, aValue) {
+ if (aElement.value != aValue) {
+ aElement.value = aValue;
+
+ // Clear the editor's undo stack
+ // FYI: editor may be null.
+ aElement.editor?.clearUndoRedo();
+ }
+ },
+
+ /**
+ * Appends a menu-item representing a bookmarks folder to a menu-popup.
+ *
+ * @param {DOMElement} aMenupopup
+ * The popup to which the menu-item should be added.
+ * @param {string} aFolderGuid
+ * The identifier of the bookmarks folder.
+ * @param {string} aTitle
+ * The title to use as a label.
+ * @returns {DOMElement}
+ * The new menu item.
+ */
+ _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) {
+ // First make sure the folders-separator is visible
+ this._element("foldersSeparator").hidden = false;
+
+ var folderMenuItem = document.createXULElement("menuitem");
+ folderMenuItem.folderGuid = aFolderGuid;
+ folderMenuItem.setAttribute("label", aTitle);
+ folderMenuItem.className = "menuitem-iconic folder-icon";
+ aMenupopup.appendChild(folderMenuItem);
+ return folderMenuItem;
+ },
+
+ async _initFolderMenuList(aSelectedFolderGuid) {
+ // clean up first
+ var menupopup = this._folderMenuList.menupopup;
+ while (menupopup.children.length > 6) {
+ menupopup.removeChild(menupopup.lastElementChild);
+ }
+
+ // Build the static list
+ if (!this._staticFoldersListBuilt) {
+ let unfiledItem = this._element("unfiledRootItem");
+ unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle");
+ unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid;
+ let bmMenuItem = this._element("bmRootItem");
+ bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle");
+ bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid;
+ let toolbarItem = this._element("toolbarFolderItem");
+ toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle");
+ toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid;
+ this._staticFoldersListBuilt = true;
+ }
+
+ // List of recently used folders:
+ let lastUsedFolderGuids = await PlacesUtils.metadata.get(
+ PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
+ []
+ );
+
+ /**
+ * The list of last used folders is sorted in most-recent first order.
+ *
+ * First we build the annotated folders array, each item has both the
+ * folder identifier and the time at which it was last-used by this dialog
+ * set. Then we sort it descendingly based on the time field.
+ */
+ this._recentFolders = [];
+ for (let guid of lastUsedFolderGuids) {
+ let bm = await PlacesUtils.bookmarks.fetch(guid);
+ if (bm) {
+ let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
+ this._recentFolders.push({ guid, title });
+ }
+ }
+
+ var numberOfItems = Math.min(
+ PlacesUIUtils.maxRecentFolders,
+ this._recentFolders.length
+ );
+ for (let i = 0; i < numberOfItems; i++) {
+ await this._appendFolderItemToMenupopup(
+ menupopup,
+ this._recentFolders[i].guid,
+ this._recentFolders[i].title
+ );
+ }
+
+ let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title;
+ var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title);
+ this._folderMenuList.selectedItem = defaultItem;
+ // Ensure the selectedGuid attribute is set correctly (the above line wouldn't
+ // necessary trigger a select event, so handle it manually, then add the
+ // listener).
+ this._onFolderListSelected();
+
+ this._folderMenuList.addEventListener("select", this);
+ this._folderMenuListListenerAdded = true;
+
+ // Hide the folders-separator if no folder is annotated as recently-used
+ this._element("foldersSeparator").hidden = menupopup.children.length <= 6;
+ this._folderMenuList.disabled = this.readOnly;
+ },
+
+ _onFolderListSelected() {
+ // Set a selectedGuid attribute to show special icons
+ let folderGuid = this.selectedFolderGuid;
+ if (folderGuid) {
+ this._folderMenuList.setAttribute("selectedGuid", folderGuid);
+ } else {
+ this._folderMenuList.removeAttribute("selectedGuid");
+ }
+ },
+
+ _element(aID) {
+ return document.getElementById("editBMPanel_" + aID);
+ },
+
+ uninitPanel(aHideCollapsibleElements) {
+ if (aHideCollapsibleElements) {
+ // Hide the folder tree if it was previously visible.
+ var folderTreeRow = this._element("folderTreeRow");
+ if (!folderTreeRow.hidden) {
+ this.toggleFolderTreeVisibility();
+ }
+
+ // Hide the tag selector if it was previously visible.
+ var tagsSelectorRow = this._element("tagsSelectorRow");
+ if (!tagsSelectorRow.hidden) {
+ this.toggleTagsSelector().catch(Cu.reportError);
+ }
+ }
+
+ if (this._folderMenuListListenerAdded) {
+ this._folderMenuList.removeEventListener("select", this);
+ this._folderMenuListListenerAdded = false;
+ }
+
+ this._setPaneInfo(null);
+ this._firstEditedField = "";
+ this._didChangeFolder = false;
+ this.transactionPromises = [];
+ this._bookmarkState = null;
+ this._allTags = null;
+ },
+
+ get selectedFolderGuid() {
+ return (
+ this._folderMenuList.selectedItem &&
+ this._folderMenuList.selectedItem.folderGuid
+ );
+ },
+
+ makeNewStateObject() {
+ if (
+ this._paneInfo.isItem ||
+ this._paneInfo.isTag ||
+ this._paneInfo.bulkTagging
+ ) {
+ const isLibraryWindow =
+ document.documentElement.getAttribute("windowtype") ===
+ "Places:Organizer";
+ const options = { autosave: isLibraryWindow, info: this._paneInfo };
+
+ if (this._paneInfo.isBookmark) {
+ options.tags = this._element("tagsField").value;
+ options.keyword = this._element("keywordField").value;
+ }
+
+ const dialogInfo = window.arguments[0];
+ if (typeof dialogInfo === "object" && dialogInfo.type === "folder") {
+ options.isFolder = true;
+ options.children = dialogInfo.URIList;
+ }
+
+ if (this._paneInfo.bulkTagging) {
+ options.tags = this._element("tagsField").value;
+ }
+
+ return new PlacesUIUtils.BookmarkState(options);
+ }
+ return null;
+ },
+
+ onTagsFieldChange() {
+ // Check for _paneInfo existing as the dialog may be closing but receiving
+ // async updates from unresolved promises.
+ if (
+ this._paneInfo &&
+ (this._paneInfo.isURI || this._paneInfo.bulkTagging)
+ ) {
+ this._updateTags().then(() => {
+ // Check _paneInfo here as we might be closing the dialog.
+ if (this._paneInfo) {
+ this._mayUpdateFirstEditField("tagsField");
+ }
+ }, Cu.reportError);
+ }
+ },
+
+ /**
+ * Handle tag list updates from the input field or selector box.
+ */
+ async _updateTags() {
+ this._tagsUpdatePromise = (async () => {
+ const inputTags = this._getTagsArrayFromTagsInputField();
+ await this._bookmarkState._tagsChanged(inputTags);
+
+ // Ensure the tagsField is in sync, clean it up from empty tags
+ this._initTextField(this._tagsField, inputTags.sort().join(", "), false);
+
+ await this._initAllTags();
+ await this._rebuildTagsSelectorList();
+ })().catch(console.error);
+ await this._tagsUpdatePromise;
+ this._tagsUpdatePromise = null;
+ },
+
+ /**
+ * Stores the first-edit field for this dialog, if the passed-in field
+ * is indeed the first edited field.
+ *
+ * @param {string} aNewField
+ * The id of the field that may be set (without the "editBMPanel_" prefix).
+ */
+ _mayUpdateFirstEditField(aNewField) {
+ // * The first-edit-field behavior is not applied in the multi-edit case
+ // * if this._firstEditedField is already set, this is not the first field,
+ // so there's nothing to do
+ if (this._paneInfo.bulkTagging || this._firstEditedField) {
+ return;
+ }
+
+ this._firstEditedField = aNewField;
+
+ // set the pref
+ Services.prefs.setCharPref(
+ "browser.bookmarks.editDialog.firstEditField",
+ aNewField
+ );
+ },
+
+ async onNamePickerChange() {
+ if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) {
+ return;
+ }
+
+ // Here we update either the item title or its cached static title
+ if (this._paneInfo.isTag) {
+ let tag = this._namePicker.value;
+ if (!tag || tag.includes("&")) {
+ // We don't allow setting an empty title for a tag, restore the old one.
+ this._initNamePicker();
+ return;
+ }
+
+ this._bookmarkState._titleChanged(tag);
+ return;
+ }
+ this._mayUpdateFirstEditField("namePicker");
+ this._bookmarkState._titleChanged(this._namePicker.value);
+ },
+
+ onLocationFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+
+ let newURI;
+ try {
+ newURI = Services.uriFixup.getFixupURIInfo(this._locationField.value)
+ .preferredURI;
+ } catch (ex) {
+ // TODO: Bug 1089141 - Provide some feedback about the invalid url.
+ return;
+ }
+
+ if (this._paneInfo.uri.equals(newURI)) {
+ return;
+ }
+ this._bookmarkState._locationChanged(newURI.spec);
+ },
+
+ onKeywordFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+ this._bookmarkState._keywordChanged(this._keywordField.value);
+ },
+
+ toggleFolderTreeVisibility() {
+ let expander = this._element("foldersExpander");
+ let folderTreeRow = this._element("folderTreeRow");
+ let wasHidden = folderTreeRow.hidden;
+ expander.classList.toggle("expander-up", wasHidden);
+ expander.classList.toggle("expander-down", !wasHidden);
+ if (!wasHidden) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ folderTreeRow.hidden = true;
+ this._element("chooseFolderSeparator").hidden = this._element(
+ "chooseFolderMenuItem"
+ ).hidden = false;
+ // Stop editing if we were (will no-op if not). This avoids permanently
+ // breaking the tree if/when it is reshown.
+ this._folderTree.stopEditing(false);
+ // Unlinking the view will break the connection with the result. We don't
+ // want to pay for live updates while the view is not visible.
+ this._folderTree.view = null;
+ } else {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ folderTreeRow.hidden = false;
+
+ // XXXmano: Ideally we would only do this once, but for some odd reason,
+ // the editable mode set on this tree, together with its hidden state
+ // breaks the view.
+ const FOLDER_TREE_PLACE_URI =
+ "place:excludeItems=1&excludeQueries=1&type=" +
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY;
+ this._folderTree.place = FOLDER_TREE_PLACE_URI;
+
+ this._element("chooseFolderSeparator").hidden = this._element(
+ "chooseFolderMenuItem"
+ ).hidden = true;
+ this._folderTree.selectItems([this._paneInfo.parentGuid]);
+ this._folderTree.focus();
+ }
+ },
+
+ /**
+ * Get the corresponding menu-item in the folder-menu-list for a bookmarks
+ * folder if such an item exists. Otherwise, this creates a menu-item for the
+ * folder. If the items-count limit (see
+ * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the
+ * new item replaces the last menu-item.
+ *
+ * @param {string} aFolderGuid
+ * The identifier of the bookmarks folder.
+ * @param {string} aTitle
+ * The title to use in case of menuitem creation.
+ * @returns {DOMElement}
+ * The handle to the menuitem.
+ */
+ _getFolderMenuItem(aFolderGuid, aTitle) {
+ let menupopup = this._folderMenuList.menupopup;
+ let menuItem = Array.prototype.find.call(
+ menupopup.children,
+ item => item.folderGuid === aFolderGuid
+ );
+ if (menuItem !== undefined) {
+ return menuItem;
+ }
+
+ // 3 special folders + separator + folder-items-count limit
+ if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) {
+ menupopup.removeChild(menupopup.lastElementChild);
+ }
+
+ return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle);
+ },
+
+ async onFolderMenuListCommand(aEvent) {
+ // Check for _paneInfo existing as the dialog may be closing but receiving
+ // async updates from unresolved promises.
+ if (!this._paneInfo) {
+ return;
+ }
+
+ if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") {
+ // reset the selection back to where it was and expand the tree
+ // (this menu-item is hidden when the tree is already visible
+ let item = this._getFolderMenuItem(
+ this._bookmarkState._originalState.parentGuid,
+ this._bookmarkState._originalState.title
+ );
+ this._folderMenuList.selectedItem = item;
+ // XXXmano HACK: setTimeout 100, otherwise focus goes back to the
+ // menulist right away
+ setTimeout(() => this.toggleFolderTreeVisibility(), 100);
+ return;
+ }
+
+ // Move the item
+ let containerGuid = this._folderMenuList.selectedItem.folderGuid;
+ if (
+ this._bookmarkState._originalState.parentGuid != containerGuid &&
+ this._bookmarkState._originalState.title != containerGuid
+ ) {
+ this._bookmarkState._parentGuidChanged(containerGuid);
+
+ // Auto-show the bookmarks toolbar when adding / moving an item there.
+ if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ this._autoshowBookmarksToolbar();
+ }
+
+ // Unless the user cancels the panel, we'll use the chosen folder as
+ // the default for new bookmarks.
+ this._didChangeFolder = true;
+ }
+
+ // Update folder-tree selection
+ var folderTreeRow = this._element("folderTreeRow");
+ if (!folderTreeRow.hidden) {
+ var selectedNode = this._folderTree.selectedNode;
+ if (
+ !selectedNode ||
+ PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid
+ ) {
+ this._folderTree.selectItems([containerGuid]);
+ }
+ }
+ },
+
+ _autoshowBookmarksToolbar() {
+ let neverShowToolbar =
+ Services.prefs.getCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ "newtab"
+ ) == "never";
+ let toolbar = document.getElementById("PersonalToolbar");
+ if (!toolbar.collapsed || neverShowToolbar) {
+ return;
+ }
+
+ let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
+ let area = placement && placement.area;
+ if (area != CustomizableUI.AREA_BOOKMARKS) {
+ return;
+ }
+
+ // Show the toolbar but don't persist it permanently open
+ setToolbarVisibility(toolbar, true, false);
+ },
+
+ onFolderTreeSelect() {
+ // Ignore this event when the folder tree is hidden, even if the tree is
+ // alive, it's clearly not a user activated action.
+ if (this._element("folderTreeRow").hidden) {
+ return;
+ }
+
+ var selectedNode = this._folderTree.selectedNode;
+
+ // Disable the "New Folder" button if we cannot create a new folder
+ this._element("newFolderButton").disabled =
+ !this._folderTree.insertionPoint || !selectedNode;
+
+ if (!selectedNode) {
+ return;
+ }
+
+ var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
+ if (this._folderMenuList.selectedItem.folderGuid == folderGuid) {
+ return;
+ }
+
+ var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title);
+ this._folderMenuList.selectedItem = folderItem;
+ folderItem.doCommand();
+ },
+
+ async _rebuildTagsSelectorList() {
+ let tagsSelector = this._element("tagsSelector");
+ let tagsSelectorRow = this._element("tagsSelectorRow");
+ if (tagsSelectorRow.hidden) {
+ return;
+ }
+
+ let selectedIndex = tagsSelector.selectedIndex;
+ let selectedTag =
+ selectedIndex >= 0 ? tagsSelector.selectedItem.label : null;
+
+ while (tagsSelector.hasChildNodes()) {
+ tagsSelector.removeChild(tagsSelector.lastElementChild);
+ }
+
+ let tagsInField = this._getTagsArrayFromTagsInputField();
+
+ let fragment = document.createDocumentFragment();
+ let sortedTags = this._allTags ? [...this._allTags.values()].sort() : [];
+
+ for (let i = 0; i < sortedTags.length; i++) {
+ let tag = sortedTags[i];
+ let elt = document.createXULElement("richlistitem");
+ elt.appendChild(document.createXULElement("image"));
+ let label = document.createXULElement("label");
+ label.setAttribute("value", tag);
+ elt.appendChild(label);
+ if (tagsInField.includes(tag)) {
+ elt.setAttribute("checked", "true");
+ }
+ fragment.appendChild(elt);
+ if (selectedTag === tag) {
+ selectedIndex = i;
+ }
+ }
+ tagsSelector.appendChild(fragment);
+
+ if (selectedIndex >= 0 && tagsSelector.itemCount > 0) {
+ selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1);
+ tagsSelector.selectedIndex = selectedIndex;
+ tagsSelector.ensureIndexIsVisible(selectedIndex);
+ }
+ let event = new CustomEvent("BookmarkTagsSelectorUpdated", {
+ bubbles: true,
+ });
+ tagsSelector.dispatchEvent(event);
+ },
+
+ async toggleTagsSelector() {
+ var tagsSelector = this._element("tagsSelector");
+ var tagsSelectorRow = this._element("tagsSelectorRow");
+ var expander = this._element("tagsSelectorExpander");
+ expander.classList.toggle("expander-up", tagsSelectorRow.hidden);
+ expander.classList.toggle("expander-down", !tagsSelectorRow.hidden);
+ if (tagsSelectorRow.hidden) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ tagsSelectorRow.hidden = false;
+ await this._rebuildTagsSelectorList();
+
+ // This is a no-op if we've added the listener.
+ tagsSelector.addEventListener("mousedown", this);
+ tagsSelector.addEventListener("keypress", this);
+ } else {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ tagsSelectorRow.hidden = true;
+
+ // This is a no-op if we've removed the listener.
+ tagsSelector.removeEventListener("mousedown", this);
+ tagsSelector.removeEventListener("keypress", this);
+ }
+ },
+
+ /**
+ * Splits "tagsField" element value, returning an array of valid tag strings.
+ *
+ * @returns {string[]}
+ * Array of tag strings found in the field value.
+ */
+ _getTagsArrayFromTagsInputField() {
+ let tags = this._element("tagsField").value;
+ return tags
+ .trim()
+ .split(/\s*,\s*/) // Split on commas and remove spaces.
+ .filter(tag => !!tag.length); // Kill empty tags.
+ },
+
+ async newFolder() {
+ let ip = this._folderTree.insertionPoint;
+
+ // default to the bookmarks menu folder
+ if (!ip) {
+ ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ });
+ }
+
+ // XXXmano: add a separate "New Folder" string at some point...
+ let title = this._element("newFolderButton").label;
+ let promise = PlacesTransactions.NewFolder({
+ parentGuid: ip.guid,
+ title,
+ index: await ip.getIndex(),
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ let guid = await promise;
+
+ this._folderTree.focus();
+ this._folderTree.selectItems([ip.guid]);
+ PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true;
+ this._folderTree.selectItems([guid]);
+ this._folderTree.startEditing(
+ this._folderTree.view.selection.currentIndex,
+ this._folderTree.columns.getFirstColumn()
+ );
+ },
+
+ // EventListener
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousedown":
+ if (event.button == 0) {
+ // Make sure the event is triggered on an item and not the empty space.
+ let item = event.target.closest("richlistbox,richlistitem");
+ if (item.localName == "richlistitem") {
+ this.toggleItemCheckbox(item);
+ }
+ }
+ break;
+ case "keypress":
+ if (event.key == " ") {
+ let item = event.target.currentItem;
+ if (item) {
+ this.toggleItemCheckbox(item);
+ }
+ }
+ break;
+ case "unload":
+ this.uninitPanel(false);
+ break;
+ case "select":
+ this._onFolderListSelected();
+ break;
+ }
+ },
+
+ toggleItemCheckbox(item) {
+ // Update the tags field when items are checked/unchecked in the listbox
+ let tags = this._getTagsArrayFromTagsInputField();
+
+ let curTagIndex = tags.indexOf(item.label);
+ let tagsSelector = this._element("tagsSelector");
+ tagsSelector.selectedItem = item;
+
+ if (!item.hasAttribute("checked")) {
+ item.setAttribute("checked", "true");
+ if (curTagIndex == -1) {
+ tags.push(item.label);
+ }
+ } else {
+ item.removeAttribute("checked");
+ if (curTagIndex != -1) {
+ tags.splice(curTagIndex, 1);
+ }
+ }
+ this._element("tagsField").value = tags.join(", ");
+ this._updateTags();
+ },
+
+ _initTagsField() {
+ let tags;
+ if (this._paneInfo.isURI) {
+ tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
+ } else if (this._paneInfo.bulkTagging) {
+ tags = this._getCommonTags();
+ } else {
+ throw new Error("_promiseTagsStr called unexpectedly");
+ }
+
+ this._initTextField(this._tagsField, tags.join(", "));
+ },
+
+ /**
+ * Flag which signals to consumers that this script is loaded, thus delayed
+ * apply logic should be used.
+ *
+ * @returns {boolean} Always true.
+ */
+ get delayedApplyEnabled() {
+ return true;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => {
+ if (!customElements.get("places-tree")) {
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/places/places-tree.js",
+ window
+ );
+ }
+ gEditItemOverlay._element("folderTreeRow").prepend(
+ MozXULElement.parseXULToFragment(`
+ <tree id="editBMPanel_folderTree"
+ flex="1"
+ class="placesTree"
+ is="places-tree"
+ editable="true"
+ onselect="gEditItemOverlay.onFolderTreeSelect();"
+ disableUserActions="true"
+ hidecolumnpicker="true">
+ <treecols>
+ <treecol anonid="title" flex="1" primary="true" hideheader="true"/>
+ </treecols>
+ <treechildren flex="1"/>
+ </tree>
+ `)
+ );
+ return gEditItemOverlay._element("folderTree");
+});
+
+for (let elt of [
+ "folderMenuList",
+ "namePicker",
+ "locationField",
+ "keywordField",
+ "tagsField",
+]) {
+ let eltScoped = elt;
+ XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () =>
+ gEditItemOverlay._element(eltScoped)
+ );
+}
diff --git a/browser/components/places/content/editBookmarkPanel.inc.xhtml b/browser/components/places/content/editBookmarkPanel.inc.xhtml
new file mode 100644
index 0000000000..3e7e2c3646
--- /dev/null
+++ b/browser/components/places/content/editBookmarkPanel.inc.xhtml
@@ -0,0 +1,126 @@
+# 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/.
+
+<div id="editBookmarkPanelContent">
+ <label id="editBMPanel_itemsCountText"
+ class="editBMPanel_selectionCount"/>
+
+ <label data-l10n-id="bookmark-overlay-name-2"
+ class="editBMPanel_nameRow hideable"
+ control="editBMPanel_namePicker"/>
+ <html:input id="editBMPanel_namePicker"
+ class="editBMPanel_nameRow hideable"
+ type="text"
+ onchange="gEditItemOverlay.onNamePickerChange().catch(Cu.reportError);"/>
+
+ <label data-l10n-id="bookmark-overlay-url"
+ class="editBMPanel_locationRow hideable"
+ control="editBMPanel_locationField"/>
+ <html:input id="editBMPanel_locationField"
+ class="editBMPanel_locationRow uri-element hideable"
+ type="text"
+ onchange="gEditItemOverlay.onLocationFieldChange();"/>
+
+ <label data-l10n-id="bookmark-overlay-location-2"
+ class="editBMPanel_folderRow hideable"
+ control="editBMPanel_folderMenuList"/>
+ <hbox class="editBMPanel_folderRow hideable">
+ <menulist id="editBMPanel_folderMenuList"
+ class="folder-icon"
+ flex="1"
+ size="large"
+ oncommand="gEditItemOverlay.onFolderMenuListCommand(event).catch(Cu.reportError);">
+ <menupopup>
+ <!-- Static item for special folders -->
+ <menuitem id="editBMPanel_toolbarFolderItem"
+ class="menuitem-iconic folder-icon"/>
+ <menuitem id="editBMPanel_bmRootItem"
+ class="menuitem-iconic folder-icon"/>
+ <menuitem id="editBMPanel_unfiledRootItem"
+ class="menuitem-iconic folder-icon"/>
+ <menuseparator id="editBMPanel_chooseFolderSeparator"/>
+ <menuitem id="editBMPanel_chooseFolderMenuItem"
+ data-l10n-id="bookmark-overlay-choose"
+ class="menuitem-iconic folder-icon"/>
+ <menuseparator id="editBMPanel_foldersSeparator" hidden="true"/>
+ </menupopup>
+ </menulist>
+ <button id="editBMPanel_foldersExpander"
+ class="expander-down panel-button"
+ data-l10n-id="bookmark-overlay-folders-expander"
+ data-l10n-attrs="tooltiptextdown, tooltiptextup"
+ oncommand="gEditItemOverlay.toggleFolderTreeVisibility();"/>
+ </hbox>
+
+ <vbox id="editBMPanel_folderTreeRow"
+ class="hideable"
+ hidden="true">
+ <!-- editBMPanel_folderTree will go here when this is shown -->
+ <hbox id="editBMPanel_newFolderBox">
+ <button data-l10n-id="bookmark-overlay-new-folder-button"
+ id="editBMPanel_newFolderButton"
+ oncommand="gEditItemOverlay.newFolder().catch(Cu.reportError);"/>
+ </hbox>
+ </vbox>
+
+ <label data-l10n-id="bookmark-overlay-tags-2"
+ class="editBMPanel_tagsRow hideable"
+ control="editBMPanel_tagsField"/>
+ <hbox class="editBMPanel_tagsRow hideable">
+ <html:input id="editBMPanel_tagsField"
+ type="text"
+ is="autocomplete-input"
+ style="-moz-box-flex: 1;"
+ autocompletesearch="places-tag-autocomplete"
+ autocompletepopup="editBMPanel_tagsAutocomplete"
+ completedefaultindex="true"
+ completeselectedindex="true"
+ tabscrolling="true"
+ data-l10n-id="bookmark-overlay-tags-empty-description"
+ data-l10n-attrs="placeholder"
+ aria-describedby="tags-field-info"
+ onchange="gEditItemOverlay.onTagsFieldChange();"/>
+ <popupset>
+ <panel is="autocomplete-richlistbox-popup"
+ type="autocomplete-richlistbox"
+ id="editBMPanel_tagsAutocomplete"
+ role="group"
+ noautofocus="true"
+ hidden="true"
+ overflowpadding="4"
+ norolluponanchor="true"
+ nomaxresults="true"/>
+ </popupset>
+ <button id="editBMPanel_tagsSelectorExpander"
+ class="expander-down panel-button"
+ data-l10n-id="bookmark-overlay-tags-expander"
+ data-l10n-attrs="tooltiptextdown, tooltiptextup"
+ oncommand="gEditItemOverlay.toggleTagsSelector().catch(Cu.reportError);"/>
+ </hbox>
+
+ <div id="tags-field-info"
+ class="editBMPanel_tagsRow caption-label hideable"
+ data-l10n-id="bookmark-overlay-tags-caption-label"/>
+
+ <div id="editBMPanel_tagsSelectorRow"
+ class="hideable"
+ hidden="true">
+ <richlistbox id="editBMPanel_tagsSelector"
+ style="-moz-box-flex: 1"
+ styled="true"/>
+ </div>
+
+ <label data-l10n-id="bookmark-overlay-keyword-2"
+ class="editBMPanel_keywordRow hideable"
+ control="editBMPanel_keywordField"/>
+ <html:input id="editBMPanel_keywordField"
+ class="editBMPanel_keywordRow hideable"
+ type="text"
+ aria-describedby="keyword-field-info"
+ onchange="gEditItemOverlay.onKeywordFieldChange();"/>
+
+ <div id="keyword-field-info"
+ class="editBMPanel_keywordRow caption-label hideable"
+ data-l10n-id="bookmark-overlay-keyword-caption-label-2"/>
+</div>
diff --git a/browser/components/places/content/historySidebar.js b/browser/components/places/content/historySidebar.js
new file mode 100644
index 0000000000..d0559e4d19
--- /dev/null
+++ b/browser/components/places/content/historySidebar.js
@@ -0,0 +1,173 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* 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/. */
+
+/* Shared Places Import - change other consumers if you change this: */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PlacesTreeView",
+ "chrome://browser/content/places/treeView.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"],
+ "chrome://browser/content/places/controller.js"
+);
+/* End Shared Places Import */
+
+var gHistoryTree;
+var gSearchBox;
+var gHistoryGrouping = "";
+var gCumulativeSearches = 0;
+var gCumulativeFilterCount = 0;
+
+function HistorySidebarInit() {
+ Services.telemetry.keyedScalarAdd("sidebar.opened", "history", 1);
+
+ let uidensity = window.top.document.documentElement.getAttribute("uidensity");
+ if (uidensity) {
+ document.documentElement.setAttribute("uidensity", uidensity);
+ }
+
+ gHistoryTree = document.getElementById("historyTree");
+ gSearchBox = document.getElementById("search-box");
+
+ gHistoryGrouping = document
+ .getElementById("viewButton")
+ .getAttribute("selectedsort");
+
+ this.groupHistogram = Services.telemetry.getHistogramById(
+ "PLACES_SEARCHBAR_FILTER_TYPE"
+ );
+ this.groupHistogram.add(gHistoryGrouping);
+
+ if (gHistoryGrouping == "site") {
+ document.getElementById("bysite").setAttribute("checked", "true");
+ } else if (gHistoryGrouping == "visited") {
+ document.getElementById("byvisited").setAttribute("checked", "true");
+ } else if (gHistoryGrouping == "lastvisited") {
+ document.getElementById("bylastvisited").setAttribute("checked", "true");
+ } else if (gHistoryGrouping == "dayandsite") {
+ document.getElementById("bydayandsite").setAttribute("checked", "true");
+ } else {
+ document.getElementById("byday").setAttribute("checked", "true");
+ }
+
+ searchHistory("");
+}
+
+function GroupBy(groupingType) {
+ if (groupingType != gHistoryGrouping) {
+ this.groupHistogram.add(groupingType);
+ }
+ gHistoryGrouping = groupingType;
+ gCumulativeFilterCount++;
+ searchHistory(gSearchBox.value);
+}
+
+function selectLink(event) {
+ let searchesHistogram = Services.telemetry.getHistogramById(
+ "PLACES_SEARCHBAR_CUMULATIVE_SEARCHES"
+ );
+ searchesHistogram.add(gCumulativeSearches);
+ let filterCountHistogram = Services.telemetry.getHistogramById(
+ "PLACES_SEARCHBAR_CUMULATIVE_FILTER_COUNT"
+ );
+ filterCountHistogram.add(gCumulativeFilterCount);
+ clearCumulativeCounters();
+
+ if (event.type == "click") {
+ PlacesUIUtils.onSidebarTreeClick(event);
+ } else if (event.type == "keypress") {
+ PlacesUIUtils.onSidebarTreeKeyPress(event);
+ }
+}
+
+function searchHistory(aInput) {
+ var query = PlacesUtils.history.getNewQuery();
+ var options = PlacesUtils.history.getNewQueryOptions();
+
+ const NHQO = Ci.nsINavHistoryQueryOptions;
+ var sortingMode;
+ var resultType;
+
+ switch (gHistoryGrouping) {
+ case "visited":
+ resultType = NHQO.RESULTS_AS_URI;
+ sortingMode = NHQO.SORT_BY_VISITCOUNT_DESCENDING;
+ break;
+ case "lastvisited":
+ resultType = NHQO.RESULTS_AS_URI;
+ sortingMode = NHQO.SORT_BY_DATE_DESCENDING;
+ break;
+ case "dayandsite":
+ resultType = NHQO.RESULTS_AS_DATE_SITE_QUERY;
+ break;
+ case "site":
+ resultType = NHQO.RESULTS_AS_SITE_QUERY;
+ sortingMode = NHQO.SORT_BY_TITLE_ASCENDING;
+ break;
+ case "day":
+ default:
+ resultType = NHQO.RESULTS_AS_DATE_QUERY;
+ break;
+ }
+
+ if (aInput) {
+ query.searchTerms = aInput;
+ if (gHistoryGrouping != "visited" && gHistoryGrouping != "lastvisited") {
+ sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING;
+ resultType = NHQO.RESULTS_AS_URI;
+ }
+ }
+
+ options.sortingMode = sortingMode;
+ options.resultType = resultType;
+ options.includeHidden = !!aInput;
+
+ if (gHistoryGrouping == "lastvisited") {
+ TelemetryStopwatch.start("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS");
+ }
+
+ // call load() on the tree manually
+ // instead of setting the place attribute in historySidebar.xhtml
+ // otherwise, we will end up calling load() twice
+ gHistoryTree.load(query, options);
+
+ // Sometimes search is activated without an input string. For example, when
+ // the history sidbar is first opened or when a search filter is selected.
+ // Since we're trying to measure how often the searchbar was used, we should first
+ // check if there's an input string before collecting telemetry.
+ if (aInput) {
+ Services.telemetry.keyedScalarAdd("sidebar.search", "history", 1);
+ gCumulativeSearches++;
+ }
+
+ if (gHistoryGrouping == "lastvisited") {
+ TelemetryStopwatch.finish("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS");
+ }
+}
+
+function clearCumulativeCounters() {
+ gCumulativeSearches = 0;
+ gCumulativeFilterCount = 0;
+}
+
+function unloadHistorySidebar() {
+ clearCumulativeCounters();
+ PlacesUIUtils.setMouseoverURL("", window);
+}
+
+window.addEventListener("SidebarFocused", () => gSearchBox.focus());
diff --git a/browser/components/places/content/historySidebar.xhtml b/browser/components/places/content/historySidebar.xhtml
new file mode 100644
index 0000000000..646efc1489
--- /dev/null
+++ b/browser/components/places/content/historySidebar.xhtml
@@ -0,0 +1,96 @@
+<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- -->
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://browser/content/places/places.css"?>
+<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/sidebar.css"?>
+
+<!DOCTYPE window>
+
+<window id="history-panel"
+ class="sidebar-panel"
+ orient="vertical"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="HistorySidebarInit();"
+ onunload="unloadHistorySidebar();"
+ data-l10n-id="places-history">
+
+ <script src="chrome://browser/content/places/historySidebar.js"/>
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+ <script src="chrome://browser/content/contentTheme.js"/>
+ <script src="chrome://browser/content/places/places-tree.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/places.ftl"/>
+ </linkset>
+
+#include placesCommands.inc.xhtml
+
+#include ../../../../toolkit/content/editMenuKeys.inc.xhtml
+#ifdef XP_MACOSX
+ <keyset id="editMenuKeysExtra">
+ <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/>
+ </keyset>
+#endif
+
+#include placesContextMenu.inc.xhtml
+#include bookmarksHistoryTooltip.inc.xhtml
+
+ <hbox id="sidebar-search-container">
+ <search-textbox id="search-box" flex="1"
+ data-l10n-id="places-history-search"
+ data-l10n-attrs="placeholder"
+ aria-controls="historyTree"
+ oncommand="searchHistory(this.value);"/>
+ <button id="viewButton" style="min-width:0px !important;" type="menu"
+ data-l10n-id="places-view" selectedsort="day"
+ persist="selectedsort">
+ <menupopup>
+ <menuitem id="bydayandsite"
+ data-l10n-id="places-by-day-and-site" type="radio"
+ oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'dayandsite'); GroupBy('dayandsite');"/>
+ <menuitem id="bysite"
+ data-l10n-id="places-by-site"
+ type="radio"
+ oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'site'); GroupBy('site');"/>
+ <menuitem id="byday"
+ data-l10n-id="places-by-date"
+ type="radio"
+ oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'day'); GroupBy('day');"/>
+ <menuitem id="byvisited"
+ data-l10n-id="places-by-most-visited"
+ type="radio"
+ oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'visited'); GroupBy('visited');"/>
+ <menuitem id="bylastvisited"
+ data-l10n-id="places-by-last-visited"
+ type="radio"
+ oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'lastvisited'); GroupBy('lastvisited');"/>
+ </menupopup>
+ </button>
+ </hbox>
+
+ <tree id="historyTree"
+ class="sidebar-placesTree"
+ flex="1"
+ is="places-tree"
+ hidecolumnpicker="true"
+ context="placesContext"
+ singleclickopens="true"
+ onclick="selectLink(event);"
+ onkeypress="selectLink(event);"
+ onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);"
+ onmouseout="PlacesUIUtils.setMouseoverURL('', window);">
+ <treecols>
+ <treecol id="title" flex="1" primary="true" hideheader="true"/>
+ </treecols>
+ <treechildren class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/>
+ </tree>
+</window>
diff --git a/browser/components/places/content/instantEditBookmark.js b/browser/components/places/content/instantEditBookmark.js
new file mode 100644
index 0000000000..067bb1eaaa
--- /dev/null
+++ b/browser/components/places/content/instantEditBookmark.js
@@ -0,0 +1,1396 @@
+/* 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/. */
+
+/* global MozXULElement */
+/* import-globals-from controller.js */
+
+// This is defined in browser.js and only used in the star UI.
+/* global setToolbarVisibility */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ CustomizableUI: "resource:///modules/CustomizableUI.jsm",
+});
+
+var gEditItemOverlay = {
+ // Array of PlacesTransactions accumulated by internal changes. It can be used
+ // to wait for completion.
+ transactionPromises: null,
+ _observersAdded: false,
+ _staticFoldersListBuilt: false,
+ _didChangeFolder: false,
+
+ _paneInfo: null,
+ _setPaneInfo(aInitInfo) {
+ if (!aInitInfo) {
+ return (this._paneInfo = null);
+ }
+
+ if ("uris" in aInitInfo && "node" in aInitInfo) {
+ throw new Error("ambiguous pane info");
+ }
+ if (!("uris" in aInitInfo) && !("node" in aInitInfo)) {
+ throw new Error("Neither node nor uris set for pane info");
+ }
+
+ // We either pass a node or uris.
+ let node = "node" in aInitInfo ? aInitInfo.node : null;
+
+ // Since there's no true UI for folder shortcuts (they show up just as their target
+ // folders), when the pane shows for them it's opened in read-only mode, showing the
+ // properties of the target folder.
+ let itemId = node ? node.itemId : -1;
+ let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null;
+ let isItem = itemId != -1;
+ let isFolderShortcut =
+ isItem &&
+ node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
+ let isTag = node && PlacesUtils.nodeIsTagQuery(node);
+ let tag = null;
+ if (isTag) {
+ tag =
+ PlacesUtils.asQuery(node).query.tags.length == 1
+ ? node.query.tags[0]
+ : node.title;
+ }
+ let isURI = node && PlacesUtils.nodeIsURI(node);
+ let uri = isURI || isTag ? Services.io.newURI(node.uri) : null;
+ let title = node ? node.title : null;
+ let isBookmark = isItem && isURI;
+ let bulkTagging = !node;
+ let uris = bulkTagging ? aInitInfo.uris : null;
+ let visibleRows = new Set();
+ let isParentReadOnly = false;
+ let postData = aInitInfo.postData;
+ let parentGuid = null;
+
+ if (node && isItem) {
+ if (
+ !node.parent ||
+ (node.parent.itemId > 0 && !node.parent.bookmarkGuid)
+ ) {
+ throw new Error(
+ "Cannot use an incomplete node to initialize the edit bookmark panel"
+ );
+ }
+ let parent = node.parent;
+ isParentReadOnly = !PlacesUtils.nodeIsFolder(parent);
+ parentGuid = parent.bookmarkGuid;
+ }
+
+ let focusedElement = aInitInfo.focusedElement;
+ let onPanelReady = aInitInfo.onPanelReady;
+
+ return (this._paneInfo = {
+ itemId,
+ itemGuid,
+ parentGuid,
+ isItem,
+ isURI,
+ uri,
+ title,
+ isBookmark,
+ isFolderShortcut,
+ isParentReadOnly,
+ bulkTagging,
+ uris,
+ visibleRows,
+ postData,
+ isTag,
+ focusedElement,
+ onPanelReady,
+ tag,
+ });
+ },
+
+ get initialized() {
+ return this._paneInfo != null;
+ },
+
+ // Backwards-compatibility getters
+ get itemId() {
+ if (
+ !this.initialized ||
+ this._paneInfo.isTag ||
+ this._paneInfo.bulkTagging
+ ) {
+ return -1;
+ }
+ return this._paneInfo.itemId;
+ },
+
+ get uri() {
+ if (!this.initialized) {
+ return null;
+ }
+ if (this._paneInfo.bulkTagging) {
+ return this._paneInfo.uris[0];
+ }
+ return this._paneInfo.uri;
+ },
+
+ get multiEdit() {
+ return this.initialized && this._paneInfo.bulkTagging;
+ },
+
+ // Check if the pane is initialized to show only read-only fields.
+ get readOnly() {
+ // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some
+ // quirky implementation details (the most important being the "smart"
+ // semantics of node.title that makes hard to edit the right entry).
+ // This pane is read-only if:
+ // * the panel is not initialized
+ // * the node is a folder shortcut
+ // * the node is not bookmarked and not a tag container
+ // * the node is child of a read-only container and is not a bookmarked
+ // URI nor a tag container
+ return (
+ !this.initialized ||
+ this._paneInfo.isFolderShortcut ||
+ (!this._paneInfo.isItem && !this._paneInfo.isTag) ||
+ (this._paneInfo.isParentReadOnly &&
+ !this._paneInfo.isBookmark &&
+ !this._paneInfo.isTag)
+ );
+ },
+
+ get didChangeFolder() {
+ return this._didChangeFolder;
+ },
+
+ // the first field which was edited after this panel was initialized for
+ // a certain item
+ _firstEditedField: "",
+
+ _initNamePicker() {
+ if (this._paneInfo.bulkTagging) {
+ throw new Error("_initNamePicker called unexpectedly");
+ }
+
+ // title may by null, which, for us, is the same as an empty string.
+ this._initTextField(
+ this._namePicker,
+ this._paneInfo.title || this._paneInfo.tag || ""
+ );
+ },
+
+ _initLocationField() {
+ if (!this._paneInfo.isURI) {
+ throw new Error("_initLocationField called unexpectedly");
+ }
+ this._initTextField(this._locationField, this._paneInfo.uri.spec);
+ },
+
+ async _initKeywordField(newKeyword = "") {
+ if (!this._paneInfo.isBookmark) {
+ throw new Error("_initKeywordField called unexpectedly");
+ }
+
+ // Reset the field status synchronously now, eventually we'll reinit it
+ // later if we find an existing keyword. This way we can ensure to be in a
+ // consistent status when reusing the panel across different bookmarks.
+ this._keyword = newKeyword;
+ this._initTextField(this._keywordField, newKeyword);
+
+ if (!newKeyword) {
+ let entries = [];
+ await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e =>
+ entries.push(e)
+ );
+ if (entries.length) {
+ // We show an existing keyword if either POST data was not provided, or
+ // if the POST data is the same.
+ let existingKeyword = entries[0].keyword;
+ let postData = this._paneInfo.postData;
+ if (postData) {
+ let sameEntry = entries.find(e => e.postData === postData);
+ existingKeyword = sameEntry ? sameEntry.keyword : "";
+ }
+ if (existingKeyword) {
+ this._keyword = existingKeyword;
+ // Update the text field to the existing keyword.
+ this._initTextField(this._keywordField, this._keyword);
+ }
+ }
+ }
+ },
+
+ /**
+ * Initialize the panel.
+ *
+ * @param {object} aInfo
+ * The initialization info.
+ * @param {object} [aInfo.node]
+ * If aInfo.uris is not specified, this must be specified.
+ * Either a result node or a node-like object representing the item to be edited.
+ * A node-like object must have the following properties (with values that
+ * match exactly those a result node would have):
+ * itemId, bookmarkGuid, uri, title, type.
+ * @param {nsIURI[]} [aInfo.uris]
+ * If aInfo.node is not specified, this must be specified.
+ * An array of uris for bulk tagging.
+ * @param {string[]} [aInfo.hiddenRows]
+ * List of rows to be hidden regardless of the item edited. Possible values:
+ * "title", "location", "keyword", "folderPicker".
+ */
+ async initPanel(aInfo) {
+ if (typeof aInfo != "object" || aInfo === null) {
+ throw new Error("aInfo must be an object.");
+ }
+ if ("node" in aInfo) {
+ try {
+ aInfo.node.type;
+ } catch (e) {
+ // If the lazy loader for |type| generates an exception, it means that
+ // this bookmark could not be loaded. This sometimes happens when tests
+ // create a bookmark by clicking the bookmark star, then try to cleanup
+ // before the bookmark panel has finished opening. Either way, if we
+ // cannot retrieve the bookmark information, we cannot open the panel.
+ return;
+ }
+ }
+
+ // For sanity ensure that the implementer has uninited the panel before
+ // trying to init it again, or we could end up leaking due to observers.
+ if (this.initialized) {
+ this.uninitPanel(false);
+ }
+
+ this._didChangeFolder = false;
+ this.transactionPromises = [];
+
+ let {
+ parentGuid,
+ isItem,
+ isURI,
+ isBookmark,
+ bulkTagging,
+ uris,
+ visibleRows,
+ focusedElement,
+ onPanelReady,
+ } = this._setPaneInfo(aInfo);
+
+ // initPanel can be called multiple times in a row,
+ // and awaits Promises. If the reference to `instance`
+ // changes, it must mean another caller has called
+ // initPanel again, so bail out of the initialization.
+ let instance = (this._instance = {});
+
+ // If we're creating a new item on the toolbar, show it:
+ if (
+ aInfo.isNewBookmark &&
+ parentGuid == PlacesUtils.bookmarks.toolbarGuid
+ ) {
+ this._autoshowBookmarksToolbar();
+ }
+
+ let showOrCollapse = (
+ rowId,
+ isAppropriateForInput,
+ nameInHiddenRows = null
+ ) => {
+ let visible = isAppropriateForInput;
+ if (visible && "hiddenRows" in aInfo && nameInHiddenRows) {
+ visible &= !aInfo.hiddenRows.includes(nameInHiddenRows);
+ }
+ if (visible) {
+ visibleRows.add(rowId);
+ }
+ const cells = document.getElementsByClassName("editBMPanel_" + rowId);
+ for (const cell of cells) {
+ cell.hidden = !visible;
+ }
+ return visible;
+ };
+
+ if (showOrCollapse("nameRow", !bulkTagging, "name")) {
+ this._initNamePicker();
+ this._namePicker.readOnly = this.readOnly;
+ }
+
+ // In some cases we want to hide the location field, since it's not
+ // human-readable, but we still want to initialize it.
+ showOrCollapse("locationRow", isURI, "location");
+ if (isURI) {
+ this._initLocationField();
+ this._locationField.readOnly = this.readOnly;
+ }
+
+ if (showOrCollapse("keywordRow", isBookmark, "keyword")) {
+ await this._initKeywordField().catch(Cu.reportError);
+ // paneInfo can be null if paneInfo is uninitialized while
+ // the process above is awaiting initialization
+ if (instance != this._instance || this._paneInfo == null) {
+ return;
+ }
+ this._keywordField.readOnly = this.readOnly;
+ }
+
+ // Collapse the tag selector if the item does not accept tags.
+ if (showOrCollapse("tagsRow", isURI || bulkTagging, "tags")) {
+ this._initTagsField();
+ } else if (!this._element("tagsSelectorRow").hidden) {
+ this.toggleTagsSelector().catch(Cu.reportError);
+ }
+
+ // Folder picker.
+ // Technically we should check that the item is not moveable, but that's
+ // not cheap (we don't always have the parent), and there's no use case for
+ // this (it's only the Star UI that shows the folderPicker)
+ if (showOrCollapse("folderRow", isItem, "folderPicker")) {
+ await this._initFolderMenuList(parentGuid).catch(Cu.reportError);
+ if (instance != this._instance || this._paneInfo == null) {
+ return;
+ }
+ }
+
+ // Selection count.
+ if (showOrCollapse("selectionCount", bulkTagging)) {
+ this._element(
+ "itemsCountText"
+ ).value = PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ uris.length,
+ [uris.length]
+ );
+ }
+
+ // Observe changes.
+ if (!this._observersAdded) {
+ PlacesUtils.bookmarks.addObserver(this);
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-moved",
+ "bookmark-tags-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ window.addEventListener("unload", this);
+ this._observersAdded = true;
+ }
+
+ let focusElement = () => {
+ // The focusedElement possible values are:
+ // * preferred: focus the field that the user touched first the last
+ // time the pane was shown (either namePicker or tagsField)
+ // * first: focus the first non hidden input
+ // Note: since all controls are hidden by default, we don't get the
+ // default XUL dialog behavior, that selects the first control, so we set
+ // the focus explicitly.
+ let elt;
+ if (focusedElement === "preferred") {
+ elt = this._element(
+ Services.prefs.getCharPref(
+ "browser.bookmarks.editDialog.firstEditField"
+ )
+ );
+ } else if (focusedElement === "first") {
+ elt = document.querySelector('input:not([hidden="true"])');
+ }
+ if (elt) {
+ elt.focus({ preventScroll: true });
+ elt.select();
+ }
+ };
+
+ if (onPanelReady) {
+ onPanelReady(focusElement);
+ } else {
+ focusElement();
+ }
+ },
+
+ /**
+ * Finds tags that are in common among this._currentInfo.uris;
+ *
+ * @returns {string[]}
+ */
+ _getCommonTags() {
+ if ("_cachedCommonTags" in this._paneInfo) {
+ return this._paneInfo._cachedCommonTags;
+ }
+
+ let uris = [...this._paneInfo.uris];
+ let firstURI = uris.shift();
+ let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI));
+ if (commonTags.size == 0) {
+ return (this._cachedCommonTags = []);
+ }
+
+ for (let uri of uris) {
+ let curentURITags = PlacesUtils.tagging.getTagsForURI(uri);
+ for (let tag of commonTags) {
+ if (!curentURITags.includes(tag)) {
+ commonTags.delete(tag);
+ if (commonTags.size == 0) {
+ return (this._paneInfo.cachedCommonTags = []);
+ }
+ }
+ }
+ }
+ return (this._paneInfo._cachedCommonTags = [...commonTags]);
+ },
+
+ _initTextField(aElement, aValue) {
+ if (aElement.value != aValue) {
+ aElement.value = aValue;
+
+ // Clear the editor's undo stack
+ // FYI: editor may be null.
+ aElement.editor?.clearUndoRedo();
+ }
+ },
+
+ /**
+ * Appends a menu-item representing a bookmarks folder to a menu-popup.
+ *
+ * @param {DOMElement} aMenupopup
+ * The popup to which the menu-item should be added.
+ * @param {string} aFolderGuid
+ * The identifier of the bookmarks folder.
+ * @param {string} aTitle
+ * The title to use as a label.
+ * @returns {DOMElement}
+ * The new menu item.
+ */
+ _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) {
+ // First make sure the folders-separator is visible
+ this._element("foldersSeparator").hidden = false;
+
+ var folderMenuItem = document.createXULElement("menuitem");
+ folderMenuItem.folderGuid = aFolderGuid;
+ folderMenuItem.setAttribute("label", aTitle);
+ folderMenuItem.className = "menuitem-iconic folder-icon";
+ aMenupopup.appendChild(folderMenuItem);
+ return folderMenuItem;
+ },
+
+ async _initFolderMenuList(aSelectedFolderGuid) {
+ // clean up first
+ var menupopup = this._folderMenuList.menupopup;
+ while (menupopup.children.length > 6) {
+ menupopup.removeChild(menupopup.lastElementChild);
+ }
+
+ // Build the static list
+ if (!this._staticFoldersListBuilt) {
+ let unfiledItem = this._element("unfiledRootItem");
+ unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle");
+ unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid;
+ let bmMenuItem = this._element("bmRootItem");
+ bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle");
+ bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid;
+ let toolbarItem = this._element("toolbarFolderItem");
+ toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle");
+ toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid;
+ this._staticFoldersListBuilt = true;
+ }
+
+ // List of recently used folders:
+ let lastUsedFolderGuids = await PlacesUtils.metadata.get(
+ PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
+ []
+ );
+
+ /**
+ * The list of last used folders is sorted in most-recent first order.
+ *
+ * First we build the annotated folders array, each item has both the
+ * folder identifier and the time at which it was last-used by this dialog
+ * set. Then we sort it descendingly based on the time field.
+ */
+ this._recentFolders = [];
+ for (let guid of lastUsedFolderGuids) {
+ let bm = await PlacesUtils.bookmarks.fetch(guid);
+ if (bm) {
+ let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
+ this._recentFolders.push({ guid, title });
+ }
+ }
+
+ var numberOfItems = Math.min(
+ PlacesUIUtils.maxRecentFolders,
+ this._recentFolders.length
+ );
+ for (let i = 0; i < numberOfItems; i++) {
+ await this._appendFolderItemToMenupopup(
+ menupopup,
+ this._recentFolders[i].guid,
+ this._recentFolders[i].title
+ );
+ }
+
+ let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title;
+ var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title);
+ this._folderMenuList.selectedItem = defaultItem;
+ // Ensure the selectedGuid attribute is set correctly (the above line wouldn't
+ // necessary trigger a select event, so handle it manually, then add the
+ // listener).
+ this._onFolderListSelected();
+
+ this._folderMenuList.addEventListener("select", this);
+ this._folderMenuListListenerAdded = true;
+
+ // Hide the folders-separator if no folder is annotated as recently-used
+ this._element("foldersSeparator").hidden = menupopup.children.length <= 6;
+ this._folderMenuList.disabled = this.readOnly;
+ },
+
+ _onFolderListSelected() {
+ // Set a selectedGuid attribute to show special icons
+ let folderGuid = this.selectedFolderGuid;
+ if (folderGuid) {
+ this._folderMenuList.setAttribute("selectedGuid", folderGuid);
+ } else {
+ this._folderMenuList.removeAttribute("selectedGuid");
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsINavBookmarkObserver"]),
+
+ _element(aID) {
+ return document.getElementById("editBMPanel_" + aID);
+ },
+
+ uninitPanel(aHideCollapsibleElements) {
+ if (aHideCollapsibleElements) {
+ // Hide the folder tree if it was previously visible.
+ var folderTreeRow = this._element("folderTreeRow");
+ if (!folderTreeRow.hidden) {
+ this.toggleFolderTreeVisibility();
+ }
+
+ // Hide the tag selector if it was previously visible.
+ var tagsSelectorRow = this._element("tagsSelectorRow");
+ if (!tagsSelectorRow.hidden) {
+ this.toggleTagsSelector().catch(Cu.reportError);
+ }
+ }
+
+ if (this._observersAdded) {
+ PlacesUtils.bookmarks.removeObserver(this);
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-moved",
+ "bookmark-tags-changed",
+ "bookmark-title-changed",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ window.removeEventListener("unload", this);
+ this._observersAdded = false;
+ }
+
+ if (this._folderMenuListListenerAdded) {
+ this._folderMenuList.removeEventListener("select", this);
+ this._folderMenuListListenerAdded = false;
+ }
+
+ this._setPaneInfo(null);
+ this._firstEditedField = "";
+ this._didChangeFolder = false;
+ this.transactionPromises = [];
+ },
+
+ get selectedFolderGuid() {
+ return (
+ this._folderMenuList.selectedItem &&
+ this._folderMenuList.selectedItem.folderGuid
+ );
+ },
+
+ onTagsFieldChange() {
+ // Check for _paneInfo existing as the dialog may be closing but receiving
+ // async updates from unresolved promises.
+ if (
+ this._paneInfo &&
+ (this._paneInfo.isURI || this._paneInfo.bulkTagging)
+ ) {
+ this._updateTags().then(anyChanges => {
+ // Check _paneInfo here as we might be closing the dialog.
+ if (anyChanges && this._paneInfo) {
+ this._mayUpdateFirstEditField("tagsField");
+ }
+ }, Cu.reportError);
+ }
+ },
+
+ /**
+ * Works out the necessary changes for a given array of currently-set tags and
+ * the tags-input-field value.
+ *
+ * @param {string[]} aCurrentTags
+ * The tags to compare.
+ * @returns {object}
+ * Returns which tags should be removed and which should be added in
+ * the form of an object: `{ removedTags: [...], newTags: [...] }`.
+ */
+ _getTagsChanges(aCurrentTags) {
+ let inputTags = this._getTagsArrayFromTagsInputField();
+
+ // Optimize the trivial cases (which are actually the most common).
+ if (!inputTags.length && !aCurrentTags.length) {
+ return { newTags: [], removedTags: [] };
+ }
+ if (!inputTags.length) {
+ return { newTags: [], removedTags: aCurrentTags };
+ }
+ if (!aCurrentTags.length) {
+ return { newTags: inputTags, removedTags: [] };
+ }
+
+ // Do not remove tags that may be reinserted with a different
+ // case, since the tagging service may handle those more efficiently.
+ let lcInputTags = inputTags.map(t => t.toLowerCase());
+ let removedTags = aCurrentTags.filter(
+ t => !lcInputTags.includes(t.toLowerCase())
+ );
+ let newTags = inputTags.filter(t => !aCurrentTags.includes(t));
+ return { removedTags, newTags };
+ },
+
+ // Adds and removes tags for one or more uris.
+ _setTagsFromTagsInputField(aCurrentTags, aURIs) {
+ let { removedTags, newTags } = this._getTagsChanges(aCurrentTags);
+ if (removedTags.length + newTags.length == 0) {
+ return false;
+ }
+
+ let setTags = async () => {
+ let promises = [];
+ if (removedTags.length) {
+ let promise = PlacesTransactions.Untag({
+ urls: aURIs,
+ tags: removedTags,
+ })
+ .transact()
+ .catch(Cu.reportError);
+ this.transactionPromises.push(promise);
+ promises.push(promise);
+ }
+ if (newTags.length) {
+ let promise = PlacesTransactions.Tag({ urls: aURIs, tags: newTags })
+ .transact()
+ .catch(Cu.reportError);
+ this.transactionPromises.push(promise);
+ promises.push(promise);
+ }
+ // Don't use Promise.all because we want these to be executed in order.
+ for (let promise of promises) {
+ await promise;
+ }
+ };
+
+ // Only in the library info-pane it's safe (and necessary) to batch these.
+ // TODO bug 1093030: cleanup this mess when the bookmarksProperties dialog
+ // and star UI code don't "run a batch in the background".
+ if (window.document.documentElement.id == "places") {
+ PlacesTransactions.batch(setTags);
+ } else {
+ setTags();
+ }
+ return true;
+ },
+
+ async _updateTags() {
+ let uris = this._paneInfo.bulkTagging
+ ? this._paneInfo.uris
+ : [this._paneInfo.uri];
+ let currentTags = this._paneInfo.bulkTagging
+ ? await this._getCommonTags()
+ : PlacesUtils.tagging.getTagsForURI(uris[0]);
+ let anyChanges = this._setTagsFromTagsInputField(currentTags, uris);
+ if (!anyChanges) {
+ return false;
+ }
+
+ // The panel could have been closed in the meanwhile.
+ if (!this._paneInfo) {
+ return false;
+ }
+
+ // Ensure the tagsField is in sync, clean it up from empty tags
+ currentTags = this._paneInfo.bulkTagging
+ ? this._getCommonTags()
+ : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
+ this._initTextField(this._tagsField, currentTags.join(", "), false);
+ return true;
+ },
+
+ /**
+ * Stores the first-edit field for this dialog, if the passed-in field
+ * is indeed the first edited field.
+ *
+ * @param {string} aNewField
+ * The id of the field that may be set (without the "editBMPanel_" prefix).
+ */
+ _mayUpdateFirstEditField(aNewField) {
+ // * The first-edit-field behavior is not applied in the multi-edit case
+ // * if this._firstEditedField is already set, this is not the first field,
+ // so there's nothing to do
+ if (this._paneInfo.bulkTagging || this._firstEditedField) {
+ return;
+ }
+
+ this._firstEditedField = aNewField;
+
+ // set the pref
+ Services.prefs.setCharPref(
+ "browser.bookmarks.editDialog.firstEditField",
+ aNewField
+ );
+ },
+
+ async onNamePickerChange() {
+ if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) {
+ return;
+ }
+
+ // Here we update either the item title or its cached static title
+ if (this._paneInfo.isTag) {
+ let tag = this._namePicker.value;
+ if (!tag || tag.includes("&")) {
+ // We don't allow setting an empty title for a tag, restore the old one.
+ this._initNamePicker();
+ return;
+ }
+ // Get all the bookmarks for the old tag, tag them with the new tag, and
+ // untag them from the old tag.
+ let oldTag = this._paneInfo.tag;
+ this._paneInfo.tag = tag;
+ let title = this._paneInfo.title;
+ if (title == oldTag) {
+ this._paneInfo.title = tag;
+ }
+ let promise = PlacesTransactions.RenameTag({ oldTag, tag }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+ return;
+ }
+
+ this._mayUpdateFirstEditField("namePicker");
+ let promise = PlacesTransactions.EditTitle({
+ guid: this._paneInfo.itemGuid,
+ title: this._namePicker.value,
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+ },
+
+ onLocationFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+
+ let newURI;
+ try {
+ newURI = Services.uriFixup.getFixupURIInfo(this._locationField.value)
+ .preferredURI;
+ } catch (ex) {
+ // TODO: Bug 1089141 - Provide some feedback about the invalid url.
+ return;
+ }
+
+ if (this._paneInfo.uri.equals(newURI)) {
+ return;
+ }
+
+ let guid = this._paneInfo.itemGuid;
+ this.transactionPromises.push(
+ PlacesTransactions.EditUrl({ guid, url: newURI })
+ .transact()
+ .catch(Cu.reportError)
+ );
+ },
+
+ onKeywordFieldChange() {
+ if (this.readOnly || !this._paneInfo.isBookmark) {
+ return;
+ }
+
+ let oldKeyword = this._keyword;
+ let keyword = (this._keyword = this._keywordField.value);
+ let postData = this._paneInfo.postData;
+ let guid = this._paneInfo.itemGuid;
+ this.transactionPromises.push(
+ PlacesTransactions.EditKeyword({ guid, keyword, postData, oldKeyword })
+ .transact()
+ .catch(Cu.reportError)
+ );
+ },
+
+ toggleFolderTreeVisibility() {
+ let expander = this._element("foldersExpander");
+ let folderTreeRow = this._element("folderTreeRow");
+ let wasHidden = folderTreeRow.hidden;
+ expander.classList.toggle("expander-up", wasHidden);
+ expander.classList.toggle("expander-down", !wasHidden);
+ if (!wasHidden) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ folderTreeRow.hidden = true;
+ this._element("chooseFolderSeparator").hidden = this._element(
+ "chooseFolderMenuItem"
+ ).hidden = false;
+ // Stop editing if we were (will no-op if not). This avoids permanently
+ // breaking the tree if/when it is reshown.
+ this._folderTree.stopEditing(false);
+ // Unlinking the view will break the connection with the result. We don't
+ // want to pay for live updates while the view is not visible.
+ this._folderTree.view = null;
+ } else {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ folderTreeRow.hidden = false;
+
+ // XXXmano: Ideally we would only do this once, but for some odd reason,
+ // the editable mode set on this tree, together with its hidden state
+ // breaks the view.
+ const FOLDER_TREE_PLACE_URI =
+ "place:excludeItems=1&excludeQueries=1&type=" +
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY;
+ this._folderTree.place = FOLDER_TREE_PLACE_URI;
+
+ this._element("chooseFolderSeparator").hidden = this._element(
+ "chooseFolderMenuItem"
+ ).hidden = true;
+ this._folderTree.selectItems([this._paneInfo.parentGuid]);
+ this._folderTree.focus();
+ }
+ },
+
+ /**
+ * Get the corresponding menu-item in the folder-menu-list for a bookmarks
+ * folder if such an item exists. Otherwise, this creates a menu-item for the
+ * folder. If the items-count limit (see
+ * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the
+ * new item replaces the last menu-item.
+ *
+ * @param {string} aFolderGuid
+ * The identifier of the bookmarks folder.
+ * @param {string} aTitle
+ * The title to use in case of menuitem creation.
+ * @returns {DOMElement}
+ * The handle to the menuitem.
+ */
+ _getFolderMenuItem(aFolderGuid, aTitle) {
+ let menupopup = this._folderMenuList.menupopup;
+ let menuItem = Array.prototype.find.call(
+ menupopup.children,
+ item => item.folderGuid === aFolderGuid
+ );
+ if (menuItem !== undefined) {
+ return menuItem;
+ }
+
+ // 3 special folders + separator + folder-items-count limit
+ if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) {
+ menupopup.removeChild(menupopup.lastElementChild);
+ }
+
+ return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle);
+ },
+
+ async onFolderMenuListCommand(aEvent) {
+ // Check for _paneInfo existing as the dialog may be closing but receiving
+ // async updates from unresolved promises.
+ if (!this._paneInfo) {
+ return;
+ }
+
+ if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") {
+ // reset the selection back to where it was and expand the tree
+ // (this menu-item is hidden when the tree is already visible
+ let item = this._getFolderMenuItem(
+ this._paneInfo.parentGuid,
+ this._paneInfo.title
+ );
+ this._folderMenuList.selectedItem = item;
+ // XXXmano HACK: setTimeout 100, otherwise focus goes back to the
+ // menulist right away
+ setTimeout(() => this.toggleFolderTreeVisibility(), 100);
+ return;
+ }
+
+ // Move the item
+ let containerGuid = this._folderMenuList.selectedItem.folderGuid;
+ if (
+ this._paneInfo.parentGuid != containerGuid &&
+ this._paneInfo.itemGuid != containerGuid
+ ) {
+ let promise = PlacesTransactions.Move({
+ guid: this._paneInfo.itemGuid,
+ newParentGuid: containerGuid,
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ await promise;
+
+ // Auto-show the bookmarks toolbar when adding / moving an item there.
+ if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ this._autoshowBookmarksToolbar();
+ }
+
+ // Unless the user cancels the panel, we'll use the chosen folder as
+ // the default for new bookmarks.
+ this._didChangeFolder = true;
+ }
+
+ // Update folder-tree selection
+ var folderTreeRow = this._element("folderTreeRow");
+ if (!folderTreeRow.hidden) {
+ var selectedNode = this._folderTree.selectedNode;
+ if (
+ !selectedNode ||
+ PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid
+ ) {
+ this._folderTree.selectItems([containerGuid]);
+ }
+ }
+ },
+
+ _autoshowBookmarksToolbar() {
+ let neverShowToolbar =
+ Services.prefs.getCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ "newtab"
+ ) == "never";
+ let toolbar = document.getElementById("PersonalToolbar");
+ if (!toolbar.collapsed || neverShowToolbar) {
+ return;
+ }
+
+ let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
+ let area = placement && placement.area;
+ if (area != CustomizableUI.AREA_BOOKMARKS) {
+ return;
+ }
+
+ // Show the toolbar but don't persist it permanently open
+ setToolbarVisibility(toolbar, true, false);
+ },
+
+ onFolderTreeSelect() {
+ // Ignore this event when the folder tree is hidden, even if the tree is
+ // alive, it's clearly not a user activated action.
+ if (this._element("folderTreeRow").hidden) {
+ return;
+ }
+
+ var selectedNode = this._folderTree.selectedNode;
+
+ // Disable the "New Folder" button if we cannot create a new folder
+ this._element("newFolderButton").disabled =
+ !this._folderTree.insertionPoint || !selectedNode;
+
+ if (!selectedNode) {
+ return;
+ }
+
+ var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
+ if (this._folderMenuList.selectedItem.folderGuid == folderGuid) {
+ return;
+ }
+
+ var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title);
+ this._folderMenuList.selectedItem = folderItem;
+ folderItem.doCommand();
+ },
+
+ async _rebuildTagsSelectorList() {
+ let tagsSelector = this._element("tagsSelector");
+ let tagsSelectorRow = this._element("tagsSelectorRow");
+ if (tagsSelectorRow.hidden) {
+ return;
+ }
+
+ let selectedIndex = tagsSelector.selectedIndex;
+ let selectedTag =
+ selectedIndex >= 0 ? tagsSelector.selectedItem.label : null;
+
+ while (tagsSelector.hasChildNodes()) {
+ tagsSelector.removeChild(tagsSelector.lastElementChild);
+ }
+
+ let tagsInField = this._getTagsArrayFromTagsInputField();
+ let allTags = await PlacesUtils.bookmarks.fetchTags();
+ let fragment = document.createDocumentFragment();
+ for (let i = 0; i < allTags.length; i++) {
+ let tag = allTags[i].name;
+ let elt = document.createXULElement("richlistitem");
+ elt.appendChild(document.createXULElement("image"));
+ let label = document.createXULElement("label");
+ label.setAttribute("value", tag);
+ elt.appendChild(label);
+ if (tagsInField.includes(tag)) {
+ elt.setAttribute("checked", "true");
+ }
+ fragment.appendChild(elt);
+ if (selectedTag === tag) {
+ selectedIndex = i;
+ }
+ }
+ tagsSelector.appendChild(fragment);
+
+ if (selectedIndex >= 0 && tagsSelector.itemCount > 0) {
+ selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1);
+ tagsSelector.selectedIndex = selectedIndex;
+ tagsSelector.ensureIndexIsVisible(selectedIndex);
+ }
+ let event = new CustomEvent("BookmarkTagsSelectorUpdated", {
+ bubbles: true,
+ });
+ tagsSelector.dispatchEvent(event);
+ },
+
+ async toggleTagsSelector() {
+ var tagsSelector = this._element("tagsSelector");
+ var tagsSelectorRow = this._element("tagsSelectorRow");
+ var expander = this._element("tagsSelectorExpander");
+ expander.classList.toggle("expander-up", tagsSelectorRow.hidden);
+ expander.classList.toggle("expander-down", !tagsSelectorRow.hidden);
+ if (tagsSelectorRow.hidden) {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextup")
+ );
+ tagsSelectorRow.hidden = false;
+ await this._rebuildTagsSelectorList();
+
+ // This is a no-op if we've added the listener.
+ tagsSelector.addEventListener("mousedown", this);
+ tagsSelector.addEventListener("keypress", this);
+ } else {
+ expander.setAttribute(
+ "tooltiptext",
+ expander.getAttribute("tooltiptextdown")
+ );
+ tagsSelectorRow.hidden = true;
+
+ // This is a no-op if we've removed the listener.
+ tagsSelector.removeEventListener("mousedown", this);
+ tagsSelector.removeEventListener("keypress", this);
+ }
+ },
+
+ /**
+ * Splits "tagsField" element value, returning an array of valid tag strings.
+ *
+ * @returns {string[]}
+ * Array of tag strings found in the field value.
+ */
+ _getTagsArrayFromTagsInputField() {
+ let tags = this._element("tagsField").value;
+ return tags
+ .trim()
+ .split(/\s*,\s*/) // Split on commas and remove spaces.
+ .filter(tag => !!tag.length); // Kill empty tags.
+ },
+
+ async newFolder() {
+ let ip = this._folderTree.insertionPoint;
+
+ // default to the bookmarks menu folder
+ if (!ip) {
+ ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ });
+ }
+
+ // XXXmano: add a separate "New Folder" string at some point...
+ let title = this._element("newFolderButton").label;
+ let promise = PlacesTransactions.NewFolder({
+ parentGuid: ip.guid,
+ title,
+ index: await ip.getIndex(),
+ }).transact();
+ this.transactionPromises.push(promise.catch(Cu.reportError));
+ let guid = await promise;
+
+ this._folderTree.focus();
+ this._folderTree.selectItems([ip.guid]);
+ PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true;
+ this._folderTree.selectItems([guid]);
+ this._folderTree.startEditing(
+ this._folderTree.view.selection.currentIndex,
+ this._folderTree.columns.getFirstColumn()
+ );
+ },
+
+ // EventListener
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousedown":
+ if (event.button == 0) {
+ // Make sure the event is triggered on an item and not the empty space.
+ let item = event.target.closest("richlistbox,richlistitem");
+ if (item.localName == "richlistitem") {
+ this.toggleItemCheckbox(item);
+ }
+ }
+ break;
+ case "keypress":
+ if (event.key == " ") {
+ let item = event.target.currentItem;
+ if (item) {
+ this.toggleItemCheckbox(item);
+ }
+ }
+ break;
+ case "unload":
+ this.uninitPanel(false);
+ break;
+ case "select":
+ this._onFolderListSelected();
+ break;
+ }
+ },
+
+ async handlePlacesEvents(events) {
+ for (const event of events) {
+ switch (event.type) {
+ case "bookmark-moved":
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != event.id) {
+ return;
+ }
+
+ this._paneInfo.parentGuid = event.parentGuid;
+
+ if (
+ !this._paneInfo.visibleRows.has("folderRow") ||
+ event.parentGuid === this._folderMenuList.selectedItem.folderGuid
+ ) {
+ return;
+ }
+
+ // Just setting selectItem _does not_ trigger oncommand, so we don't
+ // recurse.
+ const bm = await PlacesUtils.bookmarks.fetch(event.parentGuid);
+ this._folderMenuList.selectedItem = this._getFolderMenuItem(
+ event.parentGuid,
+ bm.title
+ );
+ break;
+ case "bookmark-tags-changed":
+ if (this._paneInfo.visibleRows.has("tagsRow")) {
+ this._onTagsChange(event.guid).catch(Cu.reportError);
+ }
+ break;
+ case "bookmark-title-changed":
+ if (this._paneInfo.isItem || this._paneInfo.isTag) {
+ // This also updates titles of folders in the folder menu list.
+ this._onItemTitleChange(event.id, event.title, event.guid);
+ }
+ break;
+ case "bookmark-url-changed":
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != event.id) {
+ return;
+ }
+
+ const newURI = Services.io.newURI(event.url);
+ if (!newURI.equals(this._paneInfo.uri)) {
+ this._paneInfo.uri = newURI;
+ if (this._paneInfo.visibleRows.has("locationRow")) {
+ this._initLocationField();
+ }
+
+ if (this._paneInfo.visibleRows.has("tagsRow")) {
+ delete this._paneInfo._cachedCommonTags;
+ this._onTagsChange(event.guid, newURI).catch(Cu.reportError);
+ }
+ }
+ break;
+ }
+ }
+ },
+
+ toggleItemCheckbox(item) {
+ // Update the tags field when items are checked/unchecked in the listbox
+ let tags = this._getTagsArrayFromTagsInputField();
+
+ let curTagIndex = tags.indexOf(item.label);
+ let tagsSelector = this._element("tagsSelector");
+ tagsSelector.selectedItem = item;
+
+ if (!item.hasAttribute("checked")) {
+ item.setAttribute("checked", "true");
+ if (curTagIndex == -1) {
+ tags.push(item.label);
+ }
+ } else {
+ item.removeAttribute("checked");
+ if (curTagIndex != -1) {
+ tags.splice(curTagIndex, 1);
+ }
+ }
+ this._element("tagsField").value = tags.join(", ");
+ this._updateTags();
+ },
+
+ _initTagsField() {
+ let tags;
+ if (this._paneInfo.isURI) {
+ tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
+ } else if (this._paneInfo.bulkTagging) {
+ tags = this._getCommonTags();
+ } else {
+ throw new Error("_promiseTagsStr called unexpectedly");
+ }
+
+ this._initTextField(this._tagsField, tags.join(", "));
+ },
+
+ async _onTagsChange(guid, changedURI = null) {
+ let paneInfo = this._paneInfo;
+ let updateTagsField = false;
+ if (paneInfo.isURI) {
+ if (paneInfo.isBookmark && guid == paneInfo.itemGuid) {
+ updateTagsField = true;
+ } else if (!paneInfo.isBookmark) {
+ if (!changedURI) {
+ let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href;
+ changedURI = Services.io.newURI(href);
+ }
+ updateTagsField = changedURI.equals(paneInfo.uri);
+ }
+ } else if (paneInfo.bulkTagging) {
+ if (!changedURI) {
+ let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href;
+ changedURI = Services.io.newURI(href);
+ }
+ if (paneInfo.uris.some(uri => uri.equals(changedURI))) {
+ updateTagsField = true;
+ delete this._paneInfo._cachedCommonTags;
+ }
+ } else {
+ throw new Error("_onTagsChange called unexpectedly");
+ }
+
+ if (updateTagsField) {
+ this._initTagsField();
+ // Any tags change should be reflected in the tags selector.
+ if (this._element("tagsSelector")) {
+ await this._rebuildTagsSelectorList();
+ }
+ }
+ },
+
+ _onItemTitleChange(aItemId, aNewTitle, aGuid) {
+ if (aItemId == this._paneInfo.itemId || aGuid == this._paneInfo.itemGuid) {
+ this._paneInfo.title = aNewTitle;
+ this._initTextField(this._namePicker, aNewTitle);
+ } else if (this._paneInfo.visibleRows.has("folderRow")) {
+ // If the title of a folder which is listed within the folders
+ // menulist has been changed, we need to update the label of its
+ // representing element.
+ let menupopup = this._folderMenuList.menupopup;
+ for (let menuitem of menupopup.children) {
+ if ("folderGuid" in menuitem && menuitem.folderGuid == aGuid) {
+ menuitem.label = aNewTitle;
+ break;
+ }
+ }
+ }
+ // We need to also update title of recent folders.
+ if (this._recentFolders) {
+ for (let folder of this._recentFolders) {
+ if (folder.folderGuid == aGuid) {
+ folder.title = aNewTitle;
+ break;
+ }
+ }
+ }
+ },
+
+ // nsINavBookmarkObserver
+ onItemChanged(
+ aItemId,
+ aProperty,
+ aIsAnnotationProperty,
+ aValue,
+ aLastModified,
+ aItemType,
+ aParentId,
+ aGuid
+ ) {
+ if (!this._paneInfo.isItem || this._paneInfo.itemId != aItemId) {
+ return;
+ }
+
+ switch (aProperty) {
+ case "keyword":
+ if (this._paneInfo.visibleRows.has("keywordRow")) {
+ this._initKeywordField(aValue).catch(Cu.reportError);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Flag which signals to consumers that this script is loaded, thus instant
+ * apply logic should be used.
+ *
+ * @returns {boolean} Always false.
+ */
+ get delayedApplyEnabled() {
+ return false;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => {
+ if (!customElements.get("places-tree")) {
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/places/places-tree.js",
+ window
+ );
+ }
+ gEditItemOverlay._element("folderTreeRow").prepend(
+ MozXULElement.parseXULToFragment(`
+ <tree id="editBMPanel_folderTree"
+ flex="1"
+ class="placesTree"
+ is="places-tree"
+ editable="true"
+ onselect="gEditItemOverlay.onFolderTreeSelect();"
+ disableUserActions="true"
+ hidecolumnpicker="true">
+ <treecols>
+ <treecol anonid="title" flex="1" primary="true" hideheader="true"/>
+ </treecols>
+ <treechildren flex="1"/>
+ </tree>
+ `)
+ );
+ return gEditItemOverlay._element("folderTree");
+});
+
+for (let elt of [
+ "folderMenuList",
+ "namePicker",
+ "locationField",
+ "keywordField",
+ "tagsField",
+]) {
+ let eltScoped = elt;
+ XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () =>
+ gEditItemOverlay._element(eltScoped)
+ );
+}
diff --git a/browser/components/places/content/places-menupopup.js b/browser/components/places/content/places-menupopup.js
new file mode 100644
index 0000000000..629595a359
--- /dev/null
+++ b/browser/components/places/content/places-menupopup.js
@@ -0,0 +1,692 @@
+/* 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/. */
+
+"use strict";
+
+/* eslint-env mozilla/browser-window */
+/* import-globals-from controller.js */
+
+// On Wayland when D&D source popup is closed,
+// D&D operation is canceled by window manager.
+function closingPopupEndsDrag(popup) {
+ if (!popup.isWaylandPopup) {
+ return false;
+ }
+ if (popup.isWaylandDragSource) {
+ return true;
+ }
+ for (let childPopup of popup.querySelectorAll("menu > menupopup")) {
+ if (childPopup.isWaylandDragSource) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// This is loaded into all XUL windows. Wrap in a block to prevent
+// leaking to window scope.
+{
+ /**
+ * This class handles the custom element for the places popup menu.
+ */
+ class MozPlacesPopup extends MozElements.MozMenuPopup {
+ constructor() {
+ super();
+
+ const event_names = [
+ "DOMMenuItemActive",
+ "DOMMenuItemInactive",
+ "dragstart",
+ "drop",
+ "dragover",
+ "dragleave",
+ "dragend",
+ ];
+ for (let event_name of event_names) {
+ this.addEventListener(event_name, this);
+ }
+ }
+
+ get markup() {
+ return `
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <hbox part="drop-indicator-container">
+ <vbox part="drop-indicator-bar" hidden="true">
+ <image part="drop-indicator"/>
+ </vbox>
+ <arrowscrollbox class="menupopup-arrowscrollbox" flex="1" orient="vertical"
+ exportparts="scrollbox: arrowscrollbox-scrollbox"
+ smoothscroll="false" part="arrowscrollbox content">
+ <html:slot/>
+ </arrowscrollbox>
+ </hbox>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ /**
+ * Sub-menus should be opened when the mouse drags over them, and closed
+ * when the mouse drags off. The overFolder object manages opening and
+ * closing of folders when the mouse hovers.
+ */
+ this._overFolder = {
+ _self: this,
+ _folder: {
+ elt: null,
+ openTimer: null,
+ hoverTime: 350,
+ closeTimer: null,
+ },
+ _closeMenuTimer: null,
+
+ get elt() {
+ return this._folder.elt;
+ },
+ set elt(val) {
+ this._folder.elt = val;
+ },
+
+ get openTimer() {
+ return this._folder.openTimer;
+ },
+ set openTimer(val) {
+ this._folder.openTimer = val;
+ },
+
+ get hoverTime() {
+ return this._folder.hoverTime;
+ },
+ set hoverTime(val) {
+ this._folder.hoverTime = val;
+ },
+
+ get closeTimer() {
+ return this._folder.closeTimer;
+ },
+ set closeTimer(val) {
+ this._folder.closeTimer = val;
+ },
+
+ get closeMenuTimer() {
+ return this._closeMenuTimer;
+ },
+ set closeMenuTimer(val) {
+ this._closeMenuTimer = val;
+ },
+
+ setTimer: function OF__setTimer(aTime) {
+ var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT);
+ return timer;
+ },
+
+ notify: function OF__notify(aTimer) {
+ // Function to process all timer notifications.
+
+ if (aTimer == this._folder.openTimer) {
+ // Timer to open a submenu that's being dragged over.
+ this._folder.elt.lastElementChild.setAttribute(
+ "autoopened",
+ "true"
+ );
+ this._folder.elt.lastElementChild.openPopup();
+ this._folder.openTimer = null;
+ } else if (aTimer == this._folder.closeTimer) {
+ // Timer to close a submenu that's been dragged off of.
+ // Only close the submenu if the mouse isn't being dragged over any
+ // of its child menus.
+ var draggingOverChild = PlacesControllerDragHelper.draggingOverChildNode(
+ this._folder.elt
+ );
+ if (draggingOverChild) {
+ this._folder.elt = null;
+ }
+ this.clear();
+
+ // Close any parent folders which aren't being dragged over.
+ // (This is necessary because of the above code that keeps a folder
+ // open while its children are being dragged over.)
+ if (!draggingOverChild && !closingPopupEndsDrag(this._self)) {
+ this.closeParentMenus();
+ }
+ } else if (aTimer == this.closeMenuTimer) {
+ // Timer to close this menu after the drag exit.
+ var popup = this._self;
+ // if we are no more dragging we can leave the menu open to allow
+ // for better D&D bookmark organization
+ var hidePopup =
+ PlacesControllerDragHelper.getSession() &&
+ !PlacesControllerDragHelper.draggingOverChildNode(
+ popup.parentNode
+ );
+ if (hidePopup) {
+ if (!closingPopupEndsDrag(popup)) {
+ popup.hidePopup();
+ // Close any parent menus that aren't being dragged over;
+ // otherwise they'll stay open because they couldn't close
+ // while this menu was being dragged over.
+ this.closeParentMenus();
+ } else if (popup.isWaylandDragSource) {
+ // Postpone popup hide until drag end on Wayland.
+ this._closeMenuTimer = this.setTimer(this.hoverTime);
+ }
+ }
+ }
+ },
+
+ // Helper function to close all parent menus of this menu,
+ // as long as none of the parent's children are currently being
+ // dragged over.
+ closeParentMenus: function OF__closeParentMenus() {
+ var popup = this._self;
+ var parent = popup.parentNode;
+ while (parent) {
+ if (parent.localName == "menupopup" && parent._placesNode) {
+ if (
+ PlacesControllerDragHelper.draggingOverChildNode(
+ parent.parentNode
+ )
+ ) {
+ break;
+ }
+ parent.hidePopup();
+ }
+ parent = parent.parentNode;
+ }
+ },
+
+ // The mouse is no longer dragging over the stored menubutton.
+ // Close the menubutton, clear out drag styles, and clear all
+ // timers for opening/closing it.
+ clear: function OF__clear() {
+ if (this._folder.elt && this._folder.elt.lastElementChild) {
+ var popup = this._folder.elt.lastElementChild;
+ if (
+ !popup.hasAttribute("dragover") &&
+ !closingPopupEndsDrag(popup)
+ ) {
+ popup.hidePopup();
+ }
+ // remove menuactive style
+ this._folder.elt.removeAttribute("_moz-menuactive");
+ this._folder.elt = null;
+ }
+ if (this._folder.openTimer) {
+ this._folder.openTimer.cancel();
+ this._folder.openTimer = null;
+ }
+ if (this._folder.closeTimer) {
+ this._folder.closeTimer.cancel();
+ this._folder.closeTimer = null;
+ }
+ },
+ };
+ }
+
+ get _indicatorBar() {
+ if (!this.__indicatorBar) {
+ this.__indicatorBar = this.shadowRoot.querySelector(
+ "[part=drop-indicator-bar]"
+ );
+ }
+ return this.__indicatorBar;
+ }
+
+ /**
+ * This is the view that manages the popup.
+ *
+ * @see {@link PlacesUIUtils.getViewForNode}
+ * @returns {DOMNode}
+ */
+ get _rootView() {
+ if (!this.__rootView) {
+ this.__rootView = PlacesUIUtils.getViewForNode(this);
+ }
+ return this.__rootView;
+ }
+
+ /**
+ * Check if we should hide the drop indicator for the target
+ *
+ * @param {object} aEvent
+ * The event associated with the drop.
+ * @returns {boolean}
+ */
+ _hideDropIndicator(aEvent) {
+ let target = aEvent.target;
+
+ // Don't draw the drop indicator outside of markers or if current
+ // node is not a Places node.
+ let betweenMarkers =
+ this._startMarker.compareDocumentPosition(target) &
+ Node.DOCUMENT_POSITION_FOLLOWING &&
+ this._endMarker.compareDocumentPosition(target) &
+ Node.DOCUMENT_POSITION_PRECEDING;
+
+ // Hide the dropmarker if current node is not a Places node.
+ return !(target && target._placesNode && betweenMarkers);
+ }
+
+ /**
+ * This function returns information about where to drop when
+ * dragging over this popup insertion point
+ *
+ * @param {object} aEvent
+ * The event associated with the drop.
+ * @returns {object|null}
+ * The associated drop point information.
+ */
+ _getDropPoint(aEvent) {
+ // Can't drop if the menu isn't a folder
+ let resultNode = this._placesNode;
+
+ if (
+ !PlacesUtils.nodeIsFolder(resultNode) ||
+ this._rootView.controller.disallowInsertion(resultNode)
+ ) {
+ return null;
+ }
+
+ var dropPoint = { ip: null, folderElt: null };
+
+ // The element we are dragging over
+ let elt = aEvent.target;
+ if (elt.localName == "menupopup") {
+ elt = elt.parentNode;
+ }
+
+ let eventY = aEvent.clientY;
+ let { y: eltY, height: eltHeight } = elt.getBoundingClientRect();
+
+ if (!elt._placesNode) {
+ // If we are dragging over a non places node drop at the end.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(resultNode),
+ });
+ // We can set folderElt if we are dropping over a static menu that
+ // has an internal placespopup.
+ let isMenu =
+ elt.localName == "menu" ||
+ (elt.localName == "toolbarbutton" &&
+ elt.getAttribute("type") == "menu");
+ if (
+ isMenu &&
+ elt.lastElementChild &&
+ elt.lastElementChild.hasAttribute("placespopup")
+ ) {
+ dropPoint.folderElt = elt;
+ }
+ return dropPoint;
+ }
+
+ let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode)
+ ? elt._placesNode.title
+ : null;
+ if (
+ (PlacesUtils.nodeIsFolder(elt._placesNode) &&
+ !PlacesUIUtils.isFolderReadOnly(elt._placesNode)) ||
+ PlacesUtils.nodeIsTagQuery(elt._placesNode)
+ ) {
+ // This is a folder or a tag container.
+ if (eventY - eltY < eltHeight * 0.2) {
+ // If mouse is in the top part of the element, drop above folder.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(resultNode),
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ tagName,
+ dropNearNode: elt._placesNode,
+ });
+ return dropPoint;
+ } else if (eventY - eltY < eltHeight * 0.8) {
+ // If mouse is in the middle of the element, drop inside folder.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode),
+ tagName,
+ });
+ dropPoint.folderElt = elt;
+ return dropPoint;
+ }
+ } else if (eventY - eltY <= eltHeight / 2) {
+ // This is a non-folder node or a readonly folder.
+ // If the mouse is above the middle, drop above this item.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(resultNode),
+ orientation: Ci.nsITreeView.DROP_BEFORE,
+ tagName,
+ dropNearNode: elt._placesNode,
+ });
+ return dropPoint;
+ }
+
+ // Drop below the item.
+ dropPoint.ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(resultNode),
+ orientation: Ci.nsITreeView.DROP_AFTER,
+ tagName,
+ dropNearNode: elt._placesNode,
+ });
+ return dropPoint;
+ }
+
+ _cleanupDragDetails() {
+ // Called on dragend and drop.
+ PlacesControllerDragHelper.currentDropTarget = null;
+ this._rootView._draggedElt = null;
+ this.removeAttribute("dragover");
+ this.removeAttribute("dragstart");
+ this._indicatorBar.hidden = true;
+ }
+
+ on_DOMMenuItemActive(event) {
+ if (super.on_DOMMenuItemActive) {
+ super.on_DOMMenuItemActive(event);
+ }
+
+ let elt = event.target;
+ if (elt.parentNode != this) {
+ return;
+ }
+
+ if (window.XULBrowserWindow) {
+ let placesNode = elt._placesNode;
+
+ var linkURI;
+ if (placesNode && PlacesUtils.nodeIsURI(placesNode)) {
+ linkURI = placesNode.uri;
+ } else if (elt.hasAttribute("targetURI")) {
+ linkURI = elt.getAttribute("targetURI");
+ }
+
+ if (linkURI) {
+ window.XULBrowserWindow.setOverLink(linkURI);
+ }
+ }
+ }
+
+ on_DOMMenuItemInactive(event) {
+ let elt = event.target;
+ if (elt.parentNode != this) {
+ return;
+ }
+
+ if (window.XULBrowserWindow) {
+ window.XULBrowserWindow.setOverLink("");
+ }
+ }
+
+ on_dragstart(event) {
+ let elt = event.target;
+ if (!elt._placesNode) {
+ return;
+ }
+
+ let draggedElt = elt._placesNode;
+
+ // Force a copy action if parent node is a query or we are dragging a
+ // not-removable node.
+ if (!this._rootView.controller.canMoveNode(draggedElt)) {
+ event.dataTransfer.effectAllowed = "copyLink";
+ }
+
+ // Activate the view and cache the dragged element.
+ this._rootView._draggedElt = draggedElt;
+ this._rootView.controller.setDataTransfer(event);
+ this.setAttribute("dragstart", "true");
+ event.stopPropagation();
+ }
+
+ on_drop(event) {
+ PlacesControllerDragHelper.currentDropTarget = event.target;
+
+ let dropPoint = this._getDropPoint(event);
+ if (dropPoint && dropPoint.ip) {
+ PlacesControllerDragHelper.onDrop(
+ dropPoint.ip,
+ event.dataTransfer
+ ).catch(Cu.reportError);
+ event.preventDefault();
+ }
+
+ this._cleanupDragDetails();
+ event.stopPropagation();
+ }
+
+ on_dragover(event) {
+ PlacesControllerDragHelper.currentDropTarget = event.target;
+ let dt = event.dataTransfer;
+
+ let dropPoint = this._getDropPoint(event);
+ if (
+ !dropPoint ||
+ !dropPoint.ip ||
+ !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)
+ ) {
+ this._indicatorBar.hidden = true;
+ event.stopPropagation();
+ return;
+ }
+
+ // Mark this popup as being dragged over.
+ this.setAttribute("dragover", "true");
+
+ if (dropPoint.folderElt) {
+ // We are dragging over a folder.
+ // _overFolder should take the care of opening it on a timer.
+ if (
+ this._overFolder.elt &&
+ this._overFolder.elt != dropPoint.folderElt
+ ) {
+ // We are dragging over a new folder, let's clear old values
+ this._overFolder.clear();
+ }
+ if (!this._overFolder.elt) {
+ this._overFolder.elt = dropPoint.folderElt;
+ // Create the timer to open this folder.
+ this._overFolder.openTimer = this._overFolder.setTimer(
+ this._overFolder.hoverTime
+ );
+ }
+ // Since we are dropping into a folder set the corresponding style.
+ dropPoint.folderElt.setAttribute("_moz-menuactive", true);
+ } else {
+ // We are not dragging over a folder.
+ // Clear out old _overFolder information.
+ this._overFolder.clear();
+ }
+
+ // Autoscroll the popup strip if we drag over the scroll buttons.
+ let scrollDir = 0;
+ if (event.originalTarget == this.scrollBox._scrollButtonUp) {
+ scrollDir = -1;
+ } else if (event.originalTarget == this.scrollBox._scrollButtonDown) {
+ scrollDir = 1;
+ }
+ if (scrollDir != 0) {
+ this.scrollBox.scrollByIndex(scrollDir, true);
+ }
+
+ // Check if we should hide the drop indicator for this target.
+ if (dropPoint.folderElt || this._hideDropIndicator(event)) {
+ this._indicatorBar.hidden = true;
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ // We should display the drop indicator relative to the arrowscrollbox.
+ let scrollRect = this.scrollBox.getBoundingClientRect();
+ let newMarginTop = 0;
+ if (scrollDir == 0) {
+ let elt = this.firstElementChild;
+ for (; elt; elt = elt.nextElementSibling) {
+ let height = elt.getBoundingClientRect().height;
+ if (height == 0) {
+ continue;
+ }
+ if (event.screenY <= elt.screenY + height / 2) {
+ break;
+ }
+ }
+ newMarginTop = elt
+ ? elt.screenY - this.scrollBox.screenY
+ : scrollRect.height;
+ } else if (scrollDir == 1) {
+ newMarginTop = scrollRect.height;
+ }
+
+ // Set the new marginTop based on arrowscrollbox.
+ newMarginTop +=
+ scrollRect.y - this._indicatorBar.parentNode.getBoundingClientRect().y;
+ this._indicatorBar.firstElementChild.style.marginTop =
+ newMarginTop + "px";
+ this._indicatorBar.hidden = false;
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ on_dragleave(event) {
+ PlacesControllerDragHelper.currentDropTarget = null;
+ this.removeAttribute("dragover");
+
+ // If we have not moved to a valid new target clear the drop indicator
+ // this happens when moving out of the popup.
+ let target = event.relatedTarget;
+ if (!target || !this.contains(target)) {
+ this._indicatorBar.hidden = true;
+ }
+
+ // Close any folder being hovered over
+ if (this._overFolder.elt) {
+ this._overFolder.closeTimer = this._overFolder.setTimer(
+ this._overFolder.hoverTime
+ );
+ }
+
+ // The autoopened attribute is set when this folder was automatically
+ // opened after the user dragged over it. If this attribute is set,
+ // auto-close the folder on drag exit.
+ // We should also try to close this popup if the drag has started
+ // from here, the timer will check if we are dragging over a child.
+ if (this.hasAttribute("autoopened") || this.hasAttribute("dragstart")) {
+ this._overFolder.closeMenuTimer = this._overFolder.setTimer(
+ this._overFolder.hoverTime
+ );
+ }
+
+ event.stopPropagation();
+ }
+
+ on_dragend(event) {
+ this._cleanupDragDetails();
+ }
+ }
+
+ customElements.define("places-popup", MozPlacesPopup, {
+ extends: "menupopup",
+ });
+
+ /**
+ * Custom element for the places popup arrow.
+ */
+ class MozPlacesPopupArrow extends MozPlacesPopup {
+ constructor() {
+ super();
+
+ const event_names = [
+ "popupshowing",
+ "popuppositioned",
+ "popupshown",
+ "popuphiding",
+ "popuphidden",
+ ];
+ for (let event_name of event_names) {
+ this.addEventListener(event_name, this);
+ }
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ super.connectedCallback();
+ this.initializeAttributeInheritance();
+
+ this.setAttribute("flip", "both");
+ this.setAttribute("side", "top");
+ this.setAttribute("position", "bottomright topright");
+ }
+
+ _setSideAttribute(event) {
+ if (!this.anchorNode) {
+ return;
+ }
+
+ var position = event.alignmentPosition;
+ if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) {
+ // The assigned side stays the same regardless of direction.
+ let isRTL = this.matches(":-moz-locale-dir(rtl)");
+
+ if (position.indexOf("start_") == 0) {
+ this.setAttribute("side", isRTL ? "left" : "right");
+ } else {
+ this.setAttribute("side", isRTL ? "right" : "left");
+ }
+ } else if (
+ position.indexOf("before_") == 0 ||
+ position.indexOf("after_") == 0
+ ) {
+ if (position.indexOf("before_") == 0) {
+ this.setAttribute("side", "bottom");
+ } else {
+ this.setAttribute("side", "top");
+ }
+ }
+ }
+
+ on_popupshowing(event) {
+ if (event.target == this) {
+ this.setAttribute("animate", "open");
+ this.style.pointerEvents = "none";
+ }
+ }
+
+ on_popuppositioned(event) {
+ if (event.target == this) {
+ this._setSideAttribute(event);
+ }
+ }
+
+ on_popupshown(event) {
+ if (event.target != this) {
+ return;
+ }
+
+ this.setAttribute("panelopen", "true");
+ this.style.removeProperty("pointer-events");
+ }
+
+ on_popuphiding(event) {
+ if (event.target == this) {
+ this.setAttribute("animate", "cancel");
+ }
+ }
+
+ on_popuphidden(event) {
+ if (event.target == this) {
+ this.removeAttribute("panelopen");
+ this.removeAttribute("animate");
+ }
+ }
+ }
+
+ customElements.define("places-popup-arrow", MozPlacesPopupArrow, {
+ extends: "menupopup",
+ });
+}
diff --git a/browser/components/places/content/places-tree.js b/browser/components/places/content/places-tree.js
new file mode 100644
index 0000000000..0908431a8b
--- /dev/null
+++ b/browser/components/places/content/places-tree.js
@@ -0,0 +1,861 @@
+/* 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/. */
+
+/* import-globals-from controller.js */
+/* import-globals-from treeView.js */
+
+// This is loaded into all XUL windows. Wrap in a block to prevent
+// leaking to window scope.
+{
+ /**
+ * Custom element definition for the places tree.
+ */
+ class MozPlacesTree extends customElements.get("tree") {
+ constructor() {
+ super();
+
+ this.addEventListener("focus", event => {
+ this._cachedInsertionPoint = undefined;
+ // See select handler. We need the sidebar's places commandset to be
+ // updated as well
+ document.commandDispatcher.updateCommands("focus");
+ });
+
+ this.addEventListener("select", event => {
+ this._cachedInsertionPoint = undefined;
+
+ // This additional complexity is here for the sidebars
+ var win = window;
+ while (true) {
+ win.document.commandDispatcher.updateCommands("focus");
+ if (win == window.top) {
+ break;
+ }
+
+ win = win.parent;
+ }
+ });
+
+ this.addEventListener("dragstart", event => {
+ if (event.target.localName != "treechildren") {
+ return;
+ }
+
+ if (this.disableUserActions) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ let nodes = this.selectedNodes;
+ for (let i = 0; i < nodes.length; i++) {
+ let node = nodes[i];
+
+ // Disallow dragging the root node of a tree.
+ if (!node.parent) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ // If this node is child of a readonly container or cannot be moved,
+ // we must force a copy.
+ if (!this.controller.canMoveNode(node)) {
+ event.dataTransfer.effectAllowed = "copyLink";
+ break;
+ }
+ }
+
+ // Indicate to drag and drop listeners
+ // whether or not this was the start of the drag
+ this._isDragSource = true;
+
+ this._controller.setDataTransfer(event);
+ event.stopPropagation();
+ });
+
+ this.addEventListener("dragover", event => {
+ if (event.target.localName != "treechildren") {
+ return;
+ }
+
+ let cell = this.getCellAt(event.clientX, event.clientY);
+ let node =
+ cell.row != -1
+ ? this.view.nodeForTreeIndex(cell.row)
+ : this.result.root;
+ // cache the dropTarget for the view
+ PlacesControllerDragHelper.currentDropTarget = node;
+
+ // We have to calculate the orientation since view.canDrop will use
+ // it and we want to be consistent with the dropfeedback.
+ let rowHeight = this.rowHeight;
+ let eventY =
+ event.clientY -
+ this.treeBody.getBoundingClientRect().y -
+ rowHeight * (cell.row - this.getFirstVisibleRow());
+
+ let orientation = Ci.nsITreeView.DROP_BEFORE;
+
+ if (cell.row == -1) {
+ // If the row is not valid we try to insert inside the resultNode.
+ orientation = Ci.nsITreeView.DROP_ON;
+ } else if (
+ PlacesUtils.nodeIsContainer(node) &&
+ eventY > rowHeight * 0.75
+ ) {
+ // If we are below the 75% of a container the treeview we try
+ // to drop after the node.
+ orientation = Ci.nsITreeView.DROP_AFTER;
+ } else if (
+ PlacesUtils.nodeIsContainer(node) &&
+ eventY > rowHeight * 0.25
+ ) {
+ // If we are below the 25% of a container the treeview we try
+ // to drop inside the node.
+ orientation = Ci.nsITreeView.DROP_ON;
+ }
+
+ if (!this.view.canDrop(cell.row, orientation, event.dataTransfer)) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ });
+
+ this.addEventListener("dragend", event => {
+ this._isDragSource = false;
+ PlacesControllerDragHelper.currentDropTarget = null;
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ super.connectedCallback();
+ this._contextMenuShown = false;
+
+ this._active = true;
+
+ // Force an initial build.
+ if (this.place) {
+ // eslint-disable-next-line no-self-assign
+ this.place = this.place;
+ }
+ }
+
+ get controller() {
+ return this._controller;
+ }
+
+ set disableUserActions(val) {
+ if (val) {
+ this.setAttribute("disableUserActions", "true");
+ } else {
+ this.removeAttribute("disableUserActions");
+ }
+ }
+
+ get disableUserActions() {
+ return this.getAttribute("disableUserActions") == "true";
+ }
+ /**
+ * overriding
+ *
+ * @param {PlacesTreeView} val
+ * The parent view
+ */
+ set view(val) {
+ // We save the view so that we can avoid expensive get calls when
+ // we need to get the view again.
+ this._view = val;
+ Object.getOwnPropertyDescriptor(
+ // eslint-disable-next-line no-undef
+ XULTreeElement.prototype,
+ "view"
+ ).set.call(this, val);
+ }
+
+ get view() {
+ return this._view;
+ }
+
+ get associatedElement() {
+ return this;
+ }
+
+ set flatList(val) {
+ if (this.flatList != val) {
+ this.setAttribute("flatList", val);
+ // reload with the last place set
+ if (this.place) {
+ // eslint-disable-next-line no-self-assign
+ this.place = this.place;
+ }
+ }
+ }
+
+ get flatList() {
+ return this.getAttribute("flatList") == "true";
+ }
+
+ get result() {
+ try {
+ return this.view.QueryInterface(Ci.nsINavHistoryResultObserver).result;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ set place(val) {
+ this.setAttribute("place", val);
+
+ let query = {},
+ options = {};
+ PlacesUtils.history.queryStringToQuery(val, query, options);
+ this.load(query.value, options.value);
+ }
+
+ get place() {
+ return this.getAttribute("place");
+ }
+
+ get hasSelection() {
+ return this.view && this.view.selection.count >= 1;
+ }
+
+ get selectedNodes() {
+ let nodes = [];
+ if (!this.hasSelection) {
+ return nodes;
+ }
+
+ let selection = this.view.selection;
+ let rc = selection.getRangeCount();
+ let resultview = this.view;
+ for (let i = 0; i < rc; ++i) {
+ let min = {},
+ max = {};
+ selection.getRangeAt(i, min, max);
+ for (let j = min.value; j <= max.value; ++j) {
+ nodes.push(resultview.nodeForTreeIndex(j));
+ }
+ }
+ return nodes;
+ }
+
+ get removableSelectionRanges() {
+ // This property exists in addition to selectedNodes because it
+ // encodes selection ranges (which only occur in list views) into
+ // the return value. For each removed range, the index at which items
+ // will be re-inserted upon the remove transaction being performed is
+ // the first index of the range, so that the view updates correctly.
+ //
+ // For example, if we remove rows 2,3,4 and 7,8 from a list, when we
+ // undo that operation, if we insert what was at row 3 at row 3 again,
+ // it will show up _after_ the item that was at row 5. So we need to
+ // insert all items at row 2, and the tree view will update correctly.
+ //
+ // Also, this function collapses the selection to remove redundant
+ // data, e.g. when deleting this selection:
+ //
+ // http://www.foo.com/
+ // (-) Some Folder
+ // http://www.bar.com/
+ //
+ // ... returning http://www.bar.com/ as part of the selection is
+ // redundant because it is implied by removing "Some Folder". We
+ // filter out all such redundancies since some partial amount of
+ // the folder's children may be selected.
+ //
+ let nodes = [];
+ if (!this.hasSelection) {
+ return nodes;
+ }
+
+ var selection = this.view.selection;
+ var rc = selection.getRangeCount();
+ var resultview = this.view;
+ // This list is kept independently of the range selected (i.e. OUTSIDE
+ // the for loop) since the row index of a container is unique for the
+ // entire view, and we could have some really wacky selection and we
+ // don't want to blow up.
+ var containers = {};
+ for (var i = 0; i < rc; ++i) {
+ var range = [];
+ var min = {},
+ max = {};
+ selection.getRangeAt(i, min, max);
+
+ for (var j = min.value; j <= max.value; ++j) {
+ if (this.view.isContainer(j)) {
+ containers[j] = true;
+ }
+ if (!(this.view.getParentIndex(j) in containers)) {
+ range.push(resultview.nodeForTreeIndex(j));
+ }
+ }
+ nodes.push(range);
+ }
+ return nodes;
+ }
+
+ get draggableSelection() {
+ return this.selectedNodes;
+ }
+
+ get selectedNode() {
+ var view = this.view;
+ if (!view || view.selection.count != 1) {
+ return null;
+ }
+
+ var selection = view.selection;
+ var min = {},
+ max = {};
+ selection.getRangeAt(0, min, max);
+
+ return this.view.nodeForTreeIndex(min.value);
+ }
+
+ get singleClickOpens() {
+ return this.getAttribute("singleclickopens") == "true";
+ }
+
+ get insertionPoint() {
+ // invalidated on selection and focus changes
+ if (this._cachedInsertionPoint !== undefined) {
+ return this._cachedInsertionPoint;
+ }
+
+ // there is no insertion point for history queries
+ // so bail out now and save a lot of work when updating commands
+ var resultNode = this.result.root;
+ if (
+ PlacesUtils.nodeIsQuery(resultNode) &&
+ PlacesUtils.asQuery(resultNode).queryOptions.queryType ==
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ return (this._cachedInsertionPoint = null);
+ }
+
+ var orientation = Ci.nsITreeView.DROP_BEFORE;
+ // If there is no selection, insert at the end of the container.
+ if (!this.hasSelection) {
+ var index = this.view.rowCount - 1;
+ this._cachedInsertionPoint = this._getInsertionPoint(
+ index,
+ orientation
+ );
+ return this._cachedInsertionPoint;
+ }
+
+ // This is a two-part process. The first part is determining the drop
+ // orientation.
+ // * The default orientation is to drop _before_ the selected item.
+ // * If the selected item is a container, the default orientation
+ // is to drop _into_ that container.
+ //
+ // Warning: It may be tempting to use tree indexes in this code, but
+ // you must not, since the tree is nested and as your tree
+ // index may change when folders before you are opened and
+ // closed. You must convert your tree index to a node, and
+ // then use getChildIndex to find your absolute index in
+ // the parent container instead.
+ //
+ var resultView = this.view;
+ var selection = resultView.selection;
+ var rc = selection.getRangeCount();
+ var min = {},
+ max = {};
+ selection.getRangeAt(rc - 1, min, max);
+
+ // If the sole selection is a container, and we are not in
+ // a flatlist, insert into it.
+ // Note that this only applies to _single_ selections,
+ // if the last element within a multi-selection is a
+ // container, insert _adjacent_ to the selection.
+ //
+ // If the sole selection is the bookmarks toolbar folder, we insert
+ // into it even if it is not opened
+ if (
+ selection.count == 1 &&
+ resultView.isContainer(max.value) &&
+ !this.flatList
+ ) {
+ orientation = Ci.nsITreeView.DROP_ON;
+ }
+
+ this._cachedInsertionPoint = this._getInsertionPoint(
+ max.value,
+ orientation
+ );
+ return this._cachedInsertionPoint;
+ }
+
+ get isDragSource() {
+ return this._isDragSource;
+ }
+
+ get ownerWindow() {
+ return window;
+ }
+
+ set active(val) {
+ this._active = val;
+ }
+
+ get active() {
+ return this._active;
+ }
+
+ applyFilter(filterString, folderRestrict, includeHidden) {
+ // preserve grouping
+ var queryNode = PlacesUtils.asQuery(this.result.root);
+ var options = queryNode.queryOptions.clone();
+
+ // Make sure we're getting uri results.
+ // We do not yet support searching into grouped queries or into
+ // tag containers, so we must fall to the default case.
+ if (
+ PlacesUtils.nodeIsHistoryContainer(queryNode) ||
+ PlacesUtils.nodeIsTagQuery(queryNode) ||
+ options.resultType == options.RESULTS_AS_TAGS_ROOT ||
+ options.resultType == options.RESULTS_AS_ROOTS_QUERY
+ ) {
+ options.resultType = options.RESULTS_AS_URI;
+ }
+
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = filterString;
+
+ if (folderRestrict) {
+ query.setParents(folderRestrict);
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ Services.telemetry.keyedScalarAdd("sidebar.search", "bookmarks", 1);
+ }
+
+ options.includeHidden = !!includeHidden;
+
+ this.load(query, options);
+ }
+
+ load(query, options) {
+ let result = PlacesUtils.history.executeQuery(query, options);
+
+ if (!this._controller) {
+ this._controller = new PlacesController(this);
+ this._controller.disableUserActions = this.disableUserActions;
+ this.controllers.appendController(this._controller);
+ }
+
+ let treeView = new PlacesTreeView(this);
+
+ // Observer removal is done within the view itself. When the tree
+ // goes away, view.setTree(null) is called, which then
+ // calls removeObserver.
+ result.addObserver(treeView);
+ this.view = treeView;
+
+ if (
+ this.getAttribute("selectfirstnode") == "true" &&
+ treeView.rowCount > 0
+ ) {
+ treeView.selection.select(0);
+ }
+
+ this._cachedInsertionPoint = undefined;
+ }
+
+ /**
+ * Causes a particular node represented by the specified placeURI to be
+ * selected in the tree. All containers above the node in the hierarchy
+ * will be opened, so that the node is visible.
+ *
+ * @param {string} placeURI
+ * The URI that should be selected
+ */
+ selectPlaceURI(placeURI) {
+ // Do nothing if a node matching the given uri is already selected
+ if (this.hasSelection && this.selectedNode.uri == placeURI) {
+ return;
+ }
+
+ function findNode(container, nodesURIChecked) {
+ var containerURI = container.uri;
+ if (containerURI == placeURI) {
+ return container;
+ }
+ if (nodesURIChecked.includes(containerURI)) {
+ return null;
+ }
+
+ // never check the contents of the same query
+ nodesURIChecked.push(containerURI);
+
+ var wasOpen = container.containerOpen;
+ if (!wasOpen) {
+ container.containerOpen = true;
+ }
+ for (var i = 0; i < container.childCount; ++i) {
+ var child = container.getChild(i);
+ var childURI = child.uri;
+ if (childURI == placeURI) {
+ return child;
+ } else if (PlacesUtils.nodeIsContainer(child)) {
+ var nested = findNode(
+ PlacesUtils.asContainer(child),
+ nodesURIChecked
+ );
+ if (nested) {
+ return nested;
+ }
+ }
+ }
+
+ if (!wasOpen) {
+ container.containerOpen = false;
+ }
+
+ return null;
+ }
+
+ var container = this.result.root;
+ console.assert(container, "No result, cannot select place URI!");
+ if (!container) {
+ return;
+ }
+
+ var child = findNode(container, []);
+ if (child) {
+ this.selectNode(child);
+ } else {
+ // If the specified child could not be located, clear the selection
+ var selection = this.view.selection;
+ selection.clearSelection();
+ }
+ }
+
+ /**
+ * Causes a particular node to be selected in the tree, resulting in all
+ * containers above the node in the hierarchy to be opened, so that the
+ * node is visible.
+ *
+ * @param {object} node
+ * The node that should be selected
+ */
+ selectNode(node) {
+ var view = this.view;
+
+ var parent = node.parent;
+ if (parent && !parent.containerOpen) {
+ // Build a list of all of the nodes that are the parent of this one
+ // in the result.
+ var parents = [];
+ var root = this.result.root;
+ while (parent && parent != root) {
+ parents.push(parent);
+ parent = parent.parent;
+ }
+
+ // Walk the list backwards (opening from the root of the hierarchy)
+ // opening each folder as we go.
+ for (var i = parents.length - 1; i >= 0; --i) {
+ let index = view.treeIndexForNode(parents[i]);
+ if (
+ index != -1 &&
+ view.isContainer(index) &&
+ !view.isContainerOpen(index)
+ ) {
+ view.toggleOpenState(index);
+ }
+ }
+ // Select the specified node...
+ }
+
+ let index = view.treeIndexForNode(node);
+ if (index == -1) {
+ return;
+ }
+
+ view.selection.select(index);
+ // ... and ensure it's visible, not scrolled off somewhere.
+ this.ensureRowIsVisible(index);
+ }
+
+ toggleCutNode(aNode, aValue) {
+ this.view.toggleCutNode(aNode, aValue);
+ }
+
+ _getInsertionPoint(index, orientation) {
+ var result = this.result;
+ var resultview = this.view;
+ var container = result.root;
+ var dropNearNode = null;
+ console.assert(container, "null container");
+ // When there's no selection, assume the container is the container
+ // the view is populated from (i.e. the result's itemId).
+ if (index != -1) {
+ var lastSelected = resultview.nodeForTreeIndex(index);
+ if (
+ resultview.isContainer(index) &&
+ orientation == Ci.nsITreeView.DROP_ON
+ ) {
+ // If the last selected item is an open container, append _into_
+ // it, rather than insert adjacent to it.
+ container = lastSelected;
+ index = -1;
+ } else if (
+ lastSelected.containerOpen &&
+ orientation == Ci.nsITreeView.DROP_AFTER &&
+ lastSelected.hasChildren
+ ) {
+ // If the last selected item is an open container and the user is
+ // trying to drag into it as a first item, really insert into it.
+ container = lastSelected;
+ orientation = Ci.nsITreeView.DROP_ON;
+ index = 0;
+ } else {
+ // Use the last-selected node's container.
+ container = lastSelected.parent;
+
+ // See comment in the treeView.js's copy of this method
+ if (!container || !container.containerOpen) {
+ return null;
+ }
+
+ // Avoid the potentially expensive call to getChildIndex
+ // if we know this container doesn't allow insertion
+ if (this.controller.disallowInsertion(container)) {
+ return null;
+ }
+
+ var queryOptions = PlacesUtils.asQuery(result.root).queryOptions;
+ if (
+ queryOptions.sortingMode !=
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ ) {
+ // If we are within a sorted view, insert at the end
+ index = -1;
+ } else if (queryOptions.excludeItems || queryOptions.excludeQueries) {
+ // Some item may be invisible, insert near last selected one.
+ // We don't replace index here to avoid requests to the db,
+ // instead it will be calculated later by the controller.
+ index = -1;
+ dropNearNode = lastSelected;
+ } else {
+ var lsi = container.getChildIndex(lastSelected);
+ index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1;
+ }
+ }
+ }
+
+ if (this.controller.disallowInsertion(container)) {
+ return null;
+ }
+
+ let tagName = PlacesUtils.nodeIsTagQuery(container)
+ ? PlacesUtils.asQuery(container).query.tags[0]
+ : null;
+
+ return new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(container),
+ index,
+ orientation,
+ tagName,
+ dropNearNode,
+ });
+ }
+
+ selectAll() {
+ this.view.selection.selectAll();
+ }
+
+ /**
+ * This method will select the first node in the tree that matches
+ * each given item guid. It will open any folder nodes that it needs
+ * to in order to show the selected items.
+ *
+ * @param {Array} aGuids
+ * Guids to select.
+ * @param {boolean} aOpenContainers
+ * Whether or not to open containers.
+ */
+ selectItems(aGuids, aOpenContainers) {
+ // Never open containers in flat lists.
+ if (this.flatList) {
+ aOpenContainers = false;
+ }
+ // By default, we do search and select within containers which were
+ // closed (note that containers in which nodes were not found are
+ // closed).
+ if (aOpenContainers === undefined) {
+ aOpenContainers = true;
+ }
+
+ var guids = aGuids; // don't manipulate the caller's array
+
+ // Array of nodes found by findNodes which are to be selected
+ var nodes = [];
+
+ // Array of nodes found by findNodes which should be opened
+ var nodesToOpen = [];
+
+ // A set of GUIDs of container-nodes that were previously searched,
+ // and thus shouldn't be searched again. This is empty at the initial
+ // start of the recursion and gets filled in as the recursion
+ // progresses.
+ var checkedGuidsSet = new Set();
+
+ /**
+ * Recursively search through a node's children for items
+ * with the given GUIDs. When a matching item is found, remove its GUID
+ * from the GUIDs array, and add the found node to the nodes dictionary.
+ *
+ * NOTE: This method will leave open any node that had matching items
+ * in its subtree.
+ *
+ * @param {object} node
+ * The node to search.
+ * @returns {boolean}
+ * Returns true if at least one item was found.
+ */
+ function findNodes(node) {
+ var foundOne = false;
+ // See if node matches an ID we wanted; add to results.
+ // For simple folder queries, check both itemId and the concrete
+ // item id.
+ var index = guids.indexOf(node.bookmarkGuid);
+ if (index == -1) {
+ let concreteGuid = PlacesUtils.getConcreteItemGuid(node);
+ if (concreteGuid != node.bookmarkGuid) {
+ index = guids.indexOf(concreteGuid);
+ }
+ }
+
+ if (index != -1) {
+ nodes.push(node);
+ foundOne = true;
+ guids.splice(index, 1);
+ }
+
+ var concreteGuid = PlacesUtils.getConcreteItemGuid(node);
+ if (
+ !guids.length ||
+ !PlacesUtils.nodeIsContainer(node) ||
+ checkedGuidsSet.has(concreteGuid)
+ ) {
+ return foundOne;
+ }
+
+ // Only follow a query if it has been been explicitly opened by the
+ // caller. We support the "AllBookmarks" case to allow callers to
+ // specify just the top-level bookmark folders.
+ let shouldOpen =
+ aOpenContainers &&
+ (PlacesUtils.nodeIsFolder(node) ||
+ (PlacesUtils.nodeIsQuery(node) &&
+ node.bookmarkGuid == PlacesUIUtils.virtualAllBookmarksGuid));
+
+ PlacesUtils.asContainer(node);
+ if (!node.containerOpen && !shouldOpen) {
+ return foundOne;
+ }
+
+ checkedGuidsSet.add(concreteGuid);
+
+ // Remember the beginning state so that we can re-close
+ // this node if we don't find any additional results here.
+ var previousOpenness = node.containerOpen;
+ node.containerOpen = true;
+ for (var child = 0; child < node.childCount && guids.length; child++) {
+ var childNode = node.getChild(child);
+ var found = findNodes(childNode);
+ if (!foundOne) {
+ foundOne = found;
+ }
+ }
+
+ // If we didn't find any additional matches in this node's
+ // subtree, revert the node to its previous openness.
+ if (foundOne) {
+ nodesToOpen.unshift(node);
+ }
+ node.containerOpen = previousOpenness;
+ return foundOne;
+ }
+
+ // Disable notifications while looking for nodes.
+ let result = this.result;
+ let didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = true;
+ }
+ try {
+ findNodes(this.result.root);
+ } finally {
+ if (!didSuppressNotifications) {
+ result.suppressNotifications = false;
+ }
+ }
+
+ // For all the nodes we've found, highlight the corresponding
+ // index in the tree.
+ var resultview = this.view;
+ var selection = this.view.selection;
+ selection.selectEventsSuppressed = true;
+ selection.clearSelection();
+ // Open nodes containing found items
+ for (let i = 0; i < nodesToOpen.length; i++) {
+ nodesToOpen[i].containerOpen = true;
+ }
+ let firstValidTreeIndex = -1;
+ for (let i = 0; i < nodes.length; i++) {
+ var index = resultview.treeIndexForNode(nodes[i]);
+ if (index == -1) {
+ continue;
+ }
+ if (firstValidTreeIndex < 0 && index >= 0) {
+ firstValidTreeIndex = index;
+ }
+ selection.rangedSelect(index, index, true);
+ }
+ selection.selectEventsSuppressed = false;
+
+ // Bring the first valid node into view if necessary
+ if (firstValidTreeIndex >= 0) {
+ this.ensureRowIsVisible(firstValidTreeIndex);
+ }
+ }
+
+ buildContextMenu(aPopup) {
+ this._contextMenuShown = true;
+ return this.controller.buildContextMenu(aPopup);
+ }
+
+ destroyContextMenu(aPopup) {}
+ disconnectedCallback() {
+ // Unregister the controller before unlinking the view, otherwise it
+ // may still try to update commands on a view with a null result.
+ if (this._controller) {
+ this._controller.terminate();
+ this.controllers.removeController(this._controller);
+ }
+
+ if (this.view) {
+ this.view.uninit();
+ }
+ // view.setTree(null) will be called upon unsetting the view, which
+ // breaks the reference cycle between the PlacesTreeView and result.
+ // See the "setTree" method of PlacesTreeView in treeView.js.
+ this.view = null;
+ }
+ }
+
+ customElements.define("places-tree", MozPlacesTree, {
+ extends: "tree",
+ });
+}
diff --git a/browser/components/places/content/places.css b/browser/components/places/content/places.css
new file mode 100644
index 0000000000..0021e28bb6
--- /dev/null
+++ b/browser/components/places/content/places.css
@@ -0,0 +1,43 @@
+/* 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/. */
+
+:root {
+ /* we eventually want to share this value with the bookmark panel, which is
+ currently using --arrowpanel-padding */
+ --editbookmarkdialog-padding: 1.25em;
+}
+
+tree[is="places-tree"] > treechildren::-moz-tree-cell {
+ /* ensure we use the direction of the website title / url instead of the
+ * browser locale */
+ unicode-bidi: plaintext;
+}
+
+.places-tooltip-title {
+ /* ensure we use the direction of the website title instead of the
+ * browser locale */
+ unicode-bidi: plaintext;
+}
+
+.toolbar-drop-indicator {
+ position: relative;
+ z-index: 1;
+}
+
+/* Apply crisp rendering for favicons at exactly 2dppx resolution */
+@media (resolution: 2dppx) {
+ #bookmarksChildren,
+ .sidebar-placesTreechildren,
+ .placesTree > treechildren {
+ image-rendering: -moz-crisp-edges;
+ }
+}
+
+#searchFilter {
+ max-width: 23em;
+}
+
+.places-tooltip-box {
+ display: block;
+}
diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js
new file mode 100644
index 0000000000..6d78f2c97d
--- /dev/null
+++ b/browser/components/places/content/places.js
@@ -0,0 +1,1499 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* import-globals-from instantEditBookmark.js */
+/* import-globals-from /toolkit/content/contentAreaUtils.js */
+/* import-globals-from /browser/components/downloads/content/allDownloadsView.js */
+
+/* Shared Places Import - change other consumers if you change this: */
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs",
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+ PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PlacesTreeView",
+ "chrome://browser/content/places/treeView.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"],
+ "chrome://browser/content/places/controller.js"
+);
+/* End Shared Places Import */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "DownloadUtils",
+ "resource://gre/modules/DownloadUtils.jsm"
+);
+
+const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4";
+const HISTORY_LIBRARY_SEARCH_TELEMETRY =
+ "PLACES_HISTORY_LIBRARY_SEARCH_TIME_MS";
+
+var PlacesOrganizer = {
+ _places: null,
+
+ _initFolderTree() {
+ this._places.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`;
+ },
+
+ /**
+ * Selects a left pane built-in item.
+ *
+ * @param {string} item The built-in item to select, may be one of (case sensitive):
+ * AllBookmarks, BookmarksMenu, BookmarksToolbar,
+ * History, Downloads, Tags, UnfiledBookmarks.
+ */
+ selectLeftPaneBuiltIn(item) {
+ switch (item) {
+ case "AllBookmarks":
+ this._places.selectItems([PlacesUtils.virtualAllBookmarksGuid]);
+ PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true;
+ break;
+ case "History":
+ this._places.selectItems([PlacesUtils.virtualHistoryGuid]);
+ PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true;
+ break;
+ case "Downloads":
+ this._places.selectItems([PlacesUtils.virtualDownloadsGuid]);
+ break;
+ case "Tags":
+ this._places.selectItems([PlacesUtils.virtualTagsGuid]);
+ break;
+ case "BookmarksMenu":
+ this.selectLeftPaneContainerByHierarchy([
+ PlacesUtils.virtualAllBookmarksGuid,
+ PlacesUtils.bookmarks.virtualMenuGuid,
+ ]);
+ break;
+ case "BookmarksToolbar":
+ this.selectLeftPaneContainerByHierarchy([
+ PlacesUtils.virtualAllBookmarksGuid,
+ PlacesUtils.bookmarks.virtualToolbarGuid,
+ ]);
+ break;
+ case "UnfiledBookmarks":
+ this.selectLeftPaneContainerByHierarchy([
+ PlacesUtils.virtualAllBookmarksGuid,
+ PlacesUtils.bookmarks.virtualUnfiledGuid,
+ ]);
+ break;
+ default:
+ throw new Error(
+ `Unrecognized item ${item} passed to selectLeftPaneRootItem`
+ );
+ }
+ },
+
+ /**
+ * Opens a given hierarchy in the left pane, stopping at the last reachable
+ * container. Note: item ids should be considered deprecated.
+ *
+ * @param {Array | string | number} aHierarchy
+ * A single container or an array of containers, sorted from
+ * the outmost to the innermost in the hierarchy. Each
+ * container may be either an item id, a Places URI string,
+ * or a named query, like:
+ * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks".
+ */
+ selectLeftPaneContainerByHierarchy(aHierarchy) {
+ if (!aHierarchy) {
+ throw new Error("Containers hierarchy not specified");
+ }
+ let hierarchy = [].concat(aHierarchy);
+ let selectWasSuppressed = this._places.view.selection
+ .selectEventsSuppressed;
+ if (!selectWasSuppressed) {
+ this._places.view.selection.selectEventsSuppressed = true;
+ }
+ try {
+ for (let container of hierarchy) {
+ if (typeof container != "string") {
+ throw new Error("Invalid container type found: " + container);
+ }
+
+ try {
+ this.selectLeftPaneBuiltIn(container);
+ } catch (ex) {
+ if (container.substr(0, 6) == "place:") {
+ this._places.selectPlaceURI(container);
+ } else {
+ // Must be a guid.
+ this._places.selectItems([container], false);
+ }
+ }
+ PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true;
+ }
+ } finally {
+ if (!selectWasSuppressed) {
+ this._places.view.selection.selectEventsSuppressed = false;
+ }
+ }
+ },
+
+ init: function PO_init() {
+ // Register the downloads view.
+ const DOWNLOADS_QUERY =
+ "place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
+ "&sort=" +
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+
+ ContentArea.setContentViewForQueryString(
+ DOWNLOADS_QUERY,
+ () =>
+ new DownloadsPlacesView(
+ document.getElementById("downloadsListBox"),
+ false
+ ),
+ {
+ showDetailsPane: false,
+ toolbarSet:
+ "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter",
+ }
+ );
+
+ ContentArea.init();
+
+ this._places = document.getElementById("placesList");
+ this._initFolderTree();
+
+ var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks
+ if (window.arguments && window.arguments[0]) {
+ leftPaneSelection = window.arguments[0];
+ }
+
+ this.selectLeftPaneContainerByHierarchy(leftPaneSelection);
+ if (leftPaneSelection === "History") {
+ let historyNode = this._places.selectedNode;
+ if (historyNode.childCount > 0) {
+ this._places.selectNode(historyNode.getChild(0));
+ }
+ }
+
+ // clear the back-stack
+ this._backHistory.splice(0, this._backHistory.length);
+ document
+ .getElementById("OrganizerCommand:Back")
+ .setAttribute("disabled", true);
+
+ // Set up the search UI.
+ PlacesSearchBox.init();
+
+ window.addEventListener("AppCommand", this, true);
+
+ let placeContentElement = document.getElementById("placeContent");
+ placeContentElement.addEventListener("onOpenFlatContainer", function(e) {
+ PlacesOrganizer.openFlatContainer(e.detail);
+ });
+
+ if (AppConstants.platform === "macosx") {
+ // 1. Map Edit->Find command to OrganizerCommand_find:all. Need to map
+ // both the menuitem and the Find key.
+ let findMenuItem = document.getElementById("menu_find");
+ findMenuItem.setAttribute("command", "OrganizerCommand_find:all");
+ let findKey = document.getElementById("key_find");
+ findKey.setAttribute("command", "OrganizerCommand_find:all");
+
+ // 2. Disable some keybindings from browser.xhtml
+ let elements = ["cmd_handleBackspace", "cmd_handleShiftBackspace"];
+ for (let i = 0; i < elements.length; i++) {
+ document.getElementById(elements[i]).setAttribute("disabled", "true");
+ }
+ }
+
+ // remove the "Edit" and "Edit Bookmark" context-menu item, we're in our own details pane
+ let contextMenu = document.getElementById("placesContext");
+ contextMenu.removeChild(document.getElementById("placesContext_show:info"));
+ contextMenu.removeChild(
+ document.getElementById("placesContext_show_bookmark:info")
+ );
+ contextMenu.removeChild(
+ document.getElementById("placesContext_show_folder:info")
+ );
+
+ if (!Services.policies.isAllowed("profileImport")) {
+ document
+ .getElementById("OrganizerCommand_browserImport")
+ .setAttribute("disabled", true);
+ }
+
+ ContentArea.focus();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([]),
+
+ handleEvent: function PO_handleEvent(aEvent) {
+ if (aEvent.type != "AppCommand") {
+ return;
+ }
+
+ aEvent.stopPropagation();
+ switch (aEvent.command) {
+ case "Back":
+ if (this._backHistory.length) {
+ this.back();
+ }
+ break;
+ case "Forward":
+ if (this._forwardHistory.length) {
+ this.forward();
+ }
+ break;
+ case "Search":
+ PlacesSearchBox.findAll();
+ break;
+ }
+ },
+
+ destroy: function PO_destroy() {},
+
+ _location: null,
+ get location() {
+ return this._location;
+ },
+
+ set location(aLocation) {
+ if (!aLocation || this._location == aLocation) {
+ return;
+ }
+
+ if (this.location) {
+ this._backHistory.unshift(this.location);
+ this._forwardHistory.splice(0, this._forwardHistory.length);
+ }
+
+ this._location = aLocation;
+ this._places.selectPlaceURI(aLocation);
+
+ if (!this._places.hasSelection) {
+ // If no node was found for the given place: uri, just load it directly
+ ContentArea.currentPlace = aLocation;
+ }
+ this.updateDetailsPane();
+
+ // update navigation commands
+ if (!this._backHistory.length) {
+ document
+ .getElementById("OrganizerCommand:Back")
+ .setAttribute("disabled", true);
+ } else {
+ document
+ .getElementById("OrganizerCommand:Back")
+ .removeAttribute("disabled");
+ }
+ if (!this._forwardHistory.length) {
+ document
+ .getElementById("OrganizerCommand:Forward")
+ .setAttribute("disabled", true);
+ } else {
+ document
+ .getElementById("OrganizerCommand:Forward")
+ .removeAttribute("disabled");
+ }
+ },
+
+ _backHistory: [],
+ _forwardHistory: [],
+
+ back: function PO_back() {
+ this._forwardHistory.unshift(this.location);
+ var historyEntry = this._backHistory.shift();
+ this._location = null;
+ this.location = historyEntry;
+ },
+ forward: function PO_forward() {
+ this._backHistory.unshift(this.location);
+ var historyEntry = this._forwardHistory.shift();
+ this._location = null;
+ this.location = historyEntry;
+ },
+
+ /**
+ * Called when a place folder is selected in the left pane.
+ *
+ * @param resetSearchBox
+ * true if the search box should also be reset, false otherwise.
+ * The search box should be reset when a new folder in the left
+ * pane is selected; the search scope and text need to be cleared in
+ * preparation for the new folder. Note that if the user manually
+ * resets the search box, either by clicking its reset button or by
+ * deleting its text, this will be false.
+ */
+ _cachedLeftPaneSelectedURI: null,
+ onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) {
+ // Don't change the right-hand pane contents when there's no selection.
+ if (!this._places.hasSelection) {
+ return;
+ }
+
+ let node = this._places.selectedNode;
+ let placeURI = node.uri;
+
+ // If either the place of the content tree in the right pane has changed or
+ // the user cleared the search box, update the place, hide the search UI,
+ // and update the back/forward buttons by setting location.
+ if (ContentArea.currentPlace != placeURI || !resetSearchBox) {
+ ContentArea.currentPlace = placeURI;
+ this.location = placeURI;
+ }
+
+ // When we invalidate a container we use suppressSelectionEvent, when it is
+ // unset a select event is fired, in many cases the selection did not really
+ // change, so we should check for it, and return early in such a case. Note
+ // that we cannot return any earlier than this point, because when
+ // !resetSearchBox, we need to update location and hide the UI as above,
+ // even though the selection has not changed.
+ if (placeURI == this._cachedLeftPaneSelectedURI) {
+ return;
+ }
+ this._cachedLeftPaneSelectedURI = placeURI;
+
+ // At this point, resetSearchBox is true, because the left pane selection
+ // has changed; otherwise we would have returned earlier.
+
+ let input = PlacesSearchBox.searchFilter;
+ input.value = "";
+ input.editor?.clearUndoRedo();
+ this._setSearchScopeForNode(node);
+ this.updateDetailsPane();
+ },
+
+ /**
+ * Sets the search scope based on aNode's properties.
+ *
+ * @param {object} aNode
+ * the node to set up scope from
+ */
+ _setSearchScopeForNode: function PO__setScopeForNode(aNode) {
+ let itemGuid = aNode.bookmarkGuid;
+
+ if (
+ PlacesUtils.nodeIsHistoryContainer(aNode) ||
+ itemGuid == PlacesUtils.virtualHistoryGuid
+ ) {
+ PlacesQueryBuilder.setScope("history");
+ } else if (itemGuid == PlacesUtils.virtualDownloadsGuid) {
+ PlacesQueryBuilder.setScope("downloads");
+ } else {
+ // Default to All Bookmarks for all other nodes, per bug 469437.
+ PlacesQueryBuilder.setScope("bookmarks");
+ }
+ },
+
+ /**
+ * Handle clicks on the places list.
+ * Single Left click, right click or modified click do not result in any
+ * special action, since they're related to selection.
+ *
+ * @param {object} aEvent
+ * The mouse event.
+ */
+ onPlacesListClick: function PO_onPlacesListClick(aEvent) {
+ // Only handle clicks on tree children.
+ if (aEvent.target.localName != "treechildren") {
+ return;
+ }
+
+ let node = this._places.selectedNode;
+ if (node) {
+ let middleClick = aEvent.button == 1 && aEvent.detail == 1;
+ if (middleClick && PlacesUtils.nodeIsContainer(node)) {
+ // The command execution function will take care of seeing if the
+ // selection is a folder or a different container type, and will
+ // load its contents in tabs.
+ PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this._places);
+ }
+ }
+ },
+
+ /**
+ * Handle focus changes on the places list and the current content view.
+ */
+ updateDetailsPane: function PO_updateDetailsPane() {
+ if (!ContentArea.currentViewOptions.showDetailsPane) {
+ return;
+ }
+ let view = PlacesUIUtils.getViewForNode(document.activeElement);
+ if (view) {
+ let selectedNodes = view.selectedNode
+ ? [view.selectedNode]
+ : view.selectedNodes;
+ this._fillDetailsPane(selectedNodes);
+ }
+ },
+
+ /**
+ * Handle openFlatContainer events.
+ *
+ * @param {object} aContainer
+ * The node the event was dispatched on.
+ */
+ openFlatContainer(aContainer) {
+ if (aContainer.bookmarkGuid) {
+ PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true;
+ this._places.selectItems([aContainer.bookmarkGuid], false);
+ } else if (PlacesUtils.nodeIsQuery(aContainer)) {
+ this._places.selectPlaceURI(aContainer.uri);
+ }
+ },
+
+ /**
+ * @returns {object}
+ * Returns the options associated with the query currently loaded in the
+ * main places pane.
+ */
+ getCurrentOptions: function PO_getCurrentOptions() {
+ return PlacesUtils.asQuery(ContentArea.currentView.result.root)
+ .queryOptions;
+ },
+
+ /**
+ * Show the migration wizard for importing passwords,
+ * cookies, history, preferences, and bookmarks.
+ */
+ importFromBrowser: function PO_importFromBrowser() {
+ // We pass in the type of source we're using for use in telemetry:
+ MigrationUtils.showMigrationWizard(window, {
+ entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES,
+ });
+ },
+
+ /**
+ * Open a file-picker and import the selected file into the bookmarks store
+ */
+ importFromFile: function PO_importFromFile() {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let fpCallback = function fpCallback_done(aResult) {
+ if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) {
+ var { BookmarkHTMLUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/BookmarkHTMLUtils.sys.mjs"
+ );
+ BookmarkHTMLUtils.importFromURL(fp.fileURL.spec).catch(Cu.reportError);
+ }
+ };
+
+ fp.init(
+ window,
+ PlacesUIUtils.getString("SelectImport"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ fp.open(fpCallback);
+ },
+
+ /**
+ * Allows simple exporting of bookmarks.
+ */
+ exportBookmarks: function PO_exportBookmarks() {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let fpCallback = function fpCallback_done(aResult) {
+ if (aResult != Ci.nsIFilePicker.returnCancel) {
+ var { BookmarkHTMLUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/BookmarkHTMLUtils.sys.mjs"
+ );
+ BookmarkHTMLUtils.exportToFile(fp.file.path).catch(Cu.reportError);
+ }
+ };
+
+ fp.init(
+ window,
+ PlacesUIUtils.getString("EnterExport"),
+ Ci.nsIFilePicker.modeSave
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterHTML);
+ fp.defaultString = "bookmarks.html";
+ fp.open(fpCallback);
+ },
+
+ /**
+ * Populates the restore menu with the dates of the backups available.
+ */
+ populateRestoreMenu: function PO_populateRestoreMenu() {
+ let restorePopup = document.getElementById("fileRestorePopup");
+
+ const dtOptions = {
+ dateStyle: "long",
+ };
+ let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions);
+
+ // Remove existing menu items. Last item is the restoreFromFile item.
+ while (restorePopup.childNodes.length > 1) {
+ restorePopup.firstChild.remove();
+ }
+
+ (async function() {
+ let backupFiles = await PlacesBackups.getBackupFiles();
+ if (!backupFiles.length) {
+ return;
+ }
+
+ // Populate menu with backups.
+ for (let i = 0; i < backupFiles.length; i++) {
+ let fileSize = (await IOUtils.stat(backupFiles[i])).size;
+ let [size, unit] = DownloadUtils.convertByteUnits(fileSize);
+ let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", [
+ size,
+ unit,
+ ]);
+ let sizeInfo;
+ let bookmarkCount = PlacesBackups.getBookmarkCountForFile(
+ backupFiles[i]
+ );
+ if (bookmarkCount != null) {
+ sizeInfo =
+ " (" +
+ sizeString +
+ " - " +
+ PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ bookmarkCount,
+ [bookmarkCount]
+ ) +
+ ")";
+ } else {
+ sizeInfo = " (" + sizeString + ")";
+ }
+
+ let backupDate = PlacesBackups.getDateForFile(backupFiles[i]);
+ let m = restorePopup.insertBefore(
+ document.createXULElement("menuitem"),
+ document.getElementById("restoreFromFile")
+ );
+ m.setAttribute("label", dateFormatter.format(backupDate) + sizeInfo);
+ m.setAttribute("value", PathUtils.filename(backupFiles[i]));
+ m.setAttribute(
+ "oncommand",
+ "PlacesOrganizer.onRestoreMenuItemClick(this);"
+ );
+ }
+
+ // Add the restoreFromFile item.
+ restorePopup.insertBefore(
+ document.createXULElement("menuseparator"),
+ document.getElementById("restoreFromFile")
+ );
+ })();
+ },
+
+ /**
+ * Called when a menuitem is selected from the restore menu.
+ *
+ * @param {object} aMenuItem The menuitem that was selected.
+ */
+ async onRestoreMenuItemClick(aMenuItem) {
+ let backupName = aMenuItem.getAttribute("value");
+ let backupFilePaths = await PlacesBackups.getBackupFiles();
+ for (let backupFilePath of backupFilePaths) {
+ if (PathUtils.filename(backupFilePath) == backupName) {
+ PlacesOrganizer.restoreBookmarksFromFile(backupFilePath);
+ break;
+ }
+ }
+ },
+
+ /**
+ * Called when 'Choose File...' is selected from the restore menu.
+ * Prompts for a file and restores bookmarks to those in the file.
+ */
+ onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() {
+ let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile);
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let fpCallback = aResult => {
+ if (aResult != Ci.nsIFilePicker.returnCancel) {
+ this.restoreBookmarksFromFile(fp.file.path);
+ }
+ };
+
+ fp.init(
+ window,
+ PlacesUIUtils.getString("bookmarksRestoreTitle"),
+ Ci.nsIFilePicker.modeOpen
+ );
+ fp.appendFilter(
+ PlacesUIUtils.getString("bookmarksRestoreFilterName"),
+ RESTORE_FILEPICKER_FILTER_EXT
+ );
+ fp.appendFilters(Ci.nsIFilePicker.filterAll);
+ fp.displayDirectory = backupsDir;
+ fp.open(fpCallback);
+ },
+
+ /**
+ * Restores bookmarks from a JSON file.
+ *
+ * @param {string} aFilePath
+ * The path of the file to restore from.
+ */
+ restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) {
+ // check file extension
+ if (
+ !aFilePath.toLowerCase().endsWith("json") &&
+ !aFilePath.toLowerCase().endsWith("jsonlz4")
+ ) {
+ this._showErrorAlert(
+ PlacesUIUtils.getString("bookmarksRestoreFormatError")
+ );
+ return;
+ }
+
+ // confirm ok to delete existing bookmarks
+ if (
+ !Services.prompt.confirm(
+ null,
+ PlacesUIUtils.getString("bookmarksRestoreAlertTitle"),
+ PlacesUIUtils.getString("bookmarksRestoreAlert")
+ )
+ ) {
+ return;
+ }
+
+ (async function() {
+ try {
+ await BookmarkJSONUtils.importFromFile(aFilePath, {
+ replace: true,
+ });
+ } catch (ex) {
+ PlacesOrganizer._showErrorAlert(
+ PlacesUIUtils.getString("bookmarksRestoreParseError")
+ );
+ }
+ })();
+ },
+
+ _showErrorAlert: function PO__showErrorAlert(aMsg) {
+ var brandShortName = document
+ .getElementById("brandStrings")
+ .getString("brandShortName");
+
+ Services.prompt.alert(window, brandShortName, aMsg);
+ },
+
+ /**
+ * Backup bookmarks to desktop, auto-generate a filename with a date.
+ * The file is a JSON serialization of bookmarks, tags and any annotations
+ * of those items.
+ */
+ backupBookmarks: function PO_backupBookmarks() {
+ let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile);
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ let fpCallback = function fpCallback_done(aResult) {
+ if (aResult != Ci.nsIFilePicker.returnCancel) {
+ // There is no OS.File version of the filepicker yet (Bug 937812).
+ PlacesBackups.saveBookmarksToJSONFile(fp.file.path).catch(
+ Cu.reportError
+ );
+ }
+ };
+
+ fp.init(
+ window,
+ PlacesUIUtils.getString("bookmarksBackupTitle"),
+ Ci.nsIFilePicker.modeSave
+ );
+ fp.appendFilter(
+ PlacesUIUtils.getString("bookmarksRestoreFilterName"),
+ RESTORE_FILEPICKER_FILTER_EXT
+ );
+ fp.defaultString = PlacesBackups.getFilenameForDate();
+ fp.defaultExtension = "json";
+ fp.displayDirectory = backupsDir;
+ fp.open(fpCallback);
+ },
+
+ _fillDetailsPane: function PO__fillDetailsPane(aNodeList) {
+ var infoBox = document.getElementById("infoBox");
+ var itemsCountBox = document.getElementById("itemsCountBox");
+
+ // Make sure the infoBox UI is visible if we need to use it, we hide it
+ // below when we don't.
+ infoBox.hidden = false;
+ itemsCountBox.hidden = true;
+
+ let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null;
+
+ // If an input within a panel is focused, force-blur it so its contents
+ // are saved
+ if (gEditItemOverlay.itemId != -1) {
+ var focusedElement = document.commandDispatcher.focusedElement;
+ if (
+ (HTMLInputElement.isInstance(focusedElement) ||
+ HTMLTextAreaElement.isInstance(focusedElement)) &&
+ /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id)
+ ) {
+ focusedElement.blur();
+ }
+
+ // don't update the panel if we are already editing this node unless we're
+ // in multi-edit mode
+ if (selectedNode) {
+ let concreteGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
+ var nodeIsSame =
+ gEditItemOverlay.itemId == selectedNode.itemId ||
+ gEditItemOverlay._paneInfo.itemGuid == concreteGuid ||
+ (selectedNode.itemId == -1 &&
+ gEditItemOverlay.uri &&
+ gEditItemOverlay.uri == selectedNode.uri);
+ if (nodeIsSame && !infoBox.hidden && !gEditItemOverlay.multiEdit) {
+ return;
+ }
+ }
+ }
+
+ // Clean up the panel before initing it again.
+ gEditItemOverlay.uninitPanel(false);
+
+ if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) {
+ gEditItemOverlay
+ .initPanel({
+ node: selectedNode,
+ hiddenRows: ["folderPicker"],
+ })
+ .catch(ex => console.error(ex));
+ } else if (!selectedNode && aNodeList[0]) {
+ if (aNodeList.every(PlacesUtils.nodeIsURI)) {
+ let uris = aNodeList.map(node => Services.io.newURI(node.uri));
+ gEditItemOverlay
+ .initPanel({
+ uris,
+ hiddenRows: ["folderPicker", "location", "keyword", "name"],
+ })
+ .catch(ex => console.error(ex));
+ } else {
+ let selectItemDesc = document.getElementById("selectItemDescription");
+ let itemsCountLabel = document.getElementById("itemsCountText");
+ selectItemDesc.hidden = false;
+ itemsCountLabel.value = PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ aNodeList.length,
+ [aNodeList.length]
+ );
+ infoBox.hidden = true;
+ }
+ } else {
+ infoBox.hidden = true;
+ let selectItemDesc = document.getElementById("selectItemDescription");
+ let itemsCountLabel = document.getElementById("itemsCountText");
+ let itemsCount = 0;
+ if (ContentArea.currentView.result) {
+ let rootNode = ContentArea.currentView.result.root;
+ if (rootNode.containerOpen) {
+ itemsCount = rootNode.childCount;
+ }
+ }
+ if (itemsCount == 0) {
+ selectItemDesc.hidden = true;
+ itemsCountLabel.value = PlacesUIUtils.getString("detailsPane.noItems");
+ } else {
+ selectItemDesc.hidden = false;
+ itemsCountLabel.value = PlacesUIUtils.getPluralString(
+ "detailsPane.itemsCountLabel",
+ itemsCount,
+ [itemsCount]
+ );
+ }
+ }
+ itemsCountBox.hidden = !infoBox.hidden;
+ },
+};
+
+/**
+ * A set of utilities relating to search within Bookmarks and History.
+ */
+var PlacesSearchBox = {
+ /**
+ * The Search text field
+ *
+ * @see {@link https://searchfox.org/mozilla-central/source/toolkit/content/widgets/search-textbox.js}
+ * @returns {HTMLInputElement}
+ */
+ get searchFilter() {
+ return document.getElementById("searchFilter");
+ },
+
+ /**
+ * Folders to include when searching.
+ */
+ _folders: [],
+ get folders() {
+ if (!this._folders.length) {
+ this._folders = PlacesUtils.bookmarks.userContentRoots;
+ }
+ return this._folders;
+ },
+ set folders(aFolders) {
+ this._folders = aFolders;
+ },
+
+ /**
+ * Run a search for the specified text, over the collection specified by
+ * the dropdown arrow. The default is all bookmarks, but can be
+ * localized to the active collection.
+ *
+ * @param {string} filterString
+ * The text to search for.
+ */
+ search: function PSB_search(filterString) {
+ var PO = PlacesOrganizer;
+ // If the user empties the search box manually, reset it and load all
+ // contents of the current scope.
+ // XXX this might be to jumpy, maybe should search for "", so results
+ // are ungrouped, and search box not reset
+ if (filterString == "") {
+ PO.onPlaceSelected(false);
+ return;
+ }
+
+ let currentView = ContentArea.currentView;
+
+ // Search according to the current scope, which was set by
+ // PQB_setScope()
+ switch (PlacesSearchBox.filterCollection) {
+ case "bookmarks":
+ currentView.applyFilter(filterString, this.folders);
+ break;
+ case "history": {
+ let currentOptions = PO.getCurrentOptions();
+ if (
+ currentOptions.queryType !=
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
+ ) {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = filterString;
+ let options = currentOptions.clone();
+ // Make sure we're getting uri results.
+ options.resultType = currentOptions.RESULTS_AS_URI;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;
+ options.includeHidden = true;
+ currentView.load([query], options);
+ } else {
+ TelemetryStopwatch.start(HISTORY_LIBRARY_SEARCH_TELEMETRY);
+ currentView.applyFilter(filterString, null, true);
+ TelemetryStopwatch.finish(HISTORY_LIBRARY_SEARCH_TELEMETRY);
+ }
+ break;
+ }
+ case "downloads": {
+ // The new downloads view doesn't use places for searching downloads.
+ currentView.searchTerm = filterString;
+ break;
+ }
+ default:
+ throw new Error("Invalid filterCollection on search");
+ }
+
+ // Update the details panel
+ PlacesOrganizer.updateDetailsPane();
+ },
+
+ /**
+ * Finds across all history, downloads or all bookmarks.
+ */
+ findAll: function PSB_findAll() {
+ switch (this.filterCollection) {
+ case "history":
+ PlacesQueryBuilder.setScope("history");
+ break;
+ case "downloads":
+ PlacesQueryBuilder.setScope("downloads");
+ break;
+ default:
+ PlacesQueryBuilder.setScope("bookmarks");
+ break;
+ }
+ this.focus();
+ },
+
+ /**
+ * Updates the display with the title of the current collection.
+ *
+ * @param {string} aTitle
+ * The title of the current collection.
+ */
+ updateCollectionTitle: function PSB_updateCollectionTitle(aTitle) {
+ let title = "";
+ switch (this.filterCollection) {
+ case "history":
+ title = PlacesUIUtils.getString("searchHistory");
+ break;
+ case "downloads":
+ title = PlacesUIUtils.getString("searchDownloads");
+ break;
+ default:
+ title = PlacesUIUtils.getString("searchBookmarks");
+ }
+ this.searchFilter.placeholder = title;
+ },
+
+ /**
+ * Gets/sets the active collection from the dropdown menu.
+ *
+ * @returns {string}
+ */
+ get filterCollection() {
+ return this.searchFilter.getAttribute("collection");
+ },
+ set filterCollection(collectionName) {
+ if (collectionName == this.filterCollection) {
+ return;
+ }
+
+ this.searchFilter.setAttribute("collection", collectionName);
+ this.updateCollectionTitle();
+ },
+
+ /**
+ * Focus the search box
+ */
+ focus: function PSB_focus() {
+ this.searchFilter.focus();
+ },
+
+ /**
+ * Set up the gray text in the search bar as the Places View loads.
+ */
+ init: function PSB_init() {
+ this.updateCollectionTitle();
+ },
+
+ /**
+ * Gets or sets the text shown in the Places Search Box
+ *
+ * @returns {string}
+ */
+ get value() {
+ return this.searchFilter.value;
+ },
+ set value(value) {
+ this.searchFilter.value = value;
+ },
+};
+
+/**
+ * Functions and data for advanced query builder
+ */
+var PlacesQueryBuilder = {
+ queries: [],
+ queryOptions: null,
+
+ /**
+ * Sets the search scope. This can be called when no search is active, and
+ * in that case, when the user does begin a search aScope will be used (see
+ * PSB_search()). If there is an active search, it's performed again to
+ * update the content tree.
+ *
+ * @param {string} aScope
+ * The search scope: "bookmarks", "collection", "downloads" or
+ * "history".
+ */
+ setScope: function PQB_setScope(aScope) {
+ // Determine filterCollection, folders, and scopeButtonId based on aScope.
+ var filterCollection;
+ var folders = [];
+ switch (aScope) {
+ case "history":
+ filterCollection = "history";
+ break;
+ case "bookmarks":
+ filterCollection = "bookmarks";
+ folders = PlacesUtils.bookmarks.userContentRoots;
+ break;
+ case "downloads":
+ filterCollection = "downloads";
+ break;
+ default:
+ throw new Error("Invalid search scope");
+ }
+
+ // Update the search box. Re-search if there's an active search.
+ PlacesSearchBox.filterCollection = filterCollection;
+ PlacesSearchBox.folders = folders;
+ var searchStr = PlacesSearchBox.searchFilter.value;
+ if (searchStr) {
+ PlacesSearchBox.search(searchStr);
+ }
+ },
+};
+
+/**
+ * Population and commands for the View Menu.
+ */
+var ViewMenu = {
+ /**
+ * Removes content generated previously from a menupopup.
+ *
+ * @param {object} popup
+ * The popup that contains the previously generated content.
+ * @param {string} startID
+ * The id attribute of an element that is the start of the
+ * dynamically generated region - remove elements after this
+ * item only.
+ * Must be contained by popup. Can be null (in which case the
+ * contents of popup are removed).
+ * @param {string} endID
+ * The id attribute of an element that is the end of the
+ * dynamically generated region - remove elements up to this
+ * item only.
+ * Must be contained by popup. Can be null (in which case all
+ * items until the end of the popup will be removed). Ignored
+ * if startID is null.
+ * @returns {object|null} The element for the caller to insert new items before,
+ * null if the caller should just append to the popup.
+ */
+ _clean: function VM__clean(popup, startID, endID) {
+ if (endID && !startID) {
+ throw new Error("meaningless to have valid endID and null startID");
+ }
+ if (startID) {
+ var startElement = document.getElementById(startID);
+ if (startElement.parentNode != popup) {
+ throw new Error("startElement is not in popup");
+ }
+ if (!startElement) {
+ throw new Error("startID does not correspond to an existing element");
+ }
+ var endElement = null;
+ if (endID) {
+ endElement = document.getElementById(endID);
+ if (endElement.parentNode != popup) {
+ throw new Error("endElement is not in popup");
+ }
+ if (!endElement) {
+ throw new Error("endID does not correspond to an existing element");
+ }
+ }
+ while (startElement.nextSibling != endElement) {
+ popup.removeChild(startElement.nextSibling);
+ }
+ return endElement;
+ }
+ while (popup.hasChildNodes()) {
+ popup.firstChild.remove();
+ }
+ return null;
+ },
+
+ /**
+ * Fills a menupopup with a list of columns
+ *
+ * @param {object} event
+ * The popupshowing event that invoked this function.
+ * @param {string} startID
+ * see _clean
+ * @param {string} endID
+ * see _clean
+ * @param {string} type
+ * the type of the menuitem, e.g. "radio" or "checkbox".
+ * Can be null (no-type).
+ * Checkboxes are checked if the column is visible.
+ * @param {string} propertyPrefix
+ * If propertyPrefix is non-null:
+ * propertyPrefix + column ID + ".label" will be used to get the
+ * localized label string.
+ * propertyPrefix + column ID + ".accesskey" will be used to get the
+ * localized accesskey.
+ * If propertyPrefix is null, the column label is used as label and
+ * no accesskey is assigned.
+ */
+ fillWithColumns: function VM_fillWithColumns(
+ event,
+ startID,
+ endID,
+ type,
+ propertyPrefix
+ ) {
+ var popup = event.target;
+ var pivot = this._clean(popup, startID, endID);
+
+ var content = document.getElementById("placeContent");
+ var columns = content.columns;
+ for (var i = 0; i < columns.count; ++i) {
+ var column = columns.getColumnAt(i).element;
+ var menuitem = document.createXULElement("menuitem");
+ menuitem.id = "menucol_" + column.id;
+ menuitem.column = column;
+ var label = column.getAttribute("label");
+ if (propertyPrefix) {
+ var menuitemPrefix = propertyPrefix;
+ // for string properties, use "name" as the id, instead of "title"
+ // see bug #386287 for details
+ var columnId = column.getAttribute("anonid");
+ menuitemPrefix += columnId == "title" ? "name" : columnId;
+ label = PlacesUIUtils.getString(menuitemPrefix + ".label");
+ var accesskey = PlacesUIUtils.getString(menuitemPrefix + ".accesskey");
+ menuitem.setAttribute("accesskey", accesskey);
+ }
+ menuitem.setAttribute("label", label);
+ if (type == "radio") {
+ menuitem.setAttribute("type", "radio");
+ menuitem.setAttribute("name", "columns");
+ // This column is the sort key. Its item is checked.
+ if (column.getAttribute("sortDirection") != "") {
+ menuitem.setAttribute("checked", "true");
+ }
+ } else if (type == "checkbox") {
+ menuitem.setAttribute("type", "checkbox");
+ // Cannot uncheck the primary column.
+ if (column.getAttribute("primary") == "true") {
+ menuitem.setAttribute("disabled", "true");
+ }
+ // Items for visible columns are checked.
+ if (!column.hidden) {
+ menuitem.setAttribute("checked", "true");
+ }
+ }
+ if (pivot) {
+ popup.insertBefore(menuitem, pivot);
+ } else {
+ popup.appendChild(menuitem);
+ }
+ }
+ event.stopPropagation();
+ },
+
+ /**
+ * Set up the content of the view menu.
+ *
+ * @param {object} event
+ * The event that invoked this function
+ */
+ populateSortMenu: function VM_populateSortMenu(event) {
+ this.fillWithColumns(
+ event,
+ "viewUnsorted",
+ "directionSeparator",
+ "radio",
+ "view.sortBy.1."
+ );
+
+ var sortColumn = this._getSortColumn();
+ var viewSortAscending = document.getElementById("viewSortAscending");
+ var viewSortDescending = document.getElementById("viewSortDescending");
+ // We need to remove an existing checked attribute because the unsorted
+ // menu item is not rebuilt every time we open the menu like the others.
+ var viewUnsorted = document.getElementById("viewUnsorted");
+ if (!sortColumn) {
+ viewSortAscending.removeAttribute("checked");
+ viewSortDescending.removeAttribute("checked");
+ viewUnsorted.setAttribute("checked", "true");
+ } else if (sortColumn.getAttribute("sortDirection") == "ascending") {
+ viewSortAscending.setAttribute("checked", "true");
+ viewSortDescending.removeAttribute("checked");
+ viewUnsorted.removeAttribute("checked");
+ } else if (sortColumn.getAttribute("sortDirection") == "descending") {
+ viewSortDescending.setAttribute("checked", "true");
+ viewSortAscending.removeAttribute("checked");
+ viewUnsorted.removeAttribute("checked");
+ }
+ },
+
+ /**
+ * Shows/Hides a tree column.
+ *
+ * @param {object} element
+ * The menuitem element for the column
+ */
+ showHideColumn: function VM_showHideColumn(element) {
+ var column = element.column;
+
+ var splitter = column.nextSibling;
+ if (splitter && splitter.localName != "splitter") {
+ splitter = null;
+ }
+
+ const isChecked = element.getAttribute("checked") == "true";
+ column.hidden = !isChecked;
+ if (splitter) {
+ splitter.hidden = !isChecked;
+ }
+ },
+
+ /**
+ * Gets the last column that was sorted.
+ *
+ * @returns {object|null} the currently sorted column, null if there is no sorted column.
+ */
+ _getSortColumn: function VM__getSortColumn() {
+ var content = document.getElementById("placeContent");
+ var cols = content.columns;
+ for (var i = 0; i < cols.count; ++i) {
+ var column = cols.getColumnAt(i).element;
+ var sortDirection = column.getAttribute("sortDirection");
+ if (sortDirection == "ascending" || sortDirection == "descending") {
+ return column;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Sorts the view by the specified column.
+ *
+ * @param {object} aColumn
+ * The colum that is the sort key. Can be null - the
+ * current sort column or the title column will be used.
+ * @param {string} aDirection
+ * The direction to sort - "ascending" or "descending".
+ * Can be null - the last direction or descending will be used.
+ *
+ * If both aColumnID and aDirection are null, the view will be unsorted.
+ */
+ setSortColumn: function VM_setSortColumn(aColumn, aDirection) {
+ var result = document.getElementById("placeContent").result;
+ if (!aColumn && !aDirection) {
+ result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE;
+ return;
+ }
+
+ var columnId;
+ if (aColumn) {
+ columnId = aColumn.getAttribute("anonid");
+ if (!aDirection) {
+ let sortColumn = this._getSortColumn();
+ if (sortColumn) {
+ aDirection = sortColumn.getAttribute("sortDirection");
+ }
+ }
+ } else {
+ let sortColumn = this._getSortColumn();
+ columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title";
+ }
+
+ // This maps the possible values of columnId (i.e., anonid's of treecols in
+ // placeContent) to the default sortingMode for each column.
+ // key: Sort key in the name of one of the
+ // nsINavHistoryQueryOptions.SORT_BY_* constants
+ // dir: Default sort direction to use if none has been specified
+ const colLookupTable = {
+ title: { key: "TITLE", dir: "ascending" },
+ tags: { key: "TAGS", dir: "ascending" },
+ url: { key: "URI", dir: "ascending" },
+ date: { key: "DATE", dir: "descending" },
+ visitCount: { key: "VISITCOUNT", dir: "descending" },
+ dateAdded: { key: "DATEADDED", dir: "descending" },
+ lastModified: { key: "LASTMODIFIED", dir: "descending" },
+ };
+
+ // Make sure we have a valid column.
+ if (!colLookupTable.hasOwnProperty(columnId)) {
+ throw new Error("Invalid column");
+ }
+
+ // Use a default sort direction if none has been specified. If aDirection
+ // is invalid, result.sortingMode will be undefined, which has the effect
+ // of unsorting the tree.
+ aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase();
+
+ var sortConst =
+ "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection;
+ result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst];
+ },
+};
+
+var ContentArea = {
+ _specialViews: new Map(),
+
+ init: function CA_init() {
+ this._box = document.getElementById("placesViewsBox");
+ this._toolbar = document.getElementById("placesToolbar");
+ ContentTree.init();
+ this._setupView();
+ },
+
+ /**
+ * Gets the content view to be used for loading the given query.
+ * If a custom view was set by setContentViewForQueryString, that
+ * view would be returned, else the default tree view is returned
+ *
+ * @param {string} aQueryString
+ * a query string
+ * @returns {object} the view to be used for loading aQueryString.
+ */
+ getContentViewForQueryString: function CA_getContentViewForQueryString(
+ aQueryString
+ ) {
+ try {
+ if (this._specialViews.has(aQueryString)) {
+ let { view, options } = this._specialViews.get(aQueryString);
+ if (typeof view == "function") {
+ view = view();
+ this._specialViews.set(aQueryString, { view, options });
+ }
+ return view;
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ return ContentTree.view;
+ },
+
+ /**
+ * Sets a custom view to be used rather than the default places tree
+ * whenever the given query is selected in the left pane.
+ *
+ * @param {string} aQueryString
+ * a query string
+ * @param {object} aView
+ * Either the custom view or a function that will return the view
+ * the first (and only) time it's called.
+ * @param {object} [aOptions]
+ * Object defining special options for the view.
+ * @see ContentTree.viewOptions for supported options and default values.
+ */
+ setContentViewForQueryString: function CA_setContentViewForQueryString(
+ aQueryString,
+ aView,
+ aOptions
+ ) {
+ if (
+ !aQueryString ||
+ (typeof aView != "object" && typeof aView != "function")
+ ) {
+ throw new Error("Invalid arguments");
+ }
+
+ this._specialViews.set(aQueryString, {
+ view: aView,
+ options: aOptions || {},
+ });
+ },
+
+ get currentView() {
+ let selectedPane = [...this._box.children].filter(
+ child => !child.hidden
+ )[0];
+ return PlacesUIUtils.getViewForNode(selectedPane);
+ },
+ set currentView(aNewView) {
+ let oldView = this.currentView;
+ if (oldView != aNewView) {
+ oldView.associatedElement.hidden = true;
+ aNewView.associatedElement.hidden = false;
+
+ // If the content area inactivated view was focused, move focus
+ // to the new view.
+ if (document.activeElement == oldView.associatedElement) {
+ aNewView.associatedElement.focus();
+ }
+ }
+ },
+
+ get currentPlace() {
+ return this.currentView.place;
+ },
+ set currentPlace(aQueryString) {
+ let oldView = this.currentView;
+ let newView = this.getContentViewForQueryString(aQueryString);
+ newView.place = aQueryString;
+ if (oldView != newView) {
+ oldView.active = false;
+ this.currentView = newView;
+ this._setupView();
+ newView.active = true;
+ }
+ },
+
+ /**
+ * Applies view options.
+ */
+ _setupView: function CA__setupView() {
+ let options = this.currentViewOptions;
+
+ // showDetailsPane.
+ let detailsPane = document.getElementById("detailsPane");
+ detailsPane.hidden = !options.showDetailsPane;
+
+ // toolbarSet.
+ for (let elt of this._toolbar.childNodes) {
+ // On Windows and Linux the menu buttons are menus wrapped in a menubar.
+ if (elt.id == "placesMenu") {
+ for (let menuElt of elt.childNodes) {
+ menuElt.hidden = !options.toolbarSet.includes(menuElt.id);
+ }
+ } else {
+ elt.hidden = !options.toolbarSet.includes(elt.id);
+ }
+ }
+ },
+
+ /**
+ * Options for the current view.
+ *
+ * @see {@link ContentTree.viewOptions} for supported options and default values.
+ * @returns {{showDetailsPane: boolean;toolbarSet: string;}}
+ */
+ get currentViewOptions() {
+ // Use ContentTree options as default.
+ let viewOptions = ContentTree.viewOptions;
+ if (this._specialViews.has(this.currentPlace)) {
+ let { options } = this._specialViews.get(this.currentPlace);
+ for (let option in options) {
+ viewOptions[option] = options[option];
+ }
+ }
+ return viewOptions;
+ },
+
+ focus() {
+ this.currentView.associatedElement.focus();
+ },
+};
+
+var ContentTree = {
+ init: function CT_init() {
+ this._view = document.getElementById("placeContent");
+ },
+
+ get view() {
+ return this._view;
+ },
+
+ get viewOptions() {
+ return Object.seal({
+ showDetailsPane: true,
+ toolbarSet:
+ "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter",
+ });
+ },
+
+ openSelectedNode: function CT_openSelectedNode(aEvent) {
+ let view = this.view;
+ PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent);
+ },
+
+ onClick: function CT_onClick(aEvent) {
+ let node = this.view.selectedNode;
+ if (node) {
+ let doubleClick = aEvent.button == 0 && aEvent.detail == 2;
+ let middleClick = aEvent.button == 1 && aEvent.detail == 1;
+ if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) {
+ // Open associated uri in the browser.
+ this.openSelectedNode(aEvent);
+ } else if (middleClick && PlacesUtils.nodeIsContainer(node)) {
+ // The command execution function will take care of seeing if the
+ // selection is a folder or a different container type, and will
+ // load its contents in tabs.
+ PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this.view);
+ }
+ }
+ },
+
+ onKeyPress: function CT_onKeyPress(aEvent) {
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ this.openSelectedNode(aEvent);
+ }
+ },
+};
diff --git a/browser/components/places/content/places.xhtml b/browser/components/places/content/places.xhtml
new file mode 100644
index 0000000000..82acf97e32
--- /dev/null
+++ b/browser/components/places/content/places.xhtml
@@ -0,0 +1,416 @@
+<?xml version="1.0"?>
+
+# 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/.
+
+<?xml-stylesheet href="chrome://browser/content/places/places.css"?>
+<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?>
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/organizer.css"?>
+
+<?xml-stylesheet href="chrome://browser/content/downloads/downloads.css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/allDownloadsView.css"?>
+
+<!DOCTYPE window>
+
+<window id="places"
+ data-l10n-id="places-library3"
+ windowtype="Places:Organizer"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="PlacesOrganizer.init();"
+ onunload="PlacesOrganizer.destroy();"
+ width="800" height="500"
+ screenX="10" screenY="10"
+ toggletoolbar="true"
+ persist="width height screenX screenY sizemode">
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/browserSets.ftl"/>
+ <html:link rel="localization" href="browser/places.ftl"/>
+ <html:link rel="localization" href="browser/downloads.ftl"/>
+ <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/>
+ </linkset>
+
+ <script src="chrome://browser/content/places/places.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+#ifndef XP_MACOSX
+ <!-- On Mac, this is included via macWindow.inc.xhtml -> global-scripts.inc -> browser.js -> defineLazyScriptGetter -->
+ <script>
+ if (Services.prefs.getBoolPref("browser.bookmarks.editDialog.delayedApply.enabled", false)) {
+ Services.scriptloader.loadSubScript("chrome://browser/content/places/editBookmark.js", this);
+ } else {
+ Services.scriptloader.loadSubScript("chrome://browser/content/places/instantEditBookmark.js", this);
+ }
+ </script>
+ <!-- On Mac, thes are included via macWindow.inc.xhtml -> global-scripts.inc -->
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+#endif
+
+ <stringbundleset id="placesStringSet">
+ <stringbundle id="brandStrings" src="chrome://branding/locale/brand.properties"/>
+ </stringbundleset>
+
+#ifdef XP_MACOSX
+#include ../../../base/content/macWindow.inc.xhtml
+#else
+#include placesCommands.inc.xhtml
+#endif
+
+ <!-- This must be included after macWindow.inc.xhtml to override DownloadsView -->
+ <script src="chrome://browser/content/downloads/allDownloadsView.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://browser/content/places/places-tree.js"/>
+
+ <commandset id="organizerCommandSet">
+ <command id="OrganizerCommand_find:all"
+ oncommand="PlacesSearchBox.findAll();"/>
+ <command id="OrganizerCommand_export"
+ oncommand="PlacesOrganizer.exportBookmarks();"/>
+ <command id="OrganizerCommand_import"
+ oncommand="PlacesOrganizer.importFromFile();"/>
+ <command id="OrganizerCommand_browserImport"
+ oncommand="PlacesOrganizer.importFromBrowser();"/>
+ <command id="OrganizerCommand_backup"
+ oncommand="PlacesOrganizer.backupBookmarks();"/>
+ <command id="OrganizerCommand_restoreFromFile"
+ oncommand="PlacesOrganizer.onRestoreBookmarksFromFile();"/>
+ <command id="OrganizerCommand_search:save"
+ oncommand="PlacesOrganizer.saveSearch();"/>
+ <command id="OrganizerCommand_search:moreCriteria"
+ oncommand="PlacesQueryBuilder.addRow();"/>
+ <command id="OrganizerCommand:Back"
+ oncommand="PlacesOrganizer.back();"/>
+ <command id="OrganizerCommand:Forward"
+ oncommand="PlacesOrganizer.forward();"/>
+ </commandset>
+#include ../../downloads/content/downloadsCommands.inc.xhtml
+
+ <keyset id="placesOrganizerKeyset">
+ <!-- Instantiation Keys -->
+ <key id="placesKey_close" data-l10n-id="places-cmd-close" modifiers="accel"
+ oncommand="window.close();"/>
+
+ <!-- Command Keys -->
+ <key id="placesKey_find:all"
+ command="OrganizerCommand_find:all"
+ data-l10n-id="places-cmd-find-key"
+ modifiers="accel"/>
+
+ <!-- Back/Forward Keys Support -->
+#ifndef XP_MACOSX
+ <key id="placesKey_goBackKb"
+ keycode="VK_LEFT"
+ command="OrganizerCommand:Back"
+ modifiers="alt"/>
+ <key id="placesKey_goForwardKb"
+ keycode="VK_RIGHT"
+ command="OrganizerCommand:Forward"
+ modifiers="alt"/>
+#else
+ <key id="placesKey_goBackKb"
+ keycode="VK_LEFT"
+ command="OrganizerCommand:Back"
+ modifiers="accel"/>
+ <key id="placesKey_goForwardKb"
+ keycode="VK_RIGHT"
+ command="OrganizerCommand:Forward"
+ modifiers="accel"/>
+#endif
+#ifdef XP_UNIX
+ <key id="placesKey_goBackKb2"
+ data-l10n-id="nav-back-shortcut-alt"
+ command="OrganizerCommand:Back"
+ modifiers="accel"/>
+ <key id="placesKey_goForwardKb2"
+ data-l10n-id="nav-fwd-shortcut-alt"
+ command="OrganizerCommand:Forward"
+ modifiers="accel"/>
+#endif
+ </keyset>
+
+#include ../../../../toolkit/content/editMenuKeys.inc.xhtml
+#ifdef XP_MACOSX
+ <keyset id="editMenuKeysExtra">
+ <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/>
+ </keyset>
+#endif
+
+ <popupset id="placesPopupset">
+#include placesContextMenu.inc.xhtml
+ <menupopup id="placesColumnsContext"
+ onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);"
+ oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/>
+#include ../../downloads/content/downloadsContextMenu.inc.xhtml
+ </popupset>
+
+ <toolbox id="placesToolbox">
+ <toolbar class="chromeclass-toolbar" id="placesToolbar" align="center">
+ <toolbarbutton id="back-button"
+ command="OrganizerCommand:Back"
+ data-l10n-id="places-back-button"
+ disabled="true"/>
+
+ <toolbarbutton id="forward-button"
+ command="OrganizerCommand:Forward"
+ data-l10n-id="places-forward-button"
+ disabled="true"/>
+
+#ifdef XP_MACOSX
+ <toolbarbutton type="menu" class="tabbable" wantdropmarker="true"
+ onpopupshowing="document.getElementById('placeContent').focus()"
+ data-l10n-id="places-organize-button-mac"
+#else
+ <menubar id="placesMenu">
+ <menu class="menu-iconic" data-l10n-id="places-organize-button"
+#endif
+ id="organizeButton">
+ <menupopup id="organizeButtonPopup">
+ <menuitem id="newbookmark"
+ command="placesCmd_new:bookmark"
+ data-l10n-id="places-add-bookmark"/>
+ <menuitem id="newfolder"
+ command="placesCmd_new:folder"
+ data-l10n-id="places-add-folder"/>
+ <menuitem id="newseparator"
+ command="placesCmd_new:separator"
+ data-l10n-id="places-add-separator"/>
+
+#ifdef XP_MACOSX
+ <menuseparator id="orgDeleteSeparator"/>
+
+ <menuitem id="orgDelete"
+ command="cmd_delete"
+ data-l10n-id="text-action-delete"
+ key="key_delete"/>
+#else
+ <menuseparator id="orgUndoSeparator"/>
+
+ <menuitem id="orgUndo"
+ command="cmd_undo"
+ data-l10n-id="text-action-undo"
+ key="key_undo"/>
+ <menuitem id="orgRedo"
+ command="cmd_redo"
+ data-l10n-id="text-action-redo"
+ key="key_redo"/>
+
+ <menuseparator id="orgCutSeparator"/>
+
+ <menuitem id="orgCut"
+ command="cmd_cut"
+ data-l10n-id="text-action-cut"
+ key="key_cut"
+ selection="separator|link|folder|mixed"/>
+ <menuitem id="orgCopy"
+ command="cmd_copy"
+ data-l10n-id="text-action-copy"
+ key="key_copy"
+ selection="separator|link|folder|mixed"/>
+ <menuitem id="orgPaste"
+ command="cmd_paste"
+ data-l10n-id="text-action-paste"
+ key="key_paste"
+ selection="mutable"/>
+ <menuitem id="orgDelete"
+ command="cmd_delete"
+ data-l10n-id="text-action-delete"
+ key="key_delete"/>
+
+ <menuseparator id="selectAllSeparator"/>
+
+ <menuitem id="orgSelectAll"
+ command="cmd_selectAll"
+ data-l10n-id="text-action-select-all"
+ key="key_selectAll"/>
+
+ <menuseparator id="orgCloseSeparator"/>
+
+ <menuitem id="orgClose"
+ key="placesKey_close"
+ data-l10n-id="places-file-close"
+ oncommand="window.close();"/>
+#endif
+ </menupopup>
+#ifdef XP_MACOSX
+ </toolbarbutton>
+ <toolbarbutton type="menu" class="tabbable" wantdropmarker="true"
+ data-l10n-id="places-view-button-mac"
+#else
+ </menu>
+ <menu class="menu-iconic" data-l10n-id="places-view-button"
+#endif
+ id="viewMenu">
+ <menupopup id="viewMenuPopup">
+
+ <menu id="viewColumns"
+ data-l10n-id="places-view-menu-columns">
+ <menupopup onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);"
+ oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/>
+ </menu>
+
+ <menu id="viewSort" data-l10n-id="places-view-menu-sort">
+ <menupopup onpopupshowing="ViewMenu.populateSortMenu(event);"
+ oncommand="ViewMenu.setSortColumn(event.target.column, null);">
+ <menuitem id="viewUnsorted" type="radio" name="columns"
+ data-l10n-id="places-view-sort-unsorted"
+ oncommand="ViewMenu.setSortColumn(null, null);"/>
+ <menuseparator id="directionSeparator"/>
+ <menuitem id="viewSortAscending" type="radio" name="direction"
+ data-l10n-id="places-view-sort-ascending"
+ oncommand="ViewMenu.setSortColumn(null, 'ascending'); event.stopPropagation();"/>
+ <menuitem id="viewSortDescending" type="radio" name="direction"
+ data-l10n-id="places-view-sort-descending"
+ oncommand="ViewMenu.setSortColumn(null, 'descending'); event.stopPropagation();"/>
+ </menupopup>
+ </menu>
+ </menupopup>
+#ifdef XP_MACOSX
+ </toolbarbutton>
+ <toolbarbutton type="menu" class="tabbable" wantdropmarker="true"
+ data-l10n-id="places-maintenance-button-mac"
+#else
+ </menu>
+ <menu class="menu-iconic" data-l10n-id="places-maintenance-button"
+#endif
+ id="maintenanceButton">
+ <menupopup id="maintenanceButtonPopup">
+ <menuitem id="backupBookmarks"
+ command="OrganizerCommand_backup"
+ data-l10n-id="places-cmd-backup"/>
+ <menu id="fileRestoreMenu" data-l10n-id="places-cmd-restore">
+ <menupopup id="fileRestorePopup" onpopupshowing="PlacesOrganizer.populateRestoreMenu();">
+ <menuitem id="restoreFromFile"
+ command="OrganizerCommand_restoreFromFile"
+ data-l10n-id="places-cmd-restore-from-file"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menuitem id="fileImport"
+ command="OrganizerCommand_import"
+ data-l10n-id="places-import-bookmarks-from-html"/>
+ <menuitem id="fileExport"
+ command="OrganizerCommand_export"
+ data-l10n-id="places-export-bookmarks-to-html"/>
+ <menuseparator/>
+ <menuitem id="browserImport"
+ command="OrganizerCommand_browserImport"
+ data-l10n-id="places-import-other-browser"/>
+ </menupopup>
+#ifdef XP_MACOSX
+ </toolbarbutton>
+#else
+ </menu>
+ </menubar>
+#endif
+
+ <toolbarbutton id="clearDownloadsButton"
+ data-l10n-id="downloads-clear-downloads-button"
+ class="tabbable"
+ command="downloadsCmd_clearDownloads"/>
+
+ <spacer id="libraryToolbarSpacer" flex="1"/>
+
+ <search-textbox id="searchFilter"
+ flex="1"
+ aria-controls="placeContent"
+ oncommand="PlacesSearchBox.search(this.value);"
+ collection="bookmarks"/>
+ </toolbar>
+ </toolbox>
+
+ <hbox flex="1" id="placesView">
+ <tree id="placesList"
+ class="plain placesTree"
+ is="places-tree"
+ hidecolumnpicker="true" context="placesContext"
+ onselect="PlacesOrganizer.onPlaceSelected(true);"
+ onclick="PlacesOrganizer.onPlacesListClick(event);"
+ onfocus="PlacesOrganizer.updateDetailsPane(event);"
+ seltype="single"
+ persist="width"
+ width="200"
+ minwidth="100"
+ maxwidth="400">
+ <treecols>
+ <treecol anonid="title" flex="1" primary="true" hideheader="true"/>
+ </treecols>
+ <treechildren flex="1"/>
+ </tree>
+ <splitter collapse="none" persist="state"></splitter>
+ <vbox id="contentView" style="-moz-box-flex: 4">
+ <vbox id="placesViewsBox" flex="1">
+ <tree id="placeContent"
+ class="plain placesTree"
+ context="placesContext"
+ hidecolumnpicker="true"
+ flex="1"
+ is="places-tree"
+ flatList="true"
+ selectfirstnode="true"
+ enableColumnDrag="true"
+ onfocus="PlacesOrganizer.updateDetailsPane(event)"
+ onselect="PlacesOrganizer.updateDetailsPane(event)"
+ onkeypress="ContentTree.onKeyPress(event);">
+ <treecols id="placeContentColumns" context="placesColumnsContext">
+ <!--
+ The below code may suggest that 'ordinal' is still a supported XUL
+ attribute. It is not. This is a crutch so that we can continue
+ persisting the CSS -moz-box-ordinal-group attribute, which is the
+ appropriate replacement for the ordinal attribute but cannot yet
+ be easily persisted. The code that synchronizes the attribute with
+ the CSS lives in toolkit/content/widget/tree.js and is specific to
+ tree elements.
+ -->
+ <treecol data-l10n-id="places-view-sort-col-name" id="placesContentTitle" anonid="title" style="-moz-box-flex: 5" primary="true" ordinal="1"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-tags" id="placesContentTags" anonid="tags" style="-moz-box-flex: 2"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-url" id="placesContentUrl" anonid="url" style="-moz-box-flex: 5"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-most-recent-visit" id="placesContentDate" anonid="date" style="-moz-box-flex: 1" hidden="true"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-visit-count" id="placesContentVisitCount" anonid="visitCount" style="-moz-box-flex: 1" hidden="true"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-date-added" id="placesContentDateAdded" anonid="dateAdded" style="-moz-box-flex: 1" hidden="true"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ <splitter class="tree-splitter"/>
+ <treecol data-l10n-id="places-view-sort-col-last-modified" id="placesContentLastModified" anonid="lastModified" style="-moz-box-flex: 1" hidden="true"
+ persist="width hidden ordinal sortActive sortDirection"/>
+ </treecols>
+ <treechildren flex="1" onclick="ContentTree.onClick(event);"/>
+ </tree>
+ <richlistbox flex="1"
+ hidden="true"
+ seltype="multiple"
+ id="downloadsListBox"
+ class="allDownloadsListBox"
+ context="downloadsContextMenu"/>
+ </vbox>
+ <vbox id="detailsPane">
+ <vbox id="itemsCountBox" align="center" flex="1" hidden="true">
+ <spacer style="-moz-box-flex: 3"/>
+ <label id="itemsCountText"/>
+ <spacer flex="1"/>
+ <description id="selectItemDescription" data-l10n-id="places-details-pane-select-an-item-description">
+ </description>
+ <spacer style="-moz-box-flex: 3"/>
+ </vbox>
+ <vbox id="infoBox">
+#include editBookmarkPanel.inc.xhtml
+ </vbox>
+ </vbox>
+ </vbox>
+ </hbox>
+</window>
diff --git a/browser/components/places/content/placesCommands.inc.xhtml b/browser/components/places/content/placesCommands.inc.xhtml
new file mode 100644
index 0000000000..a3676ba6b1
--- /dev/null
+++ b/browser/components/places/content/placesCommands.inc.xhtml
@@ -0,0 +1,52 @@
+# 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/.
+
+<commandset id="placesCommands"
+ commandupdater="true"
+ events="focus,sort,places"
+ oncommandupdate="PlacesUIUtils.updateCommands(window);">
+ <command id="Browser:ShowAllBookmarks"
+ oncommand="PlacesCommandHook.showPlacesOrganizer('UnfiledBookmarks');"/>
+ <command id="Browser:ShowAllHistory"
+ oncommand="PlacesCommandHook.showPlacesOrganizer('History');"/>
+
+ <command id="placesCmd_open"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open');"/>
+ <command id="placesCmd_open:window"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:window');"/>
+ <command id="placesCmd_open:privatewindow"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:privatewindow');"/>
+ <command id="placesCmd_open:tab"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:tab');"/>
+
+ <command id="placesCmd_new:bookmark"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:bookmark');"/>
+ <command id="placesCmd_new:folder"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:folder');"/>
+ <command id="placesCmd_new:separator"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:separator');"/>
+ <command id="placesCmd_show:info"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');"/>
+ <command id="placesCmd_rename"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');"
+ observes="placesCmd_show:info"/>
+ <command id="placesCmd_sortBy:name"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_sortBy:name');"/>
+ <command id="placesCmd_deleteDataHost"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_deleteDataHost');"/>
+ <command id="placesCmd_createBookmark"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_createBookmark');"/>
+
+ <!-- Special versions of cut/copy/paste/delete which check for an open context menu. -->
+ <command id="placesCmd_cut"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_cut');"/>
+ <command id="placesCmd_copy"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_copy');"/>
+ <command id="placesCmd_paste"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_paste');"/>
+ <command id="placesCmd_delete"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_delete');"/>
+ <command id="placesCmd_showInFolder"
+ oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_showInFolder');"/>
+</commandset>
diff --git a/browser/components/places/content/placesContextMenu.inc.xhtml b/browser/components/places/content/placesContextMenu.inc.xhtml
new file mode 100644
index 0000000000..11cbd25ae3
--- /dev/null
+++ b/browser/components/places/content/placesContextMenu.inc.xhtml
@@ -0,0 +1,174 @@
+# 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/.
+
+<menupopup id="placesContext"
+ onpopupshowing="return PlacesUIUtils.placesContextShowing(event);"
+ onpopuphiding="PlacesUIUtils.placesContextHiding(event);">
+ <menuitem id="placesContext_open"
+ command="placesCmd_open"
+ data-l10n-id="places-open"
+ default="true"
+ selection-type="single"
+ node-type="link"
+ hide-if-single-click-opens="true"/>
+ <menuitem id="placesContext_openBookmarkContainer:tabs"
+ oncommand="PlacesUIUtils.openSelectionInTabs(event);"
+ data-l10n-id="places-open-all-bookmarks"
+ selection-type="single|none"
+ node-type="folder|query_tag"/>
+ <menuitem id="placesContext_openBookmarkLinks:tabs"
+ oncommand="PlacesUIUtils.openSelectionInTabs(event);"
+ data-l10n-id="places-open-all-bookmarks"
+ selection-type="multiple"
+ node-type="link_bookmark|separator"/>
+ <menuitem id="placesContext_open:newtab"
+ command="placesCmd_open:tab"
+ data-l10n-id="places-open-in-tab"
+ selection-type="single"
+ node-type="link"/>
+ <menu id="placesContext_open:newcontainertab"
+ data-l10n-id="places-open-in-container-tab"
+ selection-type="single"
+ node-type="link"
+ hide-if-private-browsing="true"
+ hide-if-usercontext-disabled="true">
+ <menupopup oncommand="PlacesUIUtils.openInContainerTab(event);"
+ onpopupshowing="return PlacesUIUtils.createContainerTabMenu(event);" />
+ </menu>
+ <menuitem id="placesContext_openContainer:tabs"
+ oncommand="PlacesUIUtils.openSelectionInTabs(event);"
+ data-l10n-id="places-open-all-in-tabs"
+ selection-type="single|none"
+ node-type="query"
+ hide-if-node-type="query_tag"/>
+ <menuitem id="placesContext_openLinks:tabs"
+ oncommand="PlacesUIUtils.openSelectionInTabs(event);"
+ data-l10n-id="places-open-all-in-tabs"
+ selection-type="multiple"
+ node-type="link"
+ hide-if-node-type="link_bookmark"/>
+ <menuitem id="placesContext_open:newwindow"
+ command="placesCmd_open:window"
+ data-l10n-id="places-open-in-window"
+ selection-type="single"
+ node-type="link"/>
+ <menuitem id="placesContext_open:newprivatewindow"
+ command="placesCmd_open:privatewindow"
+ data-l10n-id="places-open-in-private-window"
+ selection-type="single"
+ node-type="link"
+ hide-if-private-browsing="true"/>
+ <menuitem id="placesContext_showInFolder"
+ data-l10n-id="places-show-in-folder"
+ command="placesCmd_showInFolder"
+ closemenu="single"
+ node-type="link_bookmark"
+ hide-if-not-search="true"
+ selection-type="single"/>
+ <menuseparator id="placesContext_openSeparator"/>
+ <menuitem id="placesContext_show_bookmark:info"
+ command="placesCmd_show:info"
+ data-l10n-id="places-edit-bookmark"
+ node-type="link_bookmark"/>
+ <menuitem id="placesContext_show:info"
+ command="placesCmd_show:info"
+ data-l10n-id="places-edit-generic"
+ node-type="query"
+ hide-if-node-type="query_host|query_day"/>
+ <menuitem id="placesContext_show_folder:info"
+ command="placesCmd_show:info"
+ data-l10n-id="places-edit-folder2"
+ node-type="folder"/>
+ <menuitem id="placesContext_deleteBookmark"
+ data-l10n-id="places-delete-bookmark"
+ data-l10n-args='{"count":"1"}'
+ command="placesCmd_delete"
+ closemenu="single"
+ node-type="link_bookmark"
+ hide-if-node-type="link_bookmark_tag"/>
+ <menuitem id="placesContext_removeTag"
+ data-l10n-id="places-untag-bookmark"
+ command="placesCmd_delete"
+ closemenu="single"
+ node-type="link_bookmark_tag"/>
+ <menuitem id="placesContext_deleteFolder"
+ data-l10n-id="places-delete-folder"
+ data-l10n-args='{"count":"1"}'
+ command="placesCmd_delete"
+ node-type="folder"
+ closemenu="single"/>
+ <menuitem id="placesContext_delete"
+ data-l10n-id="text-action-delete"
+ command="placesCmd_delete"
+ closemenu="single"
+ hide-if-node-type-is-only="link|folder"/>
+ <menuitem id="placesContext_delete_history"
+ command="placesCmd_delete"
+ closemenu="single"
+ node-type="link"
+ hide-if-node-type="link_bookmark"/>
+ <menuitem id="placesContext_deleteHost"
+ command="placesCmd_deleteDataHost"
+ data-l10n-id="places-delete-domain-data"
+ closemenu="single"
+ node-type="link|query_host"
+ selection-type="single"
+ hide-if-node-type="link_bookmark"/>
+ <menuitem id="placesContext_sortBy:name"
+ command="placesCmd_sortBy:name"
+ data-l10n-id="places-sortby-name"
+ closemenu="single"
+ node-type="folder"/>
+ <menuseparator id="placesContext_deleteSeparator"/>
+ <menuitem id="placesContext_cut"
+ command="placesCmd_cut"
+ data-l10n-id="text-action-cut"
+ closemenu="single"
+ node-type="link_bookmark|folder|separator|query"
+ hide-if-node-type="link_bookmark_tag|query_host|query_day|query_tag"/>
+ <menuitem id="placesContext_copy"
+ command="placesCmd_copy"
+ data-l10n-id="text-action-copy"
+ closemenu="single"/>
+ <menuitem id="placesContext_paste_group"
+ data-l10n-id="text-action-paste"
+ command="placesCmd_paste"
+ closemenu="single"
+ hide-if-no-insertion-point="true"/>
+ <menuseparator id="placesContext_editSeparator"/>
+ <menuitem id="placesContext_new:bookmark"
+ command="placesCmd_new:bookmark"
+ data-l10n-id="places-add-bookmark"
+ selection-type="any"
+ hide-if-no-insertion-point="true"/>
+ <menuitem id="placesContext_new:folder"
+ command="placesCmd_new:folder"
+ data-l10n-id="places-add-folder-contextmenu"
+ selection-type="any"
+ hide-if-no-insertion-point="true"/>
+ <menuitem id="placesContext_new:separator"
+ command="placesCmd_new:separator"
+ data-l10n-id="places-add-separator"
+ closemenu="single"
+ selection-type="any"
+ hide-if-no-insertion-point="true"/>
+ <menuseparator id="placesContext_newSeparator"/>
+ <menuitem id="placesContext_paste"
+ data-l10n-id="text-action-paste"
+ command="placesCmd_paste"
+ closemenu="single"
+ selection-type="none"
+ hide-if-no-insertion-point="true"/>
+ <menuseparator id="placesContext_pasteSeparator"/>
+ <menuitem id="placesContext_createBookmark"
+ command="placesCmd_createBookmark"
+ node-type="link"
+ hide-if-node-type="link_bookmark"/>
+ <menuitem id="placesContext_showAllBookmarks"
+ data-l10n-id="places-manage-bookmarks"
+ command="Browser:ShowAllBookmarks"
+ ignore-item="true"
+ hidden="true"/>
+
+</menupopup>
diff --git a/browser/components/places/content/treeView.js b/browser/components/places/content/treeView.js
new file mode 100644
index 0000000000..6d2fac1bed
--- /dev/null
+++ b/browser/components/places/content/treeView.js
@@ -0,0 +1,1871 @@
+/* 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/. */
+
+/* import-globals-from controller.js */
+
+/**
+ * This returns the key for any node/details object.
+ *
+ * @param {object} nodeOrDetails
+ * A node, or an object containing the following properties:
+ * - uri
+ * - time
+ * - itemId
+ * In case any of these is missing, an empty string will be returned. This is
+ * to facilitate easy delete statements which occur due to assignment to items in `this._rows`,
+ * since the item we are deleting may be undefined in the array.
+ *
+ * @returns {string} key or empty string.
+ */
+function makeNodeDetailsKey(nodeOrDetails) {
+ if (
+ nodeOrDetails &&
+ typeof nodeOrDetails === "object" &&
+ "uri" in nodeOrDetails &&
+ "time" in nodeOrDetails &&
+ "itemId" in nodeOrDetails
+ ) {
+ return `${nodeOrDetails.uri}*${nodeOrDetails.time}*${nodeOrDetails.itemId}`;
+ }
+ return "";
+}
+
+function PlacesTreeView(aContainer) {
+ this._tree = null;
+ this._result = null;
+ this._selection = null;
+ this._rootNode = null;
+ this._rows = [];
+ this._flatList = aContainer.flatList;
+ this._nodeDetails = new Map();
+ this._element = aContainer;
+ this._controller = aContainer._controller;
+}
+
+PlacesTreeView.prototype = {
+ get wrappedJSObject() {
+ return this;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsITreeView",
+ "nsINavHistoryResultObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * This is called once both the result and the tree are set.
+ */
+ _finishInit: function PTV__finishInit() {
+ let selection = this.selection;
+ if (selection) {
+ selection.selectEventsSuppressed = true;
+ }
+
+ if (!this._rootNode.containerOpen) {
+ // This triggers containerStateChanged which then builds the visible
+ // section.
+ this._rootNode.containerOpen = true;
+ } else {
+ this.invalidateContainer(this._rootNode);
+ }
+
+ // "Activate" the sorting column and update commands.
+ this.sortingChanged(this._result.sortingMode);
+
+ if (selection) {
+ selection.selectEventsSuppressed = false;
+ }
+ },
+
+ uninit() {
+ if (this._editingObservers) {
+ for (let observer of this._editingObservers.values()) {
+ observer.disconnect();
+ }
+ delete this._editingObservers;
+ }
+ },
+
+ /**
+ * Plain Container: container result nodes which may never include sub
+ * hierarchies.
+ *
+ * When the rows array is constructed, we don't set the children of plain
+ * containers. Instead, we keep placeholders for these children. We then
+ * build these children lazily as the tree asks us for information about each
+ * row. Luckily, the tree doesn't ask about rows outside the visible area.
+ *
+ * It's guaranteed that all containers are listed in the rows
+ * elements array. It's also guaranteed that separators (if they're not
+ * filtered, see below) are listed in the visible elements array, because
+ * bookmark folders are never built lazily, as described above.
+ *
+ * @see {@link PlacesTreeView._getNodeForRow} and
+ * {@link PlacesTreeView._getRowForNode} for the actual magic.
+ *
+ * @param {object} aContainer
+ * A container result node.
+ *
+ * @returns {boolean} true if aContainer is a plain container, false otherwise.
+ */
+ _isPlainContainer: function PTV__isPlainContainer(aContainer) {
+ // We don't know enough about non-query containers.
+ if (!(aContainer instanceof Ci.nsINavHistoryQueryResultNode)) {
+ return false;
+ }
+
+ switch (aContainer.queryOptions.resultType) {
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY:
+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY:
+ return false;
+ }
+
+ // If it's a folder, it's not a plain container.
+ let nodeType = aContainer.type;
+ return (
+ nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER &&
+ nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT
+ );
+ },
+
+ /**
+ * Gets the row number for a given node. Assumes that the given node is
+ * visible (i.e. it's not an obsolete node).
+ *
+ * If aParentRow and aNodeIndex are passed and parent is a plain
+ * container, this method will just return a calculated row value, without
+ * making assumptions on existence of the node at that position.
+ *
+ * @param {object} aNode
+ * A result node. Do not pass an obsolete node, or any
+ * node which isn't supposed to be in the tree (e.g. separators in
+ * sorted trees).
+ * @param {boolean} [aForceBuild]
+ * See {@link _isPlainContainer}.
+ * If true, the row will be computed even if the node still isn't set
+ * in our rows array.
+ * @param {object} [aParentRow]
+ * The row of aNode's parent. Ignored for the root node.
+ * @param {number} [aNodeIndex]
+ * The index of aNode in its parent. Only used if aParentRow is
+ * set too.
+ *
+ * @throws if aNode is invisible.
+ * @returns {object} aNode's row if it's in the rows list or if aForceBuild is set, -1
+ * otherwise.
+ */
+ _getRowForNode: function PTV__getRowForNode(
+ aNode,
+ aForceBuild,
+ aParentRow,
+ aNodeIndex
+ ) {
+ if (aNode == this._rootNode) {
+ throw new Error("The root node is never visible");
+ }
+
+ // A node is removed form the view either if it has no parent or if its
+ // root-ancestor is not the root node (in which case that's the node
+ // for which nodeRemoved was called).
+ let ancestors = Array.from(PlacesUtils.nodeAncestors(aNode));
+ if (
+ !ancestors.length ||
+ ancestors[ancestors.length - 1] != this._rootNode
+ ) {
+ throw new Error("Removed node passed to _getRowForNode");
+ }
+
+ // Ensure that the entire chain is open, otherwise that node is invisible.
+ for (let ancestor of ancestors) {
+ if (!ancestor.containerOpen) {
+ throw new Error("Invisible node passed to _getRowForNode");
+ }
+ }
+
+ // Non-plain containers are initially built with their contents.
+ let parent = aNode.parent;
+ let parentIsPlain = this._isPlainContainer(parent);
+ if (!parentIsPlain) {
+ if (parent == this._rootNode) {
+ return this._rows.indexOf(aNode);
+ }
+
+ return this._rows.indexOf(aNode, aParentRow);
+ }
+
+ let row = -1;
+ let useNodeIndex = typeof aNodeIndex == "number";
+ if (parent == this._rootNode) {
+ row = useNodeIndex ? aNodeIndex : this._rootNode.getChildIndex(aNode);
+ } else if (useNodeIndex && typeof aParentRow == "number") {
+ // If we have both the row of the parent node, and the node's index, we
+ // can avoid searching the rows array if the parent is a plain container.
+ row = aParentRow + aNodeIndex + 1;
+ } else {
+ // Look for the node in the nodes array. Start the search at the parent
+ // row. If the parent row isn't passed, we'll pass undefined to indexOf,
+ // which is fine.
+ row = this._rows.indexOf(aNode, aParentRow);
+ if (row == -1 && aForceBuild) {
+ let parentRow =
+ typeof aParentRow == "number"
+ ? aParentRow
+ : this._getRowForNode(parent);
+ row = parentRow + parent.getChildIndex(aNode) + 1;
+ }
+ }
+
+ if (row != -1) {
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row]));
+ this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode);
+ this._rows[row] = aNode;
+ }
+
+ return row;
+ },
+
+ /**
+ * Given a row, finds and returns the parent details of the associated node.
+ *
+ * @param {number} aChildRow
+ * Row number.
+ * @returns {Array} [parentNode, parentRow]
+ */
+ _getParentByChildRow: function PTV__getParentByChildRow(aChildRow) {
+ let node = this._getNodeForRow(aChildRow);
+ let parent = node === null ? this._rootNode : node.parent;
+
+ // The root node is never visible
+ if (parent == this._rootNode) {
+ return [this._rootNode, -1];
+ }
+
+ let parentRow = this._rows.lastIndexOf(parent, aChildRow - 1);
+ return [parent, parentRow];
+ },
+
+ /**
+ * Gets the node at a given row.
+ *
+ * @param {number} aRow
+ * The index of the row to set
+ * @returns {object}
+ */
+ _getNodeForRow: function PTV__getNodeForRow(aRow) {
+ if (aRow < 0) {
+ return null;
+ }
+
+ let node = this._rows[aRow];
+ if (node !== undefined) {
+ return node;
+ }
+
+ // Find the nearest node.
+ let rowNode, row;
+ for (let i = aRow - 1; i >= 0 && rowNode === undefined; i--) {
+ rowNode = this._rows[i];
+ row = i;
+ }
+
+ // If there's no container prior to the given row, it's a child of
+ // the root node (remember: all containers are listed in the rows array).
+ if (!rowNode) {
+ let newNode = this._rootNode.getChild(aRow);
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow]));
+ this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode);
+ return (this._rows[aRow] = newNode);
+ }
+
+ // Unset elements may exist only in plain containers. Thus, if the nearest
+ // node is a container, it's the row's parent, otherwise, it's a sibling.
+ if (rowNode instanceof Ci.nsINavHistoryContainerResultNode) {
+ let newNode = rowNode.getChild(aRow - row - 1);
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow]));
+ this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode);
+ return (this._rows[aRow] = newNode);
+ }
+
+ let [parent, parentRow] = this._getParentByChildRow(row);
+ let newNode = parent.getChild(aRow - parentRow - 1);
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow]));
+ this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode);
+ return (this._rows[aRow] = newNode);
+ },
+
+ /**
+ * This takes a container and recursively appends our rows array per its
+ * contents. Assumes that the rows arrays has no rows for the given
+ * container.
+ *
+ * @param {object} aContainer
+ * A container result node.
+ * @param {object} aFirstChildRow
+ * The first row at which nodes may be inserted to the row array.
+ * In other words, that's aContainer's row + 1.
+ * @param {Array} aToOpen
+ * An array of containers to open once the build is done (out param)
+ *
+ * @returns {number} the number of rows which were inserted.
+ */
+ _buildVisibleSection: function PTV__buildVisibleSection(
+ aContainer,
+ aFirstChildRow,
+ aToOpen
+ ) {
+ // There's nothing to do if the container is closed.
+ if (!aContainer.containerOpen) {
+ return 0;
+ }
+
+ // Inserting the new elements into the rows array in one shot (by
+ // Array.prototype.concat) is faster than resizing the array (by splice) on each loop
+ // iteration.
+ let cc = aContainer.childCount;
+ let newElements = new Array(cc);
+ // We need to clean up the node details from aFirstChildRow + 1 to the end of rows.
+ for (let i = aFirstChildRow + 1; i < this._rows.length; i++) {
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[i]));
+ }
+ this._rows = this._rows
+ .splice(0, aFirstChildRow)
+ .concat(newElements, this._rows);
+
+ if (this._isPlainContainer(aContainer)) {
+ return cc;
+ }
+
+ let sortingMode = this._result.sortingMode;
+
+ let rowsInserted = 0;
+ for (let i = 0; i < cc; i++) {
+ let curChild = aContainer.getChild(i);
+ let curChildType = curChild.type;
+
+ let row = aFirstChildRow + rowsInserted;
+
+ // Don't display separators when sorted.
+ if (curChildType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
+ if (sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) {
+ // Remove the element for the filtered separator.
+ // Notice that the rows array was initially resized to include all
+ // children.
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row]));
+ this._rows.splice(row, 1);
+ continue;
+ }
+ }
+
+ this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row]));
+ this._nodeDetails.set(makeNodeDetailsKey(curChild), curChild);
+ this._rows[row] = curChild;
+ rowsInserted++;
+
+ // Recursively do containers.
+ if (
+ !this._flatList &&
+ curChild instanceof Ci.nsINavHistoryContainerResultNode
+ ) {
+ let uri = curChild.uri;
+ let isopen = false;
+
+ if (uri) {
+ let val = Services.xulStore.getValue(
+ document.documentURI,
+ PlacesUIUtils.obfuscateUrlForXulStore(uri),
+ "open"
+ );
+ isopen = val == "true";
+ }
+
+ if (isopen != curChild.containerOpen) {
+ aToOpen.push(curChild);
+ } else if (curChild.containerOpen && curChild.childCount > 0) {
+ rowsInserted += this._buildVisibleSection(curChild, row + 1, aToOpen);
+ }
+ }
+ }
+
+ return rowsInserted;
+ },
+
+ /**
+ * This counts how many rows a node takes in the tree. For containers it
+ * will count the node itself plus any child node following it.
+ *
+ * @param {number} aNodeRow
+ * The row of the node to count
+ * @returns {number}
+ */
+ _countVisibleRowsForNodeAtRow: function PTV__countVisibleRowsForNodeAtRow(
+ aNodeRow
+ ) {
+ let node = this._rows[aNodeRow];
+
+ // If it's not listed yet, we know that it's a leaf node (instanceof also
+ // null-checks).
+ if (!(node instanceof Ci.nsINavHistoryContainerResultNode)) {
+ return 1;
+ }
+
+ let outerLevel = node.indentLevel;
+ for (let i = aNodeRow + 1; i < this._rows.length; i++) {
+ let rowNode = this._rows[i];
+ if (rowNode && rowNode.indentLevel <= outerLevel) {
+ return i - aNodeRow;
+ }
+ }
+
+ // This node plus its children take up the bottom of the list.
+ return this._rows.length - aNodeRow;
+ },
+
+ _getSelectedNodesInRange: function PTV__getSelectedNodesInRange(
+ aFirstRow,
+ aLastRow
+ ) {
+ let selection = this.selection;
+ let rc = selection.getRangeCount();
+ if (rc == 0) {
+ return [];
+ }
+
+ // The visible-area borders are needed for checking whether a
+ // selected row is also visible.
+ let firstVisibleRow = this._tree.getFirstVisibleRow();
+ let lastVisibleRow = this._tree.getLastVisibleRow();
+
+ let nodesInfo = [];
+ for (let rangeIndex = 0; rangeIndex < rc; rangeIndex++) {
+ let min = {},
+ max = {};
+ selection.getRangeAt(rangeIndex, min, max);
+
+ // If this range does not overlap the replaced chunk, we don't need to
+ // persist the selection.
+ if (max.value < aFirstRow || min.value > aLastRow) {
+ continue;
+ }
+
+ let firstRow = Math.max(min.value, aFirstRow);
+ let lastRow = Math.min(max.value, aLastRow);
+ for (let i = firstRow; i <= lastRow; i++) {
+ nodesInfo.push({
+ node: this._rows[i],
+ oldRow: i,
+ wasVisible: i >= firstVisibleRow && i <= lastVisibleRow,
+ });
+ }
+ }
+
+ return nodesInfo;
+ },
+
+ /**
+ * Tries to find an equivalent node for a node which was removed. We first
+ * look for the original node, in case it was just relocated. Then, if we
+ * that node was not found, we look for a node that has the same itemId, uri
+ * and time values.
+ *
+ * @param {object} aUpdatedContainer
+ * An ancestor of the node which was removed. It does not have to be
+ * its direct parent.
+ * @param {object} aOldNode
+ * The node which was removed.
+ *
+ * @returns {number} the row number of an equivalent node for aOldOne, if one was
+ * found, -1 otherwise.
+ */
+ _getNewRowForRemovedNode: function PTV__getNewRowForRemovedNode(
+ aUpdatedContainer,
+ aOldNode
+ ) {
+ let parent = aOldNode.parent;
+ if (parent) {
+ // If the node's parent is still set, the node is not obsolete
+ // and we should just find out its new position.
+ // However, if any of the node's ancestor is closed, the node is
+ // invisible.
+ let ancestors = PlacesUtils.nodeAncestors(aOldNode);
+ for (let ancestor of ancestors) {
+ if (!ancestor.containerOpen) {
+ return -1;
+ }
+ }
+
+ return this._getRowForNode(aOldNode, true);
+ }
+
+ // There's a broken edge case here.
+ // If a visit appears in two queries, and the second one was
+ // the old node, we'll select the first one after refresh. There's
+ // nothing we could do about that, because aOldNode.parent is
+ // gone by the time invalidateContainer is called.
+ let newNode = this._nodeDetails.get(makeNodeDetailsKey(aOldNode));
+
+ if (!newNode) {
+ return -1;
+ }
+
+ return this._getRowForNode(newNode, true);
+ },
+
+ /**
+ * Restores a given selection state as near as possible to the original
+ * selection state.
+ *
+ * @param {Array} aNodesInfo
+ * The persisted selection state as returned by
+ * _getSelectedNodesInRange.
+ * @param {object} aUpdatedContainer
+ * The container which was updated.
+ */
+ _restoreSelection: function PTV__restoreSelection(
+ aNodesInfo,
+ aUpdatedContainer
+ ) {
+ if (!aNodesInfo.length) {
+ return;
+ }
+
+ let selection = this.selection;
+
+ // Attempt to ensure that previously-visible selection will be visible
+ // if it's re-selected. However, we can only ensure that for one row.
+ let scrollToRow = -1;
+ for (let i = 0; i < aNodesInfo.length; i++) {
+ let nodeInfo = aNodesInfo[i];
+ let row = this._getNewRowForRemovedNode(aUpdatedContainer, nodeInfo.node);
+ // Select the found node, if any.
+ if (row != -1) {
+ selection.rangedSelect(row, row, true);
+ if (nodeInfo.wasVisible && scrollToRow == -1) {
+ scrollToRow = row;
+ }
+ }
+ }
+
+ // If only one node was previously selected and there's no selection now,
+ // select the node at its old row, if any.
+ if (aNodesInfo.length == 1 && selection.count == 0) {
+ let row = Math.min(aNodesInfo[0].oldRow, this._rows.length - 1);
+ if (row != -1) {
+ selection.rangedSelect(row, row, true);
+ if (aNodesInfo[0].wasVisible && scrollToRow == -1) {
+ scrollToRow = aNodesInfo[0].oldRow;
+ }
+ }
+ }
+
+ if (scrollToRow != -1) {
+ this._tree.ensureRowIsVisible(scrollToRow);
+ }
+ },
+
+ _convertPRTimeToString: function PTV__convertPRTimeToString(aTime) {
+ const MS_PER_MINUTE = 60000;
+ const MS_PER_DAY = 86400000;
+ let timeMs = aTime / 1000; // PRTime is in microseconds
+
+ // Date is calculated starting from midnight, so the modulo with a day are
+ // milliseconds from today's midnight.
+ // getTimezoneOffset corrects that based on local time, notice midnight
+ // can have a different offset during DST-change days.
+ let dateObj = new Date();
+ let now = dateObj.getTime() - dateObj.getTimezoneOffset() * MS_PER_MINUTE;
+ let midnight = now - (now % MS_PER_DAY);
+ midnight += new Date(midnight).getTimezoneOffset() * MS_PER_MINUTE;
+
+ let timeObj = new Date(timeMs);
+ return timeMs >= midnight
+ ? this._todayFormatter.format(timeObj)
+ : this._dateFormatter.format(timeObj);
+ },
+
+ // We use a different formatter for times within the current day,
+ // so we cache both a "today" formatter and a general date formatter.
+ __todayFormatter: null,
+ get _todayFormatter() {
+ if (!this.__todayFormatter) {
+ const dtOptions = { timeStyle: "short" };
+ this.__todayFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ dtOptions
+ );
+ }
+ return this.__todayFormatter;
+ },
+
+ __dateFormatter: null,
+ get _dateFormatter() {
+ if (!this.__dateFormatter) {
+ const dtOptions = {
+ dateStyle: "short",
+ timeStyle: "short",
+ };
+ this.__dateFormatter = new Services.intl.DateTimeFormat(
+ undefined,
+ dtOptions
+ );
+ }
+ return this.__dateFormatter;
+ },
+
+ COLUMN_TYPE_UNKNOWN: 0,
+ COLUMN_TYPE_TITLE: 1,
+ COLUMN_TYPE_URI: 2,
+ COLUMN_TYPE_DATE: 3,
+ COLUMN_TYPE_VISITCOUNT: 4,
+ COLUMN_TYPE_DATEADDED: 5,
+ COLUMN_TYPE_LASTMODIFIED: 6,
+ COLUMN_TYPE_TAGS: 7,
+
+ _getColumnType: function PTV__getColumnType(aColumn) {
+ let columnType = aColumn.element.getAttribute("anonid") || aColumn.id;
+
+ switch (columnType) {
+ case "title":
+ return this.COLUMN_TYPE_TITLE;
+ case "url":
+ return this.COLUMN_TYPE_URI;
+ case "date":
+ return this.COLUMN_TYPE_DATE;
+ case "visitCount":
+ return this.COLUMN_TYPE_VISITCOUNT;
+ case "dateAdded":
+ return this.COLUMN_TYPE_DATEADDED;
+ case "lastModified":
+ return this.COLUMN_TYPE_LASTMODIFIED;
+ case "tags":
+ return this.COLUMN_TYPE_TAGS;
+ }
+ return this.COLUMN_TYPE_UNKNOWN;
+ },
+
+ _sortTypeToColumnType: function PTV__sortTypeToColumnType(aSortType) {
+ switch (aSortType) {
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING:
+ return [this.COLUMN_TYPE_TITLE, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING:
+ return [this.COLUMN_TYPE_TITLE, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING:
+ return [this.COLUMN_TYPE_DATE, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING:
+ return [this.COLUMN_TYPE_DATE, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING:
+ return [this.COLUMN_TYPE_URI, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING:
+ return [this.COLUMN_TYPE_URI, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING:
+ return [this.COLUMN_TYPE_VISITCOUNT, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING:
+ return [this.COLUMN_TYPE_VISITCOUNT, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING:
+ return [this.COLUMN_TYPE_DATEADDED, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING:
+ return [this.COLUMN_TYPE_DATEADDED, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING:
+ return [this.COLUMN_TYPE_LASTMODIFIED, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING:
+ return [this.COLUMN_TYPE_LASTMODIFIED, true];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING:
+ return [this.COLUMN_TYPE_TAGS, false];
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING:
+ return [this.COLUMN_TYPE_TAGS, true];
+ }
+ return [this.COLUMN_TYPE_UNKNOWN, false];
+ },
+
+ // nsINavHistoryResultObserver
+ nodeInserted: function PTV_nodeInserted(aParentNode, aNode, aNewIndex) {
+ console.assert(this._result, "Got a notification but have no result!");
+ if (!this._tree || !this._result) {
+ return;
+ }
+
+ // Bail out for hidden separators.
+ if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) {
+ return;
+ }
+
+ let parentRow;
+ if (aParentNode != this._rootNode) {
+ parentRow = this._getRowForNode(aParentNode);
+
+ // Update parent when inserting the first item, since twisty has changed.
+ if (aParentNode.childCount == 1) {
+ this._tree.invalidateRow(parentRow);
+ }
+ }
+
+ // Compute the new row number of the node.
+ let row = -1;
+ let cc = aParentNode.childCount;
+ if (aNewIndex == 0 || this._isPlainContainer(aParentNode) || cc == 0) {
+ // We don't need to worry about sub hierarchies of the parent node
+ // if it's a plain container, or if the new node is its first child.
+ if (aParentNode == this._rootNode) {
+ row = aNewIndex;
+ } else {
+ row = parentRow + aNewIndex + 1;
+ }
+ } else {
+ // Here, we try to find the next visible element in the child list so we
+ // can set the new visible index to be right before that. Note that we
+ // have to search down instead of up, because some siblings could have
+ // children themselves that would be in the way.
+ let separatorsAreHidden =
+ PlacesUtils.nodeIsSeparator(aNode) && this.isSorted();
+ for (let i = aNewIndex + 1; i < cc; i++) {
+ let node = aParentNode.getChild(i);
+ if (!separatorsAreHidden || PlacesUtils.nodeIsSeparator(node)) {
+ // The children have not been shifted so the next item will have what
+ // should be our index.
+ row = this._getRowForNode(node, false, parentRow, i);
+ break;
+ }
+ }
+ if (row < 0) {
+ // At the end of the child list without finding a visible sibling. This
+ // is a little harder because we don't know how many rows the last item
+ // in our list takes up (it could be a container with many children).
+ let prevChild = aParentNode.getChild(aNewIndex - 1);
+ let prevIndex = this._getRowForNode(
+ prevChild,
+ false,
+ parentRow,
+ aNewIndex - 1
+ );
+ row = prevIndex + this._countVisibleRowsForNodeAtRow(prevIndex);
+ }
+ }
+
+ this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode);
+ this._rows.splice(row, 0, aNode);
+ this._tree.rowCountChanged(row, 1);
+
+ if (
+ PlacesUtils.nodeIsContainer(aNode) &&
+ PlacesUtils.asContainer(aNode).containerOpen
+ ) {
+ this.invalidateContainer(aNode);
+ }
+ },
+
+ /**
+ * THIS FUNCTION DOES NOT HANDLE cases where a collapsed node is being
+ * removed but the node it is collapsed with is not being removed (this then
+ * just swap out the removee with its collapsing partner). The only time
+ * when we really remove things is when deleting URIs, which will apply to
+ * all collapsees. This function is called sometimes when resorting items.
+ * However, we won't do this when sorted by date because dates will never
+ * change for visits, and date sorting is the only time things are collapsed.
+ *
+ * @param {object} aParentNode
+ * The parent node of the node being removed.
+ * @param {object} aNode
+ * The node to remove from the tree.
+ * @param {number} aOldIndex
+ * The old index of the node in the parent.
+ */
+ nodeRemoved: function PTV_nodeRemoved(aParentNode, aNode, aOldIndex) {
+ console.assert(this._result, "Got a notification but have no result!");
+ if (!this._tree || !this._result) {
+ return;
+ }
+
+ // XXX bug 517701: We don't know what to do when the root node is removed.
+ if (aNode == this._rootNode) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ // Bail out for hidden separators.
+ if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) {
+ return;
+ }
+
+ let parentRow =
+ aParentNode == this._rootNode
+ ? undefined
+ : this._getRowForNode(aParentNode, true);
+ let oldRow = this._getRowForNode(aNode, true, parentRow, aOldIndex);
+ if (oldRow < 0) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // If the node was exclusively selected, the node next to it will be
+ // selected.
+ let selectNext = false;
+ let selection = this.selection;
+ if (selection.getRangeCount() == 1) {
+ let min = {},
+ max = {};
+ selection.getRangeAt(0, min, max);
+ if (min.value == max.value && this.nodeForTreeIndex(min.value) == aNode) {
+ selectNext = true;
+ }
+ }
+
+ // Remove the node and its children, if any.
+ let count = this._countVisibleRowsForNodeAtRow(oldRow);
+ for (let splicedNode of this._rows.splice(oldRow, count)) {
+ this._nodeDetails.delete(makeNodeDetailsKey(splicedNode));
+ }
+ this._tree.rowCountChanged(oldRow, -count);
+
+ // Redraw the parent if its twisty state has changed.
+ if (aParentNode != this._rootNode && !aParentNode.hasChildren) {
+ parentRow = oldRow - 1;
+ this._tree.invalidateRow(parentRow);
+ }
+
+ // Restore selection if the node was exclusively selected.
+ if (!selectNext) {
+ return;
+ }
+
+ // Restore selection.
+ let rowToSelect = Math.min(oldRow, this._rows.length - 1);
+ if (rowToSelect != -1) {
+ this.selection.rangedSelect(rowToSelect, rowToSelect, true);
+ }
+ },
+
+ nodeMoved: function PTV_nodeMoved(
+ aNode,
+ aOldParent,
+ aOldIndex,
+ aNewParent,
+ aNewIndex
+ ) {
+ console.assert(this._result, "Got a notification but have no result!");
+ if (!this._tree || !this._result) {
+ return;
+ }
+
+ // Bail out for hidden separators.
+ if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) {
+ return;
+ }
+
+ // Note that at this point the node has already been moved by the backend,
+ // so we must give hints to _getRowForNode to get the old row position.
+ let oldParentRow =
+ aOldParent == this._rootNode
+ ? undefined
+ : this._getRowForNode(aOldParent, true);
+ let oldRow = this._getRowForNode(aNode, true, oldParentRow, aOldIndex);
+ if (oldRow < 0) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // If this node is a container it could take up more than one row.
+ let count = this._countVisibleRowsForNodeAtRow(oldRow);
+
+ // Persist selection state.
+ let nodesToReselect = this._getSelectedNodesInRange(
+ oldRow,
+ oldRow + count - 1
+ );
+ if (nodesToReselect.length) {
+ this.selection.selectEventsSuppressed = true;
+ }
+
+ // Redraw the parent if its twisty state has changed.
+ if (aOldParent != this._rootNode && !aOldParent.hasChildren) {
+ let parentRow = oldRow - 1;
+ this._tree.invalidateRow(parentRow);
+ }
+
+ // Remove node and its children, if any, from the old position.
+ for (let splicedNode of this._rows.splice(oldRow, count)) {
+ this._nodeDetails.delete(makeNodeDetailsKey(splicedNode));
+ }
+ this._tree.rowCountChanged(oldRow, -count);
+
+ // Insert the node into the new position.
+ this.nodeInserted(aNewParent, aNode, aNewIndex);
+
+ // Restore selection.
+ if (nodesToReselect.length) {
+ this._restoreSelection(nodesToReselect, aNewParent);
+ this.selection.selectEventsSuppressed = false;
+ }
+ },
+
+ _invalidateCellValue: function PTV__invalidateCellValue(aNode, aColumnType) {
+ console.assert(this._result, "Got a notification but have no result!");
+ if (!this._tree || !this._result) {
+ return;
+ }
+
+ // Nothing to do for the root node.
+ if (aNode == this._rootNode) {
+ return;
+ }
+
+ let row = this._getRowForNode(aNode);
+ if (row == -1) {
+ return;
+ }
+
+ let column = this._findColumnByType(aColumnType);
+ if (column && !column.element.hidden) {
+ if (aColumnType == this.COLUMN_TYPE_TITLE) {
+ this._tree.removeImageCacheEntry(row, column);
+ }
+ this._tree.invalidateCell(row, column);
+ }
+
+ // Last modified time is altered for almost all node changes.
+ if (aColumnType != this.COLUMN_TYPE_LASTMODIFIED) {
+ let lastModifiedColumn = this._findColumnByType(
+ this.COLUMN_TYPE_LASTMODIFIED
+ );
+ if (lastModifiedColumn && !lastModifiedColumn.hidden) {
+ this._tree.invalidateCell(row, lastModifiedColumn);
+ }
+ }
+ },
+
+ nodeTitleChanged: function PTV_nodeTitleChanged(aNode, aNewTitle) {
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE);
+ },
+
+ nodeURIChanged: function PTV_nodeURIChanged(aNode, aOldURI) {
+ this._nodeDetails.delete(
+ makeNodeDetailsKey({
+ uri: aOldURI,
+ itemId: aNode.itemId,
+ time: aNode.time,
+ })
+ );
+ this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode);
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_URI);
+ },
+
+ nodeIconChanged: function PTV_nodeIconChanged(aNode) {
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE);
+ },
+
+ nodeHistoryDetailsChanged: function PTV_nodeHistoryDetailsChanged(
+ aNode,
+ aOldVisitDate,
+ aOldVisitCount
+ ) {
+ this._nodeDetails.delete(
+ makeNodeDetailsKey({
+ uri: aNode.uri,
+ itemId: aNode.itemId,
+ time: aOldVisitDate,
+ })
+ );
+ this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode);
+
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATE);
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_VISITCOUNT);
+ },
+
+ nodeTagsChanged: function PTV_nodeTagsChanged(aNode) {
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_TAGS);
+ },
+
+ nodeKeywordChanged(aNode, aNewKeyword) {},
+
+ nodeDateAddedChanged: function PTV_nodeDateAddedChanged(aNode, aNewValue) {
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATEADDED);
+ },
+
+ nodeLastModifiedChanged: function PTV_nodeLastModifiedChanged(
+ aNode,
+ aNewValue
+ ) {
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_LASTMODIFIED);
+ },
+
+ containerStateChanged: function PTV_containerStateChanged(
+ aNode,
+ aOldState,
+ aNewState
+ ) {
+ this.invalidateContainer(aNode);
+ },
+
+ invalidateContainer: function PTV_invalidateContainer(aContainer) {
+ console.assert(this._result, "Need to have a result to update");
+ if (!this._tree) {
+ return;
+ }
+
+ // If we are currently editing, don't invalidate the container until we
+ // finish.
+ if (this._tree.getAttribute("editing")) {
+ if (!this._editingObservers) {
+ this._editingObservers = new Map();
+ }
+ if (!this._editingObservers.has(aContainer)) {
+ let mutationObserver = new MutationObserver(() => {
+ Services.tm.dispatchToMainThread(() =>
+ this.invalidateContainer(aContainer)
+ );
+ let observer = this._editingObservers.get(aContainer);
+ observer.disconnect();
+ this._editingObservers.delete(aContainer);
+ });
+
+ mutationObserver.observe(this._tree, {
+ attributes: true,
+ attributeFilter: ["editing"],
+ });
+
+ this._editingObservers.set(aContainer, mutationObserver);
+ }
+ return;
+ }
+
+ let startReplacement, replaceCount;
+ if (aContainer == this._rootNode) {
+ startReplacement = 0;
+ replaceCount = this._rows.length;
+
+ // If the root node is now closed, the tree is empty.
+ if (!this._rootNode.containerOpen) {
+ this._nodeDetails.clear();
+ this._rows = [];
+ if (replaceCount) {
+ this._tree.rowCountChanged(startReplacement, -replaceCount);
+ }
+
+ return;
+ }
+ } else {
+ // Update the twisty state.
+ let row = this._getRowForNode(aContainer);
+ this._tree.invalidateRow(row);
+
+ // We don't replace the container node itself, so we should decrease the
+ // replaceCount by 1.
+ startReplacement = row + 1;
+ replaceCount = this._countVisibleRowsForNodeAtRow(row) - 1;
+ }
+
+ // Persist selection state.
+ let nodesToReselect = this._getSelectedNodesInRange(
+ startReplacement,
+ startReplacement + replaceCount
+ );
+
+ // Now update the number of elements.
+ this.selection.selectEventsSuppressed = true;
+
+ // First remove the old elements
+ for (let splicedNode of this._rows.splice(startReplacement, replaceCount)) {
+ this._nodeDetails.delete(makeNodeDetailsKey(splicedNode));
+ }
+
+ // If the container is now closed, we're done.
+ if (!aContainer.containerOpen) {
+ let oldSelectionCount = this.selection.count;
+ if (replaceCount) {
+ this._tree.rowCountChanged(startReplacement, -replaceCount);
+ }
+
+ // Select the row next to the closed container if any of its
+ // children were selected, and nothing else is selected.
+ if (
+ nodesToReselect.length &&
+ nodesToReselect.length == oldSelectionCount
+ ) {
+ this.selection.rangedSelect(startReplacement, startReplacement, true);
+ this._tree.ensureRowIsVisible(startReplacement);
+ }
+
+ this.selection.selectEventsSuppressed = false;
+ return;
+ }
+
+ // Otherwise, start a batch first.
+ this._tree.beginUpdateBatch();
+ if (replaceCount) {
+ this._tree.rowCountChanged(startReplacement, -replaceCount);
+ }
+
+ let toOpenElements = [];
+ let elementsAddedCount = this._buildVisibleSection(
+ aContainer,
+ startReplacement,
+ toOpenElements
+ );
+ if (elementsAddedCount) {
+ this._tree.rowCountChanged(startReplacement, elementsAddedCount);
+ }
+
+ if (!this._flatList) {
+ // Now, open any containers that were persisted.
+ for (let i = 0; i < toOpenElements.length; i++) {
+ let item = toOpenElements[i];
+ let parent = item.parent;
+
+ // Avoid recursively opening containers.
+ while (parent) {
+ if (parent.uri == item.uri) {
+ break;
+ }
+ parent = parent.parent;
+ }
+
+ // If we don't have a parent, we made it all the way to the root
+ // and didn't find a match, so we can open our item.
+ if (!parent && !item.containerOpen) {
+ item.containerOpen = true;
+ }
+ }
+ }
+
+ this._tree.endUpdateBatch();
+
+ // Restore selection.
+ this._restoreSelection(nodesToReselect, aContainer);
+ this.selection.selectEventsSuppressed = false;
+ },
+
+ _columns: [],
+ _findColumnByType: function PTV__findColumnByType(aColumnType) {
+ if (this._columns[aColumnType]) {
+ return this._columns[aColumnType];
+ }
+
+ let columns = this._tree.columns;
+ let colCount = columns.count;
+ for (let i = 0; i < colCount; i++) {
+ let column = columns.getColumnAt(i);
+ let columnType = this._getColumnType(column);
+ this._columns[columnType] = column;
+ if (columnType == aColumnType) {
+ return column;
+ }
+ }
+
+ // That's completely valid. Most of our trees actually include just the
+ // title column.
+ return null;
+ },
+
+ sortingChanged: function PTV__sortingChanged(aSortingMode) {
+ if (!this._tree || !this._result) {
+ return;
+ }
+
+ // Depending on the sort mode, certain commands may be disabled.
+ window.updateCommands("sort");
+
+ let columns = this._tree.columns;
+
+ // Clear old sorting indicator.
+ let sortedColumn = columns.getSortedColumn();
+ if (sortedColumn) {
+ sortedColumn.element.removeAttribute("sortDirection");
+ }
+
+ // Set new sorting indicator by looking through all columns for ours.
+ if (aSortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) {
+ return;
+ }
+
+ let [desiredColumn, desiredIsDescending] = this._sortTypeToColumnType(
+ aSortingMode
+ );
+ let column = this._findColumnByType(desiredColumn);
+ if (column) {
+ let sortDir = desiredIsDescending ? "descending" : "ascending";
+ column.element.setAttribute("sortDirection", sortDir);
+ }
+ },
+
+ _inBatchMode: false,
+ batching: function PTV__batching(aToggleMode) {
+ if (this._inBatchMode != aToggleMode) {
+ this._inBatchMode = this.selection.selectEventsSuppressed = aToggleMode;
+ if (this._inBatchMode) {
+ this._tree.beginUpdateBatch();
+ } else {
+ this._tree.endUpdateBatch();
+ }
+ }
+ },
+
+ get result() {
+ return this._result;
+ },
+ set result(val) {
+ if (this._result) {
+ this._result.removeObserver(this);
+ this._rootNode.containerOpen = false;
+ }
+
+ if (val) {
+ this._result = val;
+ this._rootNode = this._result.root;
+ this._cellProperties = new Map();
+ this._cuttingNodes = new Set();
+ } else if (this._result) {
+ delete this._result;
+ delete this._rootNode;
+ delete this._cellProperties;
+ delete this._cuttingNodes;
+ }
+
+ // If the tree is not set yet, setTree will call finishInit.
+ if (this._tree && val) {
+ this._finishInit();
+ }
+ },
+
+ /**
+ * This allows you to get at the real node for a given row index. This is
+ * only valid when a tree is attached.
+ *
+ * @param {Integer} aIndex The index for the node to get.
+ * @returns {Ci.nsINavHistoryResultNode} The node.
+ * @throws Cr.NS_ERROR_INVALID_ARG if the index is greater than the number of
+ * rows.
+ */
+ nodeForTreeIndex(aIndex) {
+ if (aIndex > this._rows.length) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return this._getNodeForRow(aIndex);
+ },
+
+ /**
+ * Reverse of nodeForTreeIndex, returns the row index for a given result node.
+ * The node should be part of the tree.
+ *
+ * @param {Ci.nsINavHistoryResultNode} aNode The node to look for in the tree.
+ * @returns {Integer} The found index, or -1 if the item is not visible or not found.
+ */
+ treeIndexForNode(aNode) {
+ // The API allows passing invisible nodes.
+ try {
+ return this._getRowForNode(aNode, true);
+ } catch (ex) {}
+
+ return -1;
+ },
+
+ // nsITreeView
+ get rowCount() {
+ return this._rows.length;
+ },
+ get selection() {
+ return this._selection;
+ },
+ set selection(val) {
+ this._selection = val;
+ },
+
+ getRowProperties() {
+ return "";
+ },
+
+ getCellProperties: function PTV_getCellProperties(aRow, aColumn) {
+ // for anonid-trees, we need to add the column-type manually
+ var props = "";
+ let columnType = aColumn.element.getAttribute("anonid");
+ if (columnType) {
+ props += columnType;
+ } else {
+ columnType = aColumn.id;
+ }
+
+ // Set the "ltr" property on url cells
+ if (columnType == "url") {
+ props += " ltr";
+ }
+
+ if (columnType != "title") {
+ return props;
+ }
+
+ let node = this._getNodeForRow(aRow);
+
+ if (this._cuttingNodes.has(node)) {
+ props += " cutting";
+ }
+
+ let properties = this._cellProperties.get(node);
+ if (properties === undefined) {
+ properties = "";
+ let itemId = node.itemId;
+ let nodeType = node.type;
+ if (PlacesUtils.containerTypes.includes(nodeType)) {
+ if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) {
+ properties += " query";
+ if (PlacesUtils.nodeIsTagQuery(node)) {
+ properties += " tagContainer";
+ } else if (PlacesUtils.nodeIsDay(node)) {
+ properties += " dayContainer";
+ } else if (PlacesUtils.nodeIsHost(node)) {
+ properties += " hostContainer";
+ }
+ }
+
+ if (itemId == -1) {
+ switch (node.bookmarkGuid) {
+ case PlacesUtils.bookmarks.virtualToolbarGuid:
+ properties += ` queryFolder_${PlacesUtils.bookmarks.toolbarGuid}`;
+ break;
+ case PlacesUtils.bookmarks.virtualMenuGuid:
+ properties += ` queryFolder_${PlacesUtils.bookmarks.menuGuid}`;
+ break;
+ case PlacesUtils.bookmarks.virtualUnfiledGuid:
+ properties += ` queryFolder_${PlacesUtils.bookmarks.unfiledGuid}`;
+ break;
+ case PlacesUtils.virtualAllBookmarksGuid:
+ case PlacesUtils.virtualHistoryGuid:
+ case PlacesUtils.virtualDownloadsGuid:
+ case PlacesUtils.virtualTagsGuid:
+ properties += ` OrganizerQuery_${node.bookmarkGuid}`;
+ break;
+ }
+ }
+ } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) {
+ properties += " separator";
+ } else if (PlacesUtils.nodeIsURI(node)) {
+ properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri);
+ }
+
+ this._cellProperties.set(node, properties);
+ }
+
+ return props + " " + properties;
+ },
+
+ getColumnProperties(aColumn) {
+ return "";
+ },
+
+ isContainer: function PTV_isContainer(aRow) {
+ // Only leaf nodes aren't listed in the rows array.
+ let node = this._rows[aRow];
+ if (node === undefined || !PlacesUtils.nodeIsContainer(node)) {
+ return false;
+ }
+
+ // Flat-lists may ignore expandQueries and other query options when
+ // they are asked to open a container.
+ if (this._flatList) {
+ return true;
+ }
+
+ // Treat non-expandable childless queries as non-containers, unless they
+ // are tags.
+ if (PlacesUtils.nodeIsQuery(node) && !PlacesUtils.nodeIsTagQuery(node)) {
+ return (
+ PlacesUtils.asQuery(node).queryOptions.expandQueries || node.hasChildren
+ );
+ }
+ return true;
+ },
+
+ isContainerOpen: function PTV_isContainerOpen(aRow) {
+ if (this._flatList) {
+ return false;
+ }
+
+ // All containers are listed in the rows array.
+ return this._rows[aRow].containerOpen;
+ },
+
+ isContainerEmpty: function PTV_isContainerEmpty(aRow) {
+ if (this._flatList) {
+ return true;
+ }
+
+ // All containers are listed in the rows array.
+ return !this._rows[aRow].hasChildren;
+ },
+
+ isSeparator: function PTV_isSeparator(aRow) {
+ // All separators are listed in the rows array.
+ let node = this._rows[aRow];
+ return node && PlacesUtils.nodeIsSeparator(node);
+ },
+
+ isSorted: function PTV_isSorted() {
+ return (
+ this._result.sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ );
+ },
+
+ canDrop: function PTV_canDrop(aRow, aOrientation, aDataTransfer) {
+ if (!this._result) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ if (this._controller.disableUserActions) {
+ return false;
+ }
+
+ // Drop position into a sorted treeview would be wrong.
+ if (this.isSorted()) {
+ return false;
+ }
+
+ let ip = this._getInsertionPoint(aRow, aOrientation);
+ return ip && PlacesControllerDragHelper.canDrop(ip, aDataTransfer);
+ },
+
+ _getInsertionPoint: function PTV__getInsertionPoint(index, orientation) {
+ let container = this._result.root;
+ let dropNearNode = null;
+ // When there's no selection, assume the container is the container
+ // the view is populated from (i.e. the result's itemId).
+ if (index != -1) {
+ let lastSelected = this.nodeForTreeIndex(index);
+ if (this.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) {
+ // If the last selected item is an open container, append _into_
+ // it, rather than insert adjacent to it.
+ container = lastSelected;
+ index = -1;
+ } else if (
+ lastSelected.containerOpen &&
+ orientation == Ci.nsITreeView.DROP_AFTER &&
+ lastSelected.hasChildren
+ ) {
+ // If the last selected node is an open container and the user is
+ // trying to drag into it as a first node, really insert into it.
+ container = lastSelected;
+ orientation = Ci.nsITreeView.DROP_ON;
+ index = 0;
+ } else {
+ // Use the last-selected node's container.
+ container = lastSelected.parent;
+
+ // During its Drag & Drop operation, the tree code closes-and-opens
+ // containers very often (part of the XUL "spring-loaded folders"
+ // implementation). And in certain cases, we may reach a closed
+ // container here. However, we can simply bail out when this happens,
+ // because we would then be back here in less than a millisecond, when
+ // the container had been reopened.
+ if (!container || !container.containerOpen) {
+ return null;
+ }
+
+ // Don't show an insertion point if the index is contained
+ // within the selection and drag source is the same
+ if (
+ this._element.isDragSource &&
+ this._element.view.selection.isSelected(index)
+ ) {
+ return null;
+ }
+
+ // Avoid the potentially expensive call to getChildIndex
+ // if we know this container doesn't allow insertion.
+ if (this._controller.disallowInsertion(container)) {
+ return null;
+ }
+
+ let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions;
+ if (
+ queryOptions.sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
+ ) {
+ // If we are within a sorted view, insert at the end.
+ index = -1;
+ } else if (queryOptions.excludeItems || queryOptions.excludeQueries) {
+ // Some item may be invisible, insert near last selected one.
+ // We don't replace index here to avoid requests to the db,
+ // instead it will be calculated later by the controller.
+ index = -1;
+ dropNearNode = lastSelected;
+ } else {
+ let lastSelectedIndex = container.getChildIndex(lastSelected);
+ index =
+ orientation == Ci.nsITreeView.DROP_BEFORE
+ ? lastSelectedIndex
+ : lastSelectedIndex + 1;
+ }
+ }
+ }
+
+ if (this._controller.disallowInsertion(container)) {
+ return null;
+ }
+
+ let tagName = PlacesUtils.nodeIsTagQuery(container)
+ ? PlacesUtils.asQuery(container).query.tags[0]
+ : null;
+
+ return new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.getConcreteItemGuid(container),
+ index,
+ orientation,
+ tagName,
+ dropNearNode,
+ });
+ },
+
+ async drop(aRow, aOrientation, aDataTransfer) {
+ if (this._controller.disableUserActions) {
+ return;
+ }
+
+ // We are responsible for translating the |index| and |orientation|
+ // parameters into a container id and index within the container,
+ // since this information is specific to the tree view.
+ let ip = this._getInsertionPoint(aRow, aOrientation);
+ if (ip) {
+ try {
+ await PlacesControllerDragHelper.onDrop(ip, aDataTransfer, this._tree);
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ // We should only clear the drop target once
+ // the onDrop is complete, as it is an async function.
+ PlacesControllerDragHelper.currentDropTarget = null;
+ }
+ }
+ },
+
+ getParentIndex: function PTV_getParentIndex(aRow) {
+ let [, parentRow] = this._getParentByChildRow(aRow);
+ return parentRow;
+ },
+
+ hasNextSibling: function PTV_hasNextSibling(aRow, aAfterIndex) {
+ if (aRow == this._rows.length - 1) {
+ // The last row has no sibling.
+ return false;
+ }
+
+ let node = this._rows[aRow];
+ if (node === undefined || this._isPlainContainer(node.parent)) {
+ // The node is a child of a plain container.
+ // If the next row is either unset or has the same parent,
+ // it's a sibling.
+ let nextNode = this._rows[aRow + 1];
+ return nextNode == undefined || nextNode.parent == node.parent;
+ }
+
+ let thisLevel = node.indentLevel;
+ for (let i = aAfterIndex + 1; i < this._rows.length; ++i) {
+ let rowNode = this._getNodeForRow(i);
+ let nextLevel = rowNode.indentLevel;
+ if (nextLevel == thisLevel) {
+ return true;
+ }
+ if (nextLevel < thisLevel) {
+ break;
+ }
+ }
+
+ return false;
+ },
+
+ getLevel(aRow) {
+ return this._getNodeForRow(aRow).indentLevel;
+ },
+
+ getImageSrc: function PTV_getImageSrc(aRow, aColumn) {
+ // Only the title column has an image.
+ if (this._getColumnType(aColumn) != this.COLUMN_TYPE_TITLE) {
+ return "";
+ }
+
+ let node = this._getNodeForRow(aRow);
+ return node.icon;
+ },
+
+ getCellValue(aRow, aColumn) {},
+
+ getCellText: function PTV_getCellText(aRow, aColumn) {
+ let node = this._getNodeForRow(aRow);
+ switch (this._getColumnType(aColumn)) {
+ case this.COLUMN_TYPE_TITLE:
+ // normally, this is just the title, but we don't want empty items in
+ // the tree view so return a special string if the title is empty.
+ // Do it here so that callers can still get at the 0 length title
+ // if they go through the "result" API.
+ if (PlacesUtils.nodeIsSeparator(node)) {
+ return "";
+ }
+ return PlacesUIUtils.getBestTitle(node, true);
+ case this.COLUMN_TYPE_TAGS:
+ return node.tags;
+ case this.COLUMN_TYPE_URI:
+ if (PlacesUtils.nodeIsURI(node)) {
+ return node.uri;
+ }
+ return "";
+ case this.COLUMN_TYPE_DATE:
+ let nodeTime = node.time;
+ if (nodeTime == 0 || !PlacesUtils.nodeIsURI(node)) {
+ // hosts and days shouldn't have a value for the date column.
+ // Actually, you could argue this point, but looking at the
+ // results, seeing the most recently visited date is not what
+ // I expect, and gives me no information I know how to use.
+ // Only show this for URI-based items.
+ return "";
+ }
+
+ return this._convertPRTimeToString(nodeTime);
+ case this.COLUMN_TYPE_VISITCOUNT:
+ return node.accessCount;
+ case this.COLUMN_TYPE_DATEADDED:
+ if (node.dateAdded) {
+ return this._convertPRTimeToString(node.dateAdded);
+ }
+ return "";
+ case this.COLUMN_TYPE_LASTMODIFIED:
+ if (node.lastModified) {
+ return this._convertPRTimeToString(node.lastModified);
+ }
+ return "";
+ }
+ return "";
+ },
+
+ setTree: function PTV_setTree(aTree) {
+ // If we are replacing the tree during a batch, there is a concrete risk
+ // that the treeView goes out of sync, thus it's safer to end the batch now.
+ // This is a no-op if we are not batching.
+ this.batching(false);
+
+ let hasOldTree = this._tree != null;
+ this._tree = aTree;
+
+ if (this._result) {
+ if (hasOldTree) {
+ // detach from result when we are detaching from the tree.
+ // This breaks the reference cycle between us and the result.
+ if (!aTree) {
+ // Balances the addObserver call from the load method in tree.xml
+ this._result.removeObserver(this);
+ this._rootNode.containerOpen = false;
+ }
+ }
+ if (aTree) {
+ this._finishInit();
+ }
+ }
+ },
+
+ toggleOpenState: function PTV_toggleOpenState(aRow) {
+ if (!this._result) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let node = this._rows[aRow];
+ if (this._flatList && this._element) {
+ let event = new CustomEvent("onOpenFlatContainer", { detail: node });
+ this._element.dispatchEvent(event);
+ return;
+ }
+
+ let uri = node.uri;
+
+ if (uri) {
+ let docURI = document.documentURI;
+
+ if (node.containerOpen) {
+ Services.xulStore.removeValue(
+ docURI,
+ PlacesUIUtils.obfuscateUrlForXulStore(uri),
+ "open"
+ );
+ } else {
+ Services.xulStore.setValue(
+ docURI,
+ PlacesUIUtils.obfuscateUrlForXulStore(uri),
+ "open",
+ "true"
+ );
+ }
+ }
+
+ node.containerOpen = !node.containerOpen;
+ },
+
+ cycleHeader: function PTV_cycleHeader(aColumn) {
+ if (!this._result) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ // Sometimes you want a tri-state sorting, and sometimes you don't. This
+ // rule allows tri-state sorting when the root node is a folder. This will
+ // catch the most common cases. When you are looking at folders, you want
+ // the third state to reset the sorting to the natural bookmark order. When
+ // you are looking at history, that third state has no meaning so we try
+ // to disallow it.
+ //
+ // The problem occurs when you have a query that results in bookmark
+ // folders. One example of this is the subscriptions view. In these cases,
+ // this rule doesn't allow you to sort those sub-folders by their natural
+ // order.
+ let allowTriState = PlacesUtils.nodeIsFolder(this._result.root);
+
+ let oldSort = this._result.sortingMode;
+ let newSort;
+ const NHQO = Ci.nsINavHistoryQueryOptions;
+ switch (this._getColumnType(aColumn)) {
+ case this.COLUMN_TYPE_TITLE:
+ if (oldSort == NHQO.SORT_BY_TITLE_ASCENDING) {
+ newSort = NHQO.SORT_BY_TITLE_DESCENDING;
+ } else if (allowTriState && oldSort == NHQO.SORT_BY_TITLE_DESCENDING) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_TITLE_ASCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_URI:
+ if (oldSort == NHQO.SORT_BY_URI_ASCENDING) {
+ newSort = NHQO.SORT_BY_URI_DESCENDING;
+ } else if (allowTriState && oldSort == NHQO.SORT_BY_URI_DESCENDING) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_URI_ASCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_DATE:
+ if (oldSort == NHQO.SORT_BY_DATE_ASCENDING) {
+ newSort = NHQO.SORT_BY_DATE_DESCENDING;
+ } else if (allowTriState && oldSort == NHQO.SORT_BY_DATE_DESCENDING) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_DATE_ASCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_VISITCOUNT:
+ // visit count default is unusual because we sort by descending
+ // by default because you are most likely to be looking for
+ // highly visited sites when you click it
+ if (oldSort == NHQO.SORT_BY_VISITCOUNT_DESCENDING) {
+ newSort = NHQO.SORT_BY_VISITCOUNT_ASCENDING;
+ } else if (
+ allowTriState &&
+ oldSort == NHQO.SORT_BY_VISITCOUNT_ASCENDING
+ ) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_VISITCOUNT_DESCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_DATEADDED:
+ if (oldSort == NHQO.SORT_BY_DATEADDED_ASCENDING) {
+ newSort = NHQO.SORT_BY_DATEADDED_DESCENDING;
+ } else if (
+ allowTriState &&
+ oldSort == NHQO.SORT_BY_DATEADDED_DESCENDING
+ ) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_DATEADDED_ASCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_LASTMODIFIED:
+ if (oldSort == NHQO.SORT_BY_LASTMODIFIED_ASCENDING) {
+ newSort = NHQO.SORT_BY_LASTMODIFIED_DESCENDING;
+ } else if (
+ allowTriState &&
+ oldSort == NHQO.SORT_BY_LASTMODIFIED_DESCENDING
+ ) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_LASTMODIFIED_ASCENDING;
+ }
+
+ break;
+ case this.COLUMN_TYPE_TAGS:
+ if (oldSort == NHQO.SORT_BY_TAGS_ASCENDING) {
+ newSort = NHQO.SORT_BY_TAGS_DESCENDING;
+ } else if (allowTriState && oldSort == NHQO.SORT_BY_TAGS_DESCENDING) {
+ newSort = NHQO.SORT_BY_NONE;
+ } else {
+ newSort = NHQO.SORT_BY_TAGS_ASCENDING;
+ }
+
+ break;
+ default:
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ this._result.sortingMode = newSort;
+ },
+
+ isEditable: function PTV_isEditable(aRow, aColumn) {
+ // At this point we only support editing the title field.
+ if (aColumn.index != 0) {
+ return false;
+ }
+
+ let node = this._rows[aRow];
+ if (!node) {
+ console.error("isEditable called for an unbuilt row.");
+ return false;
+ }
+ let itemGuid = node.bookmarkGuid;
+
+ // Only bookmark-nodes are editable.
+ if (!itemGuid) {
+ return false;
+ }
+
+ // The following items are also not editable, even though they are bookmark
+ // items.
+ // * places-roots
+ // * the left pane special folders and queries (those are place: uri
+ // bookmarks)
+ // * separators
+ //
+ // Note that concrete itemIds aren't used intentionally. For example, we
+ // have no reason to disallow renaming a shortcut to the Bookmarks Toolbar,
+ // except for the one under All Bookmarks.
+ if (
+ PlacesUtils.nodeIsSeparator(node) ||
+ PlacesUtils.isRootItem(itemGuid) ||
+ PlacesUtils.isQueryGeneratedFolder(node)
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+
+ setCellText: function PTV_setCellText(aRow, aColumn, aText) {
+ // We may only get here if the cell is editable.
+ let node = this._rows[aRow];
+ if (node.title != aText) {
+ PlacesTransactions.EditTitle({ guid: node.bookmarkGuid, title: aText })
+ .transact()
+ .catch(Cu.reportError);
+ }
+ },
+
+ toggleCutNode: function PTV_toggleCutNode(aNode, aValue) {
+ let currentVal = this._cuttingNodes.has(aNode);
+ if (currentVal != aValue) {
+ if (aValue) {
+ this._cuttingNodes.add(aNode);
+ } else {
+ this._cuttingNodes.delete(aNode);
+ }
+
+ this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE);
+ }
+ },
+
+ selectionChanged() {},
+ cycleCell(aRow, aColumn) {},
+};