diff options
Diffstat (limited to 'browser/components/places/content/controller.js')
-rw-r--r-- | browser/components/places/content/controller.js | 1742 |
1 files changed, 1742 insertions, 0 deletions
diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js new file mode 100644 index 000000000..0d66fbcaf --- /dev/null +++ b/browser/components/places/content/controller.js @@ -0,0 +1,1742 @@ +/* -*- 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/. */ + +XPCOMUtils.defineLazyModuleGetter(this, "ForgetAboutSite", + "resource://gre/modules/ForgetAboutSite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +// XXXmano: we should move most/all of these constants to PlacesUtils +const ORGANIZER_ROOT_BOOKMARKS = "place:folder=BOOKMARKS_MENU&excludeItems=1&queryType=1"; + +// No change to the view, preserve current selection +const RELOAD_ACTION_NOTHING = 0; +// Inserting items new to the view, select the inserted rows +const RELOAD_ACTION_INSERT = 1; +// Removing items from the view, select the first item after the last selected +const RELOAD_ACTION_REMOVE = 2; +// Moving items within a view, don't treat the dropped items as additional +// rows. +const RELOAD_ACTION_MOVE = 3; + +// When removing a bunch of pages we split them in chunks to give some breath +// to the main-thread. +const REMOVE_PAGES_CHUNKLEN = 300; + +/** + * Represents an insertion point within a container where we can insert + * items. + * @param aItemId + * The identifier of the parent container + * @param aIndex + * The index within the container where we should insert + * @param aOrientation + * 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! + * @param [optional] aTag + * The tag name if this IP is set to a tag, null otherwise. + * @param [optional] aDropNearItemId + * When defined we will calculate index based on this itemId + * @constructor + */ +function InsertionPoint(aItemId, aIndex, aOrientation, aTagName = null, + aDropNearItemId = false) { + this.itemId = aItemId; + this._index = aIndex; + this.orientation = aOrientation; + this.tagName = aTagName; + this.dropNearItemId = aDropNearItemId; +} + +InsertionPoint.prototype = { + set index(val) { + return this._index = val; + }, + + promiseGuid: function () { + return PlacesUtils.promiseItemGuid(this.itemId); + }, + + get index() { + if (this.dropNearItemId > 0) { + // If dropNearItemId is set up we must calculate the real index of + // the item near which we will drop. + var index = PlacesUtils.bookmarks.getItemIndex(this.dropNearItemId); + 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.defineLazyServiceGetter(this, "clipboard", + "@mozilla.org/widget/clipboard;1", + "nsIClipboard"); + XPCOMUtils.defineLazyGetter(this, "profileName", function () { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + this._cachedLivemarkInfoObjects = new Map(); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIClipboardOwner + ]), + + // nsIClipboardOwner + LosingOwnership: function PC_LosingOwnership (aXferable) { + this.cutNodes = []; + }, + + terminate: function PC_terminate() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function PC_supportsCommand(aCommand) { + // 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) { + switch (aCommand) { + case "cmd_undo": + if (!PlacesUIUtils.useAsyncTransactions) + return PlacesUtils.transactionManager.numberOfUndoItems > 0; + + return PlacesTransactions.topUndoEntry != null; + case "cmd_redo": + if (!PlacesUIUtils.useAsyncTransactions) + return PlacesUtils.transactionManager.numberOfRedoItems > 0; + + return PlacesTransactions.topRedoEntry != null; + case "cmd_cut": + case "placesCmd_cut": + case "placesCmd_moveBookmarks": + 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": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + return this._canInsert(true) && this._isClipboardDataPasteable(); + 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": + var selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + case "placesCmd_new:folder": + return this._canInsert(); + case "placesCmd_new:bookmark": + return this._canInsert(); + case "placesCmd_new:separator": + return this._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.getConcreteItemId(selectedNode) != -1 + } + case "placesCmd_reload": { + // Livemark containers + let selectedNode = this._view.selectedNode; + return selectedNode && this.hasCachedLivemarkInfo(selectedNode); + } + case "placesCmd_sortBy:name": { + let selectedNode = this._view.selectedNode; + return selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isContentsReadOnly(selectedNode) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + } + case "placesCmd_createBookmark": + var node = this._view.selectedNode; + return node && PlacesUtils.nodeIsURI(node) && node.itemId == -1; + default: + return false; + } + }, + + doCommand: function PC_doCommand(aCommand) { + switch (aCommand) { + case "cmd_undo": + if (!PlacesUIUtils.useAsyncTransactions) { + PlacesUtils.transactionManager.undoTransaction(); + return; + } + PlacesTransactions.undo().then(null, Components.utils.reportError); + break; + case "cmd_redo": + if (!PlacesUIUtils.useAsyncTransactions) { + PlacesUtils.transactionManager.redoTransaction(); + return; + } + PlacesTransactions.redo().then(null, Components.utils.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().then(null, Components.utils.reportError); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection").then(null, Components.utils.reportError); + break; + case "placesCmd_deleteDataHost": + var host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + var queries = this._view.selectedNode.getQueries(); + host = queries[0].domain; + } + else + host = NetUtil.newURI(this._view.selectedNode.uri).host; + ForgetAboutSite.removeDataFromDomain(host); + 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"); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark"); + break; + case "placesCmd_new:separator": + this.newSeparator().catch(Components.utils.reportError); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_moveBookmarks": + this.moveSelectedBookmarks(); + break; + case "placesCmd_reload": + this.reloadSelectedLivemark(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName().then(null, Components.utils.reportError); + break; + case "placesCmd_createBookmark": + let node = this._view.selectedNode; + PlacesUIUtils.showBookmarkDialog({ action: "add" + , type: "bookmark" + , hiddenRows: [ "description" + , "keyword" + , "location" + , "loadInSidebar" ] + , uri: NetUtil.newURI(node.uri) + , title: node.title + }, window.top); + 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. + * + * @return 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; + }, + + /** + * Determines whether or not nodes can be inserted relative to the selection. + */ + _canInsert: function PC__canInsert(isPaste) { + var ip = this._view.insertionPoint; + return ip != null && (isPaste || ip.isTag != true); + }, + + /** + * Looks at the data on the clipboard to see if it is paste-able. + * Paste-able data is: + * - in a format that the view can receive + * @return true if: - clipboard data is of a TYPE_X_MOZ_PLACE_* flavor, + * - clipboard data is of type TEXT_UNICODE and + * is a valid URI. + */ + _isClipboardDataPasteable: function PC__isClipboardDataPasteable() { + // if the clipboard contains TYPE_X_MOZ_PLACE_* data, it is definitely + // pasteable, with no need to unwrap all the nodes. + + var flavors = PlacesUIUtils.PLACES_FLAVORS; + var clipboard = this.clipboard; + var hasPlacesData = + clipboard.hasDataMatchingFlavors(flavors, flavors.length, + Ci.nsIClipboard.kGlobalClipboard); + if (hasPlacesData) + return this._view.insertionPoint != null; + + // if the clipboard doesn't have TYPE_X_MOZ_PLACE_* data, we also allow + // pasting of valid "text/unicode" and "text/x-moz-url" data + var xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_URL); + xferable.addDataFlavor(PlacesUtils.TYPE_UNICODE); + clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + try { + // getAnyTransferData will throw if no data is available. + var data = { }, type = { }; + xferable.getAnyTransferData(type, data, { }); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + if (type.value != PlacesUtils.TYPE_X_MOZ_URL && + type.value != PlacesUtils.TYPE_UNICODE) + return false; + + // unwrapNodes() will throw if the data blob is malformed. + PlacesUtils.unwrapNodes(data, type.value); + return this._view.insertionPoint != null; + } + catch (e) { + // getAnyTransferData or unwrapNodes failed + return false; + } + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "livemarkChild" node is a child of a livemark + * "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 + * + * @return 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: function PC__buildSelectionMetadata() { + var metadata = []; + var nodes = this._view.selectedNodes; + + for (var i = 0; i < nodes.length; i++) { + var nodeData = {}; + var node = nodes[i]; + var nodeType = node.type; + var uri = null; + + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (nodeType) { + 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["host"] = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData["day"] = true; + break; + } + } + 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; + uri = NetUtil.newURI(node.uri); + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData["bookmark"] = true; + var parentNode = node.parent; + if (parentNode) { + if (PlacesUtils.nodeIsTagQuery(parentNode)) + nodeData["tagChild"] = true; + else if (this.hasCachedLivemarkInfo(parentNode)) + nodeData["livemarkChild"] = true; + } + } + break; + } + + // annotations + if (uri) { + let names = PlacesUtils.annotations.getPageAnnotationNames(uri); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + + // For items also include the item-specific annotations + if (node.itemId != -1) { + let names = PlacesUtils.annotations + .getItemAnnotationNames(node.itemId); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + metadata.push(nodeData); + } + + return metadata; + }, + + /** + * Determines if a context-menu item should be shown + * @param aMenuItem + * the context menu item + * @param aMetaData + * meta data about the selection + * @return true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem: function PC__shouldShowMenuItem(aMenuItem, aMetaData) { + var selectiontype = aMenuItem.getAttribute("selectiontype"); + if (!selectiontype) { + selectiontype = "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; + // NB: if there is no selection, we show the item if and only if + // the selectiontype includes 'none' - the metadata list will be + // empty so none of the other criteria will apply anyway. + if (count == 0) + return selectionTypes.includes("none"); + + var forceHideAttr = aMenuItem.getAttribute("forcehideselection"); + if (forceHideAttr) { + var forceHideRules = forceHideAttr.split("|"); + for (let i = 0; i < aMetaData.length; ++i) { + for (let j = 0; j < forceHideRules.length; ++j) { + if (forceHideRules[j] in aMetaData[i]) + return false; + } + } + } + + var selectionAttr = aMenuItem.getAttribute("selection"); + if (!selectionAttr) { + return !aMenuItem.hidden; + } + + if (selectionAttr == "any") + return true; + + var showRules = selectionAttr.split("|"); + var anyMatched = false; + function metaDataNodeMatches(metaDataNode, rules) { + for (var i = 0; i < rules.length; i++) { + if (rules[i] in metaDataNode) + return true; + } + return false; + } + + for (var i = 0; i < aMetaData.length; ++i) { + if (metaDataNodeMatches(aMetaData[i], showRules)) + anyMatched = true; + else + return false; + } + return anyMatched; + }, + + /** + * Detects information (meta-data rules) about the current selection in the + * view (see _buildSelectionMetadata) and sets the visibility state for each + * of the menu-items in the given popup with the following rules applied: + * 0) The "ignoreitem" attribute may be set to "true" for this code not to + * handle that menuitem. + * 1) The "selectiontype" attribute may be set on a menu-item to "single" + * if the menu-item should be visible only if there is a single node + * selected, or to "multiple" if the menu-item should be visible only if + * multiple nodes are selected, or to "none" if the menuitems should be + * visible for if there are no selected nodes, or to a |-separated + * combination of these. + * If the attribute is not set or set to an invalid value, the menu-item + * may be visible irrespective of the selection. + * 2) The "selection" attribute may be set on a menu-item to the various + * meta-data rules for which it may be visible. The rules should be + * separated with the | character. + * 3) A menu-item may be visible only if at least one of the rules set in + * its selection attribute apply to each of the selected nodes in the + * view. + * 4) The "forcehideselection" attribute may be set on a menu-item to rules + * for which it should be hidden. This attribute takes priority over the + * selection attribute. A menu-item would be hidden if at least one of the + * given rules apply to one of the selected nodes. The rules should be + * separated with the | character. + * 5) The "hideifnoinsertionpoint" attribute may be set on a menu-item to + * true if it should be hidden when there's no insertion point + * 6) The visibility state of a menu-item is unchanged if none of these + * attribute are set. + * 7) These attributes should not be set on separators for which the + * visibility state is "auto-detected." + * 8) The "hideifprivatebrowsing" attribute may be set on a menu-item to + * true if it should be hidden inside the private browsing mode + * @param aPopup + * The menupopup to build children into. + * @return true if at least one item is visible, false otherwise. + */ + buildContextMenu: function PC_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.childNodes.length; ++i) { + var item = aPopup.childNodes[i]; + if (item.getAttribute("ignoreitem") == "true") { + continue; + } + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + var hideIfNoIP = item.getAttribute("hideifnoinsertionpoint") == "true" && + noIp && !(ip && ip.isTag && item.id == "placesContext_paste"); + var hideIfPrivate = item.getAttribute("hideifprivatebrowsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + var shouldHideItem = hideIfNoIP || hideIfPrivate || + !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; + } + } + + // Set Open Folder/Links In Tabs items enabled state if they're visible + if (usableItemCount > 0) { + var openContainerInTabsItem = document.getElementById("placesContext_openContainer:tabs"); + if (!openContainerInTabsItem.hidden) { + var containerToUse = this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + openContainerInTabsItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + + 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); + }, + + /** + * This method can be run on a URI parameter to ensure that it didn't + * receive a string instead of an nsIURI object. + */ + _assertURINotString: function PC__assertURINotString(value) { + NS_ASSERT((typeof(value) == "object") && !(value instanceof String), + "This method should be passed a URI as a nsIURI object, not as a string."); + }, + + /** + * Reloads the selected livemark if any. + */ + reloadSelectedLivemark: function PC_reloadSelectedLivemark() { + var selectedNode = this._view.selectedNode; + if (selectedNode) { + let itemId = selectedNode.itemId; + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + aLivemark.reload(true); + }, Components.utils.reportError); + } + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + */ + 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; + } + if (node && PlacesUtils.nodeIsContainer(node)) + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this._view); + else + PlacesUIUtils.openURINodesInTabs(nodes, aEvent, this._view); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param aType + * the type of the new item (bookmark/livemark/folder) + */ + newItem: function PC_newItem(aType) { + let ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let performed = + PlacesUIUtils.showBookmarkDialog({ action: "add" + , type: aType + , defaultInsertionPoint: ip + , hiddenRows: [ "folderPicker" ] + }, window.top); + if (performed) { + // Select the new item. + let insertedNodeId = PlacesUtils.bookmarks + .getIdForItemAt(ip.itemId, ip.index); + this._view.selectItems([insertedNodeId], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + newSeparator: Task.async(function* () { + var ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesCreateSeparatorTransaction(ip.itemId, ip.index); + PlacesUtils.transactionManager.doTransaction(txn); + // Select the new item. + let insertedNodeId = PlacesUtils.bookmarks + .getIdForItemAt(ip.itemId, ip.index); + this._view.selectItems([insertedNodeId], false); + return; + } + + let txn = PlacesTransactions.NewSeparator({ parentGuid: yield ip.promiseGuid() + , index: ip.index }); + let guid = yield txn.transact(); + let itemId = yield PlacesUtils.promiseItemId(guid); + // Select the new item. + this._view.selectItems([itemId], false); + }), + + /** + * Opens a dialog for moving the selected nodes. + */ + moveSelectedBookmarks: function PC_moveBookmarks() { + window.openDialog("chrome://browser/content/places/moveBookmarks.xul", + "", "chrome, modal", + this._view.selectedNodes); + }, + + /** + * Sort the selected folder by name + */ + sortFolderByName: Task.async(function* () { + let itemId = PlacesUtils.getConcreteItemId(this._view.selectedNode); + if (!PlacesUIUtils.useAsyncTransactions) { + var txn = new PlacesSortFolderByNameTransaction(itemId); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = yield PlacesUtils.promiseItemGuid(itemId); + yield 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 node + * Node to check for containment. + * @param pastFolders + * List of folders the calling function has already traversed + * @return 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 node + * The node to check for containment for + * @param parent + * The parent container to check for containment in + * @return true if node is a member of parent's children, false otherwise. + */ + function isContainedBy(node, 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 (isContainedBy(node, 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 [in] range + * An array of nodes to remove. Should all be adjacent. + * @param [out] transactions + * An array of transactions. + * @param [optional] removedFolders + * An array of folder nodes that have already been removed. + */ + _removeRange: function PC__removeRange(range, transactions, removedFolders) { + NS_ASSERT(transactions instanceof Array, "Must pass a transactions array"); + if (!removedFolders) + removedFolders = []; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) + continue; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + var tagItemId = PlacesUtils.getConcreteItemId(node.parent); + var uri = NetUtil.newURI(node.uri); + if (PlacesUIUtils.useAsyncTransactions) { + let tag = node.parent.title; + if (!tag) + tag = PlacesUtils.bookmarks.getItemTitle(tagItemId); + transactions.push(PlacesTransactions.Untag({ uri: uri, tag: tag })); + } + else { + let txn = new PlacesUntagURITransaction(uri, [tagItemId]); + transactions.push(txn); + } + } + else if (PlacesUtils.nodeIsTagQuery(node) && node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY) { + // 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 URIs = PlacesUtils.tagging.getURIsForTag(tag); + if (PlacesUIUtils.useAsyncTransactions) { + transactions.push(PlacesTransactions.Untag({ tag: tag, uris: URIs })); + } + else { + for (var j = 0; j < URIs.length; j++) { + let txn = new PlacesUntagURITransaction(URIs[j], [tag]); + transactions.push(txn); + } + } + } + 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. + PlacesUtils.bhistory.removePage(NetUtil.newURI(node.uri)); + // 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. + this._removeHistoryContainer(node); + // 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); + } + if (PlacesUIUtils.useAsyncTransactions) { + transactions.push( + PlacesTransactions.Remove({ guid: node.bookmarkGuid })); + } + else { + let txn = new PlacesRemoveItemTransaction(node.itemId); + transactions.push(txn); + } + } + } + }, + + /** + * Removes the set of selected ranges from bookmarks. + * @param txnName + * See |remove|. + */ + _removeRowsFromBookmarks: Task.async(function* (txnName) { + var ranges = this._view.removableSelectionRanges; + var transactions = []; + var removedFolders = []; + + for (var i = 0; i < ranges.length; i++) + this._removeRange(ranges[i], transactions, removedFolders); + + if (transactions.length > 0) { + if (PlacesUIUtils.useAsyncTransactions) { + yield PlacesTransactions.batch(transactions); + } + else { + var txn = new PlacesAggregatedTransaction(txnName, transactions); + PlacesUtils.transactionManager.doTransaction(txn); + } + } + }), + + /** + * Removes the set of selected ranges from history. + * + * @note history deletes are not undoable. + */ + _removeRowsFromHistory: function PC__removeRowsFromHistory() { + let nodes = this._view.selectedNodes; + let URIs = []; + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + let uri = NetUtil.newURI(node.uri); + // Avoid duplicates. + if (URIs.indexOf(uri) < 0) { + URIs.push(uri); + } + } + else if (PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + this._removeHistoryContainer(node); + } + } + + // Do removal in chunks to give some breath to main-thread. + function* pagesChunkGenerator(aURIs) { + while (aURIs.length) { + let URIslice = aURIs.splice(0, REMOVE_PAGES_CHUNKLEN); + PlacesUtils.bhistory.removePages(URIslice, URIslice.length); + Services.tm.mainThread.dispatch(() => gen.next(), + Ci.nsIThread.DISPATCH_NORMAL); + yield undefined; + } + } + let gen = pagesChunkGenerator(URIs); + gen.next(); + }, + + /** + * Removes history visits for an history container node. + * @param [in] aContainerNode + * The container node to remove. + * + * @note history deletes are not undoable. + */ + _removeHistoryContainer: function PC__removeHistoryContainer(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // Site container. + PlacesUtils.bhistory.removePagesFromHost(aContainerNode.title, true); + } + else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // Day container. + let query = aContainerNode.getQueries()[0]; + let beginTime = query.beginTime; + let endTime = query.endTime; + NS_ASSERT(query && beginTime && endTime, + "A valid date container query should exist!"); + // 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. + PlacesUtils.bhistory.removePagesByTimeframe(beginTime + 1, endTime); + } + }, + + /** + * Removes the selection + * @param aTxnName + * A name for the transaction if this is being performed + * as part of another operation. + */ + remove: Task.async(function* (aTxnName) { + if (!this._hasRemovableSelection()) + return; + + NS_ASSERT(aTxnName !== undefined, "Must supply Transaction Name"); + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) { + if (PlacesUIUtils.useAsyncTransactions) + yield this._removeRowsFromBookmarks(aTxnName); + else + this._removeRowsFromBookmarks(aTxnName); + } + else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { + if (PlacesUIUtils.useAsyncTransactions) + yield this._removeRowsFromBookmarks(aTxnName); + else + this._removeRowsFromBookmarks(aTxnName); + } + else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + this._removeRowsFromHistory(); + } + else { + NS_ASSERT(false, "implement support for QUERY_TYPE_UNIFIED"); + } + } + else + NS_ASSERT(false, "unexpected root"); + }), + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * @param 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, feedURI) { + let wrapNode = PlacesUtils.wrapNode(node, type, feedURI); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index, feedURI) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index, feedURI); + addData(PlacesUtils.TYPE_UNICODE, index, feedURI); + addData(PlacesUtils.TYPE_HTML, index, feedURI); + } + + 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); + + // Drop the feed uri for livemark containers + let livemarkInfo = this.getCachedLivemarkInfo(node); + if (livemarkInfo) { + addURIData(i, livemarkInfo.feedURI.spec); + } + else 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) + this.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 > 0) { + // This clears the logical clipboard, doesn't remove data. + this.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(""), 0); + this.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); + + let livemarkInfo = this.getCachedLivemarkInfo(node); + let feedURI = livemarkInfo && livemarkInfo.feedURI.spec; + + contents.forEach(function (content) { + content.entries.push( + PlacesUtils.wrapNode(node, content.type, feedURI) + ); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data), + data.length * 2); + } + + 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 > 0) { + 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) { + this.clipboard.setData(xferable, + this.cutNodes.length > 0 ? 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); + return aNodes; + }, + + /** + * 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 + */ + paste: Task.async(function* () { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) + throw 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)); + + this.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 itemsToSelect = []; + if (PlacesUIUtils.useAsyncTransactions) { + if (ip.isTag) { + let uris = items.filter(item => "uri" in item).map(item => NetUtil.newURI(item.uri)); + yield PlacesTransactions.Tag({ uris: uris, tag: ip.tagName }).transact(); + } + else { + yield PlacesTransactions.batch(function* () { + let insertionIndex = ip.index; + let parent = yield ip.promiseGuid(); + + for (let item of items) { + let doCopy = action == "copy"; + + // If this is not a copy, check for safety that we can move the + // source, otherwise report an error and fallback to a copy. + if (!doCopy && + !PlacesControllerDragHelper.canMoveUnwrappedNode(item)) { + Components.utils.reportError("Tried to move an unmovable " + + "Places node, reverting to a copy operation."); + doCopy = true; + } + let guid = yield PlacesUIUtils.getTransactionForData( + item, type, parent, insertionIndex, doCopy).transact(); + itemsToSelect.push(yield PlacesUtils.promiseItemId(guid)); + + // Adjust index to make sure items are pasted in the correct + // position. If index is DEFAULT_INDEX, items are just appended. + if (insertionIndex != PlacesUtils.bookmarks.DEFAULT_INDEX) + insertionIndex++; + } + }); + } + } + else { + let transactions = []; + let insertionIndex = ip.index; + for (let i = 0; i < items.length; ++i) { + if (ip.isTag) { + // Pasting into a tag container means tagging the item, regardless of + // the requested action. + let tagTxn = new PlacesTagURITransaction(NetUtil.newURI(items[i].uri), + [ip.itemId]); + transactions.push(tagTxn); + continue; + } + + // Adjust index to make sure items are pasted in the correct position. + // If index is DEFAULT_INDEX, items are just appended. + if (ip.index != PlacesUtils.bookmarks.DEFAULT_INDEX) + insertionIndex = ip.index + i; + + // If this is not a copy, check for safety that we can move the source, + // otherwise report an error and fallback to a copy. + if (action != "copy" && !PlacesControllerDragHelper.canMoveUnwrappedNode(items[i])) { + Components.utils.reportError("Tried to move an unmovable Places " + + "node, reverting to a copy operation."); + action = "copy"; + } + transactions.push( + PlacesUIUtils.makeTransaction(items[i], type, ip.itemId, + insertionIndex, action == "copy") + ); + } + + let aggregatedTxn = new PlacesAggregatedTransaction("Paste", transactions); + PlacesUtils.transactionManager.doTransaction(aggregatedTxn); + + for (let i = 0; i < transactions.length; ++i) { + itemsToSelect.push( + PlacesUtils.bookmarks.getIdForItemAt(ip.itemId, ip.index + i) + ); + } + } + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + if (itemsToSelect.length > 0) + this._view.selectItems(itemsToSelect, false); + }), + + /** + * Cache the livemark info for a node. This allows the controller and the + * views to treat the given node as a livemark. + * @param aNode + * a places result node. + * @param aLivemarkInfo + * a mozILivemarkInfo object. + */ + cacheLivemarkInfo: function PC_cacheLivemarkInfo(aNode, aLivemarkInfo) { + this._cachedLivemarkInfoObjects.set(aNode, aLivemarkInfo); + }, + + /** + * Returns whether or not there's cached mozILivemarkInfo object for a node. + * @param aNode + * a places result node. + * @return true if there's a cached mozILivemarkInfo object for + * aNode, false otherwise. + */ + hasCachedLivemarkInfo: function PC_hasCachedLivemarkInfo(aNode) { + return this._cachedLivemarkInfoObjects.has(aNode); + }, + + /** + * Returns the cached livemark info for a node, if set by cacheLivemarkInfo, + * null otherwise. + * @param aNode + * a places result node. + * @return the mozILivemarkInfo object for aNode, if set, null otherwise. + */ + getCachedLivemarkInfo: function PC_getCachedLivemarkInfo(aNode) { + return this._cachedLivemarkInfoObjects.get(aNode, null); + } +}; + +/** + * 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 = { + /** + * DOM Element currently being dragged over + */ + 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 node + * The container node + * @return 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; + }, + + /** + * @return 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 aFlavors + * The flavors list of type DOMStringList. + */ + 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 ip + * The insertion point where the items should be dropped. + */ + 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 dragged; + try { + dragged = PlacesUtils.unwrapNodes(data, flavor)[0]; + } + catch (e) { + return false; + } + + // 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; + + // The following loop disallows the dropping of a folder on itself or + // on any of its descendants. + if (dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) ) { + let parentId = ip.itemId; + while (parentId != PlacesUtils.placesRootId) { + if (dragged.concreteId == parentId || dragged.id == parentId) + return false; + parentId = PlacesUtils.bookmarks.getFolderIdForItem(parentId); + } + } + } + return true; + }, + + /** + * Determines if an unwrapped node can be moved. + * + * @param aUnwrappedNode + * A node unwrapped by PlacesUtils.unwrapNodes(). + * @return True if the node can be moved, false otherwise. + */ + canMoveUnwrappedNode: function (aUnwrappedNode) { + return aUnwrappedNode.id > 0 && + !PlacesUtils.isRootItem(aUnwrappedNode.id) && + (!aUnwrappedNode.parent || !PlacesUIUtils.isContentsReadOnly(aUnwrappedNode.parent)) && + aUnwrappedNode.parent != PlacesUtils.tagsFolderId && + aUnwrappedNode.grandParentId != PlacesUtils.tagsFolderId; + }, + + /** + * Determines if a node can be moved. + * + * @param aNode + * A nsINavHistoryResultNode node. + * @param [optional] aDOMNode + * A XUL DOM node. + * @return True if the node can be moved, false otherwise. + */ + canMoveNode(aNode, aDOMNode) { + // Only bookmark items are movable. + if (aNode.itemId == -1) + return false; + + let parentNode = aNode.parent; + if (!parentNode) { + // Normally parentless places nodes can not be moved, + // but simulated bookmarked URI nodes are special. + return !!aDOMNode && + aDOMNode.hasAttribute("simulated-places-node") && + PlacesUtils.nodeIsBookmark(aNode); + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + return !(PlacesUtils.nodeIsFolder(parentNode) && + PlacesUIUtils.isContentsReadOnly(parentNode)) && + !PlacesUtils.nodeIsTagQuery(parentNode); + }, + + /** + * Handles the drop of one or more items onto a view. + * @param insertionPoint + * The insertion point where the items should be dropped + */ + onDrop: Task.async(function* (insertionPoint, dt) { + let doCopy = ["copy", "link"].includes(dt.dropEffect); + + let transactions = []; + let dropCount = dt.mozItemCount; + let movedCount = 0; + let parentGuid = PlacesUIUtils.useAsyncTransactions ? + (yield insertionPoint.promiseGuid()) : null; + let tagName = insertionPoint.tagName; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i)); + if (!flavor) + return; + + let data = dt.mozGetDataAt(flavor, i); + let unwrapped; + if (flavor != TAB_DROP_TYPE) { + // There's only ever one in the D&D case. + unwrapped = PlacesUtils.unwrapNodes(data, flavor)[0]; + } + else if (data instanceof XULElement && data.localName == "tab" && + data.ownerGlobal instanceof ChromeWindow) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + unwrapped = { uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL}; + } + else + throw new Error("bogus data was passed as a tab"); + + let index = insertionPoint.index; + + // Adjust insertion index to prevent reversal of dragged items. When you + // drag multiple elts upward: need to increment index or each successive + // elt will be inserted at the same index, each above the previous. + let dragginUp = insertionPoint.itemId == unwrapped.parent && + index < PlacesUtils.bookmarks.getItemIndex(unwrapped.id); + if (index != -1 && dragginUp) + index+= movedCount++; + + // If dragging over a tag container we should tag the item. + if (insertionPoint.isTag) { + let uri = NetUtil.newURI(unwrapped.uri); + let tagItemId = insertionPoint.itemId; + if (PlacesUIUtils.useAsyncTransactions) + transactions.push(PlacesTransactions.Tag({ uri: uri, tag: tagName })); + else + transactions.push(new PlacesTagURITransaction(uri, [tagItemId])); + } + else { + // If this is not a copy, check for safety that we can move the source, + // otherwise report an error and fallback to a copy. + if (!doCopy && !PlacesControllerDragHelper.canMoveUnwrappedNode(unwrapped)) { + Components.utils.reportError("Tried to move an unmovable Places " + + "node, reverting to a copy operation."); + doCopy = true; + } + if (PlacesUIUtils.useAsyncTransactions) { + transactions.push( + PlacesUIUtils.getTransactionForData(unwrapped, + flavor, + parentGuid, + index, + doCopy)); + } + else { + transactions.push(PlacesUIUtils.makeTransaction(unwrapped, + flavor, insertionPoint.itemId, + index, doCopy)); + } + } + } + + if (PlacesUIUtils.useAsyncTransactions) { + yield PlacesTransactions.batch(transactions); + } + else { + let txn = new PlacesAggregatedTransaction("DropItems", transactions); + PlacesUtils.transactionManager.doTransaction(txn); + } + }), + + /** + * Checks if we can insert into a container. + * @param aContainer + * The container were we are want to drop + */ + disallowInsertion: function(aContainer) { + NS_ASSERT(aContainer, "empty container"); + // Allow dropping into Tag containers and editable folders. + return !PlacesUtils.nodeIsTagQuery(aContainer) && + (!PlacesUtils.nodeIsFolder(aContainer) || + PlacesUIUtils.isContentsReadOnly(aContainer)); + } +}; + + +XPCOMUtils.defineLazyServiceGetter(PlacesControllerDragHelper, "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService"); + +function goUpdatePlacesCommands() { + // Get the controller for one of the places commands. + var placesController = doGetPlacesControllerForCommand("placesCmd_open"); + function updatePlacesCommand(aCommand) { + goSetCommandEnabled(aCommand, placesController && + placesController.isCommandEnabled(aCommand)); + } + + updatePlacesCommand("placesCmd_open"); + updatePlacesCommand("placesCmd_open:window"); + updatePlacesCommand("placesCmd_open:privatewindow"); + updatePlacesCommand("placesCmd_open:tab"); + updatePlacesCommand("placesCmd_new:folder"); + updatePlacesCommand("placesCmd_new:bookmark"); + updatePlacesCommand("placesCmd_new:separator"); + updatePlacesCommand("placesCmd_show:info"); + updatePlacesCommand("placesCmd_moveBookmarks"); + updatePlacesCommand("placesCmd_reload"); + updatePlacesCommand("placesCmd_sortBy:name"); + updatePlacesCommand("placesCmd_cut"); + updatePlacesCommand("placesCmd_copy"); + updatePlacesCommand("placesCmd_paste"); + updatePlacesCommand("placesCmd_delete"); +} + +function doGetPlacesControllerForCommand(aCommand) +{ + // A context menu may be built for non-focusable views. Thus, we first try + // to look for a view associated with document.popupNode + let popupNode; + try { + popupNode = document.popupNode; + } catch (e) { + // The document went away (bug 797307). + return null; + } + if (popupNode) { + let view = PlacesUIUtils.getViewForNode(popupNode); + if (view && view._contextMenuShown) + return view.controllers.getControllerForCommand(aCommand); + } + + // When we're not building a context menu, only focusable views + // are possible. Thus, we can safely use the command dispatcher. + let controller = top.document.commandDispatcher + .getControllerForCommand(aCommand); + if (controller) + return controller; + + return null; +} + +function goDoPlacesCommand(aCommand) +{ + let controller = doGetPlacesControllerForCommand(aCommand); + if (controller && controller.isCommandEnabled(aCommand)) + controller.doCommand(aCommand); +} |