/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80: */ /* 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/. */ /** * Handles the Downloads panel user interface for each browser window. * * This file includes the following constructors and global objects: * * DownloadsPanel * Main entry point for the downloads panel interface. * * DownloadsOverlayLoader * Allows loading the downloads panel and the status indicator interfaces on * demand, to improve startup performance. * * DownloadsView * Builds and updates the downloads list widget, responding to changes in the * download state and real-time data. In addition, handles part of the user * interaction events raised by the downloads list widget. * * DownloadsViewItem * Builds and updates a single item in the downloads list widget, responding to * changes in the download state and real-time data. * * DownloadsViewController * Handles part of the user interaction events raised by the downloads list * widget, in particular the "commands" that apply to multiple items, and * dispatches the commands that apply to individual items. * * DownloadsViewItemController * Handles all the user interaction events, in particular the "commands", * related to a single item in the downloads list widgets. */ "use strict"; //////////////////////////////////////////////////////////////////////////////// //// Globals XPCOMUtils.defineLazyModuleGetter(this, "DownloadUtils", "resource://gre/modules/DownloadUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "DownloadsCommon", "resource:///modules/DownloadsCommon.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm"); //////////////////////////////////////////////////////////////////////////////// //// DownloadsPanel /** * Main entry point for the downloads panel interface. */ const DownloadsPanel = { ////////////////////////////////////////////////////////////////////////////// //// Initialization and termination /** * Internal state of the downloads panel, based on one of the kState * constants. This is not the same state as the XUL panel element. */ _state: 0, /** The panel is not linked to downloads data yet. */ get kStateUninitialized() 0, /** This object is linked to data, but the panel is invisible. */ get kStateHidden() 1, /** The panel will be shown as soon as possible. */ get kStateWaitingData() 2, /** The panel is almost shown - we're just waiting to get a handle on the anchor. */ get kStateWaitingAnchor() 3, /** The panel is open. */ get kStateShown() 4, /** * Location of the panel overlay. */ get kDownloadsOverlay() "chrome://browser/content/downloads/downloadsOverlay.xul", /** * Starts loading the download data in background, without opening the panel. * Use showPanel instead to load the data and open the panel at the same time. * * @param aCallback * Called when initialization is complete. */ initialize: function DP_initialize(aCallback) { if (this._state != this.kStateUninitialized) { DownloadsOverlayLoader.ensureOverlayLoaded(this.kDownloadsOverlay, aCallback); return; } this._state = this.kStateHidden; window.addEventListener("unload", this.onWindowUnload, false); // Ensure that the Download Manager service is running. This resumes // active downloads if required. If there are downloads to be shown in the // panel, starting the service will make us load their data asynchronously. Services.downloads; // Now that data loading has eventually started, load the required XUL // elements and initialize our views. DownloadsOverlayLoader.ensureOverlayLoaded(this.kDownloadsOverlay, function DP_I_callback() { DownloadsViewController.initialize(); DownloadsCommon.getData(window).addView(DownloadsView); DownloadsPanel._attachEventListeners(); aCallback(); }); }, /** * Closes the downloads panel and frees the internal resources related to the * downloads. The downloads panel can be reopened later, even after this * function has been called. */ terminate: function DP_terminate() { if (this._state == this.kStateUninitialized) { return; } window.removeEventListener("unload", this.onWindowUnload, false); // Ensure that the panel is closed before shutting down. this.hidePanel(); DownloadsViewController.terminate(); DownloadsCommon.getData(window).removeView(DownloadsView); this._unattachEventListeners(); this._state = this.kStateUninitialized; DownloadsSummary.active = false; }, ////////////////////////////////////////////////////////////////////////////// //// Panel interface /** * Main panel element in the browser window. */ get panel() { delete this.panel; return this.panel = document.getElementById("downloadsPanel"); }, /** * Starts opening the downloads panel interface, anchored to the downloads * button of the browser window. The list of downloads to display is * initialized the first time this method is called, and the panel is shown * only when data is ready. */ showPanel: function DP_showPanel() { if (this.isPanelShowing) { this._focusPanel(); return; } this.initialize(function DP_SP_callback() { // Delay displaying the panel because this function will sometimes be // called while another window is closing (like the window for selecting // whether to save or open the file), and that would cause the panel to // close immediately. setTimeout(function () DownloadsPanel._openPopupIfDataReady(), 0); }.bind(this)); this._state = this.kStateWaitingData; }, /** * Hides the downloads panel, if visible, but keeps the internal state so that * the panel can be reopened quickly if required. */ hidePanel: function DP_hidePanel() { if (!this.isPanelShowing) { return; } this.panel.hidePopup(); // Ensure that we allow the panel to be reopened. Note that, if the popup // was open, then the onPopupHidden event handler has already updated the // current state, otherwise we must update the state ourselves. this._state = this.kStateHidden; }, /** * Indicates whether the panel is shown or will be shown. */ get isPanelShowing() { return this._state == this.kStateWaitingData || this._state == this.kStateWaitingAnchor || this._state == this.kStateShown; }, ////////////////////////////////////////////////////////////////////////////// //// Callback functions from DownloadsView /** * Called after data loading finished. */ onViewLoadCompleted: function DP_onViewLoadCompleted() { this._openPopupIfDataReady(); }, ////////////////////////////////////////////////////////////////////////////// //// User interface event functions onWindowUnload: function DP_onWindowUnload() { // This function is registered as an event listener, we can't use "this". DownloadsPanel.terminate(); }, onPopupShown: function DP_onPopupShown(aEvent) { // Ignore events raised by nested popups. if (aEvent.target != aEvent.currentTarget) { return; } this._state = this.kStateShown; // Since at most one popup is open at any given time, we can set globally. DownloadsCommon.getIndicatorData(window).attentionSuppressed = true; // Ensure that an item is selected when the panel is focused. if (DownloadsView.richListBox.itemCount > 0 && !DownloadsView.richListBox.selectedItem) { DownloadsView.richListBox.selectedIndex = 0; } this._focusPanel(); }, onPopupHidden: function DP_onPopupHidden(aEvent) { // Ignore events raised by nested popups. if (aEvent.target != aEvent.currentTarget) { return; } // Since at most one popup is open at any given time, we can set globally. DownloadsCommon.getIndicatorData(window).attentionSuppressed = false; // Allow the anchor to be hidden. DownloadsButton.releaseAnchor(); // Allow the panel to be reopened. this._state = this.kStateHidden; }, ////////////////////////////////////////////////////////////////////////////// //// Related operations /** * Shows or focuses the user interface dedicated to downloads history. */ showDownloadsHistory: function DP_showDownloadsHistory() { // Hide the panel before showing another window, otherwise focus will return // to the browser window when the panel closes automatically. this.hidePanel(); BrowserDownloadsUI(); }, ////////////////////////////////////////////////////////////////////////////// //// Internal functions /** * Attach event listeners to a panel element. These listeners should be * removed in _unattachEventListeners. This is called automatically after the * panel has successfully loaded. */ _attachEventListeners: function DP__attachEventListeners() { this.panel.addEventListener("keydown", this._onKeyDown.bind(this), false); }, /** * Unattach event listeners that were added in _attachEventListeners. This * is called automatically on panel termination. */ _unattachEventListeners: function DP__unattachEventListeners() { this.panel.removeEventListener("keydown", this._onKeyDown.bind(this), false); }, /** * Keydown listener that listens for the accel-V "paste" event. Initiates a * file download if the pasted item can be resolved to a URI. */ _onKeyDown: function DP__onKeyDown(aEvent) { let pasting = aEvent.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_V && #ifdef XP_MACOSX aEvent.metaKey; #else aEvent.ctrlKey; #endif if (!pasting) { return; } let trans = Cc["@mozilla.org/widget/transferable;1"] .createInstance(Ci.nsITransferable); trans.init(null); let flavors = ["text/x-moz-url", "text/unicode"]; flavors.forEach(trans.addDataFlavor); Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard); // Getting the data or creating the nsIURI might fail try { let data = {}; trans.getAnyTransferData({}, data, {}); let [url, name] = data.value .QueryInterface(Ci.nsISupportsString) .data .split("\n"); if (!url) { return; } let uri = NetUtil.newURI(url); saveURL(uri.spec, name || uri.spec, null, true, true, undefined, document); } catch (ex) {} }, /** * Move focus to the main element in the downloads panel, unless another * element in the panel is already focused. */ _focusPanel: function DP_focusPanel() { // We may be invoked while the panel is still waiting to be shown. if (this._state != this.kStateShown) { return; } let element = document.commandDispatcher.focusedElement; while (element && element != this.panel) { element = element.parentNode; } if (!element) { if (DownloadsView.richListBox.itemCount > 0) { DownloadsView.richListBox.focus(); } else { this.panel.focus(); } } }, /** * Opens the downloads panel when data is ready to be displayed. */ _openPopupIfDataReady: function DP_openPopupIfDataReady() { // We don't want to open the popup if we already displayed it, or if we are // still loading data. if (this._state != this.kStateWaitingData || DownloadsView.loading) { return; } this._state = this.kStateWaitingAnchor; // Ensure the anchor is visible. If that is not possible, show the panel // anchored to the top area of the window, near the default anchor position. DownloadsButton.getAnchor(function DP_OPIDR_callback(aAnchor) { // If somehow we've switched states already (by getting a panel hiding // event before an overlay is loaded, for example), bail out. if (this._state != this.kStateWaitingAnchor) return; // At this point, if the window is minimized, opening the panel could fail // without any notification, and there would be no way to either open or // close the panel anymore. To prevent this, check if the window is // minimized and in that case force the panel to the closed state. if (window.windowState == Ci.nsIDOMChromeWindow.STATE_MINIMIZED) { DownloadsButton.releaseAnchor(); this._state = this.kStateHidden; return; } if (aAnchor) { this.panel.openPopup(aAnchor, "bottomcenter topright", 0, 0, false, null); } else { this.panel.openPopup(document.getElementById("TabsToolbar"), "after_end", 0, 0, false, null); } }.bind(this)); } }; //////////////////////////////////////////////////////////////////////////////// //// DownloadsOverlayLoader /** * Allows loading the downloads panel and the status indicator interfaces on * demand, to improve startup performance. */ const DownloadsOverlayLoader = { /** * We cannot load two overlays at the same time, thus we use a queue of * pending load requests. */ _loadRequests: [], /** * True while we are waiting for an overlay to be loaded. */ _overlayLoading: false, /** * This object has a key for each overlay URI that is already loaded. */ _loadedOverlays: {}, /** * Loads the specified overlay and invokes the given callback when finished. * * @param aOverlay * String containing the URI of the overlay to load in the current * window. If this overlay has already been loaded using this * function, then the overlay is not loaded again. * @param aCallback * Invoked when loading is completed. If the overlay is already * loaded, the function is called immediately. */ ensureOverlayLoaded: function DOL_ensureOverlayLoaded(aOverlay, aCallback) { // The overlay is already loaded, invoke the callback immediately. if (aOverlay in this._loadedOverlays) { aCallback(); return; } // The callback will be invoked when loading is finished. this._loadRequests.push({ overlay: aOverlay, callback: aCallback }); if (this._overlayLoading) { return; } function DOL_EOL_loadCallback() { this._overlayLoading = false; this._loadedOverlays[aOverlay] = true; // Loading the overlay causes all the persisted XUL attributes to be // reapplied, including "iconsize" on the toolbars. Until bug 640158 is // fixed, we must recalculate the correct "iconsize" attributes manually. retrieveToolbarIconsizesFromTheme(); this.processPendingRequests(); } this._overlayLoading = true; document.loadOverlay(aOverlay, DOL_EOL_loadCallback.bind(this)); }, /** * Re-processes all the currently pending requests, invoking the callbacks * and/or loading more overlays as needed. In most cases, there will be a * single request for one overlay, that will be processed immediately. */ processPendingRequests: function DOL_processPendingRequests() { // Re-process all the currently pending requests, yet allow more requests // to be appended at the end of the array if we're not ready for them. let currentLength = this._loadRequests.length; for (let i = 0; i < currentLength; i++) { let request = this._loadRequests.shift(); // We must call ensureOverlayLoaded again for each request, to check if // the associated callback can be invoked now, or if we must still wait // for the associated overlay to load. this.ensureOverlayLoaded(request.overlay, request.callback); } } }; //////////////////////////////////////////////////////////////////////////////// //// DownloadsView /** * Builds and updates the downloads list widget, responding to changes in the * download state and real-time data. In addition, handles part of the user * interaction events raised by the downloads list widget. */ const DownloadsView = { ////////////////////////////////////////////////////////////////////////////// //// Functions handling download items in the list /** * Maximum number of items shown by the list at any given time. */ kItemCountLimit: 3, /** * Indicates whether we are still loading downloads data asynchronously. */ loading: false, /** * Ordered array of all DownloadsDataItem objects. We need to keep this array * because only a limited number of items are shown at once, and if an item * that is currently visible is removed from the list, we might need to take * another item from the array and make it appear at the bottom. */ _dataItems: [], /** * Object containing the available DownloadsViewItem objects, indexed by their * numeric download identifier. There is a limited number of view items in * the panel at any given time. */ _viewItems: {}, /** * Called when the number of items in the list changes. */ _itemCountChanged: function DV_itemCountChanged() { let count = this._dataItems.length; let hiddenCount = count - this.kItemCountLimit; if (count > 0) { DownloadsPanel.panel.setAttribute("hasdownloads", "true"); } else { DownloadsPanel.panel.removeAttribute("hasdownloads"); } // If we've got some hidden downloads, we should activate the // DownloadsSummary. The DownloadsSummary will determine whether or not // it's appropriate to actually display the summary. DownloadsSummary.active = hiddenCount > 0; }, /** * Element corresponding to the list of downloads. */ get richListBox() { delete this.richListBox; return this.richListBox = document.getElementById("downloadsListBox"); }, /** * Element corresponding to the button for showing more downloads. */ get downloadsHistory() { delete this.downloadsHistory; return this.downloadsHistory = document.getElementById("downloadsHistory"); }, ////////////////////////////////////////////////////////////////////////////// //// Callback functions from DownloadsData /** * Called before multiple downloads are about to be loaded. */ onDataLoadStarting: function DV_onDataLoadStarting() { this.loading = true; }, /** * Called after data loading finished. */ onDataLoadCompleted: function DV_onDataLoadCompleted() { this.loading = false; // We suppressed item count change notifications during the batch load, at // this point we should just call the function once. this._itemCountChanged(); // Notify the panel that all the initially available downloads have been // loaded. This ensures that the interface is visible, if still required. DownloadsPanel.onViewLoadCompleted(); }, /** * Called when the downloads database becomes unavailable (for example, * entering Private Browsing Mode). References to existing data should be * discarded. */ onDataInvalidated: function DV_onDataInvalidated() { DownloadsPanel.terminate(); // Clear the list by replacing with a shallow copy. let emptyView = this.richListBox.cloneNode(false); this.richListBox.parentNode.replaceChild(emptyView, this.richListBox); this.richListBox = emptyView; this._viewItems = {}; this._dataItems = []; }, /** * Called when a new download data item is available, either during the * asynchronous data load or when a new download is started. * * @param aDataItem * DownloadsDataItem object that was just added. * @param aNewest * When true, indicates that this item is the most recent and should be * added in the topmost position. This happens when a new download is * started. When false, indicates that the item is the least recent * and should be appended. The latter generally happens during the * asynchronous data load. */ onDataItemAdded: function DV_onDataItemAdded(aDataItem, aNewest) { if (aNewest) { this._dataItems.unshift(aDataItem); } else { this._dataItems.push(aDataItem); } let itemsNowOverflow = this._dataItems.length > this.kItemCountLimit; if (aNewest || !itemsNowOverflow) { // The newly added item is visible in the panel and we must add the // corresponding element. This is either because it is the first item, or // because it was added at the bottom but the list still doesn't overflow. this._addViewItem(aDataItem, aNewest); } if (aNewest && itemsNowOverflow) { // If the list overflows, remove the last item from the panel to make room // for the new one that we just added at the top. this._removeViewItem(this._dataItems[this.kItemCountLimit]); } // For better performance during batch loads, don't update the count for // every item, because the interface won't be visible until load finishes. if (!this.loading) { this._itemCountChanged(); } }, /** * Called when a data item is removed. Ensures that the widget associated * with the view item is removed from the user interface. * * @param aDataItem * DownloadsDataItem object that is being removed. */ onDataItemRemoved: function DV_onDataItemRemoved(aDataItem) { let itemIndex = this._dataItems.indexOf(aDataItem); this._dataItems.splice(itemIndex, 1); if (itemIndex < this.kItemCountLimit) { // The item to remove is visible in the panel. this._removeViewItem(aDataItem); if (this._dataItems.length >= this.kItemCountLimit) { // Reinsert the next item into the panel. this._addViewItem(this._dataItems[this.kItemCountLimit - 1], false); } } this._itemCountChanged(); }, /** * Returns the view item associated with the provided data item for this view. * * @param aDataItem * DownloadsDataItem object for which the view item is requested. * * @return Object that can be used to notify item status events. */ getViewItem: function DV_getViewItem(aDataItem) { // If the item is visible, just return it, otherwise return a mock object // that doesn't react to notifications. if (aDataItem.downloadGuid in this._viewItems) { return this._viewItems[aDataItem.downloadGuid]; } return this._invisibleViewItem; }, /** * Mock DownloadsDataItem object that doesn't react to notifications. */ _invisibleViewItem: Object.freeze({ onStateChange: function () { }, onProgressChange: function () { } }), /** * Creates a new view item associated with the specified data item, and adds * it to the top or the bottom of the list. */ _addViewItem: function DV_addViewItem(aDataItem, aNewest) { let element = document.createElement("richlistitem"); let viewItem = new DownloadsViewItem(aDataItem, element); this._viewItems[aDataItem.downloadGuid] = viewItem; if (aNewest) { this.richListBox.insertBefore(element, this.richListBox.firstChild); } else { this.richListBox.appendChild(element); } }, /** * Removes the view item associated with the specified data item. */ _removeViewItem: function DV_removeViewItem(aDataItem) { let element = this.getViewItem(aDataItem)._element; let previousSelectedIndex = this.richListBox.selectedIndex; this.richListBox.removeChild(element); this.richListBox.selectedIndex = Math.min(previousSelectedIndex, this.richListBox.itemCount - 1); delete this._viewItems[aDataItem.downloadGuid]; }, ////////////////////////////////////////////////////////////////////////////// //// User interface event functions /** * Helper function to do commands on a specific download item. * * @param aEvent * Event object for the event being handled. If the event target is * not a richlistitem that represents a download, this function will * walk up the parent nodes until it finds a DOM node that is. * @param aCommand * The command to be performed. */ onDownloadCommand: function DV_onDownloadCommand(aEvent, aCommand) { let target = aEvent.target; while (target.nodeName != "richlistitem") { target = target.parentNode; } new DownloadsViewItemController(target).doCommand(aCommand); }, onDownloadClick: function DV_onDownloadClick(aEvent) { // Handle primary clicks only, and exclude the action button. if (aEvent.button == 0 && !aEvent.originalTarget.hasAttribute("oncommand")) { goDoCommand("downloadsCmd_open"); } }, onDownloadKeyPress: function DV_onDownloadKeyPress(aEvent) { // Handle unmodified keys only. if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) { return; } // Pressing the key on buttons should not invoke the action because the // event has already been handled by the button itself. if (aEvent.originalTarget.hasAttribute("command") || aEvent.originalTarget.hasAttribute("oncommand")) { return; } if (aEvent.charCode == " ".charCodeAt(0)) { goDoCommand("downloadsCmd_pauseResume"); return; } switch (aEvent.keyCode) { case KeyEvent.DOM_VK_ENTER: case KeyEvent.DOM_VK_RETURN: goDoCommand("downloadsCmd_doDefault"); break; case KeyEvent.DOM_VK_DOWN: // Are we focused on the last element in the list? if (this.richListBox.currentIndex == (this.richListBox.itemCount - 1)) { DownloadsFooter.focus(); } break; } }, onDownloadContextMenu: function DV_onDownloadContextMenu(aEvent) { let element = this.richListBox.selectedItem; if (!element) { return; } DownloadsViewController.updateCommands(); // Set the state attribute so that only the appropriate items are displayed. let contextMenu = document.getElementById("downloadsContextMenu"); contextMenu.setAttribute("state", element.getAttribute("state")); }, onDownloadDragStart: function DV_onDownloadDragStart(aEvent) { let element = this.richListBox.selectedItem; if (!element) { return; } let controller = new DownloadsViewItemController(element); let localFile = controller.dataItem.localFile; if (!localFile.exists()) { return; } let dataTransfer = aEvent.dataTransfer; dataTransfer.mozSetDataAt("application/x-moz-file", localFile, 0); dataTransfer.effectAllowed = "copyMove"; var url = Services.io.newFileURI(localFile).spec; dataTransfer.setData("text/uri-list", url); dataTransfer.setData("text/plain", url); dataTransfer.addElement(element); aEvent.stopPropagation(); } } //////////////////////////////////////////////////////////////////////////////// //// DownloadsViewItem /** * Builds and updates a single item in the downloads list widget, responding to * changes in the download state and real-time data. * * @param aDataItem * DownloadsDataItem to be associated with the view item. * @param aElement * XUL element corresponding to the single download item in the view. */ function DownloadsViewItem(aDataItem, aElement) { this._element = aElement; this.dataItem = aDataItem; this.lastEstimatedSecondsLeft = Infinity; // Set the URI that represents the correct icon for the target file. As soon // as bug 239948 comment 12 is handled, the "file" property will be always a // file URL rather than a file name. At that point we should remove the "//" // (double slash) from the icon URI specification (see test_moz_icon_uri.js). this.image = "moz-icon://" + this.dataItem.file + "?size=32"; let attributes = { "type": "download", "class": "download-state", "id": "downloadsItem_" + this.dataItem.downloadGuid, "downloadGuid": this.dataItem.downloadGuid, "state": this.dataItem.state, "progress": this.dataItem.inProgress ? this.dataItem.percentComplete : 100, "target": this.dataItem.target, "image": this.image }; for (let attributeName in attributes) { this._element.setAttribute(attributeName, attributes[attributeName]); } // Initialize more complex attributes. this._updateProgress(); this._updateStatusLine(); } DownloadsViewItem.prototype = { /** * The DownloadDataItem associated with this view item. */ dataItem: null, /** * The XUL element corresponding to the associated richlistbox item. */ _element: null, /** * The inner XUL element for the progress bar, or null if not available. */ _progressElement: null, ////////////////////////////////////////////////////////////////////////////// //// Callback functions from DownloadsData /** * Called when the download state might have changed. Sometimes the state of * the download might be the same as before, if the data layer received * multiple events for the same download. */ onStateChange: function DVI_onStateChange(aOldState) { // If a download just finished successfully, it means that the target file // now exists and we can extract its specific icon. To ensure that the icon // is reloaded, we must change the URI used by the XUL image element, for // example by adding a query parameter. Since this URI has a "moz-icon" // scheme, this only works if we add one of the parameters explicitly // supported by the nsIMozIconURI interface. if (aOldState != Ci.nsIDownloadManager.DOWNLOAD_FINISHED && aOldState != this.dataItem.state) { this._element.setAttribute("image", this.image + "&state=normal"); } // Update the user interface after switching states. this._element.setAttribute("state", this.dataItem.state); this._updateProgress(); this._updateStatusLine(); }, /** * Called when the download progress has changed. */ onProgressChange: function DVI_onProgressChange() { this._updateProgress(); this._updateStatusLine(); }, ////////////////////////////////////////////////////////////////////////////// //// Functions for updating the user interface /** * Updates the progress bar. */ _updateProgress: function DVI_updateProgress() { if (this.dataItem.starting) { // Before the download starts, the progress meter has its initial value. this._element.setAttribute("progressmode", "normal"); this._element.setAttribute("progress", "0"); } else if (this.dataItem.state == Ci.nsIDownloadManager.DOWNLOAD_SCANNING || this.dataItem.percentComplete == -1) { // We might not know the progress of a running download, and we don't know // the remaining time during the malware scanning phase. this._element.setAttribute("progressmode", "undetermined"); } else { // This is a running download of which we know the progress. this._element.setAttribute("progressmode", "normal"); this._element.setAttribute("progress", this.dataItem.percentComplete); } // Find the progress element as soon as the download binding is accessible. if (!this._progressElement) { this._progressElement = document.getAnonymousElementByAttribute(this._element, "anonid", "progressmeter"); } // Dispatch the ValueChange event for accessibility, if possible. if (this._progressElement) { let event = document.createEvent("Events"); event.initEvent("ValueChange", true, true); this._progressElement.dispatchEvent(event); } }, /** * Updates the main status line, including bytes transferred, bytes total, * download rate, and time remaining. */ _updateStatusLine: function DVI_updateStatusLine() { const nsIDM = Ci.nsIDownloadManager; let status = ""; let statusTip = ""; if (this.dataItem.paused) { let transfer = DownloadUtils.getTransferTotal(this.dataItem.currBytes, this.dataItem.maxBytes); // We use the same XUL label to display both the state and the amount // transferred, for example "Paused - 1.1 MB". status = DownloadsCommon.strings.statusSeparatorBeforeNumber( DownloadsCommon.strings.statePaused, transfer); } else if (this.dataItem.state == nsIDM.DOWNLOAD_DOWNLOADING) { // We don't show the rate for each download in order to reduce clutter. // The remaining time per download is likely enough information for the // panel. [status] = DownloadUtils.getDownloadStatusNoRate(this.dataItem.currBytes, this.dataItem.maxBytes, this.dataItem.speed, this.lastEstimatedSecondsLeft); // We are, however, OK with displaying the rate in the tooltip. let newEstimatedSecondsLeft; [statusTip, newEstimatedSecondsLeft] = DownloadUtils.getDownloadStatus(this.dataItem.currBytes, this.dataItem.maxBytes, this.dataItem.speed, this.lastEstimatedSecondsLeft); this.lastEstimatedSecondsLeft = newEstimatedSecondsLeft; } else if (this.dataItem.starting) { status = DownloadsCommon.strings.stateStarting; } else if (this.dataItem.state == nsIDM.DOWNLOAD_SCANNING) { status = DownloadsCommon.strings.stateScanning; } else if (!this.dataItem.inProgress) { let stateLabel = function () { let s = DownloadsCommon.strings; switch (this.dataItem.state) { case nsIDM.DOWNLOAD_FAILED: return s.stateFailed; case nsIDM.DOWNLOAD_CANCELED: return s.stateCanceled; case nsIDM.DOWNLOAD_BLOCKED_PARENTAL: return s.stateBlockedParentalControls; case nsIDM.DOWNLOAD_BLOCKED_POLICY: return s.stateBlockedPolicy; case nsIDM.DOWNLOAD_DIRTY: return s.stateDirty; case nsIDM.DOWNLOAD_FINISHED: return this._fileSizeText; } return null; }.apply(this); let [displayHost, fullHost] = DownloadUtils.getURIHost(this.dataItem.referrer || this.dataItem.uri); let end = new Date(this.dataItem.endTime); let [displayDate, fullDate] = DownloadUtils.getReadableDates(end); // We use the same XUL label to display the state, the host name, and the // end time, for example "Canceled - 222.net - 11:15" or "1.1 MB - // website2.com - Yesterday". We show the full host and the complete date // in the tooltip. let firstPart = DownloadsCommon.strings.statusSeparator(stateLabel, displayHost); status = DownloadsCommon.strings.statusSeparator(firstPart, displayDate); statusTip = DownloadsCommon.strings.statusSeparator(fullHost, fullDate); } this._element.setAttribute("status", status); this._element.setAttribute("statusTip", statusTip || status); }, /** * Localized string representing the total size of completed downloads, for * example "1.5 MB" or "Unknown size". */ get _fileSizeText() { // Display the file size, but show "Unknown" for negative sizes. let fileSize = this.dataItem.maxBytes; if (fileSize < 0) { return DownloadsCommon.strings.sizeUnknown; } let [size, unit] = DownloadUtils.convertByteUnits(fileSize); return DownloadsCommon.strings.sizeWithUnits(size, unit); } }; //////////////////////////////////////////////////////////////////////////////// //// DownloadsViewController /** * Handles part of the user interaction events raised by the downloads list * widget, in particular the "commands" that apply to multiple items, and * dispatches the commands that apply to individual items. */ const DownloadsViewController = { ////////////////////////////////////////////////////////////////////////////// //// Initialization and termination initialize: function DVC_initialize() { window.controllers.insertControllerAt(0, this); }, terminate: function DVC_terminate() { window.controllers.removeController(this); }, ////////////////////////////////////////////////////////////////////////////// //// nsIController supportsCommand: function DVC_supportsCommand(aCommand) { // Firstly, determine if this is a command that we can handle. if (!(aCommand in this.commands) && !(aCommand in DownloadsViewItemController.prototype.commands)) { return false; } // Secondly, determine if focus is on a control in the downloads list. let element = document.commandDispatcher.focusedElement; while (element && element != DownloadsView.richListBox) { element = element.parentNode; } // We should handle the command only if the downloads list is among the // ancestors of the focused element. return !!element; }, isCommandEnabled: function DVC_isCommandEnabled(aCommand) { // Handle commands that are not selection-specific. if (aCommand == "downloadsCmd_clearList") { if (PrivateBrowsingUtils.isWindowPrivate(window)) { return Services.downloads.canCleanUpPrivate; } else { return Services.downloads.canCleanUp; } } // Other commands are selection-specific. let element = DownloadsView.richListBox.selectedItem; return element && new DownloadsViewItemController(element).isCommandEnabled(aCommand); }, doCommand: function DVC_doCommand(aCommand) { // If this command is not selection-specific, execute it. if (aCommand in this.commands) { this.commands[aCommand].apply(this); return; } // Other commands are selection-specific. let element = DownloadsView.richListBox.selectedItem; if (element) { // The doCommand function also checks if the command is enabled. new DownloadsViewItemController(element).doCommand(aCommand); } }, onEvent: function () { }, ////////////////////////////////////////////////////////////////////////////// //// Other functions updateCommands: function DVC_updateCommands() { Object.keys(this.commands).forEach(goUpdateCommand); Object.keys(DownloadsViewItemController.prototype.commands) .forEach(goUpdateCommand); }, ////////////////////////////////////////////////////////////////////////////// //// Selection-independent commands /** * This object contains one key for each command that operates regardless of * the currently selected item in the list. */ commands: { downloadsCmd_clearList: function DVC_downloadsCmd_clearList() { if (PrivateBrowsingUtils.isWindowPrivate(window)) { Services.downloads.cleanUpPrivate(); } else { Services.downloads.cleanUp(); } } } }; //////////////////////////////////////////////////////////////////////////////// //// DownloadsViewItemController /** * Handles all the user interaction events, in particular the "commands", * related to a single item in the downloads list widgets. */ function DownloadsViewItemController(aElement) { let downloadGuid = aElement.getAttribute("downloadGuid"); this.dataItem = DownloadsCommon.getData(window).dataItems[downloadGuid]; } DownloadsViewItemController.prototype = { ////////////////////////////////////////////////////////////////////////////// //// Command dispatching /** * The DownloadDataItem controlled by this object. */ dataItem: null, isCommandEnabled: function DVIC_isCommandEnabled(aCommand) { switch (aCommand) { case "downloadsCmd_open": { return this.dataItem.openable && this.dataItem.localFile.exists(); } case "downloadsCmd_show": { return this.dataItem.localFile.exists() || this.dataItem.partFile.exists(); } case "downloadsCmd_pauseResume": return this.dataItem.inProgress && this.dataItem.resumable; case "downloadsCmd_retry": return this.dataItem.canRetry; case "downloadsCmd_openReferrer": return !!this.dataItem.referrer; case "cmd_delete": case "downloadsCmd_cancel": case "downloadsCmd_copyLocation": case "downloadsCmd_doDefault": return true; } return false; }, doCommand: function DVIC_doCommand(aCommand) { if (this.isCommandEnabled(aCommand)) { this.commands[aCommand].apply(this); } }, ////////////////////////////////////////////////////////////////////////////// //// Item commands /** * This object contains one key for each command that operates on this item. * * In commands, the "this" identifier points to the controller item. */ commands: { cmd_delete: function DVIC_cmd_delete() { this.dataItem.remove(); PlacesUtils.bhistory.removePage(NetUtil.newURI(this.dataItem.uri)); }, downloadsCmd_cancel: function DVIC_downloadsCmd_cancel() { this.dataItem.cancel(); }, downloadsCmd_open: function DVIC_downloadsCmd_open() { this.dataItem.openLocalFile(window); // We explicitly close the panel here to give the user the feedback that // their click has been received, and we're handling the action. // Otherwise, we'd have to wait for the file-type handler to execute // before the panel would close. This also helps to prevent the user from // accidentally opening a file several times. DownloadsPanel.hidePanel(); }, downloadsCmd_show: function DVIC_downloadsCmd_show() { this.dataItem.showLocalFile(); // We explicitly close the panel here to give the user the feedback that // their click has been received, and we're handling the action. // Otherwise, we'd have to wait for the operating system file manager // window to open before the panel closed. This also helps to prevent the // user from opening the containing folder several times. DownloadsPanel.hidePanel(); }, downloadsCmd_pauseResume: function DVIC_downloadsCmd_pauseResume() { this.dataItem.togglePauseResume(); }, downloadsCmd_retry: function DVIC_downloadsCmd_retry() { this.dataItem.retry(); }, downloadsCmd_openReferrer: function DVIC_downloadsCmd_openReferrer() { openURL(this.dataItem.referrer); }, downloadsCmd_copyLocation: function DVIC_downloadsCmd_copyLocation() { let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"] .getService(Ci.nsIClipboardHelper); clipboard.copyString(this.dataItem.uri, document); }, downloadsCmd_doDefault: function DVIC_downloadsCmd_doDefault() { const nsIDM = Ci.nsIDownloadManager; // Determine the default command for the current item. let defaultCommand = function () { switch (this.dataItem.state) { case nsIDM.DOWNLOAD_NOTSTARTED: return "downloadsCmd_cancel"; case nsIDM.DOWNLOAD_DOWNLOADING: return "downloadsCmd_show"; case nsIDM.DOWNLOAD_FINISHED: return "downloadsCmd_open"; case nsIDM.DOWNLOAD_FAILED: return "downloadsCmd_retry"; case nsIDM.DOWNLOAD_CANCELED: return "downloadsCmd_retry"; case nsIDM.DOWNLOAD_PAUSED: return "downloadsCmd_pauseResume"; case nsIDM.DOWNLOAD_QUEUED: return "downloadsCmd_cancel"; case nsIDM.DOWNLOAD_BLOCKED_PARENTAL: return "downloadsCmd_openReferrer"; case nsIDM.DOWNLOAD_SCANNING: return "downloadsCmd_show"; case nsIDM.DOWNLOAD_DIRTY: return "downloadsCmd_openReferrer"; case nsIDM.DOWNLOAD_BLOCKED_POLICY: return "downloadsCmd_openReferrer"; } return null; }.apply(this); // Invoke the command. this.doCommand(defaultCommand); } } }; //////////////////////////////////////////////////////////////////////////////// //// DownloadsSummary /** * Manages the summary at the bottom of the downloads panel list if the number * of items in the list exceeds the panels limit. */ const DownloadsSummary = { /** * Sets the active state of the summary. When active, the sumamry subscribes * to the DownloadsCommon DownloadsSummaryData singleton. * * @param aActive * Set to true to activate the summary. */ set active(aActive) { if (aActive == this._active || !this._summaryNode) { return this._active; } if (aActive) { DownloadsCommon.getSummary(window, DownloadsView.kItemCountLimit) .addView(this); } else { DownloadsCommon.getSummary(window, DownloadsView.kItemCountLimit) .removeView(this); DownloadsFooter.showingSummary = false; } return this._active = aActive; }, /** * Returns the active state of the downloads summary. */ get active() this._active, _active: false, /** * Sets whether or not we show the progress bar. * * @param aShowingProgress * True if we should show the progress bar. */ set showingProgress(aShowingProgress) { if (aShowingProgress) { this._summaryNode.setAttribute("inprogress", "true"); } else { this._summaryNode.removeAttribute("inprogress"); } // If progress isn't being shown, then we simply do not show the summary. return DownloadsFooter.showingSummary = aShowingProgress; }, /** * Sets the amount of progress that is visible in the progress bar. * * @param aValue * A value between 0 and 100 to represent the progress of the * summarized downloads. */ set percentComplete(aValue) { if (this._progressNode) { this._progressNode.setAttribute("value", aValue); } return aValue; }, /** * Sets the description for the download summary. * * @param aValue * A string representing the description of the summarized * downloads. */ set description(aValue) { if (this._descriptionNode) { this._descriptionNode.setAttribute("value", aValue); this._descriptionNode.setAttribute("tooltiptext", aValue); } return aValue; }, /** * Sets the details for the download summary, such as the time remaining, * the amount of bytes transferred, etc. * * @param aValue * A string representing the details of the summarized * downloads. */ set details(aValue) { if (this._detailsNode) { this._detailsNode.setAttribute("value", aValue); this._detailsNode.setAttribute("tooltiptext", aValue); } return aValue; }, /** * Focuses the root element of the summary. */ focus: function() { if (this._summaryNode) { this._summaryNode.focus(); } }, /** * Respond to keypress events on the Downloads Summary node. * * @param aEvent * The keypress event being handled. */ onKeyPress: function DS_onKeyPress(aEvent) { if (aEvent.charCode == " ".charCodeAt(0) || aEvent.keyCode == KeyEvent.DOM_VK_ENTER || aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { DownloadsPanel.showDownloadsHistory(); } }, /** * Respond to click events on the Downloads Summary node. * * @param aEvent * The click event being handled. */ onClick: function DS_onClick(aEvent) { DownloadsPanel.showDownloadsHistory(); }, /** * Element corresponding to the root of the downloads summary. */ get _summaryNode() { let node = document.getElementById("downloadsSummary"); if (!node) { return null; } delete this._summaryNode; return this._summaryNode = node; }, /** * Element corresponding to the progress bar in the downloads summary. */ get _progressNode() { let node = document.getElementById("downloadsSummaryProgress"); if (!node) { return null; } delete this._progressNode; return this._progressNode = node; }, /** * Element corresponding to the main description of the downloads * summary. */ get _descriptionNode() { let node = document.getElementById("downloadsSummaryDescription"); if (!node) { return null; } delete this._descriptionNode; return this._descriptionNode = node; }, /** * Element corresponding to the secondary description of the downloads * summary. */ get _detailsNode() { let node = document.getElementById("downloadsSummaryDetails"); if (!node) { return null; } delete this._detailsNode; return this._detailsNode = node; } } //////////////////////////////////////////////////////////////////////////////// //// DownloadsFooter /** * Manages events sent to to the footer vbox, which contains both the * DownloadsSummary as well as the "Show All Downloads" button. */ const DownloadsFooter = { /** * Focuses the appropriate element within the footer. If the summary * is visible, focus it. If not, focus the "Show All Downloads" * button. */ focus: function DF_focus() { if (DownloadsSummary.visible) { DownloadsSummary.focus(); } else { DownloadsView.downloadsHistory.focus(); } }, /** * Handles keypress events on the footer element. */ onKeyPress: function DF_onKeyPress(aEvent) { // If the up key is pressed, and the downloads list has at least 1 element // in it, focus the last element in the list. if (aEvent.keyCode == KeyEvent.DOM_VK_UP && DownloadsView.richListBox.itemCount > 0) { DownloadsView.richListBox.focus(); DownloadsView.richListBox.selectedIndex = (DownloadsView.richListBox.itemCount - 1); } }, /** * Sets whether or not the Downloads Summary should be displayed in the * footer. If not, the "Show All Downloads" button is shown instead. */ set showingSummary(aValue) { if (this._footerNode) { if (aValue) { this._footerNode.setAttribute("showingsummary", "true"); } else { this._footerNode.removeAttribute("showingsummary"); } } return aValue; }, /** * Element corresponding to the footer of the downloads panel. */ get _footerNode() { let node = document.getElementById("downloadsFooter"); if (!node) { return null; } delete this._footerNode; return this._footerNode = node; } };