michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: /** michael@0: * THE PLACES VIEW IMPLEMENTED IN THIS FILE HAS A VERY PARTICULAR USE CASE. michael@0: * IT IS HIGHLY RECOMMENDED NOT TO EXTEND IT FOR ANY OTHER USE CASES OR RELY michael@0: * ON IT AS AN API. michael@0: */ michael@0: michael@0: let Cu = Components.utils; michael@0: let Ci = Components.interfaces; michael@0: let Cc = Components.classes; michael@0: michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/NetUtil.jsm"); michael@0: Cu.import("resource://gre/modules/DownloadUtils.jsm"); michael@0: Cu.import("resource:///modules/DownloadsCommon.jsm"); michael@0: Cu.import("resource://gre/modules/PlacesUtils.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", michael@0: "resource://gre/modules/PrivateBrowsingUtils.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", michael@0: "resource:///modules/RecentWindow.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", michael@0: "resource://gre/modules/FileUtils.jsm"); michael@0: michael@0: const nsIDM = Ci.nsIDownloadManager; michael@0: michael@0: const DESTINATION_FILE_URI_ANNO = "downloads/destinationFileURI"; michael@0: const DOWNLOAD_META_DATA_ANNO = "downloads/metaData"; michael@0: michael@0: const DOWNLOAD_VIEW_SUPPORTED_COMMANDS = michael@0: ["cmd_delete", "cmd_copy", "cmd_paste", "cmd_selectAll", michael@0: "downloadsCmd_pauseResume", "downloadsCmd_cancel", michael@0: "downloadsCmd_open", "downloadsCmd_show", "downloadsCmd_retry", michael@0: "downloadsCmd_openReferrer", "downloadsCmd_clearDownloads"]; michael@0: michael@0: const NOT_AVAILABLE = Number.MAX_VALUE; michael@0: michael@0: /** michael@0: * A download element shell is responsible for handling the commands and the michael@0: * displayed data for a single download view element. The download element michael@0: * could represent either a past download (for which we get data from places) or michael@0: * a "session" download (using a data-item object. See DownloadsCommon.jsm), or both. michael@0: * michael@0: * Once initialized with either a data item or a places node, the created richlistitem michael@0: * can be accessed through the |element| getter, and can then be inserted/removed from michael@0: * a richlistbox. michael@0: * michael@0: * The shell doesn't take care of inserting the item, or removing it when it's no longer michael@0: * valid. That's the caller (a DownloadsPlacesView object) responsibility. michael@0: * michael@0: * The caller is also responsible for "passing over" notification from both the michael@0: * download-view and the places-result-observer, in the following manner: michael@0: * - The DownloadsPlacesView object implements getViewItem of the download-view michael@0: * pseudo interface. It returns this object (therefore we implement michael@0: * onStateChangea and onProgressChange here). michael@0: * - The DownloadsPlacesView object adds itself as a places result observer and michael@0: * calls this object's placesNodeIconChanged, placesNodeTitleChanged and michael@0: * placeNodeAnnotationChanged from its callbacks. michael@0: * michael@0: * @param [optional] aDataItem michael@0: * The data item of a the session download. Required if aPlacesNode is not set michael@0: * @param [optional] aPlacesNode michael@0: * The places node for a past download. Required if aDataItem is not set. michael@0: * @param [optional] aAnnotations michael@0: * Map containing annotations values, to speed up the initial loading. michael@0: */ michael@0: function DownloadElementShell(aDataItem, aPlacesNode, aAnnotations) { michael@0: this._element = document.createElement("richlistitem"); michael@0: this._element._shell = this; michael@0: michael@0: this._element.classList.add("download"); michael@0: this._element.classList.add("download-state"); michael@0: michael@0: if (aAnnotations) michael@0: this._annotations = aAnnotations; michael@0: if (aDataItem) michael@0: this.dataItem = aDataItem; michael@0: if (aPlacesNode) michael@0: this.placesNode = aPlacesNode; michael@0: } michael@0: michael@0: DownloadElementShell.prototype = { michael@0: // The richlistitem for the download michael@0: get element() this._element, michael@0: michael@0: /** michael@0: * Manages the "active" state of the shell. By default all the shells michael@0: * without a dataItem are inactive, thus their UI is not updated. They must michael@0: * be activated when entering the visible area. Session downloads are michael@0: * always active since they always have a dataItem. michael@0: */ michael@0: ensureActive: function DES_ensureActive() { michael@0: if (!this._active) { michael@0: this._active = true; michael@0: this._element.setAttribute("active", true); michael@0: this._updateUI(); michael@0: } michael@0: }, michael@0: get active() !!this._active, michael@0: michael@0: // The data item for the download michael@0: _dataItem: null, michael@0: get dataItem() this._dataItem, michael@0: michael@0: set dataItem(aValue) { michael@0: if (this._dataItem != aValue) { michael@0: if (!aValue && !this._placesNode) michael@0: throw new Error("Should always have either a dataItem or a placesNode"); michael@0: michael@0: this._dataItem = aValue; michael@0: if (!this.active) michael@0: this.ensureActive(); michael@0: else michael@0: this._updateUI(); michael@0: } michael@0: return aValue; michael@0: }, michael@0: michael@0: _placesNode: null, michael@0: get placesNode() this._placesNode, michael@0: set placesNode(aValue) { michael@0: if (this._placesNode != aValue) { michael@0: if (!aValue && !this._dataItem) michael@0: throw new Error("Should always have either a dataItem or a placesNode"); michael@0: michael@0: // Preserve the annotations map if this is the first loading and we got michael@0: // cached values. michael@0: if (this._placesNode || !this._annotations) { michael@0: this._annotations = new Map(); michael@0: } michael@0: michael@0: this._placesNode = aValue; michael@0: michael@0: // We don't need to update the UI if we had a data item, because michael@0: // the places information isn't used in this case. michael@0: if (!this._dataItem && this.active) michael@0: this._updateUI(); michael@0: } michael@0: return aValue; michael@0: }, michael@0: michael@0: // The download uri (as a string) michael@0: get downloadURI() { michael@0: if (this._dataItem) michael@0: return this._dataItem.uri; michael@0: if (this._placesNode) michael@0: return this._placesNode.uri; michael@0: throw new Error("Unexpected download element state"); michael@0: }, michael@0: michael@0: get _downloadURIObj() { michael@0: if (!("__downloadURIObj" in this)) michael@0: this.__downloadURIObj = NetUtil.newURI(this.downloadURI); michael@0: return this.__downloadURIObj; michael@0: }, michael@0: michael@0: _getIcon: function DES__getIcon() { michael@0: let metaData = this.getDownloadMetaData(); michael@0: if ("filePath" in metaData) michael@0: return "moz-icon://" + metaData.filePath + "?size=32"; michael@0: michael@0: if (this._placesNode) { michael@0: // Try to extract an extension from the uri. michael@0: let ext = this._downloadURIObj.QueryInterface(Ci.nsIURL).fileExtension; michael@0: if (ext) michael@0: return "moz-icon://." + ext + "?size=32"; michael@0: return this._placesNode.icon || "moz-icon://.unknown?size=32"; michael@0: } michael@0: if (this._dataItem) michael@0: throw new Error("Session-download items should always have a target file uri"); michael@0: michael@0: throw new Error("Unexpected download element state"); michael@0: }, michael@0: michael@0: // Helper for getting a places annotation set for the download. michael@0: _getAnnotation: function DES__getAnnotation(aAnnotation, aDefaultValue) { michael@0: let value; michael@0: if (this._annotations.has(aAnnotation)) michael@0: value = this._annotations.get(aAnnotation); michael@0: michael@0: // If the value is cached, or we know it doesn't exist, avoid a database michael@0: // lookup. michael@0: if (value === undefined) { michael@0: try { michael@0: value = PlacesUtils.annotations.getPageAnnotation( michael@0: this._downloadURIObj, aAnnotation); michael@0: } michael@0: catch(ex) { michael@0: value = NOT_AVAILABLE; michael@0: } michael@0: } michael@0: michael@0: if (value === NOT_AVAILABLE) { michael@0: if (aDefaultValue === undefined) { michael@0: throw new Error("Could not get required annotation '" + aAnnotation + michael@0: "' for download with url '" + this.downloadURI + "'"); michael@0: } michael@0: value = aDefaultValue; michael@0: } michael@0: michael@0: this._annotations.set(aAnnotation, value); michael@0: return value; michael@0: }, michael@0: michael@0: _fetchTargetFileInfo: function DES__fetchTargetFileInfo(aUpdateMetaDataAndStatusUI = false) { michael@0: if (this._targetFileInfoFetched) michael@0: throw new Error("_fetchTargetFileInfo should not be called if the information was already fetched"); michael@0: if (!this.active) michael@0: throw new Error("Trying to _fetchTargetFileInfo on an inactive download shell"); michael@0: michael@0: let path = this.getDownloadMetaData().filePath; michael@0: michael@0: // In previous version, the target file annotations were not set, michael@0: // so we cannot tell where is the file. michael@0: if (path === undefined) { michael@0: this._targetFileInfoFetched = true; michael@0: this._targetFileExists = false; michael@0: if (aUpdateMetaDataAndStatusUI) { michael@0: this._metaData = null; michael@0: this._updateDownloadStatusUI(); michael@0: } michael@0: // Here we don't need to update the download commands, michael@0: // as the state is unknown as it was. michael@0: return; michael@0: } michael@0: michael@0: OS.File.stat(path).then( michael@0: function onSuccess(fileInfo) { michael@0: this._targetFileInfoFetched = true; michael@0: this._targetFileExists = true; michael@0: this._targetFileSize = fileInfo.size; michael@0: if (aUpdateMetaDataAndStatusUI) { michael@0: this._metaData = null; michael@0: this._updateDownloadStatusUI(); michael@0: } michael@0: if (this._element.selected) michael@0: goUpdateDownloadCommands(); michael@0: }.bind(this), michael@0: michael@0: function onFailure(aReason) { michael@0: if (aReason instanceof OS.File.Error && aReason.becauseNoSuchFile) { michael@0: this._targetFileInfoFetched = true; michael@0: this._targetFileExists = false; michael@0: } michael@0: else { michael@0: Cu.reportError("Could not fetch info for target file (reason: " + michael@0: aReason + ")"); michael@0: } michael@0: michael@0: if (aUpdateMetaDataAndStatusUI) { michael@0: this._metaData = null; michael@0: this._updateDownloadStatusUI(); michael@0: } michael@0: michael@0: if (this._element.selected) michael@0: goUpdateDownloadCommands(); michael@0: }.bind(this) michael@0: ); michael@0: }, michael@0: michael@0: _getAnnotatedMetaData: function DES__getAnnotatedMetaData() michael@0: JSON.parse(this._getAnnotation(DOWNLOAD_META_DATA_ANNO)), michael@0: michael@0: _extractFilePathAndNameFromFileURI: michael@0: function DES__extractFilePathAndNameFromFileURI(aFileURI) { michael@0: let file = Cc["@mozilla.org/network/protocol;1?name=file"] michael@0: .getService(Ci.nsIFileProtocolHandler) michael@0: .getFileFromURLSpec(aFileURI); michael@0: return [file.path, file.leafName]; michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the meta data object for the download. The following fields michael@0: * may be set. michael@0: * michael@0: * - state - any download state defined in nsIDownloadManager. If this field michael@0: * is not set, the download state is unknown. michael@0: * - endTime: the end time of the download. michael@0: * - filePath: the downloaded file path on the file system, when it michael@0: * was downloaded. The file may not exist. This is set for session michael@0: * downloads that have a local file set, and for history downloads done michael@0: * after the landing of bug 591289. michael@0: * - fileName: the downloaded file name on the file system. Set if filePath michael@0: * is set. michael@0: * - displayName: the user-facing label for the download. This is always michael@0: * set. If available, it's set to the downloaded file name. If not, michael@0: * the places title for the download uri is used it's set. As a last michael@0: * resort, we fallback to the download uri. michael@0: * - fileSize (only set for downloads which completed succesfully): michael@0: * the downloaded file size. For downloads done after the landing of michael@0: * bug 826991, this value is "static" - that is, it does not necessarily michael@0: * mean that the file is in place and has this size. michael@0: */ michael@0: getDownloadMetaData: function DES_getDownloadMetaData() { michael@0: if (!this._metaData) { michael@0: if (this._dataItem) { michael@0: this._metaData = { michael@0: state: this._dataItem.state, michael@0: endTime: this._dataItem.endTime, michael@0: fileName: this._dataItem.target, michael@0: displayName: this._dataItem.target michael@0: }; michael@0: if (this._dataItem.done) michael@0: this._metaData.fileSize = this._dataItem.maxBytes; michael@0: if (this._dataItem.localFile) michael@0: this._metaData.filePath = this._dataItem.localFile.path; michael@0: } michael@0: else { michael@0: try { michael@0: this._metaData = this._getAnnotatedMetaData(); michael@0: } michael@0: catch(ex) { michael@0: this._metaData = { }; michael@0: if (this._targetFileInfoFetched && this._targetFileExists) { michael@0: this._metaData.state = this._targetFileSize > 0 ? michael@0: nsIDM.DOWNLOAD_FINISHED : nsIDM.DOWNLOAD_FAILED; michael@0: this._metaData.fileSize = this._targetFileSize; michael@0: } michael@0: michael@0: // This is actually the start-time, but it's the best we can get. michael@0: this._metaData.endTime = this._placesNode.time / 1000; michael@0: } michael@0: michael@0: try { michael@0: let targetFileURI = this._getAnnotation(DESTINATION_FILE_URI_ANNO); michael@0: [this._metaData.filePath, this._metaData.fileName] = michael@0: this._extractFilePathAndNameFromFileURI(targetFileURI); michael@0: this._metaData.displayName = this._metaData.fileName; michael@0: } michael@0: catch(ex) { michael@0: this._metaData.displayName = this._placesNode.title || this.downloadURI; michael@0: } michael@0: } michael@0: } michael@0: return this._metaData; michael@0: }, michael@0: michael@0: // The status text for the download michael@0: _getStatusText: function DES__getStatusText() { michael@0: let s = DownloadsCommon.strings; michael@0: if (this._dataItem && this._dataItem.inProgress) { michael@0: if (this._dataItem.paused) { michael@0: let transfer = michael@0: DownloadUtils.getTransferTotal(this._dataItem.currBytes, michael@0: this._dataItem.maxBytes); michael@0: michael@0: // We use the same XUL label to display both the state and the amount michael@0: // transferred, for example "Paused - 1.1 MB". michael@0: return s.statusSeparatorBeforeNumber(s.statePaused, transfer); michael@0: } michael@0: if (this._dataItem.state == nsIDM.DOWNLOAD_DOWNLOADING) { michael@0: let [status, newEstimatedSecondsLeft] = michael@0: DownloadUtils.getDownloadStatus(this.dataItem.currBytes, michael@0: this.dataItem.maxBytes, michael@0: this.dataItem.speed, michael@0: this._lastEstimatedSecondsLeft || Infinity); michael@0: this._lastEstimatedSecondsLeft = newEstimatedSecondsLeft; michael@0: return status; michael@0: } michael@0: if (this._dataItem.starting) { michael@0: return s.stateStarting; michael@0: } michael@0: if (this._dataItem.state == nsIDM.DOWNLOAD_SCANNING) { michael@0: return s.stateScanning; michael@0: } michael@0: michael@0: throw new Error("_getStatusText called with a bogus download state"); michael@0: } michael@0: michael@0: // This is a not-in-progress or history download. michael@0: let stateLabel = ""; michael@0: let state = this.getDownloadMetaData().state; michael@0: switch (state) { michael@0: case nsIDM.DOWNLOAD_FAILED: michael@0: stateLabel = s.stateFailed; michael@0: break; michael@0: case nsIDM.DOWNLOAD_CANCELED: michael@0: stateLabel = s.stateCanceled; michael@0: break; michael@0: case nsIDM.DOWNLOAD_BLOCKED_PARENTAL: michael@0: stateLabel = s.stateBlockedParentalControls; michael@0: break; michael@0: case nsIDM.DOWNLOAD_BLOCKED_POLICY: michael@0: stateLabel = s.stateBlockedPolicy; michael@0: break; michael@0: case nsIDM.DOWNLOAD_DIRTY: michael@0: stateLabel = s.stateDirty; michael@0: break; michael@0: case nsIDM.DOWNLOAD_FINISHED:{ michael@0: // For completed downloads, show the file size (e.g. "1.5 MB") michael@0: let metaData = this.getDownloadMetaData(); michael@0: if ("fileSize" in metaData) { michael@0: let [size, unit] = DownloadUtils.convertByteUnits(metaData.fileSize); michael@0: stateLabel = s.sizeWithUnits(size, unit); michael@0: break; michael@0: } michael@0: // Fallback to default unknown state. michael@0: } michael@0: default: michael@0: stateLabel = s.sizeUnknown; michael@0: break; michael@0: } michael@0: michael@0: // TODO (bug 829201): history downloads should get the referrer from Places. michael@0: let referrer = this._dataItem && this._dataItem.referrer || michael@0: this.downloadURI; michael@0: let [displayHost, fullHost] = DownloadUtils.getURIHost(referrer); michael@0: michael@0: let date = new Date(this.getDownloadMetaData().endTime); michael@0: let [displayDate, fullDate] = DownloadUtils.getReadableDates(date); michael@0: michael@0: // We use the same XUL label to display the state, the host name, and the michael@0: // end time. michael@0: let firstPart = s.statusSeparator(stateLabel, displayHost); michael@0: return s.statusSeparator(firstPart, displayDate); michael@0: }, michael@0: michael@0: // The progressmeter element for the download michael@0: get _progressElement() { michael@0: if (!("__progressElement" in this)) { michael@0: this.__progressElement = michael@0: document.getAnonymousElementByAttribute(this._element, "anonid", michael@0: "progressmeter"); michael@0: } michael@0: return this.__progressElement; michael@0: }, michael@0: michael@0: // Updates the download state attribute (and by that hide/unhide the michael@0: // appropriate buttons and context menu items), the status text label, michael@0: // and the progress meter. michael@0: _updateDownloadStatusUI: function DES__updateDownloadStatusUI() { michael@0: if (!this.active) michael@0: throw new Error("_updateDownloadStatusUI called for an inactive item."); michael@0: michael@0: let state = this.getDownloadMetaData().state; michael@0: if (state !== undefined) michael@0: this._element.setAttribute("state", state); michael@0: michael@0: this._element.setAttribute("status", this._getStatusText()); michael@0: michael@0: // For past-downloads, we're done. For session-downloads, we may also need michael@0: // to update the progress-meter. michael@0: if (!this._dataItem) michael@0: return; michael@0: michael@0: // Copied from updateProgress in downloads.js. michael@0: if (this._dataItem.starting) { michael@0: // Before the download starts, the progress meter has its initial value. michael@0: this._element.setAttribute("progressmode", "normal"); michael@0: this._element.setAttribute("progress", "0"); michael@0: } michael@0: else if (this._dataItem.state == nsIDM.DOWNLOAD_SCANNING || michael@0: this._dataItem.percentComplete == -1) { michael@0: // We might not know the progress of a running download, and we don't know michael@0: // the remaining time during the malware scanning phase. michael@0: this._element.setAttribute("progressmode", "undetermined"); michael@0: } michael@0: else { michael@0: // This is a running download of which we know the progress. michael@0: this._element.setAttribute("progressmode", "normal"); michael@0: this._element.setAttribute("progress", this._dataItem.percentComplete); michael@0: } michael@0: michael@0: // Dispatch the ValueChange event for accessibility, if possible. michael@0: if (this._progressElement) { michael@0: let event = document.createEvent("Events"); michael@0: event.initEvent("ValueChange", true, true); michael@0: this._progressElement.dispatchEvent(event); michael@0: } michael@0: }, michael@0: michael@0: _updateDisplayNameAndIcon: function DES__updateDisplayNameAndIcon() { michael@0: let metaData = this.getDownloadMetaData(); michael@0: this._element.setAttribute("displayName", metaData.displayName); michael@0: this._element.setAttribute("image", this._getIcon()); michael@0: }, michael@0: michael@0: _updateUI: function DES__updateUI() { michael@0: if (!this.active) michael@0: throw new Error("Trying to _updateUI on an inactive download shell"); michael@0: michael@0: this._metaData = null; michael@0: this._targetFileInfoFetched = false; michael@0: michael@0: this._updateDisplayNameAndIcon(); michael@0: michael@0: // For history downloads done in past releases, the downloads/metaData michael@0: // annotation is not set, and therefore we cannot tell the download michael@0: // state without the target file information. michael@0: if (this._dataItem || this.getDownloadMetaData().state !== undefined) michael@0: this._updateDownloadStatusUI(); michael@0: else michael@0: this._fetchTargetFileInfo(true); michael@0: }, michael@0: michael@0: placesNodeIconChanged: function DES_placesNodeIconChanged() { michael@0: if (!this._dataItem) michael@0: this._element.setAttribute("image", this._getIcon()); michael@0: }, michael@0: michael@0: placesNodeTitleChanged: function DES_placesNodeTitleChanged() { michael@0: // If there's a file path, we use the leaf name for the title. michael@0: if (!this._dataItem && this.active && !this.getDownloadMetaData().filePath) { michael@0: this._metaData = null; michael@0: this._updateDisplayNameAndIcon(); michael@0: } michael@0: }, michael@0: michael@0: placesNodeAnnotationChanged: function DES_placesNodeAnnotationChanged(aAnnoName) { michael@0: this._annotations.delete(aAnnoName); michael@0: if (!this._dataItem && this.active) { michael@0: if (aAnnoName == DOWNLOAD_META_DATA_ANNO) { michael@0: let metaData = this.getDownloadMetaData(); michael@0: let annotatedMetaData = this._getAnnotatedMetaData(); michael@0: metaData.endTme = annotatedMetaData.endTime; michael@0: if ("fileSize" in annotatedMetaData) michael@0: metaData.fileSize = annotatedMetaData.fileSize; michael@0: else michael@0: delete metaData.fileSize; michael@0: michael@0: if (metaData.state != annotatedMetaData.state) { michael@0: metaData.state = annotatedMetaData.state; michael@0: if (this._element.selected) michael@0: goUpdateDownloadCommands(); michael@0: } michael@0: michael@0: this._updateDownloadStatusUI(); michael@0: } michael@0: else if (aAnnoName == DESTINATION_FILE_URI_ANNO) { michael@0: let metaData = this.getDownloadMetaData(); michael@0: let targetFileURI = this._getAnnotation(DESTINATION_FILE_URI_ANNO); michael@0: [metaData.filePath, metaData.fileName] = michael@0: this._extractFilePathAndNameFromFileURI(targetFileURI); michael@0: metaData.displayName = metaData.fileName; michael@0: this._updateDisplayNameAndIcon(); michael@0: michael@0: if (this._targetFileInfoFetched) { michael@0: // This will also update the download commands if necessary. michael@0: this._targetFileInfoFetched = false; michael@0: this._fetchTargetFileInfo(); michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /* DownloadView */ michael@0: onStateChange: function DES_onStateChange(aOldState) { michael@0: let metaData = this.getDownloadMetaData(); michael@0: metaData.state = this.dataItem.state; michael@0: if (aOldState != nsIDM.DOWNLOAD_FINISHED && aOldState != metaData.state) { michael@0: // See comment in DVI_onStateChange in downloads.js (the panel-view) michael@0: this._element.setAttribute("image", this._getIcon() + "&state=normal"); michael@0: metaData.fileSize = this._dataItem.maxBytes; michael@0: if (this._targetFileInfoFetched) { michael@0: this._targetFileInfoFetched = false; michael@0: this._fetchTargetFileInfo(); michael@0: } michael@0: } michael@0: michael@0: this._updateDownloadStatusUI(); michael@0: if (this._element.selected) michael@0: goUpdateDownloadCommands(); michael@0: else michael@0: goUpdateCommand("downloadsCmd_clearDownloads"); michael@0: }, michael@0: michael@0: /* DownloadView */ michael@0: onProgressChange: function DES_onProgressChange() { michael@0: this._updateDownloadStatusUI(); michael@0: }, michael@0: michael@0: /* nsIController */ michael@0: isCommandEnabled: function DES_isCommandEnabled(aCommand) { michael@0: // The only valid command for inactive elements is cmd_delete. michael@0: if (!this.active && aCommand != "cmd_delete") michael@0: return false; michael@0: switch (aCommand) { michael@0: case "downloadsCmd_open": { michael@0: // We cannot open a session dowload file unless it's done ("openable"). michael@0: // If it's finished, we need to make sure the file was not removed, michael@0: // as we do for past downloads. michael@0: if (this._dataItem && !this._dataItem.openable) michael@0: return false; michael@0: michael@0: if (this._targetFileInfoFetched) michael@0: return this._targetFileExists; michael@0: michael@0: // If the target file information is not yet fetched, michael@0: // temporarily assume that the file is in place. michael@0: return this.getDownloadMetaData().state == nsIDM.DOWNLOAD_FINISHED; michael@0: } michael@0: case "downloadsCmd_show": { michael@0: // TODO: Bug 827010 - Handle part-file asynchronously. michael@0: if (this._dataItem && michael@0: this._dataItem.partFile && this._dataItem.partFile.exists()) michael@0: return true; michael@0: michael@0: if (this._targetFileInfoFetched) michael@0: return this._targetFileExists; michael@0: michael@0: // If the target file information is not yet fetched, michael@0: // temporarily assume that the file is in place. michael@0: return this.getDownloadMetaData().state == nsIDM.DOWNLOAD_FINISHED; michael@0: } michael@0: case "downloadsCmd_pauseResume": michael@0: return this._dataItem && this._dataItem.inProgress && this._dataItem.resumable; michael@0: case "downloadsCmd_retry": michael@0: // An history download can always be retried. michael@0: return !this._dataItem || this._dataItem.canRetry; michael@0: case "downloadsCmd_openReferrer": michael@0: return this._dataItem && !!this._dataItem.referrer; michael@0: case "cmd_delete": michael@0: // The behavior in this case is somewhat unexpected, so we disallow that. michael@0: if (this._placesNode && this._dataItem && this._dataItem.inProgress) michael@0: return false; michael@0: return true; michael@0: case "downloadsCmd_cancel": michael@0: return this._dataItem != null; michael@0: } michael@0: return false; michael@0: }, michael@0: michael@0: _retryAsHistoryDownload: function DES__retryAsHistoryDownload() { michael@0: // In future we may try to download into the same original target uri, when michael@0: // we have it. Though that requires verifying the path is still valid and michael@0: // may surprise the user if he wants to be requested every time. michael@0: let browserWin = RecentWindow.getMostRecentBrowserWindow(); michael@0: let initiatingDoc = browserWin ? browserWin.document : document; michael@0: DownloadURL(this.downloadURI, this.getDownloadMetaData().fileName, michael@0: initiatingDoc); michael@0: }, michael@0: michael@0: /* nsIController */ michael@0: doCommand: function DES_doCommand(aCommand) { michael@0: switch (aCommand) { michael@0: case "downloadsCmd_open": { michael@0: let file = this._dataItem ? michael@0: this.dataItem.localFile : michael@0: new FileUtils.File(this.getDownloadMetaData().filePath); michael@0: michael@0: DownloadsCommon.openDownloadedFile(file, null, window); michael@0: break; michael@0: } michael@0: case "downloadsCmd_show": { michael@0: if (this._dataItem) { michael@0: this._dataItem.showLocalFile(); michael@0: } michael@0: else { michael@0: let file = new FileUtils.File(this.getDownloadMetaData().filePath); michael@0: DownloadsCommon.showDownloadedFile(file); michael@0: } michael@0: break; michael@0: } michael@0: case "downloadsCmd_openReferrer": { michael@0: openURL(this._dataItem.referrer); michael@0: break; michael@0: } michael@0: case "downloadsCmd_cancel": { michael@0: this._dataItem.cancel(); michael@0: break; michael@0: } michael@0: case "cmd_delete": { michael@0: if (this._dataItem) michael@0: this._dataItem.remove(); michael@0: if (this._placesNode) michael@0: PlacesUtils.bhistory.removePage(this._downloadURIObj); michael@0: break; michael@0: } michael@0: case "downloadsCmd_retry": { michael@0: if (this._dataItem) michael@0: this._dataItem.retry(); michael@0: else michael@0: this._retryAsHistoryDownload(); michael@0: break; michael@0: } michael@0: case "downloadsCmd_pauseResume": { michael@0: this._dataItem.togglePauseResume(); michael@0: break; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: // Returns whether or not the download handled by this shell should michael@0: // show up in the search results for the given term. Both the display michael@0: // name for the download and the url are searched. michael@0: matchesSearchTerm: function DES_matchesSearchTerm(aTerm) { michael@0: if (!aTerm) michael@0: return true; michael@0: aTerm = aTerm.toLowerCase(); michael@0: return this.getDownloadMetaData().displayName.toLowerCase().contains(aTerm) || michael@0: this.downloadURI.toLowerCase().contains(aTerm); michael@0: }, michael@0: michael@0: // Handles return kepress on the element (the keypress listener is michael@0: // set in the DownloadsPlacesView object). michael@0: doDefaultCommand: function DES_doDefaultCommand() { michael@0: function getDefaultCommandForState(aState) { michael@0: switch (aState) { michael@0: case nsIDM.DOWNLOAD_FINISHED: michael@0: return "downloadsCmd_open"; michael@0: case nsIDM.DOWNLOAD_PAUSED: michael@0: return "downloadsCmd_pauseResume"; michael@0: case nsIDM.DOWNLOAD_NOTSTARTED: michael@0: case nsIDM.DOWNLOAD_QUEUED: michael@0: return "downloadsCmd_cancel"; michael@0: case nsIDM.DOWNLOAD_FAILED: michael@0: case nsIDM.DOWNLOAD_CANCELED: michael@0: return "downloadsCmd_retry"; michael@0: case nsIDM.DOWNLOAD_SCANNING: michael@0: return "downloadsCmd_show"; michael@0: case nsIDM.DOWNLOAD_BLOCKED_PARENTAL: michael@0: case nsIDM.DOWNLOAD_DIRTY: michael@0: case nsIDM.DOWNLOAD_BLOCKED_POLICY: michael@0: return "downloadsCmd_openReferrer"; michael@0: } michael@0: return ""; michael@0: } michael@0: let command = getDefaultCommandForState(this.getDownloadMetaData().state); michael@0: if (command && this.isCommandEnabled(command)) michael@0: this.doCommand(command); michael@0: }, michael@0: michael@0: /** michael@0: * At the first time an item is selected, we don't yet have michael@0: * the target file information. Thus the call to goUpdateDownloadCommands michael@0: * in DPV_onSelect would result in best-guess enabled/disabled result. michael@0: * That way we let the user perform command immediately. However, once michael@0: * we have the target file information, we can update the commands michael@0: * appropriately (_fetchTargetFileInfo() calls goUpdateDownloadCommands). michael@0: */ michael@0: onSelect: function DES_onSelect() { michael@0: if (!this.active) michael@0: return; michael@0: if (!this._targetFileInfoFetched) michael@0: this._fetchTargetFileInfo(); michael@0: } michael@0: }; michael@0: michael@0: /** michael@0: * A Downloads Places View is a places view designed to show a places query michael@0: * for history donwloads alongside the current "session"-downloads. michael@0: * michael@0: * As we don't use the places controller, some methods implemented by other michael@0: * places views are not implemented by this view. michael@0: * michael@0: * A richlistitem in this view can represent either a past download or a session michael@0: * download, or both. Session downloads are shown first in the view, and as long michael@0: * as they exist they "collapses" their history "counterpart" (So we don't show two michael@0: * items for every download). michael@0: */ michael@0: function DownloadsPlacesView(aRichListBox, aActive = true) { michael@0: this._richlistbox = aRichListBox; michael@0: this._richlistbox._placesView = this; michael@0: window.controllers.insertControllerAt(0, this); michael@0: michael@0: // Map download URLs to download element shells regardless of their type michael@0: this._downloadElementsShellsForURI = new Map(); michael@0: michael@0: // Map download data items to their element shells. michael@0: this._viewItemsForDataItems = new WeakMap(); michael@0: michael@0: // Points to the last session download element. We keep track of this michael@0: // in order to keep all session downloads above past downloads. michael@0: this._lastSessionDownloadElement = null; michael@0: michael@0: this._searchTerm = ""; michael@0: michael@0: this._active = aActive; michael@0: michael@0: // Register as a downloads view. The places data will be initialized by michael@0: // the places setter. michael@0: this._initiallySelectedElement = null; michael@0: this._downloadsData = DownloadsCommon.getData(window.opener || window); michael@0: this._downloadsData.addView(this); michael@0: michael@0: // Get the Download button out of the attention state since we're about to michael@0: // view all downloads. michael@0: DownloadsCommon.getIndicatorData(window).attention = false; michael@0: michael@0: // Make sure to unregister the view if the window is closed. michael@0: window.addEventListener("unload", function() { michael@0: window.controllers.removeController(this); michael@0: this._downloadsData.removeView(this); michael@0: this.result = null; michael@0: }.bind(this), true); michael@0: // Resizing the window may change items visibility. michael@0: window.addEventListener("resize", function() { michael@0: this._ensureVisibleElementsAreActive(); michael@0: }.bind(this), true); michael@0: } michael@0: michael@0: DownloadsPlacesView.prototype = { michael@0: get associatedElement() this._richlistbox, michael@0: michael@0: get active() this._active, michael@0: set active(val) { michael@0: this._active = val; michael@0: if (this._active) michael@0: this._ensureVisibleElementsAreActive(); michael@0: return this._active; michael@0: }, michael@0: michael@0: _forEachDownloadElementShellForURI: michael@0: function DPV__forEachDownloadElementShellForURI(aURI, aCallback) { michael@0: if (this._downloadElementsShellsForURI.has(aURI)) { michael@0: let downloadElementShells = this._downloadElementsShellsForURI.get(aURI); michael@0: for (let des of downloadElementShells) { michael@0: aCallback(des); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _getAnnotationsFor: function DPV_getAnnotationsFor(aURI) { michael@0: if (!this._cachedAnnotations) { michael@0: this._cachedAnnotations = new Map(); michael@0: for (let name of [ DESTINATION_FILE_URI_ANNO, michael@0: DOWNLOAD_META_DATA_ANNO ]) { michael@0: let results = PlacesUtils.annotations.getAnnotationsWithName(name); michael@0: for (let result of results) { michael@0: let url = result.uri.spec; michael@0: if (!this._cachedAnnotations.has(url)) michael@0: this._cachedAnnotations.set(url, new Map()); michael@0: let m = this._cachedAnnotations.get(url); michael@0: m.set(result.annotationName, result.annotationValue); michael@0: } michael@0: } michael@0: } michael@0: michael@0: let annotations = this._cachedAnnotations.get(aURI); michael@0: if (!annotations) { michael@0: // There are no annotations for this entry, that means it is quite old. michael@0: // Make up a fake annotations entry with default values. michael@0: annotations = new Map(); michael@0: annotations.set(DESTINATION_FILE_URI_ANNO, NOT_AVAILABLE); michael@0: } michael@0: // The meta-data annotation has been added recently, so it's likely missing. michael@0: if (!annotations.has(DOWNLOAD_META_DATA_ANNO)) { michael@0: annotations.set(DOWNLOAD_META_DATA_ANNO, NOT_AVAILABLE); michael@0: } michael@0: return annotations; michael@0: }, michael@0: michael@0: /** michael@0: * Given a data item for a session download, or a places node for a past michael@0: * download, updates the view as necessary. michael@0: * 1. If the given data is a places node, we check whether there are any michael@0: * elements for the same download url. If there are, then we just reset michael@0: * their places node. Otherwise we add a new download element. michael@0: * 2. If the given data is a data item, we first check if there's a history michael@0: * download in the list that is not associated with a data item. If we michael@0: * found one, we use it for the data item as well and reposition it michael@0: * alongside the other session downloads. If we don't, then we go ahead michael@0: * and create a new element for the download. michael@0: * michael@0: * @param aDataItem michael@0: * The data item of a session download. Set to null for history michael@0: * downloads data. michael@0: * @param [optional] aPlacesNode michael@0: * The places node for a history download. Required if there's no data michael@0: * item. michael@0: * @param [optional] aNewest michael@0: * @see onDataItemAdded. Ignored for history downlods. michael@0: * @param [optional] aDocumentFragment michael@0: * To speed up the appending of multiple elements to the end of the michael@0: * list which are coming in a single batch (i.e. invalidateContainer), michael@0: * a document fragment may be passed to which the new elements would michael@0: * be appended. It's the caller's job to ensure the fragment is merged michael@0: * to the richlistbox at the end. michael@0: */ michael@0: _addDownloadData: michael@0: function DPV_addDownloadData(aDataItem, aPlacesNode, aNewest = false, michael@0: aDocumentFragment = null) { michael@0: let downloadURI = aPlacesNode ? aPlacesNode.uri : aDataItem.uri; michael@0: let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI); michael@0: if (!shellsForURI) { michael@0: shellsForURI = new Set(); michael@0: this._downloadElementsShellsForURI.set(downloadURI, shellsForURI); michael@0: } michael@0: michael@0: let newOrUpdatedShell = null; michael@0: michael@0: // Trivial: if there are no shells for this download URI, we always michael@0: // need to create one. michael@0: let shouldCreateShell = shellsForURI.size == 0; michael@0: michael@0: // However, if we do have shells for this download uri, there are michael@0: // few options: michael@0: // 1) There's only one shell and it's for a history download (it has michael@0: // no data item). In this case, we update this shell and move it michael@0: // if necessary michael@0: // 2) There are multiple shells, indicicating multiple downloads for michael@0: // the same download uri are running. In this case we create michael@0: // anoter shell for the download (so we have one shell for each data michael@0: // item). michael@0: // michael@0: // Note: If a cancelled session download is already in the list, and the michael@0: // download is retired, onDataItemAdded is called again for the same michael@0: // data item. Thus, we also check that we make sure we don't have a view item michael@0: // already. michael@0: if (!shouldCreateShell && michael@0: aDataItem && this.getViewItem(aDataItem) == null) { michael@0: // If there's a past-download-only shell for this download-uri with no michael@0: // associated data item, use it for the new data item. Otherwise, go ahead michael@0: // and create another shell. michael@0: shouldCreateShell = true; michael@0: for (let shell of shellsForURI) { michael@0: if (!shell.dataItem) { michael@0: shouldCreateShell = false; michael@0: shell.dataItem = aDataItem; michael@0: newOrUpdatedShell = shell; michael@0: this._viewItemsForDataItems.set(aDataItem, shell); michael@0: break; michael@0: } michael@0: } michael@0: } michael@0: michael@0: if (shouldCreateShell) { michael@0: // Bug 836271: The annotations for a url should be cached only when the michael@0: // places node is available, i.e. when we know we we'd be notified for michael@0: // annoation changes. michael@0: // Otherwise we may cache NOT_AVILABLE values first for a given session michael@0: // download, and later use these NOT_AVILABLE values when a history michael@0: // download for the same URL is added. michael@0: let cachedAnnotations = aPlacesNode ? this._getAnnotationsFor(downloadURI) : null; michael@0: let shell = new DownloadElementShell(aDataItem, aPlacesNode, cachedAnnotations); michael@0: newOrUpdatedShell = shell; michael@0: shellsForURI.add(shell); michael@0: if (aDataItem) michael@0: this._viewItemsForDataItems.set(aDataItem, shell); michael@0: } michael@0: else if (aPlacesNode) { michael@0: for (let shell of shellsForURI) { michael@0: if (shell.placesNode != aPlacesNode) michael@0: shell.placesNode = aPlacesNode; michael@0: } michael@0: } michael@0: michael@0: if (newOrUpdatedShell) { michael@0: if (aNewest) { michael@0: this._richlistbox.insertBefore(newOrUpdatedShell.element, michael@0: this._richlistbox.firstChild); michael@0: if (!this._lastSessionDownloadElement) { michael@0: this._lastSessionDownloadElement = newOrUpdatedShell.element; michael@0: } michael@0: // Some operations like retrying an history download move an element to michael@0: // the top of the richlistbox, along with other session downloads. michael@0: // More generally, if a new download is added, should be made visible. michael@0: this._richlistbox.ensureElementIsVisible(newOrUpdatedShell.element); michael@0: } michael@0: else if (aDataItem) { michael@0: let before = this._lastSessionDownloadElement ? michael@0: this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild; michael@0: this._richlistbox.insertBefore(newOrUpdatedShell.element, before); michael@0: this._lastSessionDownloadElement = newOrUpdatedShell.element; michael@0: } michael@0: else { michael@0: let appendTo = aDocumentFragment || this._richlistbox; michael@0: appendTo.appendChild(newOrUpdatedShell.element); michael@0: } michael@0: michael@0: if (this.searchTerm) { michael@0: newOrUpdatedShell.element.hidden = michael@0: !newOrUpdatedShell.element._shell.matchesSearchTerm(this.searchTerm); michael@0: } michael@0: } michael@0: michael@0: // If aDocumentFragment is defined this is a batch change, so it's up to michael@0: // the caller to append the fragment and activate the visible shells. michael@0: if (!aDocumentFragment) { michael@0: this._ensureVisibleElementsAreActive(); michael@0: goUpdateCommand("downloadsCmd_clearDownloads"); michael@0: } michael@0: }, michael@0: michael@0: _removeElement: function DPV__removeElement(aElement) { michael@0: // If the element was selected exclusively, select its next michael@0: // sibling first, if not, try for previous sibling, if any. michael@0: if ((aElement.nextSibling || aElement.previousSibling) && michael@0: this._richlistbox.selectedItems && michael@0: this._richlistbox.selectedItems.length == 1 && michael@0: this._richlistbox.selectedItems[0] == aElement) { michael@0: this._richlistbox.selectItem(aElement.nextSibling || michael@0: aElement.previousSibling); michael@0: } michael@0: michael@0: if (this._lastSessionDownloadElement == aElement) michael@0: this._lastSessionDownloadElement = aElement.previousSibling; michael@0: michael@0: this._richlistbox.removeItemFromSelection(aElement); michael@0: this._richlistbox.removeChild(aElement); michael@0: this._ensureVisibleElementsAreActive(); michael@0: goUpdateCommand("downloadsCmd_clearDownloads"); michael@0: }, michael@0: michael@0: _removeHistoryDownloadFromView: michael@0: function DPV__removeHistoryDownloadFromView(aPlacesNode) { michael@0: let downloadURI = aPlacesNode.uri; michael@0: let shellsForURI = this._downloadElementsShellsForURI.get(downloadURI); michael@0: if (shellsForURI) { michael@0: for (let shell of shellsForURI) { michael@0: if (shell.dataItem) { michael@0: shell.placesNode = null; michael@0: } michael@0: else { michael@0: this._removeElement(shell.element); michael@0: shellsForURI.delete(shell); michael@0: if (shellsForURI.size == 0) michael@0: this._downloadElementsShellsForURI.delete(downloadURI); michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _removeSessionDownloadFromView: michael@0: function DPV__removeSessionDownloadFromView(aDataItem) { michael@0: let shells = this._downloadElementsShellsForURI.get(aDataItem.uri); michael@0: if (shells.size == 0) michael@0: throw new Error("Should have had at leaat one shell for this uri"); michael@0: michael@0: let shell = this.getViewItem(aDataItem); michael@0: if (!shells.has(shell)) michael@0: throw new Error("Missing download element shell in shells list for url"); michael@0: michael@0: // If there's more than one item for this download uri, we can let the michael@0: // view item for this this particular data item go away. michael@0: // If there's only one item for this download uri, we should only michael@0: // keep it if it is associated with a history download. michael@0: if (shells.size > 1 || !shell.placesNode) { michael@0: this._removeElement(shell.element); michael@0: shells.delete(shell); michael@0: if (shells.size == 0) michael@0: this._downloadElementsShellsForURI.delete(aDataItem.uri); michael@0: } michael@0: else { michael@0: shell.dataItem = null; michael@0: // Move it below the session-download items; michael@0: if (this._lastSessionDownloadElement == shell.element) { michael@0: this._lastSessionDownloadElement = shell.element.previousSibling; michael@0: } michael@0: else { michael@0: let before = this._lastSessionDownloadElement ? michael@0: this._lastSessionDownloadElement.nextSibling : this._richlistbox.firstChild; michael@0: this._richlistbox.insertBefore(shell.element, before); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _ensureVisibleElementsAreActive: michael@0: function DPV__ensureVisibleElementsAreActive() { michael@0: if (!this.active || this._ensureVisibleTimer || !this._richlistbox.firstChild) michael@0: return; michael@0: michael@0: this._ensureVisibleTimer = setTimeout(function() { michael@0: delete this._ensureVisibleTimer; michael@0: if (!this._richlistbox.firstChild) michael@0: return; michael@0: michael@0: let rlbRect = this._richlistbox.getBoundingClientRect(); michael@0: let winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor) michael@0: .getInterface(Ci.nsIDOMWindowUtils); michael@0: let nodes = winUtils.nodesFromRect(rlbRect.left, rlbRect.top, michael@0: 0, rlbRect.width, rlbRect.height, 0, michael@0: true, false); michael@0: // nodesFromRect returns nodes in z-index order, and for the same z-index michael@0: // sorts them in inverted DOM order, thus starting from the one that would michael@0: // be on top. michael@0: let firstVisibleNode, lastVisibleNode; michael@0: for (let node of nodes) { michael@0: if (node.localName === "richlistitem" && node._shell) { michael@0: node._shell.ensureActive(); michael@0: // The first visible node is the last match. michael@0: firstVisibleNode = node; michael@0: // While the last visible node is the first match. michael@0: if (!lastVisibleNode) michael@0: lastVisibleNode = node; michael@0: } michael@0: } michael@0: michael@0: // Also activate the first invisible nodes in both boundaries (that is, michael@0: // above and below the visible area) to ensure proper keyboard navigation michael@0: // in both directions. michael@0: let nodeBelowVisibleArea = lastVisibleNode && lastVisibleNode.nextSibling; michael@0: if (nodeBelowVisibleArea && nodeBelowVisibleArea._shell) michael@0: nodeBelowVisibleArea._shell.ensureActive(); michael@0: michael@0: let nodeABoveVisibleArea = michael@0: firstVisibleNode && firstVisibleNode.previousSibling; michael@0: if (nodeABoveVisibleArea && nodeABoveVisibleArea._shell) michael@0: nodeABoveVisibleArea._shell.ensureActive(); michael@0: }.bind(this), 10); michael@0: }, michael@0: michael@0: _place: "", michael@0: get place() this._place, michael@0: set place(val) { michael@0: // Don't reload everything if we don't have to. michael@0: if (this._place == val) { michael@0: // XXXmano: places.js relies on this behavior (see Bug 822203). michael@0: this.searchTerm = ""; michael@0: return val; michael@0: } michael@0: michael@0: this._place = val; michael@0: michael@0: let history = PlacesUtils.history; michael@0: let queries = { }, options = { }; michael@0: history.queryStringToQueries(val, queries, { }, options); michael@0: if (!queries.value.length) michael@0: queries.value = [history.getNewQuery()]; michael@0: michael@0: let result = history.executeQueries(queries.value, queries.value.length, michael@0: options.value); michael@0: result.addObserver(this, false); michael@0: return val; michael@0: }, michael@0: michael@0: _result: null, michael@0: get result() this._result, michael@0: set result(val) { michael@0: if (this._result == val) michael@0: return val; michael@0: michael@0: if (this._result) { michael@0: this._result.removeObserver(this); michael@0: this._resultNode.containerOpen = false; michael@0: } michael@0: michael@0: if (val) { michael@0: this._result = val; michael@0: this._resultNode = val.root; michael@0: this._resultNode.containerOpen = true; michael@0: this._ensureInitialSelection(); michael@0: } michael@0: else { michael@0: delete this._resultNode; michael@0: delete this._result; michael@0: } michael@0: michael@0: return val; michael@0: }, michael@0: michael@0: get selectedNodes() { michael@0: let placesNodes = []; michael@0: let selectedElements = this._richlistbox.selectedItems; michael@0: for (let elt of selectedElements) { michael@0: if (elt._shell.placesNode) michael@0: placesNodes.push(elt._shell.placesNode); michael@0: } michael@0: return placesNodes; michael@0: }, michael@0: michael@0: get selectedNode() { michael@0: let selectedNodes = this.selectedNodes; michael@0: return selectedNodes.length == 1 ? selectedNodes[0] : null; michael@0: }, michael@0: michael@0: get hasSelection() this.selectedNodes.length > 0, michael@0: michael@0: containerStateChanged: michael@0: function DPV_containerStateChanged(aNode, aOldState, aNewState) { michael@0: this.invalidateContainer(aNode) michael@0: }, michael@0: michael@0: invalidateContainer: michael@0: function DPV_invalidateContainer(aContainer) { michael@0: if (aContainer != this._resultNode) michael@0: throw new Error("Unexpected container node"); michael@0: if (!aContainer.containerOpen) michael@0: throw new Error("Root container for the downloads query cannot be closed"); michael@0: michael@0: let suppressOnSelect = this._richlistbox.suppressOnSelect; michael@0: this._richlistbox.suppressOnSelect = true; michael@0: try { michael@0: // Remove the invalidated history downloads from the list and unset the michael@0: // places node for data downloads. michael@0: // Loop backwards since _removeHistoryDownloadFromView may removeChild(). michael@0: for (let i = this._richlistbox.childNodes.length - 1; i >= 0; --i) { michael@0: let element = this._richlistbox.childNodes[i]; michael@0: if (element._shell.placesNode) michael@0: this._removeHistoryDownloadFromView(element._shell.placesNode); michael@0: } michael@0: } michael@0: finally { michael@0: this._richlistbox.suppressOnSelect = suppressOnSelect; michael@0: } michael@0: michael@0: if (aContainer.childCount > 0) { michael@0: let elementsToAppendFragment = document.createDocumentFragment(); michael@0: for (let i = 0; i < aContainer.childCount; i++) { michael@0: try { michael@0: this._addDownloadData(null, aContainer.getChild(i), false, michael@0: elementsToAppendFragment); michael@0: } michael@0: catch(ex) { michael@0: Cu.reportError(ex); michael@0: } michael@0: } michael@0: michael@0: // _addDownloadData may not add new elements if there were already michael@0: // data items in place. michael@0: if (elementsToAppendFragment.firstChild) { michael@0: this._appendDownloadsFragment(elementsToAppendFragment); michael@0: this._ensureVisibleElementsAreActive(); michael@0: } michael@0: } michael@0: michael@0: goUpdateDownloadCommands(); michael@0: }, michael@0: michael@0: _appendDownloadsFragment: function DPV__appendDownloadsFragment(aDOMFragment) { michael@0: // Workaround multiple reflows hang by removing the richlistbox michael@0: // and adding it back when we're done. michael@0: michael@0: // Hack for bug 836283: reset xbl fields to their old values after the michael@0: // binding is reattached to avoid breaking the selection state michael@0: let xblFields = new Map(); michael@0: for (let [key, value] in Iterator(this._richlistbox)) { michael@0: xblFields.set(key, value); michael@0: } michael@0: michael@0: let parentNode = this._richlistbox.parentNode; michael@0: let nextSibling = this._richlistbox.nextSibling; michael@0: parentNode.removeChild(this._richlistbox); michael@0: this._richlistbox.appendChild(aDOMFragment); michael@0: parentNode.insertBefore(this._richlistbox, nextSibling); michael@0: michael@0: for (let [key, value] of xblFields) { michael@0: this._richlistbox[key] = value; michael@0: } michael@0: }, michael@0: michael@0: nodeInserted: function DPV_nodeInserted(aParent, aPlacesNode) { michael@0: this._addDownloadData(null, aPlacesNode); michael@0: }, michael@0: michael@0: nodeRemoved: function DPV_nodeRemoved(aParent, aPlacesNode, aOldIndex) { michael@0: this._removeHistoryDownloadFromView(aPlacesNode); michael@0: }, michael@0: michael@0: nodeIconChanged: function DPV_nodeIconChanged(aNode) { michael@0: this._forEachDownloadElementShellForURI(aNode.uri, function(aDownloadElementShell) { michael@0: aDownloadElementShell.placesNodeIconChanged(); michael@0: }); michael@0: }, michael@0: michael@0: nodeAnnotationChanged: function DPV_nodeAnnotationChanged(aNode, aAnnoName) { michael@0: this._forEachDownloadElementShellForURI(aNode.uri, function(aDownloadElementShell) { michael@0: aDownloadElementShell.placesNodeAnnotationChanged(aAnnoName); michael@0: }); michael@0: }, michael@0: michael@0: nodeTitleChanged: function DPV_nodeTitleChanged(aNode, aNewTitle) { michael@0: this._forEachDownloadElementShellForURI(aNode.uri, function(aDownloadElementShell) { michael@0: aDownloadElementShell.placesNodeTitleChanged(); michael@0: }); michael@0: }, michael@0: michael@0: nodeKeywordChanged: function() {}, michael@0: nodeDateAddedChanged: function() {}, michael@0: nodeLastModifiedChanged: function() {}, michael@0: nodeHistoryDetailsChanged: function() {}, michael@0: nodeTagsChanged: function() {}, michael@0: sortingChanged: function() {}, michael@0: nodeMoved: function() {}, michael@0: nodeURIChanged: function() {}, michael@0: batching: function() {}, michael@0: michael@0: get controller() this._richlistbox.controller, michael@0: michael@0: get searchTerm() this._searchTerm, michael@0: set searchTerm(aValue) { michael@0: if (this._searchTerm != aValue) { michael@0: for (let element of this._richlistbox.childNodes) { michael@0: element.hidden = !element._shell.matchesSearchTerm(aValue); michael@0: } michael@0: this._ensureVisibleElementsAreActive(); michael@0: } michael@0: return this._searchTerm = aValue; michael@0: }, michael@0: michael@0: /** michael@0: * When the view loads, we want to select the first item. michael@0: * However, because session downloads, for which the data is loaded michael@0: * asynchronously, always come first in the list, and because the list michael@0: * may (or may not) already contain history downloads at that point, it michael@0: * turns out that by the time we can select the first item, the user may michael@0: * have already started using the view. michael@0: * To make things even more complicated, in other cases, the places data michael@0: * may be loaded after the session downloads data. Thus we cannot rely on michael@0: * the order in which the data comes in. michael@0: * We work around this by attempting to select the first element twice, michael@0: * once after the places data is loaded and once when the session downloads michael@0: * data is done loading. However, if the selection has changed in-between, michael@0: * we assume the user has already started using the view and give up. michael@0: */ michael@0: _ensureInitialSelection: function DPV__ensureInitialSelection() { michael@0: // Either they're both null, or the selection has not changed in between. michael@0: if (this._richlistbox.selectedItem == this._initiallySelectedElement) { michael@0: let firstDownloadElement = this._richlistbox.firstChild; michael@0: if (firstDownloadElement != this._initiallySelectedElement) { michael@0: // We may be called before _ensureVisibleElementsAreActive, michael@0: // or before the download binding is attached. Therefore, ensure the michael@0: // first item is activated, and pass the item to the richlistbox michael@0: // setters only at a point we know for sure the binding is attached. michael@0: firstDownloadElement._shell.ensureActive(); michael@0: Services.tm.mainThread.dispatch(function() { michael@0: this._richlistbox.selectedItem = firstDownloadElement; michael@0: this._richlistbox.currentItem = firstDownloadElement; michael@0: this._initiallySelectedElement = firstDownloadElement; michael@0: }.bind(this), Ci.nsIThread.DISPATCH_NORMAL); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: onDataLoadStarting: function() { }, michael@0: onDataLoadCompleted: function DPV_onDataLoadCompleted() { michael@0: this._ensureInitialSelection(); michael@0: }, michael@0: michael@0: onDataItemAdded: function DPV_onDataItemAdded(aDataItem, aNewest) { michael@0: this._addDownloadData(aDataItem, null, aNewest); michael@0: }, michael@0: michael@0: onDataItemRemoved: function DPV_onDataItemRemoved(aDataItem) { michael@0: this._removeSessionDownloadFromView(aDataItem); michael@0: }, michael@0: michael@0: getViewItem: function(aDataItem) michael@0: this._viewItemsForDataItems.get(aDataItem, null), michael@0: michael@0: supportsCommand: function DPV_supportsCommand(aCommand) { michael@0: if (DOWNLOAD_VIEW_SUPPORTED_COMMANDS.indexOf(aCommand) != -1) { michael@0: // The clear-downloads command may be performed by the toolbar-button, michael@0: // which can be focused on OS X. Thus enable this command even if the michael@0: // richlistbox is not focused. michael@0: // For other commands, be prudent and disable them unless the richlistview michael@0: // is focused. It's important to make the decision here rather than in michael@0: // isCommandEnabled. Otherwise our controller may "steal" commands from michael@0: // other controls in the window (see goUpdateCommand & michael@0: // getControllerForCommand). michael@0: if (document.activeElement == this._richlistbox || michael@0: aCommand == "downloadsCmd_clearDownloads") { michael@0: return true; michael@0: } michael@0: } michael@0: return false; michael@0: }, michael@0: michael@0: isCommandEnabled: function DPV_isCommandEnabled(aCommand) { michael@0: switch (aCommand) { michael@0: case "cmd_copy": michael@0: return this._richlistbox.selectedItems.length > 0; michael@0: case "cmd_selectAll": michael@0: return true; michael@0: case "cmd_paste": michael@0: return this._canDownloadClipboardURL(); michael@0: case "downloadsCmd_clearDownloads": michael@0: return this._canClearDownloads(); michael@0: default: michael@0: return Array.every(this._richlistbox.selectedItems, function(element) { michael@0: return element._shell.isCommandEnabled(aCommand); michael@0: }); michael@0: } michael@0: }, michael@0: michael@0: _canClearDownloads: function DPV__canClearDownloads() { michael@0: // Downloads can be cleared if there's at least one removeable download in michael@0: // the list (either a history download or a completed session download). michael@0: // Because history downloads are always removable and are listed after the michael@0: // session downloads, check from bottom to top. michael@0: for (let elt = this._richlistbox.lastChild; elt; elt = elt.previousSibling) { michael@0: if (elt._shell.placesNode || !elt._shell.dataItem.inProgress) michael@0: return true; michael@0: } michael@0: return false; michael@0: }, michael@0: michael@0: _copySelectedDownloadsToClipboard: michael@0: function DPV__copySelectedDownloadsToClipboard() { michael@0: let selectedElements = this._richlistbox.selectedItems; michael@0: let urls = [e._shell.downloadURI for each (e in selectedElements)]; michael@0: michael@0: Cc["@mozilla.org/widget/clipboardhelper;1"]. michael@0: getService(Ci.nsIClipboardHelper).copyString(urls.join("\n"), document); michael@0: }, michael@0: michael@0: _getURLFromClipboardData: function DPV__getURLFromClipboardData() { michael@0: let trans = Cc["@mozilla.org/widget/transferable;1"]. michael@0: createInstance(Ci.nsITransferable); michael@0: trans.init(null); michael@0: michael@0: let flavors = ["text/x-moz-url", "text/unicode"]; michael@0: flavors.forEach(trans.addDataFlavor); michael@0: michael@0: Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard); michael@0: michael@0: // Getting the data or creating the nsIURI might fail. michael@0: try { michael@0: let data = {}; michael@0: trans.getAnyTransferData({}, data, {}); michael@0: let [url, name] = data.value.QueryInterface(Ci.nsISupportsString) michael@0: .data.split("\n"); michael@0: if (url) michael@0: return [NetUtil.newURI(url, null, null).spec, name]; michael@0: } michael@0: catch(ex) { } michael@0: michael@0: return ["", ""]; michael@0: }, michael@0: michael@0: _canDownloadClipboardURL: function DPV__canDownloadClipboardURL() { michael@0: let [url, name] = this._getURLFromClipboardData(); michael@0: return url != ""; michael@0: }, michael@0: michael@0: _downloadURLFromClipboard: function DPV__downloadURLFromClipboard() { michael@0: let [url, name] = this._getURLFromClipboardData(); michael@0: let browserWin = RecentWindow.getMostRecentBrowserWindow(); michael@0: let initiatingDoc = browserWin ? browserWin.document : document; michael@0: DownloadURL(url, name, initiatingDoc); michael@0: }, michael@0: michael@0: doCommand: function DPV_doCommand(aCommand) { michael@0: switch (aCommand) { michael@0: case "cmd_copy": michael@0: this._copySelectedDownloadsToClipboard(); michael@0: break; michael@0: case "cmd_selectAll": michael@0: this._richlistbox.selectAll(); michael@0: break; michael@0: case "cmd_paste": michael@0: this._downloadURLFromClipboard(); michael@0: break; michael@0: case "downloadsCmd_clearDownloads": michael@0: this._downloadsData.removeFinished(); michael@0: if (this.result) { michael@0: Cc["@mozilla.org/browser/download-history;1"] michael@0: .getService(Ci.nsIDownloadHistory) michael@0: .removeAllDownloads(); michael@0: } michael@0: // There may be no selection or focus change as a result michael@0: // of these change, and we want the command updated immediately. michael@0: goUpdateCommand("downloadsCmd_clearDownloads"); michael@0: break; michael@0: default: { michael@0: // Slicing the array to get a freezed list of selected items. Otherwise, michael@0: // the selectedItems array is live and doCommand may alter the selection michael@0: // while we are trying to do one particular action, like removing items michael@0: // from history. michael@0: let selectedElements = this._richlistbox.selectedItems.slice(); michael@0: for (let element of selectedElements) { michael@0: element._shell.doCommand(aCommand); michael@0: } michael@0: } michael@0: } michael@0: }, michael@0: michael@0: onEvent: function() { }, michael@0: michael@0: onContextMenu: function DPV_onContextMenu(aEvent) michael@0: { michael@0: let element = this._richlistbox.selectedItem; michael@0: if (!element || !element._shell) michael@0: return false; michael@0: michael@0: // Set the state attribute so that only the appropriate items are displayed. michael@0: let contextMenu = document.getElementById("downloadsContextMenu"); michael@0: let state = element._shell.getDownloadMetaData().state; michael@0: if (state !== undefined) michael@0: contextMenu.setAttribute("state", state); michael@0: else michael@0: contextMenu.removeAttribute("state"); michael@0: michael@0: if (state == nsIDM.DOWNLOAD_DOWNLOADING) { michael@0: // The resumable property of a download may change at any time, so michael@0: // ensure we update the related command now. michael@0: goUpdateCommand("downloadsCmd_pauseResume"); michael@0: } michael@0: return true; michael@0: }, michael@0: michael@0: onKeyPress: function DPV_onKeyPress(aEvent) { michael@0: let selectedElements = this._richlistbox.selectedItems; michael@0: if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { michael@0: // In the content tree, opening bookmarks by pressing return is only michael@0: // supported when a single item is selected. To be consistent, do the michael@0: // same here. michael@0: if (selectedElements.length == 1) { michael@0: let element = selectedElements[0]; michael@0: if (element._shell) michael@0: element._shell.doDefaultCommand(); michael@0: } michael@0: } michael@0: else if (aEvent.charCode == " ".charCodeAt(0)) { michael@0: // Pausue/Resume every selected download michael@0: for (let element of selectedElements) { michael@0: if (element._shell.isCommandEnabled("downloadsCmd_pauseResume")) michael@0: element._shell.doCommand("downloadsCmd_pauseResume"); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: onDoubleClick: function DPV_onDoubleClick(aEvent) { michael@0: if (aEvent.button != 0) michael@0: return; michael@0: michael@0: let selectedElements = this._richlistbox.selectedItems; michael@0: if (selectedElements.length != 1) michael@0: return; michael@0: michael@0: let element = selectedElements[0]; michael@0: if (element._shell) michael@0: element._shell.doDefaultCommand(); michael@0: }, michael@0: michael@0: onScroll: function DPV_onScroll() { michael@0: this._ensureVisibleElementsAreActive(); michael@0: }, michael@0: michael@0: onSelect: function DPV_onSelect() { michael@0: goUpdateDownloadCommands(); michael@0: michael@0: let selectedElements = this._richlistbox.selectedItems; michael@0: for (let elt of selectedElements) { michael@0: if (elt._shell) michael@0: elt._shell.onSelect(); michael@0: } michael@0: }, michael@0: michael@0: onDragStart: function DPV_onDragStart(aEvent) { michael@0: // TODO Bug 831358: Support d&d for multiple selection. michael@0: // For now, we just drag the first element. michael@0: let selectedItem = this._richlistbox.selectedItem; michael@0: if (!selectedItem) michael@0: return; michael@0: michael@0: let metaData = selectedItem._shell.getDownloadMetaData(); michael@0: if (!("filePath" in metaData)) michael@0: return; michael@0: let file = new FileUtils.File(metaData.filePath); michael@0: if (!file.exists()) michael@0: return; michael@0: michael@0: let dt = aEvent.dataTransfer; michael@0: dt.mozSetDataAt("application/x-moz-file", file, 0); michael@0: let url = Services.io.newFileURI(file).spec; michael@0: dt.setData("text/uri-list", url); michael@0: dt.setData("text/plain", url); michael@0: dt.effectAllowed = "copyMove"; michael@0: dt.addElement(selectedItem); michael@0: }, michael@0: michael@0: onDragOver: function DPV_onDragOver(aEvent) { michael@0: let types = aEvent.dataTransfer.types; michael@0: if (types.contains("text/uri-list") || michael@0: types.contains("text/x-moz-url") || michael@0: types.contains("text/plain")) { michael@0: aEvent.preventDefault(); michael@0: } michael@0: }, michael@0: michael@0: onDrop: function DPV_onDrop(aEvent) { michael@0: let dt = aEvent.dataTransfer; michael@0: // If dragged item is from our source, do not try to michael@0: // redownload already downloaded file. michael@0: if (dt.mozGetDataAt("application/x-moz-file", 0)) michael@0: return; michael@0: michael@0: let name = { }; michael@0: let url = Services.droppedLinkHandler.dropLink(aEvent, name); michael@0: if (url) { michael@0: let browserWin = RecentWindow.getMostRecentBrowserWindow(); michael@0: let initiatingDoc = browserWin ? browserWin.document : document; michael@0: DownloadURL(url, name.value, initiatingDoc); michael@0: } michael@0: } michael@0: }; michael@0: michael@0: for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) { michael@0: DownloadsPlacesView.prototype[methodName] = function() { michael@0: throw new Error("|" + methodName + "| is not implemented by the downloads view."); michael@0: } michael@0: } michael@0: michael@0: function goUpdateDownloadCommands() { michael@0: for (let command of DOWNLOAD_VIEW_SUPPORTED_COMMANDS) { michael@0: goUpdateCommand(command); michael@0: } michael@0: }