summaryrefslogtreecommitdiffstats
path: root/browser/components/downloads/DownloadsSubview.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/downloads/DownloadsSubview.jsm631
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);
+ }
+};