diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/downloads/DownloadsSubview.jsm | 631 |
1 files changed, 631 insertions, 0 deletions
diff --git a/browser/components/downloads/DownloadsSubview.jsm b/browser/components/downloads/DownloadsSubview.jsm new file mode 100644 index 0000000000..e58e3cce07 --- /dev/null +++ b/browser/components/downloads/DownloadsSubview.jsm @@ -0,0 +1,631 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DownloadsSubview"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadsCommon: "resource:///modules/DownloadsCommon.jsm", + DownloadsViewUI: "resource:///modules/DownloadsViewUI.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +let gPanelViewInstances = new WeakMap(); +const kRefreshBatchSize = 10; +const kMaxWaitForIdleMs = 200; +XPCOMUtils.defineLazyGetter(this, "kButtonLabels", () => { + return { + show: + DownloadsCommon.strings[ + AppConstants.platform == "macosx" ? "showMacLabel" : "showLabel" + ], + open: DownloadsCommon.strings.openFileLabel, + retry: DownloadsCommon.strings.retryLabel, + }; +}); + +class DownloadsSubview extends DownloadsViewUI.BaseView { + constructor(panelview) { + super(); + this.document = panelview.ownerDocument; + this.window = panelview.ownerGlobal; + + this.context = "panelDownloadsContextMenu"; + + this.panelview = panelview; + this.container = this.document.getElementById("panelMenu_downloadsMenu"); + while (this.container.lastChild) { + this.container.lastChild.remove(); + } + this.panelview.addEventListener("click", DownloadsSubview.onClick); + this.panelview.addEventListener( + "ViewHiding", + DownloadsSubview.onViewHiding + ); + + this._viewItemsForDownloads = new WeakMap(); + + let contextMenu = this.document.getElementById(this.context); + if (!contextMenu) { + contextMenu = this.document + .getElementById("downloadsContextMenu") + .cloneNode(true); + contextMenu.setAttribute("closemenu", "none"); + contextMenu.setAttribute("id", this.context); + contextMenu.removeAttribute("onpopupshown"); + contextMenu.setAttribute( + "onpopupshowing", + "DownloadsSubview.updateContextMenu(document.popupNode, this);" + ); + contextMenu.setAttribute( + "onpopuphidden", + "DownloadsSubview.onContextMenuHidden(this);" + ); + let clearButton = contextMenu.querySelector( + "menuitem[command='downloadsCmd_clearDownloads']" + ); + clearButton.hidden = false; + clearButton.previousElementSibling.hidden = true; + contextMenu + .querySelector("menuitem[command='cmd_delete']") + .setAttribute("command", "downloadsCmd_delete"); + } + this.panelview.appendChild(contextMenu); + this.container.setAttribute("context", this.context); + + this._downloadsData = DownloadsCommon.getData( + this.window, + true, + true, + true + ); + this._downloadsData.addView(this); + } + + destructor(event) { + this.panelview.removeEventListener("click", DownloadsSubview.onClick); + this.panelview.removeEventListener( + "ViewHiding", + DownloadsSubview.onViewHiding + ); + this._downloadsData.removeView(this); + gPanelViewInstances.delete(this); + this.destroyed = true; + } + + /** + * DataView handler; invoked when a batch of downloads is being passed in - + * usually when this instance is added as a view in the constructor. + */ + onDownloadBatchStarting() { + this.window.clearTimeout(this._batchTimeout); + } + + /** + * DataView handler; invoked when the view stopped feeding its current list of + * downloads. + */ + onDownloadBatchEnded() { + let { window } = this; + window.clearTimeout(this._batchTimeout); + // If there are no downloads to display, wait a bit to dispatch the load + // completion event, because another batch may start right away. + this._batchTimeout = window.setTimeout( + () => { + this._updateStatsFromDisk(); + this.panelview.dispatchEvent(new window.CustomEvent("DownloadsLoaded")); + }, + this.container.childElementCount ? 0 : 200 + ); + } + + /** + * DataView handler; invoked when a new download is added to the list. + * + * @param {Download} download + * @param {DOMNode} [options.insertBefore] + */ + onDownloadAdded(download, { insertBefore } = {}) { + let element = this.document.createXULElement("hbox"); + let shell = new DownloadsSubview.Button(download, element); + this._viewItemsForDownloads.set(download, shell); + + // Since newest downloads are displayed at the top, either prepend the new + // element or insert it after the one indicated by the insertBefore option. + if (insertBefore) { + this._viewItemsForDownloads + .get(insertBefore) + .element.insertAdjacentElement("afterend", element); + } else { + this.container.prepend(element); + } + + // After connecting to the document, trigger the code that updates all + // attributes to match the current state of the downloads. + shell.ensureActive(); + } + + /** + * DataView Handler; invoked when the state of a download changed. + * + * @param {Download} download + */ + onDownloadChanged(download) { + this._viewItemsForDownloads.get(download).onChanged(); + } + + /** + * DataView handler; invoked when a download is removed. + * + * @param {Download} download + */ + onDownloadRemoved(download) { + this._viewItemsForDownloads.get(download).element.remove(); + } + + /** + * Schedule a refresh of the downloads that were added, which is mainly about + * checking whether the target file still exists. + * We're doing this during idle time and in chunks. + */ + async _updateStatsFromDisk() { + if (this._updatingStats) { + return; + } + + this._updatingStats = true; + + try { + let idleOptions = { timeout: kMaxWaitForIdleMs }; + // Start with getting an idle moment to (maybe) refresh the list of downloads. + await new Promise( + resolve => this.window.requestIdleCallback(resolve), + idleOptions + ); + // In the meantime, this instance could have been destroyed, so take note. + if (this.destroyed) { + return; + } + + let count = 0; + for (let button of this.container.children) { + if (this.destroyed) { + return; + } + if (!button._shell) { + continue; + } + + await button._shell.refresh(); + + // Make sure to request a new idle moment every `kRefreshBatchSize` buttons. + if (++count % kRefreshBatchSize === 0) { + await new Promise(resolve => + this.window.requestIdleCallback(resolve, idleOptions) + ); + } + } + } catch (ex) { + Cu.reportError(ex); + } finally { + this._updatingStats = false; + } + } + + // ----- Static methods. ----- + + /** + * Show the Downloads subview panel and listen for events that will trigger + * building the dynamic part of the view. + * + * @param {DOMNode} anchor The button that was commanded to trigger this function. + */ + static show(anchor) { + let document = anchor.ownerDocument; + let window = anchor.ownerGlobal; + + let panelview = document.getElementById("PanelUI-downloads"); + anchor.setAttribute("closemenu", "none"); + gPanelViewInstances.set(panelview, new DownloadsSubview(panelview)); + + // Since the DownloadsLists are propagated asynchronously, we need to wait a + // little to get the view propagated. + panelview.addEventListener( + "ViewShowing", + event => { + event.detail.addBlocker( + new Promise(resolve => { + panelview.addEventListener("DownloadsLoaded", resolve, { + once: true, + }); + }) + ); + }, + { once: true } + ); + + window.PanelUI.showSubView("PanelUI-downloads", anchor); + } + + /** + * Handler method; reveal the users' download directory using the OS specific + * method. + */ + static async onShowDownloads() { + // Retrieve the user's default download directory. + let preferredDir = await Downloads.getPreferredDownloadsDirectory(); + DownloadsCommon.showDirectory(new FileUtils.File(preferredDir)); + } + + /** + * Handler method; clear the list downloads finished and old(er) downloads, + * just like in the Library. + * + * @param {DOMNode} button Button that was clicked to call this method. + */ + static onClearDownloads(button) { + let instance = gPanelViewInstances.get(button.closest("panelview")); + if (!instance) { + return; + } + instance._downloadsData.removeFinished(); + PlacesUtils.history + .removeVisitsByFilter({ + transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD, + }) + .catch(Cu.reportError); + } + + /** + * Just before showing the context menu, anchored to a download item, we need + * to set the right properties to make sure the right menu-items are visible. + * + * @param {DOMNode} button The Button the context menu will be anchored to. + * @param {DOMNode} menu The context menu. + */ + static updateContextMenu(button, menu) { + while (!button._shell) { + button = button.parentNode; + } + let download = button._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + menu.setAttribute("state", button.getAttribute("state")); + if (button.hasAttribute("exists")) { + menu.setAttribute("exists", button.getAttribute("exists")); + } else { + menu.removeAttribute("exists"); + } + menu.classList.toggle( + "temporary-block", + button.classList.contains("temporary-block") + ); + // menu items are conditionally displayed via CSS based on a viewable-internally attribute + DownloadsCommon.log( + "DownloadsSubview, updateContextMenu, download is viewable internally? ", + download.target.path, + button.hasAttribute("viewable-internally") + ); + if (button.hasAttribute("viewable-internally")) { + menu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = menu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = menu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + menu.removeAttribute("viewable-internally"); + } + + for (let menuitem of menu.getElementsByTagName("menuitem")) { + let command = menuitem.getAttribute("command"); + if (!command) { + continue; + } + if (command == "downloadsCmd_clearDownloads") { + menuitem.disabled = !DownloadsSubview.canClearDownloads(button); + } else { + menuitem.disabled = !button._shell.isCommandEnabled(command); + } + } + + // The menu anchorNode property is not available long enough to be used elsewhere, + // so tack it another property name. + menu._anchorNode = button; + } + + /** + * Right after the context menu was hidden, perform a bit of cleanup. + * + * @param {DOMNode} menu The context menu. + */ + static onContextMenuHidden(menu) { + delete menu._anchorNode; + } + + /** + * Static version of DownloadsSubview#canClearDownloads(). + * + * @param {DOMNode} button Button that we'll use to find the right + * DownloadsSubview instance. + */ + static canClearDownloads(button) { + let instance = gPanelViewInstances.get(button.closest("panelview")); + if (!instance) { + return false; + } + return instance.canClearDownloads(instance.container); + } + + /** + * Handler method; invoked when the Downloads panel is hidden and should be + * torn down & cleaned up. + * + * @param {DOMEvent} event + */ + static onViewHiding(event) { + let instance = gPanelViewInstances.get(event.target); + if (!instance) { + return; + } + instance.destructor(event); + } + + /** + * Handler method; invoked when anything is clicked inside the Downloads panel. + * Depending on the context, it will find the appropriate command to invoke. + * + * We don't have a command dispatcher registered for this view, so we don't go + * through the goDoCommand path like we do for the other views. + * + * @param {DOMMouseEvent} event + */ + static onClick(event) { + // Handle left & middle clicks with any key modifiers + if (event.button > 1) { + return; + } + + let button = event.target.closest( + ".subviewbutton,toolbarbutton,menuitem,panelview" + ); + if (!button || button.localName == "panelview") { + return; + } + + let item = button.closest(".subviewbutton.download"); + + let command = "downloadsCmd_open"; + let openWhere; + if (button.classList.contains("action-button")) { + command = item.hasAttribute("canShow") + ? "downloadsCmd_show" + : "downloadsCmd_retry"; + } else if (button.localName == "menuitem") { + command = button.getAttribute("command"); + if (command == "downloadsCmd_clearDownloads") { + DownloadsSubview.onClearDownloads(button); + return; + } + item = button.parentNode._anchorNode; + } + if ( + command == "downloadsCmd_open" && + (event.shiftKey || event.ctrlKey || event.metaKey || event.button == 1) + ) { + // We adjust the command for supported modifiers to suggest where the download may + // be opened. + let topWindow = BrowserWindowTracker.getTopWindow(); + openWhere = topWindow.whereToOpenLink(event, false, true); + } + if (item && item._shell.isCommandEnabled(command)) { + item._shell[command](openWhere); + } + } +} + +/** + * Associates each document with a pre-built DOM fragment representing the + * download list item. This is then cloned to create each individual list item. + * This is stored on the document to prevent leaks that would occur if a single + * instance created by one document's DOMParser was stored globally. + */ +var gDownloadsSubviewItemFragments = new WeakMap(); + +DownloadsSubview.Button = class extends DownloadsViewUI.DownloadElementShell { + constructor(download, element) { + super(); + this.download = download; + this.element = element; + this.element._shell = this; + + this.element.classList.add( + "subviewbutton", + "subviewbutton-iconic", + "download", + "download-state", + "navigable" + ); + + let hover = event => { + if (event.originalTarget.classList.contains("action-button")) { + this.element.classList.toggle( + "downloadHoveringButton", + event.type == "mouseover" + ); + } + }; + this.element.addEventListener("mouseover", hover); + this.element.addEventListener("mouseout", hover); + } + + get browserWindow() { + return this.element.ownerGlobal; + } + + async refresh() { + if (this._targetFileChecked) { + return; + } + + try { + await this.download.refresh(); + } catch (ex) { + Cu.reportError(ex); + } finally { + this._targetFileChecked = true; + } + } + + /** + * Handle state changes of a download. + */ + onStateChanged() { + // Since the state changed, we may need to check the target file again. + this._targetFileChecked = false; + + this._updateState(); + } + + /** + * Handler method; invoked when any state attribute of a download changed. + */ + onChanged() { + let newState = DownloadsCommon.stateOfDownload(this.download); + if (this._downloadState !== newState) { + this._downloadState = newState; + this.onStateChanged(); + } else { + this._updateState(); + } + } + + // DownloadElementShell + connect() { + let document = this.element.ownerDocument; + let downloadsSubviewItemFragment = gDownloadsSubviewItemFragments.get( + document + ); + if (!downloadsSubviewItemFragment) { + let MozXULElement = document.defaultView.MozXULElement; + downloadsSubviewItemFragment = MozXULElement.parseXULToFragment(` + <image class="toolbarbutton-icon" validate="always"/> + <vbox class="toolbarbutton-text" flex="1"> + <label crop="end"/> + <label class="status-text status-full" crop="end"/> + <label class="status-text status-open" crop="end"/> + <label class="status-text status-retry" crop="end"/> + <label class="status-text status-show" crop="end"/> + </vbox> + <toolbarbutton class="action-button"/> + `); + gDownloadsSubviewItemFragments.set( + document, + downloadsSubviewItemFragment + ); + } + this.element.appendChild(downloadsSubviewItemFragment.cloneNode(true)); + for (let [propertyName, selector] of [ + ["_downloadTypeIcon", ".toolbarbutton-icon"], + ["_downloadTarget", "label"], + ["_downloadStatus", ".status-full"], + ["_downloadButton", ".action-button"], + ]) { + this[propertyName] = this.element.querySelector(selector); + } + + for (let [label, selector] of [ + [kButtonLabels.open, ".status-open"], + [kButtonLabels.retry, ".status-retry"], + [kButtonLabels.show, ".status-show"], + ]) { + this.element.querySelector(selector).value = label; + } + } + + // DownloadElementShell + showDisplayNameAndIcon(displayName, icon) { + this._downloadTarget.value = displayName; + this._downloadTypeIcon.src = icon; + } + + // DownloadElementShell + showProgress() {} + + // DownloadElementShell + showStatus(status) { + this._downloadStatus.value = status; + this.element.tooltipText = status; + } + + // DownloadElementShell + showButton() {} + + // DownloadElementShell + hideButton() {} + + // DownloadElementShell + _updateState() { + // This view only show completed and failed downloads. + let state = DownloadsCommon.stateOfDownload(this.download); + let shouldDisplay = + state == DownloadsCommon.DOWNLOAD_FINISHED || + state == DownloadsCommon.DOWNLOAD_FAILED; + this.element.hidden = !shouldDisplay; + if (!shouldDisplay) { + return; + } + + super._updateState(); + + if (this.isCommandEnabled("downloadsCmd_show")) { + this.element.setAttribute("canShow", "true"); + this.element.removeAttribute("canRetry"); + } else if (this.isCommandEnabled("downloadsCmd_retry")) { + this.element.setAttribute("canRetry", "true"); + this.element.removeAttribute("canShow"); + } else { + this.element.removeAttribute("canRetry"); + this.element.removeAttribute("canShow"); + } + } + + // DownloadElementShell + _updateStateInner() { + if (!this.element.hidden) { + super._updateStateInner(); + } + } + + /** + * Command handler; copy the download URL to the OS general clipboard. + */ + downloadsCmd_copyLocation() { + DownloadsCommon.copyDownloadLink(this.download); + } +}; |