summaryrefslogtreecommitdiffstats
path: root/browser/components/downloads/content/allDownloadsView.js
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/downloads/content/allDownloadsView.js')
-rw-r--r--browser/components/downloads/content/allDownloadsView.js949
1 files changed, 949 insertions, 0 deletions
diff --git a/browser/components/downloads/content/allDownloadsView.js b/browser/components/downloads/content/allDownloadsView.js
new file mode 100644
index 0000000000..9245127b0e
--- /dev/null
+++ b/browser/components/downloads/content/allDownloadsView.js
@@ -0,0 +1,949 @@
+/* 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/. */
+/* eslint-env mozilla/browser-window */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+/**
+ * A download element shell is responsible for handling the commands and the
+ * displayed data for a single download view element.
+ *
+ * The shell may contain a session download, a history download, or both. When
+ * both a history and a session download are present, the session download gets
+ * priority and its information is displayed.
+ *
+ * On construction, a new richlistitem is created, and can be accessed through
+ * the |element| getter. The shell doesn't insert the item in a richlistbox, the
+ * caller must do it and remove the element when it's no longer needed.
+ *
+ * The caller is also responsible for forwarding status notifications, calling
+ * the onChanged method.
+ *
+ * @param download
+ * The Download object from the DownloadHistoryList.
+ */
+function HistoryDownloadElementShell(download) {
+ this._download = download;
+
+ this.element = document.createXULElement("richlistitem");
+ this.element._shell = this;
+
+ this.element.classList.add("download");
+ this.element.classList.add("download-state");
+}
+
+HistoryDownloadElementShell.prototype = {
+ /**
+ * Overrides the base getter to return the Download or HistoryDownload object
+ * for displaying information and executing commands in the user interface.
+ */
+ get download() {
+ return this._download;
+ },
+
+ onStateChanged() {
+ // Since the state changed, we may need to check the target file again.
+ this._targetFileChecked = false;
+
+ this._updateState();
+
+ if (this.element.selected) {
+ goUpdateDownloadCommands();
+ } else {
+ // If a state change occurs in an item that is not currently selected,
+ // this is the only command that may be affected.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onChanged() {
+ // There is nothing to do if the item has always been invisible.
+ if (!this.active) {
+ return;
+ }
+
+ let newState = DownloadsCommon.stateOfDownload(this.download);
+ if (this._downloadState !== newState) {
+ this._downloadState = newState;
+ this.onStateChanged();
+ } else {
+ this._updateStateInner();
+ }
+ },
+ _downloadState: null,
+
+ isCommandEnabled(aCommand) {
+ // The only valid command for inactive elements is cmd_delete.
+ if (!this.active && aCommand != "cmd_delete") {
+ return false;
+ }
+ return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
+ this,
+ aCommand
+ );
+ },
+
+ downloadsCmd_unblock() {
+ this.confirmUnblock(window, "unblock");
+ },
+ downloadsCmd_unblockAndSave() {
+ this.confirmUnblock(window, "unblock");
+ },
+
+ downloadsCmd_chooseUnblock() {
+ this.confirmUnblock(window, "chooseUnblock");
+ },
+
+ downloadsCmd_chooseOpen() {
+ this.confirmUnblock(window, "chooseOpen");
+ },
+
+ // Returns whether or not the download handled by this shell should
+ // show up in the search results for the given term. Both the display
+ // name for the download and the url are searched.
+ matchesSearchTerm(aTerm) {
+ if (!aTerm) {
+ return true;
+ }
+ aTerm = aTerm.toLowerCase();
+ let displayName = DownloadsViewUI.getDisplayName(this.download);
+ return (
+ displayName.toLowerCase().includes(aTerm) ||
+ (this.download.source.originalUrl || this.download.source.url)
+ .toLowerCase()
+ .includes(aTerm)
+ );
+ },
+
+ // Handles double-click and return keypress on the element (the keypress
+ // listener is set in the DownloadsPlacesView object).
+ doDefaultCommand(event) {
+ let command = this.currentDefaultCommandName;
+ if (
+ command == "downloadsCmd_open" &&
+ event &&
+ (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 browserWin = BrowserWindowTracker.getTopWindow();
+ let openWhere = browserWin
+ ? browserWin.whereToOpenLink(event, false, true)
+ : "window";
+ if (["window", "tabshifted", "tab"].includes(openWhere)) {
+ command += ":" + openWhere;
+ }
+ }
+
+ if (command && this.isCommandEnabled(command)) {
+ this.doCommand(command);
+ }
+ },
+
+ /**
+ * This method is called by the outer download view, after the controller
+ * commands have already been updated. In case we did not check for the
+ * existence of the target file already, we can do it now and then update
+ * the commands as needed.
+ */
+ onSelect() {
+ if (!this.active) {
+ return;
+ }
+
+ // If this is a history download for which no target file information is
+ // available, we cannot retrieve information about the target file.
+ if (!this.download.target.path) {
+ return;
+ }
+
+ // Start checking for existence. This may be done twice if onSelect is
+ // called again before the information is collected.
+ if (!this._targetFileChecked) {
+ this.download
+ .refresh()
+ .catch(console.error)
+ .then(() => {
+ // Do not try to check for existence again even if this failed.
+ this._targetFileChecked = true;
+ });
+ }
+ },
+};
+Object.setPrototypeOf(
+ HistoryDownloadElementShell.prototype,
+ DownloadsViewUI.DownloadElementShell.prototype
+);
+
+/**
+ * Relays commands from the download.xml binding to the selected items.
+ */
+var DownloadsView = {
+ onDownloadButton(event) {
+ event.target.closest("richlistitem")._shell.onButton();
+ },
+
+ onDownloadClick() {},
+};
+
+/**
+ * A Downloads Places View is a places view designed to show a places query
+ * for history downloads alongside the session downloads.
+ *
+ * As we don't use the places controller, some methods implemented by other
+ * places views are not implemented by this view.
+ *
+ * A richlistitem in this view can represent either a past download or a session
+ * download, or both. Session downloads are shown first in the view, and as long
+ * as they exist they "collapses" their history "counterpart" (So we don't show two
+ * items for every download).
+ */
+function DownloadsPlacesView(
+ aRichListBox,
+ aActive = true,
+ aSuppressionFlag = DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN
+) {
+ this._richlistbox = aRichListBox;
+ this._richlistbox._placesView = this;
+ window.controllers.insertControllerAt(0, this);
+
+ // Map downloads to their element shells.
+ this._viewItemsForDownloads = new WeakMap();
+
+ this._searchTerm = "";
+
+ this._active = aActive;
+
+ // Register as a downloads view. The places data will be initialized by
+ // the places setter.
+ this._initiallySelectedElement = null;
+ this._downloadsData = DownloadsCommon.getData(window.opener || window, true);
+ this._waitingForInitialData = true;
+ this._downloadsData.addView(this);
+
+ // Pause the download indicator as user is interacting with downloads. This is
+ // skipped on about:downloads because it handles this by itself.
+ if (aSuppressionFlag === DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN) {
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ aSuppressionFlag;
+ }
+
+ // Make sure to unregister the view if the window is closed.
+ window.addEventListener(
+ "unload",
+ () => {
+ window.controllers.removeController(this);
+ // Unpause the main window's download indicator.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed &=
+ ~aSuppressionFlag;
+ this._downloadsData.removeView(this);
+ this.result = null;
+ },
+ true
+ );
+ // Resizing the window may change items visibility.
+ window.addEventListener(
+ "resize",
+ () => {
+ this._ensureVisibleElementsAreActive(true);
+ },
+ true
+ );
+}
+
+DownloadsPlacesView.prototype = {
+ get associatedElement() {
+ return this._richlistbox;
+ },
+
+ get active() {
+ return this._active;
+ },
+ set active(val) {
+ this._active = val;
+ if (this._active) {
+ this._ensureVisibleElementsAreActive(true);
+ }
+ },
+
+ /**
+ * Ensure the custom element contents are created and shown for each
+ * visible element in the list.
+ *
+ * @param debounce whether to use a short timeout rather than running
+ * immediately. The default is running immediately. If you
+ * pass `true`, we'll run on a 10ms timeout. This is used to
+ * avoid running this code lots while scrolling or resizing.
+ */
+ _ensureVisibleElementsAreActive(debounce = false) {
+ if (
+ !this.active ||
+ (debounce && this._ensureVisibleTimer) ||
+ !this._richlistbox.firstChild
+ ) {
+ return;
+ }
+
+ if (debounce) {
+ this._ensureVisibleTimer = setTimeout(() => {
+ this._internalEnsureVisibleElementsAreActive();
+ }, 10);
+ } else {
+ this._internalEnsureVisibleElementsAreActive();
+ }
+ },
+
+ _internalEnsureVisibleElementsAreActive() {
+ // If there are no children, we can't do anything so bail out.
+ // However, avoid clearing the timer because there may be children
+ // when the timer fires.
+ if (!this._richlistbox.firstChild) {
+ // If we were called asynchronously (debounced), we need to delete
+ // the timer variable to ensure we are called again if another
+ // debounced call comes in.
+ delete this._ensureVisibleTimer;
+ return;
+ }
+
+ if (this._ensureVisibleTimer) {
+ clearTimeout(this._ensureVisibleTimer);
+ delete this._ensureVisibleTimer;
+ }
+
+ let rlbRect = this._richlistbox.getBoundingClientRect();
+ let winUtils = window.windowUtils;
+ let nodes = winUtils.nodesFromRect(
+ rlbRect.left,
+ rlbRect.top,
+ 0,
+ rlbRect.width,
+ rlbRect.height,
+ 0,
+ true,
+ false,
+ false
+ );
+ // nodesFromRect returns nodes in z-index order, and for the same z-index
+ // sorts them in inverted DOM order, thus starting from the one that would
+ // be on top.
+ let firstVisibleNode, lastVisibleNode;
+ for (let node of nodes) {
+ if (node.localName === "richlistitem" && node._shell) {
+ node._shell.ensureActive();
+ // The first visible node is the last match.
+ firstVisibleNode = node;
+ // While the last visible node is the first match.
+ if (!lastVisibleNode) {
+ lastVisibleNode = node;
+ }
+ }
+ }
+
+ // Also activate the first invisible nodes in both boundaries (that is,
+ // above and below the visible area) to ensure proper keyboard navigation
+ // in both directions.
+ let nodeBelowVisibleArea = lastVisibleNode && lastVisibleNode.nextSibling;
+ if (nodeBelowVisibleArea && nodeBelowVisibleArea._shell) {
+ nodeBelowVisibleArea._shell.ensureActive();
+ }
+
+ let nodeAboveVisibleArea =
+ firstVisibleNode && firstVisibleNode.previousSibling;
+ if (nodeAboveVisibleArea && nodeAboveVisibleArea._shell) {
+ nodeAboveVisibleArea._shell.ensureActive();
+ }
+ },
+
+ _place: "",
+ get place() {
+ return this._place;
+ },
+ set place(val) {
+ if (this._place == val) {
+ // XXXmano: places.js relies on this behavior (see Bug 822203).
+ this.searchTerm = "";
+ } else {
+ this._place = val;
+ }
+ },
+
+ get selectedNodes() {
+ return Array.prototype.filter.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.download.placesNode
+ );
+ },
+
+ get selectedNode() {
+ let selectedNodes = this.selectedNodes;
+ return selectedNodes.length == 1 ? selectedNodes[0] : null;
+ },
+
+ get hasSelection() {
+ return !!this.selectedNodes.length;
+ },
+
+ get controller() {
+ return this._richlistbox.controller;
+ },
+
+ get searchTerm() {
+ return this._searchTerm;
+ },
+ set searchTerm(aValue) {
+ if (this._searchTerm != aValue) {
+ // Always clear selection on a new search, since the user is starting a
+ // different workflow. This also solves the fact we could end up
+ // retaining selection on hidden elements.
+ this._richlistbox.clearSelection();
+ for (let element of this._richlistbox.childNodes) {
+ element.hidden = !element._shell.matchesSearchTerm(aValue);
+ }
+ this._ensureVisibleElementsAreActive();
+ }
+ this._searchTerm = aValue;
+ },
+
+ /**
+ * When the view loads, we want to select the first item.
+ * However, because session downloads, for which the data is loaded
+ * asynchronously, always come first in the list, and because the list
+ * may (or may not) already contain history downloads at that point, it
+ * turns out that by the time we can select the first item, the user may
+ * have already started using the view.
+ * To make things even more complicated, in other cases, the places data
+ * may be loaded after the session downloads data. Thus we cannot rely on
+ * the order in which the data comes in.
+ * We work around this by attempting to select the first element twice,
+ * once after the places data is loaded and once when the session downloads
+ * data is done loading. However, if the selection has changed in-between,
+ * we assume the user has already started using the view and give up.
+ */
+ _ensureInitialSelection() {
+ // Either they're both null, or the selection has not changed in between.
+ if (this._richlistbox.selectedItem == this._initiallySelectedElement) {
+ let firstDownloadElement = this._richlistbox.firstChild;
+ if (firstDownloadElement != this._initiallySelectedElement) {
+ // We may be called before _ensureVisibleElementsAreActive,
+ // therefore, ensure the first item is activated.
+ firstDownloadElement._shell.ensureActive();
+ this._richlistbox.selectedItem = firstDownloadElement;
+ this._richlistbox.currentItem = firstDownloadElement;
+ this._initiallySelectedElement = firstDownloadElement;
+ }
+ }
+ },
+
+ /**
+ * DocumentFragment object that contains all the new elements added during a
+ * batch operation, or null if no batch is in progress.
+ *
+ * Since newest downloads are displayed at the top, elements are normally
+ * prepended to the fragment, and then the fragment is prepended to the list.
+ */
+ batchFragment: null,
+
+ onDownloadBatchStarting() {
+ this.batchFragment = document.createDocumentFragment();
+
+ this.oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ },
+
+ onDownloadBatchEnded() {
+ this._richlistbox.suppressOnSelect = this.oldSuppressOnSelect;
+ delete this.oldSuppressOnSelect;
+
+ if (this.batchFragment.childElementCount) {
+ this._prependBatchFragment();
+ }
+ this.batchFragment = null;
+
+ this._ensureInitialSelection();
+ this._ensureVisibleElementsAreActive();
+ goUpdateDownloadCommands();
+ if (this._waitingForInitialData) {
+ this._waitingForInitialData = false;
+ this._richlistbox.dispatchEvent(
+ new CustomEvent("InitialDownloadsLoaded")
+ );
+ }
+ },
+
+ _prependBatchFragment() {
+ // Workaround multiple reflows hang by removing the richlistbox
+ // and adding it back when we're done.
+
+ // Hack for bug 836283: reset xbl fields to their old values after the
+ // binding is reattached to avoid breaking the selection state
+ let xblFields = new Map();
+ for (let key of Object.getOwnPropertyNames(this._richlistbox)) {
+ let value = this._richlistbox[key];
+ xblFields.set(key, value);
+ }
+
+ let oldActiveElement = document.activeElement;
+ let parentNode = this._richlistbox.parentNode;
+ let nextSibling = this._richlistbox.nextSibling;
+ parentNode.removeChild(this._richlistbox);
+ this._richlistbox.prepend(this.batchFragment);
+ parentNode.insertBefore(this._richlistbox, nextSibling);
+ if (oldActiveElement && oldActiveElement != document.activeElement) {
+ oldActiveElement.focus();
+ }
+
+ for (let [key, value] of xblFields) {
+ this._richlistbox[key] = value;
+ }
+ },
+
+ onDownloadAdded(download, { insertBefore } = {}) {
+ let shell = new HistoryDownloadElementShell(download);
+ 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", shell.element);
+ } else {
+ (this.batchFragment || this._richlistbox).prepend(shell.element);
+ }
+
+ if (this.searchTerm) {
+ shell.element.hidden = !shell.matchesSearchTerm(this.searchTerm);
+ }
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onDownloadChanged(download) {
+ this._viewItemsForDownloads.get(download).onChanged();
+ },
+
+ onDownloadRemoved(download) {
+ let element = this._viewItemsForDownloads.get(download).element;
+
+ // If the element was selected exclusively, select its next
+ // sibling first, if not, try for previous sibling, if any.
+ if (
+ (element.nextSibling || element.previousSibling) &&
+ this._richlistbox.selectedItems &&
+ this._richlistbox.selectedItems.length == 1 &&
+ this._richlistbox.selectedItems[0] == element
+ ) {
+ this._richlistbox.selectItem(
+ element.nextSibling || element.previousSibling
+ );
+ }
+
+ this._richlistbox.removeItemFromSelection(element);
+ element.remove();
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ // nsIController
+ supportsCommand(aCommand) {
+ // Firstly, determine if this is a command that we can handle.
+ if (!DownloadsViewUI.isCommandName(aCommand)) {
+ return false;
+ }
+ if (
+ !(aCommand in this) &&
+ !(aCommand in HistoryDownloadElementShell.prototype)
+ ) {
+ return false;
+ }
+ // If this function returns true, other controllers won't get a chance to
+ // process the command even if isCommandEnabled returns false, so it's
+ // important to check if the list is focused here to handle common commands
+ // like copy and paste correctly. The clear downloads command, instead, is
+ // specific to the downloads list but can be invoked from the toolbar, so we
+ // can just return true unconditionally.
+ return (
+ aCommand == "downloadsCmd_clearDownloads" ||
+ document.activeElement == this._richlistbox
+ );
+ },
+
+ // nsIController
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "cmd_copy":
+ return Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ element => {
+ const { source } = element._shell.download;
+ return !!(source?.originalUrl || source?.url);
+ }
+ );
+ case "downloadsCmd_openReferrer":
+ case "downloadShowMenuItem":
+ return this._richlistbox.selectedItems.length == 1;
+ case "cmd_selectAll":
+ return true;
+ case "cmd_paste":
+ return this._canDownloadClipboardURL();
+ case "downloadsCmd_clearDownloads":
+ return this.canClearDownloads(this._richlistbox);
+ default:
+ return Array.prototype.every.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.isCommandEnabled(aCommand)
+ );
+ }
+ },
+
+ _copySelectedDownloadsToClipboard() {
+ let urls = Array.from(this._richlistbox.selectedItems, element => {
+ const { source } = element._shell.download;
+ return source?.originalUrl || source?.url;
+ }).filter(Boolean);
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(urls.join("\n"));
+ },
+
+ _getURLFromClipboardData() {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+
+ let flavors = ["text/x-moz-url", "text/plain"];
+ 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 [NetUtil.newURI(url).spec, name];
+ }
+ } catch (ex) {}
+
+ return ["", ""];
+ },
+
+ _canDownloadClipboardURL() {
+ let [url /* ,name */] = this._getURLFromClipboardData();
+ return url != "";
+ },
+
+ _downloadURLFromClipboard() {
+ let [url, name] = this._getURLFromClipboardData();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ DownloadURL(url, name, initiatingDoc);
+ },
+
+ // nsIController
+ doCommand(aCommand) {
+ // Commands may be invoked with keyboard shortcuts even if disabled.
+ if (!this.isCommandEnabled(aCommand)) {
+ return;
+ }
+
+ // If this command is not selection-specific, execute it.
+ if (aCommand in this) {
+ this[aCommand]();
+ return;
+ }
+
+ // Cloning the nodelist into an array to get a frozen list of selected items.
+ // Otherwise, the selectedItems nodelist is live and doCommand may alter the
+ // selection while we are trying to do one particular action, like removing
+ // items from history.
+ let selectedElements = [...this._richlistbox.selectedItems];
+ for (let element of selectedElements) {
+ element._shell.doCommand(aCommand);
+ }
+ },
+
+ // nsIController
+ onEvent() {},
+
+ cmd_copy() {
+ this._copySelectedDownloadsToClipboard();
+ },
+
+ cmd_selectAll() {
+ if (!this.searchTerm) {
+ this._richlistbox.selectAll();
+ return;
+ }
+ // If there is a filtering search term, some rows are hidden and should not
+ // be selected.
+ let oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ this._richlistbox.clearSelection();
+ var item = this._richlistbox.getItemAtIndex(0);
+ while (item) {
+ if (!item.hidden) {
+ this._richlistbox.addItemToSelection(item);
+ }
+ item = this._richlistbox.getNextItem(item, 1);
+ }
+ this._richlistbox.suppressOnSelect = oldSuppressOnSelect;
+ },
+
+ cmd_paste() {
+ this._downloadURLFromClipboard();
+ },
+
+ downloadsCmd_clearDownloads() {
+ this._downloadsData.removeFinished();
+ if (this._place) {
+ PlacesUtils.history
+ .removeVisitsByFilter({
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ })
+ .catch(console.error);
+ }
+ // There may be no selection or focus change as a result
+ // of these change, and we want the command updated immediately.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ },
+
+ onContextMenu(aEvent) {
+ let element = this._richlistbox.selectedItem;
+ if (!element || !element._shell) {
+ return false;
+ }
+
+ let contextMenu = document.getElementById("downloadsContextMenu");
+ DownloadsViewUI.updateContextMenuForElement(contextMenu, element);
+ // Hide the copy location item if there is somehow no URL. We have to do
+ // this here instead of in DownloadsViewUI because DownloadsView doesn't
+ // allow selecting multiple downloads, so in that view the menuitem will be
+ // shown according to whether just the selected item has a source URL.
+ contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden =
+ !Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ el => !!el._shell.download.source?.url
+ );
+
+ let download = element._shell.download;
+ if (!download.stopped) {
+ // The hasPartialData property of a download may change at any time after
+ // it has started, so ensure we update the related command now.
+ goUpdateCommand("downloadsCmd_pauseResume");
+ }
+
+ return true;
+ },
+
+ onKeyPress(aEvent) {
+ let selectedElements = this._richlistbox.selectedItems;
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // In the content tree, opening bookmarks by pressing return is only
+ // supported when a single item is selected. To be consistent, do the
+ // same here.
+ if (selectedElements.length == 1) {
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ }
+ } else if (aEvent.charCode == " ".charCodeAt(0)) {
+ let atLeastOneDownloadToggled = false;
+ // Pause/Resume every selected download
+ for (let element of selectedElements) {
+ if (element._shell.isCommandEnabled("downloadsCmd_pauseResume")) {
+ element._shell.doCommand("downloadsCmd_pauseResume");
+ atLeastOneDownloadToggled = true;
+ }
+ }
+
+ if (atLeastOneDownloadToggled) {
+ aEvent.preventDefault();
+ }
+ }
+ },
+
+ onDoubleClick(aEvent) {
+ if (aEvent.button != 0) {
+ return;
+ }
+
+ let selectedElements = this._richlistbox.selectedItems;
+ if (selectedElements.length != 1) {
+ return;
+ }
+
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ },
+
+ onScroll() {
+ this._ensureVisibleElementsAreActive(true);
+ },
+
+ onSelect() {
+ goUpdateDownloadCommands();
+
+ let selectedElements = this._richlistbox.selectedItems;
+ for (let elt of selectedElements) {
+ if (elt._shell) {
+ elt._shell.onSelect();
+ }
+ }
+ },
+
+ onDragStart(aEvent) {
+ // TODO Bug 831358: Support d&d for multiple selection.
+ // For now, we just drag the first element.
+ let selectedItem = this._richlistbox.selectedItem;
+ if (!selectedItem) {
+ return;
+ }
+
+ let targetPath = selectedItem._shell.download.target.path;
+ if (!targetPath) {
+ return;
+ }
+
+ // We must check for existence synchronously because this is a DOM event.
+ let file = new FileUtils.File(targetPath);
+ if (!file.exists()) {
+ return;
+ }
+
+ let dt = aEvent.dataTransfer;
+ dt.mozSetDataAt("application/x-moz-file", file, 0);
+ let url = Services.io.newFileURI(file).spec;
+ dt.setData("text/uri-list", url);
+ dt.setData("text/plain", url);
+ dt.effectAllowed = "copyMove";
+ dt.addElement(selectedItem);
+ },
+
+ onDragOver(aEvent) {
+ let types = aEvent.dataTransfer.types;
+ if (
+ types.includes("text/uri-list") ||
+ types.includes("text/x-moz-url") ||
+ types.includes("text/plain")
+ ) {
+ aEvent.preventDefault();
+ }
+ },
+
+ onDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ // If dragged item is from our source, do not try to
+ // redownload already downloaded file.
+ if (dt.mozGetDataAt("application/x-moz-file", 0)) {
+ return;
+ }
+
+ let links = Services.droppedLinkHandler.dropLinks(aEvent);
+ if (!links.length) {
+ return;
+ }
+ aEvent.preventDefault();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ for (let link of links) {
+ if (link.url.startsWith("about:")) {
+ continue;
+ }
+ DownloadURL(link.url, link.name, initiatingDoc);
+ }
+ },
+};
+Object.setPrototypeOf(
+ DownloadsPlacesView.prototype,
+ DownloadsViewUI.BaseView.prototype
+);
+
+for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) {
+ DownloadsPlacesView.prototype[methodName] = function () {
+ throw new Error(
+ "|" + methodName + "| is not implemented by the downloads view."
+ );
+ };
+}
+
+function goUpdateDownloadCommands() {
+ function updateCommandsForObject(object) {
+ for (let name in object) {
+ if (DownloadsViewUI.isCommandName(name)) {
+ goUpdateCommand(name);
+ }
+ }
+ }
+ updateCommandsForObject(DownloadsPlacesView.prototype);
+ updateCommandsForObject(HistoryDownloadElementShell.prototype);
+}
+
+document.addEventListener("DOMContentLoaded", function () {
+ let richListBox = document.getElementById("downloadsListBox");
+ richListBox.addEventListener("scroll", function (event) {
+ return this._placesView.onScroll();
+ });
+ richListBox.addEventListener("keypress", function (event) {
+ return this._placesView.onKeyPress(event);
+ });
+ richListBox.addEventListener("dblclick", function (event) {
+ return this._placesView.onDoubleClick(event);
+ });
+ richListBox.addEventListener("contextmenu", function (event) {
+ return this._placesView.onContextMenu(event);
+ });
+ richListBox.addEventListener("dragstart", function (event) {
+ this._placesView.onDragStart(event);
+ });
+ let dropNode = richListBox;
+ // In about:downloads, also allow drops if the list is empty, by
+ // adding the listener to the document, as the richlistbox is
+ // hidden when it is empty.
+ if (document.documentElement.id == "contentAreaDownloadsView") {
+ dropNode = richListBox.parentNode;
+ }
+ dropNode.addEventListener("dragover", function (event) {
+ richListBox._placesView.onDragOver(event);
+ });
+ dropNode.addEventListener("drop", function (event) {
+ richListBox._placesView.onDrop(event);
+ });
+ richListBox.addEventListener("select", function (event) {
+ this._placesView.onSelect();
+ });
+ richListBox.addEventListener("focus", goUpdateDownloadCommands);
+ richListBox.addEventListener("blur", goUpdateDownloadCommands);
+});