From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../places/content/bookmarkProperties.js | 524 +++++ .../places/content/bookmarkProperties.xul | 41 + .../components/places/content/bookmarksPanel.js | 24 + .../components/places/content/bookmarksPanel.xul | 54 + .../places/content/browserPlacesViews.js | 2287 ++++++++++++++++++++ comm/suite/components/places/content/controller.js | 1442 ++++++++++++ .../places/content/editBookmarkOverlay.js | 1129 ++++++++++ .../places/content/editBookmarkOverlay.xul | 191 ++ .../components/places/content/history-panel.js | 86 + .../components/places/content/history-panel.xul | 95 + comm/suite/components/places/content/menu.xml | 624 ++++++ comm/suite/components/places/content/organizer.css | 7 + comm/suite/components/places/content/places.css | 37 + comm/suite/components/places/content/places.js | 1366 ++++++++++++ comm/suite/components/places/content/places.xul | 337 +++ .../components/places/content/placesOverlay.xul | 228 ++ .../components/places/content/sidebarUtils.js | 105 + comm/suite/components/places/content/tree.xml | 812 +++++++ comm/suite/components/places/content/treeView.js | 1823 ++++++++++++++++ 19 files changed, 11212 insertions(+) create mode 100644 comm/suite/components/places/content/bookmarkProperties.js create mode 100644 comm/suite/components/places/content/bookmarkProperties.xul create mode 100644 comm/suite/components/places/content/bookmarksPanel.js create mode 100644 comm/suite/components/places/content/bookmarksPanel.xul create mode 100644 comm/suite/components/places/content/browserPlacesViews.js create mode 100644 comm/suite/components/places/content/controller.js create mode 100644 comm/suite/components/places/content/editBookmarkOverlay.js create mode 100644 comm/suite/components/places/content/editBookmarkOverlay.xul create mode 100644 comm/suite/components/places/content/history-panel.js create mode 100644 comm/suite/components/places/content/history-panel.xul create mode 100644 comm/suite/components/places/content/menu.xml create mode 100644 comm/suite/components/places/content/organizer.css create mode 100644 comm/suite/components/places/content/places.css create mode 100644 comm/suite/components/places/content/places.js create mode 100644 comm/suite/components/places/content/places.xul create mode 100644 comm/suite/components/places/content/placesOverlay.xul create mode 100644 comm/suite/components/places/content/sidebarUtils.js create mode 100644 comm/suite/components/places/content/tree.xml create mode 100644 comm/suite/components/places/content/treeView.js (limited to 'comm/suite/components/places/content') diff --git a/comm/suite/components/places/content/bookmarkProperties.js b/comm/suite/components/places/content/bookmarkProperties.js new file mode 100644 index 0000000000..8078a4e9a7 --- /dev/null +++ b/comm/suite/components/places/content/bookmarkProperties.js @@ -0,0 +1,524 @@ +/* -*- 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" + * @ loadBookmarkInSidebar - optional, the default state for the + * "Load this bookmark in the sidebar" field. + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * - "livemark" + * @ 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. + * @ description (String) - optional, the default description 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/livemark item 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" (also applies to livemarks) + * @ 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" + * - "description" + * - "keyword" + * - "tags" + * - "loadInSidebar" + * - "folderPicker" - hides both the tree and the menu. + * + * window.arguments[0].performed is set to true if any transaction has + * been performed by the dialog. + */ + +/* import-globals-from editBookmarkOverlay.js */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; +const LIVEMARK_CONTAINER = 2; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var elementsHeight = new Map(); + +var BookmarkPropertiesPanel = { + + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _itemId: -1, + _uri: null, + _loadInSidebar: false, + _title: "", + _description: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + _feedURI: null, + _siteURI: null, + + _defaultInsertionPoint: null, + _hiddenRows: [], + + /** + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function BPP__getAcceptLabel() { + if (this._action == ACTION_ADD) { + if (this._URIs.length) + return this._strings.getString("dialogAcceptLabelAddMulti"); + + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogAcceptLabelAddLivemark"); + + if (this._dummyItem || this._loadInSidebar) + return this._strings.getString("dialogAcceptLabelAddItem"); + + return this._strings.getString("dialogAcceptLabelSaveItem"); + } + return this._strings.getString("dialogAcceptLabelEdit"); + }, + + /** + * 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("dialogTitleAddBookmark"); + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogTitleAddLivemark"); + + // 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("dialogTitleAddFolder"); + } + if (this._action == ACTION_EDIT) { + return this._strings.getFormattedString("dialogTitleEdit", [this._title]); + } + 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 { + this._defaultInsertionPoint = + new PlacesInsertionPoint({ + parentId: PlacesUtils.bookmarksMenuFolderId, + parentGuid: PlacesUtils.bookmarks.menuGuid + }); + } + + 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 ("loadBookmarkInSidebar" in dialogInfo) + this._loadInSidebar = dialogInfo.loadBookmarkInSidebar; + + 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; + + case "livemark": + this._itemType = LIVEMARK_CONTAINER; + if ("feedURI" in dialogInfo) + this._feedURI = dialogInfo.feedURI; + if ("siteURI" in dialogInfo) + this._siteURI = dialogInfo.siteURI; + + if (!this._title) { + if (this._feedURI) { + this._title = await PlacesUtils.history.fetch(this._feedURI) || + this._feedURI.spec; + } else + this._title = this._strings.getString("newLivemarkDefault"); + } + } + + if ("description" in dialogInfo) + this._description = dialogInfo.description; + } 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() { + await this._determineItemInfo(); + + document.title = this._getDialogTitle(); + + // Disable the buttons until we have all the information required. + let acceptButton = document.documentElement.getButton("accept"); + acceptButton.disabled = true; + + // Allow initialization to complete in a truely async manner so that we're + // not blocking the main thread. + this._initDialog().catch(ex => { + Cu.reportError(`Failed to initialize dialog: ${ex}`); + }); + }, + + /** + * 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.documentElement.getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + let acceptButtonDisabled = false; + + // Do not use sizeToContent, otherwise, due to bug 90276, the dialog will + // grow at every opening. + // Since elements can be uncollapsed asynchronously, we must observe their + // mutations and resize the dialog using a cached element size. + this._height = window.outerHeight; + this._mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + let target = mutation.target; + let id = target.id; + if (!/^editBMPanel_.*(Row|Checkbox)$/.test(id)) + continue; + + let collapsed = target.getAttribute("collapsed") === "true"; + let wasCollapsed = mutation.oldValue === "true"; + if (collapsed == wasCollapsed) + continue; + + if (collapsed) { + this._height -= elementsHeight.get(id); + elementsHeight.delete(id); + } else { + elementsHeight.set(id, target.boxObject.height); + this._height += elementsHeight.get(id); + } + window.resizeTo(window.outerWidth, this._height); + } + }); + + this._mutationObserver.observe(document, + { subtree: true, + attributeOldValue: true, + attributeFilter: ["collapsed"] }); + + // Some controls are flexible and we want to update their cached size when + // the dialog is resized. + window.addEventListener("resize", this); + + switch (this._action) { + case ACTION_EDIT: + 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 + 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; + }, + + // nsIDOMEventListener + 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.documentElement + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + case "resize": + for (let [id, oldHeight] of elementsHeight) { + let newHeight = document.getElementById(id).boxObject.height; + this._height += -oldHeight + newHeight; + elementsHeight.set(id, newHeight); + } + break; + } + }, + + // nsISupports + QueryInterface: function BPP_QueryInterface(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_NOINTERFACE; + }, + + _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; + + window.removeEventListener("resize", this); + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField") + .removeEventListener("input", this); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement.blur(); + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + window.arguments[0].performed = true; + }, + + onDialogCancel() { + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + window.arguments[0].performed = false; + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns 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 XUL textbox with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns 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) { + PlacesUIUtils.createFixedURI(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 [ + this._defaultInsertionPoint.itemId, + await this._defaultInsertionPoint.getIndex(), + this._defaultInsertionPoint.guid, + ]; + }, + + async _promiseNewItem() { + let [containerId, index, parentGuid] = await this._getInsertionPointDetails(); + let annotations = []; + if (this._description) { + annotations.push({ name: PlacesUIUtils.DESCRIPTION_ANNO, + value: this._description }); + } + if (this._loadInSidebar) { + annotations.push({ name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + value: true }); + } + + let itemGuid; + let info = { parentGuid, index, title: this._title, annotations }; + 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 && !PrivateBrowsingUtils.isWindowPrivate(window)) + PlacesUtils.setCharsetForURI(this._uri, this._charSet); + + itemGuid = await PlacesTransactions.NewBookmark(info).transact(); + } else if (this._itemType == LIVEMARK_CONTAINER) { + info.feedUrl = this._feedURI; + if (this._siteURI) + info.siteUrl = this._siteURI; + + itemGuid = await PlacesTransactions.NewLivemark(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 = await PlacesTransactions.NewFolder(info).transact(); + } else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + + this._itemGuid = itemGuid; + this._itemId = await PlacesUtils.promiseItemId(itemGuid); + return Object.freeze({ + itemId: this._itemId, + bookmarkGuid: this._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: containerId, + bookmarkGuid: parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER + } + }); + } +}; diff --git a/comm/suite/components/places/content/bookmarkProperties.xul b/comm/suite/components/places/content/bookmarkProperties.xul new file mode 100644 index 0000000000..739d21c879 --- /dev/null +++ b/comm/suite/components/places/content/bookmarkProperties.xul @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + %editBookmarkOverlayDTD; +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/comm/suite/components/places/content/sidebarUtils.js b/comm/suite/components/places/content/sidebarUtils.js new file mode 100644 index 0000000000..b11f891187 --- /dev/null +++ b/comm/suite/components/places/content/sidebarUtils.js @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); + +let uidensity = window.top.document.documentElement.getAttribute("uidensity"); +if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); +} + +var SidebarUtils = { + handleTreeClick: function SU_handleTreeClick(aTree, aEvent, aGutterSelect) { + // right-clicks are not handled here + if (aEvent.button == 2) + return; + + var tbo = aTree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + if (cell.row == -1 || cell.childElt == "twisty") + return; + + var mouseInGutter = false; + if (aGutterSelect) { + var rect = tbo.getCoordsForCellItem(cell.row, cell.col, "image"); + // getCoordsForCellItem returns the x coordinate in logical coordinates + // (i.e., starting from the left and right sides in LTR and RTL modes, + // respectively.) Therefore, we make sure to exclude the blank area + // before the tree item icon (that is, to the left or right of it in + // LTR and RTL modes, respectively) from the click target area. + var isRTL = window.getComputedStyle(aTree).direction == "rtl"; + if (isRTL) + mouseInGutter = aEvent.clientX > rect.x; + else + mouseInGutter = aEvent.clientX < rect.x; + } + + var metaKey = AppConstants.platform === "macosx" ? aEvent.metaKey + : aEvent.ctrlKey; + var modifKey = metaKey || aEvent.shiftKey; + var isContainer = tbo.view.isContainer(cell.row); + var openInTabs = isContainer && + (aEvent.button == 1 || + (aEvent.button == 0 && modifKey)) && + PlacesUtils.hasChildURIs(aTree.view.nodeForTreeIndex(cell.row)); + + if (aEvent.button == 0 && isContainer && !openInTabs) { + tbo.view.toggleOpenState(cell.row); + } else if (!mouseInGutter && openInTabs && + aEvent.originalTarget.localName == "treechildren") { + tbo.view.selection.select(cell.row); + PlacesUIUtils.openContainerNodeInTabs(aTree.selectedNode, aEvent, aTree); + } else if (!mouseInGutter && !isContainer && + aEvent.originalTarget.localName == "treechildren") { + // Clear all other selection since we're loading a link now. We must + // do this *before* attempting to load the link since openURL uses + // selection as an indication of which link to load. + tbo.view.selection.select(cell.row); + PlacesUIUtils.openNodeWithEvent(aTree.selectedNode, aEvent); + } + }, + + handleTreeKeyPress: function SU_handleTreeKeyPress(aEvent) { + let node = aEvent.target.selectedNode; + if (node) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) + PlacesUIUtils.openNodeWithEvent(node, aEvent); + } + }, + + /** + * The following function displays the URL of a node that is being + * hovered over. + */ + handleTreeMouseMove: function SU_handleTreeMouseMove(aEvent) { + if (aEvent.target.localName != "treechildren") + return; + + var tree = aEvent.target.parentNode; + var tbo = tree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + // cell.row is -1 when the mouse is hovering an empty area within the tree. + // To avoid showing a URL from a previously hovered node for a currently + // hovered non-url node, we must clear the moused-over URL in these cases. + if (cell.row != -1) { + var node = tree.view.nodeForTreeIndex(cell.row); + if (PlacesUtils.nodeIsURI(node)) + this.setMouseoverURL(node.uri); + else + this.setMouseoverURL(""); + } else + this.setMouseoverURL(""); + }, + + setMouseoverURL: function SU_setMouseoverURL(aURL) { + // When the browser window is closed with an open sidebar, the sidebar + // unload event happens after the browser's one. In this case + // top.XULBrowserWindow has been nullified already. + if (top.XULBrowserWindow) { + top.XULBrowserWindow.setOverLink(aURL, null); + } + } +}; diff --git a/comm/suite/components/places/content/tree.xml b/comm/suite/components/places/content/tree.xml new file mode 100644 index 0000000000..f033f193fa --- /dev/null +++ b/comm/suite/components/places/content/tree.xml @@ -0,0 +1,812 @@ + + + + + + + + + + + result->treeviewer cycle. + // Note: unsetting the result's viewer also unsets + // the viewer's reference to our treeBoxObject. + var result = this.result; + if (result) { + result.root.containerOpen = false; + } + + // Unregister the controllber 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(); + } + this.view = null; + ]]> + + + + + + + + + + + + + + + + + + + + + + 0) { + treeView.selection.select(0); + } + + this._cachedInsertionPoint = undefined; + ]]> + + + + + + + + + + + + + + + + + + + + + + = 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.treeBoxObject.ensureRowIsVisible(index); + ]]> + + + + + + + + + + + + + + + + = 1; + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0; 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; + } + for (let i = 0; i < nodes.length; i++) { + var index = resultview.treeIndexForNode(nodes[i]); + if (index == -1) + continue; + selection.rangedSelect(index, index, true); + } + selection.selectEventsSuppressed = false; + ]]> + + + false + + + + + + + + + this._contextMenuShown = false; + + + + + + true + + + + + + + + + + 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(); + ]]> + + + + + + + diff --git a/comm/suite/components/places/content/treeView.js b/comm/suite/components/places/content/treeView.js new file mode 100644 index 0000000000..209af6549a --- /dev/null +++ b/comm/suite/components/places/content/treeView.js @@ -0,0 +1,1823 @@ +/* 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/. */ + +var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const PTV_interfaces = [Ci.nsITreeView, + Ci.nsINavHistoryResultObserver, + Ci.nsISupportsWeakReference]; + +/** + * This returns the key for any node/details object. + * + * @param 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. + * + * @return 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(aFlatList, aOnOpenFlatContainer, aController) { + this._tree = null; + this._result = null; + this._selection = null; + this._rootNode = null; + this._rows = []; + this._flatList = aFlatList; + this._nodeDetails = new Map(); + this._openContainerCallback = aOnOpenFlatContainer; + this._controller = aController; +} + +PlacesTreeView.prototype = { + get wrappedJSObject() { + return this; + }, + + __xulStore: null, + get _xulStore() { + if (!this.__xulStore) { + this.__xulStore = Cc["@mozilla.org/xul/xulstore;1"].getService(Ci.nsIXULStore); + } + return this.__xulStore; + }, + + QueryInterface: XPCOMUtils.generateQI(PTV_interfaces), + + // Bug 761494: + // ---------- + // Some addons use methods from nsINavHistoryResultObserver and + // nsINavHistoryResultTreeViewer, without QIing to these interfaces first. + // That's not a problem when the view is retrieved through the + // .view getter (which returns the wrappedJSObject of this object), + // it raises an issue when the view retrieved through the treeBoxObject.view + // getter. Thus, to avoid breaking addons, the interfaces are prefetched. + classInfo: XPCOMUtils.generateCI({ interfaces: PTV_interfaces }), + + /** + * 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. + * + * @see _getNodeForRow and _getRowForNode for the actual magic. + * + * @note 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. + * + * @param aContainer + * A container result node. + * + * @return true if aContainer is a plain container, false otherwise. + */ + _isPlainContainer: function PTV__isPlainContainer(aContainer) { + // Livemarks are always plain containers. + if (this._controller.hasCachedLivemarkInfo(aContainer)) + return true; + + // 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_TAG_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_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). + * + * @param 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 [optional] aForceBuild + * @see _isPlainContainer. + * If true, the row will be computed even if the node still isn't set + * in our rows array. + * @param [optional] aParentRow + * The row of aNode's parent. Ignored for the root node. + * @param [optional] aNodeIndex + * The index of aNode in its parent. Only used if aParentRow is + * set too. + * + * @throws if aNode is invisible. + * @note 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. + * @return 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 == 0 || + 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 aChildRow + * Row number. + * @return [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. + */ + _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 [in] aContainer + * A container result node. + * @param [in] aFirstChildRow + * The first row at which nodes may be inserted to the row array. + * In other words, that's aContainer's row + 1. + * @param [out] aToOpen + * An array of containers to open once the build is done. + * + * @return 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 && + !this._controller.hasCachedLivemarkInfo(curChild)) { + let uri = curChild.uri; + let isopen = false; + + if (uri) { + let val = this._xulStore.getValue(document.documentURI, 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. + */ + _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 aUpdatedContainer + * An ancestor of the node which was removed. It does not have to be + * its direct parent. + * @param aOldNode + * The node which was removed. + * + * @return 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 aNodesInfo + * The persisted selection state as returned by + * _getSelectedNodesInRange. + * @param aUpdatedContainer + * The container which was updated. + */ + _restoreSelection: + function PTV__restoreSelection(aNodesInfo, aUpdatedContainer) { + if (aNodesInfo.length == 0) + 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_DESCRIPTION: 5, + COLUMN_TYPE_DATEADDED: 6, + COLUMN_TYPE_LASTMODIFIED: 7, + COLUMN_TYPE_TAGS: 8, + + _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 "description": + return this.COLUMN_TYPE_DESCRIPTION; + 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_ANNOTATION_ASCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, false]; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, 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. + */ + 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 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 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 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); + if (nodesToReselect.length > 0) + 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 > 0) { + 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); + } + }, + + _populateLivemarkContainer: function PTV__populateLivemarkContainer(aNode) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let placesNode = aNode; + // Need to check containerOpen since getLivemark is async. + if (!placesNode.containerOpen) + return; + + let children = aLivemark.getNodesForContainer(placesNode); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + this.nodeInserted(placesNode, child, i); + } + }, Cu.reportError); + }, + + 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); + if (aNode.parent && this._controller.hasCachedLivemarkInfo(aNode.parent)) { + // Find the node in the parent. + let parentRow = this._flatList ? 0 : this._getRowForNode(aNode.parent); + for (let i = parentRow; i < this._rows.length; i++) { + let child = this.nodeForTreeIndex(i); + if (child.uri == aNode.uri) { + this._cellProperties.delete(child); + this._invalidateCellValue(child, this.COLUMN_TYPE_TITLE); + break; + } + } + return; + } + + 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) {}, + + nodeAnnotationChanged: function PTV_nodeAnnotationChanged(aNode, aAnno) { + if (aAnno == PlacesUIUtils.DESCRIPTION_ANNO) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DESCRIPTION); + } else if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(aNode, aLivemark); + let properties = this._cellProperties.get(aNode); + this._cellProperties.set(aNode, properties += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, Cu.reportError); + } + }, + + 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); + + if (PlacesUtils.nodeIsFolder(aNode) || + (this._flatList && aNode == this._rootNode)) { + let queryOptions = PlacesUtils.asQuery(this._rootNode).queryOptions; + if (queryOptions.excludeItems) { + return; + } + if (aNode.itemId != -1) { // run when there's a valid node id + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let shouldInvalidate = + !this._controller.hasCachedLivemarkInfo(aNode); + this._controller.cacheLivemarkInfo(aNode, aLivemark); + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + aLivemark.registerForUpdates(aNode, this); + // Prioritize the current livemark. + aLivemark.reload(); + PlacesUtils.livemarks.reloadLivemarks(); + if (shouldInvalidate) + this.invalidateContainer(aNode); + } else { + aLivemark.unregisterForUpdates(aNode); + } + }, () => undefined); + } + } + }, + + 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.element.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.element, { + 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 > 0 && + 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; + } + } + + if (this._controller.hasCachedLivemarkInfo(aContainer)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (!queryOptions.excludeItems) { + this._populateLivemarkContainer(aContainer); + } + } + + 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(); + + return val; + }, + + /** + * 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. + * @return {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 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"; + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER || + nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + if (itemId != -1) { + if (this._controller.hasCachedLivemarkInfo(node)) { + properties += " livemark"; + } else { + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(node, aLivemark); + let livemarkProps = this._cellProperties.get(node); + this._cellProperties.set(node, livemarkProps += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(node, this.COLUMN_TYPE_TITLE); + }, () => undefined); + } + } + } + + 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; + } + } else { + let queryName = PlacesUIUtils.getLeftPaneQueryNameFromId(itemId); + if (queryName) + properties += " OrganizerQuery_" + queryName; + } + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) + properties += " separator"; + else if (PlacesUtils.nodeIsURI(node)) { + properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri); + + if (this._controller.hasCachedLivemarkInfo(node.parent)) { + properties += " livemarkItem"; + if (node.accessCount) { + properties += " visited"; + } + } + } + + 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)) { + PlacesUtils.asQuery(node); + return 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; + + let node = this._rows[aRow]; + if (this._controller.hasCachedLivemarkInfo(node)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + return queryOptions.excludeItems; + } + + // All containers are listed in the rows array. + return !node.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 Cr.NS_ERROR_UNEXPECTED; + + // 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; + + // 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 || + queryOptions.excludeReadOnlyFolders) { + // 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 lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (this._controller.disallowInsertion(container)) + return null; + + // TODO (Bug 1160193): properly support dropping on a tag root. + let tagName = null; + if (PlacesUtils.nodeIsTagQuery(container)) { + tagName = container.title; + if (!tagName) + return null; + } + + return new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(container), + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, orientation, tagName, dropNearNode + }); + }, + + drop: function PTV_drop(aRow, aOrientation, aDataTransfer) { + // 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) { + PlacesControllerDragHelper.onDrop(ip, aDataTransfer, this._tree.element) + .catch(Cu.reportError) + .then(() => { + // 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_DESCRIPTION: + if (node.itemId != -1) { + try { + return PlacesUtils.annotations. + getItemAnnotation(node.itemId, PlacesUIUtils.DESCRIPTION_ANNO); + } catch (ex) { /* has no description */ } + } + return ""; + 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) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + } + if (aTree) + this._finishInit(); + } + }, + + toggleOpenState: function PTV_toggleOpenState(aRow) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + let node = this._rows[aRow]; + if (this._flatList && this._openContainerCallback) { + this._openContainerCallback(node); + return; + } + + // Persist containers open status, but never persist livemarks. + if (!this._controller.hasCachedLivemarkInfo(node)) { + let uri = node.uri; + + if (uri) { + let docURI = document.documentURI; + + if (node.containerOpen) { + this._xulStore.removeValue(docURI, uri, "open"); + } else { + this._xulStore.setValue(docURI, uri, "open", "true"); + } + } + } + + node.containerOpen = !node.containerOpen; + }, + + cycleHeader: function PTV_cycleHeader(aColumn) { + if (!this._result) + throw 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 oldSortingAnnotation = this._result.sortingAnnotation; + let newSort; + let newSortingAnnotation = ""; + 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_DESCRIPTION: + if (oldSort == NHQO.SORT_BY_ANNOTATION_ASCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) { + newSort = NHQO.SORT_BY_ANNOTATION_DESCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } else if (allowTriState && + oldSort == NHQO.SORT_BY_ANNOTATION_DESCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + newSort = NHQO.SORT_BY_NONE; + else { + newSort = NHQO.SORT_BY_ANNOTATION_ASCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } + + 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 Cr.NS_ERROR_INVALID_ARG; + } + this._result.sortingAnnotation = newSortingAnnotation; + 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) { + Cu.reportError("isEditable called for an unbuilt row."); + return false; + } + let itemGuid = node.bookmarkGuid; + + // Only bookmark-nodes are editable. Fortunately, this checks also takes + // care of livemark children. + 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(itemGuid)) + return false; + + let parentId = PlacesUtils.getConcreteItemId(node.parent); + if (parentId == PlacesUIUtils.leftPaneFolderId) { + // Note that the for the time being this is the check that actually + // blocks renaming places "roots", and not the isRootItem check above. + // That's because places root are only exposed through folder shortcuts + // descendants of the left pane folder. + 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) { }, + isSelectable(aRow, aColumn) { return false; }, +}; -- cgit v1.2.3