gecko-dev/browser/components/downloads/DownloadsSubview.jsm

609 строки
17 KiB
JavaScript

/* 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"
);
ChromeUtils.defineModuleGetter(
this,
"AppConstants",
"resource://gre/modules/AppConstants.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Downloads",
"resource://gre/modules/Downloads.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"DownloadsCommon",
"resource:///modules/DownloadsCommon.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"DownloadsViewUI",
"resource:///modules/DownloadsViewUI.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"FileUtils",
"resource://gre/modules/FileUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"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;
}
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")
);
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) {
// Middle clicks fall through and are regarded as left clicks.
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";
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 (item && item._shell.isCommandEnabled(command)) {
item._shell[command]();
}
}
}
/**
* 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);
}
};