diff options
Diffstat (limited to 'browser/components/places')
194 files changed, 38105 insertions, 0 deletions
diff --git a/browser/components/places/.eslintrc.js b/browser/components/places/.eslintrc.js new file mode 100644 index 0000000000..9aafb4a214 --- /dev/null +++ b/browser/components/places/.eslintrc.js @@ -0,0 +1,9 @@ +/* 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"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], +}; diff --git a/browser/components/places/Interactions.sys.mjs b/browser/components/places/Interactions.sys.mjs new file mode 100644 index 0000000000..f7128c3efd --- /dev/null +++ b/browser/components/places/Interactions.sys.mjs @@ -0,0 +1,751 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logConsole", function () { + return console.createInstance({ + prefix: "InteractionsManager", + maxLogLevel: Services.prefs.getBoolPref( + "browser.places.interactions.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"], +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "pageViewIdleTime", + "browser.places.interactions.pageViewIdleTime", + 60 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "saveInterval", + "browser.places.interactions.saveInterval", + 10000 +); + +const DOMWINDOW_OPENED_TOPIC = "domwindowopened"; + +/** + * Returns a monotonically increasing timestamp, that is critical to distinguish + * database entries by creation time. + */ +let gLastTime = 0; +function monotonicNow() { + let time = Date.now(); + if (time == gLastTime) { + time++; + } + return (gLastTime = time); +} + +/** + * @typedef {object} DocumentInfo + * DocumentInfo is used to pass document information from the child process + * to _Interactions. + * @property {boolean} isActive + * Set to true if the document is active, i.e. visible. + * @property {string} url + * The url of the page that was interacted with. + */ + +/** + * @typedef {object} InteractionInfo + * InteractionInfo is used to store information associated with interactions. + * @property {number} totalViewTime + * Time in milliseconds that the page has been actively viewed for. + * @property {string} url + * The url of the page that was interacted with. + * @property {Interactions.DOCUMENT_TYPE} documentType + * The type of the document. + * @property {number} typingTime + * Time in milliseconds that the user typed on the page + * @property {number} keypresses + * The number of keypresses made on the page + * @property {number} scrollingTime + * Time in milliseconds that the user spent scrolling the page + * @property {number} scrollingDistance + * The distance, in pixels, that the user scrolled the page + * @property {number} created_at + * Creation time as the number of milliseconds since the epoch. + * @property {number} updated_at + * Last updated time as the number of milliseconds since the epoch. + * @property {string} referrer + * The referrer to the url of the page that was interacted with (may be empty) + * + */ + +/** + * The Interactions object sets up listeners and other approriate tools for + * obtaining interaction information and passing it to the InteractionsManager. + */ +class _Interactions { + DOCUMENT_TYPE = { + // Used when the document type is unknown. + GENERIC: 0, + // Used for pages serving media, e.g. videos. + MEDIA: 1, + }; + + /** + * This is used to store potential interactions. It maps the browser + * to the current interaction information. + * The current interaction is updated to the database when it transitions + * to non-active, which occurs before a browser tab is closed, hence this + * can be a weak map. + * + * @type {WeakMap<browser, InteractionInfo>} + */ + #interactions = new WeakMap(); + + /** + * Tracks the currently active window so that we can avoid recording + * interactions in non-active windows. + * + * @type {DOMWindow} + */ + #activeWindow = undefined; + + /** + * Tracks if the user is idle. + * + * @type {boolean} + */ + #userIsIdle = false; + + /** + * This stores the page view start time of the current page view. + * For any single page view, this may be moved multiple times as the + * associated interaction is updated for the current total page view time. + * + * @type {number} + */ + _pageViewStartTime = Cu.now(); + + /** + * Stores interactions in the database, see the {@link InteractionsStore} + * class. This is created lazily, see the `store` getter. + * + * @type {InteractionsStore | undefined} + */ + #store = undefined; + + /** + * Whether the component has been initialized. + */ + #initialized = false; + + /** + * Initializes, sets up actors and observers. + */ + init() { + if ( + !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) + ) { + return; + } + + ChromeUtils.registerWindowActor("Interactions", { + parent: { + esModuleURI: "resource:///actors/InteractionsParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/InteractionsChild.sys.mjs", + events: { + DOMContentLoaded: {}, + pagehide: { mozSystemGroup: true }, + }, + }, + messageManagerGroups: ["browsers"], + }); + + this.#activeWindow = Services.wm.getMostRecentBrowserWindow(); + + for (let win of lazy.BrowserWindowTracker.orderedWindows) { + if (!win.closed) { + this.#registerWindow(win); + } + } + Services.obs.addObserver(this, DOMWINDOW_OPENED_TOPIC, true); + lazy.idleService.addIdleObserver(this, lazy.pageViewIdleTime); + this.#initialized = true; + } + + /** + * Uninitializes, removes any observers that need cleaning up manually. + */ + uninit() { + if (this.#initialized) { + lazy.idleService.removeIdleObserver(this, lazy.pageViewIdleTime); + } + } + + /** + * Resets any stored user or interaction state. + * Used by tests. + */ + async reset() { + lazy.logConsole.debug("Database reset"); + this.#interactions = new WeakMap(); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + ChromeUtils.consumeInteractionData(); + await _Interactions.interactionUpdatePromise; + await this.store.reset(); + } + + /** + * Retrieve the underlying InteractionsStore object. This exists for testing + * purposes and should not be abused by production code (for example it'd be + * a bad idea to force flushes). + * + * @returns {InteractionsStore} + */ + get store() { + if (!this.#store) { + this.#store = new InteractionsStore(); + } + return this.#store; + } + + /** + * Registers the start of a new interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + * @param {DocumentInfo} docInfo + * The document information of the page associated with the interaction. + */ + registerNewInteraction(browser, docInfo) { + if (!browser) { + // The browser may have already gone away. + return; + } + let interaction = this.#interactions.get(browser); + if (interaction && interaction.url != docInfo.url) { + this.registerEndOfInteraction(browser); + } + + if (lazy.InteractionsBlocklist.isUrlBlocklisted(docInfo.url)) { + lazy.logConsole.debug( + "Ignoring a page as the URL is blocklisted", + docInfo + ); + return; + } + + lazy.logConsole.debug("Tracking a new interaction", docInfo); + let now = monotonicNow(); + interaction = { + url: docInfo.url, + referrer: docInfo.referrer, + totalViewTime: 0, + typingTime: 0, + keypresses: 0, + scrollingTime: 0, + scrollingDistance: 0, + created_at: now, + updated_at: now, + }; + this.#interactions.set(browser, interaction); + + // Only reset the time if this is being loaded in the active tab of the + // active window. + if (docInfo.isActive && browser.ownerGlobal == this.#activeWindow) { + this._pageViewStartTime = Cu.now(); + } + } + + /** + * Registers the end of an interaction, e.g. if the user navigates away + * from the page. This will store the final interaction details and clear + * the current interaction. + * + * @param {Browser} browser + * The browser object associated with the interaction. + */ + registerEndOfInteraction(browser) { + // Not having a browser passed to us probably means the tab has gone away + // before we received the notification - due to the tab being a background + // tab. Since that will be a non-active tab, it is acceptable that we don't + // update the interaction. When switching away from active tabs, a TabSelect + // notification is generated which we handle elsewhere. + if (!browser) { + return; + } + lazy.logConsole.debug("Saw the end of an interaction"); + + this.#updateInteraction(browser); + this.#interactions.delete(browser); + } + + /** + * Updates the current interaction + * + * @param {Browser} [browser] + * The browser object that has triggered the update, if known. This is + * used to check if the browser is in the active window, and as an + * optimization to avoid obtaining the browser object. + */ + #updateInteraction(browser = undefined) { + _Interactions.#updateInteraction_async( + browser, + this.#activeWindow, + this.#userIsIdle, + this.#interactions, + this._pageViewStartTime, + this.store + ); + } + + /** + * Stores the promise created in updateInteraction_async so that we can await its fulfillment + * when sychronization is needed. + */ + static interactionUpdatePromise = Promise.resolve(); + + /** + * Returns the interactions update promise to be used when sychronization is needed from tests. + * + * @returns {Promise<void>} + */ + get interactionUpdatePromise() { + return _Interactions.interactionUpdatePromise; + } + + /** + * Updates the current interaction on fulfillment of the asynchronous collection of scrolling interactions. + * + * @param {Browser} browser + * The browser object that has triggered the update, if known. + * @param {DOMWindow} activeWindow + * The active window. + * @param {boolean} userIsIdle + * Whether the user is idle. + * @param {WeakMap<Browser, InteractionInfo>} interactions + * A map of interactions for each browser instance + * @param {number} pageViewStartTime + * The time the page was loaded. + * @param {InteractionsStore} store + * The interactions store. + */ + static async #updateInteraction_async( + browser, + activeWindow, + userIsIdle, + interactions, + pageViewStartTime, + store + ) { + if (!activeWindow || (browser && browser.ownerGlobal != activeWindow)) { + lazy.logConsole.debug( + "Not updating interaction as there is no active window" + ); + return; + } + + // We do not update the interaction when the user is idle, since we will + // have already updated it when idle was signalled. + // Sometimes an interaction may be signalled before idle is cleared, however + // worst case we'd only loose approx 2 seconds of interaction detail. + if (userIsIdle) { + lazy.logConsole.debug("Not updating interaction as the user is idle"); + return; + } + + if (!browser) { + browser = activeWindow.gBrowser.selectedTab.linkedBrowser; + } + + let interaction = interactions.get(browser); + if (!interaction) { + lazy.logConsole.debug("No interaction to update"); + return; + } + + interaction.totalViewTime += Cu.now() - pageViewStartTime; + Interactions._pageViewStartTime = Cu.now(); + + const interactionData = ChromeUtils.consumeInteractionData(); + const typing = interactionData.Typing; + if (typing) { + interaction.typingTime += typing.interactionTimeInMilliseconds; + interaction.keypresses += typing.interactionCount; + } + + // Collect the scrolling data and add the interaction to the store on completion + _Interactions.interactionUpdatePromise = + _Interactions.interactionUpdatePromise + .then(async () => ChromeUtils.collectScrollingData()) + .then( + result => { + interaction.scrollingTime += result.interactionTimeInMilliseconds; + interaction.scrollingDistance += result.scrollingDistanceInPixels; + }, + reason => { + console.error(reason); + } + ) + .then(() => { + interaction.updated_at = monotonicNow(); + + lazy.logConsole.debug("Add to store: ", interaction); + store.add(interaction); + }); + } + + /** + * Handles a window becoming active. + * + * @param {DOMWindow} win + * The window that has become active. + */ + #onActivateWindow(win) { + lazy.logConsole.debug("Window activated"); + + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + this.#activeWindow = win; + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles a window going inactive. + * + * @param {DOMWindow} win + * The window that is going inactive. + */ + #onDeactivateWindow(win) { + lazy.logConsole.debug("Window deactivate"); + + this.#updateInteraction(); + this.#activeWindow = undefined; + } + + /** + * Handles the TabSelect notification. Updates the current interaction and + * then switches it to the interaction for the new tab. The new interaction + * may be null if it doesn't exist. + * + * @param {Browser} previousBrowser + * The instance of the browser that the user switched away from. + */ + #onTabSelect(previousBrowser) { + lazy.logConsole.debug("Tab switched"); + + this.#updateInteraction(previousBrowser); + this._pageViewStartTime = Cu.now(); + } + + /** + * Handles various events and forwards them to appropriate functions. + * + * @param {DOMEvent} event + * The event that will be handled + */ + handleEvent(event) { + switch (event.type) { + case "TabSelect": + this.#onTabSelect(event.detail.previousTab.linkedBrowser); + break; + case "activate": + this.#onActivateWindow(event.target); + break; + case "deactivate": + this.#onDeactivateWindow(event.target); + break; + case "unload": + this.#unregisterWindow(event.target); + break; + } + } + + /** + * Handles notifications from the observer service. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + observe(subject, topic, data) { + switch (topic) { + case DOMWINDOW_OPENED_TOPIC: + this.#onWindowOpen(subject); + break; + case "idle": + lazy.logConsole.debug("User went idle"); + // We save the state of the current interaction when we are notified + // that the user is idle. + this.#updateInteraction(); + this.#userIsIdle = true; + break; + case "active": + lazy.logConsole.debug("User became active"); + this.#userIsIdle = false; + this._pageViewStartTime = Cu.now(); + break; + } + } + + /** + * Handles registration of listeners in a new window. + * + * @param {DOMWindow} win + * The window to register in. + */ + #registerWindow(win) { + if (lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return; + } + + win.addEventListener("TabSelect", this, true); + win.addEventListener("deactivate", this, true); + win.addEventListener("activate", this, true); + } + + /** + * Handles removing of listeners from a window. + * + * @param {DOMWindow} win + * The window to remove listeners from. + */ + #unregisterWindow(win) { + win.removeEventListener("TabSelect", this, true); + win.removeEventListener("deactivate", this, true); + win.removeEventListener("activate", this, true); + } + + /** + * Handles a new window being opened, waits for load and checks that + * it is a browser window, then adds listeners. + * + * @param {DOMWindow} win + * The window being opened. + */ + #onWindowOpen(win) { + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + this.#registerWindow(win); + }, + { once: true } + ); + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); +} + +export const Interactions = new _Interactions(); + +/** + * Store interactions data in the Places database. + * To improve performance the writes are buffered every `saveInterval` + * milliseconds. Even if this means we could be trying to write interaction for + * pages that in the meanwhile have been removed, that's not a problem because + * we won't be able to insert entries having a NULL place_id, they will just be + * ignored. + * Use .add(interaction) to request storing of an interaction. + * Use .pendingPromise to await for any pending writes to have happened. + */ +class InteractionsStore { + /** + * Timer to run database updates on. + */ + #timer = undefined; + /** + * Tracks interactions replicating the unique index in the underlying schema. + * Interactions are keyed by url and then created_at. + * + * @type {Map<string, Map<number, InteractionInfo>>} + */ + #interactions = new Map(); + /** + * Used to unblock the queue of promises when the timer is cleared. + */ + #timerResolve = undefined; + + constructor() { + // Block async shutdown to ensure the last write goes through. + this.progress = {}; + lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker( + "Interactions.jsm:: store", + async () => this.flush(), + { fetchState: () => this.progress } + ); + + // Can be used to wait for the last pending write to have happened. + this.pendingPromise = Promise.resolve(); + } + + /** + * Synchronizes the pending interactions with the storage device. + * + * @returns {Promise} resolved when the pending data is on disk. + */ + async flush() { + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timerResolve(); + await this.#updateDatabase(); + } + } + + /** + * Completely clears the store and any pending writes. + * This exists for testing purposes. + */ + async reset() { + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::reset", + async db => { + await db.executeCached(`DELETE FROM moz_places_metadata`); + } + ); + if (this.#timer) { + lazy.clearTimeout(this.#timer); + this.#timer = undefined; + this.#timerResolve(); + this.#interactions.clear(); + } + } + + /** + * Registers an interaction to be stored persistently. At the end of the call + * the interaction has not yet been added to the store, tests can await + * flushStore() for that. + * + * @param {InteractionInfo} interaction + * The document information to write. + */ + add(interaction) { + lazy.logConsole.debug("Preparing interaction for storage", interaction); + + let interactionsForUrl = this.#interactions.get(interaction.url); + if (!interactionsForUrl) { + interactionsForUrl = new Map(); + this.#interactions.set(interaction.url, interactionsForUrl); + } + interactionsForUrl.set(interaction.created_at, interaction); + + if (!this.#timer) { + let promise = new Promise(resolve => { + this.#timerResolve = resolve; + this.#timer = lazy.setTimeout(() => { + this.#updateDatabase().catch(console.error).then(resolve); + }, lazy.saveInterval); + }); + this.pendingPromise = this.pendingPromise.then(() => promise); + } + } + + async #updateDatabase() { + this.#timer = undefined; + + // Reset the buffer. + let interactions = this.#interactions; + if (!interactions.size) { + return; + } + // Don't clear() this, since that would also clear interactions. + this.#interactions = new Map(); + + let params = {}; + let SQLInsertFragments = []; + let i = 0; + for (let interactionsForUrl of interactions.values()) { + for (let interaction of interactionsForUrl.values()) { + params[`url${i}`] = interaction.url; + params[`referrer${i}`] = interaction.referrer; + params[`created_at${i}`] = interaction.created_at; + params[`updated_at${i}`] = interaction.updated_at; + params[`document_type${i}`] = + interaction.documentType ?? Interactions.DOCUMENT_TYPE.GENERIC; + params[`total_view_time${i}`] = + Math.round(interaction.totalViewTime) || 0; + params[`typing_time${i}`] = Math.round(interaction.typingTime) || 0; + params[`key_presses${i}`] = interaction.keypresses || 0; + params[`scrolling_time${i}`] = + Math.round(interaction.scrollingTime) || 0; + params[`scrolling_distance${i}`] = + Math.round(interaction.scrollingDistance) || 0; + SQLInsertFragments.push(`( + (SELECT id FROM moz_places_metadata + WHERE place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}) + AND created_at = :created_at${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:url${i}) AND url = :url${i}), + (SELECT id FROM moz_places WHERE url_hash = hash(:referrer${i}) AND url = :referrer${i} AND :referrer${i} != :url${i}), + :created_at${i}, + :updated_at${i}, + :document_type${i}, + :total_view_time${i}, + :typing_time${i}, + :key_presses${i}, + :scrolling_time${i}, + :scrolling_distance${i} + )`); + i++; + } + } + + lazy.logConsole.debug(`Storing ${i} entries in the database`); + + this.progress.pendingUpdates = i; + await lazy.PlacesUtils.withConnectionWrapper( + "Interactions.jsm::updateDatabase", + async db => { + await db.executeCached( + ` + WITH inserts (id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance) AS ( + VALUES ${SQLInsertFragments.join(", ")} + ) + INSERT OR REPLACE INTO moz_places_metadata ( + id, place_id, referrer_place_id, created_at, updated_at, document_type, total_view_time, typing_time, key_presses, scrolling_time, scrolling_distance + ) SELECT * FROM inserts WHERE place_id NOT NULL; + `, + params + ); + } + ); + this.progress.pendingUpdates = 0; + + Services.obs.notifyObservers(null, "places-metadata-updated"); + } +} diff --git a/browser/components/places/InteractionsBlocklist.sys.mjs b/browser/components/places/InteractionsBlocklist.sys.mjs new file mode 100644 index 0000000000..c29e8beb77 --- /dev/null +++ b/browser/components/places/InteractionsBlocklist.sys.mjs @@ -0,0 +1,286 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logConsole", function () { + return console.createInstance({ + prefix: "InteractionsBlocklist", + maxLogLevel: Services.prefs.getBoolPref( + "browser.places.interactions.log", + false + ) + ? "Debug" + : "Warn", + }); +}); + +// A blocklist of regular expressions. Maps base hostnames to a list regular +// expressions for URLs with that base hostname. In this context, "base +// hostname" means the hostname without any subdomains or a public suffix. For +// example, the base hostname for "https://www.maps.google.com/a/place" is +// "google". We do this mapping to improve performance; otherwise we'd have to +// check all URLs against a long list of regular expressions. The regexes are +// defined as escaped strings so that we build them lazily. +// We may want to migrate this list to Remote Settings in the future. +let HOST_BLOCKLIST = { + auth0: [ + // Auth0 OAuth. + // XXX: Used alone this could produce false positives where an auth0 URL + // appears after another valid domain and TLD, but since we limit this to + // the auth0 hostname those occurrences will be filtered out. + "^https:\\/\\/.*\\.auth0\\.com\\/login", + ], + baidu: [ + // Baidu SERP + "^(https?:\\/\\/)?(www\\.)?baidu\\.com\\/s.*(\\?|&)wd=.*", + ], + bing: [ + // Bing SERP + "^(https?:\\/\\/)?(www\\.)?bing\\.com\\/search.*(\\?|&)q=.*", + ], + duckduckgo: [ + // DuckDuckGo SERP + "^(https?:\\/\\/)?(www\\.)?duckduckgo\\.com\\/.*(\\?|&)q=.*", + ], + google: [ + // Google SERP + "^(https?:\\/\\/)?(www\\.)?google\\.(\\w|\\.){2,}\\/search.*(\\?|&)q=.*", + // Google OAuth + "^https:\\/\\/accounts\\.google\\.com\\/o\\/oauth2\\/v2\\/auth", + "^https:\\/\\/accounts\\.google\\.com\\/signin\\/oauth\\/consent", + ], + microsoftonline: [ + // Microsoft OAuth + "^https:\\/\\/login\\.microsoftonline\\.com\\/common\\/oauth2\\/v2\\.0\\/authorize", + ], + yandex: [ + // Yandex SERP + "^(https?:\\/\\/)?(www\\.)?yandex\\.(\\w|\\.){2,}\\/search.*(\\?|&)text=.*", + ], + zoom: [ + // Zoom meeting interstitial + "^(https?:\\/\\/)?(www\\.)?.*\\.zoom\\.us\\/j\\/\\d+", + ], +}; + +HOST_BLOCKLIST = new Proxy(HOST_BLOCKLIST, { + get(target, property) { + let regexes = target[property]; + if (!regexes || !Array.isArray(regexes)) { + return null; + } + + for (let i = 0; i < regexes.length; i++) { + let regex = regexes[i]; + if (typeof regex === "string") { + regex = new RegExp(regex, "i"); + if (regex) { + regexes[i] = regex; + } else { + throw new Error("Blocklist contains invalid regex."); + } + } + } + return regexes; + }, +}); + +/** + * A class that maintains a blocklist of URLs. The class exposes a method to + * check if a particular URL is contained on the blocklist. + */ +class _InteractionsBlocklist { + constructor() { + // Load custom blocklist items from pref. + try { + let customBlocklist = JSON.parse( + Services.prefs.getStringPref( + "places.interactions.customBlocklist", + "[]" + ) + ); + if (!Array.isArray(customBlocklist)) { + throw new Error(); + } + let parsedBlocklist = customBlocklist.map( + regexStr => new RegExp(regexStr) + ); + HOST_BLOCKLIST["*"] = parsedBlocklist; + } catch (ex) { + lazy.logConsole.warn("places.interactions.customBlocklist is corrupted."); + } + } + + /** + * Only certain urls can be added as Interactions, either manually or + * automatically. + * + * @returns {Map} A Map keyed by protocol, for each protocol an object may + * define stricter requirements, like extension. + */ + get urlRequirements() { + return new Map([ + ["http:", {}], + ["https:", {}], + ["file:", { extension: "pdf" }], + ]); + } + + /** + * Whether to record interactions for a given URL. + * The rules are defined in InteractionsBlocklist.urlRequirements. + * + * @param {string|URL|nsIURI} url The URL to check. + * @returns {boolean} whether the url can be recorded. + */ + canRecordUrl(url) { + let protocol, pathname; + if (typeof url == "string") { + url = new URL(url); + } + if (url instanceof Ci.nsIURI) { + protocol = url.scheme + ":"; + pathname = url.filePath; + } else { + protocol = url.protocol; + pathname = url.pathname; + } + let requirements = InteractionsBlocklist.urlRequirements.get(protocol); + return ( + requirements && + (!requirements.extension || pathname.endsWith(requirements.extension)) + ); + } + + /** + * Checks a URL against a blocklist of URLs. If the URL is blocklisted, we + * should not record an interaction. + * + * @param {string} urlToCheck + * The URL we are looking for on the blocklist. + * @returns {boolean} + * True if `url` is on a blocklist. False otherwise. + */ + isUrlBlocklisted(urlToCheck) { + if (lazy.FilterAdult.isAdultUrl(urlToCheck)) { + return true; + } + + if (!this.canRecordUrl(urlToCheck)) { + return true; + } + + // First, find the URL's base host: the hostname without any subdomains or a + // public suffix. + let url; + try { + url = new URL(urlToCheck); + if (!url) { + throw new Error(); + } + } catch (ex) { + lazy.logConsole.warn( + `Invalid URL passed to InteractionsBlocklist.isUrlBlocklisted: ${url}` + ); + return false; + } + + if (url.protocol == "file:") { + return false; + } + + let hostWithoutSuffix = lazy.UrlbarUtils.stripPublicSuffixFromHost( + url.host + ); + let [hostWithSubdomains] = lazy.UrlbarUtils.stripPrefixAndTrim( + hostWithoutSuffix, + { + stripWww: true, + trimTrailingDot: true, + } + ); + let baseHost = hostWithSubdomains.substring( + hostWithSubdomains.lastIndexOf(".") + 1 + ); + // Then fetch blocked regexes for that baseHost and compare them to the full + // URL. Also check the URL against the custom blocklist. + let regexes = HOST_BLOCKLIST[baseHost.toLocaleLowerCase()] || []; + regexes.push(...(HOST_BLOCKLIST["*"] || [])); + if (!regexes) { + return false; + } + + return regexes.some(r => r.test(url.href)); + } + + /** + * Adds a regex to HOST_BLOCKLIST. Since we can't parse the base host from + * the regex, we add it to a list of wildcard regexes. All URLs are checked + * against these wildcard regexes. Currently only exposed for tests and use in + * the console. In the future we could hook this up to a UI component. + * + * @param {string|RegExp} regexToAdd + * The regular expression to add to our blocklist. + */ + addRegexToBlocklist(regexToAdd) { + let regex; + try { + regex = new RegExp(regexToAdd, "i"); + } catch (ex) { + this.logConsole.warn("Invalid regex passed to addRegexToBlocklist."); + return; + } + + if (!HOST_BLOCKLIST["*"]) { + HOST_BLOCKLIST["*"] = []; + } + HOST_BLOCKLIST["*"].push(regex); + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + JSON.stringify(HOST_BLOCKLIST["*"].map(reg => reg.toString())) + ); + } + + /** + * Removes a regex from HOST_BLOCKLIST. If `regexToRemove` is not in the + * blocklist, this is a no-op. Currently only exposed for tests and use in the + * console. In the future we could hook this up to a UI component. + * + * @param {string|RegExp} regexToRemove + * The regular expression to add to our blocklist. + */ + removeRegexFromBlocklist(regexToRemove) { + let regex; + try { + regex = new RegExp(regexToRemove, "i"); + } catch (ex) { + this.logConsole.warn("Invalid regex passed to addRegexToBlocklist."); + return; + } + + if (!HOST_BLOCKLIST["*"] || !Array.isArray(HOST_BLOCKLIST["*"])) { + return; + } + HOST_BLOCKLIST["*"] = HOST_BLOCKLIST["*"].filter( + curr => curr.source != regex.source + ); + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + JSON.stringify(HOST_BLOCKLIST["*"].map(reg => reg.toString())) + ); + } +} + +export const InteractionsBlocklist = new _InteractionsBlocklist(); diff --git a/browser/components/places/InteractionsChild.sys.mjs b/browser/components/places/InteractionsChild.sys.mjs new file mode 100644 index 0000000000..ff7dd3bd8c --- /dev/null +++ b/browser/components/places/InteractionsChild.sys.mjs @@ -0,0 +1,148 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +/** + * Listens for interactions in the child process and passes information to the + * parent. + */ +export class InteractionsChild extends JSWindowActorChild { + #progressListener; + #currentURL; + + actorCreated() { + this.isContentWindowPrivate = + lazy.PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow); + + if (this.isContentWindowPrivate) { + return; + } + + this.#progressListener = { + onLocationChange: (webProgress, request, location, flags) => { + this.onLocationChange(webProgress, request, location, flags); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener2", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + let webProgress = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + this.#progressListener, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + } + + didDestroy() { + // If the tab is closed then the docshell is no longer available. + if (!this.#progressListener || !this.docShell) { + return; + } + + let webProgress = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.removeProgressListener(this.#progressListener); + } + + onLocationChange(webProgress, request, location, flags) { + // We don't care about inner-frame navigations. + if (!webProgress.isTopLevel) { + return; + } + + // If this is a new document then the DOMContentLoaded event will trigger + // the new interaction instead. + if (!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)) { + return; + } + + this.#recordNewPage(); + } + + #recordNewPage() { + if (!this.docShell.currentDocumentChannel) { + // If there is no document channel, then it is something we're not + // interested in, but we do need to know that the previous interaction + // has ended. + this.sendAsyncMessage("Interactions:PageHide"); + return; + } + + let docInfo = this.#getDocumentInfo(); + + // This may happen when the page calls replaceState or pushState with the + // same URL. We'll just consider this to not be a new page. + if (docInfo.url == this.#currentURL) { + return; + } + + this.#currentURL = docInfo.url; + + if ( + this.docShell.currentDocumentChannel instanceof Ci.nsIHttpChannel && + !this.docShell.currentDocumentChannel.requestSucceeded + ) { + return; + } + + this.sendAsyncMessage("Interactions:PageLoaded", docInfo); + } + + async handleEvent(event) { + if (this.isContentWindowPrivate) { + // No recording in private browsing mode. + return; + } + switch (event.type) { + case "DOMContentLoaded": { + this.#recordNewPage(); + break; + } + case "pagehide": { + if (!this.docShell.currentDocumentChannel) { + return; + } + + if (!this.docShell.currentDocumentChannel.requestSucceeded) { + return; + } + + this.sendAsyncMessage("Interactions:PageHide"); + break; + } + } + } + + /** + * Returns the current document information for sending to the parent process. + * + * @returns {{ isActive: boolean, url: string, referrer: * }?} + */ + #getDocumentInfo() { + let doc = this.document; + + let referrer; + if (doc.referrer) { + referrer = Services.io.newURI(doc.referrer); + } + return { + isActive: this.manager.browsingContext.isActive, + url: doc.documentURIObject.specIgnoringRef, + referrer: referrer?.specIgnoringRef, + }; + } +} diff --git a/browser/components/places/InteractionsParent.sys.mjs b/browser/components/places/InteractionsParent.sys.mjs new file mode 100644 index 0000000000..a43774ef97 --- /dev/null +++ b/browser/components/places/InteractionsParent.sys.mjs @@ -0,0 +1,33 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Interactions: "resource:///modules/Interactions.sys.mjs", +}); + +/** + * Receives messages from InteractionsChild and passes them to the appropriate + * interactions object. + */ +export class InteractionsParent extends JSWindowActorParent { + receiveMessage(msg) { + switch (msg.name) { + case "Interactions:PageLoaded": + lazy.Interactions.registerNewInteraction( + this.browsingContext.embedderElement, + msg.data + ); + break; + case "Interactions:PageHide": + lazy.Interactions.registerEndOfInteraction( + // This could be null if the browsing context has already gone away, + // e.g. on tab close. + this.browsingContext?.embedderElement + ); + break; + } + } +} diff --git a/browser/components/places/PlacesUIUtils.sys.mjs b/browser/components/places/PlacesUIUtils.sys.mjs new file mode 100644 index 0000000000..08b0423d07 --- /dev/null +++ b/browser/components/places/PlacesUIUtils.sys.mjs @@ -0,0 +1,2231 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + Weave: "resource://services-sync/main.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.jsm", +}); + +const gInContentProcess = + Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT; +const FAVICON_REQUEST_TIMEOUT = 60 * 1000; +// Map from windows to arrays of data about pending favicon loads. +let gFaviconLoadDataMap = new Map(); + +const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10; + +// copied from utilityOverlay.js +const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab"; + +let InternalFaviconLoader = { + /** + * Actually cancel the request, and clear the timeout for cancelling it. + * + * @param {object} options + * The options object containing: + * @param {object} options.uri + * The URI of the favicon to cancel. + * @param {number} options.innerWindowID + * The inner window ID of the window. Unused. + * @param {number} options.timerID + * The timer ID of the timeout to be cancelled + * @param {*} options.callback + * The request callback + * @param {string} reason + * The reason for cancelling the request. + */ + _cancelRequest({ uri, innerWindowID, timerID, callback }, reason) { + // Break cycle + let request = callback.request; + delete callback.request; + // Ensure we don't time out. + clearTimeout(timerID); + try { + request.cancel(); + } catch (ex) { + console.error( + "When cancelling a request for " + + uri.spec + + " because " + + reason + + ", it was already canceled!" + ); + } + }, + + /** + * Called for every inner that gets destroyed, only in the parent process. + * + * @param {number} innerID + * The innerID of the window. + */ + removeRequestsForInner(innerID) { + for (let [window, loadDataForWindow] of gFaviconLoadDataMap) { + let newLoadDataForWindow = loadDataForWindow.filter(loadData => { + let innerWasDestroyed = loadData.innerWindowID == innerID; + if (innerWasDestroyed) { + this._cancelRequest( + loadData, + "the inner window was destroyed or a new favicon was loaded for it" + ); + } + // Keep the items whose inner is still alive. + return !innerWasDestroyed; + }); + // Map iteration with for...of is safe against modification, so + // now just replace the old value: + gFaviconLoadDataMap.set(window, newLoadDataForWindow); + } + }, + + /** + * Called when a toplevel chrome window unloads. We use this to tidy up after ourselves, + * avoid leaks, and cancel any remaining requests. The last part should in theory be + * handled by the inner-window-destroyed handlers. We clean up just to be on the safe side. + * + * @param {DOMWindow} win + * The window that was unloaded. + */ + onUnload(win) { + let loadDataForWindow = gFaviconLoadDataMap.get(win); + if (loadDataForWindow) { + for (let loadData of loadDataForWindow) { + this._cancelRequest(loadData, "the chrome window went away"); + } + } + gFaviconLoadDataMap.delete(win); + }, + + /** + * Remove a particular favicon load's loading data from our map tracking + * load data per chrome window. + * + * @param {DOMWindow} win + * the chrome window in which we should look for this load + * @param {object} filterData + * the data we should use to find this particular load to remove. + * @param {number} filterData.innerWindowID + * The inner window ID of the window. + * @param {string} filterData.uri + * The URI of the favicon to cancel. + * @param {*} filterData.callback + * The request callback + * + * @returns {object|null} + * the loadData object we removed, or null if we didn't find any. + */ + _removeLoadDataFromWindowMap(win, { innerWindowID, uri, callback }) { + let loadDataForWindow = gFaviconLoadDataMap.get(win); + if (loadDataForWindow) { + let itemIndex = loadDataForWindow.findIndex(loadData => { + return ( + loadData.innerWindowID == innerWindowID && + loadData.uri.equals(uri) && + loadData.callback.request == callback.request + ); + }); + if (itemIndex != -1) { + let loadData = loadDataForWindow[itemIndex]; + loadDataForWindow.splice(itemIndex, 1); + return loadData; + } + } + return null; + }, + + /** + * Create a function to use as a nsIFaviconDataCallback, so we can remove cancelling + * information when the request succeeds. Note that right now there are some edge-cases, + * such as about: URIs with chrome:// favicons where the success callback is not invoked. + * This is OK: we will 'cancel' the request after the timeout (or when the window goes + * away) but that will be a no-op in such cases. + * + * @param {DOMWindow} win + * The chrome window in which the request was made. + * @param {number} id + * The inner window ID of the window. + * @returns {object} + */ + _makeCompletionCallback(win, id) { + return { + onComplete(uri) { + let loadData = InternalFaviconLoader._removeLoadDataFromWindowMap(win, { + uri, + innerWindowID: id, + callback: this, + }); + if (loadData) { + clearTimeout(loadData.timerID); + } + delete this.request; + }, + }; + }, + + ensureInitialized() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(windowGlobal => { + this.removeRequestsForInner(windowGlobal.innerWindowId); + }, "window-global-destroyed"); + }, + + loadFavicon(browser, principal, pageURI, uri, expiration, iconURI) { + this.ensureInitialized(); + let { ownerGlobal: win, innerWindowID } = browser; + if (!gFaviconLoadDataMap.has(win)) { + gFaviconLoadDataMap.set(win, []); + let unloadHandler = event => { + let doc = event.target; + let eventWin = doc.defaultView; + if (eventWin == win) { + win.removeEventListener("unload", unloadHandler); + this.onUnload(win); + } + }; + win.addEventListener("unload", unloadHandler, true); + } + + // First we do the actual setAndFetch call: + let loadType = lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ? lazy.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE + : lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE; + let callback = this._makeCompletionCallback(win, innerWindowID); + + if (iconURI && iconURI.schemeIs("data")) { + expiration = lazy.PlacesUtils.toPRTime(expiration); + lazy.PlacesUtils.favicons.replaceFaviconDataFromDataURL( + uri, + iconURI.spec, + expiration, + principal + ); + } + + let request = lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + pageURI, + uri, + false, + loadType, + callback, + principal + ); + + // Now register the result so we can cancel it if/when necessary. + if (!request) { + // The favicon service can return with success but no-op (and leave request + // as null) if the icon is the same as the page (e.g. for images) or if it is + // the favicon for an error page. In this case, we do not need to do anything else. + return; + } + callback.request = request; + let loadData = { innerWindowID, uri, callback }; + loadData.timerID = setTimeout(() => { + this._cancelRequest(loadData, "it timed out"); + this._removeLoadDataFromWindowMap(win, loadData); + }, FAVICON_REQUEST_TIMEOUT); + let loadDataForWindow = gFaviconLoadDataMap.get(win); + loadDataForWindow.push(loadData); + }, +}; + +/** + * Collects all information for a bookmark and performs editmethods + */ +class BookmarkState { + /** + * Construct a new BookmarkState. + * + * @param {object} options + * The constructor options. + * @param {object} options.info + * Either a result node or a node-like object representing the item to be edited. + * @param {string} [options.tags] + * Tags (if any) for the bookmark in a comma separated string. Empty tags are + * skipped + * @param {string} [options.keyword] + * Existing (if there are any) keyword for bookmark + * @param {boolean} [options.isFolder] + * If the item is a folder. + * @param {Array<{ title: string; url: nsIURI; }>} [options.children] + * The list of child URIs to bookmark within the folder. + * @param {boolean} [options.autosave] + * If changes to bookmark fields should be saved immediately after calling + * its respective "changed" method, rather than waiting for save() to be + * called. + * @param {number} [options.index] + * The insertion point index of the bookmark. + */ + constructor({ + info, + tags = "", + keyword = "", + isFolder = false, + children = [], + autosave = false, + index, + }) { + this._guid = info.itemGuid; + this._postData = info.postData; + this._isTagContainer = info.isTag; + this._bulkTaggingUrls = info.uris?.map(uri => uri.spec); + this._isFolder = isFolder; + this._children = children; + this._autosave = autosave; + + // Original Bookmark + this._originalState = { + title: this._isTagContainer ? info.tag : info.title, + uri: info.uri?.spec, + tags: tags + .trim() + .split(/\s*,\s*/) + .filter(tag => !!tag.length), + keyword, + parentGuid: info.parentGuid, + index, + }; + + // Edited bookmark + this._newState = {}; + } + + /** + * Save edited title for the bookmark + * + * @param {string} title + * The title of the bookmark + */ + async _titleChanged(title) { + this._newState.title = title; + await this._maybeSave(); + } + + /** + * Save edited location for the bookmark + * + * @param {string} location + * The location of the bookmark + */ + async _locationChanged(location) { + this._newState.uri = location; + await this._maybeSave(); + } + + /** + * Save edited tags for the bookmark + * + * @param {string} tags + * Comma separated list of tags + */ + async _tagsChanged(tags) { + this._newState.tags = tags; + await this._maybeSave(); + } + + /** + * Save edited keyword for the bookmark + * + * @param {string} keyword + * The keyword of the bookmark + */ + async _keywordChanged(keyword) { + this._newState.keyword = keyword; + await this._maybeSave(); + } + + /** + * Save edited parentGuid for the bookmark + * + * @param {string} parentGuid + * The parentGuid of the bookmark + */ + async _parentGuidChanged(parentGuid) { + this._newState.parentGuid = parentGuid; + await this._maybeSave(); + } + + /** + * Save changes if autosave is enabled. + */ + async _maybeSave() { + if (this._autosave) { + await this.save(); + } + } + + /** + * Create a new bookmark. + * + * @returns {string} The bookmark's GUID. + */ + async _createBookmark() { + await lazy.PlacesTransactions.batch(async () => { + this._guid = await lazy.PlacesTransactions.NewBookmark({ + parentGuid: this._newState.parentGuid ?? this._originalState.parentGuid, + tags: this._newState.tags, + title: this._newState.title ?? this._originalState.title, + url: this._newState.uri ?? this._originalState.uri, + index: this._originalState.index, + }).transact(); + if (this._newState.keyword) { + await lazy.PlacesTransactions.EditKeyword({ + guid: this._guid, + keyword: this._newState.keyword, + postData: this._postData, + }).transact(); + } + }); + return this._guid; + } + + /** + * Create a new folder. + * + * @returns {string} The folder's GUID. + */ + async _createFolder() { + this._guid = await lazy.PlacesTransactions.NewFolder({ + parentGuid: this._newState.parentGuid ?? this._originalState.parentGuid, + title: this._newState.title ?? this._originalState.title, + children: this._children, + index: this._originalState.index, + }).transact(); + return this._guid; + } + + /** + * Save() API function for bookmark. + * + * @returns {string} bookmark.guid + */ + async save() { + if (this._guid === lazy.PlacesUtils.bookmarks.unsavedGuid) { + return this._isFolder ? this._createFolder() : this._createBookmark(); + } + + if (!Object.keys(this._newState).length) { + return this._guid; + } + + if (this._isTagContainer && this._newState.title) { + await lazy.PlacesTransactions.RenameTag({ + oldTag: this._originalState.title, + tag: this._newState.title, + }) + .transact() + .catch(console.error); + return this._guid; + } + + let url = this._newState.uri || this._originalState.uri; + let transactions = []; + + if (this._newState.uri) { + transactions.push( + lazy.PlacesTransactions.EditUrl({ + guid: this._guid, + url, + }) + ); + } + + for (const [key, value] of Object.entries(this._newState)) { + switch (key) { + case "title": + transactions.push( + lazy.PlacesTransactions.EditTitle({ + guid: this._guid, + title: value, + }) + ); + break; + case "tags": + const newTags = value.filter( + tag => !this._originalState.tags.includes(tag) + ); + const removedTags = this._originalState.tags.filter( + tag => !value.includes(tag) + ); + if (newTags.length) { + transactions.push( + lazy.PlacesTransactions.Tag({ + urls: this._bulkTaggingUrls || [url], + tags: newTags, + }) + ); + } + if (removedTags.length) { + transactions.push( + lazy.PlacesTransactions.Untag({ + urls: this._bulkTaggingUrls || [url], + tags: removedTags, + }) + ); + } + break; + case "keyword": + transactions.push( + lazy.PlacesTransactions.EditKeyword({ + guid: this._guid, + keyword: value, + postData: this._postData, + oldKeyword: this._originalState.keyword, + }) + ); + break; + case "parentGuid": + transactions.push( + lazy.PlacesTransactions.Move({ + guid: this._guid, + newParentGuid: this._newState.parentGuid, + }) + ); + break; + } + } + if (transactions.length) { + await lazy.PlacesTransactions.batch(transactions); + } + + this._originalState = { ...this._originalState, ...this._newState }; + this._newState = {}; + return this._guid; + } +} + +export var PlacesUIUtils = { + BookmarkState, + _bookmarkToolbarTelemetryListening: false, + LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders", + + lastContextMenuTriggerNode: null, + + // This allows to await for all the relevant bookmark changes to be applied + // when a bookmark dialog is closed. It is resolved to the bookmark guid, + // if a bookmark was created or modified. + lastBookmarkDialogDeferred: null, + + /** + * Obfuscates a place: URL to use it in xulstore without the risk of + leaking browsing information. Uses md5 to hash the query string. + * + * @param {URL} url + * the URL for xulstore with place: key pairs. + * @returns {string} "place:[md5_hash]" hashed url + */ + + obfuscateUrlForXulStore(url) { + if (!url.startsWith("place:")) { + throw new Error("Method must be used to only obfuscate place: uris!"); + } + let urlNoProtocol = url.substring(url.indexOf(":") + 1); + let hashedURL = lazy.PlacesUtils.md5(urlNoProtocol); + + return `place:${hashedURL}`; + }, + + /** + * Shows the bookmark dialog corresponding to the specified info. + * + * @param {object} aInfo + * Describes the item to be edited/added in the dialog. + * See documentation at the top of bookmarkProperties.js + * @param {DOMWindow} [aParentWindow] + * Owner window for the new dialog. + * + * @see documentation at the top of bookmarkProperties.js + * @returns {string} The guid of the item that was created or edited, + * undefined otherwise. + */ + async showBookmarkDialog(aInfo, aParentWindow = null) { + this.lastBookmarkDialogDeferred = lazy.PromiseUtils.defer(); + + let dialogURL = "chrome://browser/content/places/bookmarkProperties.xhtml"; + let features = "centerscreen,chrome,modal,resizable=no"; + let bookmarkGuid; + + if (!aParentWindow) { + aParentWindow = Services.wm.getMostRecentWindow(null); + } + + if (aParentWindow.gDialogBox) { + await aParentWindow.gDialogBox.open(dialogURL, aInfo); + } else { + aParentWindow.openDialog(dialogURL, "", features, aInfo); + } + + if (aInfo.bookmarkState) { + bookmarkGuid = await aInfo.bookmarkState.save(); + this.lastBookmarkDialogDeferred.resolve(bookmarkGuid); + return bookmarkGuid; + } + bookmarkGuid = undefined; + this.lastBookmarkDialogDeferred.resolve(bookmarkGuid); + return bookmarkGuid; + }, + + /** + * Bookmarks one or more pages. If there is more than one, this will create + * the bookmarks in a new folder. + * + * @param {Array.<nsIURI>} URIList + * The list of URIs to bookmark. + * @param {Array.<string>} [hiddenRows] + * An array of rows to be hidden. + * @param {DOMWindow} [win] + * The window to use as the parent to display the bookmark dialog. + */ + async showBookmarkPagesDialog(URIList, hiddenRows = [], win = null) { + if (!URIList.length) { + return; + } + + const bookmarkDialogInfo = { action: "add", hiddenRows }; + if (URIList.length > 1) { + bookmarkDialogInfo.type = "folder"; + bookmarkDialogInfo.URIList = URIList; + } else { + bookmarkDialogInfo.type = "bookmark"; + bookmarkDialogInfo.title = URIList[0].title; + bookmarkDialogInfo.uri = URIList[0].uri; + } + + await PlacesUIUtils.showBookmarkDialog(bookmarkDialogInfo, win); + }, + + /** + * set and fetch a favicon. Can only be used from the parent process. + * + * @param {object} browser + * The XUL browser element for which we're fetching a favicon. + * @param {Principal} principal + * The loading principal to use for the fetch. + * @param {URI} pageURI + * The page URI associated to this favicon load. + * @param {URI} uri + * The URI to fetch. + * @param {number} expiration + * An optional expiration time. + * @param {URI} iconURI + * An optional data: URI holding the icon's data. + */ + loadFavicon( + browser, + principal, + pageURI, + uri, + expiration = 0, + iconURI = null + ) { + if (gInContentProcess) { + throw new Error("Can't track loads from within the child process!"); + } + InternalFaviconLoader.loadFavicon( + browser, + principal, + pageURI, + uri, + expiration, + iconURI + ); + }, + + /** + * Returns the closet ancestor places view for the given DOM node + * + * @param {DOMNode} aNode + * a DOM node + * @returns {DOMNode} the closest ancestor places view if exists, null otherwsie. + */ + getViewForNode: function PUIU_getViewForNode(aNode) { + let node = aNode; + + if (Cu.isDeadWrapper(node)) { + return null; + } + + if (node.localName == "panelview" && node._placesView) { + return node._placesView; + } + + // The view for a <menu> of which its associated menupopup is a places + // view, is the menupopup. + if ( + node.localName == "menu" && + !node._placesNode && + node.menupopup._placesView + ) { + return node.menupopup._placesView; + } + + while (Element.isInstance(node)) { + if (node._placesView) { + return node._placesView; + } + if ( + node.localName == "tree" && + node.getAttribute("is") == "places-tree" + ) { + return node; + } + + node = node.parentNode; + } + + return null; + }, + + /** + * Returns the active PlacesController for a given command. + * + * @param {DOMWindow} win The window containing the affected view + * @param {string} command The command + * @returns {PlacesController} a places controller + */ + getControllerForCommand(win, command) { + // If we're building a context menu for a non-focusable view, for example + // a menupopup, we must return the view that triggered the context menu. + let popupNode = PlacesUIUtils.lastContextMenuTriggerNode; + if (popupNode) { + let isManaged = !!popupNode.closest("#managed-bookmarks"); + if (isManaged) { + return this.managedBookmarksController; + } + let view = this.getViewForNode(popupNode); + if (view && view._contextMenuShown) { + return view.controllers.getControllerForCommand(command); + } + } + + // When we're not building a context menu, only focusable views + // are possible. Thus, we can safely use the command dispatcher. + let controller = + win.top.document.commandDispatcher.getControllerForCommand(command); + return controller || null; + }, + + /** + * Update all the Places commands for the given window. + * + * @param {DOMWindow} win The window to update. + */ + updateCommands(win) { + // Get the controller for one of the places commands. + let controller = this.getControllerForCommand(win, "placesCmd_open"); + for (let command of [ + "placesCmd_open", + "placesCmd_open:window", + "placesCmd_open:privatewindow", + "placesCmd_open:tab", + "placesCmd_new:folder", + "placesCmd_new:bookmark", + "placesCmd_new:separator", + "placesCmd_show:info", + "placesCmd_reload", + "placesCmd_sortBy:name", + "placesCmd_cut", + "placesCmd_copy", + "placesCmd_paste", + "placesCmd_delete", + "placesCmd_showInFolder", + ]) { + win.goSetCommandEnabled( + command, + controller && controller.isCommandEnabled(command) + ); + } + }, + + /** + * Executes the given command on the currently active controller. + * + * @param {DOMWindow} win The window containing the affected view + * @param {string} command The command to execute + */ + doCommand(win, command) { + let controller = this.getControllerForCommand(win, command); + if (controller && controller.isCommandEnabled(command)) { + controller.doCommand(command); + } + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_TYPED transition (if there is no a referrer). + * This is used when visiting pages from the history menu, history sidebar, + * url bar, url autocomplete results, and history searches from the places + * organizer. If this is not called visits will be marked as + * TRANSITION_LINK. + * + * @param {string} aURL + * The URL to mark as typed. + */ + markPageAsTyped: function PUIU_markPageAsTyped(aURL) { + lazy.PlacesUtils.history.markPageAsTyped( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_BOOKMARK transition. + * This is used when visiting pages from the bookmarks menu, + * personal toolbar, and bookmarks from within the places organizer. + * If this is not called visits will be marked as TRANSITION_LINK. + * + * @param {string} aURL + * The URL to mark as TRANSITION_BOOKMARK. + */ + markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) { + lazy.PlacesUtils.history.markPageAsFollowedBookmark( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * By calling this before visiting an URL, any visit in frames will be + * associated to a TRANSITION_FRAMED_LINK transition. + * This is actually used to distinguish user-initiated visits in frames + * so automatic visits can be correctly ignored. + * + * @param {string} aURL + * The URL to mark as TRANSITION_FRAMED_LINK. + */ + markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) { + lazy.PlacesUtils.history.markPageAsFollowedLink( + Services.uriFixup.getFixupURIInfo(aURL).preferredURI + ); + }, + + /** + * Sets the character-set for a page. The character set will not be saved + * if the window is determined to be a private browsing window. + * + * @param {string|URL|nsIURI} url The URL of the page to set the charset on. + * @param {string} charset character-set value. + * @param {DOMWindow} window The window that the charset is being set from. + * @returns {Promise} + */ + async setCharsetForPage(url, charset, window) { + if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) { + return; + } + + // UTF-8 is the default. If we are passed the value then set it to null, + // to ensure any charset is removed from the database. + if (charset.toLowerCase() == "utf-8") { + charset = null; + } + + await lazy.PlacesUtils.history.update({ + url, + annotations: new Map([[lazy.PlacesUtils.CHARSET_ANNO, charset]]), + }); + }, + + /** + * Allows opening of javascript/data URI only if the given node is + * bookmarked (see bug 224521). + * + * @param {object} aURINode + * a URI node + * @param {DOMWindow} aWindow + * a window on which a potential error alert is shown on. + * @returns {boolean} true if it's safe to open the node in the browser, false otherwise. + * + */ + checkURLSecurity: function PUIU_checkURLSecurity(aURINode, aWindow) { + if (lazy.PlacesUtils.nodeIsBookmark(aURINode)) { + return true; + } + + var uri = Services.io.newURI(aURINode.uri); + if (uri.schemeIs("javascript") || uri.schemeIs("data")) { + const [title, errorStr] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-error-title", + "places-load-js-data-url-error", + ]); + Services.prompt.alert(aWindow, title, errorStr); + return false; + } + return true; + }, + + /** + * Check whether or not the given node represents a removable entry (either in + * history or in bookmarks). + * + * @param {object} aNode + * a node, except the root node of a query. + * @returns {boolean} true if the aNode represents a removable entry, false otherwise. + */ + canUserRemove(aNode) { + let parentNode = aNode.parent; + if (!parentNode) { + // canUserRemove doesn't accept root nodes. + return false; + } + + // Is it a query pointing to one of the special root folders? + if (lazy.PlacesUtils.nodeIsQuery(parentNode)) { + if (lazy.PlacesUtils.nodeIsFolder(aNode)) { + let guid = lazy.PlacesUtils.getConcreteItemGuid(aNode); + // If the parent folder is not a folder, it must be a query, and so this node + // cannot be removed. + if (lazy.PlacesUtils.isRootItem(guid)) { + return false; + } + } else if (lazy.PlacesUtils.isVirtualLeftPaneItem(aNode.bookmarkGuid)) { + // If the item is a left-pane top-level item, it can't be removed. + return false; + } + } + + // If it's not a bookmark, or it's child of a query, we can remove it. + if (aNode.itemId == -1 || lazy.PlacesUtils.nodeIsQuery(parentNode)) { + return true; + } + + // Otherwise it has to be a child of an editable folder. + return !this.isFolderReadOnly(parentNode); + }, + + /** + * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH + * TO GUIDS IS COMPLETE (BUG 1071511). + * + * Check whether or not the given Places node points to a folder which + * should not be modified by the user (i.e. its children should be unremovable + * and unmovable, new children should be disallowed, etc). + * These semantics are not inherited, meaning that read-only folder may + * contain editable items (for instance, the places root is read-only, but all + * of its direct children aren't). + * + * You should only pass folder nodes. + * + * @param {object} placesNode + * any folder result node. + * @throws if placesNode is not a folder result node or views is invalid. + * @returns {boolean} true if placesNode is a read-only folder, false otherwise. + */ + isFolderReadOnly(placesNode) { + if ( + typeof placesNode != "object" || + !lazy.PlacesUtils.nodeIsFolder(placesNode) + ) { + throw new Error("invalid value for placesNode"); + } + + return ( + lazy.PlacesUtils.getConcreteItemGuid(placesNode) == + lazy.PlacesUtils.bookmarks.rootGuid + ); + }, + + /** + * @param {Array<object>} aItemsToOpen + * needs to be an array of objects of the form: + * {uri: string, isBookmark: boolean} + * @param {object} aEvent + * The associated event triggering the open. + * @param {DOMWindow} aWindow + * The window associated with the event. + */ + openTabset(aItemsToOpen, aEvent, aWindow) { + if (!aItemsToOpen.length) { + return; + } + + let browserWindow = getBrowserWindow(aWindow); + var urls = []; + let isPrivate = + browserWindow && lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow); + for (let item of aItemsToOpen) { + urls.push(item.uri); + if (isPrivate) { + continue; + } + + if (item.isBookmark) { + this.markPageAsFollowedBookmark(item.uri); + } else { + this.markPageAsTyped(item.uri); + } + } + + // whereToOpenLink doesn't return "window" when there's no browser window + // open (Bug 630255). + var where = browserWindow + ? browserWindow.whereToOpenLink(aEvent, false, true) + : "window"; + if (where == "window") { + // There is no browser window open, thus open a new one. + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let stringsToLoad = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + urls.forEach(url => + stringsToLoad.appendElement(lazy.PlacesUtils.toISupportsString(url)) + ); + args.appendElement(stringsToLoad); + + let features = "chrome,dialog=no,all"; + if (isPrivate) { + features += ",private"; + } + + browserWindow = Services.ww.openWindow( + aWindow, + AppConstants.BROWSER_CHROME_URL, + null, + features, + args + ); + return; + } + + var loadInBackground = where == "tabshifted"; + // For consistency, we want all the bookmarks to open in new tabs, instead + // of having one of them replace the currently focused tab. Hence we call + // loadTabs with aReplace set to false. + browserWindow.gBrowser.loadTabs(urls, { + inBackground: loadInBackground, + replace: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + }, + + /** + * Loads a selected node's or nodes' URLs in tabs, + * warning the user when lots of URLs are being opened + * + * @param {object | Array} nodeOrNodes + * Contains the node or nodes that we're opening in tabs + * @param {event} event + * The DOM mouse/key event with modifier keys set that track the + * user's preferred destination window or tab. + * @param {object} view + * The current view that contains the node or nodes selected for + * opening + */ + openMultipleLinksInTabs(nodeOrNodes, event, view) { + let window = view.ownerWindow; + let urlsToOpen = []; + + if (lazy.PlacesUtils.nodeIsContainer(nodeOrNodes)) { + urlsToOpen = lazy.PlacesUtils.getURLsForContainerNode(nodeOrNodes); + } else { + for (var i = 0; i < nodeOrNodes.length; i++) { + // Skip over separators and folders. + if (lazy.PlacesUtils.nodeIsURI(nodeOrNodes[i])) { + urlsToOpen.push({ + uri: nodeOrNodes[i].uri, + isBookmark: lazy.PlacesUtils.nodeIsBookmark(nodeOrNodes[i]), + }); + } + } + } + if (lazy.OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) { + if (window.updateTelemetry) { + window.updateTelemetry(urlsToOpen); + } + this.openTabset(urlsToOpen, event, window); + } + }, + + /** + * Loads the node's URL in the appropriate tab or window given the + * user's preference specified by modifier keys tracked by a + * DOM mouse/key event. + * + * @param {object} aNode + * An uri result node. + * @param {object} aEvent + * The DOM mouse/key event with modifier keys set that track the + * user's preferred destination window or tab. + */ + openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent) { + let window = aEvent.target.ownerGlobal; + + let browserWindow = getBrowserWindow(window); + + let where = window.whereToOpenLink(aEvent, false, true); + if (this.loadBookmarksInTabs && lazy.PlacesUtils.nodeIsBookmark(aNode)) { + if (where == "current" && !aNode.uri.startsWith("javascript:")) { + where = "tab"; + } + if (where == "tab" && browserWindow.gBrowser.selectedTab.isEmpty) { + where = "current"; + } + } + + this._openNodeIn(aNode, where, window); + }, + + /** + * Loads the node's URL in the appropriate tab or window. + * see also URILoadingHelper's openWebLinkIn + * + * @param {object} aNode + * An uri result node. + * @param {string} aWhere + * Where to open the URL. + * @param {object} aView + * The associated view of the node being opened. + * @param {boolean} aPrivate + * True if the window being opened is private. + */ + openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) { + let window = aView.ownerWindow; + this._openNodeIn(aNode, aWhere, window, { aPrivate }); + }, + + _openNodeIn: function PUIU__openNodeIn( + aNode, + aWhere, + aWindow, + { aPrivate = false, userContextId = 0 } = {} + ) { + if ( + aNode && + lazy.PlacesUtils.nodeIsURI(aNode) && + this.checkURLSecurity(aNode, aWindow) + ) { + let isBookmark = lazy.PlacesUtils.nodeIsBookmark(aNode); + + if (!lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + if (isBookmark) { + this.markPageAsFollowedBookmark(aNode.uri); + } else { + this.markPageAsTyped(aNode.uri); + } + } else { + // This is a targeted fix for bug 1792163, where it was discovered + // that if you open the Library from a Private Browsing window, and then + // use the "Open in New Window" context menu item to open a new window, + // that the window will open under the wrong icon on the Windows taskbar. + aPrivate = true; + } + + const isJavaScriptURL = aNode.uri.startsWith("javascript:"); + aWindow.openTrustedLinkIn(aNode.uri, aWhere, { + allowPopups: isJavaScriptURL, + inBackground: this.loadBookmarksInBackground, + allowInheritPrincipal: isJavaScriptURL, + private: aPrivate, + userContextId, + }); + if (aWindow.updateTelemetry) { + aWindow.updateTelemetry([aNode]); + } + } + }, + + /** + * Helper for guessing scheme from an url string. + * Used to avoid nsIURI overhead in frequently called UI functions. This is not + * supposed be perfect, so use it only for UI purposes. + * + * @param {string} href The url to guess the scheme from. + * @returns {string} guessed scheme for this url string. + */ + guessUrlSchemeForUI(href) { + return href.substr(0, href.indexOf(":")); + }, + + getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) { + var title; + if (!aNode.title && lazy.PlacesUtils.nodeIsURI(aNode)) { + // if node title is empty, try to set the label using host and filename + // Services.io.newURI will throw if aNode.uri is not a valid URI + try { + var uri = Services.io.newURI(aNode.uri); + var host = uri.host; + var fileName = uri.QueryInterface(Ci.nsIURL).fileName; + // if fileName is empty, use path to distinguish labels + if (aDoNotCutTitle) { + title = host + uri.pathQueryRef; + } else { + title = + host + + (fileName + ? (host ? "/" + this.ellipsis + "/" : "") + fileName + : uri.pathQueryRef); + } + } catch (e) { + // Use (no title) for non-standard URIs (data:, javascript:, ...) + title = ""; + } + } else { + title = aNode.title; + } + + return title || this.promptLocalization.formatValueSync("places-no-title"); + }, + + shouldShowTabsFromOtherComputersMenuitem() { + let weaveOK = + lazy.Weave.Status.checkSetup() != lazy.CLIENT_NOT_CONFIGURED && + lazy.Weave.Svc.Prefs.get("firstSync", "") != "notReady"; + return weaveOK; + }, + + /** + * WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT'S LIKELY TO BE REMOVED IN A + * FUTURE RELEASE. + * + * Checks if a place: href represents a folder shortcut. + * + * @param {string} queryString + * the query string to check (a place: href) + * @returns {boolean} whether or not queryString represents a folder shortcut. + * @throws if queryString is malformed. + */ + isFolderShortcutQueryString(queryString) { + // Based on GetSimpleBookmarksQueryFolder in nsNavHistory.cpp. + + let query = {}, + options = {}; + lazy.PlacesUtils.history.queryStringToQuery(queryString, query, options); + query = query.value; + options = options.value; + return ( + query.folderCount == 1 && + !query.hasBeginTime && + !query.hasEndTime && + !query.hasDomain && + !query.hasURI && + !query.hasSearchTerms && + !query.tags.length == 0 && + options.maxResults == 0 + ); + }, + + /** + * Helpers for consumers of editBookmarkOverlay which don't have a node as their input. + * + * Given a bookmark object for either a url bookmark or a folder, returned by + * Bookmarks.fetch (see Bookmark.jsm), this creates a node-like object suitable for + * initialising the edit overlay with it. + * + * @param {object} aFetchInfo + * a bookmark object returned by Bookmarks.fetch. + * @returns {object} a node-like object suitable for initialising editBookmarkOverlay. + * @throws if aFetchInfo is representing a separator. + */ + async promiseNodeLikeFromFetchInfo(aFetchInfo) { + if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR) { + throw new Error("promiseNodeLike doesn't support separators"); + } + + let parent = { + itemId: await lazy.PlacesUtils.promiseItemId(aFetchInfo.parentGuid), + bookmarkGuid: aFetchInfo.parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + }; + + let itemId = + aFetchInfo.guid === lazy.PlacesUtils.bookmarks.unsavedGuid + ? undefined + : await lazy.PlacesUtils.promiseItemId(aFetchInfo.guid); + return Object.freeze({ + itemId, + bookmarkGuid: aFetchInfo.guid, + title: aFetchInfo.title, + uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "", + + get type() { + if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_FOLDER) { + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER; + } + + if (!this.uri.length) { + throw new Error("Unexpected item type"); + } + + if (/^place:/.test(this.uri)) { + if (this.isFolderShortcutQueryString(this.uri)) { + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + } + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; + } + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + }, + + get parent() { + return parent; + }, + }); + }, + + /** + * This function wraps potentially large places transaction operations + * with batch notifications to the result node, hence switching the views + * to batch mode. If resultNode is not supplied, the function will + * pass-through to functionToWrap. + * + * @param {nsINavHistoryResult} resultNode The result node to turn on batching. + * @param {number} itemsBeingChanged The count of items being changed. If the + * count is lower than a threshold, then + * batching won't be set. + * @param {Function} functionToWrap The function to + */ + async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) { + if (!resultNode) { + await functionToWrap(); + return; + } + + if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { + resultNode.onBeginUpdateBatch(); + } + + try { + await functionToWrap(); + } finally { + if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { + resultNode.onEndUpdateBatch(); + } + } + }, + + /** + * Processes a set of transfer items that have been dropped or pasted. + * Batching will be applied where necessary. + * + * @param {Array} items A list of unwrapped nodes to process. + * @param {object} insertionPoint The requested point for insertion. + * @param {boolean} doCopy Set to true to copy the items, false will move them + * if possible. + * @param {object} view The view that should be used for batching. + * @returns {Array} Returns an empty array when the insertion point is a tag, else + * returns an array of copied or moved guids. + */ + async handleTransferItems(items, insertionPoint, doCopy, view) { + let transactions; + let itemsCount; + if (insertionPoint.isTag) { + let urls = items.filter(item => "uri" in item).map(item => item.uri); + itemsCount = urls.length; + transactions = [ + lazy.PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName }), + ]; + } else { + let insertionIndex = await insertionPoint.getIndex(); + itemsCount = items.length; + transactions = getTransactionsForTransferItems( + items, + insertionIndex, + insertionPoint.guid, + !doCopy + ); + } + + // Check if we actually have something to add, if we don't it probably wasn't + // valid, or it was moving to the same location, so just ignore it. + if (!transactions.length) { + return []; + } + + let guidsToSelect = []; + let resultForBatching = getResultForBatching(view); + + // If we're inserting into a tag, we don't get the guid, so we'll just + // pass the transactions direct to the batch function. + let batchingItem = transactions; + if (!insertionPoint.isTag) { + // If we're not a tag, then we need to get the ids of the items to select. + batchingItem = async () => { + for (let transaction of transactions) { + let result = await transaction.transact(); + guidsToSelect = guidsToSelect.concat(result); + } + }; + } + + await this.batchUpdatesForNode(resultForBatching, itemsCount, async () => { + await lazy.PlacesTransactions.batch(batchingItem); + }); + + return guidsToSelect; + }, + + onSidebarTreeClick(event) { + // right-clicks are not handled here + if (event.button == 2) { + return; + } + + let tree = event.target.parentNode; + let cell = tree.getCellAt(event.clientX, event.clientY); + if (cell.row == -1 || cell.childElt == "twisty") { + return; + } + + // getCoordsForCellItem returns the x coordinate in logical coordinates + // (i.e., starting from the left and right sides in LTR and RTL modes, + // respectively.) Therefore, we make sure to exclude the blank area + // before the tree item icon (that is, to the left or right of it in + // LTR and RTL modes, respectively) from the click target area. + let win = tree.ownerGlobal; + let rect = tree.getCoordsForCellItem(cell.row, cell.col, "image"); + let isRTL = win.getComputedStyle(tree).direction == "rtl"; + let mouseInGutter = isRTL ? event.clientX > rect.x : event.clientX < rect.x; + + let metaKey = + AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey; + let modifKey = metaKey || event.shiftKey; + let isContainer = tree.view.isContainer(cell.row); + let openInTabs = + isContainer && + (event.button == 1 || (event.button == 0 && modifKey)) && + lazy.PlacesUtils.hasChildURIs(tree.view.nodeForTreeIndex(cell.row)); + + if (event.button == 0 && isContainer && !openInTabs) { + tree.view.toggleOpenState(cell.row); + } else if ( + !mouseInGutter && + openInTabs && + event.originalTarget.localName == "treechildren" + ) { + tree.view.selection.select(cell.row); + this.openMultipleLinksInTabs(tree.selectedNode, event, tree); + } else if ( + !mouseInGutter && + !isContainer && + event.originalTarget.localName == "treechildren" + ) { + // Clear all other selection since we're loading a link now. We must + // do this *before* attempting to load the link since openURL uses + // selection as an indication of which link to load. + tree.view.selection.select(cell.row); + this.openNodeWithEvent(tree.selectedNode, event); + } + }, + + onSidebarTreeKeyPress(event) { + let node = event.target.selectedNode; + if (node) { + if (event.keyCode == event.DOM_VK_RETURN) { + PlacesUIUtils.openNodeWithEvent(node, event); + } + } + }, + + /** + * The following function displays the URL of a node that is being + * hovered over. + * + * @param {object} event + * The event that triggered the hover. + */ + onSidebarTreeMouseMove(event) { + let treechildren = event.target; + if (treechildren.localName != "treechildren") { + return; + } + + let tree = treechildren.parentNode; + let cell = tree.getCellAt(event.clientX, event.clientY); + + // cell.row is -1 when the mouse is hovering an empty area within the tree. + // To avoid showing a URL from a previously hovered node for a currently + // hovered non-url node, we must clear the moused-over URL in these cases. + if (cell.row != -1) { + let node = tree.view.nodeForTreeIndex(cell.row); + if (lazy.PlacesUtils.nodeIsURI(node)) { + this.setMouseoverURL(node.uri, tree.ownerGlobal); + return; + } + } + this.setMouseoverURL("", tree.ownerGlobal); + }, + + setMouseoverURL(url, win) { + // When the browser window is closed with an open sidebar, the sidebar + // unload event happens after the browser's one. In this case + // top.XULBrowserWindow has been nullified already. + if (win.top.XULBrowserWindow) { + win.top.XULBrowserWindow.setOverLink(url); + } + }, + + /** + * Uncollapses PersonalToolbar if its collapsed status is not + * persisted, and user customized it or changed default bookmarks. + * + * If the user does not have a persisted value for the toolbar's + * "collapsed" attribute, try to determine whether it's customized. + * + * @param {boolean} aForceVisible Set to true to ignore if the user had + * previously collapsed the toolbar manually. + */ + NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE: 3, + async maybeToggleBookmarkToolbarVisibility(aForceVisible = false) { + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + let xulStore = Services.xulStore; + + if ( + aForceVisible || + !xulStore.hasValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed") + ) { + function uncollapseToolbar() { + Services.obs.notifyObservers( + null, + "browser-set-toolbar-visibility", + JSON.stringify([lazy.CustomizableUI.AREA_BOOKMARKS, "true"]) + ); + } + // We consider the toolbar customized if it has more than + // NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE children, or if it has a persisted + // currentset value. + let toolbarIsCustomized = xulStore.hasValue( + BROWSER_DOCURL, + "PersonalToolbar", + "currentset" + ); + if (aForceVisible || toolbarIsCustomized) { + uncollapseToolbar(); + return; + } + + let numBookmarksOnToolbar = ( + await lazy.PlacesUtils.bookmarks.fetch( + lazy.PlacesUtils.bookmarks.toolbarGuid + ) + ).childCount; + if (numBookmarksOnToolbar > this.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE) { + uncollapseToolbar(); + } + } + }, + + async managedPlacesContextShowing(event) { + let menupopup = event.target; + let document = menupopup.ownerDocument; + let window = menupopup.ownerGlobal; + // We need to populate the submenus in order to have information + // to show the context menu. + if ( + menupopup.triggerNode.id == "managed-bookmarks" && + !menupopup.triggerNode.menupopup.hasAttribute("hasbeenopened") + ) { + await window.PlacesToolbarHelper.populateManagedBookmarks( + menupopup.triggerNode.menupopup + ); + } + let linkItems = [ + "placesContext_open:newtab", + "placesContext_open:newwindow", + "placesContext_openSeparator", + "placesContext_copy", + ]; + // Hide everything. We'll unhide the things we need. + Array.from(menupopup.children).forEach(function (child) { + child.hidden = true; + }); + // Store triggerNode in controller for checking if commands are enabled + this.managedBookmarksController.triggerNode = menupopup.triggerNode; + // Container in this context means a folder. + let isFolder = menupopup.triggerNode.hasAttribute("container"); + if (isFolder) { + // Disable the openContainerInTabs menuitem if there + // are no children of the menu that have links. + let openContainerInTabs_menuitem = document.getElementById( + "placesContext_openContainer:tabs" + ); + let menuitems = menupopup.triggerNode.menupopup.children; + let openContainerInTabs = Array.from(menuitems).some( + menuitem => menuitem.link + ); + openContainerInTabs_menuitem.disabled = !openContainerInTabs; + openContainerInTabs_menuitem.hidden = false; + } else { + linkItems.forEach(id => (document.getElementById(id).hidden = false)); + document.getElementById("placesContext_open:newprivatewindow").hidden = + lazy.PrivateBrowsingUtils.isWindowPrivate(window) || + !lazy.PrivateBrowsingUtils.enabled; + document.getElementById("placesContext_open:newcontainertab").hidden = + !Services.prefs.getBoolPref("privacy.userContext.enabled", false); + } + + event.target.ownerGlobal.updateCommands("places"); + }, + + placesContextShowing(event) { + let menupopup = event.target; + if (menupopup.id != "placesContext") { + // Ignore any popupshowing events from submenus + return true; + } + + PlacesUIUtils.lastContextMenuTriggerNode = menupopup.triggerNode; + + if (Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs", false)) { + menupopup.ownerDocument + .getElementById("placesContext_open") + .removeAttribute("default"); + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .setAttribute("default", "true"); + // else clause ensures correct behavior if pref is repeatedly toggled + } else { + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .removeAttribute("default"); + menupopup.ownerDocument + .getElementById("placesContext_open") + .setAttribute("default", "true"); + } + + let isManaged = !!menupopup.triggerNode.closest("#managed-bookmarks"); + if (isManaged) { + this.managedPlacesContextShowing(event); + return true; + } + menupopup._view = this.getViewForNode(menupopup.triggerNode); + if (!menupopup._view) { + // This can happen if we try to invoke the context menu on + // an uninitialized places toolbar. Just bail out: + event.preventDefault(); + return false; + } + if (!this.openInTabClosesMenu) { + menupopup.ownerDocument + .getElementById("placesContext_open:newtab") + .setAttribute("closemenu", "single"); + } + return menupopup._view.buildContextMenu(menupopup); + }, + + placesContextHiding(event) { + let menupopup = event.target; + if (menupopup._view) { + menupopup._view.destroyContextMenu(); + } + + if (menupopup.id == "placesContext") { + PlacesUIUtils.lastContextMenuTriggerNode = null; + } + }, + + createContainerTabMenu(event) { + let window = event.target.ownerGlobal; + return window.createUserContextMenu(event, { isContextMenu: true }); + }, + + openInContainerTab(event) { + let userContextId = parseInt( + event.target.getAttribute("data-usercontextid") + ); + let triggerNode = this.lastContextMenuTriggerNode; + let isManaged = !!triggerNode?.closest("#managed-bookmarks"); + if (isManaged) { + let window = triggerNode.ownerGlobal; + window.openTrustedLinkIn(triggerNode.link, "tab", { userContextId }); + return; + } + let view = this.getViewForNode(triggerNode); + this._openNodeIn(view.selectedNode, "tab", view.ownerWindow, { + userContextId, + }); + }, + + openSelectionInTabs(event) { + let isManaged = + !!event.target.parentNode.triggerNode.closest("#managed-bookmarks"); + let controller; + if (isManaged) { + controller = this.managedBookmarksController; + } else { + controller = PlacesUIUtils.getViewForNode( + PlacesUIUtils.lastContextMenuTriggerNode + ).controller; + } + controller.openSelectionInTabs(event); + }, + + managedBookmarksController: { + triggerNode: null, + + openSelectionInTabs(event) { + let window = event.target.ownerGlobal; + let menuitems = event.target.parentNode.triggerNode.menupopup.children; + let items = []; + for (let i = 0; i < menuitems.length; i++) { + if (menuitems[i].link) { + let item = {}; + item.uri = menuitems[i].link; + item.isBookmark = true; + items.push(item); + } + } + PlacesUIUtils.openTabset(items, event, window); + }, + + isCommandEnabled(command) { + switch (command) { + case "placesCmd_copy": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + return true; + } + } + return false; + }, + + doCommand(command) { + let window = this.triggerNode.ownerGlobal; + switch (command) { + case "placesCmd_copy": + // This is a little hacky, but there is a lot of code in Places that handles + // clipboard stuff, so it's easier to reuse. + let node = {}; + node.type = 0; + node.title = this.triggerNode.label; + node.uri = this.triggerNode.link; + + // Copied from _populateClipboard in controller.js + + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: lazy.PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: lazy.PlacesUtils.TYPE_HTML, entries: [] }, + { type: lazy.PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + contents.forEach(function (content) { + content.entries.push(lazy.PlacesUtils.wrapNode(node, content.type)); + }); + + let xferable = Cc[ + "@mozilla.org/widget/transferable;1" + ].createInstance(Ci.nsITransferable); + xferable.init(null); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData( + type, + lazy.PlacesUtils.toISupportsString(data) + ); + } + + contents.forEach(function (content) { + addData(content.type, content.entries.join(lazy.PlacesUtils.endl)); + }); + + Services.clipboard.setData( + xferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + break; + case "placesCmd_open:privatewindow": + window.openTrustedLinkIn(this.triggerNode.link, "window", { + private: true, + }); + break; + case "placesCmd_open:window": + window.openTrustedLinkIn(this.triggerNode.link, "window", { + private: false, + }); + break; + case "placesCmd_open:tab": { + window.openTrustedLinkIn(this.triggerNode.link, "tab"); + } + } + }, + }, + + async maybeAddImportButton() { + if (!Services.policies.isAllowed("profileImport")) { + return; + } + + let numberOfBookmarks = await lazy.PlacesUtils.withConnectionWrapper( + "PlacesUIUtils: maybeAddImportButton", + async db => { + let rows = await db.execute( + `SELECT COUNT(*) as n FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE p.guid = :guid`, + { guid: lazy.PlacesUtils.bookmarks.toolbarGuid } + ); + return rows[0].getResultByName("n"); + } + ).catch(e => { + // We want to report errors, but we still want to add the button then: + console.error(e); + return 0; + }); + + if (numberOfBookmarks < 3) { + lazy.CustomizableUI.addWidgetToArea( + "import-button", + lazy.CustomizableUI.AREA_BOOKMARKS, + 0 + ); + Services.prefs.setBoolPref("browser.bookmarks.addedImportButton", true); + this.removeImportButtonWhenImportSucceeds(); + } + }, + + removeImportButtonWhenImportSucceeds() { + // If the user (re)moved the button, clear the pref and stop worrying about + // moving the item. + let placement = lazy.CustomizableUI.getPlacementOfWidget("import-button"); + if (placement?.area != lazy.CustomizableUI.AREA_BOOKMARKS) { + Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); + return; + } + // Otherwise, wait for a successful migration: + let obs = (subject, topic, data) => { + if ( + data == lazy.MigrationUtils.resourceTypes.BOOKMARKS && + lazy.MigrationUtils.getImportedCount("bookmarks") > 0 + ) { + lazy.CustomizableUI.removeWidgetFromArea("import-button"); + Services.prefs.clearUserPref("browser.bookmarks.addedImportButton"); + Services.obs.removeObserver(obs, "Migration:ItemAfterMigrate"); + Services.obs.removeObserver(obs, "Migration:ItemError"); + } + }; + Services.obs.addObserver(obs, "Migration:ItemAfterMigrate"); + Services.obs.addObserver(obs, "Migration:ItemError"); + }, + + /** + * Tries to initiate a speculative connection to a given url. This is not + * infallible, if a speculative connection cannot be initialized, it will be a + * no-op. + * + * @param {nsIURI|URL|string} url entity to initiate + * a speculative connection for. + * @param {window} window the window from where the connection is initialized. + */ + setupSpeculativeConnection(url, window) { + if ( + !Services.prefs.getBoolPref( + "browser.places.speculativeConnect.enabled", + true + ) + ) { + return; + } + if (!url.startsWith("http")) { + return; + } + try { + let uri = url instanceof Ci.nsIURI ? url : Services.io.newURI(url); + Services.io.speculativeConnect( + uri, + window.gBrowser.contentPrincipal, + null, + false + ); + } catch (ex) { + // Can't setup speculative connection for this url, just ignore it. + } + }, + + getImageURL(icon) { + let iconURL = icon; + // don't initiate a connection just to fetch a favicon (see bug 467828) + if (/^https?:/.test(iconURL)) { + iconURL = "moz-anno:favicon:" + iconURL; + } + return iconURL; + }, + + /** + * Determines the string indexes where titles differ from similar titles (where + * the first n characters are the same) in the provided list of items, and + * adds that into the item. + * + * This assumes the titles will be displayed along the lines of + * `Start of title ... place where differs` the index would be reference + * the `p` here. + * + * @param {object[]} candidates + * An array of candidates to modify. The candidates should have a `title` + * property which should be a string or null. + * The order of the array does not matter. The objects are modified + * in-place. + * If a difference to other similar titles is found then a + * `titleDifferentIndex` property will be inserted into all similar + * candidates with the index of the start of the difference. + */ + insertTitleStartDiffs(candidates) { + function findStartDifference(a, b) { + let i; + // We already know the start is the same, so skip that part. + for (i = PlacesUIUtils.similarTitlesMinChars; i < a.length; i++) { + if (a[i] != b[i]) { + return i; + } + } + if (b.length > i) { + return i; + } + // They are the same. + return -1; + } + + let longTitles = new Map(); + + for (let candidate of candidates) { + // Title is too short for us to care about, simply continue. + if ( + !candidate.title || + candidate.title.length < this.similarTitlesMinChars + ) { + continue; + } + let titleBeginning = candidate.title.slice(0, this.similarTitlesMinChars); + let matches = longTitles.get(titleBeginning); + if (matches) { + for (let match of matches) { + let startDiff = findStartDifference(candidate.title, match.title); + if (startDiff > 0) { + candidate.titleDifferentIndex = startDiff; + // If we have an existing difference index for the match, move + // it forward if this one is earlier in the string. + if ( + !("titleDifferentIndex" in match) || + match.titleDifferentIndex > startDiff + ) { + match.titleDifferentIndex = startDiff; + } + } + } + + matches.push(candidate); + } else { + longTitles.set(titleBeginning, [candidate]); + } + } + }, +}; + +/** + * Promise used by the toolbar view browser-places to determine whether we + * can start loading its content (which involves IO, and so is postponed + * during startup). + */ +PlacesUIUtils.canLoadToolbarContentPromise = new Promise(resolve => { + PlacesUIUtils.unblockToolbars = resolve; +}); + +// These are lazy getters to avoid importing PlacesUtils immediately. +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => { + return [ + lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + lazy.PlacesUtils.TYPE_X_MOZ_PLACE, + ]; +}); +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => { + return [ + lazy.PlacesUtils.TYPE_X_MOZ_URL, + TAB_DROP_TYPE, + lazy.PlacesUtils.TYPE_PLAINTEXT, + ]; +}); +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => { + return [...PlacesUIUtils.PLACES_FLAVORS, ...PlacesUIUtils.URI_FLAVORS]; +}); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function () { + return Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; +}); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "promptLocalization", () => { + return new Localization( + ["browser/placesPrompts.ftl", "branding/brand.ftl"], + true + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "similarTitlesMinChars", + "browser.places.similarTitlesMinChars", + 20 +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "loadBookmarksInBackground", + "browser.tabs.loadBookmarksInBackground", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "loadBookmarksInTabs", + "browser.tabs.loadBookmarksInTabs", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "openInTabClosesMenu", + "browser.bookmarks.openInTabClosesMenu", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "maxRecentFolders", + "browser.bookmarks.editDialog.maxRecentFolders", + 7 +); + +XPCOMUtils.defineLazyPreferenceGetter( + PlacesUIUtils, + "defaultParentGuid", + "browser.bookmarks.defaultLocation", + "", // Avoid eagerly loading PlacesUtils. + null, + async prefValue => { + if (!prefValue) { + return lazy.PlacesUtils.bookmarks.toolbarGuid; + } + if (["toolbar", "menu", "unfiled"].includes(prefValue)) { + return lazy.PlacesUtils.bookmarks[prefValue + "Guid"]; + } + + try { + return await lazy.PlacesUtils.bookmarks + .fetch({ guid: prefValue }) + .then(bm => bm.guid); + } catch (ex) { + // The guid may have an invalid format. + return lazy.PlacesUtils.bookmarks.toolbarGuid; + } + } +); + +/** + * Determines if an unwrapped node can be moved. + * + * @param {object} unwrappedNode + * A node unwrapped by PlacesUtils.unwrapNodes(). + * @returns {boolean} True if the node can be moved, false otherwise. + */ +function canMoveUnwrappedNode(unwrappedNode) { + if ( + (unwrappedNode.concreteGuid && + lazy.PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) || + (unwrappedNode.guid && lazy.PlacesUtils.isRootItem(unwrappedNode.guid)) + ) { + return false; + } + + let parentGuid = unwrappedNode.parentGuid; + if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) { + return false; + } + + return true; +} + +/** + * This gets the most appropriate item for using for batching. In the case of multiple + * views being related, the method returns the most expensive result to batch. + * For example, if it detects the left-hand library pane, then it will look for + * and return the reference to the right-hand pane. + * + * @param {object} viewOrElement The item to check. + * @returns {object} Will return the best result node to batch, or null + * if one could not be found. + */ +function getResultForBatching(viewOrElement) { + if ( + viewOrElement && + Element.isInstance(viewOrElement) && + viewOrElement.id === "placesList" + ) { + // Note: fall back to the existing item if we can't find the right-hane pane. + viewOrElement = + viewOrElement.ownerDocument.getElementById("placeContent") || + viewOrElement; + } + + if (viewOrElement && viewOrElement.result) { + return viewOrElement.result; + } + + return null; +} + +/** + * Processes a set of transfer items and returns transactions to insert or + * move them. + * + * @param {Array} items A list of unwrapped nodes to get transactions for. + * @param {number} insertionIndex The requested index for insertion. + * @param {string} insertionParentGuid The guid of the parent folder to insert + * or move the items to. + * @param {boolean} doMove Set to true to MOVE the items if possible, false will + * copy them. + * @returns {Array} Returns an array of created PlacesTransactions. + */ +function getTransactionsForTransferItems( + items, + insertionIndex, + insertionParentGuid, + doMove +) { + let canMove = true; + for (let item of items) { + if (!PlacesUIUtils.SUPPORTED_FLAVORS.includes(item.type)) { + throw new Error(`Unsupported '${item.type}' data type`); + } + + // Work out if this is data from the same app session we're running in. + if ( + !("instanceId" in item) || + item.instanceId != lazy.PlacesUtils.instanceId + ) { + if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + throw new Error( + "Can't copy a container from a legacy-transactions build" + ); + } + // Only log if this is one of "our" types as external items, e.g. drag from + // url bar to toolbar, shouldn't complain. + if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type)) { + console.error( + "Tried to move an unmovable Places " + + "node, reverting to a copy operation." + ); + } + + // We can never move from an external copy. + canMove = false; + } + + if (doMove && canMove) { + canMove = canMoveUnwrappedNode(item); + } + } + + if (doMove && !canMove) { + doMove = false; + } + + if (doMove) { + // Move is simple, we pass the transaction a list of GUIDs and where to move + // them to. + return [ + lazy.PlacesTransactions.Move({ + guids: items.map(item => item.itemGuid), + newParentGuid: insertionParentGuid, + newIndex: insertionIndex, + }), + ]; + } + + return getTransactionsForCopy(items, insertionIndex, insertionParentGuid); +} + +/** + * Processes a set of transfer items and returns an array of transactions. + * + * @param {Array} items A list of unwrapped nodes to get transactions for. + * @param {number} insertionIndex The requested index for insertion. + * @param {string} insertionParentGuid The guid of the parent folder to insert + * or move the items to. + * @returns {Array} Returns an array of created PlacesTransactions. + */ +function getTransactionsForCopy(items, insertionIndex, insertionParentGuid) { + let transactions = []; + let index = insertionIndex; + + for (let item of items) { + let transaction; + let guid = item.itemGuid; + + if ( + PlacesUIUtils.PLACES_FLAVORS.includes(item.type) && + // For anything that is comming from within this session, we do a + // direct copy, otherwise we fallback and form a new item below. + "instanceId" in item && + item.instanceId == lazy.PlacesUtils.instanceId && + // If the Item doesn't have a guid, this could be a virtual tag query or + // other item, so fallback to inserting a new bookmark with the URI. + guid && + // For virtual root items, we fallback to creating a new bookmark, as + // we want a shortcut to be created, not a full tree copy. + !lazy.PlacesUtils.bookmarks.isVirtualRootItem(guid) && + !lazy.PlacesUtils.isVirtualLeftPaneItem(guid) + ) { + transaction = lazy.PlacesTransactions.Copy({ + guid, + newIndex: index, + newParentGuid: insertionParentGuid, + }); + } else if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { + transaction = lazy.PlacesTransactions.NewSeparator({ + index, + parentGuid: insertionParentGuid, + }); + } else { + let title = + item.type != lazy.PlacesUtils.TYPE_PLAINTEXT ? item.title : item.uri; + transaction = lazy.PlacesTransactions.NewBookmark({ + index, + parentGuid: insertionParentGuid, + title, + url: item.uri, + }); + } + + transactions.push(transaction); + + if (index != -1) { + index++; + } + } + return transactions; +} + +function getBrowserWindow(aWindow) { + // Prefer the caller window if it's a browser window, otherwise use + // the top browser window. + return aWindow && + aWindow.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ? aWindow + : lazy.BrowserWindowTracker.getTopWindow(); +} diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js new file mode 100644 index 0000000000..bc98820a7b --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.js @@ -0,0 +1,519 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/** + * The panel is initialized based on data given in the js object passed + * as window.arguments[0]. The object must have the following fields set: + * @ action (String). Possible values: + * - "add" - for adding a new item. + * @ type (String). Possible values: + * - "bookmark" + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * @ uri (nsIURI object) - optional, the default uri for the new item. + * The property is not used for the "folder with items" type. + * @ title (String) - optional, the default title for the new item. + * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the + * default insertion point for the new item. + * @ keyword (String) - optional, the default keyword for the new item. + * @ postData (String) - optional, POST data to accompany the keyword. + * @ charSet (String) - optional, character-set to accompany the keyword. + * Notes: + * 1) If |uri| is set for a bookmark and |title| isn't, + * the dialog will query the history tables for the title associated + * with the given uri. If the dialog is set to adding a folder with + * bookmark items under it (see URIList), a default static title is + * used ("[Folder Name]"). + * 2) The index field of the default insertion point is ignored if + * the folder picker is shown. + * - "edit" - for editing a bookmark item or a folder. + * @ type (String). Possible values: + * - "bookmark" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the bookmark. + * - "folder" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the folder. + * @ hiddenRows (Strings array) - optional, list of rows to be hidden + * regardless of the item edited or added by the dialog. + * Possible values: + * - "title" + * - "location" + * - "keyword" + * - "tags" + * - "folderPicker" - hides both the tree and the menu. + * + * window.arguments[0].bookmarkGuid is set to the guid of the item, if the + * dialog is accepted. + */ + +/* import-globals-from editBookmark.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var BookmarkPropertiesPanel = { + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _uri: null, + _title: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + + _defaultInsertionPoint: null, + _hiddenRows: [], + + /** + * @returns {string} + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function BPP__getAcceptLabel() { + return this._strings.getString("dialogAcceptLabelSaveItem"); + }, + + /** + * @returns {string} + * This method returns the correct title for the current variant + * of this dialog. + */ + _getDialogTitle: function BPP__getDialogTitle() { + if (this._action == ACTION_ADD) { + if (this._itemType == BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleAddNewBookmark2"); + } + + // add folder + if (this._itemType != BOOKMARK_FOLDER) { + throw new Error("Unknown item type"); + } + if (this._URIs.length) { + return this._strings.getString("dialogTitleAddMulti"); + } + + return this._strings.getString("dialogTitleAddBookmarkFolder"); + } + if (this._action == ACTION_EDIT) { + if (this._itemType === BOOKMARK_ITEM) { + return this._strings.getString("dialogTitleEditBookmark2"); + } + + return this._strings.getString("dialogTitleEditBookmarkFolder"); + } + return ""; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + async _determineItemInfo() { + let dialogInfo = window.arguments[0]; + this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; + this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; + if (this._action == ACTION_ADD) { + if (!("type" in dialogInfo)) { + throw new Error("missing type property for add action"); + } + + if ("title" in dialogInfo) { + this._title = dialogInfo.title; + } + + if ("defaultInsertionPoint" in dialogInfo) { + this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; + } else { + let parentGuid = await PlacesUIUtils.defaultParentGuid; + this._defaultInsertionPoint = new PlacesInsertionPoint({ + parentGuid, + }); + } + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + if (!(dialogInfo.uri instanceof Ci.nsIURI)) { + throw new Error("uri property should be a uri object"); + } + this._uri = dialogInfo.uri; + if (typeof this._title != "string") { + this._title = + (await PlacesUtils.history.fetch(this._uri)) || this._uri.spec; + } + } else { + this._uri = Services.io.newURI("about:blank"); + this._title = this._strings.getString("newBookmarkDefault"); + this._dummyItem = true; + } + + if ("keyword" in dialogInfo) { + this._keyword = dialogInfo.keyword; + this._isAddKeywordDialog = true; + if ("postData" in dialogInfo) { + this._postData = dialogInfo.postData; + } + if ("charSet" in dialogInfo) { + this._charSet = dialogInfo.charSet; + } + } + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + if (!this._title) { + if ("URIList" in dialogInfo) { + this._title = this._strings.getString("bookmarkAllTabsDefault"); + this._URIs = dialogInfo.URIList; + } else { + this._title = this._strings.getString("newFolderDefault"); + this._dummyItem = true; + } + } + break; + } + } else { + // edit + this._node = dialogInfo.node; + this._title = this._node.title; + if (PlacesUtils.nodeIsFolder(this._node)) { + this._itemType = BOOKMARK_FOLDER; + } else if (PlacesUtils.nodeIsURI(this._node)) { + this._itemType = BOOKMARK_ITEM; + } + } + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + async onDialogLoad() { + document.addEventListener("dialogaccept", function () { + BookmarkPropertiesPanel.onDialogAccept(); + }); + document.addEventListener("dialogcancel", function () { + BookmarkPropertiesPanel.onDialogCancel(); + }); + + // Disable the buttons until we have all the information required. + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.disabled = true; + await this._determineItemInfo(); + document.title = this._getDialogTitle(); + + // Set adjustable title + let title = { raw: document.title }; + document.documentElement.setAttribute("headertitle", JSON.stringify(title)); + + let iconUrl = this._getIconUrl(); + if (iconUrl) { + document.documentElement.style.setProperty( + "--icon-url", + `url(${iconUrl})` + ); + } + + await this._initDialog(); + }, + + _getIconUrl() { + let url = "chrome://browser/skin/bookmark-hollow.svg"; + + if (this._action === ACTION_EDIT && this._itemType === BOOKMARK_ITEM) { + url = window.arguments[0]?.node?.icon; + } + + return url; + }, + + /** + * Initializes the dialog, gathering the required bookmark data. This function + * will enable the accept button (if appropraite) when it is complete. + */ + async _initDialog() { + let acceptButton = document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + let acceptButtonDisabled = false; + + // Since elements can be unhidden asynchronously, we must observe their + // mutations and resize the dialog accordingly. + this._mutationObserver = new MutationObserver(mutations => { + for (let { target, oldValue } of mutations) { + let hidden = target.getAttribute("hidden") == "true"; + if ( + target.classList.contains("hideable") && + hidden != (oldValue == "true") + ) { + // To support both kind of dialogs (window and dialog-box) we need + // both resizeBy and sizeToContent, otherwise either the dialog + // doesn't resize, or it gets empty unused space. + if (hidden) { + let diff = this._mutationObserver._heightsById.get(target.id); + window.resizeBy(0, -diff); + } else { + let diff = target.getBoundingClientRect().height; + this._mutationObserver._heightsById.set(target.id, diff); + window.resizeBy(0, diff); + } + window.sizeToContent(); + } + } + }); + this._mutationObserver._heightsById = new Map(); + this._mutationObserver.observe(document, { + subtree: true, + attributeOldValue: true, + attributeFilter: ["hidden"], + }); + + switch (this._action) { + case ACTION_EDIT: + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + focusedElement: "first", + }); + acceptButtonDisabled = gEditItemOverlay.readOnly; + break; + case ACTION_ADD: + this._node = await this._promiseNewItem(); + // Edit the new item + await gEditItemOverlay.initPanel({ + node: this._node, + hiddenRows: this._hiddenRows, + postData: this._postData, + focusedElement: "first", + }); + + // Empty location field if the uri is about:blank, this way inserting a new + // url will be easier for the user, Accept button will be automatically + // disabled by the input listener until the user fills the field. + let locationField = this._element("locationField"); + if (locationField.value == "about:blank") { + locationField.value = ""; + } + + // if this is an uri related dialog disable accept button until + // the user fills an uri value. + if (this._itemType == BOOKMARK_ITEM) { + acceptButtonDisabled = !this._inputIsValid(); + } + break; + } + + if (!gEditItemOverlay.readOnly) { + // Listen on uri fields to enable accept button if input is valid + if (this._itemType == BOOKMARK_ITEM) { + this._element("locationField").addEventListener("input", this); + if (this._isAddKeywordDialog) { + this._element("keywordField").addEventListener("input", this); + } + } + } + // Only enable the accept button once we've finished everything. + acceptButton.disabled = acceptButtonDisabled; + }, + + // EventListener + handleEvent: function BPP_handleEvent(aEvent) { + var target = aEvent.target; + switch (aEvent.type) { + case "input": + if ( + target.id == "editBMPanel_locationField" || + target.id == "editBMPanel_keywordField" + ) { + // Check uri fields to enable accept button if input is valid + document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + } + }, + + // nsISupports + QueryInterface: ChromeUtils.generateQI([]), + + _element: function BPP__element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + onDialogUnload() { + // gEditItemOverlay does not exist anymore here, so don't rely on it. + this._mutationObserver.disconnect(); + delete this._mutationObserver; + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField").removeEventListener("input", this); + this._element("keywordField").removeEventListener("input", this); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement?.blur(); + + // Get the states to compare bookmark and editedBookmark + window.arguments[0].bookmarkState = gEditItemOverlay._bookmarkState; + + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + + window.arguments[0].bookmarkGuid = this._node.bookmarkGuid; + }, + + onDialogCancel() { + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns {boolean} true if the input is valid, false otherwise + */ + _inputIsValid: function BPP__inputIsValid() { + if ( + this._itemType == BOOKMARK_ITEM && + !this._containsValidURI("locationField") + ) { + return false; + } + if ( + this._isAddKeywordDialog && + !this._element("keywordField").value.length + ) { + return false; + } + + return true; + }, + + /** + * Determines whether the input with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param {number} aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns {boolean} true if the textbox contains a valid URI string, false otherwise + */ + _containsValidURI: function BPP__containsValidURI(aTextboxID) { + try { + var value = this._element(aTextboxID).value; + if (value) { + Services.uriFixup.getFixupURIInfo(value); + return true; + } + } catch (e) {} + return false; + }, + + /** + * [New Item Mode] Get the insertion point details for the new item, given + * dialog state and opening arguments. + * + * The container-identifier and insertion-index are returned separately in + * the form of [containerIdentifier, insertionIndex] + */ + async _getInsertionPointDetails() { + return [ + await this._defaultInsertionPoint.getIndex(), + this._defaultInsertionPoint.guid, + ]; + }, + + async _promiseNewItem() { + let [index, parentGuid] = await this._getInsertionPointDetails(); + + let info = { parentGuid, index, title: this._title }; + if (this._itemType == BOOKMARK_ITEM) { + info.url = this._uri; + if (this._keyword) { + info.keyword = this._keyword; + } + if (this._postData) { + info.postData = this._postData; + } + + if (this._charSet) { + PlacesUIUtils.setCharsetForPage(this._uri, this._charSet, window).catch( + console.error + ); + } + } else if (this._itemType == BOOKMARK_FOLDER) { + // NewFolder requires a url rather than uri. + info.children = this._URIs.map(item => { + return { url: item.uri, title: item.title }; + }); + } else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + return Object.freeze({ + index, + bookmarkGuid: PlacesUtils.bookmarks.unsavedGuid, + title: this._title, + uri: this._uri ? this._uri.spec : "", + type: + this._itemType == BOOKMARK_ITEM + ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI + : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + parent: { + bookmarkGuid: parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + }, + children: info.children, + }); + }, +}; + +document.addEventListener("DOMContentLoaded", function () { + // Content initialization is asynchronous, thus set mozSubdialogReady + // immediately to properly wait for it. + document.mozSubdialogReady = BookmarkPropertiesPanel.onDialogLoad() + .catch(ex => console.error(`Failed to initialize dialog: ${ex}`)) + .then(() => window.sizeToContent()); +}); diff --git a/browser/components/places/content/bookmarkProperties.xhtml b/browser/components/places/content/bookmarkProperties.xhtml new file mode 100644 index 0000000000..44ab82849e --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.xhtml @@ -0,0 +1,46 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://global/content/commonDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://global/skin/commonDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> + +<!DOCTYPE window> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + id="bookmarkproperties" + headerparent="bookmarkpropertiesdialog" + neediconheader="true" + onunload="BookmarkPropertiesPanel.onDialogUnload();" + style="min-width: 40em;"> +<dialog id="bookmarkpropertiesdialog" + buttons="accept, cancel"> + + <linkset> + <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/> + </linkset> + + <stringbundleset id="stringbundleset"> + <stringbundle id="stringBundle" + src="chrome://browser/locale/places/bookmarkProperties.properties"/> + </stringbundleset> + + <script src="chrome://browser/content/places/editBookmark.js"/> + <script src="chrome://browser/content/places/bookmarkProperties.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/adjustableTitle.js"/> + +#include editBookmarkPanel.inc.xhtml + +</dialog> +</window> diff --git a/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml new file mode 100644 index 0000000000..445f2cdcb4 --- /dev/null +++ b/browser/components/places/content/bookmarksHistoryTooltip.inc.xhtml @@ -0,0 +1,14 @@ +# 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/. + +<!-- Bookmarks and history tooltip --> +<tooltip id="bhTooltip" noautohide="true" + class="places-tooltip" + onpopupshowing="return window.top.BookmarksEventHandler.fillInBHTooltip(this, event)" + onpopuphiding="this.removeAttribute('position')"> + <box class="places-tooltip-box"> + <description class="tooltip-label places-tooltip-title"/> + <description crop="center" class="tooltip-label places-tooltip-uri uri-element"/> + </box> +</tooltip> diff --git a/browser/components/places/content/bookmarksSidebar.js b/browser/components/places/content/bookmarksSidebar.js new file mode 100644 index 0000000000..5da984543b --- /dev/null +++ b/browser/components/places/content/bookmarksSidebar.js @@ -0,0 +1,78 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ +var gCumulativeSearches = 0; + +function init() { + let uidensity = window.top.document.documentElement.getAttribute("uidensity"); + if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); + } + + document.getElementById("bookmarks-view").place = + "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; +} + +function searchBookmarks(aSearchString) { + var tree = document.getElementById("bookmarks-view"); + if (!aSearchString) { + // eslint-disable-next-line no-self-assign + tree.place = tree.place; + } else { + Services.telemetry.keyedScalarAdd("sidebar.search", "bookmarks", 1); + gCumulativeSearches++; + tree.applyFilter(aSearchString, PlacesUtils.bookmarks.userContentRoots); + } +} + +function updateTelemetry(urlsOpened = []) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_BOOKMARKS_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add(gCumulativeSearches); + clearCumulativeCounter(); + + Services.telemetry.keyedScalarAdd( + "sidebar.link", + "bookmarks", + urlsOpened.length + ); +} + +function clearCumulativeCounter() { + gCumulativeSearches = 0; +} + +function unloadBookmarksSidebar() { + clearCumulativeCounter(); + PlacesUIUtils.setMouseoverURL("", window); +} + +window.addEventListener("SidebarFocused", () => + document.getElementById("search-box").focus() +); diff --git a/browser/components/places/content/bookmarksSidebar.xhtml b/browser/components/places/content/bookmarksSidebar.xhtml new file mode 100644 index 0000000000..a3dd32ea9f --- /dev/null +++ b/browser/components/places/content/bookmarksSidebar.xhtml @@ -0,0 +1,64 @@ +<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/sidebar.css"?> + +<!DOCTYPE window> + +<window id="bookmarksPanel" + class="sidebar-panel" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="init();" + onunload="unloadBookmarksSidebar();" + data-l10n-id="bookmarks-sidebar-content"> + + <script src="chrome://browser/content/places/bookmarksSidebar.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/contentTheme.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <linkset> + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/browser.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + </linkset> + +#include placesCommands.inc.xhtml +#include placesContextMenu.inc.xhtml +#include bookmarksHistoryTooltip.inc.xhtml + + <hbox id="sidebar-search-container" align="center"> + <search-textbox id="search-box" flex="1" + data-l10n-id="places-bookmarks-search" + data-l10n-attrs="placeholder" + aria-controls="bookmarks-view" + oncommand="searchBookmarks(this.value);"/> + </hbox> + + <tree id="bookmarks-view" + class="sidebar-placesTree" + is="places-tree" + flex="1" + hidecolumnpicker="true" + context="placesContext" + singleclickopens="true" + onkeypress="PlacesUIUtils.onSidebarTreeKeyPress(event);" + onclick="PlacesUIUtils.onSidebarTreeClick(event);" + onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);" + onmouseout="PlacesUIUtils.setMouseoverURL('', window);"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren view="bookmarks-view" + class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</window> diff --git a/browser/components/places/content/browserPlacesViews.js b/browser/components/places/content/browserPlacesViews.js new file mode 100644 index 0000000000..d7c3518e76 --- /dev/null +++ b/browser/components/places/content/browserPlacesViews.js @@ -0,0 +1,2285 @@ +/* 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 */ + +/** + * The base view implements everything that's common to all the views. + * It should not be instanced directly, use a derived class instead. + */ +class PlacesViewBase { + /** + * @param {string} placesUrl + * The query string associated with the view. + * @param {DOMElement} rootElt + * The root element for the view. + * @param {DOMElement} viewElt + * The view element. + */ + constructor(placesUrl, rootElt, viewElt) { + this._rootElt = rootElt; + this._viewElt = viewElt; + let appendClass = this._rootElt.getAttribute("appendclasstochildren"); + if (appendClass) { + this._appendClassToChildren = appendClass; + } + // Do initialization in subclass now that `this` exists. + this._init?.(); + this._controller = new PlacesController(this); + this.place = placesUrl; + this._viewElt.controllers.appendController(this._controller); + } + + // The xul element that holds the entire view. + _viewElt = null; + + get associatedElement() { + return this._viewElt; + } + + get controllers() { + return this._viewElt.controllers; + } + + // The xul element that represents the root container. + _rootElt = null; + + // Set to true for views that are represented by native widgets (i.e. + // the native mac menu). + _nativeView = false; + + static interfaces = [ + Ci.nsINavHistoryResultObserver, + Ci.nsISupportsWeakReference, + ]; + + QueryInterface = ChromeUtils.generateQI(PlacesViewBase.interfaces); + + _place = ""; + get place() { + return this._place; + } + set place(val) { + this._place = val; + + let history = PlacesUtils.history; + let query = {}, + options = {}; + history.queryStringToQuery(val, query, options); + let result = history.executeQuery(query.value, options.value); + result.addObserver(this); + } + + _result = null; + get result() { + return this._result; + } + set result(val) { + if (this._result == val) { + return; + } + + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + } + + if (this._rootElt.localName == "menupopup") { + this._rootElt._built = false; + } + + this._result = val; + if (val) { + this._resultNode = val.root; + this._rootElt._placesNode = this._resultNode; + this._domNodes = new Map(); + this._domNodes.set(this._resultNode, this._rootElt); + + // This calls _rebuild through invalidateContainer. + this._resultNode.containerOpen = true; + } else { + this._resultNode = null; + delete this._domNodes; + } + } + + /** + * Gets the DOM node used for the given places node. + * + * @param {object} aPlacesNode + * a places result node. + * @param {boolean} aAllowMissing + * whether the node may be missing + * @returns {object|null} The associated DOM node. + * @throws if there is no DOM node set for aPlacesNode. + */ + _getDOMNodeForPlacesNode(aPlacesNode, aAllowMissing = false) { + let node = this._domNodes.get(aPlacesNode, null); + if (!node && !aAllowMissing) { + throw new Error( + "No DOM node set for aPlacesNode.\nnode.type: " + + aPlacesNode.type + + ". node.parent: " + + aPlacesNode + ); + } + return node; + } + + get controller() { + return this._controller; + } + + get selType() { + return "single"; + } + selectItems() {} + selectAll() {} + + get selectedNode() { + if (this._contextMenuShown) { + let anchor = this._contextMenuShown.triggerNode; + if (!anchor) { + return null; + } + + if (anchor._placesNode) { + return this._rootElt == anchor ? null : anchor._placesNode; + } + + anchor = anchor.parentNode; + return this._rootElt == anchor ? null : anchor._placesNode || null; + } + return null; + } + + get hasSelection() { + return this.selectedNode != null; + } + + get selectedNodes() { + let selectedNode = this.selectedNode; + return selectedNode ? [selectedNode] : []; + } + + get singleClickOpens() { + return true; + } + + get removableSelectionRanges() { + // On static content the current selectedNode would be the selection's + // parent node. We don't want to allow removing a node when the + // selection is not explicit. + let popupNode = PlacesUIUtils.lastContextMenuTriggerNode; + if (popupNode && (popupNode == "menupopup" || !popupNode._placesNode)) { + return []; + } + + return [this.selectedNodes]; + } + + get draggableSelection() { + return [this._draggedElt]; + } + + get insertionPoint() { + // There is no insertion point for history queries, so bail out now and + // save a lot of work when updating commands. + let resultNode = this._resultNode; + if ( + PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + return null; + } + + // By default, the insertion point is at the top level, at the end. + let index = PlacesUtils.bookmarks.DEFAULT_INDEX; + let container = this._resultNode; + let orientation = Ci.nsITreeView.DROP_BEFORE; + let tagName = null; + + let selectedNode = this.selectedNode; + if (selectedNode) { + let popupNode = PlacesUIUtils.lastContextMenuTriggerNode; + if ( + !popupNode._placesNode || + popupNode._placesNode == this._resultNode || + popupNode._placesNode.itemId == -1 || + !selectedNode.parent + ) { + // If a static menuitem is selected, or if the root node is selected, + // the insertion point is inside the folder, at the end. + container = selectedNode; + orientation = Ci.nsITreeView.DROP_ON; + } else { + // In all other cases the insertion point is before that node. + container = selectedNode.parent; + index = container.getChildIndex(selectedNode); + if (PlacesUtils.nodeIsTagQuery(container)) { + tagName = PlacesUtils.asQuery(container).query.tags[0]; + } + } + } + + if (this.controller.disallowInsertion(container)) { + return null; + } + + return new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, + orientation, + tagName, + }); + } + + buildContextMenu(aPopup) { + this._contextMenuShown = aPopup; + window.updateCommands("places"); + + // Ensure that an existing "Show Other Bookmarks" item is removed before adding it + // again. + let existingOtherBookmarksItem = aPopup.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + existingOtherBookmarksItem?.remove(); + + let manageBookmarksMenu = aPopup.querySelector( + "#placesContext_showAllBookmarks" + ); + // Add the View menu for the Bookmarks Toolbar and "Show Other Bookmarks" menu item + // if the click originated from the Bookmarks Toolbar. + let existingSubmenu = aPopup.querySelector("#toggle_PersonalToolbar"); + existingSubmenu?.remove(); + let bookmarksToolbar = document.getElementById("PersonalToolbar"); + if (bookmarksToolbar?.contains(aPopup.triggerNode)) { + manageBookmarksMenu.removeAttribute("hidden"); + + let menu = BookmarkingUI.buildBookmarksToolbarSubmenu(bookmarksToolbar); + aPopup.insertBefore(menu, manageBookmarksMenu); + + if ( + aPopup.triggerNode.id === "OtherBookmarks" || + aPopup.triggerNode.id === "PlacesChevron" || + aPopup.triggerNode.id === "PlacesToolbarItems" || + aPopup.triggerNode.parentNode.id === "PlacesToolbarItems" + ) { + let otherBookmarksMenuItem = + BookmarkingUI.buildShowOtherBookmarksMenuItem(); + + if (otherBookmarksMenuItem) { + aPopup.insertBefore(otherBookmarksMenuItem, menu.nextElementSibling); + } + } + } else { + manageBookmarksMenu.setAttribute("hidden", "true"); + } + + return this.controller.buildContextMenu(aPopup); + } + + destroyContextMenu(aPopup) { + this._contextMenuShown = null; + } + + clearAllContents(aPopup) { + let kid = aPopup.firstElementChild; + while (kid) { + let next = kid.nextElementSibling; + if (!kid.classList.contains("panel-header")) { + kid.remove(); + } + kid = next; + } + aPopup._emptyMenuitem = aPopup._startMarker = aPopup._endMarker = null; + } + + _cleanPopup(aPopup, aDelay) { + // Ensure markers are here when `invalidateContainer` is called before the + // popup is shown, which may the case for panelviews, for example. + this._ensureMarkers(aPopup); + // Remove Places nodes from the popup. + let child = aPopup._startMarker; + while (child.nextElementSibling != aPopup._endMarker) { + let sibling = child.nextElementSibling; + if (sibling._placesNode && !aDelay) { + aPopup.removeChild(sibling); + } else if (sibling._placesNode && aDelay) { + // HACK (bug 733419): the popups originating from the OS X native + // menubar don't live-update while open, thus we don't clean it + // until the next popupshowing, to avoid zombie menuitems. + if (!aPopup._delayedRemovals) { + aPopup._delayedRemovals = []; + } + aPopup._delayedRemovals.push(sibling); + child = child.nextElementSibling; + } else { + child = child.nextElementSibling; + } + } + } + + _rebuildPopup(aPopup) { + let resultNode = aPopup._placesNode; + if (!resultNode.containerOpen) { + return; + } + + this._cleanPopup(aPopup); + + let cc = resultNode.childCount; + if (cc > 0) { + this._setEmptyPopupStatus(aPopup, false); + let fragment = document.createDocumentFragment(); + for (let i = 0; i < cc; ++i) { + let child = resultNode.getChild(i); + this._insertNewItemToPopup(child, fragment); + } + aPopup.insertBefore(fragment, aPopup._endMarker); + } else { + this._setEmptyPopupStatus(aPopup, true); + } + aPopup._built = true; + } + + _removeChild(aChild) { + aChild.remove(); + } + + _setEmptyPopupStatus(aPopup, aEmpty) { + if (!aPopup._emptyMenuitem) { + aPopup._emptyMenuitem = document.createXULElement("menuitem"); + aPopup._emptyMenuitem.setAttribute("disabled", true); + aPopup._emptyMenuitem.className = "bookmark-item"; + document.l10n.setAttributes( + aPopup._emptyMenuitem, + "places-empty-bookmarks-folder" + ); + if (this._appendClassToChildren) { + aPopup._emptyMenuitem.classList.add(this._appendClassToChildren); + } + } + + if (aEmpty) { + aPopup.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + if ( + !aPopup._startMarker.previousElementSibling && + !aPopup._endMarker.nextElementSibling + ) { + aPopup.insertBefore(aPopup._emptyMenuitem, aPopup._endMarker); + } + } else { + aPopup.removeAttribute("emptyplacesresult"); + try { + aPopup.removeChild(aPopup._emptyMenuitem); + } catch (ex) {} + } + } + + _createDOMNodeForPlacesNode(aPlacesNode) { + this._domNodes.delete(aPlacesNode); + + let element; + let type = aPlacesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createXULElement("menuseparator"); + } else { + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) { + element = document.createXULElement("menuitem"); + element.className = + "menuitem-iconic bookmark-item menuitem-with-favicon"; + element.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri) + ); + } else if (PlacesUtils.containerTypes.includes(type)) { + element = document.createXULElement("menu"); + element.setAttribute("container", "true"); + + if (aPlacesNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + element.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aPlacesNode)) { + element.setAttribute("tagContainer", "true"); + } else if (PlacesUtils.nodeIsDay(aPlacesNode)) { + element.setAttribute("dayContainer", "true"); + } else if (PlacesUtils.nodeIsHost(aPlacesNode)) { + element.setAttribute("hostContainer", "true"); + } + } + + let popup = document.createXULElement("menupopup", { + is: "places-popup", + }); + popup._placesNode = PlacesUtils.asContainer(aPlacesNode); + + if (!this._nativeView) { + popup.setAttribute("placespopup", "true"); + } + + element.appendChild(popup); + element.className = "menu-iconic bookmark-item"; + if (this._appendClassToChildren) { + element.classList.add(this._appendClassToChildren); + } + + this._domNodes.set(aPlacesNode, popup); + } else { + throw new Error("Unexpected node"); + } + + element.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + + let icon = aPlacesNode.icon; + if (icon) { + element.setAttribute("image", icon); + } + } + + element._placesNode = aPlacesNode; + if (!this._domNodes.has(aPlacesNode)) { + this._domNodes.set(aPlacesNode, element); + } + + return element; + } + + _insertNewItemToPopup(aNewChild, aInsertionNode, aBefore = null) { + let element = this._createDOMNodeForPlacesNode(aNewChild); + + if (element.localName == "menuitem" || element.localName == "menu") { + if (this._appendClassToChildren) { + element.classList.add(this._appendClassToChildren); + } + } + + aInsertionNode.insertBefore(element, aBefore); + return element; + } + + toggleCutNode(aPlacesNode, aValue) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // We may get the popup for menus, but we need the menu itself. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + if (aValue) { + elt.setAttribute("cutting", "true"); + } else { + elt.removeAttribute("cutting"); + } + } + + nodeURIChanged(aPlacesNode, aURIString) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // There's no DOM node, thus there's nothing to be done when the URI changes. + if (!elt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + elt.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri) + ); + } + + nodeIconChanged(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // There's no UI representation for the root node, or there's no DOM node, + // thus there's nothing to be done when the icon changes. + if (!elt || elt == this._rootElt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + // We must remove and reset the attribute to force an update. + elt.removeAttribute("image"); + elt.setAttribute("image", aPlacesNode.icon); + } + + nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's + // nothing to be done when the title changes. + if (elt == this._rootElt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (!aNewTitle && elt.localName != "toolbarbutton") { + // Many users consider toolbars as shortcuts containers, so explicitly + // allow empty labels on toolbarbuttons. For any other element try to be + // smarter, guessing a title from the uri. + elt.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + } else { + elt.setAttribute("label", aNewTitle); + } + } + + nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (parentElt._built) { + parentElt.removeChild(elt); + + // Figure out if we need to show the "<Empty>" menu-item. + // TODO Bug 517701: This doesn't seem to handle the case of an empty + // root. + if (parentElt._startMarker.nextElementSibling == parentElt._endMarker) { + this._setEmptyPopupStatus(parentElt, true); + } + } + } + + // Opt-out of history details updates, since all the views derived from this + // are not showing them. + skipHistoryDetailsNotifications = true; + nodeHistoryDetailsChanged() {} + nodeTagsChanged() {} + nodeDateAddedChanged() {} + nodeLastModifiedChanged() {} + nodeKeywordChanged() {} + sortingChanged() {} + batching() {} + + nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (!parentElt._built) { + return; + } + + let index = + Array.prototype.indexOf.call(parentElt.children, parentElt._startMarker) + + aIndex + + 1; + this._insertNewItemToPopup( + aPlacesNode, + parentElt, + parentElt.children[index] || parentElt._endMarker + ); + this._setEmptyPopupStatus(parentElt, false); + } + + nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ) { + // Note: the current implementation of moveItem does not actually + // use this notification when the item in question is moved from one + // folder to another. Instead, it calls nodeRemoved and nodeInserted + // for the two folders. Thus, we can assume old-parent == new-parent. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + // If our root node is a folder, it might be moved. There's nothing + // we need to do in that case. + if (elt == this._rootElt) { + return; + } + + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt._built) { + // Move the node. + parentElt.removeChild(elt); + let index = + Array.prototype.indexOf.call( + parentElt.children, + parentElt._startMarker + ) + + aNewIndex + + 1; + parentElt.insertBefore(elt, parentElt.children[index]); + } + } + + containerStateChanged(aPlacesNode, aOldState, aNewState) { + if ( + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED || + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED + ) { + this.invalidateContainer(aPlacesNode); + } + } + + /** + * Checks whether the popup associated with the provided element is open. + * This method may be overridden by classes that extend this base class. + * + * @param {Element} elt + * The element to check. + * @returns {boolean} + */ + _isPopupOpen(elt) { + return !!elt.parentNode.open; + } + + invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + elt._built = false; + + // If the menupopup is open we should live-update it. + if (this._isPopupOpen(elt)) { + this._rebuildPopup(elt); + } + } + + uninit() { + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + this._resultNode = null; + this._result = null; + } + + if (this._controller) { + this._controller.terminate(); + // Removing the controller will fail if it is already no longer there. + // This can happen if the view element was removed/reinserted without + // our knowledge. There is no way to check for that having happened + // without the possibility of an exception. :-( + try { + this._viewElt.controllers.removeController(this._controller); + } catch (ex) { + } finally { + this._controller = null; + } + } + + delete this._viewElt._placesView; + } + + get isRTL() { + if ("_isRTL" in this) { + return this._isRTL; + } + + return (this._isRTL = + document.defaultView.getComputedStyle(this._viewElt).direction == "rtl"); + } + + get ownerWindow() { + return window; + } + + /** + * Adds an "Open All in Tabs" menuitem to the bottom of the popup. + * + * @param {object} aPopup + * a Places popup. + */ + _mayAddCommandsItems(aPopup) { + // The command items are never added to the root popup. + if (aPopup == this._rootElt) { + return; + } + + let hasMultipleURIs = false; + + // Check if the popup contains at least 2 menuitems with places nodes. + // We don't currently support opening multiple uri nodes when they are not + // populated by the result. + if (aPopup._placesNode.childCount > 0) { + let currentChild = aPopup.firstElementChild; + let numURINodes = 0; + while (currentChild) { + if (currentChild.localName == "menuitem" && currentChild._placesNode) { + if (++numURINodes == 2) { + break; + } + } + currentChild = currentChild.nextElementSibling; + } + hasMultipleURIs = numURINodes > 1; + } + + if (!hasMultipleURIs) { + // We don't have to show any option. + if (aPopup._endOptOpenAllInTabs) { + aPopup.removeChild(aPopup._endOptOpenAllInTabs); + aPopup._endOptOpenAllInTabs = null; + + aPopup.removeChild(aPopup._endOptSeparator); + aPopup._endOptSeparator = null; + } + } else if (!aPopup._endOptOpenAllInTabs) { + // Create a separator before options. + aPopup._endOptSeparator = document.createXULElement("menuseparator"); + aPopup._endOptSeparator.className = "bookmarks-actions-menuseparator"; + aPopup.appendChild(aPopup._endOptSeparator); + + // Add the "Open All in Tabs" menuitem. + aPopup._endOptOpenAllInTabs = document.createXULElement("menuitem"); + aPopup._endOptOpenAllInTabs.className = "openintabs-menuitem"; + + if (this._appendClassToChildren) { + aPopup._endOptOpenAllInTabs.classList.add(this._appendClassToChildren); + } + + aPopup._endOptOpenAllInTabs.setAttribute( + "oncommand", + "PlacesUIUtils.openMultipleLinksInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));" + ); + aPopup._endOptOpenAllInTabs.setAttribute( + "label", + gNavigatorBundle.getString("menuOpenAllInTabs.label") + ); + aPopup.appendChild(aPopup._endOptOpenAllInTabs); + } + } + + _ensureMarkers(aPopup) { + if (aPopup._startMarker) { + return; + } + + // Places nodes are appended between _startMarker and _endMarker, that + // are hidden menuseparators. By default they take the whole panel... + aPopup._startMarker = document.createXULElement("menuseparator"); + aPopup._startMarker.hidden = true; + aPopup.insertBefore(aPopup._startMarker, aPopup.firstElementChild); + aPopup._endMarker = document.createXULElement("menuseparator"); + aPopup._endMarker.hidden = true; + aPopup.appendChild(aPopup._endMarker); + + // ...but there can be static content before or after the places nodes, thus + // we move the markers to the right position, by checking for static content + // at the beginning of the view, and for an element with "afterplacescontent" + // attribute. + // TODO: In the future we should just use a container element. + let firstNonStaticNodeFound = false; + for (let child of aPopup.children) { + if (child.hasAttribute("afterplacescontent")) { + aPopup.insertBefore(aPopup._endMarker, child); + break; + } + + // Check for the first Places node that is not a view. + if (child._placesNode && !child._placesView && !firstNonStaticNodeFound) { + firstNonStaticNodeFound = true; + aPopup.insertBefore(aPopup._startMarker, child); + } + } + if (!firstNonStaticNodeFound) { + // Just put the start marker before the end marker. + aPopup.insertBefore(aPopup._startMarker, aPopup._endMarker); + } + } + + _onPopupShowing(aEvent) { + // Avoid handling popupshowing of inner views. + let popup = aEvent.originalTarget; + + this._ensureMarkers(popup); + + // Remove any delayed element, see _cleanPopup for details. + if ("_delayedRemovals" in popup) { + while (popup._delayedRemovals.length) { + popup.removeChild(popup._delayedRemovals.shift()); + } + } + + if (popup._placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + if (!popup._placesNode.containerOpen) { + popup._placesNode.containerOpen = true; + } + if (!popup._built) { + this._rebuildPopup(popup); + } + + this._mayAddCommandsItems(popup); + } + } + + _addEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.addEventListener(aEventNames[i], this, aCapturing); + } + } + + _removeEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.removeEventListener(aEventNames[i], this, aCapturing); + } + } +} + +/** + * Toolbar View implementation. + */ +class PlacesToolbar extends PlacesViewBase { + constructor(placesUrl, rootElt, viewElt) { + let startTime = Date.now(); + super(placesUrl, rootElt, viewElt); + this._addEventListeners(this._dragRoot, this._cbEvents, false); + this._addEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._addEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._addEventListeners(window, ["resize", "unload"], false); + + // If personal-bookmarks has been dragged to the tabs toolbar, + // we have to track addition and removals of tabs, to properly + // recalculate the available space for bookmarks. + // TODO (bug 734730): Use a performant mutation listener when available. + if ( + this._viewElt.parentNode.parentNode == + document.getElementById("TabsToolbar") + ) { + this._addEventListeners( + gBrowser.tabContainer, + ["TabOpen", "TabClose"], + false + ); + } + + Services.telemetry + .getHistogramById("FX_BOOKMARKS_TOOLBAR_INIT_MS") + .add(Date.now() - startTime); + } + + // Called by PlacesViewBase so we can init properties that class + // initialization depends on. PlacesViewBase will assign this.place which + // calls which sets `this.result` through its places observer, which changes + // containerOpen, which calls invalidateContainer(), which calls rebuild(), + // which needs `_overFolder`, `_chevronPopup` and various other things to + // exist. + _init() { + this._overFolder = { + elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null, + }; + + // Add some smart getters for our elements. + let thisView = this; + [ + ["_dropIndicator", "PlacesToolbarDropIndicator"], + ["_chevron", "PlacesChevron"], + ["_chevronPopup", "PlacesChevronPopup"], + ].forEach(function (elementGlobal) { + let [name, id] = elementGlobal; + thisView.__defineGetter__(name, function () { + let element = document.getElementById(id); + if (!element) { + return null; + } + + delete thisView[name]; + return (thisView[name] = element); + }); + }); + + this._viewElt._placesView = this; + + this._dragRoot = BookmarkingUI.toolbar.contains(this._viewElt) + ? BookmarkingUI.toolbar + : this._viewElt; + + this._updatingNodesVisibility = false; + } + + _cbEvents = [ + "dragstart", + "dragover", + "dragleave", + "dragend", + "drop", + "mousemove", + "mouseover", + "mouseout", + "mousedown", + ]; + + QueryInterface = ChromeUtils.generateQI([ + "nsINamed", + "nsITimerCallback", + ...PlacesViewBase.interfaces, + ]); + + uninit() { + if (this._dragRoot) { + this._removeEventListeners(this._dragRoot, this._cbEvents, false); + } + this._removeEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._removeEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._removeEventListeners(window, ["resize", "unload"], false); + this._removeEventListeners( + gBrowser.tabContainer, + ["TabOpen", "TabClose"], + false + ); + + if (this._chevron._placesView) { + this._chevron._placesView.uninit(); + } + + if (this._otherBookmarks?._placesView) { + this._otherBookmarks._placesView.uninit(); + } + + super.uninit(); + } + + _openedMenuButton = null; + _allowPopupShowing = true; + + promiseRebuilt() { + return this._rebuilding?.promise; + } + + get _isAlive() { + return this._resultNode && this._rootElt; + } + + _runBeforeFrameRender(callback) { + return new Promise((resolve, reject) => { + window.requestAnimationFrame(() => { + try { + resolve(callback()); + } catch (err) { + reject(err); + } + }); + }); + } + + async _rebuild() { + // Clear out references to existing nodes, since they will be removed + // and re-added. + if (this._overFolder.elt) { + this._clearOverFolder(); + } + + this._openedMenuButton = null; + while (this._rootElt.hasChildNodes()) { + this._rootElt.firstChild.remove(); + } + + let cc = this._resultNode.childCount; + if (cc > 0) { + // There could be a lot of nodes, but we only want to build the ones that + // are more likely to be shown, not all of them. + // We also don't want to wait for reflows at every node insertion, to + // calculate a precise number of visible items, thus we guess a size from + // the first non-separator node (because separators have flexible size). + let startIndex = 0; + let limit = await this._runBeforeFrameRender(() => { + if (!this._isAlive) { + return cc; + } + + // Look for the first non-separator node. + let elt; + while (startIndex < cc) { + elt = this._insertNewItem( + this._resultNode.getChild(startIndex), + this._rootElt + ); + ++startIndex; + if (elt.localName != "toolbarseparator") { + break; + } + } + if (!elt) { + return cc; + } + + return window.promiseDocumentFlushed(() => { + // We assume a button with just the icon will be more or less a square, + // then compensate the measurement error by considering a larger screen + // width. Moreover the window could be bigger than the screen. + let size = elt.clientHeight || 1; // Sanity fallback. + return Math.min(cc, parseInt((window.screen.width * 1.5) / size)); + }); + }); + + if (!this._isAlive) { + return; + } + + let fragment = document.createDocumentFragment(); + for (let i = startIndex; i < limit; ++i) { + this._insertNewItem(this._resultNode.getChild(i), fragment); + } + await new Promise(resolve => window.requestAnimationFrame(resolve)); + if (!this._isAlive) { + return; + } + this._rootElt.appendChild(fragment); + this.updateNodesVisibility(); + } + + if (this._chevronPopup.hasAttribute("type")) { + // Chevron has already been initialized, but since we are forcing + // a rebuild of the toolbar, it has to be rebuilt. + // Otherwise, it will be initialized when the toolbar overflows. + this._chevronPopup.place = this.place; + } + + // Rebuild the "Other Bookmarks" folder if it already exists. + let otherBookmarks = document.getElementById("OtherBookmarks"); + otherBookmarks?.remove(); + + BookmarkingUI.maybeShowOtherBookmarksFolder().catch(console.error); + } + + _insertNewItem(aChild, aInsertionNode, aBefore = null) { + this._domNodes.delete(aChild); + + let type = aChild.type; + let button; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + button = document.createXULElement("toolbarseparator"); + } else { + button = document.createXULElement("toolbarbutton"); + button.className = "bookmark-item"; + button.setAttribute("label", aChild.title || ""); + + if (PlacesUtils.containerTypes.includes(type)) { + button.setAttribute("type", "menu"); + button.setAttribute("container", "true"); + + if (PlacesUtils.nodeIsQuery(aChild)) { + button.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aChild)) { + button.setAttribute("tagContainer", "true"); + } + } + + let popup = document.createXULElement("menupopup", { + is: "places-popup", + }); + popup.setAttribute("placespopup", "true"); + button.appendChild(popup); + popup._placesNode = PlacesUtils.asContainer(aChild); + popup.setAttribute("context", "placesContext"); + + this._domNodes.set(aChild, popup); + } else if (PlacesUtils.nodeIsURI(aChild)) { + button.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(aChild.uri) + ); + } + } + + button._placesNode = aChild; + let { icon } = button._placesNode; + if (icon) { + button.setAttribute("image", icon); + } + if (!this._domNodes.has(aChild)) { + this._domNodes.set(aChild, button); + } + + if (aBefore) { + aInsertionNode.insertBefore(button, aBefore); + } else { + aInsertionNode.appendChild(button); + } + return button; + } + + _updateChevronPopupNodesVisibility() { + // Note the toolbar by default builds less nodes than the chevron popup. + for ( + let toolbarNode = this._rootElt.firstElementChild, + node = this._chevronPopup._startMarker.nextElementSibling; + toolbarNode && node; + toolbarNode = toolbarNode.nextElementSibling, + node = node.nextElementSibling + ) { + node.hidden = toolbarNode.style.visibility != "hidden"; + } + } + + _onChevronPopupShowing(aEvent) { + // Handle popupshowing only for the chevron popup, not for nested ones. + if (aEvent.target != this._chevronPopup) { + return; + } + + if (!this._chevron._placesView) { + this._chevron._placesView = new PlacesMenu(aEvent, this.place); + } + + this._updateChevronPopupNodesVisibility(); + } + + _onOtherBookmarksPopupShowing(aEvent) { + if (aEvent.target != this._otherBookmarksPopup) { + return; + } + + if (!this._otherBookmarks._placesView) { + this._otherBookmarks._placesView = new PlacesMenu( + aEvent, + "place:parent=" + PlacesUtils.bookmarks.unfiledGuid + ); + } + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "resize": + // This handler updates nodes visibility in both the toolbar + // and the chevron popup when a window resize does not change + // the overflow status of the toolbar. + if (aEvent.target == aEvent.currentTarget) { + this.updateNodesVisibility(); + } + break; + case "overflow": + if (!this._isOverflowStateEventRelevant(aEvent)) { + return; + } + // Avoid triggering overflow in containers if possible + aEvent.stopPropagation(); + this._onOverflow(); + break; + case "underflow": + if (!this._isOverflowStateEventRelevant(aEvent)) { + return; + } + // Avoid triggering underflow in containers if possible + aEvent.stopPropagation(); + this._onUnderflow(); + break; + case "TabOpen": + case "TabClose": + this.updateNodesVisibility(); + break; + case "dragstart": + this._onDragStart(aEvent); + break; + case "dragover": + this._onDragOver(aEvent); + break; + case "dragleave": + this._onDragLeave(aEvent); + break; + case "dragend": + this._onDragEnd(aEvent); + break; + case "drop": + this._onDrop(aEvent); + break; + case "mouseover": + this._onMouseOver(aEvent); + break; + case "mousemove": + this._onMouseMove(aEvent); + break; + case "mouseout": + this._onMouseOut(aEvent); + break; + case "mousedown": + this._onMouseDown(aEvent); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + default: + throw new Error("Trying to handle unexpected event."); + } + } + + _isOverflowStateEventRelevant(aEvent) { + // Ignore events not aimed at ourselves, as well as purely vertical ones: + return aEvent.target == aEvent.currentTarget && aEvent.detail > 0; + } + + _onOverflow() { + // Attach the popup binding to the chevron popup if it has not yet + // been initialized. + if (!this._chevronPopup.hasAttribute("type")) { + this._chevronPopup.setAttribute("place", this.place); + this._chevronPopup.setAttribute("type", "places"); + } + this._chevron.collapsed = false; + this.updateNodesVisibility(); + } + + _onUnderflow() { + this.updateNodesVisibility(); + this._chevron.collapsed = true; + } + + updateNodesVisibility() { + // Update the chevron on a timer. This will avoid repeated work when + // lot of changes happen in a small timeframe. + if (this._updateNodesVisibilityTimer) { + this._updateNodesVisibilityTimer.cancel(); + } + + this._updateNodesVisibilityTimer = this._setTimer(100); + } + + async _updateNodesVisibilityTimerCallback() { + if (this._updatingNodesVisibility || window.closed) { + return; + } + this._updatingNodesVisibility = true; + + let dwu = window.windowUtils; + + let scrollRect = await window.promiseDocumentFlushed(() => + dwu.getBoundsWithoutFlushing(this._rootElt) + ); + + let childOverflowed = false; + + // We're about to potentially update a bunch of nodes, so we do it + // in a requestAnimationFrame so that other JS that's might execute + // in the same tick can avoid flushing styles and layout for these + // changes. + window.requestAnimationFrame(() => { + for (let child of this._rootElt.children) { + // Once a child overflows, all the next ones will. + if (!childOverflowed) { + let childRect = dwu.getBoundsWithoutFlushing(child); + childOverflowed = this.isRTL + ? childRect.left < scrollRect.left + : childRect.right > scrollRect.right; + } + + if (childOverflowed) { + child.removeAttribute("image"); + child.style.visibility = "hidden"; + } else { + let icon = child._placesNode.icon; + if (icon) { + child.setAttribute("image", icon); + } + child.style.removeProperty("visibility"); + } + } + + // We rebuild the chevron on popupShowing, so if it is open + // we must update it. + if (!this._chevron.collapsed && this._chevron.open) { + this._updateChevronPopupNodesVisibility(); + } + + let event = new CustomEvent("BookmarksToolbarVisibilityUpdated", { + bubbles: true, + }); + this._viewElt.dispatchEvent(event); + this._updatingNodesVisibility = false; + }); + } + + nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + let children = this._rootElt.children; + // Nothing to do if it's a never-visible node, but note it's possible + // we are appending. + if (aIndex > children.length) { + return; + } + + // Note that childCount is already accounting for the node being added, + // thus we must subtract one node from it. + if (this._resultNode.childCount - 1 > children.length) { + if (aIndex == children.length) { + // If we didn't build all the nodes and new node is being appended, + // we can skip it as well. + return; + } + // Keep the number of built nodes consistent. + this._rootElt.removeChild(this._rootElt.lastElementChild); + } + + let button = this._insertNewItem( + aPlacesNode, + this._rootElt, + children[aIndex] || null + ); + let prevSiblingOverflowed = + aIndex > 0 && + aIndex <= children.length && + children[aIndex - 1].style.visibility == "hidden"; + if (prevSiblingOverflowed) { + button.style.visibility = "hidden"; + } else { + let icon = aPlacesNode.icon; + if (icon) { + button.setAttribute("image", icon); + } + this.updateNodesVisibility(); + } + return; + } + + super.nodeInserted(aParentPlacesNode, aPlacesNode, aIndex); + } + + nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) { + return; + } + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + let overflowed = elt.style.visibility == "hidden"; + this._removeChild(elt); + if (this._resultNode.childCount > this._rootElt.children.length) { + // A new node should be built to keep a coherent number of children. + this._insertNewItem( + this._resultNode.getChild(this._rootElt.children.length), + this._rootElt + ); + } + if (!overflowed) { + this.updateNodesVisibility(); + } + return; + } + + super.nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex); + } + + nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ) { + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt == this._rootElt) { + // Node is on the toolbar. + // Do nothing if the node will never be visible. + let lastBuiltIndex = this._rootElt.children.length - 1; + if (aOldIndex > lastBuiltIndex && aNewIndex > lastBuiltIndex + 1) { + return; + } + + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + if (elt) { + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + this._removeChild(elt); + } + + if (aNewIndex > lastBuiltIndex + 1) { + if (this._resultNode.childCount > this._rootElt.children.length) { + // If the element was built and becomes non built, another node should + // be built to keep a coherent number of children. + this._insertNewItem( + this._resultNode.getChild(this._rootElt.children.length), + this._rootElt + ); + } + return; + } + + if (!elt) { + // The node has not been inserted yet, so we must create it. + elt = this._insertNewItem( + aPlacesNode, + this._rootElt, + this._rootElt.children[aNewIndex] + ); + let icon = aPlacesNode.icon; + if (icon) { + elt.setAttribute("image", icon); + } + } else { + this._rootElt.insertBefore(elt, this._rootElt.children[aNewIndex]); + } + + // The chevron view may get nodeMoved after the toolbar. In such a case, + // we should ensure (by manually swapping menuitems) that the actual nodes + // are in the final position before updateNodesVisibility tries to update + // their visibility, or the chevron may go out of sync. + // Luckily updateNodesVisibility runs on a timer, so, by the time it updates + // nodes, the menu has already handled the notification. + + this.updateNodesVisibility(); + return; + } + + super.nodeMoved( + aPlacesNode, + aOldParentPlacesNode, + aOldIndex, + aNewParentPlacesNode, + aNewIndex + ); + } + + nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // Nothing to do if it's a never-visible node. + if (!elt || elt == this._rootElt) { + return; + } + + super.nodeTitleChanged(aPlacesNode, aNewTitle); + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + if (elt.parentNode == this._rootElt) { + // Node is on the toolbar. + if (elt.style.visibility != "hidden") { + this.updateNodesVisibility(); + } + } + } + + invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) { + return; + } + + if (elt == this._rootElt) { + // Container is the toolbar itself. + let instance = (this._rebuildingInstance = {}); + if (!this._rebuilding) { + this._rebuilding = PromiseUtils.defer(); + } + this._rebuild() + .catch(console.error) + .finally(() => { + if (instance == this._rebuildingInstance) { + this._rebuilding.resolve(); + this._rebuilding = null; + } + }); + return; + } + + super.invalidateContainer(aPlacesNode); + } + + _clearOverFolder() { + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + if (this._overFolder.elt && this._overFolder.elt.menupopup) { + if (!this._overFolder.elt.menupopup.hasAttribute("dragover")) { + this._overFolder.elt.menupopup.hidePopup(); + } + this._overFolder.elt.removeAttribute("dragover"); + this._overFolder.elt = null; + } + if (this._overFolder.openTimer) { + this._overFolder.openTimer.cancel(); + this._overFolder.openTimer = null; + } + if (this._overFolder.closeTimer) { + this._overFolder.closeTimer.cancel(); + this._overFolder.closeTimer = null; + } + } + + /** + * This function returns information about where to drop when dragging over + * the toolbar. + * + * @param {object} aEvent + * The associated event. + * @returns {object} + * - ip: the insertion point for the bookmarks service. + * - beforeIndex: child index to drop before, for the drop indicator. + * - folderElt: the folder to drop into, if applicable. + */ + _getDropPoint(aEvent) { + if (!PlacesUtils.nodeIsFolder(this._resultNode)) { + return null; + } + + let dropPoint = { ip: null, beforeIndex: null, folderElt: null }; + let elt = aEvent.target; + if ( + elt._placesNode && + elt != this._rootElt && + elt.localName != "menupopup" + ) { + let eltRect = elt.getBoundingClientRect(); + let eltIndex = Array.prototype.indexOf.call(this._rootElt.children, elt); + if ( + PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode) + ) { + // This is a folder. + // If we are in the middle of it, drop inside it. + // Otherwise, drop before it, with regards to RTL mode. + let threshold = eltRect.width * 0.25; + if ( + this.isRTL + ? aEvent.clientX > eltRect.right - threshold + : aEvent.clientX < eltRect.left + threshold + ) { + // Drop before this folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = eltIndex; + } else if ( + this.isRTL + ? aEvent.clientX > eltRect.left + threshold + : aEvent.clientX < eltRect.right - threshold + ) { + // Drop inside this folder. + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) + ? elt._placesNode.title + : null; + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName, + }); + dropPoint.beforeIndex = eltIndex; + dropPoint.folderElt = elt; + } else { + // Drop after this folder. + let beforeIndex = + eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1; + + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = beforeIndex; + } + } else { + // This is a non-folder node or a read-only folder. + // Drop before it with regards to RTL mode. + let threshold = eltRect.width * 0.5; + if ( + this.isRTL + ? aEvent.clientX > eltRect.left + threshold + : aEvent.clientX < eltRect.left + threshold + ) { + // Drop before this bookmark. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = eltIndex; + } else { + // Drop after this bookmark. + let beforeIndex = + eltIndex == this._rootElt.children.length - 1 ? -1 : eltIndex + 1; + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = beforeIndex; + } + } + } else { + // We are most likely dragging on the empty area of the + // toolbar, we should drop after the last node. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + }); + dropPoint.beforeIndex = -1; + } + + return dropPoint; + } + + _setTimer(aTime) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + } + + get name() { + return "PlacesToolbar"; + } + + notify(aTimer) { + if (aTimer == this._updateNodesVisibilityTimer) { + this._updateNodesVisibilityTimer = null; + this._updateNodesVisibilityTimerCallback(); + } else if (aTimer == this._overFolder.openTimer) { + // * Timer to open a menubutton that's being dragged over. + // Set the autoopen attribute on the folder's menupopup so that + // the menu will automatically close when the mouse drags off of it. + this._overFolder.elt.menupopup.setAttribute("autoopened", "true"); + this._overFolder.elt.open = true; + this._overFolder.openTimer = null; + } else if (aTimer == this._overFolder.closeTimer) { + // * Timer to close a menubutton that's been dragged off of. + // Close the menubutton if we are not dragging over it or one of + // its children. The autoopened attribute will let the menu know to + // close later if the menu is still being dragged over. + let currentPlacesNode = PlacesControllerDragHelper.currentDropTarget; + let inHierarchy = false; + while (currentPlacesNode) { + if (currentPlacesNode == this._rootElt) { + inHierarchy = true; + break; + } + currentPlacesNode = currentPlacesNode.parentNode; + } + // The _clearOverFolder() function will close the menu for + // _overFolder.elt. So null it out if we don't want to close it. + if (inHierarchy) { + this._overFolder.elt = null; + } + + // Clear out the folder and all associated timers. + this._clearOverFolder(); + } + } + + _onMouseOver(aEvent) { + let button = aEvent.target; + if ( + button.parentNode == this._rootElt && + button._placesNode && + PlacesUtils.nodeIsURI(button._placesNode) + ) { + window.XULBrowserWindow.setOverLink(aEvent.target._placesNode.uri); + } + } + + _onMouseOut(aEvent) { + window.XULBrowserWindow.setOverLink(""); + } + + _onMouseDown(aEvent) { + let target = aEvent.target; + if ( + aEvent.button == 0 && + target.localName == "toolbarbutton" && + target.getAttribute("type") == "menu" + ) { + let modifKey = aEvent.shiftKey || aEvent.getModifierState("Accel"); + if (modifKey) { + // Do not open the popup since BEH_onClick is about to + // open all child uri nodes in tabs. + this._allowPopupShowing = false; + } + } + if (target._placesNode?.uri) { + PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window); + } + } + + _cleanupDragDetails() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._draggedElt = null; + this._dropIndicator.collapsed = true; + } + + _onDragStart(aEvent) { + // Sub menus have their own d&d handlers. + let draggedElt = aEvent.target; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) { + return; + } + + if ( + draggedElt.localName == "toolbarbutton" && + draggedElt.getAttribute("type") == "menu" + ) { + // If the drag gesture on a container is toward down we open instead + // of dragging. + let translateY = this._cachedMouseMoveEvent.clientY - aEvent.clientY; + let translateX = this._cachedMouseMoveEvent.clientX - aEvent.clientX; + if (translateY >= Math.abs(translateX / 2)) { + // Don't start the drag. + aEvent.preventDefault(); + // Open the menu. + draggedElt.open = true; + return; + } + + // If the menu is open, close it. + if (draggedElt.open) { + draggedElt.menupopup.hidePopup(); + draggedElt.open = false; + } + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(aEvent); + aEvent.stopPropagation(); + } + + _onDragOver(aEvent) { + // Cache the dataTransfer + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + let dt = aEvent.dataTransfer; + + let dropPoint = this._getDropPoint(aEvent); + if ( + !dropPoint || + !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt) + ) { + this._dropIndicator.collapsed = true; + aEvent.stopPropagation(); + return; + } + + if (dropPoint.folderElt || aEvent.originalTarget == this._chevron) { + // Dropping over a menubutton or chevron button. + // Set styles and timer to open relative menupopup. + let overElt = dropPoint.folderElt || this._chevron; + if (this._overFolder.elt != overElt) { + this._clearOverFolder(); + this._overFolder.elt = overElt; + this._overFolder.openTimer = this._setTimer(this._overFolder.hoverTime); + } + if (!this._overFolder.elt.hasAttribute("dragover")) { + this._overFolder.elt.setAttribute("dragover", "true"); + } + + this._dropIndicator.collapsed = true; + } else { + // Dragging over a normal toolbarbutton, + // show indicator bar and move it to the appropriate drop point. + let ind = this._dropIndicator; + ind.parentNode.collapsed = false; + let halfInd = ind.clientWidth / 2; + let translateX; + if (this.isRTL) { + halfInd = Math.ceil(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().right - halfInd; + if (this._rootElt.firstElementChild) { + if (dropPoint.beforeIndex == -1) { + translateX += + this._rootElt.lastElementChild.getBoundingClientRect().left; + } else { + translateX += + this._rootElt.children[ + dropPoint.beforeIndex + ].getBoundingClientRect().right; + } + } + } else { + halfInd = Math.floor(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().left + halfInd; + if (this._rootElt.firstElementChild) { + if (dropPoint.beforeIndex == -1) { + translateX += + this._rootElt.lastElementChild.getBoundingClientRect().right; + } else { + translateX += + this._rootElt.children[ + dropPoint.beforeIndex + ].getBoundingClientRect().left; + } + } + } + + ind.style.transform = "translate(" + Math.round(translateX) + "px)"; + ind.style.marginInlineStart = -ind.clientWidth + "px"; + ind.collapsed = false; + + // Clear out old folder information. + this._clearOverFolder(); + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + } + + _onDrop(aEvent) { + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + + let dropPoint = this._getDropPoint(aEvent); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop( + dropPoint.ip, + aEvent.dataTransfer + ).catch(console.error); + aEvent.preventDefault(); + } + + this._cleanupDragDetails(); + aEvent.stopPropagation(); + } + + _onDragLeave(aEvent) { + PlacesControllerDragHelper.currentDropTarget = null; + + this._dropIndicator.collapsed = true; + + // If we hovered over a folder, close it now. + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._setTimer(this._overFolder.hoverTime); + } + } + + _onDragEnd(aEvent) { + this._cleanupDragDetails(); + } + + _onPopupShowing(aEvent) { + if (!this._allowPopupShowing) { + this._allowPopupShowing = true; + aEvent.preventDefault(); + return; + } + + let parent = aEvent.target.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = parent; + } + + super._onPopupShowing(aEvent); + } + + _onPopupHidden(aEvent) { + let popup = aEvent.target; + let placesNode = popup._placesNode; + // Avoid handling popuphidden of inner views + if ( + placesNode && + PlacesUIUtils.getViewForNode(popup) == this && + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + !PlacesUtils.nodeIsFolder(placesNode) + ) { + placesNode.containerOpen = false; + } + + let parent = popup.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = null; + // Clear the dragover attribute if present, if we are dragging into a + // folder in the hierachy of current opened popup we don't clear + // this attribute on clearOverFolder. See Notify for closeTimer. + if (parent.hasAttribute("dragover")) { + parent.removeAttribute("dragover"); + } + } + } + + _onMouseMove(aEvent) { + // Used in dragStart to prevent dragging folders when dragging down. + this._cachedMouseMoveEvent = aEvent; + + if ( + this._openedMenuButton == null || + PlacesControllerDragHelper.getSession() + ) { + return; + } + + let target = aEvent.originalTarget; + if ( + this._openedMenuButton != target && + target.localName == "toolbarbutton" && + target.type == "menu" + ) { + this._openedMenuButton.open = false; + target.open = true; + } + } +} + +/** + * View for Places menus. This object should be created during the first + * popupshowing that's dispatched on the menu. + * + */ +class PlacesMenu extends PlacesViewBase { + /** + * + * @param {Event} popupShowingEvent + * The event associated with opening the menu. + * @param {string} placesUrl + * The query associated with the view on the menu. + */ + constructor(popupShowingEvent, placesUrl) { + super( + placesUrl, + popupShowingEvent.target, // <menupopup> + popupShowingEvent.target.parentNode // <menu> + ); + + this._addEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._addEventListeners(window, ["unload"], false); + this._addEventListeners(this._rootElt, ["mousedown"], false); + if (AppConstants.platform === "macosx") { + // Must walk up to support views in sub-menus, like Bookmarks Toolbar menu. + for (let elt = this._viewElt.parentNode; elt; elt = elt.parentNode) { + if (elt.localName == "menubar") { + this._nativeView = true; + break; + } + } + } + + this._onPopupShowing(popupShowingEvent); + } + + _init() { + this._viewElt._placesView = this; + } + + _removeChild(aChild) { + super._removeChild(aChild); + } + + uninit() { + this._removeEventListeners( + this._rootElt, + ["popupshowing", "popuphidden"], + true + ); + this._removeEventListeners(window, ["unload"], false); + this._removeEventListeners(this._rootElt, ["mousedown"], false); + + super.uninit(); + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + case "mousedown": + this._onMouseDown(aEvent); + break; + } + } + + _onPopupHidden(aEvent) { + // Avoid handling popuphidden of inner views. + let popup = aEvent.originalTarget; + let placesNode = popup._placesNode; + if (!placesNode || PlacesUIUtils.getViewForNode(popup) != this) { + return; + } + + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + if (!PlacesUtils.nodeIsFolder(placesNode)) { + placesNode.containerOpen = false; + } + + // The autoopened attribute is set for folders which have been + // automatically opened when dragged over. Turn off this attribute + // when the folder closes because it is no longer applicable. + popup.removeAttribute("autoopened"); + popup.removeAttribute("dragstart"); + } + + // We don't have a facility for catch "mousedown" events on the native + // Mac menus because Mac doesn't expose it + _onMouseDown(aEvent) { + let target = aEvent.target; + if (target._placesNode?.uri) { + PlacesUIUtils.setupSpeculativeConnection(target._placesNode.uri, window); + } + } +} + +// This is used from CustomizableWidgets.sys.mjs using a `window` reference, +// so we have to expose this on the global. +this.PlacesPanelview = class PlacesPanelview extends PlacesViewBase { + constructor(placeUrl, rootElt, viewElt) { + super(placeUrl, rootElt, viewElt); + this._viewElt._placesView = this; + // We're simulating a popup show, because a panelview may only be shown when + // its containing popup is already shown. + this._onPopupShowing({ originalTarget: this._rootElt }); + this._addEventListeners(window, ["unload"]); + this._rootElt.setAttribute("context", "placesContext"); + } + + get events() { + if (this._events) { + return this._events; + } + return (this._events = [ + "click", + "command", + "dragend", + "dragstart", + "ViewHiding", + "ViewShown", + ]); + } + + handleEvent(event) { + switch (event.type) { + case "click": + // For middle clicks, fall through to the command handler. + if (event.button != 1) { + break; + } + // fall through + case "command": + this._onCommand(event); + break; + case "dragend": + this._onDragEnd(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "unload": + this.uninit(event); + break; + case "ViewHiding": + this._onPopupHidden(event); + break; + case "ViewShown": + this._onViewShown(event); + break; + } + } + + _onCommand(event) { + event = getRootEvent(event); + let button = event.originalTarget; + if (!button._placesNode) { + return; + } + + let modifKey = + AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey; + if (!PlacesUIUtils.openInTabClosesMenu && modifKey) { + // If 'Recent Bookmarks' in Bookmarks Panel. + if (button.parentNode.id == "panelMenu_bookmarksMenu") { + button.setAttribute("closemenu", "none"); + } + } else { + button.removeAttribute("closemenu"); + } + PlacesUIUtils.openNodeWithEvent(button._placesNode, event); + // Unlike left-click, middle-click requires manual menu closing. + if ( + button.parentNode.id != "panelMenu_bookmarksMenu" || + (event.type == "click" && + event.button == 1 && + PlacesUIUtils.openInTabClosesMenu) + ) { + this.panelMultiView.closest("panel").hidePopup(); + } + } + + _onDragEnd() { + this._draggedElt = null; + } + + _onDragStart(event) { + let draggedElt = event.originalTarget; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) { + return; + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(event); + event.stopPropagation(); + } + + uninit(event) { + this._removeEventListeners(this.panelMultiView, this.events); + this._removeEventListeners(window, ["unload"]); + delete this.panelMultiView; + super.uninit(event); + } + + _createDOMNodeForPlacesNode(placesNode) { + this._domNodes.delete(placesNode); + + let element; + let type = placesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createXULElement("toolbarseparator"); + } else { + if (type != Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) { + throw new Error("Unexpected node"); + } + + element = document.createXULElement("toolbarbutton"); + element.classList.add( + "subviewbutton", + "subviewbutton-iconic", + "bookmark-item" + ); + element.setAttribute( + "scheme", + PlacesUIUtils.guessUrlSchemeForUI(placesNode.uri) + ); + element.setAttribute("label", PlacesUIUtils.getBestTitle(placesNode)); + + let icon = placesNode.icon; + if (icon) { + element.setAttribute("image", icon); + } + } + + element._placesNode = placesNode; + if (!this._domNodes.has(placesNode)) { + this._domNodes.set(placesNode, element); + } + + return element; + } + + _setEmptyPopupStatus(panelview, empty = false) { + if (!panelview._emptyMenuitem) { + panelview._emptyMenuitem = document.createXULElement("toolbarbutton"); + panelview._emptyMenuitem.setAttribute("disabled", true); + panelview._emptyMenuitem.className = "subviewbutton"; + document.l10n.setAttributes( + panelview._emptyMenuitem, + "places-empty-bookmarks-folder" + ); + if (this._appendClassToChildren) { + panelview._emptyMenuitem.classList.add(this._appendClassToChildren); + } + } + + if (empty) { + panelview.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + // We also support external usage for custom crafted panels - which'll have + // no markers present. + if ( + !panelview._startMarker || + (!panelview._startMarker.previousElementSibling && + !panelview._endMarker.nextElementSibling) + ) { + panelview.insertBefore(panelview._emptyMenuitem, panelview._endMarker); + } + } else { + panelview.removeAttribute("emptyplacesresult"); + try { + panelview.removeChild(panelview._emptyMenuitem); + } catch (ex) {} + } + } + + _isPopupOpen() { + return PanelView.forNode(this._viewElt).active; + } + + _onPopupHidden(event) { + let panelview = event.originalTarget; + let placesNode = panelview._placesNode; + // Avoid handling ViewHiding of inner views + if ( + placesNode && + PlacesUIUtils.getViewForNode(panelview) == this && + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + !PlacesUtils.nodeIsFolder(placesNode) + ) { + placesNode.containerOpen = false; + } + } + + _onPopupShowing(event) { + // If the event came from the root element, this is the first time + // we ever get here. + if (event.originalTarget == this._rootElt) { + // Start listening for events from all panels inside the panelmultiview. + this.panelMultiView = this._viewElt.panelMultiView; + this._addEventListeners(this.panelMultiView, this.events); + } + super._onPopupShowing(event); + } + + _onViewShown(event) { + if (event.originalTarget != this._viewElt) { + return; + } + + // Because PanelMultiView reparents the panelview internally, the controller + // may get lost. In that case we'll append it again, because we certainly + // need it later! + if (!this.controllers.getControllerCount() && this._controller) { + this.controllers.appendController(this._controller); + } + } +}; diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js new file mode 100644 index 0000000000..0eb9322f71 --- /dev/null +++ b/browser/components/places/content/controller.js @@ -0,0 +1,1711 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +/* import-globals-from /browser/base/content/utilityOverlay.js */ +/* import-globals-from ./places.js */ + +/** + * Represents an insertion point within a container where we can insert + * items. + * + * @param {object} options an object containing the following properties: + * @param {string} options.parentGuid + * The unique identifier of the parent container + * @param {number} [options.index] + * The index within the container where to insert, defaults to appending + * @param {number} [options.orientation] + * The orientation of the insertion. NOTE: the adjustments to the + * insertion point to accommodate the orientation should be done by + * the person who constructs the IP, not the user. The orientation + * is provided for informational purposes only! Defaults to DROP_ON. + * @param {string} [options.tagName] + * The tag name if this IP is set to a tag, null otherwise. + * @param {*} [options.dropNearNode] + * When defined index will be calculated based on this node + */ +function PlacesInsertionPoint({ + parentGuid, + index = PlacesUtils.bookmarks.DEFAULT_INDEX, + orientation = Ci.nsITreeView.DROP_ON, + tagName = null, + dropNearNode = null, +}) { + this.guid = parentGuid; + this._index = index; + this.orientation = orientation; + this.tagName = tagName; + this.dropNearNode = dropNearNode; +} + +PlacesInsertionPoint.prototype = { + set index(val) { + this._index = val; + }, + + async getIndex() { + if (this.dropNearNode) { + // If dropNearNode is set up we must calculate the index of the item near + // which we will drop. + let index = ( + await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid) + ).index; + return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; + } + return this._index; + }, + + get isTag() { + return typeof this.tagName == "string"; + }, +}; + +/** + * Places Controller + */ + +function PlacesController(aView) { + this._view = aView; + XPCOMUtils.defineLazyGetter(this, "profileName", function () { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "forgetSiteClearByBaseDomain", + "places.forgetThisSite.clearByBaseDomain", + false + ); + ChromeUtils.defineESModuleGetters(this, { + ForgetAboutSite: "resource://gre/modules/ForgetAboutSite.sys.mjs", + }); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + // This is used in certain views to disable user actions on the places tree + // views. This avoids accidental deletion/modification when the user is not + // actually organising the trees. + disableUserActions: false, + + QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]), + + // nsIClipboardOwner + LosingOwnership: function PC_LosingOwnership(aXferable) { + this.cutNodes = []; + }, + + terminate: function PC_terminate() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function PC_supportsCommand(aCommand) { + if (this.disableUserActions) { + return false; + } + // Non-Places specific commands that we also support + switch (aCommand) { + case "cmd_undo": + case "cmd_redo": + case "cmd_cut": + case "cmd_copy": + case "cmd_paste": + case "cmd_delete": + case "cmd_selectAll": + return true; + } + + // All other Places Commands are prefixed with "placesCmd_" ... this + // filters out other commands that we do _not_ support (see 329587). + const CMD_PREFIX = "placesCmd_"; + return aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX; + }, + + isCommandEnabled: function PC_isCommandEnabled(aCommand) { + // Determine whether or not nodes can be inserted. + let ip = this._view.insertionPoint; + let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag); + + switch (aCommand) { + case "cmd_undo": + return PlacesTransactions.topUndoEntry != null; + case "cmd_redo": + return PlacesTransactions.topRedoEntry != null; + case "cmd_cut": + case "placesCmd_cut": + for (let node of this._view.selectedNodes) { + // If selection includes history nodes or tags-as-bookmark, disallow + // cutting. + if ( + node.itemId == -1 || + (node.parent && PlacesUtils.nodeIsTagQuery(node.parent)) + ) { + return false; + } + } + // Otherwise fall through the cmd_delete check. + case "cmd_delete": + case "placesCmd_delete": + case "placesCmd_deleteDataHost": + return this._hasRemovableSelection(); + case "cmd_copy": + case "placesCmd_copy": + case "placesCmd_showInFolder": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + // If the clipboard contains a Places flavor it is definitely pasteable, + // otherwise we also allow pasting "text/plain" and "text/x-moz-url" data. + // We don't check if the data is valid here, because the clipboard may + // contain very large blobs that would largely slowdown commands updating. + // Of course later paste() should ignore any invalid data. + return ( + canInsert && + Services.clipboard.hasDataMatchingFlavors( + [ + ...PlacesUIUtils.PLACES_FLAVORS, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ], + Ci.nsIClipboard.kGlobalClipboard + ) + ); + case "cmd_selectAll": + if (this._view.selType != "single") { + let rootNode = this._view.result.root; + if (rootNode.containerOpen && rootNode.childCount > 0) { + return true; + } + } + return false; + case "placesCmd_open": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + let selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + } + case "placesCmd_new:folder": + return canInsert; + case "placesCmd_new:bookmark": + return canInsert; + case "placesCmd_new:separator": + return ( + canInsert && + !PlacesUtils.asQuery(this._view.result.root).queryOptions + .excludeItems && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + case "placesCmd_show:info": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + !PlacesUtils.isRootItem( + PlacesUtils.getConcreteItemGuid(selectedNode) + ) && + (PlacesUtils.nodeIsTagQuery(selectedNode) || + PlacesUtils.nodeIsBookmark(selectedNode) || + (PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUtils.isQueryGeneratedFolder(selectedNode))) + ); + } + case "placesCmd_sortBy:name": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isFolderReadOnly(selectedNode) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + } + case "placesCmd_createBookmark": { + return !this._view.selectedNodes.some( + node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1 + ); + } + default: + return false; + } + }, + + doCommand: function PC_doCommand(aCommand) { + switch (aCommand) { + case "cmd_undo": + PlacesTransactions.undo().catch(console.error); + break; + case "cmd_redo": + PlacesTransactions.redo().catch(console.error); + break; + case "cmd_cut": + case "placesCmd_cut": + this.cut(); + break; + case "cmd_copy": + case "placesCmd_copy": + this.copy(); + break; + case "cmd_paste": + case "placesCmd_paste": + this.paste().catch(console.error); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection").catch(console.error); + break; + case "placesCmd_deleteDataHost": + this.forgetAboutThisSite().catch(console.error); + break; + case "cmd_selectAll": + this.selectAll(); + break; + case "placesCmd_open": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "current", + this._view + ); + break; + case "placesCmd_open:window": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); + break; + case "placesCmd_open:privatewindow": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "window", + this._view, + true + ); + break; + case "placesCmd_open:tab": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); + break; + case "placesCmd_new:folder": + this.newItem("folder").catch(console.error); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark").catch(console.error); + break; + case "placesCmd_new:separator": + this.newSeparator().catch(console.error); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName().catch(console.error); + break; + case "placesCmd_createBookmark": { + const nodes = this._view.selectedNodes.map(node => { + return { + uri: Services.io.newURI(node.uri), + title: node.title, + }; + }); + PlacesUIUtils.showBookmarkPagesDialog( + nodes, + ["keyword", "location"], + window.top + ); + break; + } + case "placesCmd_showInFolder": + this.showInFolder(this._view.selectedNode.bookmarkGuid); + break; + } + }, + + onEvent: function PC_onEvent(eventName) {}, + + /** + * Determine whether or not the selection can be removed, either by the + * delete or cut operations based on whether or not any of its contents + * are non-removable. We don't need to worry about recursion here since it + * is a policy decision that a removable item not be placed inside a non- + * removable item. + * + * @returns {boolean} true if all nodes in the selection can be removed, + * false otherwise. + */ + _hasRemovableSelection() { + var ranges = this._view.removableSelectionRanges; + if (!ranges.length) { + return false; + } + + var root = this._view.result.root; + + for (var j = 0; j < ranges.length; j++) { + var nodes = ranges[j]; + for (var i = 0; i < nodes.length; ++i) { + // Disallow removing the view's root node + if (nodes[i] == root) { + return false; + } + + if (!PlacesUIUtils.canUserRemove(nodes[i])) { + return false; + } + } + } + + return true; + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "tagChild" node is a child of a tag + * "folder" node is a folder + * "query" node is a query + * "separator" node is a separator line + * "host" node is a host + * + * @returns {Array} an array of objects corresponding the selected nodes. Each + * object has each of the properties above set if its corresponding + * node matches the rule. In addition, the annotations names for each + * node are set on its corresponding object as properties. + * Notes: + * 1) This can be slow, so don't call it anywhere performance critical! + */ + _buildSelectionMetadata() { + return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n)); + }, + + _selectionMetadataForNode(node) { + let nodeData = {}; + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (node.type) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: + nodeData.query = true; + if (node.parent) { + switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + nodeData.query_host = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData.query_day = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: + nodeData.query_tag = true; + } + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: + nodeData.folder = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + nodeData.separator = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: + nodeData.link = true; + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData.link_bookmark = true; + var parentNode = node.parent; + if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) { + nodeData.link_bookmark_tag = true; + } + } + break; + } + return nodeData; + }, + + /** + * Determines if a context-menu item should be shown + * + * @param {object} aMenuItem + * the context menu item + * @param {object} aMetaData + * meta data about the selection + * @returns {boolean} true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem(aMenuItem, aMetaData) { + if ( + aMenuItem.hasAttribute("hide-if-private-browsing") && + !PrivateBrowsingUtils.enabled + ) { + return false; + } + + if ( + aMenuItem.hasAttribute("hide-if-usercontext-disabled") && + !Services.prefs.getBoolPref("privacy.userContext.enabled", false) + ) { + return false; + } + + let selectiontype = + aMenuItem.getAttribute("selection-type") || "single|multiple"; + + var selectionTypes = selectiontype.split("|"); + if (selectionTypes.includes("any")) { + return true; + } + var count = aMetaData.length; + if (count > 1 && !selectionTypes.includes("multiple")) { + return false; + } + if (count == 1 && !selectionTypes.includes("single")) { + return false; + } + // If there is no selection and selectionType doesn't include `none` + // hide the item, otherwise try to use the root node to extract valid + // metadata to compare against. + if (count == 0) { + if (!selectionTypes.includes("none")) { + return false; + } + aMetaData = [this._selectionMetadataForNode(this._view.result.root)]; + } + + let attr = aMenuItem.getAttribute("hide-if-node-type"); + if (attr) { + let rules = attr.split("|"); + if (aMetaData.some(d => rules.some(r => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("hide-if-node-type-is-only"); + if (attr) { + let rules = attr.split("|"); + if (rules.some(r => aMetaData.every(d => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("node-type"); + if (!attr) { + return true; + } + + let anyMatched = false; + let rules = attr.split("|"); + for (let metaData of aMetaData) { + if (rules.some(r => r in metaData)) { + anyMatched = true; + } else { + return false; + } + } + return anyMatched; + }, + + /** + * Uses meta-data rules set as attributes on the menuitems, representing the + * current selection in the view (see `_buildSelectionMetadata`) and sets the + * visibility state for each menuitem according to the following rules: + * 1) The visibility state is unchanged if none of the attributes are set. + * 2) Attributes should not be set on menuseparators. + * 3) The boolean `ignore-item` attribute may be set when this code should + * not handle that menuitem. + * 4) The `selection-type` attribute may be set to: + * - `single` if it should be visible only when there is a single node + * selected + * - `multiple` if it should be visible only when multiple nodes are + * selected + * - `none` if it should be visible when there are no selected nodes + * - `any` if it should be visible for any kind of selection + * - a `|` separated combination of the above. + * 5) The `node-type` attribute may be set to values representing the + * type of the node triggering the context menu. The menuitem will be + * visible when one of the rules (separated by `|`) matches. + * In case of multiple selection, the menuitem is visible only if all of + * the selected nodes match one of the rule. + * 6) The `hide-if-node-type` accepts the same rules as `node-type`, but + * hides the menuitem if the nodes match at least one of the rules. + * It takes priority over `nodetype`. + * 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but + * hides the menuitem if any of the rules match all of the nodes. + * 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a + * menuitem when there's no insertion point. An insertion point represents + * a point in the view where a new item can be inserted. + * 9) The boolean `hide-if-private-browsing` attribute may be set to hide a + * menuitem in private browsing mode + * 10) The boolean `hide-if-single-click-opens` attribute may be set to hide a + * menuitem in views opening entries with a single click. + * + * @param {object} aPopup + * The menupopup to build children into. + * @returns {boolean} true if at least one item is visible, false otherwise. + */ + buildContextMenu(aPopup) { + var metadata = this._buildSelectionMetadata(); + var ip = this._view.insertionPoint; + var noIp = !ip || ip.isTag; + + var separator = null; + var visibleItemsBeforeSep = false; + var usableItemCount = 0; + for (var i = 0; i < aPopup.children.length; ++i) { + var item = aPopup.children[i]; + if (item.getAttribute("ignore-item") == "true") { + continue; + } + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + let hideIfNoIP = + item.getAttribute("hide-if-no-insertion-point") == "true" && + noIp && + !(ip && ip.isTag && item.id == "placesContext_paste"); + let hideIfPrivate = + item.getAttribute("hide-if-private-browsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + // Hide `Open` if the primary action on click is opening. + let hideIfSingleClickOpens = + item.getAttribute("hide-if-single-click-opens") == "true" && + !PlacesUIUtils.loadBookmarksInBackground && + !PlacesUIUtils.loadBookmarksInTabs && + this._view.singleClickOpens; + let hideIfNotSearch = + item.getAttribute("hide-if-not-search") == "true" && + (!this._view.selectedNode || + !this._view.selectedNode.parent || + !PlacesUtils.nodeIsQuery(this._view.selectedNode.parent)); + + let shouldHideItem = + hideIfNoIP || + hideIfPrivate || + hideIfSingleClickOpens || + hideIfNotSearch || + !this._shouldShowMenuItem(item, metadata); + item.hidden = shouldHideItem; + item.disabled = + shouldHideItem || item.getAttribute("start-disabled") == "true"; + + if (!item.hidden) { + visibleItemsBeforeSep = true; + usableItemCount++; + + // Show the separator above the menu-item if any + if (separator) { + separator.hidden = false; + separator = null; + } + } + } else { + // menuseparator + // Initially hide it. It will be unhidden if there will be at least one + // visible menu-item above and below it. + item.hidden = true; + + // We won't show the separator at all if no items are visible above it + if (visibleItemsBeforeSep) { + separator = item; + } + + // New separator, count again: + visibleItemsBeforeSep = false; + } + + if (item.id === "placesContext_deleteBookmark") { + document.l10n.setAttributes(item, "places-delete-bookmark", { + count: metadata.length, + }); + } + if (item.id === "placesContext_deleteFolder") { + document.l10n.setAttributes(item, "places-delete-folder", { + count: metadata.length, + }); + } + } + + // Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible + if (usableItemCount > 0) { + let openContainerInTabsItem = document.getElementById( + "placesContext_openContainer:tabs" + ); + let openBookmarksItem = document.getElementById( + "placesContext_openBookmarkContainer:tabs" + ); + for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) { + if (!menuItem.hidden) { + var containerToUse = + this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + menuItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + } + + const deleteHistoryItem = document.getElementById( + "placesContext_delete_history" + ); + document.l10n.setAttributes(deleteHistoryItem, "places-delete-page", { + count: metadata.length, + }); + + const createBookmarkItem = document.getElementById( + "placesContext_createBookmark" + ); + document.l10n.setAttributes(createBookmarkItem, "places-create-bookmark", { + count: metadata.length, + }); + + return usableItemCount > 0; + }, + + /** + * Select all links in the current view. + */ + selectAll: function PC_selectAll() { + this._view.selectAll(); + }, + + /** + * Opens the bookmark properties for the selected URI Node. + */ + showBookmarkPropertiesForSelection() { + let node = this._view.selectedNode; + if (!node) { + return; + } + + PlacesUIUtils.showBookmarkDialog( + { action: "edit", node, hiddenRows: ["folderPicker"] }, + window.top + ); + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + * + * @param {object} aEvent + * The associated event. + */ + openSelectionInTabs: function PC_openLinksInTabs(aEvent) { + var node = this._view.selectedNode; + var nodes = this._view.selectedNodes; + // In the case of no selection, open the root node: + if (!node && !nodes.length) { + node = this._view.result.root; + } + PlacesUIUtils.openMultipleLinksInTabs( + node ? node : nodes, + aEvent, + this._view + ); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param {string} aType + * the type of the new item (bookmark/folder) + */ + async newItem(aType) { + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: aType, + defaultInsertionPoint: ip, + hiddenRows: ["folderPicker"], + }, + window.top + ); + if (bookmarkGuid) { + this._view.selectItems([bookmarkGuid], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + async newSeparator() { + var ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let index = await ip.getIndex(); + let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index }); + let guid = await txn.transact(); + // Select the new item. + this._view.selectItems([guid], false); + }, + + /** + * Sort the selected folder by name + */ + async sortFolderByName() { + let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode); + await PlacesTransactions.SortByName(guid).transact(); + }, + + /** + * Walk the list of folders we're removing in this delete operation, and + * see if the selected node specified is already implicitly being removed + * because it is a child of that folder. + * + * @param {object} node + * Node to check for containment. + * @param {Array} pastFolders + * List of folders the calling function has already traversed + * @returns {boolean} true if the node should be skipped, false otherwise. + */ + _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) { + /** + * Determines if a node is contained by another node within a resultset. + * + * @param {object} parent + * The parent container to check for containment in + * @returns {boolean} true if node is a member of parent's children, false otherwise. + */ + function isNodeContainedBy(parent) { + var cursor = node.parent; + while (cursor) { + if (cursor == parent) { + return true; + } + cursor = cursor.parent; + } + return false; + } + + for (var j = 0; j < pastFolders.length; ++j) { + if (isNodeContainedBy(pastFolders[j])) { + return true; + } + } + return false; + }, + + /** + * Creates a set of transactions for the removal of a range of items. + * A range is an array of adjacent nodes in a view. + * + * @param {Array} range + * An array of nodes to remove. Should all be adjacent. + * @param {Array} transactions + * An array of transactions (returned) + * @param {Array} [removedFolders] + * An array of folder nodes that have already been removed. + * @returns {number} The total number of items affected. + */ + async _removeRange(range, transactions, removedFolders) { + if (!(transactions instanceof Array)) { + throw new Error("Must pass a transactions array"); + } + if (!removedFolders) { + removedFolders = []; + } + + let bmGuidsToRemove = []; + let totalItems = 0; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) { + continue; + } + + totalItems++; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + let tag = node.parent.title || ""; + if (!tag) { + // The parent may be the root node, that doesn't have a title. + tag = node.parent.query.tags[0]; + } + transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag })); + } else if ( + PlacesUtils.nodeIsTagQuery(node) && + node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT + ) { + // This is a tag container. + // Untag all URIs tagged with this tag only if the tag container is + // child of the "Tags" query in the library, in all other places we + // must only remove the query node. + let tag = node.title; + let urls = new Set(); + await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => + urls.add(b.url) + ); + transactions.push( + PlacesTransactions.Untag({ tag, urls: Array.from(urls) }) + ); + } else if ( + PlacesUtils.nodeIsURI(node) && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a uri node inside an history query. + await PlacesUtils.history.remove(node.uri).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else if ( + node.itemId == -1 && + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a dynamically generated history query, like queries + // grouped by site, time or both. Dynamically generated queries don't + // have an itemId even if they are descendants of a bookmark. + await this._removeHistoryContainer(node).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else { + // This is a common bookmark item. + if (PlacesUtils.nodeIsFolder(node)) { + // If this is a folder we add it to our array of folders, used + // to skip nodes that are children of an already removed folder. + removedFolders.push(node); + } + bmGuidsToRemove.push(node.bookmarkGuid); + } + } + if (bmGuidsToRemove.length) { + transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove })); + } + return totalItems; + }, + + async _removeRowsFromBookmarks() { + let ranges = this._view.removableSelectionRanges; + let transactions = []; + let removedFolders = []; + let totalItems = 0; + + for (let range of ranges) { + totalItems += await this._removeRange( + range, + transactions, + removedFolders + ); + } + + if (transactions.length) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + totalItems, + async () => { + await PlacesTransactions.batch(transactions); + } + ); + } + }, + + /** + * Removes the set of selected ranges from history, asynchronously. History + * deletes are not undoable. + */ + async _removeRowsFromHistory() { + let nodes = this._view.selectedNodes; + let URIs = new Set(); + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + URIs.add(node.uri); + } else if ( + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + await this._removeHistoryContainer(node).catch(console.error); + } + } + + if (URIs.size) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + URIs.size, + async () => { + await PlacesUtils.history.remove([...URIs]); + } + ); + } + }, + + /** + * Removes history visits for an history container node. History deletes are + * not undoable. + * + * @param {object} aContainerNode + * The container node to remove. + */ + async _removeHistoryContainer(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // This is a site container. + // Check if it's the container for local files (don't be fooled by the + // bogus string name, this is "(local files)"). + let host = + "." + + (aContainerNode.title == PlacesUtils.getString("localhost") + ? "" + : aContainerNode.title); + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + await PlacesUtils.history.removeByFilter({ host }); + } else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // This is a day container. + let query = aContainerNode.query; + let beginTime = query.beginTime; + let endTime = query.endTime; + if (!query || !beginTime || !endTime) { + throw new Error("A valid date container query should exist!"); + } + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + // We want to exclude beginTime from the removal because + // removePagesByTimeframe includes both extremes, while date containers + // exclude the lower extreme. So, if we would not exclude it, we would + // end up removing more history than requested. + await PlacesUtils.history.removeByFilter({ + beginDate: PlacesUtils.toDate(beginTime + 1000), + endDate: PlacesUtils.toDate(endTime), + }); + } + }, + + /** + * Removes the selection + */ + async remove() { + if (!this._hasRemovableSelection()) { + return; + } + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) { + await this._removeRowsFromBookmarks(); + } else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { + await this._removeRowsFromBookmarks(); + } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + await this._removeRowsFromHistory(); + } else { + throw new Error("implement support for QUERY_TYPE_UNIFIED"); + } + } else { + throw new Error("unexpected root"); + } + }, + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * + * @param {object} aEvent + * The dragstart event. + */ + setDataTransfer: function PC_setDataTransfer(aEvent) { + let dt = aEvent.dataTransfer; + + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + + function addData(type, index) { + let wrapNode = PlacesUtils.wrapNode(node, type); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index); + addData(PlacesUtils.TYPE_PLAINTEXT, index); + addData(PlacesUtils.TYPE_HTML, index); + } + + try { + let nodes = this._view.draggableSelection; + for (let i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + + // This order is _important_! It controls how this and other + // applications select data to be inserted based on type. + addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); + if (node.uri) { + addURIData(i); + } + } + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + get clipboardAction() { + let action = {}; + let actionOwner; + try { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION); + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action); + [action, actionOwner] = action.value + .QueryInterface(Ci.nsISupportsString) + .data.split(","); + } catch (ex) { + // Paste from external sources don't have any associated action, just + // fallback to a copy action. + return "copy"; + } + // For cuts also check who inited the action, since cuts across different + // instances should instead be handled as copies (The sources are not + // available for this instance). + if (action == "cut" && actionOwner != this.profileName) { + action = "copy"; + } + + return action; + }, + + _releaseClipboardOwnership: function PC__releaseClipboardOwnership() { + if (this.cutNodes.length) { + // This clears the logical clipboard, doesn't remove data. + Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _clearClipboard: function PC__clearClipboard() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + xferable.addDataFlavor(TYPE); + xferable.setTransferData(TYPE, PlacesUtils.toISupportsString("")); + Services.clipboard.setData( + xferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + }, + + _populateClipboard: function PC__populateClipboard(aNodes, aAction) { + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, + { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: PlacesUtils.TYPE_HTML, entries: [] }, + { type: PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + // Avoid handling descendants of a copied node, the transactions take care + // of them automatically. + let copiedFolders = []; + aNodes.forEach(function (node) { + if (this._shouldSkipNode(node, copiedFolders)) { + return; + } + if (PlacesUtils.nodeIsFolder(node)) { + copiedFolders.push(node); + } + + contents.forEach(function (content) { + content.entries.push(PlacesUtils.wrapNode(node, content.type)); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data)); + } + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + let hasData = false; + // This order matters here! It controls how this and other applications + // select data to be inserted based on type. + contents.forEach(function (content) { + if (content.entries.length) { + hasData = true; + let glue = + content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; + addData(content.type, content.entries.join(glue)); + } + }); + + // Track the exected action in the xferable. This must be the last flavor + // since it's the least preferred one. + // Enqueue a unique instance identifier to distinguish operations across + // concurrent instances of the application. + addData( + PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, + aAction + "," + this.profileName + ); + + if (hasData) { + Services.clipboard.setData( + xferable, + aAction == "cut" ? this : null, + Ci.nsIClipboard.kGlobalClipboard + ); + } + }, + + _cutNodes: [], + get cutNodes() { + return this._cutNodes; + }, + set cutNodes(aNodes) { + let self = this; + function updateCutNodes(aValue) { + self._cutNodes.forEach(function (aNode) { + self._view.toggleCutNode(aNode, aValue); + }); + } + + updateCutNodes(false); + this._cutNodes = aNodes; + updateCutNodes(true); + }, + + /** + * Copy Bookmarks and Folders to the clipboard + */ + copy: function PC_copy() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "copy"); + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Cut Bookmarks and Folders to the clipboard + */ + cut: function PC_cut() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "cut"); + this.cutNodes = this._view.selectedNodes; + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Paste Bookmarks and Folders from the clipboard + */ + async paste() { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let action = this.clipboardAction; + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // This order matters here! It controls the preferred flavors for this + // paste operation. + [ + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ].forEach(type => xferable.addDataFlavor(type)); + + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + // Now get the clipboard contents, in the best available flavor. + let data = {}, + type = {}, + items = []; + try { + xferable.getAnyTransferData(type, data); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + type = type.value; + items = PlacesUtils.unwrapNodes(data, type); + } catch (ex) { + // No supported data exists or nodes unwrap failed, just bail out. + return; + } + + let doCopy = action == "copy"; + let itemsToSelect = await PlacesUIUtils.handleTransferItems( + items, + ip, + doCopy, + this._view + ); + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + if (itemsToSelect.length) { + this._view.selectItems(itemsToSelect, false); + } + }, + + /** + * Checks if we can insert into a container. + * + * @param {object} container + * The container were we are want to drop + * @returns {boolean} + */ + disallowInsertion(container) { + if (!container) { + throw new Error("empty container"); + } + // Allow dropping into Tag containers and editable folders. + return ( + !PlacesUtils.nodeIsTagQuery(container) && + (!PlacesUtils.nodeIsFolder(container) || + PlacesUIUtils.isFolderReadOnly(container)) + ); + }, + + /** + * Determines if a node can be moved. + * + * @param {object} node + * A nsINavHistoryResultNode node. + * @returns {boolean} True if the node can be moved, false otherwise. + */ + canMoveNode(node) { + // Only bookmark items are movable. + if (node.itemId == -1) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + let parentNode = node.parent; + if (!parentNode) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + if (PlacesUtils.nodeIsTagQuery(parentNode)) { + return false; + } + + return ( + (PlacesUtils.nodeIsFolder(parentNode) && + !PlacesUIUtils.isFolderReadOnly(parentNode)) || + PlacesUtils.nodeIsQuery(parentNode) + ); + }, + async forgetAboutThisSite() { + let host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + host = this._view.selectedNode.query.domain; + } else { + host = Services.io.newURI(this._view.selectedNode.uri).host; + } + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(host); + } catch (e) { + // If there is no baseDomain we fall back to host + } + const [title, body, forget] = await document.l10n.formatValues([ + { id: "places-forget-about-this-site-confirmation-title" }, + { + id: "places-forget-about-this-site-confirmation-msg", + args: { hostOrBaseDomain: baseDomain ?? host }, + }, + { id: "places-forget-about-this-site-forget" }, + ]); + + const flags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_POS_1_DEFAULT; + + let bag = await Services.prompt.asyncConfirmEx( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + title, + body, + flags, + forget, + null, + null, + null, + false + ); + if (bag.getProperty("buttonNumClicked") !== 0) { + return; + } + + if (this.forgetSiteClearByBaseDomain) { + await this.ForgetAboutSite.removeDataFromBaseDomain(host); + } else { + await this.ForgetAboutSite.removeDataFromDomain(host); + } + }, + + showInFolder(aBookmarkGuid) { + // Open containing folder in left pane/sidebar bookmark tree + let documentUrl = document.documentURI.toLowerCase(); + if (documentUrl.endsWith("browser.xhtml")) { + // We're in a menu or a panel. + window.SidebarUI._show("viewBookmarksSidebar").then(() => { + let theSidebar = document.getElementById("sidebar"); + theSidebar.contentDocument + .getElementById("bookmarks-view") + .selectItems([aBookmarkGuid]); + }); + } else if (documentUrl.includes("sidebar")) { + // We're in the sidebar - clear the search box first + let searchBox = document.getElementById("search-box"); + searchBox.value = ""; + searchBox.doCommand(); + + // And go to the node + this._view.selectItems([aBookmarkGuid], true); + } else { + // We're in the bookmark library/manager + PlacesUtils.bookmarks + .fetch(aBookmarkGuid, null, { includePath: true }) + .then(b => { + let containers = b.path.map(obj => { + return obj.guid; + }); + // selectLeftPane looks for literal "AllBookmarks" as a "built-in" + containers.splice(0, 0, "AllBookmarks"); + PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers); + this._view.selectItems([aBookmarkGuid], false); + }); + } + }, +}; + +/** + * Handles drag and drop operations for views. Note that this is view agnostic! + * You should not use PlacesController._view within these methods, since + * the view that the item(s) have been dropped on was not necessarily active. + * Drop functions are passed the view that is being dropped on. + */ +var PlacesControllerDragHelper = { + /** + * For views using DOM nodes like toolbars, menus and panels, this is the DOM + * element currently being dragged over. For other views not handling DOM + * nodes, like trees, it is a Places result node instead. + */ + currentDropTarget: null, + + /** + * Determines if the mouse is currently being dragged over a child node of + * this menu. This is necessary so that the menu doesn't close while the + * mouse is dragging over one of its submenus + * + * @param {object} node + * The container node + * @returns {boolean} true if the user is dragging over a node within the hierarchy of + * the container, false otherwise. + */ + draggingOverChildNode: function PCDH_draggingOverChildNode(node) { + let currentNode = this.currentDropTarget; + while (currentNode) { + if (currentNode == node) { + return true; + } + currentNode = currentNode.parentNode; + } + return false; + }, + + /** + * @returns {object|null} The current active drag session. Returns null if there is none. + */ + getSession: function PCDH__getSession() { + return this.dragService.getCurrentSession(); + }, + + /** + * Extract the most relevant flavor from a list of flavors. + * + * @param {DOMStringList} flavors The flavors list. + * @returns {string} The most relevant flavor, or undefined. + */ + getMostRelevantFlavor(flavors) { + // The DnD API returns a DOMStringList, but tests may pass an Array. + flavors = Array.from(flavors); + return PlacesUIUtils.SUPPORTED_FLAVORS.find(f => flavors.includes(f)); + }, + + /** + * Determines whether or not the data currently being dragged can be dropped + * on a places view. + * + * @param {object} ip + * The insertion point where the items should be dropped. + * @param {object} dt + * The data transfer object. + * @returns {boolean} + */ + canDrop: function PCDH_canDrop(ip, dt) { + let dropCount = dt.mozItemCount; + + // Check every dragged item. + for (let i = 0; i < dropCount; i++) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return false; + } + + // Urls can be dropped on any insertionpoint. + // XXXmano: remember that this method is called for each dragover event! + // Thus we shouldn't use unwrapNodes here at all if possible. + // I think it would be OK to accept bogus data here (e.g. text which was + // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and + // will just case the actual drop to be a no-op), and only rule out valid + // expected cases, which are either unsupported flavors, or items which + // cannot be dropped in the current insertionpoint. The last case will + // likely force us to use unwrapNodes for the private data types of + // places. + if (flavor == TAB_DROP_TYPE) { + continue; + } + + let data = dt.mozGetDataAt(flavor, i); + let nodes; + try { + nodes = PlacesUtils.unwrapNodes(data, flavor); + } catch (e) { + return false; + } + + for (let dragged of nodes) { + // Only bookmarks and urls can be dropped into tag containers. + if ( + ip.isTag && + dragged.type != PlacesUtils.TYPE_X_MOZ_URL && + (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || + (dragged.uri && dragged.uri.startsWith("place:"))) + ) { + return false; + } + + // Disallow dropping of a folder on itself or any of its descendants. + // This check is done to show an appropriate drop indicator, a stricter + // check is done later by the bookmarks API. + if ( + dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) + ) { + let dragOverPlacesNode = this.currentDropTarget; + if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) { + // If it's a DOM node, it should have a _placesNode expando, or it + // may be a static element in a places container, like the [empty] + // menuitem. + dragOverPlacesNode = + dragOverPlacesNode._placesNode ?? + dragOverPlacesNode.parentNode?._placesNode; + } + + // If we couldn't get a target Places result node then we can't check + // whether the drag is allowed, just let it go through. + if (dragOverPlacesNode) { + let guid = dragged.concreteGuid ?? dragged.itemGuid; + // Dragging over itself. + if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) { + return false; + } + // Dragging over a descendant. + for (let ancestor of PlacesUtils.nodeAncestors( + dragOverPlacesNode + )) { + if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) { + return false; + } + } + } + } + + // Disallow the dropping of multiple bookmarks if they include + // a javascript: bookmarklet + if ( + !flavor.startsWith("text/x-moz-place") && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + return false; + } + } + } + return true; + }, + + /** + * Handles the drop of one or more items onto a view. + * + * @param {object} insertionPoint The insertion point where the items should + * be dropped. + * @param {object} dt The dataTransfer information for the drop. + * @param {object} [view] The view or the tree element. This allows + * batching to take place. + */ + async onDrop(insertionPoint, dt, view) { + let doCopy = ["copy", "link"].includes(dt.dropEffect); + + let dropCount = dt.mozItemCount; + + // Following flavors may contain duplicated data. + let duplicable = new Map(); + duplicable.set(PlacesUtils.TYPE_PLAINTEXT, new Set()); + duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set()); + + // Collect all data from the DataTransfer before processing it, as the + // DataTransfer is only valid during the synchronous handling of the `drop` + // event handler callback. + let nodes = []; + let externalDrag = false; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return; + } + + let data = dt.mozGetDataAt(flavor, i); + if (duplicable.has(flavor)) { + let handled = duplicable.get(flavor); + if (handled.has(data)) { + continue; + } + handled.add(data); + } + + // Check that the drag/drop is not internal + if (i == 0 && !flavor.startsWith("text/x-moz-place")) { + externalDrag = true; + } + + if (flavor != TAB_DROP_TYPE) { + nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)]; + } else if ( + XULElement.isInstance(data) && + data.localName == "tab" && + data.ownerGlobal.isChromeWindow + ) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + nodes.push({ + uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL, + }); + } else { + throw new Error("bogus data was passed as a tab"); + } + } + + // If a multiple urls are being dropped from the urlbar or an external source, + // and they include javascript url, not bookmark any of them + if ( + externalDrag && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + throw new Error("Javascript bookmarklet passed with uris"); + } + + // If a single javascript url is being dropped from the urlbar or an external source, + // show the bookmark dialog as a speedbump protection against malicious cases. + if ( + nodes.length == 1 && + externalDrag && + nodes[0].uri?.startsWith("javascript") + ) { + let uri; + try { + uri = Services.io.newURI(nodes[0].uri); + } catch (ex) { + // Invalid uri, we skip this code and the entry will be discarded later. + } + + if (uri) { + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: "bookmark", + defaultInsertionPoint: insertionPoint, + hiddenRows: ["folderPicker"], + title: nodes[0].title, + uri, + }, + BrowserWindowTracker.getTopWindow() // `window` may be the Library. + ); + + if (bookmarkGuid && view) { + view.selectItems([bookmarkGuid], false); + } + + return; + } + } + + await PlacesUIUtils.handleTransferItems( + nodes, + insertionPoint, + doCopy, + view + ); + }, +}; + +XPCOMUtils.defineLazyServiceGetter( + PlacesControllerDragHelper, + "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService" +); diff --git a/browser/components/places/content/editBookmark.js b/browser/components/places/content/editBookmark.js new file mode 100644 index 0000000000..fe78da30a7 --- /dev/null +++ b/browser/components/places/content/editBookmark.js @@ -0,0 +1,1251 @@ +/* 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/. */ + +/* global MozXULElement */ + +// This is defined in browser.js and only used in the star UI. +/* global setToolbarVisibility */ + +/* import-globals-from controller.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +var gEditItemOverlay = { + // Array of PlacesTransactions accumulated by internal changes. It can be used + // to wait for completion. + transactionPromises: null, + _staticFoldersListBuilt: false, + _didChangeFolder: false, + // Tracks bookmark properties changes in the dialog, allowing external consumers + // to either confirm or discard them. + _bookmarkState: null, + _allTags: null, + _initPanelDeferred: null, + _updateTagsDeferred: null, + _paneInfo: null, + _setPaneInfo(aInitInfo) { + if (!aInitInfo) { + return (this._paneInfo = null); + } + + if ("uris" in aInitInfo && "node" in aInitInfo) { + throw new Error("ambiguous pane info"); + } + if (!("uris" in aInitInfo) && !("node" in aInitInfo)) { + throw new Error("Neither node nor uris set for pane info"); + } + + // We either pass a node or uris. + let node = "node" in aInitInfo ? aInitInfo.node : null; + + // Since there's no true UI for folder shortcuts (they show up just as their target + // folders), when the pane shows for them it's opened in read-only mode, showing the + // properties of the target folder. + let itemId = node ? node.itemId : -1; + let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null; + let isItem = itemId != -1; + let isFolderShortcut = + isItem && + node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + let isTag = node && PlacesUtils.nodeIsTagQuery(node); + let tag = null; + if (isTag) { + tag = + PlacesUtils.asQuery(node).query.tags.length == 1 + ? node.query.tags[0] + : node.title; + } + let isURI = node && PlacesUtils.nodeIsURI(node); + let uri = isURI || isTag ? Services.io.newURI(node.uri) : null; + let title = node ? node.title : null; + let isBookmark = isItem && isURI; + let bulkTagging = !node; + let uris = bulkTagging ? aInitInfo.uris : null; + let visibleRows = new Set(); + let isParentReadOnly = false; + let postData = aInitInfo.postData; + let parentGuid = null; + + if (node && isItem) { + if ( + !node.parent || + (node.parent.itemId > 0 && !node.parent.bookmarkGuid) + ) { + throw new Error( + "Cannot use an incomplete node to initialize the edit bookmark panel" + ); + } + let parent = node.parent; + isParentReadOnly = !PlacesUtils.nodeIsFolder(parent); + parentGuid = parent.bookmarkGuid; + } + + let focusedElement = aInitInfo.focusedElement; + let onPanelReady = aInitInfo.onPanelReady; + + return (this._paneInfo = { + itemId, + itemGuid, + parentGuid, + isItem, + isURI, + uri, + title, + isBookmark, + isFolderShortcut, + isParentReadOnly, + bulkTagging, + uris, + visibleRows, + postData, + isTag, + focusedElement, + onPanelReady, + tag, + }); + }, + + get initialized() { + return this._paneInfo != null; + }, + + // Backwards-compatibility getters + get itemId() { + if ( + !this.initialized || + this._paneInfo.isTag || + this._paneInfo.bulkTagging + ) { + return -1; + } + return this._paneInfo.itemId; + }, + + get uri() { + if (!this.initialized) { + return null; + } + if (this._paneInfo.bulkTagging) { + return this._paneInfo.uris[0]; + } + return this._paneInfo.uri; + }, + + get multiEdit() { + return this.initialized && this._paneInfo.bulkTagging; + }, + + // Check if the pane is initialized to show only read-only fields. + get readOnly() { + // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some + // quirky implementation details (the most important being the "smart" + // semantics of node.title that makes hard to edit the right entry). + // This pane is read-only if: + // * the panel is not initialized + // * the node is a folder shortcut + // * the node is not bookmarked and not a tag container + // * the node is child of a read-only container and is not a bookmarked + // URI nor a tag container + return ( + !this.initialized || + this._paneInfo.isFolderShortcut || + (!this._paneInfo.isItem && !this._paneInfo.isTag) || + (this._paneInfo.isParentReadOnly && + !this._paneInfo.isBookmark && + !this._paneInfo.isTag) + ); + }, + + get didChangeFolder() { + return this._didChangeFolder; + }, + + // the first field which was edited after this panel was initialized for + // a certain item + _firstEditedField: "", + + _initNamePicker() { + if (this._paneInfo.bulkTagging) { + throw new Error("_initNamePicker called unexpectedly"); + } + + // title may by null, which, for us, is the same as an empty string. + this._initTextField( + this._namePicker, + this._paneInfo.title || this._paneInfo.tag || "" + ); + }, + + _initLocationField() { + if (!this._paneInfo.isURI) { + throw new Error("_initLocationField called unexpectedly"); + } + this._initTextField(this._locationField, this._paneInfo.uri.spec); + }, + + async _initKeywordField(newKeyword = "") { + if (!this._paneInfo.isBookmark) { + throw new Error("_initKeywordField called unexpectedly"); + } + + // Reset the field status synchronously now, eventually we'll reinit it + // later if we find an existing keyword. This way we can ensure to be in a + // consistent status when reusing the panel across different bookmarks. + this._keyword = newKeyword; + this._initTextField(this._keywordField, newKeyword); + + if (!newKeyword) { + let entries = []; + await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e => + entries.push(e) + ); + if (entries.length) { + // We show an existing keyword if either POST data was not provided, or + // if the POST data is the same. + let existingKeyword = entries[0].keyword; + let postData = this._paneInfo.postData; + if (postData) { + let sameEntry = entries.find(e => e.postData === postData); + existingKeyword = sameEntry ? sameEntry.keyword : ""; + } + if (existingKeyword) { + this._keyword = existingKeyword; + // Update the text field to the existing keyword. + this._initTextField(this._keywordField, this._keyword); + } + } + } + }, + + async _initAllTags() { + this._allTags = new Map(); + const fetchedTags = await PlacesUtils.bookmarks.fetchTags(); + for (const tag of fetchedTags) { + this._allTags?.set(tag.name.toLowerCase(), tag.name); + } + }, + + /** + * Initialize the panel. + * + * @param {object} aInfo + * The initialization info. + * @param {object} [aInfo.node] + * If aInfo.uris is not specified, this must be specified. + * Either a result node or a node-like object representing the item to be edited. + * A node-like object must have the following properties (with values that + * match exactly those a result node would have): + * itemId, bookmarkGuid, uri, title, type. + * @param {nsIURI[]} [aInfo.uris] + * If aInfo.node is not specified, this must be specified. + * An array of uris for bulk tagging. + * @param {string[]} [aInfo.hiddenRows] + * List of rows to be hidden regardless of the item edited. Possible values: + * "title", "location", "keyword", "folderPicker". + */ + async initPanel(aInfo) { + const deferred = (this._initPanelDeferred = PromiseUtils.defer()); + try { + if (typeof aInfo != "object" || aInfo === null) { + throw new Error("aInfo must be an object."); + } + if ("node" in aInfo) { + try { + aInfo.node.type; + } catch (e) { + // If the lazy loader for |type| generates an exception, it means that + // this bookmark could not be loaded. This sometimes happens when tests + // create a bookmark by clicking the bookmark star, then try to cleanup + // before the bookmark panel has finished opening. Either way, if we + // cannot retrieve the bookmark information, we cannot open the panel. + return; + } + } + + // For sanity ensure that the implementer has uninited the panel before + // trying to init it again, or we could end up leaking due to observers. + if (this.initialized) { + this.uninitPanel(false); + } + + this._didChangeFolder = false; + this.transactionPromises = []; + + let { + parentGuid, + isItem, + isURI, + isBookmark, + bulkTagging, + uris, + visibleRows, + focusedElement, + onPanelReady, + } = this._setPaneInfo(aInfo); + + // initPanel can be called multiple times in a row, + // and awaits Promises. If the reference to `instance` + // changes, it must mean another caller has called + // initPanel again, so bail out of the initialization. + let instance = (this._instance = {}); + + // If we're creating a new item on the toolbar, show it: + if ( + aInfo.isNewBookmark && + parentGuid == PlacesUtils.bookmarks.toolbarGuid + ) { + this._autoshowBookmarksToolbar(); + } + + let showOrCollapse = ( + rowId, + isAppropriateForInput, + nameInHiddenRows = null + ) => { + let visible = isAppropriateForInput; + if (visible && "hiddenRows" in aInfo && nameInHiddenRows) { + visible &= !aInfo.hiddenRows.includes(nameInHiddenRows); + } + if (visible) { + visibleRows.add(rowId); + } + const cells = document.getElementsByClassName("editBMPanel_" + rowId); + for (const cell of cells) { + cell.hidden = !visible; + } + return visible; + }; + + if (showOrCollapse("nameRow", !bulkTagging, "name")) { + this._initNamePicker(); + this._namePicker.readOnly = this.readOnly; + } + + // In some cases we want to hide the location field, since it's not + // human-readable, but we still want to initialize it. + showOrCollapse("locationRow", isURI, "location"); + if (isURI) { + this._initLocationField(); + this._locationField.readOnly = this.readOnly; + } + + if (showOrCollapse("keywordRow", isBookmark, "keyword")) { + await this._initKeywordField().catch(console.error); + // paneInfo can be null if paneInfo is uninitialized while + // the process above is awaiting initialization + if (instance != this._instance || this._paneInfo == null) { + return; + } + this._keywordField.readOnly = this.readOnly; + } + + // Collapse the tag selector if the item does not accept tags. + if (showOrCollapse("tagsRow", isURI || bulkTagging, "tags")) { + this._initTagsField(); + } else if (!this._element("tagsSelectorRow").hidden) { + this.toggleTagsSelector().catch(console.error); + } + + // Folder picker. + // Technically we should check that the item is not moveable, but that's + // not cheap (we don't always have the parent), and there's no use case for + // this (it's only the Star UI that shows the folderPicker) + if (showOrCollapse("folderRow", isItem, "folderPicker")) { + await this._initFolderMenuList(parentGuid).catch(console.error); + if (instance != this._instance || this._paneInfo == null) { + return; + } + } + + // Selection count. + if (showOrCollapse("selectionCount", bulkTagging)) { + document.l10n.setAttributes( + this._element("itemsCountText"), + "places-details-pane-items-count", + { count: uris.length } + ); + } + + // Observe changes. + if (!this._observersAdded) { + this.handlePlacesEvents = this.handlePlacesEvents.bind(this); + PlacesUtils.observers.addListener( + ["bookmark-title-changed"], + this.handlePlacesEvents + ); + window.addEventListener("unload", this); + this._observersAdded = true; + } + + let focusElement = () => { + // The focusedElement possible values are: + // * preferred: focus the field that the user touched first the last + // time the pane was shown (either namePicker or tagsField) + // * first: focus the first non hidden input + // Note: since all controls are hidden by default, we don't get the + // default XUL dialog behavior, that selects the first control, so we set + // the focus explicitly. + let elt; + if (focusedElement === "preferred") { + elt = this._element( + Services.prefs.getCharPref( + "browser.bookmarks.editDialog.firstEditField" + ) + ); + } else if (focusedElement === "first") { + elt = document.querySelector('input:not([hidden="true"])'); + } + if (elt) { + elt.focus({ preventScroll: true }); + elt.select(); + } + }; + + if (onPanelReady) { + onPanelReady(focusElement); + } else { + focusElement(); + } + + if (this._updateTagsDeferred) { + await this._updateTagsDeferred.promise; + } + + this._bookmarkState = this.makeNewStateObject({ + children: aInfo.node?.children, + index: aInfo.node?.index, + isFolder: aInfo.node != null && PlacesUtils.nodeIsFolder(aInfo.node), + }); + if (isBookmark || bulkTagging) { + await this._initAllTags(); + await this._rebuildTagsSelectorList(); + } + } finally { + deferred.resolve(); + if (this._initPanelDeferred === deferred) { + // Since change listeners check _initPanelDeferred for truthiness, we + // can prevent unnecessary awaits by setting it back to null. + this._initPanelDeferred = null; + } + } + }, + + /** + * Finds tags that are in common among this._currentInfo.uris; + * + * @returns {string[]} + */ + _getCommonTags() { + if ("_cachedCommonTags" in this._paneInfo) { + return this._paneInfo._cachedCommonTags; + } + + let uris = [...this._paneInfo.uris]; + let firstURI = uris.shift(); + let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI)); + if (commonTags.size == 0) { + return (this._cachedCommonTags = []); + } + + for (let uri of uris) { + let curentURITags = PlacesUtils.tagging.getTagsForURI(uri); + for (let tag of commonTags) { + if (!curentURITags.includes(tag)) { + commonTags.delete(tag); + if (commonTags.size == 0) { + return (this._paneInfo.cachedCommonTags = []); + } + } + } + } + return (this._paneInfo._cachedCommonTags = [...commonTags]); + }, + + _initTextField(aElement, aValue) { + if (aElement.value != aValue) { + aElement.value = aValue; + + // Clear the editor's undo stack + // FYI: editor may be null. + aElement.editor?.clearUndoRedo(); + } + }, + + /** + * Appends a menu-item representing a bookmarks folder to a menu-popup. + * + * @param {DOMElement} aMenupopup + * The popup to which the menu-item should be added. + * @param {string} aFolderGuid + * The identifier of the bookmarks folder. + * @param {string} aTitle + * The title to use as a label. + * @returns {DOMElement} + * The new menu item. + */ + _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createXULElement("menuitem"); + folderMenuItem.folderGuid = aFolderGuid; + folderMenuItem.setAttribute("label", aTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + async _initFolderMenuList(aSelectedFolderGuid) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.children.length > 6) { + menupopup.removeChild(menupopup.lastElementChild); + } + + // Build the static list + if (!this._staticFoldersListBuilt) { + let unfiledItem = this._element("unfiledRootItem"); + unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle"); + unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid; + let bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle"); + bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle"); + toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + let lastUsedFolderGuids = await PlacesUtils.metadata.get( + PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, + [] + ); + + /** + * The list of last used folders is sorted in most-recent first order. + * + * First we build the annotated folders array, each item has both the + * folder identifier and the time at which it was last-used by this dialog + * set. Then we sort it descendingly based on the time field. + */ + this._recentFolders = []; + for (let guid of lastUsedFolderGuids) { + let bm = await PlacesUtils.bookmarks.fetch(guid); + if (bm) { + let title = PlacesUtils.bookmarks.getLocalizedTitle(bm); + this._recentFolders.push({ guid, title }); + } + } + + var numberOfItems = Math.min( + PlacesUIUtils.maxRecentFolders, + this._recentFolders.length + ); + for (let i = 0; i < numberOfItems; i++) { + await this._appendFolderItemToMenupopup( + menupopup, + this._recentFolders[i].guid, + this._recentFolders[i].title + ); + } + + let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title; + var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title); + this._folderMenuList.selectedItem = defaultItem; + // Ensure the selectedGuid attribute is set correctly (the above line wouldn't + // necessary trigger a select event, so handle it manually, then add the + // listener). + this._onFolderListSelected(); + + this._folderMenuList.addEventListener("select", this); + this._folderMenuListListenerAdded = true; + + // Hide the folders-separator if no folder is annotated as recently-used + this._element("foldersSeparator").hidden = menupopup.children.length <= 6; + this._folderMenuList.disabled = this.readOnly; + }, + + _onFolderListSelected() { + // Set a selectedGuid attribute to show special icons + let folderGuid = this.selectedFolderGuid; + if (folderGuid) { + this._folderMenuList.setAttribute("selectedGuid", folderGuid); + } else { + this._folderMenuList.removeAttribute("selectedGuid"); + } + }, + + _element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + uninitPanel(aHideCollapsibleElements) { + if (aHideCollapsibleElements) { + // Hide the folder tree if it was previously visible. + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.hidden) { + this.toggleFolderTreeVisibility(); + } + + // Hide the tag selector if it was previously visible. + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (!tagsSelectorRow.hidden) { + this.toggleTagsSelector().catch(console.error); + } + } + + if (this._observersAdded) { + PlacesUtils.observers.removeListener( + ["bookmark-title-changed"], + this.handlePlacesEvents + ); + window.removeEventListener("unload", this); + this._observersAdded = false; + } + + if (this._folderMenuListListenerAdded) { + this._folderMenuList.removeEventListener("select", this); + this._folderMenuListListenerAdded = false; + } + + this._setPaneInfo(null); + this._firstEditedField = ""; + this._didChangeFolder = false; + this.transactionPromises = []; + this._bookmarkState = null; + this._allTags = null; + }, + + get selectedFolderGuid() { + return ( + this._folderMenuList.selectedItem && + this._folderMenuList.selectedItem.folderGuid + ); + }, + + makeNewStateObject(extraOptions) { + if ( + this._paneInfo.isItem || + this._paneInfo.isTag || + this._paneInfo.bulkTagging + ) { + const isLibraryWindow = + document.documentElement.getAttribute("windowtype") === + "Places:Organizer"; + const options = { + autosave: isLibraryWindow, + info: this._paneInfo, + ...extraOptions, + }; + + if (this._paneInfo.isBookmark) { + options.tags = this._element("tagsField").value; + options.keyword = this._keyword; + } + + if (this._paneInfo.bulkTagging) { + options.tags = this._element("tagsField").value; + } + + return new PlacesUIUtils.BookmarkState(options); + } + return null; + }, + + async onTagsFieldChange() { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if ( + this._paneInfo && + (this._paneInfo.isURI || this._paneInfo.bulkTagging) + ) { + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + this._updateTags().then(() => { + // Check _paneInfo here as we might be closing the dialog. + if (this._paneInfo) { + this._mayUpdateFirstEditField("tagsField"); + } + }, console.error); + } + }, + + /** + * Handle tag list updates from the input field or selector box. + */ + async _updateTags() { + const deferred = (this._updateTagsDeferred = PromiseUtils.defer()); + try { + const inputTags = this._getTagsArrayFromTagsInputField(); + const isLibraryWindow = + document.documentElement.getAttribute("windowtype") === + "Places:Organizer"; + await this._bookmarkState._tagsChanged(inputTags); + + if (isLibraryWindow) { + // Ensure the tagsField is in sync, clean it up from empty tags + delete this._paneInfo._cachedCommonTags; + const currentTags = this._paneInfo.bulkTagging + ? this._getCommonTags() + : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + this._initTextField(this._tagsField, currentTags.join(", "), false); + await this._initAllTags(); + } else { + // Autosave is disabled. Update _allTags in memory so that the selector + // list shows any new tags that haven't been saved yet. + inputTags.forEach(tag => this._allTags?.set(tag.toLowerCase(), tag)); + } + await this._rebuildTagsSelectorList(); + } finally { + deferred.resolve(); + if (this._updateTagsDeferred === deferred) { + // Since initPanel() checks _updateTagsDeferred for truthiness, we can + // prevent unnecessary awaits by setting it back to null. + this._updateTagsDeferred = null; + } + } + }, + + /** + * Stores the first-edit field for this dialog, if the passed-in field + * is indeed the first edited field. + * + * @param {string} aNewField + * The id of the field that may be set (without the "editBMPanel_" prefix). + */ + _mayUpdateFirstEditField(aNewField) { + // * The first-edit-field behavior is not applied in the multi-edit case + // * if this._firstEditedField is already set, this is not the first field, + // so there's nothing to do + if (this._paneInfo.bulkTagging || this._firstEditedField) { + return; + } + + this._firstEditedField = aNewField; + + // set the pref + Services.prefs.setCharPref( + "browser.bookmarks.editDialog.firstEditField", + aNewField + ); + }, + + async onNamePickerChange() { + if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + + // Here we update either the item title or its cached static title + if (this._paneInfo.isTag) { + let tag = this._namePicker.value; + if (!tag || tag.includes("&")) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + return; + } + + this._bookmarkState._titleChanged(tag); + return; + } + this._mayUpdateFirstEditField("namePicker"); + this._bookmarkState._titleChanged(this._namePicker.value); + }, + + async onLocationFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + + let newURI; + try { + newURI = Services.uriFixup.getFixupURIInfo( + this._locationField.value + ).preferredURI; + } catch (ex) { + // TODO: Bug 1089141 - Provide some feedback about the invalid url. + return; + } + + if (this._paneInfo.uri.equals(newURI)) { + return; + } + this._bookmarkState._locationChanged(newURI.spec); + }, + + async onKeywordFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) { + return; + } + if (this._initPanelDeferred) { + await this._initPanelDeferred.promise; + } + this._bookmarkState._keywordChanged(this._keywordField.value); + }, + + toggleFolderTreeVisibility() { + let expander = this._element("foldersExpander"); + let folderTreeRow = this._element("folderTreeRow"); + let wasHidden = folderTreeRow.hidden; + expander.classList.toggle("expander-up", wasHidden); + expander.classList.toggle("expander-down", !wasHidden); + if (!wasHidden) { + expander.setAttribute( + "tooltiptext", + expander.getAttribute("tooltiptextdown") + ); + folderTreeRow.hidden = true; + this._element("chooseFolderSeparator").hidden = this._element( + "chooseFolderMenuItem" + ).hidden = false; + // Stop editing if we were (will no-op if not). This avoids permanently + // breaking the tree if/when it is reshown. + this._folderTree.stopEditing(false); + // Unlinking the view will break the connection with the result. We don't + // want to pay for live updates while the view is not visible. + this._folderTree.view = null; + } else { + expander.setAttribute( + "tooltiptext", + expander.getAttribute("tooltiptextup") + ); + folderTreeRow.hidden = false; + + // XXXmano: Ideally we would only do this once, but for some odd reason, + // the editable mode set on this tree, together with its hidden state + // breaks the view. + const FOLDER_TREE_PLACE_URI = + "place:excludeItems=1&excludeQueries=1&type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = this._element( + "chooseFolderMenuItem" + ).hidden = true; + this._folderTree.selectItems([this._paneInfo.parentGuid]); + this._folderTree.focus(); + } + }, + + /** + * Get the corresponding menu-item in the folder-menu-list for a bookmarks + * folder if such an item exists. Otherwise, this creates a menu-item for the + * folder. If the items-count limit (see + * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the + * new item replaces the last menu-item. + * + * @param {string} aFolderGuid + * The identifier of the bookmarks folder. + * @param {string} aTitle + * The title to use in case of menuitem creation. + * @returns {DOMElement} + * The handle to the menuitem. + */ + _getFolderMenuItem(aFolderGuid, aTitle) { + let menupopup = this._folderMenuList.menupopup; + let menuItem = Array.prototype.find.call( + menupopup.children, + item => item.folderGuid === aFolderGuid + ); + if (menuItem !== undefined) { + return menuItem; + } + + // 3 special folders + separator + folder-items-count limit + if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) { + menupopup.removeChild(menupopup.lastElementChild); + } + + return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle); + }, + + async onFolderMenuListCommand(aEvent) { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if (!this._paneInfo) { + return; + } + + if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") { + // reset the selection back to where it was and expand the tree + // (this menu-item is hidden when the tree is already visible + let item = this._getFolderMenuItem( + this._bookmarkState._originalState.parentGuid, + this._bookmarkState._originalState.title + ); + this._folderMenuList.selectedItem = item; + // XXXmano HACK: setTimeout 100, otherwise focus goes back to the + // menulist right away + setTimeout(() => this.toggleFolderTreeVisibility(), 100); + return; + } + + // Move the item + let containerGuid = this._folderMenuList.selectedItem.folderGuid; + if ( + this._bookmarkState._originalState.parentGuid != containerGuid && + this._bookmarkState._originalState.title != containerGuid + ) { + this._bookmarkState._parentGuidChanged(containerGuid); + + // Auto-show the bookmarks toolbar when adding / moving an item there. + if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) { + this._autoshowBookmarksToolbar(); + } + + // Unless the user cancels the panel, we'll use the chosen folder as + // the default for new bookmarks. + this._didChangeFolder = true; + } + + // Update folder-tree selection + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.hidden) { + var selectedNode = this._folderTree.selectedNode; + if ( + !selectedNode || + PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid + ) { + this._folderTree.selectItems([containerGuid]); + } + } + }, + + _autoshowBookmarksToolbar() { + let neverShowToolbar = + Services.prefs.getCharPref( + "browser.toolbars.bookmarks.visibility", + "newtab" + ) == "never"; + let toolbar = document.getElementById("PersonalToolbar"); + if (!toolbar.collapsed || neverShowToolbar) { + return; + } + + let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks"); + let area = placement && placement.area; + if (area != CustomizableUI.AREA_BOOKMARKS) { + return; + } + + // Show the toolbar but don't persist it permanently open + setToolbarVisibility(toolbar, true, false); + }, + + onFolderTreeSelect() { + // Ignore this event when the folder tree is hidden, even if the tree is + // alive, it's clearly not a user activated action. + if (this._element("folderTreeRow").hidden) { + return; + } + + var selectedNode = this._folderTree.selectedNode; + + // Disable the "New Folder" button if we cannot create a new folder + this._element("newFolderButton").disabled = + !this._folderTree.insertionPoint || !selectedNode; + + if (!selectedNode) { + return; + } + + var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode); + if (this._folderMenuList.selectedItem.folderGuid == folderGuid) { + return; + } + + var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + async _rebuildTagsSelectorList() { + let tagsSelector = this._element("tagsSelector"); + let tagsSelectorRow = this._element("tagsSelectorRow"); + if (tagsSelectorRow.hidden) { + return; + } + + let selectedIndex = tagsSelector.selectedIndex; + let selectedTag = + selectedIndex >= 0 ? tagsSelector.selectedItem.label : null; + + while (tagsSelector.hasChildNodes()) { + tagsSelector.removeChild(tagsSelector.lastElementChild); + } + + let tagsInField = this._getTagsArrayFromTagsInputField(); + + let fragment = document.createDocumentFragment(); + let sortedTags = this._allTags ? [...this._allTags.values()].sort() : []; + + for (let i = 0; i < sortedTags.length; i++) { + let tag = sortedTags[i]; + let elt = document.createXULElement("richlistitem"); + elt.appendChild(document.createXULElement("image")); + let label = document.createXULElement("label"); + label.setAttribute("value", tag); + elt.appendChild(label); + if (tagsInField.includes(tag)) { + elt.setAttribute("checked", "true"); + } + fragment.appendChild(elt); + if (selectedTag === tag) { + selectedIndex = i; + } + } + tagsSelector.appendChild(fragment); + + if (selectedIndex >= 0 && tagsSelector.itemCount > 0) { + selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1); + tagsSelector.selectedIndex = selectedIndex; + tagsSelector.ensureIndexIsVisible(selectedIndex); + } + let event = new CustomEvent("BookmarkTagsSelectorUpdated", { + bubbles: true, + }); + tagsSelector.dispatchEvent(event); + }, + + async toggleTagsSelector() { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + var expander = this._element("tagsSelectorExpander"); + expander.classList.toggle("expander-up", tagsSelectorRow.hidden); + expander.classList.toggle("expander-down", !tagsSelectorRow.hidden); + if (tagsSelectorRow.hidden) { + expander.setAttribute( + "tooltiptext", + expander.getAttribute("tooltiptextup") + ); + tagsSelectorRow.hidden = false; + await this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("mousedown", this); + tagsSelector.addEventListener("keypress", this); + } else { + expander.setAttribute( + "tooltiptext", + expander.getAttribute("tooltiptextdown") + ); + tagsSelectorRow.hidden = true; + + // This is a no-op if we've removed the listener. + tagsSelector.removeEventListener("mousedown", this); + tagsSelector.removeEventListener("keypress", this); + } + }, + + /** + * Splits "tagsField" element value, returning an array of valid tag strings. + * + * @returns {string[]} + * Array of tag strings found in the field value. + */ + _getTagsArrayFromTagsInputField() { + let tags = this._element("tagsField").value; + return tags + .trim() + .split(/\s*,\s*/) // Split on commas and remove spaces. + .filter(tag => !!tag.length); // Kill empty tags. + }, + + async newFolder() { + let ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip) { + ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + } + + // XXXmano: add a separate "New Folder" string at some point... + let title = this._element("newFolderButton").label; + let promise = PlacesTransactions.NewFolder({ + parentGuid: ip.guid, + title, + index: await ip.getIndex(), + }).transact(); + this.transactionPromises.push(promise.catch(console.error)); + let guid = await promise; + + this._folderTree.focus(); + this._folderTree.selectItems([ip.guid]); + PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true; + this._folderTree.selectItems([guid]); + this._folderTree.startEditing( + this._folderTree.view.selection.currentIndex, + this._folderTree.columns.getFirstColumn() + ); + }, + + // EventListener + handleEvent(event) { + switch (event.type) { + case "mousedown": + if (event.button == 0) { + // Make sure the event is triggered on an item and not the empty space. + let item = event.target.closest("richlistbox,richlistitem"); + if (item.localName == "richlistitem") { + this.toggleItemCheckbox(item); + } + } + break; + case "keypress": + if (event.key == " ") { + let item = event.target.currentItem; + if (item) { + this.toggleItemCheckbox(item); + } + } + break; + case "unload": + this.uninitPanel(false); + break; + case "select": + this._onFolderListSelected(); + break; + } + }, + + async handlePlacesEvents(events) { + for (const event of events) { + switch (event.type) { + case "bookmark-title-changed": + if (this._paneInfo.isItem || this._paneInfo.isTag) { + // This also updates titles of folders in the folder menu list. + this._onItemTitleChange(event.id, event.title, event.guid); + } + break; + } + } + }, + + toggleItemCheckbox(item) { + // Update the tags field when items are checked/unchecked in the listbox + let tags = this._getTagsArrayFromTagsInputField(); + + let curTagIndex = tags.indexOf(item.label); + let tagsSelector = this._element("tagsSelector"); + tagsSelector.selectedItem = item; + + if (!item.hasAttribute("checked")) { + item.setAttribute("checked", "true"); + if (curTagIndex == -1) { + tags.push(item.label); + } + } else { + item.removeAttribute("checked"); + if (curTagIndex != -1) { + tags.splice(curTagIndex, 1); + } + } + this._element("tagsField").value = tags.join(", "); + this._updateTags(); + }, + + _initTagsField() { + let tags; + if (this._paneInfo.isURI) { + tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + } else if (this._paneInfo.bulkTagging) { + tags = this._getCommonTags(); + } else { + throw new Error("_promiseTagsStr called unexpectedly"); + } + + this._initTextField(this._tagsField, tags.join(", ")); + }, + + _onItemTitleChange(aItemId, aNewTitle, aGuid) { + if (this._paneInfo.visibleRows.has("folderRow")) { + // If the title of a folder which is listed within the folders + // menulist has been changed, we need to update the label of its + // representing element. + let menupopup = this._folderMenuList.menupopup; + for (let menuitem of menupopup.children) { + if ("folderGuid" in menuitem && menuitem.folderGuid == aGuid) { + menuitem.label = aNewTitle; + break; + } + } + } + // We need to also update title of recent folders. + if (this._recentFolders) { + for (let folder of this._recentFolders) { + if (folder.folderGuid == aGuid) { + folder.title = aNewTitle; + break; + } + } + } + }, + + /** + * State object for the bookmark(s) currently being edited. + * + * @returns {BookmarkState} The bookmark state. + */ + get bookmarkState() { + return this._bookmarkState; + }, +}; + +XPCOMUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => { + if (!customElements.get("places-tree")) { + Services.scriptloader.loadSubScript( + "chrome://browser/content/places/places-tree.js", + window + ); + } + gEditItemOverlay._element("folderTreeRow").prepend( + MozXULElement.parseXULToFragment(` + <tree id="editBMPanel_folderTree" + class="placesTree" + is="places-tree" + editable="true" + onselect="gEditItemOverlay.onFolderTreeSelect();" + disableUserActions="true" + hidecolumnpicker="true"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + `) + ); + return gEditItemOverlay._element("folderTree"); +}); + +for (let elt of [ + "folderMenuList", + "namePicker", + "locationField", + "keywordField", + "tagsField", +]) { + let eltScoped = elt; + XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () => + gEditItemOverlay._element(eltScoped) + ); +} diff --git a/browser/components/places/content/editBookmarkPanel.inc.xhtml b/browser/components/places/content/editBookmarkPanel.inc.xhtml new file mode 100644 index 0000000000..99658f4193 --- /dev/null +++ b/browser/components/places/content/editBookmarkPanel.inc.xhtml @@ -0,0 +1,124 @@ +# 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/. + +<div id="editBookmarkPanelContent"> + <label id="editBMPanel_itemsCountText" + class="editBMPanel_selectionCount"/> + + <label data-l10n-id="bookmark-overlay-name-2" + class="editBMPanel_nameRow hideable" + control="editBMPanel_namePicker"/> + <html:input id="editBMPanel_namePicker" + class="editBMPanel_nameRow hideable" + type="text" + onchange="gEditItemOverlay.onNamePickerChange().catch(Cu.reportError);"/> + + <label data-l10n-id="bookmark-overlay-url" + class="editBMPanel_locationRow hideable" + control="editBMPanel_locationField"/> + <html:input id="editBMPanel_locationField" + class="editBMPanel_locationRow uri-element hideable" + type="text" + onchange="gEditItemOverlay.onLocationFieldChange();"/> + + <label data-l10n-id="bookmark-overlay-location-2" + class="editBMPanel_folderRow hideable" + control="editBMPanel_folderMenuList"/> + <hbox class="editBMPanel_folderRow hideable"> + <menulist id="editBMPanel_folderMenuList" + class="folder-icon" + flex="1" + size="large" + oncommand="gEditItemOverlay.onFolderMenuListCommand(event).catch(Cu.reportError);"> + <menupopup> + <!-- Static item for special folders --> + <menuitem id="editBMPanel_toolbarFolderItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_bmRootItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_unfiledRootItem" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_chooseFolderSeparator"/> + <menuitem id="editBMPanel_chooseFolderMenuItem" + data-l10n-id="bookmark-overlay-choose" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_foldersSeparator" hidden="true"/> + </menupopup> + </menulist> + <button id="editBMPanel_foldersExpander" + class="expander-down panel-button" + data-l10n-id="bookmark-overlay-folders-expander" + data-l10n-attrs="tooltiptextdown, tooltiptextup" + oncommand="gEditItemOverlay.toggleFolderTreeVisibility();"/> + </hbox> + + <vbox id="editBMPanel_folderTreeRow" + class="hideable" + hidden="true"> + <!-- editBMPanel_folderTree will go here when this is shown --> + <hbox id="editBMPanel_newFolderBox"> + <button data-l10n-id="bookmark-overlay-new-folder-button" + id="editBMPanel_newFolderButton" + oncommand="gEditItemOverlay.newFolder().catch(Cu.reportError);"/> + </hbox> + </vbox> + + <label data-l10n-id="bookmark-overlay-tags-2" + class="editBMPanel_tagsRow hideable" + control="editBMPanel_tagsField"/> + <hbox class="editBMPanel_tagsRow hideable"> + <html:input id="editBMPanel_tagsField" + type="text" + is="autocomplete-input" + style="flex: 1;" + autocompletesearch="places-tag-autocomplete" + autocompletepopup="editBMPanel_tagsAutocomplete" + completedefaultindex="true" + completeselectedindex="true" + tabscrolling="true" + data-l10n-id="bookmark-overlay-tags-empty-description" + data-l10n-attrs="placeholder" + aria-describedby="tags-field-info" + onchange="gEditItemOverlay.onTagsFieldChange();"/> + <popupset> + <panel is="autocomplete-richlistbox-popup" + type="autocomplete-richlistbox" + id="editBMPanel_tagsAutocomplete" + role="group" + noautofocus="true" + hidden="true" + overflowpadding="4" + norolluponanchor="true" + nomaxresults="true"/> + </popupset> + <button id="editBMPanel_tagsSelectorExpander" + class="expander-down panel-button" + data-l10n-id="bookmark-overlay-tags-expander" + data-l10n-attrs="tooltiptextdown, tooltiptextup" + oncommand="gEditItemOverlay.toggleTagsSelector().catch(Cu.reportError);"/> + </hbox> + + <div id="tags-field-info" + class="editBMPanel_tagsRow caption-label hideable" + data-l10n-id="bookmark-overlay-tags-caption-label"/> + + <div id="editBMPanel_tagsSelectorRow" + class="hideable" + hidden="true"> + <richlistbox id="editBMPanel_tagsSelector" styled="true"/> + </div> + + <label data-l10n-id="bookmark-overlay-keyword-2" + class="editBMPanel_keywordRow hideable" + control="editBMPanel_keywordField"/> + <html:input id="editBMPanel_keywordField" + class="editBMPanel_keywordRow hideable" + type="text" + aria-describedby="keyword-field-info" + onchange="gEditItemOverlay.onKeywordFieldChange();"/> + + <div id="keyword-field-info" + class="editBMPanel_keywordRow caption-label hideable" + data-l10n-id="bookmark-overlay-keyword-caption-label-2"/> +</div> diff --git a/browser/components/places/content/historySidebar.js b/browser/components/places/content/historySidebar.js new file mode 100644 index 0000000000..f5af2c860a --- /dev/null +++ b/browser/components/places/content/historySidebar.js @@ -0,0 +1,171 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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/. */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +var gHistoryTree; +var gSearchBox; +var gHistoryGrouping = ""; +var gCumulativeSearches = 0; +var gCumulativeFilterCount = 0; + +function HistorySidebarInit() { + let uidensity = window.top.document.documentElement.getAttribute("uidensity"); + if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); + } + + gHistoryTree = document.getElementById("historyTree"); + gSearchBox = document.getElementById("search-box"); + + gHistoryGrouping = document + .getElementById("viewButton") + .getAttribute("selectedsort"); + + this.groupHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_FILTER_TYPE" + ); + this.groupHistogram.add(gHistoryGrouping); + + if (gHistoryGrouping == "site") { + document.getElementById("bysite").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "visited") { + document.getElementById("byvisited").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "lastvisited") { + document.getElementById("bylastvisited").setAttribute("checked", "true"); + } else if (gHistoryGrouping == "dayandsite") { + document.getElementById("bydayandsite").setAttribute("checked", "true"); + } else { + document.getElementById("byday").setAttribute("checked", "true"); + } + + searchHistory(""); +} + +function GroupBy(groupingType) { + if (groupingType != gHistoryGrouping) { + this.groupHistogram.add(groupingType); + } + gHistoryGrouping = groupingType; + gCumulativeFilterCount++; + searchHistory(gSearchBox.value); +} + +function updateTelemetry(urlsOpened = []) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + searchesHistogram.add(gCumulativeSearches); + let filterCountHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_FILTER_COUNT" + ); + filterCountHistogram.add(gCumulativeFilterCount); + clearCumulativeCounters(); + + Services.telemetry.keyedScalarAdd( + "sidebar.link", + "history", + urlsOpened.length + ); +} + +function searchHistory(aInput) { + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + + const NHQO = Ci.nsINavHistoryQueryOptions; + var sortingMode; + var resultType; + + switch (gHistoryGrouping) { + case "visited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + break; + case "lastvisited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_DATE_DESCENDING; + break; + case "dayandsite": + resultType = NHQO.RESULTS_AS_DATE_SITE_QUERY; + break; + case "site": + resultType = NHQO.RESULTS_AS_SITE_QUERY; + sortingMode = NHQO.SORT_BY_TITLE_ASCENDING; + break; + case "day": + default: + resultType = NHQO.RESULTS_AS_DATE_QUERY; + break; + } + + if (aInput) { + query.searchTerms = aInput; + if (gHistoryGrouping != "visited" && gHistoryGrouping != "lastvisited") { + sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING; + resultType = NHQO.RESULTS_AS_URI; + } + } + + options.sortingMode = sortingMode; + options.resultType = resultType; + options.includeHidden = !!aInput; + + if (gHistoryGrouping == "lastvisited") { + TelemetryStopwatch.start("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS"); + } + + // call load() on the tree manually + // instead of setting the place attribute in historySidebar.xhtml + // otherwise, we will end up calling load() twice + gHistoryTree.load(query, options); + + // Sometimes search is activated without an input string. For example, when + // the history sidbar is first opened or when a search filter is selected. + // Since we're trying to measure how often the searchbar was used, we should first + // check if there's an input string before collecting telemetry. + if (aInput) { + Services.telemetry.keyedScalarAdd("sidebar.search", "history", 1); + gCumulativeSearches++; + } + + if (gHistoryGrouping == "lastvisited") { + TelemetryStopwatch.finish("HISTORY_LASTVISITED_TREE_QUERY_TIME_MS"); + } +} + +function clearCumulativeCounters() { + gCumulativeSearches = 0; + gCumulativeFilterCount = 0; +} + +function unloadHistorySidebar() { + clearCumulativeCounters(); + PlacesUIUtils.setMouseoverURL("", window); +} + +window.addEventListener("SidebarFocused", () => gSearchBox.focus()); diff --git a/browser/components/places/content/historySidebar.xhtml b/browser/components/places/content/historySidebar.xhtml new file mode 100644 index 0000000000..a210f7c2a0 --- /dev/null +++ b/browser/components/places/content/historySidebar.xhtml @@ -0,0 +1,96 @@ +<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/sidebar.css"?> + +<!DOCTYPE window> + +<window id="history-panel" + class="sidebar-panel" + orient="vertical" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="HistorySidebarInit();" + onunload="unloadHistorySidebar();" + data-l10n-id="places-history"> + + <script src="chrome://browser/content/places/historySidebar.js"/> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> + <script src="chrome://browser/content/contentTheme.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <linkset> + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + </linkset> + +#include placesCommands.inc.xhtml + +#include ../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + +#include placesContextMenu.inc.xhtml +#include bookmarksHistoryTooltip.inc.xhtml + + <hbox id="sidebar-search-container"> + <search-textbox id="search-box" flex="1" + data-l10n-id="places-history-search" + data-l10n-attrs="placeholder" + aria-controls="historyTree" + oncommand="searchHistory(this.value);"/> + <button id="viewButton" style="min-width:0px !important;" type="menu" + data-l10n-id="places-view" selectedsort="day" + persist="selectedsort"> + <menupopup> + <menuitem id="bydayandsite" + data-l10n-id="places-by-day-and-site" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'dayandsite'); GroupBy('dayandsite');"/> + <menuitem id="bysite" + data-l10n-id="places-by-site" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'site'); GroupBy('site');"/> + <menuitem id="byday" + data-l10n-id="places-by-date" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'day'); GroupBy('day');"/> + <menuitem id="byvisited" + data-l10n-id="places-by-most-visited" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'visited'); GroupBy('visited');"/> + <menuitem id="bylastvisited" + data-l10n-id="places-by-last-visited" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'lastvisited'); GroupBy('lastvisited');"/> + </menupopup> + </button> + </hbox> + + <tree id="historyTree" + class="sidebar-placesTree" + flex="1" + is="places-tree" + hidecolumnpicker="true" + context="placesContext" + singleclickopens="true" + onclick="PlacesUIUtils.onSidebarTreeClick(event);" + onkeypress="PlacesUIUtils.onSidebarTreeKeyPress(event);" + onmousemove="PlacesUIUtils.onSidebarTreeMouseMove(event);" + onmouseout="PlacesUIUtils.setMouseoverURL('', window);"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</window> diff --git a/browser/components/places/content/places-menupopup.js b/browser/components/places/content/places-menupopup.js new file mode 100644 index 0000000000..1d7615aa27 --- /dev/null +++ b/browser/components/places/content/places-menupopup.js @@ -0,0 +1,693 @@ +/* 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"; + +/* eslint-env mozilla/browser-window */ +/* import-globals-from controller.js */ + +// On Wayland when D&D source popup is closed, +// D&D operation is canceled by window manager. +function closingPopupEndsDrag(popup) { + if (!popup.isWaylandPopup) { + return false; + } + if (popup.isWaylandDragSource) { + return true; + } + for (let childPopup of popup.querySelectorAll("menu > menupopup")) { + if (childPopup.isWaylandDragSource) { + return true; + } + } + return false; +} + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + /** + * This class handles the custom element for the places popup menu. + */ + class MozPlacesPopup extends MozElements.MozMenuPopup { + constructor() { + super(); + + const event_names = [ + "DOMMenuItemActive", + "DOMMenuItemInactive", + "dragstart", + "drop", + "dragover", + "dragleave", + "dragend", + ]; + for (let event_name of event_names) { + this.addEventListener(event_name, this); + } + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + <hbox part="drop-indicator-container"> + <vbox part="drop-indicator-bar" hidden="true"> + <image part="drop-indicator"/> + </vbox> + <arrowscrollbox class="menupopup-arrowscrollbox" flex="1" orient="vertical" + exportparts="scrollbox: arrowscrollbox-scrollbox" + smoothscroll="false" part="arrowscrollbox content"> + <html:slot/> + </arrowscrollbox> + </hbox> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + /** + * Sub-menus should be opened when the mouse drags over them, and closed + * when the mouse drags off. The overFolder object manages opening and + * closing of folders when the mouse hovers. + */ + this._overFolder = { + _self: this, + _folder: { + elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null, + }, + _closeMenuTimer: null, + + get elt() { + return this._folder.elt; + }, + set elt(val) { + this._folder.elt = val; + }, + + get openTimer() { + return this._folder.openTimer; + }, + set openTimer(val) { + this._folder.openTimer = val; + }, + + get hoverTime() { + return this._folder.hoverTime; + }, + set hoverTime(val) { + this._folder.hoverTime = val; + }, + + get closeTimer() { + return this._folder.closeTimer; + }, + set closeTimer(val) { + this._folder.closeTimer = val; + }, + + get closeMenuTimer() { + return this._closeMenuTimer; + }, + set closeMenuTimer(val) { + this._closeMenuTimer = val; + }, + + setTimer: function OF__setTimer(aTime) { + var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function OF__notify(aTimer) { + // Function to process all timer notifications. + + if (aTimer == this._folder.openTimer) { + // Timer to open a submenu that's being dragged over. + this._folder.elt.lastElementChild.setAttribute( + "autoopened", + "true" + ); + this._folder.elt.lastElementChild.openPopup(); + this._folder.openTimer = null; + } else if (aTimer == this._folder.closeTimer) { + // Timer to close a submenu that's been dragged off of. + // Only close the submenu if the mouse isn't being dragged over any + // of its child menus. + var draggingOverChild = + PlacesControllerDragHelper.draggingOverChildNode( + this._folder.elt + ); + if (draggingOverChild) { + this._folder.elt = null; + } + this.clear(); + + // Close any parent folders which aren't being dragged over. + // (This is necessary because of the above code that keeps a folder + // open while its children are being dragged over.) + if (!draggingOverChild && !closingPopupEndsDrag(this._self)) { + this.closeParentMenus(); + } + } else if (aTimer == this.closeMenuTimer) { + // Timer to close this menu after the drag exit. + var popup = this._self; + // if we are no more dragging we can leave the menu open to allow + // for better D&D bookmark organization + var hidePopup = + PlacesControllerDragHelper.getSession() && + !PlacesControllerDragHelper.draggingOverChildNode( + popup.parentNode + ); + if (hidePopup) { + if (!closingPopupEndsDrag(popup)) { + popup.hidePopup(); + // Close any parent menus that aren't being dragged over; + // otherwise they'll stay open because they couldn't close + // while this menu was being dragged over. + this.closeParentMenus(); + } else if (popup.isWaylandDragSource) { + // Postpone popup hide until drag end on Wayland. + this._closeMenuTimer = this.setTimer(this.hoverTime); + } + } + } + }, + + // Helper function to close all parent menus of this menu, + // as long as none of the parent's children are currently being + // dragged over. + closeParentMenus: function OF__closeParentMenus() { + var popup = this._self; + var parent = popup.parentNode; + while (parent) { + if (parent.localName == "menupopup" && parent._placesNode) { + if ( + PlacesControllerDragHelper.draggingOverChildNode( + parent.parentNode + ) + ) { + break; + } + parent.hidePopup(); + } + parent = parent.parentNode; + } + }, + + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + clear: function OF__clear() { + if (this._folder.elt && this._folder.elt.lastElementChild) { + var popup = this._folder.elt.lastElementChild; + if ( + !popup.hasAttribute("dragover") && + !closingPopupEndsDrag(popup) + ) { + popup.hidePopup(); + } + // remove menuactive style + this._folder.elt.removeAttribute("_moz-menuactive"); + this._folder.elt = null; + } + if (this._folder.openTimer) { + this._folder.openTimer.cancel(); + this._folder.openTimer = null; + } + if (this._folder.closeTimer) { + this._folder.closeTimer.cancel(); + this._folder.closeTimer = null; + } + }, + }; + } + + get _indicatorBar() { + if (!this.__indicatorBar) { + this.__indicatorBar = this.shadowRoot.querySelector( + "[part=drop-indicator-bar]" + ); + } + return this.__indicatorBar; + } + + /** + * This is the view that manages the popup. + * + * @see {@link PlacesUIUtils.getViewForNode} + * @returns {DOMNode} + */ + get _rootView() { + if (!this.__rootView) { + this.__rootView = PlacesUIUtils.getViewForNode(this); + } + return this.__rootView; + } + + /** + * Check if we should hide the drop indicator for the target + * + * @param {object} aEvent + * The event associated with the drop. + * @returns {boolean} + */ + _hideDropIndicator(aEvent) { + let target = aEvent.target; + + // Don't draw the drop indicator outside of markers or if current + // node is not a Places node. + let betweenMarkers = + this._startMarker.compareDocumentPosition(target) & + Node.DOCUMENT_POSITION_FOLLOWING && + this._endMarker.compareDocumentPosition(target) & + Node.DOCUMENT_POSITION_PRECEDING; + + // Hide the dropmarker if current node is not a Places node. + return !(target && target._placesNode && betweenMarkers); + } + + /** + * This function returns information about where to drop when + * dragging over this popup insertion point + * + * @param {object} aEvent + * The event associated with the drop. + * @returns {object|null} + * The associated drop point information. + */ + _getDropPoint(aEvent) { + // Can't drop if the menu isn't a folder + let resultNode = this._placesNode; + + if ( + !PlacesUtils.nodeIsFolder(resultNode) || + this._rootView.controller.disallowInsertion(resultNode) + ) { + return null; + } + + var dropPoint = { ip: null, folderElt: null }; + + // The element we are dragging over + let elt = aEvent.target; + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + + let eventY = aEvent.clientY; + let { y: eltY, height: eltHeight } = elt.getBoundingClientRect(); + + if (!elt._placesNode) { + // If we are dragging over a non places node drop at the end. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + }); + // We can set folderElt if we are dropping over a static menu that + // has an internal placespopup. + let isMenu = + elt.localName == "menu" || + (elt.localName == "toolbarbutton" && + elt.getAttribute("type") == "menu"); + if ( + isMenu && + elt.lastElementChild && + elt.lastElementChild.hasAttribute("placespopup") + ) { + dropPoint.folderElt = elt; + } + return dropPoint; + } + + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) + ? elt._placesNode.title + : null; + if ( + (PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode)) || + PlacesUtils.nodeIsTagQuery(elt._placesNode) + ) { + // This is a folder or a tag container. + if (eventY - eltY < eltHeight * 0.2) { + // If mouse is in the top part of the element, drop above folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } else if (eventY - eltY < eltHeight * 0.8) { + // If mouse is in the middle of the element, drop inside folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName, + }); + dropPoint.folderElt = elt; + return dropPoint; + } + } else if (eventY - eltY <= eltHeight / 2) { + // This is a non-folder node or a readonly folder. + // If the mouse is above the middle, drop above this item. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } + + // Drop below the item. + dropPoint.ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_AFTER, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + } + + _cleanupDragDetails() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._rootView._draggedElt = null; + this.removeAttribute("dragover"); + this.removeAttribute("dragstart"); + this._indicatorBar.hidden = true; + } + + on_DOMMenuItemActive(event) { + if (super.on_DOMMenuItemActive) { + super.on_DOMMenuItemActive(event); + } + + let elt = event.target; + if (elt.parentNode != this) { + return; + } + + if (window.XULBrowserWindow) { + let placesNode = elt._placesNode; + + var linkURI; + if (placesNode && PlacesUtils.nodeIsURI(placesNode)) { + linkURI = placesNode.uri; + } else if (elt.hasAttribute("targetURI")) { + linkURI = elt.getAttribute("targetURI"); + } + + if (linkURI) { + window.XULBrowserWindow.setOverLink(linkURI); + } + } + } + + on_DOMMenuItemInactive(event) { + let elt = event.target; + if (elt.parentNode != this) { + return; + } + + if (window.XULBrowserWindow) { + window.XULBrowserWindow.setOverLink(""); + } + } + + on_dragstart(event) { + let elt = event.target; + if (!elt._placesNode) { + return; + } + + let draggedElt = elt._placesNode; + + // Force a copy action if parent node is a query or we are dragging a + // not-removable node. + if (!this._rootView.controller.canMoveNode(draggedElt)) { + event.dataTransfer.effectAllowed = "copyLink"; + } + + // Activate the view and cache the dragged element. + this._rootView._draggedElt = draggedElt; + this._rootView.controller.setDataTransfer(event); + this.setAttribute("dragstart", "true"); + event.stopPropagation(); + } + + on_drop(event) { + PlacesControllerDragHelper.currentDropTarget = event.target; + + let dropPoint = this._getDropPoint(event); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop( + dropPoint.ip, + event.dataTransfer + ).catch(console.error); + event.preventDefault(); + } + + this._cleanupDragDetails(); + event.stopPropagation(); + } + + on_dragover(event) { + PlacesControllerDragHelper.currentDropTarget = event.target; + let dt = event.dataTransfer; + + let dropPoint = this._getDropPoint(event); + if ( + !dropPoint || + !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt) + ) { + this._indicatorBar.hidden = true; + event.stopPropagation(); + return; + } + + // Mark this popup as being dragged over. + this.setAttribute("dragover", "true"); + + if (dropPoint.folderElt) { + // We are dragging over a folder. + // _overFolder should take the care of opening it on a timer. + if ( + this._overFolder.elt && + this._overFolder.elt != dropPoint.folderElt + ) { + // We are dragging over a new folder, let's clear old values + this._overFolder.clear(); + } + if (!this._overFolder.elt) { + this._overFolder.elt = dropPoint.folderElt; + // Create the timer to open this folder. + this._overFolder.openTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + // Since we are dropping into a folder set the corresponding style. + dropPoint.folderElt.setAttribute("_moz-menuactive", true); + } else { + // We are not dragging over a folder. + // Clear out old _overFolder information. + this._overFolder.clear(); + } + + // Autoscroll the popup strip if we drag over the scroll buttons. + let scrollDir = 0; + if (event.originalTarget == this.scrollBox._scrollButtonUp) { + scrollDir = -1; + } else if (event.originalTarget == this.scrollBox._scrollButtonDown) { + scrollDir = 1; + } + if (scrollDir != 0) { + this.scrollBox.scrollByIndex(scrollDir, true); + } + + // Check if we should hide the drop indicator for this target. + if (dropPoint.folderElt || this._hideDropIndicator(event)) { + this._indicatorBar.hidden = true; + event.preventDefault(); + event.stopPropagation(); + return; + } + + // We should display the drop indicator relative to the arrowscrollbox. + let scrollRect = this.scrollBox.getBoundingClientRect(); + let newMarginTop = 0; + if (scrollDir == 0) { + let elt = this.firstElementChild; + for (; elt; elt = elt.nextElementSibling) { + let height = elt.getBoundingClientRect().height; + if (height == 0) { + continue; + } + if (event.screenY <= elt.screenY + height / 2) { + break; + } + } + newMarginTop = elt + ? elt.screenY - this.scrollBox.screenY + : scrollRect.height; + } else if (scrollDir == 1) { + newMarginTop = scrollRect.height; + } + + // Set the new marginTop based on arrowscrollbox. + newMarginTop += + scrollRect.y - this._indicatorBar.parentNode.getBoundingClientRect().y; + this._indicatorBar.firstElementChild.style.marginTop = + newMarginTop + "px"; + this._indicatorBar.hidden = false; + + event.preventDefault(); + event.stopPropagation(); + } + + on_dragleave(event) { + PlacesControllerDragHelper.currentDropTarget = null; + this.removeAttribute("dragover"); + + // If we have not moved to a valid new target clear the drop indicator + // this happens when moving out of the popup. + let target = event.relatedTarget; + if (!target || !this.contains(target)) { + this._indicatorBar.hidden = true; + } + + // Close any folder being hovered over + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + + // The autoopened attribute is set when this folder was automatically + // opened after the user dragged over it. If this attribute is set, + // auto-close the folder on drag exit. + // We should also try to close this popup if the drag has started + // from here, the timer will check if we are dragging over a child. + if (this.hasAttribute("autoopened") || this.hasAttribute("dragstart")) { + this._overFolder.closeMenuTimer = this._overFolder.setTimer( + this._overFolder.hoverTime + ); + } + + event.stopPropagation(); + } + + on_dragend(event) { + this._cleanupDragDetails(); + } + } + + customElements.define("places-popup", MozPlacesPopup, { + extends: "menupopup", + }); + + /** + * Custom element for the places popup arrow. + */ + class MozPlacesPopupArrow extends MozPlacesPopup { + constructor() { + super(); + + const event_names = [ + "popupshowing", + "popuppositioned", + "popupshown", + "popuphiding", + "popuphidden", + ]; + for (let event_name of event_names) { + this.addEventListener(event_name, this); + } + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + this.initializeAttributeInheritance(); + + this.setAttribute("flip", "both"); + this.setAttribute("side", "top"); + this.setAttribute("position", "bottomright topright"); + } + + _setSideAttribute(event) { + if (!this.anchorNode) { + return; + } + + var position = event.alignmentPosition; + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + // The assigned side stays the same regardless of direction. + let isRTL = this.matches(":-moz-locale-dir(rtl)"); + + if (position.indexOf("start_") == 0) { + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if ( + position.indexOf("before_") == 0 || + position.indexOf("after_") == 0 + ) { + if (position.indexOf("before_") == 0) { + this.setAttribute("side", "bottom"); + } else { + this.setAttribute("side", "top"); + } + } + } + + on_popupshowing(event) { + if (event.target == this) { + this.setAttribute("animate", "open"); + this.style.pointerEvents = "none"; + } + } + + on_popuppositioned(event) { + if (event.target == this) { + this._setSideAttribute(event); + } + } + + on_popupshown(event) { + if (event.target != this) { + return; + } + + this.setAttribute("panelopen", "true"); + this.style.removeProperty("pointer-events"); + } + + on_popuphiding(event) { + if (event.target == this) { + this.setAttribute("animate", "cancel"); + } + } + + on_popuphidden(event) { + if (event.target == this) { + this.removeAttribute("panelopen"); + this.removeAttribute("animate"); + } + } + } + + customElements.define("places-popup-arrow", MozPlacesPopupArrow, { + extends: "menupopup", + }); +} diff --git a/browser/components/places/content/places-tree.js b/browser/components/places/content/places-tree.js new file mode 100644 index 0000000000..28edd775f7 --- /dev/null +++ b/browser/components/places/content/places-tree.js @@ -0,0 +1,864 @@ +/* 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/. */ + +/* import-globals-from controller.js */ +/* import-globals-from treeView.js */ + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + /** + * Custom element definition for the places tree. + */ + class MozPlacesTree extends customElements.get("tree") { + constructor() { + super(); + + this.addEventListener("focus", event => { + this._cachedInsertionPoint = undefined; + // See select handler. We need the sidebar's places commandset to be + // updated as well + document.commandDispatcher.updateCommands("focus"); + }); + + this.addEventListener("select", event => { + this._cachedInsertionPoint = undefined; + + // This additional complexity is here for the sidebars + var win = window; + while (true) { + win.document.commandDispatcher.updateCommands("focus"); + if (win == window.top) { + break; + } + + win = win.parent; + } + }); + + this.addEventListener("dragstart", event => { + if (event.target.localName != "treechildren") { + return; + } + + if (this.disableUserActions) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + let nodes = this.selectedNodes; + for (let i = 0; i < nodes.length; i++) { + let node = nodes[i]; + + // Disallow dragging the root node of a tree. + if (!node.parent) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + // If this node is child of a readonly container or cannot be moved, + // we must force a copy. + if (!this.controller.canMoveNode(node)) { + event.dataTransfer.effectAllowed = "copyLink"; + break; + } + } + + // Indicate to drag and drop listeners + // whether or not this was the start of the drag + this._isDragSource = true; + + this._controller.setDataTransfer(event); + event.stopPropagation(); + }); + + this.addEventListener("dragover", event => { + if (event.target.localName != "treechildren") { + return; + } + + let cell = this.getCellAt(event.clientX, event.clientY); + let node = + cell.row != -1 + ? this.view.nodeForTreeIndex(cell.row) + : this.result.root; + // cache the dropTarget for the view + PlacesControllerDragHelper.currentDropTarget = node; + + // We have to calculate the orientation since view.canDrop will use + // it and we want to be consistent with the dropfeedback. + let rowHeight = this.rowHeight; + let eventY = + event.clientY - + this.treeBody.getBoundingClientRect().y - + rowHeight * (cell.row - this.getFirstVisibleRow()); + + let orientation = Ci.nsITreeView.DROP_BEFORE; + + if (cell.row == -1) { + // If the row is not valid we try to insert inside the resultNode. + orientation = Ci.nsITreeView.DROP_ON; + } else if ( + PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.75 + ) { + // If we are below the 75% of a container the treeview we try + // to drop after the node. + orientation = Ci.nsITreeView.DROP_AFTER; + } else if ( + PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.25 + ) { + // If we are below the 25% of a container the treeview we try + // to drop inside the node. + orientation = Ci.nsITreeView.DROP_ON; + } + + if (!this.view.canDrop(cell.row, orientation, event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + }); + + this.addEventListener("dragend", event => { + this._isDragSource = false; + PlacesControllerDragHelper.currentDropTarget = null; + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + super.connectedCallback(); + this._contextMenuShown = false; + + this._active = true; + + // Force an initial build. + if (this.place) { + // eslint-disable-next-line no-self-assign + this.place = this.place; + } + } + + get controller() { + return this._controller; + } + + set disableUserActions(val) { + if (val) { + this.setAttribute("disableUserActions", "true"); + } else { + this.removeAttribute("disableUserActions"); + } + } + + get disableUserActions() { + return this.getAttribute("disableUserActions") == "true"; + } + /** + * overriding + * + * @param {PlacesTreeView} val + * The parent view + */ + set view(val) { + // We save the view so that we can avoid expensive get calls when + // we need to get the view again. + this._view = val; + Object.getOwnPropertyDescriptor( + // eslint-disable-next-line no-undef + XULTreeElement.prototype, + "view" + ).set.call(this, val); + } + + get view() { + return this._view; + } + + get associatedElement() { + return this; + } + + set flatList(val) { + if (this.flatList != val) { + this.setAttribute("flatList", val); + // reload with the last place set + if (this.place) { + // eslint-disable-next-line no-self-assign + this.place = this.place; + } + } + } + + get flatList() { + return this.getAttribute("flatList") == "true"; + } + + get result() { + try { + return this.view.QueryInterface(Ci.nsINavHistoryResultObserver).result; + } catch (e) { + return null; + } + } + + set place(val) { + this.setAttribute("place", val); + + let query = {}, + options = {}; + PlacesUtils.history.queryStringToQuery(val, query, options); + this.load(query.value, options.value); + } + + get place() { + return this.getAttribute("place"); + } + + get selectedCount() { + return this.view?.selection?.count || 0; + } + + get hasSelection() { + return this.selectedCount >= 1; + } + + get selectedNodes() { + let nodes = []; + if (!this.hasSelection) { + return nodes; + } + + let selection = this.view.selection; + let rc = selection.getRangeCount(); + let resultview = this.view; + for (let i = 0; i < rc; ++i) { + let min = {}, + max = {}; + selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; ++j) { + nodes.push(resultview.nodeForTreeIndex(j)); + } + } + return nodes; + } + + get removableSelectionRanges() { + // This property exists in addition to selectedNodes because it + // encodes selection ranges (which only occur in list views) into + // the return value. For each removed range, the index at which items + // will be re-inserted upon the remove transaction being performed is + // the first index of the range, so that the view updates correctly. + // + // For example, if we remove rows 2,3,4 and 7,8 from a list, when we + // undo that operation, if we insert what was at row 3 at row 3 again, + // it will show up _after_ the item that was at row 5. So we need to + // insert all items at row 2, and the tree view will update correctly. + // + // Also, this function collapses the selection to remove redundant + // data, e.g. when deleting this selection: + // + // http://www.foo.com/ + // (-) Some Folder + // http://www.bar.com/ + // + // ... returning http://www.bar.com/ as part of the selection is + // redundant because it is implied by removing "Some Folder". We + // filter out all such redundancies since some partial amount of + // the folder's children may be selected. + // + let nodes = []; + if (!this.hasSelection) { + return nodes; + } + + var selection = this.view.selection; + var rc = selection.getRangeCount(); + var resultview = this.view; + // This list is kept independently of the range selected (i.e. OUTSIDE + // the for loop) since the row index of a container is unique for the + // entire view, and we could have some really wacky selection and we + // don't want to blow up. + var containers = {}; + for (var i = 0; i < rc; ++i) { + var range = []; + var min = {}, + max = {}; + selection.getRangeAt(i, min, max); + + for (var j = min.value; j <= max.value; ++j) { + if (this.view.isContainer(j)) { + containers[j] = true; + } + if (!(this.view.getParentIndex(j) in containers)) { + range.push(resultview.nodeForTreeIndex(j)); + } + } + nodes.push(range); + } + return nodes; + } + + get draggableSelection() { + return this.selectedNodes; + } + + get selectedNode() { + if (this.selectedCount != 1) { + return null; + } + + var selection = this.view.selection; + var min = {}, + max = {}; + selection.getRangeAt(0, min, max); + + return this.view.nodeForTreeIndex(min.value); + } + + get singleClickOpens() { + return this.getAttribute("singleclickopens") == "true"; + } + + get insertionPoint() { + // invalidated on selection and focus changes + if (this._cachedInsertionPoint !== undefined) { + return this._cachedInsertionPoint; + } + + // there is no insertion point for history queries + // so bail out now and save a lot of work when updating commands + var resultNode = this.result.root; + if ( + PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + return (this._cachedInsertionPoint = null); + } + + var orientation = Ci.nsITreeView.DROP_BEFORE; + // If there is no selection, insert at the end of the container. + if (!this.hasSelection) { + var index = this.view.rowCount - 1; + this._cachedInsertionPoint = this._getInsertionPoint( + index, + orientation + ); + return this._cachedInsertionPoint; + } + + // This is a two-part process. The first part is determining the drop + // orientation. + // * The default orientation is to drop _before_ the selected item. + // * If the selected item is a container, the default orientation + // is to drop _into_ that container. + // + // Warning: It may be tempting to use tree indexes in this code, but + // you must not, since the tree is nested and as your tree + // index may change when folders before you are opened and + // closed. You must convert your tree index to a node, and + // then use getChildIndex to find your absolute index in + // the parent container instead. + // + var resultView = this.view; + var selection = resultView.selection; + var rc = selection.getRangeCount(); + var min = {}, + max = {}; + selection.getRangeAt(rc - 1, min, max); + + // If the sole selection is a container, and we are not in + // a flatlist, insert into it. + // Note that this only applies to _single_ selections, + // if the last element within a multi-selection is a + // container, insert _adjacent_ to the selection. + // + // If the sole selection is the bookmarks toolbar folder, we insert + // into it even if it is not opened + if ( + selection.count == 1 && + resultView.isContainer(max.value) && + !this.flatList + ) { + orientation = Ci.nsITreeView.DROP_ON; + } + + this._cachedInsertionPoint = this._getInsertionPoint( + max.value, + orientation + ); + return this._cachedInsertionPoint; + } + + get isDragSource() { + return this._isDragSource; + } + + get ownerWindow() { + return window; + } + + set active(val) { + this._active = val; + } + + get active() { + return this._active; + } + + applyFilter(filterString, folderRestrict, includeHidden) { + // preserve grouping + var queryNode = PlacesUtils.asQuery(this.result.root); + var options = queryNode.queryOptions.clone(); + + // Make sure we're getting uri results. + // We do not yet support searching into grouped queries or into + // tag containers, so we must fall to the default case. + if ( + PlacesUtils.nodeIsHistoryContainer(queryNode) || + PlacesUtils.nodeIsTagQuery(queryNode) || + options.resultType == options.RESULTS_AS_TAGS_ROOT || + options.resultType == options.RESULTS_AS_ROOTS_QUERY + ) { + options.resultType = options.RESULTS_AS_URI; + } + + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + + if (folderRestrict) { + query.setParents(folderRestrict); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + Services.telemetry.keyedScalarAdd("sidebar.search", "bookmarks", 1); + } + + options.includeHidden = !!includeHidden; + + this.load(query, options); + } + + load(query, options) { + let result = PlacesUtils.history.executeQuery(query, options); + + if (!this._controller) { + this._controller = new PlacesController(this); + this._controller.disableUserActions = this.disableUserActions; + this.controllers.appendController(this._controller); + } + + let treeView = new PlacesTreeView(this); + + // Observer removal is done within the view itself. When the tree + // goes away, view.setTree(null) is called, which then + // calls removeObserver. + result.addObserver(treeView); + this.view = treeView; + + if ( + this.getAttribute("selectfirstnode") == "true" && + treeView.rowCount > 0 + ) { + treeView.selection.select(0); + } + + this._cachedInsertionPoint = undefined; + } + + /** + * Causes a particular node represented by the specified placeURI to be + * selected in the tree. All containers above the node in the hierarchy + * will be opened, so that the node is visible. + * + * @param {string} placeURI + * The URI that should be selected + */ + selectPlaceURI(placeURI) { + // Do nothing if a node matching the given uri is already selected + if (this.hasSelection && this.selectedNode.uri == placeURI) { + return; + } + + function findNode(container, nodesURIChecked) { + var containerURI = container.uri; + if (containerURI == placeURI) { + return container; + } + if (nodesURIChecked.includes(containerURI)) { + return null; + } + + // never check the contents of the same query + nodesURIChecked.push(containerURI); + + var wasOpen = container.containerOpen; + if (!wasOpen) { + container.containerOpen = true; + } + for (var i = 0; i < container.childCount; ++i) { + var child = container.getChild(i); + var childURI = child.uri; + if (childURI == placeURI) { + return child; + } else if (PlacesUtils.nodeIsContainer(child)) { + var nested = findNode( + PlacesUtils.asContainer(child), + nodesURIChecked + ); + if (nested) { + return nested; + } + } + } + + if (!wasOpen) { + container.containerOpen = false; + } + + return null; + } + + var container = this.result.root; + console.assert(container, "No result, cannot select place URI!"); + if (!container) { + return; + } + + var child = findNode(container, []); + if (child) { + this.selectNode(child); + } else { + // If the specified child could not be located, clear the selection + var selection = this.view.selection; + selection.clearSelection(); + } + } + + /** + * Causes a particular node to be selected in the tree, resulting in all + * containers above the node in the hierarchy to be opened, so that the + * node is visible. + * + * @param {object} node + * The node that should be selected + */ + selectNode(node) { + var view = this.view; + + var parent = node.parent; + if (parent && !parent.containerOpen) { + // Build a list of all of the nodes that are the parent of this one + // in the result. + var parents = []; + var root = this.result.root; + while (parent && parent != root) { + parents.push(parent); + parent = parent.parent; + } + + // Walk the list backwards (opening from the root of the hierarchy) + // opening each folder as we go. + for (var i = parents.length - 1; i >= 0; --i) { + let index = view.treeIndexForNode(parents[i]); + if ( + index != -1 && + view.isContainer(index) && + !view.isContainerOpen(index) + ) { + view.toggleOpenState(index); + } + } + // Select the specified node... + } + + let index = view.treeIndexForNode(node); + if (index == -1) { + return; + } + + view.selection.select(index); + // ... and ensure it's visible, not scrolled off somewhere. + this.ensureRowIsVisible(index); + } + + toggleCutNode(aNode, aValue) { + this.view.toggleCutNode(aNode, aValue); + } + + _getInsertionPoint(index, orientation) { + var result = this.result; + var resultview = this.view; + var container = result.root; + var dropNearNode = null; + console.assert(container, "null container"); + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + var lastSelected = resultview.nodeForTreeIndex(index); + if ( + resultview.isContainer(index) && + orientation == Ci.nsITreeView.DROP_ON + ) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } else if ( + lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren + ) { + // If the last selected item is an open container and the user is + // trying to drag into it as a first item, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // See comment in the treeView.js's copy of this method + if (!container || !container.containerOpen) { + return null; + } + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion + if (this.controller.disallowInsertion(container)) { + return null; + } + + var queryOptions = PlacesUtils.asQuery(result.root).queryOptions; + if ( + queryOptions.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ) { + // If we are within a sorted view, insert at the end + index = -1; + } else if (queryOptions.excludeItems || queryOptions.excludeQueries) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearNode = lastSelected; + } else { + var lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (this.controller.disallowInsertion(container)) { + return null; + } + + let tagName = PlacesUtils.nodeIsTagQuery(container) + ? PlacesUtils.asQuery(container).query.tags[0] + : null; + + return new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, + orientation, + tagName, + dropNearNode, + }); + } + + selectAll() { + this.view.selection.selectAll(); + } + + /** + * This method will select the first node in the tree that matches + * each given item guid. It will open any folder nodes that it needs + * to in order to show the selected items. + * + * @param {Array} aGuids + * Guids to select. + * @param {boolean} aOpenContainers + * Whether or not to open containers. + */ + selectItems(aGuids, aOpenContainers) { + // Never open containers in flat lists. + if (this.flatList) { + aOpenContainers = false; + } + // By default, we do search and select within containers which were + // closed (note that containers in which nodes were not found are + // closed). + if (aOpenContainers === undefined) { + aOpenContainers = true; + } + + var guids = aGuids; // don't manipulate the caller's array + + // Array of nodes found by findNodes which are to be selected + var nodes = []; + + // Array of nodes found by findNodes which should be opened + var nodesToOpen = []; + + // A set of GUIDs of container-nodes that were previously searched, + // and thus shouldn't be searched again. This is empty at the initial + // start of the recursion and gets filled in as the recursion + // progresses. + var checkedGuidsSet = new Set(); + + /** + * Recursively search through a node's children for items + * with the given GUIDs. When a matching item is found, remove its GUID + * from the GUIDs array, and add the found node to the nodes dictionary. + * + * NOTE: This method will leave open any node that had matching items + * in its subtree. + * + * @param {object} node + * The node to search. + * @returns {boolean} + * Returns true if at least one item was found. + */ + function findNodes(node) { + var foundOne = false; + // See if node matches an ID we wanted; add to results. + // For simple folder queries, check both itemId and the concrete + // item id. + var index = guids.indexOf(node.bookmarkGuid); + if (index == -1) { + let concreteGuid = PlacesUtils.getConcreteItemGuid(node); + if (concreteGuid != node.bookmarkGuid) { + index = guids.indexOf(concreteGuid); + } + } + + if (index != -1) { + nodes.push(node); + foundOne = true; + guids.splice(index, 1); + } + + var concreteGuid = PlacesUtils.getConcreteItemGuid(node); + if ( + !guids.length || + !PlacesUtils.nodeIsContainer(node) || + checkedGuidsSet.has(concreteGuid) + ) { + return foundOne; + } + + // Only follow a query if it has been been explicitly opened by the + // caller. We support the "AllBookmarks" case to allow callers to + // specify just the top-level bookmark folders. + let shouldOpen = + aOpenContainers && + (PlacesUtils.nodeIsFolder(node) || + (PlacesUtils.nodeIsQuery(node) && + node.bookmarkGuid == PlacesUIUtils.virtualAllBookmarksGuid)); + + PlacesUtils.asContainer(node); + if (!node.containerOpen && !shouldOpen) { + return foundOne; + } + + checkedGuidsSet.add(concreteGuid); + + // Remember the beginning state so that we can re-close + // this node if we don't find any additional results here. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && guids.length; child++) { + var childNode = node.getChild(child); + var found = findNodes(childNode); + if (!foundOne) { + foundOne = found; + } + } + + // If we didn't find any additional matches in this node's + // subtree, revert the node to its previous openness. + if (foundOne) { + nodesToOpen.unshift(node); + } + node.containerOpen = previousOpenness; + return foundOne; + } + + // Disable notifications while looking for nodes. + let result = this.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + findNodes(this.result.root); + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + + // For all the nodes we've found, highlight the corresponding + // index in the tree. + var resultview = this.view; + var selection = this.view.selection; + selection.selectEventsSuppressed = true; + selection.clearSelection(); + // Open nodes containing found items + for (let i = 0; i < nodesToOpen.length; i++) { + nodesToOpen[i].containerOpen = true; + } + let firstValidTreeIndex = -1; + for (let i = 0; i < nodes.length; i++) { + var index = resultview.treeIndexForNode(nodes[i]); + if (index == -1) { + continue; + } + if (firstValidTreeIndex < 0 && index >= 0) { + firstValidTreeIndex = index; + } + selection.rangedSelect(index, index, true); + } + selection.selectEventsSuppressed = false; + + // Bring the first valid node into view if necessary + if (firstValidTreeIndex >= 0) { + this.ensureRowIsVisible(firstValidTreeIndex); + } + } + + buildContextMenu(aPopup) { + this._contextMenuShown = true; + return this.controller.buildContextMenu(aPopup); + } + + destroyContextMenu(aPopup) {} + disconnectedCallback() { + // Unregister the controller before unlinking the view, otherwise it + // may still try to update commands on a view with a null result. + if (this._controller) { + this._controller.terminate(); + this.controllers.removeController(this._controller); + } + + if (this.view) { + this.view.uninit(); + } + // view.setTree(null) will be called upon unsetting the view, which + // breaks the reference cycle between the PlacesTreeView and result. + // See the "setTree" method of PlacesTreeView in treeView.js. + this.view = null; + } + } + + customElements.define("places-tree", MozPlacesTree, { + extends: "tree", + }); +} diff --git a/browser/components/places/content/places.css b/browser/components/places/content/places.css new file mode 100644 index 0000000000..0021e28bb6 --- /dev/null +++ b/browser/components/places/content/places.css @@ -0,0 +1,43 @@ +/* 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/. */ + +:root { + /* we eventually want to share this value with the bookmark panel, which is + currently using --arrowpanel-padding */ + --editbookmarkdialog-padding: 1.25em; +} + +tree[is="places-tree"] > treechildren::-moz-tree-cell { + /* ensure we use the direction of the website title / url instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +.places-tooltip-title { + /* ensure we use the direction of the website title instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +.toolbar-drop-indicator { + position: relative; + z-index: 1; +} + +/* Apply crisp rendering for favicons at exactly 2dppx resolution */ +@media (resolution: 2dppx) { + #bookmarksChildren, + .sidebar-placesTreechildren, + .placesTree > treechildren { + image-rendering: -moz-crisp-edges; + } +} + +#searchFilter { + max-width: 23em; +} + +.places-tooltip-box { + display: block; +} diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js new file mode 100644 index 0000000000..a9448d37d2 --- /dev/null +++ b/browser/components/places/content/places.js @@ -0,0 +1,1534 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* import-globals-from editBookmark.js */ +/* import-globals-from /toolkit/content/contentAreaUtils.js */ +/* import-globals-from /browser/components/downloads/content/allDownloadsView.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; +const HISTORY_LIBRARY_SEARCH_TELEMETRY = + "PLACES_HISTORY_LIBRARY_SEARCH_TIME_MS"; + +const SORTBY_L10N_IDS = new Map([ + ["title", "places-view-sortby-name"], + ["url", "places-view-sortby-url"], + ["date", "places-view-sortby-date"], + ["visitCount", "places-view-sortby-visit-count"], + ["dateAdded", "places-view-sortby-date-added"], + ["lastModified", "places-view-sortby-last-modified"], + ["tags", "places-view-sortby-tags"], +]); + +var PlacesOrganizer = { + _places: null, + + _initFolderTree() { + this._places.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`; + }, + + /** + * Selects a left pane built-in item. + * + * @param {string} item The built-in item to select, may be one of (case sensitive): + * AllBookmarks, BookmarksMenu, BookmarksToolbar, + * History, Downloads, Tags, UnfiledBookmarks. + */ + selectLeftPaneBuiltIn(item) { + switch (item) { + case "AllBookmarks": + this._places.selectItems([PlacesUtils.virtualAllBookmarksGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "History": + this._places.selectItems([PlacesUtils.virtualHistoryGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "Downloads": + this._places.selectItems([PlacesUtils.virtualDownloadsGuid]); + break; + case "Tags": + this._places.selectItems([PlacesUtils.virtualTagsGuid]); + break; + case "BookmarksMenu": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + ]); + break; + case "BookmarksToolbar": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualToolbarGuid, + ]); + break; + case "UnfiledBookmarks": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualUnfiledGuid, + ]); + break; + default: + throw new Error( + `Unrecognized item ${item} passed to selectLeftPaneRootItem` + ); + } + }, + + /** + * Opens a given hierarchy in the left pane, stopping at the last reachable + * container. Note: item ids should be considered deprecated. + * + * @param {Array | string | number} aHierarchy + * A single container or an array of containers, sorted from + * the outmost to the innermost in the hierarchy. Each + * container may be either an item id, a Places URI string, + * or a named query, like: + * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks". + */ + selectLeftPaneContainerByHierarchy(aHierarchy) { + if (!aHierarchy) { + throw new Error("Containers hierarchy not specified"); + } + let hierarchy = [].concat(aHierarchy); + let selectWasSuppressed = + this._places.view.selection.selectEventsSuppressed; + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = true; + } + try { + for (let container of hierarchy) { + if (typeof container != "string") { + throw new Error("Invalid container type found: " + container); + } + + try { + this.selectLeftPaneBuiltIn(container); + } catch (ex) { + if (container.substr(0, 6) == "place:") { + this._places.selectPlaceURI(container); + } else { + // Must be a guid. + this._places.selectItems([container], false); + } + } + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + } + } finally { + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = false; + } + } + }, + + init: function PO_init() { + // Register the downloads view. + const DOWNLOADS_QUERY = + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + + ContentArea.setContentViewForQueryString( + DOWNLOADS_QUERY, + () => + new DownloadsPlacesView( + document.getElementById("downloadsListBox"), + false + ), + { + showDetailsPane: false, + toolbarSet: + "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter", + } + ); + + ContentArea.init(); + + this._places = document.getElementById("placesList"); + this._initFolderTree(); + + var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks + if (window.arguments && window.arguments[0]) { + leftPaneSelection = window.arguments[0]; + } + + this.selectLeftPaneContainerByHierarchy(leftPaneSelection); + if (leftPaneSelection === "History") { + let historyNode = this._places.selectedNode; + if (historyNode.childCount > 0) { + this._places.selectNode(historyNode.getChild(0)); + } + Services.telemetry.keyedScalarAdd("library.opened", "history", 1); + } else { + Services.telemetry.keyedScalarAdd("library.opened", "bookmarks", 1); + } + + // clear the back-stack + this._backHistory.splice(0, this._backHistory.length); + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + + // Set up the search UI. + PlacesSearchBox.init(); + + window.addEventListener("AppCommand", this, true); + + let placeContentElement = document.getElementById("placeContent"); + placeContentElement.addEventListener("onOpenFlatContainer", function (e) { + PlacesOrganizer.openFlatContainer(e.detail); + }); + + if (AppConstants.platform === "macosx") { + // 1. Map Edit->Find command to OrganizerCommand_find:all. Need to map + // both the menuitem and the Find key. + let findMenuItem = document.getElementById("menu_find"); + findMenuItem.setAttribute("command", "OrganizerCommand_find:all"); + let findKey = document.getElementById("key_find"); + findKey.setAttribute("command", "OrganizerCommand_find:all"); + + // 2. Disable some keybindings from browser.xhtml + let elements = ["cmd_handleBackspace", "cmd_handleShiftBackspace"]; + for (let i = 0; i < elements.length; i++) { + document.getElementById(elements[i]).setAttribute("disabled", "true"); + } + } + + // remove the "Edit" and "Edit Bookmark" context-menu item, we're in our own details pane + let contextMenu = document.getElementById("placesContext"); + contextMenu.removeChild(document.getElementById("placesContext_show:info")); + contextMenu.removeChild( + document.getElementById("placesContext_show_bookmark:info") + ); + contextMenu.removeChild( + document.getElementById("placesContext_show_folder:info") + ); + + if (!Services.policies.isAllowed("profileImport")) { + document + .getElementById("OrganizerCommand_browserImport") + .setAttribute("disabled", true); + } + + ContentArea.focus(); + }, + + QueryInterface: ChromeUtils.generateQI([]), + + handleEvent: function PO_handleEvent(aEvent) { + if (aEvent.type != "AppCommand") { + return; + } + + aEvent.stopPropagation(); + switch (aEvent.command) { + case "Back": + if (this._backHistory.length) { + this.back(); + } + break; + case "Forward": + if (this._forwardHistory.length) { + this.forward(); + } + break; + case "Search": + PlacesSearchBox.findAll(); + break; + } + }, + + destroy: function PO_destroy() {}, + + _location: null, + get location() { + return this._location; + }, + + set location(aLocation) { + if (!aLocation || this._location == aLocation) { + return; + } + + if (this.location) { + this._backHistory.unshift(this.location); + this._forwardHistory.splice(0, this._forwardHistory.length); + } + + this._location = aLocation; + this._places.selectPlaceURI(aLocation); + + if (!this._places.hasSelection) { + // If no node was found for the given place: uri, just load it directly + ContentArea.currentPlace = aLocation; + } + this.updateDetailsPane(); + + // update navigation commands + if (!this._backHistory.length) { + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Back") + .removeAttribute("disabled"); + } + if (!this._forwardHistory.length) { + document + .getElementById("OrganizerCommand:Forward") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Forward") + .removeAttribute("disabled"); + } + }, + + _backHistory: [], + _forwardHistory: [], + + back: function PO_back() { + this._forwardHistory.unshift(this.location); + var historyEntry = this._backHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + forward: function PO_forward() { + this._backHistory.unshift(this.location); + var historyEntry = this._forwardHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + + /** + * Called when a place folder is selected in the left pane. + * + * @param resetSearchBox + * true if the search box should also be reset, false otherwise. + * The search box should be reset when a new folder in the left + * pane is selected; the search scope and text need to be cleared in + * preparation for the new folder. Note that if the user manually + * resets the search box, either by clicking its reset button or by + * deleting its text, this will be false. + */ + _cachedLeftPaneSelectedURI: null, + onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) { + // Don't change the right-hand pane contents when there's no selection. + if (!this._places.hasSelection) { + return; + } + + let node = this._places.selectedNode; + let placeURI = node.uri; + + // If either the place of the content tree in the right pane has changed or + // the user cleared the search box, update the place, hide the search UI, + // and update the back/forward buttons by setting location. + if (ContentArea.currentPlace != placeURI || !resetSearchBox) { + ContentArea.currentPlace = placeURI; + this.location = placeURI; + } + + // When we invalidate a container we use suppressSelectionEvent, when it is + // unset a select event is fired, in many cases the selection did not really + // change, so we should check for it, and return early in such a case. Note + // that we cannot return any earlier than this point, because when + // !resetSearchBox, we need to update location and hide the UI as above, + // even though the selection has not changed. + if (placeURI == this._cachedLeftPaneSelectedURI) { + return; + } + this._cachedLeftPaneSelectedURI = placeURI; + + // At this point, resetSearchBox is true, because the left pane selection + // has changed; otherwise we would have returned earlier. + + let input = PlacesSearchBox.searchFilter; + input.value = ""; + input.editor?.clearUndoRedo(); + this._setSearchScopeForNode(node); + this.updateDetailsPane(); + }, + + /** + * Sets the search scope based on aNode's properties. + * + * @param {object} aNode + * the node to set up scope from + */ + _setSearchScopeForNode: function PO__setScopeForNode(aNode) { + let itemGuid = aNode.bookmarkGuid; + + if ( + PlacesUtils.nodeIsHistoryContainer(aNode) || + itemGuid == PlacesUtils.virtualHistoryGuid + ) { + PlacesQueryBuilder.setScope("history"); + } else if (itemGuid == PlacesUtils.virtualDownloadsGuid) { + PlacesQueryBuilder.setScope("downloads"); + } else { + // Default to All Bookmarks for all other nodes, per bug 469437. + PlacesQueryBuilder.setScope("bookmarks"); + } + }, + + /** + * Handle clicks on the places list. + * Single Left click, right click or modified click do not result in any + * special action, since they're related to selection. + * + * @param {object} aEvent + * The mouse event. + */ + onPlacesListClick: function PO_onPlacesListClick(aEvent) { + // Only handle clicks on tree children. + if (aEvent.target.localName != "treechildren") { + return; + } + + let node = this._places.selectedNode; + if (node) { + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this._places); + } + } + }, + + /** + * Handle focus changes on the places list and the current content view. + */ + updateDetailsPane: function PO_updateDetailsPane() { + if (!ContentArea.currentViewOptions.showDetailsPane) { + return; + } + let view = PlacesUIUtils.getViewForNode(document.activeElement); + if (view) { + let selectedNodes = view.selectedNode + ? [view.selectedNode] + : view.selectedNodes; + this._fillDetailsPane(selectedNodes); + } + }, + + /** + * Handle openFlatContainer events. + * + * @param {object} aContainer + * The node the event was dispatched on. + */ + openFlatContainer(aContainer) { + if (aContainer.bookmarkGuid) { + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + this._places.selectItems([aContainer.bookmarkGuid], false); + } else if (PlacesUtils.nodeIsQuery(aContainer)) { + this._places.selectPlaceURI(aContainer.uri); + } + }, + + /** + * @returns {object} + * Returns the options associated with the query currently loaded in the + * main places pane. + */ + getCurrentOptions: function PO_getCurrentOptions() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root) + .queryOptions; + }, + + /** + * Show the migration wizard for importing passwords, + * cookies, history, preferences, and bookmarks. + */ + importFromBrowser: function PO_importFromBrowser() { + // We pass in the type of source we're using for use in telemetry: + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES, + }); + }, + + /** + * Open a file-picker and import the selected file into the bookmarks store + */ + importFromFile: function PO_importFromFile() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.importFromURL(fp.fileURL.spec).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-import" + ), + Ci.nsIFilePicker.modeOpen + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.open(fpCallback); + }, + + /** + * Allows simple exporting of bookmarks. + */ + exportBookmarks: function PO_exportBookmarks() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.exportToFile(fp.file.path).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-export" + ), + Ci.nsIFilePicker.modeSave + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.defaultString = "bookmarks.html"; + fp.open(fpCallback); + }, + + /** + * Populates the restore menu with the dates of the backups available. + */ + populateRestoreMenu: function PO_populateRestoreMenu() { + let restorePopup = document.getElementById("fileRestorePopup"); + + const dtOptions = { + dateStyle: "long", + }; + let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + + // Remove existing menu items. Last item is the restoreFromFile item. + while (restorePopup.childNodes.length > 1) { + restorePopup.firstChild.remove(); + } + + (async function () { + let backupFiles = await PlacesBackups.getBackupFiles(); + if (!backupFiles.length) { + return; + } + + // Populate menu with backups. + for (let file of backupFiles) { + let fileSize = (await IOUtils.stat(file)).size; + let [size, unit] = DownloadUtils.convertByteUnits(fileSize); + let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", [ + size, + unit, + ]); + + let countString; + let count = PlacesBackups.getBookmarkCountForFile(file); + if (count != null) { + const [msg] = await document.l10n.formatMessages([ + { id: "places-details-pane-items-count", args: { count } }, + ]); + countString = msg.attributes.find( + attr => attr.name === "value" + )?.value; + } + + const backupDate = PlacesBackups.getDateForFile(file); + let label = dateFormatter.format(backupDate); + label += countString + ? ` (${sizeString} - ${countString})` + : ` (${sizeString})`; + + let m = restorePopup.insertBefore( + document.createXULElement("menuitem"), + document.getElementById("restoreFromFile") + ); + m.setAttribute("label", label); + m.setAttribute("value", PathUtils.filename(file)); + m.setAttribute( + "oncommand", + "PlacesOrganizer.onRestoreMenuItemClick(this);" + ); + } + + // Add the restoreFromFile item. + restorePopup.insertBefore( + document.createXULElement("menuseparator"), + document.getElementById("restoreFromFile") + ); + })(); + }, + + /** + * Called when a menuitem is selected from the restore menu. + * + * @param {object} aMenuItem The menuitem that was selected. + */ + async onRestoreMenuItemClick(aMenuItem) { + let backupName = aMenuItem.getAttribute("value"); + let backupFilePaths = await PlacesBackups.getBackupFiles(); + for (let backupFilePath of backupFilePaths) { + if (PathUtils.filename(backupFilePath) == backupName) { + PlacesOrganizer.restoreBookmarksFromFile(backupFilePath); + break; + } + } + }, + + /** + * Called when 'Choose File...' is selected from the restore menu. + * Prompts for a file and restores bookmarks to those in the file. + */ + onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = aResult => { + if (aResult != Ci.nsIFilePicker.returnCancel) { + this.restoreBookmarksFromFile(fp.file.path); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeOpen); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + /** + * Restores bookmarks from a JSON file. + * + * @param {string} aFilePath + * The path of the file to restore from. + */ + restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) { + // check file extension + if ( + !aFilePath.toLowerCase().endsWith("json") && + !aFilePath.toLowerCase().endsWith("jsonlz4") + ) { + this._showErrorAlert("places-bookmarks-restore-format-error"); + return; + } + + const [title, body] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-alert-title", + "places-bookmarks-restore-alert", + ]); + // confirm ok to delete existing bookmarks + if (!Services.prompt.confirm(null, title, body)) { + return; + } + + (async function () { + try { + await BookmarkJSONUtils.importFromFile(aFilePath, { + replace: true, + }); + } catch (ex) { + PlacesOrganizer._showErrorAlert("places-bookmarks-restore-parse-error"); + } + })(); + }, + + _showErrorAlert: function PO__showErrorAlert(l10nId) { + const [title, msg] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-error-title", + l10nId, + ]); + Services.prompt.alert(window, title, msg); + }, + + /** + * Backup bookmarks to desktop, auto-generate a filename with a date. + * The file is a JSON serialization of bookmarks, tags and any annotations + * of those items. + */ + backupBookmarks: function PO_backupBookmarks() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + // There is no OS.File version of the filepicker yet (Bug 937812). + PlacesBackups.saveBookmarksToJSONFile(fp.file.path).catch( + console.error + ); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-backup-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeSave); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.defaultString = PlacesBackups.getFilenameForDate(); + fp.defaultExtension = "json"; + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + _fillDetailsPane: function PO__fillDetailsPane(aNodeList) { + var infoBox = document.getElementById("infoBox"); + var itemsCountBox = document.getElementById("itemsCountBox"); + + // Make sure the infoBox UI is visible if we need to use it, we hide it + // below when we don't. + infoBox.hidden = false; + itemsCountBox.hidden = true; + + let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null; + + // If an input within a panel is focused, force-blur it so its contents + // are saved + if (gEditItemOverlay.itemId != -1) { + var focusedElement = document.commandDispatcher.focusedElement; + if ( + (HTMLInputElement.isInstance(focusedElement) || + HTMLTextAreaElement.isInstance(focusedElement)) && + /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id) + ) { + focusedElement.blur(); + } + + // don't update the panel if we are already editing this node unless we're + // in multi-edit mode + if (selectedNode) { + let concreteGuid = PlacesUtils.getConcreteItemGuid(selectedNode); + var nodeIsSame = + gEditItemOverlay.itemId == selectedNode.itemId || + gEditItemOverlay._paneInfo.itemGuid == concreteGuid || + (selectedNode.itemId == -1 && + gEditItemOverlay.uri && + gEditItemOverlay.uri == selectedNode.uri); + if (nodeIsSame && !infoBox.hidden && !gEditItemOverlay.multiEdit) { + return; + } + } + } + + // Clean up the panel before initing it again. + gEditItemOverlay.uninitPanel(false); + + if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) { + gEditItemOverlay + .initPanel({ + node: selectedNode, + hiddenRows: ["folderPicker"], + }) + .catch(ex => console.error(ex)); + } else if (!selectedNode && aNodeList[0]) { + if (aNodeList.every(PlacesUtils.nodeIsURI)) { + let uris = aNodeList.map(node => Services.io.newURI(node.uri)); + gEditItemOverlay + .initPanel({ + uris, + hiddenRows: ["folderPicker", "location", "keyword", "name"], + }) + .catch(ex => console.error(ex)); + } else { + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: aNodeList.length } + ); + infoBox.hidden = true; + } + } else { + infoBox.hidden = true; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + let itemsCount = 0; + if (ContentArea.currentView.result) { + let rootNode = ContentArea.currentView.result.root; + if (rootNode.containerOpen) { + itemsCount = rootNode.childCount; + } + } + if (itemsCount == 0) { + selectItemDesc.hidden = true; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-no-items" + ); + } else { + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: itemsCount } + ); + } + } + itemsCountBox.hidden = !infoBox.hidden; + }, +}; + +/** + * A set of utilities relating to search within Bookmarks and History. + */ +var PlacesSearchBox = { + /** + * The Search text field + * + * @see {@link https://searchfox.org/mozilla-central/source/toolkit/content/widgets/search-textbox.js} + * @returns {HTMLInputElement} + */ + get searchFilter() { + return document.getElementById("searchFilter"); + }, + + cumulativeHistorySearches: 0, + cumulativeBookmarkSearches: 0, + + /** + * Folders to include when searching. + */ + _folders: [], + get folders() { + if (!this._folders.length) { + this._folders = PlacesUtils.bookmarks.userContentRoots; + } + return this._folders; + }, + set folders(aFolders) { + this._folders = aFolders; + }, + + /** + * Run a search for the specified text, over the collection specified by + * the dropdown arrow. The default is all bookmarks, but can be + * localized to the active collection. + * + * @param {string} filterString + * The text to search for. + */ + search(filterString) { + var PO = PlacesOrganizer; + // If the user empties the search box manually, reset it and load all + // contents of the current scope. + // XXX this might be to jumpy, maybe should search for "", so results + // are ungrouped, and search box not reset + if (filterString == "") { + PO.onPlaceSelected(false); + return; + } + + let currentView = ContentArea.currentView; + + // Search according to the current scope, which was set by + // PQB_setScope() + switch (PlacesSearchBox.filterCollection) { + case "bookmarks": + currentView.applyFilter(filterString, this.folders); + Services.telemetry.keyedScalarAdd("library.search", "bookmarks", 1); + this.cumulativeBookmarkSearches++; + break; + case "history": { + let currentOptions = PO.getCurrentOptions(); + if ( + currentOptions.queryType != + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + let options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } else { + TelemetryStopwatch.start(HISTORY_LIBRARY_SEARCH_TELEMETRY); + currentView.applyFilter(filterString, null, true); + TelemetryStopwatch.finish(HISTORY_LIBRARY_SEARCH_TELEMETRY); + Services.telemetry.keyedScalarAdd("library.search", "history", 1); + this.cumulativeHistorySearches++; + } + break; + } + case "downloads": { + // The new downloads view doesn't use places for searching downloads. + currentView.searchTerm = filterString; + break; + } + default: + throw new Error("Invalid filterCollection on search"); + } + + // Update the details panel + PlacesOrganizer.updateDetailsPane(); + }, + + /** + * Finds across all history, downloads or all bookmarks. + */ + findAll() { + switch (this.filterCollection) { + case "history": + PlacesQueryBuilder.setScope("history"); + break; + case "downloads": + PlacesQueryBuilder.setScope("downloads"); + break; + default: + PlacesQueryBuilder.setScope("bookmarks"); + break; + } + this.focus(); + }, + + /** + * Updates the search input placeholder to match the current collection. + */ + updatePlaceholder() { + let l10nId = ""; + switch (this.filterCollection) { + case "history": + l10nId = "places-search-history"; + break; + case "downloads": + l10nId = "places-search-downloads"; + break; + default: + l10nId = "places-search-bookmarks"; + } + document.l10n.setAttributes(this.searchFilter, l10nId); + }, + + /** + * Gets/sets the active collection from the dropdown menu. + * + * @returns {string} + */ + get filterCollection() { + return this.searchFilter.getAttribute("collection"); + }, + set filterCollection(collectionName) { + if (collectionName == this.filterCollection) { + return; + } + + this.searchFilter.setAttribute("collection", collectionName); + this.updatePlaceholder(); + }, + + /** + * Focus the search box + */ + focus() { + this.searchFilter.focus(); + }, + + /** + * Set up the gray text in the search bar as the Places View loads. + */ + init() { + this.updatePlaceholder(); + }, + + /** + * Gets or sets the text shown in the Places Search Box + * + * @returns {string} + */ + get value() { + return this.searchFilter.value; + }, + set value(value) { + this.searchFilter.value = value; + }, +}; + +function updateTelemetry(urlsOpened) { + let historyLinks = urlsOpened.filter( + link => !link.isBookmark && !PlacesUtils.nodeIsBookmark(link) + ); + if (!historyLinks.length) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeBookmarkSearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeBookmarkSearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "bookmarks", + urlsOpened.length + ); + return; + } + + // Record cumulative search count before selecting History link from Library + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_HISTORY_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeHistorySearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeHistorySearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "history", + historyLinks.length + ); +} + +/** + * Functions and data for advanced query builder + */ +var PlacesQueryBuilder = { + queries: [], + queryOptions: null, + + /** + * Sets the search scope. This can be called when no search is active, and + * in that case, when `search()` is called, `aScope` will be used. + * If there is an active search, it's performed again to + * update the content tree. + * + * @param {"bookmarks" | "downloads" | "history"} aScope + * The search scope: "bookmarks", "downloads" or "history". + */ + setScope(aScope) { + // Determine filterCollection, folders, and scopeButtonId based on aScope. + var filterCollection; + var folders = []; + switch (aScope) { + case "history": + filterCollection = "history"; + break; + case "bookmarks": + filterCollection = "bookmarks"; + folders = PlacesUtils.bookmarks.userContentRoots; + break; + case "downloads": + filterCollection = "downloads"; + break; + default: + throw new Error("Invalid search scope"); + } + + // Update the search box. Re-search if there's an active search. + PlacesSearchBox.filterCollection = filterCollection; + PlacesSearchBox.folders = folders; + var searchStr = PlacesSearchBox.searchFilter.value; + if (searchStr) { + PlacesSearchBox.search(searchStr); + } + }, +}; + +/** + * Population and commands for the View Menu. + */ +var ViewMenu = { + /** + * Removes content generated previously from a menupopup. + * + * @param {object} popup + * The popup that contains the previously generated content. + * @param {string} startID + * The id attribute of an element that is the start of the + * dynamically generated region - remove elements after this + * item only. + * Must be contained by popup. Can be null (in which case the + * contents of popup are removed). + * @param {string} endID + * The id attribute of an element that is the end of the + * dynamically generated region - remove elements up to this + * item only. + * Must be contained by popup. Can be null (in which case all + * items until the end of the popup will be removed). Ignored + * if startID is null. + * @returns {object|null} The element for the caller to insert new items before, + * null if the caller should just append to the popup. + */ + _clean: function VM__clean(popup, startID, endID) { + if (endID && !startID) { + throw new Error("meaningless to have valid endID and null startID"); + } + if (startID) { + var startElement = document.getElementById(startID); + if (startElement.parentNode != popup) { + throw new Error("startElement is not in popup"); + } + if (!startElement) { + throw new Error("startID does not correspond to an existing element"); + } + var endElement = null; + if (endID) { + endElement = document.getElementById(endID); + if (endElement.parentNode != popup) { + throw new Error("endElement is not in popup"); + } + if (!endElement) { + throw new Error("endID does not correspond to an existing element"); + } + } + while (startElement.nextSibling != endElement) { + popup.removeChild(startElement.nextSibling); + } + return endElement; + } + while (popup.hasChildNodes()) { + popup.firstChild.remove(); + } + return null; + }, + + /** + * Fills a menupopup with a list of columns + * + * @param {object} event + * The popupshowing event that invoked this function. + * @param {string} startID + * see _clean + * @param {string} endID + * see _clean + * @param {string} type + * the type of the menuitem, e.g. "radio" or "checkbox". + * Can be null (no-type). + * Checkboxes are checked if the column is visible. + * @param {boolean} localize + * If localize is true, the column label and accesskey are set + * via DOM Localization. + * If localize is false, the column label is used as label and + * no accesskey is assigned. + */ + fillWithColumns: function VM_fillWithColumns( + event, + startID, + endID, + type, + localize + ) { + var popup = event.target; + var pivot = this._clean(popup, startID, endID); + + var content = document.getElementById("placeContent"); + var columns = content.columns; + for (var i = 0; i < columns.count; ++i) { + var column = columns.getColumnAt(i).element; + var menuitem = document.createXULElement("menuitem"); + menuitem.id = "menucol_" + column.id; + menuitem.column = column; + if (localize) { + const l10nId = SORTBY_L10N_IDS.get(column.getAttribute("anonid")); + document.l10n.setAttributes(menuitem, l10nId); + } else { + const label = column.getAttribute("label"); + menuitem.setAttribute("label", label); + } + if (type == "radio") { + menuitem.setAttribute("type", "radio"); + menuitem.setAttribute("name", "columns"); + // This column is the sort key. Its item is checked. + if (column.getAttribute("sortDirection") != "") { + menuitem.setAttribute("checked", "true"); + } + } else if (type == "checkbox") { + menuitem.setAttribute("type", "checkbox"); + // Cannot uncheck the primary column. + if (column.getAttribute("primary") == "true") { + menuitem.setAttribute("disabled", "true"); + } + // Items for visible columns are checked. + if (!column.hidden) { + menuitem.setAttribute("checked", "true"); + } + } + if (pivot) { + popup.insertBefore(menuitem, pivot); + } else { + popup.appendChild(menuitem); + } + } + event.stopPropagation(); + }, + + /** + * Set up the content of the view menu. + * + * @param {object} event + * The event that invoked this function + */ + populateSortMenu: function VM_populateSortMenu(event) { + this.fillWithColumns( + event, + "viewUnsorted", + "directionSeparator", + "radio", + true + ); + + var sortColumn = this._getSortColumn(); + var viewSortAscending = document.getElementById("viewSortAscending"); + var viewSortDescending = document.getElementById("viewSortDescending"); + // We need to remove an existing checked attribute because the unsorted + // menu item is not rebuilt every time we open the menu like the others. + var viewUnsorted = document.getElementById("viewUnsorted"); + if (!sortColumn) { + viewSortAscending.removeAttribute("checked"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.setAttribute("checked", "true"); + } else if (sortColumn.getAttribute("sortDirection") == "ascending") { + viewSortAscending.setAttribute("checked", "true"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } else if (sortColumn.getAttribute("sortDirection") == "descending") { + viewSortDescending.setAttribute("checked", "true"); + viewSortAscending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + }, + + /** + * Shows/Hides a tree column. + * + * @param {object} element + * The menuitem element for the column + */ + showHideColumn: function VM_showHideColumn(element) { + var column = element.column; + + var splitter = column.nextSibling; + if (splitter && splitter.localName != "splitter") { + splitter = null; + } + + const isChecked = element.getAttribute("checked") == "true"; + column.hidden = !isChecked; + if (splitter) { + splitter.hidden = !isChecked; + } + }, + + /** + * Gets the last column that was sorted. + * + * @returns {object|null} the currently sorted column, null if there is no sorted column. + */ + _getSortColumn: function VM__getSortColumn() { + var content = document.getElementById("placeContent"); + var cols = content.columns; + for (var i = 0; i < cols.count; ++i) { + var column = cols.getColumnAt(i).element; + var sortDirection = column.getAttribute("sortDirection"); + if (sortDirection == "ascending" || sortDirection == "descending") { + return column; + } + } + return null; + }, + + /** + * Sorts the view by the specified column. + * + * @param {object} aColumn + * The colum that is the sort key. Can be null - the + * current sort column or the title column will be used. + * @param {string} aDirection + * The direction to sort - "ascending" or "descending". + * Can be null - the last direction or descending will be used. + * + * If both aColumnID and aDirection are null, the view will be unsorted. + */ + setSortColumn: function VM_setSortColumn(aColumn, aDirection) { + var result = document.getElementById("placeContent").result; + if (!aColumn && !aDirection) { + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + return; + } + + var columnId; + if (aColumn) { + columnId = aColumn.getAttribute("anonid"); + if (!aDirection) { + let sortColumn = this._getSortColumn(); + if (sortColumn) { + aDirection = sortColumn.getAttribute("sortDirection"); + } + } + } else { + let sortColumn = this._getSortColumn(); + columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; + } + + // This maps the possible values of columnId (i.e., anonid's of treecols in + // placeContent) to the default sortingMode for each column. + // key: Sort key in the name of one of the + // nsINavHistoryQueryOptions.SORT_BY_* constants + // dir: Default sort direction to use if none has been specified + const colLookupTable = { + title: { key: "TITLE", dir: "ascending" }, + tags: { key: "TAGS", dir: "ascending" }, + url: { key: "URI", dir: "ascending" }, + date: { key: "DATE", dir: "descending" }, + visitCount: { key: "VISITCOUNT", dir: "descending" }, + dateAdded: { key: "DATEADDED", dir: "descending" }, + lastModified: { key: "LASTMODIFIED", dir: "descending" }, + }; + + // Make sure we have a valid column. + if (!colLookupTable.hasOwnProperty(columnId)) { + throw new Error("Invalid column"); + } + + // Use a default sort direction if none has been specified. If aDirection + // is invalid, result.sortingMode will be undefined, which has the effect + // of unsorting the tree. + aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); + + var sortConst = + "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; + result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; + }, +}; + +var ContentArea = { + _specialViews: new Map(), + + init: function CA_init() { + this._box = document.getElementById("placesViewsBox"); + this._toolbar = document.getElementById("placesToolbar"); + ContentTree.init(); + this._setupView(); + }, + + /** + * Gets the content view to be used for loading the given query. + * If a custom view was set by setContentViewForQueryString, that + * view would be returned, else the default tree view is returned + * + * @param {string} aQueryString + * a query string + * @returns {object} the view to be used for loading aQueryString. + */ + getContentViewForQueryString: function CA_getContentViewForQueryString( + aQueryString + ) { + try { + if (this._specialViews.has(aQueryString)) { + let { view, options } = this._specialViews.get(aQueryString); + if (typeof view == "function") { + view = view(); + this._specialViews.set(aQueryString, { view, options }); + } + return view; + } + } catch (ex) { + console.error(ex); + } + return ContentTree.view; + }, + + /** + * Sets a custom view to be used rather than the default places tree + * whenever the given query is selected in the left pane. + * + * @param {string} aQueryString + * a query string + * @param {object} aView + * Either the custom view or a function that will return the view + * the first (and only) time it's called. + * @param {object} [aOptions] + * Object defining special options for the view. + * @see ContentTree.viewOptions for supported options and default values. + */ + setContentViewForQueryString: function CA_setContentViewForQueryString( + aQueryString, + aView, + aOptions + ) { + if ( + !aQueryString || + (typeof aView != "object" && typeof aView != "function") + ) { + throw new Error("Invalid arguments"); + } + + this._specialViews.set(aQueryString, { + view: aView, + options: aOptions || {}, + }); + }, + + get currentView() { + let selectedPane = [...this._box.children].filter( + child => !child.hidden + )[0]; + return PlacesUIUtils.getViewForNode(selectedPane); + }, + set currentView(aNewView) { + let oldView = this.currentView; + if (oldView != aNewView) { + oldView.associatedElement.hidden = true; + aNewView.associatedElement.hidden = false; + + // If the content area inactivated view was focused, move focus + // to the new view. + if (document.activeElement == oldView.associatedElement) { + aNewView.associatedElement.focus(); + } + } + }, + + get currentPlace() { + return this.currentView.place; + }, + set currentPlace(aQueryString) { + let oldView = this.currentView; + let newView = this.getContentViewForQueryString(aQueryString); + newView.place = aQueryString; + if (oldView != newView) { + oldView.active = false; + this.currentView = newView; + this._setupView(); + newView.active = true; + } + }, + + /** + * Applies view options. + */ + _setupView: function CA__setupView() { + let options = this.currentViewOptions; + + // showDetailsPane. + let detailsPane = document.getElementById("detailsPane"); + detailsPane.hidden = !options.showDetailsPane; + + // toolbarSet. + for (let elt of this._toolbar.childNodes) { + // On Windows and Linux the menu buttons are menus wrapped in a menubar. + if (elt.id == "placesMenu") { + for (let menuElt of elt.childNodes) { + menuElt.hidden = !options.toolbarSet.includes(menuElt.id); + } + } else { + elt.hidden = !options.toolbarSet.includes(elt.id); + } + } + }, + + /** + * Options for the current view. + * + * @see {@link ContentTree.viewOptions} for supported options and default values. + * @returns {{showDetailsPane: boolean;toolbarSet: string;}} + */ + get currentViewOptions() { + // Use ContentTree options as default. + let viewOptions = ContentTree.viewOptions; + if (this._specialViews.has(this.currentPlace)) { + let { options } = this._specialViews.get(this.currentPlace); + for (let option in options) { + viewOptions[option] = options[option]; + } + } + return viewOptions; + }, + + focus() { + this.currentView.associatedElement.focus(); + }, +}; + +var ContentTree = { + init: function CT_init() { + this._view = document.getElementById("placeContent"); + }, + + get view() { + return this._view; + }, + + get viewOptions() { + return Object.seal({ + showDetailsPane: true, + toolbarSet: + "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter", + }); + }, + + openSelectedNode: function CT_openSelectedNode(aEvent) { + let view = this.view; + PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent); + }, + + onClick: function CT_onClick(aEvent) { + let node = this.view.selectedNode; + if (node) { + let doubleClick = aEvent.button == 0 && aEvent.detail == 2; + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { + // Open associated uri in the browser. + this.openSelectedNode(aEvent); + } else if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this.view); + } + } + }, + + onKeyPress: function CT_onKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { + this.openSelectedNode(aEvent); + } + }, +}; diff --git a/browser/components/places/content/places.xhtml b/browser/components/places/content/places.xhtml new file mode 100644 index 0000000000..0d5889651e --- /dev/null +++ b/browser/components/places/content/places.xhtml @@ -0,0 +1,405 @@ +<?xml version="1.0"?> + +# 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/. + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css"?> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/organizer-shared.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/organizer.css"?> + +<?xml-stylesheet href="chrome://browser/content/downloads/downloads.css"?> +<?xml-stylesheet href="chrome://browser/skin/downloads/allDownloadsView.css"?> + +<!DOCTYPE window> + +<window id="places" + data-l10n-id="places-library3" + windowtype="Places:Organizer" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="PlacesOrganizer.init();" + onunload="PlacesOrganizer.destroy();" + width="800" height="500" + screenX="10" screenY="10" + toggletoolbar="true" + persist="width height screenX screenY sizemode"> + + <linkset> + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="browser/browserSets.ftl"/> + <html:link rel="localization" href="browser/places.ftl"/> + <html:link rel="localization" href="browser/downloads.ftl"/> + <html:link rel="localization" href="browser/editBookmarkOverlay.ftl"/> + </linkset> + + <script src="chrome://browser/content/places/places.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> +#ifndef XP_MACOSX + <!-- On Mac, this is included via macWindow.inc.xhtml -> global-scripts.inc -> browser.js -> defineLazyScriptGetter --> + <script src="chrome://browser/content/places/editBookmark.js"/> + <!-- On Mac, thes are included via macWindow.inc.xhtml -> global-scripts.inc --> + <script src="chrome://global/content/globalOverlay.js"/> + <script src="chrome://browser/content/utilityOverlay.js"/> +#endif + +#ifdef XP_MACOSX +#include ../../../base/content/macWindow.inc.xhtml +#else +#include placesCommands.inc.xhtml +#endif + + <!-- This must be included after macWindow.inc.xhtml to override DownloadsView --> + <script src="chrome://browser/content/downloads/allDownloadsView.js"/> + <script src="chrome://global/content/contentAreaUtils.js"/> + <script src="chrome://browser/content/places/places-tree.js"/> + + <commandset id="organizerCommandSet"> + <command id="OrganizerCommand_find:all" + oncommand="PlacesSearchBox.findAll();"/> + <command id="OrganizerCommand_export" + oncommand="PlacesOrganizer.exportBookmarks();"/> + <command id="OrganizerCommand_import" + oncommand="PlacesOrganizer.importFromFile();"/> + <command id="OrganizerCommand_browserImport" + oncommand="PlacesOrganizer.importFromBrowser();"/> + <command id="OrganizerCommand_backup" + oncommand="PlacesOrganizer.backupBookmarks();"/> + <command id="OrganizerCommand_restoreFromFile" + oncommand="PlacesOrganizer.onRestoreBookmarksFromFile();"/> + <command id="OrganizerCommand_search:save" + oncommand="PlacesOrganizer.saveSearch();"/> + <command id="OrganizerCommand_search:moreCriteria" + oncommand="PlacesQueryBuilder.addRow();"/> + <command id="OrganizerCommand:Back" + oncommand="PlacesOrganizer.back();"/> + <command id="OrganizerCommand:Forward" + oncommand="PlacesOrganizer.forward();"/> + </commandset> +#include ../../downloads/content/downloadsCommands.inc.xhtml + + <keyset id="placesOrganizerKeyset"> + <!-- Instantiation Keys --> + <key id="placesKey_close" data-l10n-id="places-cmd-close" modifiers="accel" + oncommand="window.close();"/> + + <!-- Command Keys --> + <key id="placesKey_find:all" + command="OrganizerCommand_find:all" + data-l10n-id="places-cmd-find-key" + modifiers="accel"/> + + <!-- Back/Forward Keys Support --> +#ifndef XP_MACOSX + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="alt"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="alt"/> +#else + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="accel"/> +#endif +#ifdef XP_UNIX + <key id="placesKey_goBackKb2" + data-l10n-id="nav-back-shortcut-alt" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb2" + data-l10n-id="nav-fwd-shortcut-alt" + command="OrganizerCommand:Forward" + modifiers="accel"/> +#endif + </keyset> + +#include ../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + + <popupset id="placesPopupset"> +#include placesContextMenu.inc.xhtml + <menupopup id="placesColumnsContext" + onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', false);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> +#include ../../downloads/content/downloadsContextMenu.inc.xhtml + </popupset> + + <toolbox id="placesToolbox"> + <toolbar class="chromeclass-toolbar" id="placesToolbar" align="center"> + <toolbarbutton id="back-button" + command="OrganizerCommand:Back" + data-l10n-id="places-back-button" + disabled="true"/> + + <toolbarbutton id="forward-button" + command="OrganizerCommand:Forward" + data-l10n-id="places-forward-button" + disabled="true"/> + +#ifdef XP_MACOSX + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + onpopupshowing="document.getElementById('placeContent').focus()" + data-l10n-id="places-organize-button-mac" +#else + <menubar id="placesMenu"> + <menu class="menu-iconic" data-l10n-id="places-organize-button" +#endif + id="organizeButton"> + <menupopup id="organizeButtonPopup"> + <menuitem id="newbookmark" + command="placesCmd_new:bookmark" + data-l10n-id="places-add-bookmark"/> + <menuitem id="newfolder" + command="placesCmd_new:folder" + data-l10n-id="places-add-folder"/> + <menuitem id="newseparator" + command="placesCmd_new:separator" + data-l10n-id="places-add-separator"/> + +#ifdef XP_MACOSX + <menuseparator id="orgDeleteSeparator"/> + + <menuitem id="orgDelete" + command="cmd_delete" + data-l10n-id="text-action-delete" + key="key_delete"/> +#else + <menuseparator id="orgUndoSeparator"/> + + <menuitem id="orgUndo" + command="cmd_undo" + data-l10n-id="text-action-undo" + key="key_undo"/> + <menuitem id="orgRedo" + command="cmd_redo" + data-l10n-id="text-action-redo" + key="key_redo"/> + + <menuseparator id="orgCutSeparator"/> + + <menuitem id="orgCut" + command="cmd_cut" + data-l10n-id="text-action-cut" + key="key_cut" + selection="separator|link|folder|mixed"/> + <menuitem id="orgCopy" + command="cmd_copy" + data-l10n-id="text-action-copy" + key="key_copy" + selection="separator|link|folder|mixed"/> + <menuitem id="orgPaste" + command="cmd_paste" + data-l10n-id="text-action-paste" + key="key_paste" + selection="mutable"/> + <menuitem id="orgDelete" + command="cmd_delete" + data-l10n-id="text-action-delete" + key="key_delete"/> + + <menuseparator id="selectAllSeparator"/> + + <menuitem id="orgSelectAll" + command="cmd_selectAll" + data-l10n-id="text-action-select-all" + key="key_selectAll"/> + + <menuseparator id="orgCloseSeparator"/> + + <menuitem id="orgClose" + key="placesKey_close" + data-l10n-id="places-file-close" + oncommand="window.close();"/> +#endif + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + data-l10n-id="places-view-button-mac" +#else + </menu> + <menu class="menu-iconic" data-l10n-id="places-view-button" +#endif + id="viewMenu"> + <menupopup id="viewMenuPopup"> + + <menu id="viewColumns" + data-l10n-id="places-view-menu-columns"> + <menupopup onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', false);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </menu> + + <menu id="viewSort" data-l10n-id="places-view-menu-sort"> + <menupopup onpopupshowing="ViewMenu.populateSortMenu(event);" + oncommand="ViewMenu.setSortColumn(event.target.column, null);"> + <menuitem id="viewUnsorted" type="radio" name="columns" + data-l10n-id="places-view-sort-unsorted" + oncommand="ViewMenu.setSortColumn(null, null);"/> + <menuseparator id="directionSeparator"/> + <menuitem id="viewSortAscending" type="radio" name="direction" + data-l10n-id="places-view-sort-ascending" + oncommand="ViewMenu.setSortColumn(null, 'ascending'); event.stopPropagation();"/> + <menuitem id="viewSortDescending" type="radio" name="direction" + data-l10n-id="places-view-sort-descending" + oncommand="ViewMenu.setSortColumn(null, 'descending'); event.stopPropagation();"/> + </menupopup> + </menu> + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> + <toolbarbutton type="menu" class="tabbable" wantdropmarker="true" + data-l10n-id="places-maintenance-button-mac" +#else + </menu> + <menu class="menu-iconic" data-l10n-id="places-maintenance-button" +#endif + id="maintenanceButton"> + <menupopup id="maintenanceButtonPopup"> + <menuitem id="backupBookmarks" + command="OrganizerCommand_backup" + data-l10n-id="places-cmd-backup"/> + <menu id="fileRestoreMenu" data-l10n-id="places-cmd-restore"> + <menupopup id="fileRestorePopup" onpopupshowing="PlacesOrganizer.populateRestoreMenu();"> + <menuitem id="restoreFromFile" + command="OrganizerCommand_restoreFromFile" + data-l10n-id="places-cmd-restore-from-file"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="fileImport" + command="OrganizerCommand_import" + data-l10n-id="places-import-bookmarks-from-html"/> + <menuitem id="fileExport" + command="OrganizerCommand_export" + data-l10n-id="places-export-bookmarks-to-html"/> + <menuseparator/> + <menuitem id="browserImport" + command="OrganizerCommand_browserImport" + data-l10n-id="places-import-other-browser"/> + </menupopup> +#ifdef XP_MACOSX + </toolbarbutton> +#else + </menu> + </menubar> +#endif + + <toolbarbutton id="clearDownloadsButton" + data-l10n-id="downloads-clear-downloads-button" + class="tabbable" + command="downloadsCmd_clearDownloads"/> + + <spacer id="libraryToolbarSpacer" flex="1"/> + + <search-textbox id="searchFilter" + flex="1" + aria-controls="placeContent" + data-l10n-attrs="placeholder" + oncommand="PlacesSearchBox.search(this.value);" + collection="bookmarks"/> + </toolbar> + </toolbox> + + <hbox flex="1" id="placesView"> + <tree id="placesList" + class="plain placesTree" + is="places-tree" + hidecolumnpicker="true" context="placesContext" + onselect="PlacesOrganizer.onPlaceSelected(true);" + onclick="PlacesOrganizer.onPlacesListClick(event);" + onfocus="PlacesOrganizer.updateDetailsPane(event);" + seltype="single" + persist="style"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + <splitter collapse="none" persist="state"></splitter> + <vbox id="contentView"> + <vbox id="placesViewsBox" flex="1"> + <tree id="placeContent" + class="plain placesTree" + context="placesContext" + hidecolumnpicker="true" + flex="1" + is="places-tree" + flatList="true" + selectfirstnode="true" + enableColumnDrag="true" + onfocus="PlacesOrganizer.updateDetailsPane(event)" + onselect="PlacesOrganizer.updateDetailsPane(event)" + onkeypress="ContentTree.onKeyPress(event);"> + <treecols id="placeContentColumns" context="placesColumnsContext"> + <!-- + The below code may suggest that 'ordinal' is still a supported XUL + attribute. It is not. This is a crutch so that we can continue + persisting the CSS order attribute, which is the appropriate + replacement for the ordinal attribute but cannot yet + be easily persisted. The code that synchronizes the attribute with + the CSS lives in toolkit/content/widget/tree.js and is specific to + tree elements. + --> + <treecol data-l10n-id="places-view-sort-col-name" id="placesContentTitle" anonid="title" style="flex: 5 5 auto" primary="true" ordinal="1" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-tags" id="placesContentTags" anonid="tags" style="flex: 2 2 auto" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-url" id="placesContentUrl" anonid="url" style="flex: 5 5 auto" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-most-recent-visit" id="placesContentDate" anonid="date" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-visit-count" id="placesContentVisitCount" anonid="visitCount" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-date-added" id="placesContentDateAdded" anonid="dateAdded" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol data-l10n-id="places-view-sort-col-last-modified" id="placesContentLastModified" anonid="lastModified" style="flex: 1 auto" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + </treecols> + <treechildren flex="1" onclick="ContentTree.onClick(event);"/> + </tree> + <richlistbox flex="1" + hidden="true" + seltype="multiple" + id="downloadsListBox" + class="allDownloadsListBox" + context="downloadsContextMenu"/> + </vbox> + <vbox id="detailsPane"> + <vbox id="itemsCountBox" align="center" flex="1" hidden="true"> + <spacer style="flex: 3 3"/> + <label id="itemsCountText"/> + <spacer flex="1"/> + <description id="selectItemDescription" data-l10n-id="places-details-pane-select-an-item-description"> + </description> + <spacer style="flex: 3 3"/> + </vbox> + <vbox id="infoBox"> +#include editBookmarkPanel.inc.xhtml + </vbox> + </vbox> + </vbox> + </hbox> +</window> diff --git a/browser/components/places/content/placesCommands.inc.xhtml b/browser/components/places/content/placesCommands.inc.xhtml new file mode 100644 index 0000000000..a3676ba6b1 --- /dev/null +++ b/browser/components/places/content/placesCommands.inc.xhtml @@ -0,0 +1,52 @@ +# 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/. + +<commandset id="placesCommands" + commandupdater="true" + events="focus,sort,places" + oncommandupdate="PlacesUIUtils.updateCommands(window);"> + <command id="Browser:ShowAllBookmarks" + oncommand="PlacesCommandHook.showPlacesOrganizer('UnfiledBookmarks');"/> + <command id="Browser:ShowAllHistory" + oncommand="PlacesCommandHook.showPlacesOrganizer('History');"/> + + <command id="placesCmd_open" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open');"/> + <command id="placesCmd_open:window" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:window');"/> + <command id="placesCmd_open:privatewindow" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:privatewindow');"/> + <command id="placesCmd_open:tab" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:tab');"/> + + <command id="placesCmd_new:bookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:bookmark');"/> + <command id="placesCmd_new:folder" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:folder');"/> + <command id="placesCmd_new:separator" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:separator');"/> + <command id="placesCmd_show:info" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');"/> + <command id="placesCmd_rename" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');" + observes="placesCmd_show:info"/> + <command id="placesCmd_sortBy:name" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_sortBy:name');"/> + <command id="placesCmd_deleteDataHost" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_deleteDataHost');"/> + <command id="placesCmd_createBookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_createBookmark');"/> + + <!-- Special versions of cut/copy/paste/delete which check for an open context menu. --> + <command id="placesCmd_cut" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_cut');"/> + <command id="placesCmd_copy" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_copy');"/> + <command id="placesCmd_paste" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_paste');"/> + <command id="placesCmd_delete" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_delete');"/> + <command id="placesCmd_showInFolder" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_showInFolder');"/> +</commandset> diff --git a/browser/components/places/content/placesContextMenu.inc.xhtml b/browser/components/places/content/placesContextMenu.inc.xhtml new file mode 100644 index 0000000000..c9a1fdc8e4 --- /dev/null +++ b/browser/components/places/content/placesContextMenu.inc.xhtml @@ -0,0 +1,178 @@ +# 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/. + +<menupopup id="placesContext" + onpopupshowing="return PlacesUIUtils.placesContextShowing(event);" + onpopuphiding="PlacesUIUtils.placesContextHiding(event);"> + <menuitem id="placesContext_open" + command="placesCmd_open" + data-l10n-id="places-open" + default="true" + selection-type="single" + node-type="link" + hide-if-single-click-opens="true"/> + <menuitem id="placesContext_openBookmarkContainer:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-bookmarks" + selection-type="single|none" + node-type="folder|query_tag"/> + <menuitem id="placesContext_openBookmarkLinks:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-bookmarks" + selection-type="multiple" + node-type="link_bookmark|separator"/> + <menuitem id="placesContext_open:newtab" + command="placesCmd_open:tab" + data-l10n-id="places-open-in-tab" + selection-type="single" + node-type="link"/> + <menu id="placesContext_open:newcontainertab" + data-l10n-id="places-open-in-container-tab" + selection-type="single" + node-type="link" + hide-if-private-browsing="true" + hide-if-usercontext-disabled="true"> + <menupopup oncommand="PlacesUIUtils.openInContainerTab(event);" + onpopupshowing="return PlacesUIUtils.createContainerTabMenu(event);" /> + </menu> + <menuitem id="placesContext_openContainer:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-in-tabs" + selection-type="single|none" + node-type="query" + hide-if-node-type="query_tag"/> + <menuitem id="placesContext_openLinks:tabs" + oncommand="PlacesUIUtils.openSelectionInTabs(event);" + data-l10n-id="places-open-all-in-tabs" + selection-type="multiple" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_open:newwindow" + command="placesCmd_open:window" + data-l10n-id="places-open-in-window" + selection-type="single" + node-type="link"/> + <menuitem id="placesContext_open:newprivatewindow" + command="placesCmd_open:privatewindow" + data-l10n-id="places-open-in-private-window" + selection-type="single" + node-type="link" + hide-if-private-browsing="true"/> + <menuitem id="placesContext_showInFolder" + data-l10n-id="places-show-in-folder" + command="placesCmd_showInFolder" + closemenu="single" + node-type="link_bookmark" + hide-if-not-search="true" + selection-type="single"/> + <menuseparator id="placesContext_openSeparator"/> + <menuitem id="placesContext_show_bookmark:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-bookmark" + node-type="link_bookmark"/> + <menuitem id="placesContext_show:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-generic" + node-type="query" + hide-if-node-type="query_host|query_day"/> + <menuitem id="placesContext_show_folder:info" + command="placesCmd_show:info" + data-l10n-id="places-edit-folder2" + node-type="folder"/> + <menuitem id="placesContext_deleteBookmark" + data-l10n-id="places-delete-bookmark" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + closemenu="single" + node-type="link_bookmark" + hide-if-node-type="link_bookmark_tag"/> + <menuitem id="placesContext_removeTag" + data-l10n-id="places-untag-bookmark" + command="placesCmd_delete" + closemenu="single" + node-type="link_bookmark_tag"/> + <menuitem id="placesContext_deleteFolder" + data-l10n-id="places-delete-folder" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + node-type="folder" + closemenu="single"/> + <menuitem id="placesContext_delete" + data-l10n-id="text-action-delete" + command="placesCmd_delete" + closemenu="single" + hide-if-node-type-is-only="link|folder"/> + <menuitem id="placesContext_delete_history" + data-l10n-id="places-delete-page" + data-l10n-args='{"count":"1"}' + command="placesCmd_delete" + closemenu="single" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_deleteHost" + command="placesCmd_deleteDataHost" + data-l10n-id="places-delete-domain-data" + closemenu="single" + node-type="link|query_host" + selection-type="single" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_sortBy:name" + command="placesCmd_sortBy:name" + data-l10n-id="places-sortby-name" + closemenu="single" + node-type="folder"/> + <menuseparator id="placesContext_deleteSeparator"/> + <menuitem id="placesContext_cut" + command="placesCmd_cut" + data-l10n-id="text-action-cut" + closemenu="single" + node-type="link_bookmark|folder|separator|query" + hide-if-node-type="link_bookmark_tag|query_host|query_day|query_tag"/> + <menuitem id="placesContext_copy" + command="placesCmd_copy" + data-l10n-id="text-action-copy" + closemenu="single"/> + <menuitem id="placesContext_paste_group" + data-l10n-id="text-action-paste" + command="placesCmd_paste" + closemenu="single" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_editSeparator"/> + <menuitem id="placesContext_new:bookmark" + command="placesCmd_new:bookmark" + data-l10n-id="places-add-bookmark" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuitem id="placesContext_new:folder" + command="placesCmd_new:folder" + data-l10n-id="places-add-folder-contextmenu" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuitem id="placesContext_new:separator" + command="placesCmd_new:separator" + data-l10n-id="places-add-separator" + closemenu="single" + selection-type="any" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_newSeparator"/> + <menuitem id="placesContext_paste" + data-l10n-id="text-action-paste" + command="placesCmd_paste" + closemenu="single" + selection-type="none" + hide-if-no-insertion-point="true"/> + <menuseparator id="placesContext_pasteSeparator"/> + <menuitem id="placesContext_createBookmark" + data-l10n-id="places-create-bookmark" + data-l10n-args='{"count":"1"}' + command="placesCmd_createBookmark" + node-type="link" + hide-if-node-type="link_bookmark"/> + <menuitem id="placesContext_showAllBookmarks" + data-l10n-id="places-manage-bookmarks" + command="Browser:ShowAllBookmarks" + ignore-item="true" + hidden="true"/> + +</menupopup> diff --git a/browser/components/places/content/treeView.js b/browser/components/places/content/treeView.js new file mode 100644 index 0000000000..657c908a13 --- /dev/null +++ b/browser/components/places/content/treeView.js @@ -0,0 +1,1870 @@ +/* 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/. */ + +/* import-globals-from controller.js */ + +/** + * This returns the key for any node/details object. + * + * @param {object} nodeOrDetails + * A node, or an object containing the following properties: + * - uri + * - time + * - itemId + * In case any of these is missing, an empty string will be returned. This is + * to facilitate easy delete statements which occur due to assignment to items in `this._rows`, + * since the item we are deleting may be undefined in the array. + * + * @returns {string} key or empty string. + */ +function makeNodeDetailsKey(nodeOrDetails) { + if ( + nodeOrDetails && + typeof nodeOrDetails === "object" && + "uri" in nodeOrDetails && + "time" in nodeOrDetails && + "itemId" in nodeOrDetails + ) { + return `${nodeOrDetails.uri}*${nodeOrDetails.time}*${nodeOrDetails.itemId}`; + } + return ""; +} + +function PlacesTreeView(aContainer) { + this._tree = null; + this._result = null; + this._selection = null; + this._rootNode = null; + this._rows = []; + this._flatList = aContainer.flatList; + this._nodeDetails = new Map(); + this._element = aContainer; + this._controller = aContainer._controller; +} + +PlacesTreeView.prototype = { + get wrappedJSObject() { + return this; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsITreeView", + "nsINavHistoryResultObserver", + "nsISupportsWeakReference", + ]), + + /** + * This is called once both the result and the tree are set. + */ + _finishInit: function PTV__finishInit() { + let selection = this.selection; + if (selection) { + selection.selectEventsSuppressed = true; + } + + if (!this._rootNode.containerOpen) { + // This triggers containerStateChanged which then builds the visible + // section. + this._rootNode.containerOpen = true; + } else { + this.invalidateContainer(this._rootNode); + } + + // "Activate" the sorting column and update commands. + this.sortingChanged(this._result.sortingMode); + + if (selection) { + selection.selectEventsSuppressed = false; + } + }, + + uninit() { + if (this._editingObservers) { + for (let observer of this._editingObservers.values()) { + observer.disconnect(); + } + delete this._editingObservers; + } + }, + + /** + * Plain Container: container result nodes which may never include sub + * hierarchies. + * + * When the rows array is constructed, we don't set the children of plain + * containers. Instead, we keep placeholders for these children. We then + * build these children lazily as the tree asks us for information about each + * row. Luckily, the tree doesn't ask about rows outside the visible area. + * + * It's guaranteed that all containers are listed in the rows + * elements array. It's also guaranteed that separators (if they're not + * filtered, see below) are listed in the visible elements array, because + * bookmark folders are never built lazily, as described above. + * + * @see {@link PlacesTreeView._getNodeForRow} and + * {@link PlacesTreeView._getRowForNode} for the actual magic. + * + * @param {object} aContainer + * A container result node. + * + * @returns {boolean} true if aContainer is a plain container, false otherwise. + */ + _isPlainContainer: function PTV__isPlainContainer(aContainer) { + // We don't know enough about non-query containers. + if (!(aContainer instanceof Ci.nsINavHistoryQueryResultNode)) { + return false; + } + + switch (aContainer.queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY: + return false; + } + + // If it's a folder, it's not a plain container. + let nodeType = aContainer.type; + return ( + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER && + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT + ); + }, + + /** + * Gets the row number for a given node. Assumes that the given node is + * visible (i.e. it's not an obsolete node). + * + * If aParentRow and aNodeIndex are passed and parent is a plain + * container, this method will just return a calculated row value, without + * making assumptions on existence of the node at that position. + * + * @param {object} aNode + * A result node. Do not pass an obsolete node, or any + * node which isn't supposed to be in the tree (e.g. separators in + * sorted trees). + * @param {boolean} [aForceBuild] + * See {@link _isPlainContainer}. + * If true, the row will be computed even if the node still isn't set + * in our rows array. + * @param {object} [aParentRow] + * The row of aNode's parent. Ignored for the root node. + * @param {number} [aNodeIndex] + * The index of aNode in its parent. Only used if aParentRow is + * set too. + * + * @throws if aNode is invisible. + * @returns {object} aNode's row if it's in the rows list or if aForceBuild is set, -1 + * otherwise. + */ + _getRowForNode: function PTV__getRowForNode( + aNode, + aForceBuild, + aParentRow, + aNodeIndex + ) { + if (aNode == this._rootNode) { + throw new Error("The root node is never visible"); + } + + // A node is removed form the view either if it has no parent or if its + // root-ancestor is not the root node (in which case that's the node + // for which nodeRemoved was called). + let ancestors = Array.from(PlacesUtils.nodeAncestors(aNode)); + if ( + !ancestors.length || + ancestors[ancestors.length - 1] != this._rootNode + ) { + throw new Error("Removed node passed to _getRowForNode"); + } + + // Ensure that the entire chain is open, otherwise that node is invisible. + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) { + throw new Error("Invisible node passed to _getRowForNode"); + } + } + + // Non-plain containers are initially built with their contents. + let parent = aNode.parent; + let parentIsPlain = this._isPlainContainer(parent); + if (!parentIsPlain) { + if (parent == this._rootNode) { + return this._rows.indexOf(aNode); + } + + return this._rows.indexOf(aNode, aParentRow); + } + + let row = -1; + let useNodeIndex = typeof aNodeIndex == "number"; + if (parent == this._rootNode) { + row = useNodeIndex ? aNodeIndex : this._rootNode.getChildIndex(aNode); + } else if (useNodeIndex && typeof aParentRow == "number") { + // If we have both the row of the parent node, and the node's index, we + // can avoid searching the rows array if the parent is a plain container. + row = aParentRow + aNodeIndex + 1; + } else { + // Look for the node in the nodes array. Start the search at the parent + // row. If the parent row isn't passed, we'll pass undefined to indexOf, + // which is fine. + row = this._rows.indexOf(aNode, aParentRow); + if (row == -1 && aForceBuild) { + let parentRow = + typeof aParentRow == "number" + ? aParentRow + : this._getRowForNode(parent); + row = parentRow + parent.getChildIndex(aNode) + 1; + } + } + + if (row != -1) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows[row] = aNode; + } + + return row; + }, + + /** + * Given a row, finds and returns the parent details of the associated node. + * + * @param {number} aChildRow + * Row number. + * @returns {Array} [parentNode, parentRow] + */ + _getParentByChildRow: function PTV__getParentByChildRow(aChildRow) { + let node = this._getNodeForRow(aChildRow); + let parent = node === null ? this._rootNode : node.parent; + + // The root node is never visible + if (parent == this._rootNode) { + return [this._rootNode, -1]; + } + + let parentRow = this._rows.lastIndexOf(parent, aChildRow - 1); + return [parent, parentRow]; + }, + + /** + * Gets the node at a given row. + * + * @param {number} aRow + * The index of the row to set + * @returns {object} + */ + _getNodeForRow: function PTV__getNodeForRow(aRow) { + if (aRow < 0) { + return null; + } + + let node = this._rows[aRow]; + if (node !== undefined) { + return node; + } + + // Find the nearest node. + let rowNode, row; + for (let i = aRow - 1; i >= 0 && rowNode === undefined; i--) { + rowNode = this._rows[i]; + row = i; + } + + // If there's no container prior to the given row, it's a child of + // the root node (remember: all containers are listed in the rows array). + if (!rowNode) { + let newNode = this._rootNode.getChild(aRow); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + } + + // Unset elements may exist only in plain containers. Thus, if the nearest + // node is a container, it's the row's parent, otherwise, it's a sibling. + if (rowNode instanceof Ci.nsINavHistoryContainerResultNode) { + let newNode = rowNode.getChild(aRow - row - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + } + + let [parent, parentRow] = this._getParentByChildRow(row); + let newNode = parent.getChild(aRow - parentRow - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return (this._rows[aRow] = newNode); + }, + + /** + * This takes a container and recursively appends our rows array per its + * contents. Assumes that the rows arrays has no rows for the given + * container. + * + * @param {object} aContainer + * A container result node. + * @param {object} aFirstChildRow + * The first row at which nodes may be inserted to the row array. + * In other words, that's aContainer's row + 1. + * @param {Array} aToOpen + * An array of containers to open once the build is done (out param) + * + * @returns {number} the number of rows which were inserted. + */ + _buildVisibleSection: function PTV__buildVisibleSection( + aContainer, + aFirstChildRow, + aToOpen + ) { + // There's nothing to do if the container is closed. + if (!aContainer.containerOpen) { + return 0; + } + + // Inserting the new elements into the rows array in one shot (by + // Array.prototype.concat) is faster than resizing the array (by splice) on each loop + // iteration. + let cc = aContainer.childCount; + let newElements = new Array(cc); + // We need to clean up the node details from aFirstChildRow + 1 to the end of rows. + for (let i = aFirstChildRow + 1; i < this._rows.length; i++) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[i])); + } + this._rows = this._rows + .splice(0, aFirstChildRow) + .concat(newElements, this._rows); + + if (this._isPlainContainer(aContainer)) { + return cc; + } + + let sortingMode = this._result.sortingMode; + + let rowsInserted = 0; + for (let i = 0; i < cc; i++) { + let curChild = aContainer.getChild(i); + let curChildType = curChild.type; + + let row = aFirstChildRow + rowsInserted; + + // Don't display separators when sorted. + if (curChildType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + if (sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // Remove the element for the filtered separator. + // Notice that the rows array was initially resized to include all + // children. + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._rows.splice(row, 1); + continue; + } + } + + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(curChild), curChild); + this._rows[row] = curChild; + rowsInserted++; + + // Recursively do containers. + if ( + !this._flatList && + curChild instanceof Ci.nsINavHistoryContainerResultNode + ) { + let uri = curChild.uri; + let isopen = false; + + if (uri) { + let val = Services.xulStore.getValue( + document.documentURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open" + ); + isopen = val == "true"; + } + + if (isopen != curChild.containerOpen) { + aToOpen.push(curChild); + } else if (curChild.containerOpen && curChild.childCount > 0) { + rowsInserted += this._buildVisibleSection(curChild, row + 1, aToOpen); + } + } + } + + return rowsInserted; + }, + + /** + * This counts how many rows a node takes in the tree. For containers it + * will count the node itself plus any child node following it. + * + * @param {number} aNodeRow + * The row of the node to count + * @returns {number} + */ + _countVisibleRowsForNodeAtRow: function PTV__countVisibleRowsForNodeAtRow( + aNodeRow + ) { + let node = this._rows[aNodeRow]; + + // If it's not listed yet, we know that it's a leaf node (instanceof also + // null-checks). + if (!(node instanceof Ci.nsINavHistoryContainerResultNode)) { + return 1; + } + + let outerLevel = node.indentLevel; + for (let i = aNodeRow + 1; i < this._rows.length; i++) { + let rowNode = this._rows[i]; + if (rowNode && rowNode.indentLevel <= outerLevel) { + return i - aNodeRow; + } + } + + // This node plus its children take up the bottom of the list. + return this._rows.length - aNodeRow; + }, + + _getSelectedNodesInRange: function PTV__getSelectedNodesInRange( + aFirstRow, + aLastRow + ) { + let selection = this.selection; + let rc = selection.getRangeCount(); + if (rc == 0) { + return []; + } + + // The visible-area borders are needed for checking whether a + // selected row is also visible. + let firstVisibleRow = this._tree.getFirstVisibleRow(); + let lastVisibleRow = this._tree.getLastVisibleRow(); + + let nodesInfo = []; + for (let rangeIndex = 0; rangeIndex < rc; rangeIndex++) { + let min = {}, + max = {}; + selection.getRangeAt(rangeIndex, min, max); + + // If this range does not overlap the replaced chunk, we don't need to + // persist the selection. + if (max.value < aFirstRow || min.value > aLastRow) { + continue; + } + + let firstRow = Math.max(min.value, aFirstRow); + let lastRow = Math.min(max.value, aLastRow); + for (let i = firstRow; i <= lastRow; i++) { + nodesInfo.push({ + node: this._rows[i], + oldRow: i, + wasVisible: i >= firstVisibleRow && i <= lastVisibleRow, + }); + } + } + + return nodesInfo; + }, + + /** + * Tries to find an equivalent node for a node which was removed. We first + * look for the original node, in case it was just relocated. Then, if we + * that node was not found, we look for a node that has the same itemId, uri + * and time values. + * + * @param {object} aUpdatedContainer + * An ancestor of the node which was removed. It does not have to be + * its direct parent. + * @param {object} aOldNode + * The node which was removed. + * + * @returns {number} the row number of an equivalent node for aOldOne, if one was + * found, -1 otherwise. + */ + _getNewRowForRemovedNode: function PTV__getNewRowForRemovedNode( + aUpdatedContainer, + aOldNode + ) { + let parent = aOldNode.parent; + if (parent) { + // If the node's parent is still set, the node is not obsolete + // and we should just find out its new position. + // However, if any of the node's ancestor is closed, the node is + // invisible. + let ancestors = PlacesUtils.nodeAncestors(aOldNode); + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) { + return -1; + } + } + + return this._getRowForNode(aOldNode, true); + } + + // There's a broken edge case here. + // If a visit appears in two queries, and the second one was + // the old node, we'll select the first one after refresh. There's + // nothing we could do about that, because aOldNode.parent is + // gone by the time invalidateContainer is called. + let newNode = this._nodeDetails.get(makeNodeDetailsKey(aOldNode)); + + if (!newNode) { + return -1; + } + + return this._getRowForNode(newNode, true); + }, + + /** + * Restores a given selection state as near as possible to the original + * selection state. + * + * @param {Array} aNodesInfo + * The persisted selection state as returned by + * _getSelectedNodesInRange. + * @param {object} aUpdatedContainer + * The container which was updated. + */ + _restoreSelection: function PTV__restoreSelection( + aNodesInfo, + aUpdatedContainer + ) { + if (!aNodesInfo.length) { + return; + } + + let selection = this.selection; + + // Attempt to ensure that previously-visible selection will be visible + // if it's re-selected. However, we can only ensure that for one row. + let scrollToRow = -1; + for (let i = 0; i < aNodesInfo.length; i++) { + let nodeInfo = aNodesInfo[i]; + let row = this._getNewRowForRemovedNode(aUpdatedContainer, nodeInfo.node); + // Select the found node, if any. + if (row != -1) { + selection.rangedSelect(row, row, true); + if (nodeInfo.wasVisible && scrollToRow == -1) { + scrollToRow = row; + } + } + } + + // If only one node was previously selected and there's no selection now, + // select the node at its old row, if any. + if (aNodesInfo.length == 1 && selection.count == 0) { + let row = Math.min(aNodesInfo[0].oldRow, this._rows.length - 1); + if (row != -1) { + selection.rangedSelect(row, row, true); + if (aNodesInfo[0].wasVisible && scrollToRow == -1) { + scrollToRow = aNodesInfo[0].oldRow; + } + } + } + + if (scrollToRow != -1) { + this._tree.ensureRowIsVisible(scrollToRow); + } + }, + + _convertPRTimeToString: function PTV__convertPRTimeToString(aTime) { + const MS_PER_MINUTE = 60000; + const MS_PER_DAY = 86400000; + let timeMs = aTime / 1000; // PRTime is in microseconds + + // Date is calculated starting from midnight, so the modulo with a day are + // milliseconds from today's midnight. + // getTimezoneOffset corrects that based on local time, notice midnight + // can have a different offset during DST-change days. + let dateObj = new Date(); + let now = dateObj.getTime() - dateObj.getTimezoneOffset() * MS_PER_MINUTE; + let midnight = now - (now % MS_PER_DAY); + midnight += new Date(midnight).getTimezoneOffset() * MS_PER_MINUTE; + + let timeObj = new Date(timeMs); + return timeMs >= midnight + ? this._todayFormatter.format(timeObj) + : this._dateFormatter.format(timeObj); + }, + + // We use a different formatter for times within the current day, + // so we cache both a "today" formatter and a general date formatter. + __todayFormatter: null, + get _todayFormatter() { + if (!this.__todayFormatter) { + const dtOptions = { timeStyle: "short" }; + this.__todayFormatter = new Services.intl.DateTimeFormat( + undefined, + dtOptions + ); + } + return this.__todayFormatter; + }, + + __dateFormatter: null, + get _dateFormatter() { + if (!this.__dateFormatter) { + const dtOptions = { + dateStyle: "short", + timeStyle: "short", + }; + this.__dateFormatter = new Services.intl.DateTimeFormat( + undefined, + dtOptions + ); + } + return this.__dateFormatter; + }, + + COLUMN_TYPE_UNKNOWN: 0, + COLUMN_TYPE_TITLE: 1, + COLUMN_TYPE_URI: 2, + COLUMN_TYPE_DATE: 3, + COLUMN_TYPE_VISITCOUNT: 4, + COLUMN_TYPE_DATEADDED: 5, + COLUMN_TYPE_LASTMODIFIED: 6, + COLUMN_TYPE_TAGS: 7, + + _getColumnType: function PTV__getColumnType(aColumn) { + let columnType = aColumn.element.getAttribute("anonid") || aColumn.id; + + switch (columnType) { + case "title": + return this.COLUMN_TYPE_TITLE; + case "url": + return this.COLUMN_TYPE_URI; + case "date": + return this.COLUMN_TYPE_DATE; + case "visitCount": + return this.COLUMN_TYPE_VISITCOUNT; + case "dateAdded": + return this.COLUMN_TYPE_DATEADDED; + case "lastModified": + return this.COLUMN_TYPE_LASTMODIFIED; + case "tags": + return this.COLUMN_TYPE_TAGS; + } + return this.COLUMN_TYPE_UNKNOWN; + }, + + _sortTypeToColumnType: function PTV__sortTypeToColumnType(aSortType) { + switch (aSortType) { + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING: + return [this.COLUMN_TYPE_TITLE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING: + return [this.COLUMN_TYPE_TITLE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING: + return [this.COLUMN_TYPE_DATE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING: + return [this.COLUMN_TYPE_DATE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING: + return [this.COLUMN_TYPE_URI, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING: + return [this.COLUMN_TYPE_URI, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING: + return [this.COLUMN_TYPE_DATEADDED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING: + return [this.COLUMN_TYPE_DATEADDED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING: + return [this.COLUMN_TYPE_TAGS, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING: + return [this.COLUMN_TYPE_TAGS, true]; + } + return [this.COLUMN_TYPE_UNKNOWN, false]; + }, + + // nsINavHistoryResultObserver + nodeInserted: function PTV_nodeInserted(aParentNode, aNode, aNewIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + let parentRow; + if (aParentNode != this._rootNode) { + parentRow = this._getRowForNode(aParentNode); + + // Update parent when inserting the first item, since twisty has changed. + if (aParentNode.childCount == 1) { + this._tree.invalidateRow(parentRow); + } + } + + // Compute the new row number of the node. + let row = -1; + let cc = aParentNode.childCount; + if (aNewIndex == 0 || this._isPlainContainer(aParentNode) || cc == 0) { + // We don't need to worry about sub hierarchies of the parent node + // if it's a plain container, or if the new node is its first child. + if (aParentNode == this._rootNode) { + row = aNewIndex; + } else { + row = parentRow + aNewIndex + 1; + } + } else { + // Here, we try to find the next visible element in the child list so we + // can set the new visible index to be right before that. Note that we + // have to search down instead of up, because some siblings could have + // children themselves that would be in the way. + let separatorsAreHidden = + PlacesUtils.nodeIsSeparator(aNode) && this.isSorted(); + for (let i = aNewIndex + 1; i < cc; i++) { + let node = aParentNode.getChild(i); + if (!separatorsAreHidden || PlacesUtils.nodeIsSeparator(node)) { + // The children have not been shifted so the next item will have what + // should be our index. + row = this._getRowForNode(node, false, parentRow, i); + break; + } + } + if (row < 0) { + // At the end of the child list without finding a visible sibling. This + // is a little harder because we don't know how many rows the last item + // in our list takes up (it could be a container with many children). + let prevChild = aParentNode.getChild(aNewIndex - 1); + let prevIndex = this._getRowForNode( + prevChild, + false, + parentRow, + aNewIndex - 1 + ); + row = prevIndex + this._countVisibleRowsForNodeAtRow(prevIndex); + } + } + + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows.splice(row, 0, aNode); + this._tree.rowCountChanged(row, 1); + + if ( + PlacesUtils.nodeIsContainer(aNode) && + PlacesUtils.asContainer(aNode).containerOpen + ) { + this.invalidateContainer(aNode); + } + }, + + /** + * THIS FUNCTION DOES NOT HANDLE cases where a collapsed node is being + * removed but the node it is collapsed with is not being removed (this then + * just swap out the removee with its collapsing partner). The only time + * when we really remove things is when deleting URIs, which will apply to + * all collapsees. This function is called sometimes when resorting items. + * However, we won't do this when sorted by date because dates will never + * change for visits, and date sorting is the only time things are collapsed. + * + * @param {object} aParentNode + * The parent node of the node being removed. + * @param {object} aNode + * The node to remove from the tree. + * @param {number} aOldIndex + * The old index of the node in the parent. + */ + nodeRemoved: function PTV_nodeRemoved(aParentNode, aNode, aOldIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // XXX bug 517701: We don't know what to do when the root node is removed. + if (aNode == this._rootNode) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + let parentRow = + aParentNode == this._rootNode + ? undefined + : this._getRowForNode(aParentNode, true); + let oldRow = this._getRowForNode(aNode, true, parentRow, aOldIndex); + if (oldRow < 0) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // If the node was exclusively selected, the node next to it will be + // selected. + let selectNext = false; + let selection = this.selection; + if (selection.getRangeCount() == 1) { + let min = {}, + max = {}; + selection.getRangeAt(0, min, max); + if (min.value == max.value && this.nodeForTreeIndex(min.value) == aNode) { + selectNext = true; + } + } + + // Remove the node and its children, if any. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Redraw the parent if its twisty state has changed. + if (aParentNode != this._rootNode && !aParentNode.hasChildren) { + parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Restore selection if the node was exclusively selected. + if (!selectNext) { + return; + } + + // Restore selection. + let rowToSelect = Math.min(oldRow, this._rows.length - 1); + if (rowToSelect != -1) { + this.selection.rangedSelect(rowToSelect, rowToSelect, true); + } + }, + + nodeMoved: function PTV_nodeMoved( + aNode, + aOldParent, + aOldIndex, + aNewParent, + aNewIndex + ) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) { + return; + } + + // Note that at this point the node has already been moved by the backend, + // so we must give hints to _getRowForNode to get the old row position. + let oldParentRow = + aOldParent == this._rootNode + ? undefined + : this._getRowForNode(aOldParent, true); + let oldRow = this._getRowForNode(aNode, true, oldParentRow, aOldIndex); + if (oldRow < 0) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // If this node is a container it could take up more than one row. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + + // Persist selection state. + let nodesToReselect = this._getSelectedNodesInRange( + oldRow, + oldRow + count - 1 + ); + if (nodesToReselect.length) { + this.selection.selectEventsSuppressed = true; + } + + // Redraw the parent if its twisty state has changed. + if (aOldParent != this._rootNode && !aOldParent.hasChildren) { + let parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Remove node and its children, if any, from the old position. + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Insert the node into the new position. + this.nodeInserted(aNewParent, aNode, aNewIndex); + + // Restore selection. + if (nodesToReselect.length) { + this._restoreSelection(nodesToReselect, aNewParent); + this.selection.selectEventsSuppressed = false; + } + }, + + _invalidateCellValue: function PTV__invalidateCellValue(aNode, aColumnType) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) { + return; + } + + // Nothing to do for the root node. + if (aNode == this._rootNode) { + return; + } + + let row = this._getRowForNode(aNode); + if (row == -1) { + return; + } + + let column = this._findColumnByType(aColumnType); + if (column && !column.element.hidden) { + if (aColumnType == this.COLUMN_TYPE_TITLE) { + this._tree.removeImageCacheEntry(row, column); + } + this._tree.invalidateCell(row, column); + } + + // Last modified time is altered for almost all node changes. + if (aColumnType != this.COLUMN_TYPE_LASTMODIFIED) { + let lastModifiedColumn = this._findColumnByType( + this.COLUMN_TYPE_LASTMODIFIED + ); + if (lastModifiedColumn && !lastModifiedColumn.hidden) { + this._tree.invalidateCell(row, lastModifiedColumn); + } + } + }, + + nodeTitleChanged: function PTV_nodeTitleChanged(aNode, aNewTitle) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeURIChanged: function PTV_nodeURIChanged(aNode, aOldURI) { + this._nodeDetails.delete( + makeNodeDetailsKey({ + uri: aOldURI, + itemId: aNode.itemId, + time: aNode.time, + }) + ); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_URI); + }, + + nodeIconChanged: function PTV_nodeIconChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeHistoryDetailsChanged: function PTV_nodeHistoryDetailsChanged( + aNode, + aOldVisitDate, + aOldVisitCount + ) { + this._nodeDetails.delete( + makeNodeDetailsKey({ + uri: aNode.uri, + itemId: aNode.itemId, + time: aOldVisitDate, + }) + ); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATE); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_VISITCOUNT); + }, + + nodeTagsChanged: function PTV_nodeTagsChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TAGS); + }, + + nodeKeywordChanged(aNode, aNewKeyword) {}, + + nodeDateAddedChanged: function PTV_nodeDateAddedChanged(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATEADDED); + }, + + nodeLastModifiedChanged: function PTV_nodeLastModifiedChanged( + aNode, + aNewValue + ) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_LASTMODIFIED); + }, + + containerStateChanged: function PTV_containerStateChanged( + aNode, + aOldState, + aNewState + ) { + this.invalidateContainer(aNode); + }, + + invalidateContainer: function PTV_invalidateContainer(aContainer) { + console.assert(this._result, "Need to have a result to update"); + if (!this._tree) { + return; + } + + // If we are currently editing, don't invalidate the container until we + // finish. + if (this._tree.getAttribute("editing")) { + if (!this._editingObservers) { + this._editingObservers = new Map(); + } + if (!this._editingObservers.has(aContainer)) { + let mutationObserver = new MutationObserver(() => { + Services.tm.dispatchToMainThread(() => + this.invalidateContainer(aContainer) + ); + let observer = this._editingObservers.get(aContainer); + observer.disconnect(); + this._editingObservers.delete(aContainer); + }); + + mutationObserver.observe(this._tree, { + attributes: true, + attributeFilter: ["editing"], + }); + + this._editingObservers.set(aContainer, mutationObserver); + } + return; + } + + let startReplacement, replaceCount; + if (aContainer == this._rootNode) { + startReplacement = 0; + replaceCount = this._rows.length; + + // If the root node is now closed, the tree is empty. + if (!this._rootNode.containerOpen) { + this._nodeDetails.clear(); + this._rows = []; + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + return; + } + } else { + // Update the twisty state. + let row = this._getRowForNode(aContainer); + this._tree.invalidateRow(row); + + // We don't replace the container node itself, so we should decrease the + // replaceCount by 1. + startReplacement = row + 1; + replaceCount = this._countVisibleRowsForNodeAtRow(row) - 1; + } + + // Persist selection state. + let nodesToReselect = this._getSelectedNodesInRange( + startReplacement, + startReplacement + replaceCount + ); + + // Now update the number of elements. + this.selection.selectEventsSuppressed = true; + + // First remove the old elements + for (let splicedNode of this._rows.splice(startReplacement, replaceCount)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + + // If the container is now closed, we're done. + if (!aContainer.containerOpen) { + let oldSelectionCount = this.selection.count; + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + // Select the row next to the closed container if any of its + // children were selected, and nothing else is selected. + if ( + nodesToReselect.length && + nodesToReselect.length == oldSelectionCount + ) { + this.selection.rangedSelect(startReplacement, startReplacement, true); + this._tree.ensureRowIsVisible(startReplacement); + } + + this.selection.selectEventsSuppressed = false; + return; + } + + // Otherwise, start a batch first. + this._tree.beginUpdateBatch(); + if (replaceCount) { + this._tree.rowCountChanged(startReplacement, -replaceCount); + } + + let toOpenElements = []; + let elementsAddedCount = this._buildVisibleSection( + aContainer, + startReplacement, + toOpenElements + ); + if (elementsAddedCount) { + this._tree.rowCountChanged(startReplacement, elementsAddedCount); + } + + if (!this._flatList) { + // Now, open any containers that were persisted. + for (let i = 0; i < toOpenElements.length; i++) { + let item = toOpenElements[i]; + let parent = item.parent; + + // Avoid recursively opening containers. + while (parent) { + if (parent.uri == item.uri) { + break; + } + parent = parent.parent; + } + + // If we don't have a parent, we made it all the way to the root + // and didn't find a match, so we can open our item. + if (!parent && !item.containerOpen) { + item.containerOpen = true; + } + } + } + + this._tree.endUpdateBatch(); + + // Restore selection. + this._restoreSelection(nodesToReselect, aContainer); + this.selection.selectEventsSuppressed = false; + }, + + _columns: [], + _findColumnByType: function PTV__findColumnByType(aColumnType) { + if (this._columns[aColumnType]) { + return this._columns[aColumnType]; + } + + let columns = this._tree.columns; + let colCount = columns.count; + for (let i = 0; i < colCount; i++) { + let column = columns.getColumnAt(i); + let columnType = this._getColumnType(column); + this._columns[columnType] = column; + if (columnType == aColumnType) { + return column; + } + } + + // That's completely valid. Most of our trees actually include just the + // title column. + return null; + }, + + sortingChanged: function PTV__sortingChanged(aSortingMode) { + if (!this._tree || !this._result) { + return; + } + + // Depending on the sort mode, certain commands may be disabled. + window.updateCommands("sort"); + + let columns = this._tree.columns; + + // Clear old sorting indicator. + let sortedColumn = columns.getSortedColumn(); + if (sortedColumn) { + sortedColumn.element.removeAttribute("sortDirection"); + } + + // Set new sorting indicator by looking through all columns for ours. + if (aSortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + return; + } + + let [desiredColumn, desiredIsDescending] = + this._sortTypeToColumnType(aSortingMode); + let column = this._findColumnByType(desiredColumn); + if (column) { + let sortDir = desiredIsDescending ? "descending" : "ascending"; + column.element.setAttribute("sortDirection", sortDir); + } + }, + + _inBatchMode: false, + batching: function PTV__batching(aToggleMode) { + if (this._inBatchMode != aToggleMode) { + this._inBatchMode = this.selection.selectEventsSuppressed = aToggleMode; + if (this._inBatchMode) { + this._tree.beginUpdateBatch(); + } else { + this._tree.endUpdateBatch(); + } + } + }, + + get result() { + return this._result; + }, + set result(val) { + if (this._result) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + + if (val) { + this._result = val; + this._rootNode = this._result.root; + this._cellProperties = new Map(); + this._cuttingNodes = new Set(); + } else if (this._result) { + delete this._result; + delete this._rootNode; + delete this._cellProperties; + delete this._cuttingNodes; + } + + // If the tree is not set yet, setTree will call finishInit. + if (this._tree && val) { + this._finishInit(); + } + }, + + /** + * This allows you to get at the real node for a given row index. This is + * only valid when a tree is attached. + * + * @param {Integer} aIndex The index for the node to get. + * @returns {Ci.nsINavHistoryResultNode} The node. + * @throws Cr.NS_ERROR_INVALID_ARG if the index is greater than the number of + * rows. + */ + nodeForTreeIndex(aIndex) { + if (aIndex > this._rows.length) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + return this._getNodeForRow(aIndex); + }, + + /** + * Reverse of nodeForTreeIndex, returns the row index for a given result node. + * The node should be part of the tree. + * + * @param {Ci.nsINavHistoryResultNode} aNode The node to look for in the tree. + * @returns {Integer} The found index, or -1 if the item is not visible or not found. + */ + treeIndexForNode(aNode) { + // The API allows passing invisible nodes. + try { + return this._getRowForNode(aNode, true); + } catch (ex) {} + + return -1; + }, + + // nsITreeView + get rowCount() { + return this._rows.length; + }, + get selection() { + return this._selection; + }, + set selection(val) { + this._selection = val; + }, + + getRowProperties() { + return ""; + }, + + getCellProperties: function PTV_getCellProperties(aRow, aColumn) { + // for anonid-trees, we need to add the column-type manually + var props = ""; + let columnType = aColumn.element.getAttribute("anonid"); + if (columnType) { + props += columnType; + } else { + columnType = aColumn.id; + } + + // Set the "ltr" property on url cells + if (columnType == "url") { + props += " ltr"; + } + + if (columnType != "title") { + return props; + } + + let node = this._getNodeForRow(aRow); + + if (this._cuttingNodes.has(node)) { + props += " cutting"; + } + + let properties = this._cellProperties.get(node); + if (properties === undefined) { + properties = ""; + let itemId = node.itemId; + let nodeType = node.type; + if (PlacesUtils.containerTypes.includes(nodeType)) { + if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + properties += " query"; + if (PlacesUtils.nodeIsTagQuery(node)) { + properties += " tagContainer"; + } else if (PlacesUtils.nodeIsDay(node)) { + properties += " dayContainer"; + } else if (PlacesUtils.nodeIsHost(node)) { + properties += " hostContainer"; + } + } + + if (itemId == -1) { + switch (node.bookmarkGuid) { + case PlacesUtils.bookmarks.virtualToolbarGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.toolbarGuid}`; + break; + case PlacesUtils.bookmarks.virtualMenuGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.menuGuid}`; + break; + case PlacesUtils.bookmarks.virtualUnfiledGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.unfiledGuid}`; + break; + case PlacesUtils.virtualAllBookmarksGuid: + case PlacesUtils.virtualHistoryGuid: + case PlacesUtils.virtualDownloadsGuid: + case PlacesUtils.virtualTagsGuid: + properties += ` OrganizerQuery_${node.bookmarkGuid}`; + break; + } + } + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + properties += " separator"; + } else if (PlacesUtils.nodeIsURI(node)) { + properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri); + } + + this._cellProperties.set(node, properties); + } + + return props + " " + properties; + }, + + getColumnProperties(aColumn) { + return ""; + }, + + isContainer: function PTV_isContainer(aRow) { + // Only leaf nodes aren't listed in the rows array. + let node = this._rows[aRow]; + if (node === undefined || !PlacesUtils.nodeIsContainer(node)) { + return false; + } + + // Flat-lists may ignore expandQueries and other query options when + // they are asked to open a container. + if (this._flatList) { + return true; + } + + // Treat non-expandable childless queries as non-containers, unless they + // are tags. + if (PlacesUtils.nodeIsQuery(node) && !PlacesUtils.nodeIsTagQuery(node)) { + return ( + PlacesUtils.asQuery(node).queryOptions.expandQueries || node.hasChildren + ); + } + return true; + }, + + isContainerOpen: function PTV_isContainerOpen(aRow) { + if (this._flatList) { + return false; + } + + // All containers are listed in the rows array. + return this._rows[aRow].containerOpen; + }, + + isContainerEmpty: function PTV_isContainerEmpty(aRow) { + if (this._flatList) { + return true; + } + + // All containers are listed in the rows array. + return !this._rows[aRow].hasChildren; + }, + + isSeparator: function PTV_isSeparator(aRow) { + // All separators are listed in the rows array. + let node = this._rows[aRow]; + return node && PlacesUtils.nodeIsSeparator(node); + }, + + isSorted: function PTV_isSorted() { + return ( + this._result.sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + }, + + canDrop: function PTV_canDrop(aRow, aOrientation, aDataTransfer) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + if (this._controller.disableUserActions) { + return false; + } + + // Drop position into a sorted treeview would be wrong. + if (this.isSorted()) { + return false; + } + + let ip = this._getInsertionPoint(aRow, aOrientation); + return ip && PlacesControllerDragHelper.canDrop(ip, aDataTransfer); + }, + + _getInsertionPoint: function PTV__getInsertionPoint(index, orientation) { + let container = this._result.root; + let dropNearNode = null; + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + let lastSelected = this.nodeForTreeIndex(index); + if (this.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } else if ( + lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren + ) { + // If the last selected node is an open container and the user is + // trying to drag into it as a first node, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // During its Drag & Drop operation, the tree code closes-and-opens + // containers very often (part of the XUL "spring-loaded folders" + // implementation). And in certain cases, we may reach a closed + // container here. However, we can simply bail out when this happens, + // because we would then be back here in less than a millisecond, when + // the container had been reopened. + if (!container || !container.containerOpen) { + return null; + } + + // Don't show an insertion point if the index is contained + // within the selection and drag source is the same + if ( + this._element.isDragSource && + this._element.view.selection.isSelected(index) + ) { + return null; + } + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion. + if (this._controller.disallowInsertion(container)) { + return null; + } + + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if ( + queryOptions.sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ) { + // If we are within a sorted view, insert at the end. + index = -1; + } else if (queryOptions.excludeItems || queryOptions.excludeQueries) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearNode = lastSelected; + } else { + let lastSelectedIndex = container.getChildIndex(lastSelected); + index = + orientation == Ci.nsITreeView.DROP_BEFORE + ? lastSelectedIndex + : lastSelectedIndex + 1; + } + } + } + + if (this._controller.disallowInsertion(container)) { + return null; + } + + let tagName = PlacesUtils.nodeIsTagQuery(container) + ? PlacesUtils.asQuery(container).query.tags[0] + : null; + + return new PlacesInsertionPoint({ + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, + orientation, + tagName, + dropNearNode, + }); + }, + + async drop(aRow, aOrientation, aDataTransfer) { + if (this._controller.disableUserActions) { + return; + } + + // We are responsible for translating the |index| and |orientation| + // parameters into a container id and index within the container, + // since this information is specific to the tree view. + let ip = this._getInsertionPoint(aRow, aOrientation); + if (ip) { + try { + await PlacesControllerDragHelper.onDrop(ip, aDataTransfer, this._tree); + } catch (ex) { + console.error(ex); + } finally { + // We should only clear the drop target once + // the onDrop is complete, as it is an async function. + PlacesControllerDragHelper.currentDropTarget = null; + } + } + }, + + getParentIndex: function PTV_getParentIndex(aRow) { + let [, parentRow] = this._getParentByChildRow(aRow); + return parentRow; + }, + + hasNextSibling: function PTV_hasNextSibling(aRow, aAfterIndex) { + if (aRow == this._rows.length - 1) { + // The last row has no sibling. + return false; + } + + let node = this._rows[aRow]; + if (node === undefined || this._isPlainContainer(node.parent)) { + // The node is a child of a plain container. + // If the next row is either unset or has the same parent, + // it's a sibling. + let nextNode = this._rows[aRow + 1]; + return nextNode == undefined || nextNode.parent == node.parent; + } + + let thisLevel = node.indentLevel; + for (let i = aAfterIndex + 1; i < this._rows.length; ++i) { + let rowNode = this._getNodeForRow(i); + let nextLevel = rowNode.indentLevel; + if (nextLevel == thisLevel) { + return true; + } + if (nextLevel < thisLevel) { + break; + } + } + + return false; + }, + + getLevel(aRow) { + return this._getNodeForRow(aRow).indentLevel; + }, + + getImageSrc: function PTV_getImageSrc(aRow, aColumn) { + // Only the title column has an image. + if (this._getColumnType(aColumn) != this.COLUMN_TYPE_TITLE) { + return ""; + } + + let node = this._getNodeForRow(aRow); + return node.icon; + }, + + getCellValue(aRow, aColumn) {}, + + getCellText: function PTV_getCellText(aRow, aColumn) { + let node = this._getNodeForRow(aRow); + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + // normally, this is just the title, but we don't want empty items in + // the tree view so return a special string if the title is empty. + // Do it here so that callers can still get at the 0 length title + // if they go through the "result" API. + if (PlacesUtils.nodeIsSeparator(node)) { + return ""; + } + return PlacesUIUtils.getBestTitle(node, true); + case this.COLUMN_TYPE_TAGS: + return node.tags; + case this.COLUMN_TYPE_URI: + if (PlacesUtils.nodeIsURI(node)) { + return node.uri; + } + return ""; + case this.COLUMN_TYPE_DATE: + let nodeTime = node.time; + if (nodeTime == 0 || !PlacesUtils.nodeIsURI(node)) { + // hosts and days shouldn't have a value for the date column. + // Actually, you could argue this point, but looking at the + // results, seeing the most recently visited date is not what + // I expect, and gives me no information I know how to use. + // Only show this for URI-based items. + return ""; + } + + return this._convertPRTimeToString(nodeTime); + case this.COLUMN_TYPE_VISITCOUNT: + return node.accessCount; + case this.COLUMN_TYPE_DATEADDED: + if (node.dateAdded) { + return this._convertPRTimeToString(node.dateAdded); + } + return ""; + case this.COLUMN_TYPE_LASTMODIFIED: + if (node.lastModified) { + return this._convertPRTimeToString(node.lastModified); + } + return ""; + } + return ""; + }, + + setTree: function PTV_setTree(aTree) { + // If we are replacing the tree during a batch, there is a concrete risk + // that the treeView goes out of sync, thus it's safer to end the batch now. + // This is a no-op if we are not batching. + this.batching(false); + + let hasOldTree = this._tree != null; + this._tree = aTree; + + if (this._result) { + if (hasOldTree) { + // detach from result when we are detaching from the tree. + // This breaks the reference cycle between us and the result. + if (!aTree) { + // Balances the addObserver call from the load method in tree.xml + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + } + if (aTree) { + this._finishInit(); + } + } + }, + + toggleOpenState: function PTV_toggleOpenState(aRow) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + let node = this._rows[aRow]; + if (this._flatList && this._element) { + let event = new CustomEvent("onOpenFlatContainer", { detail: node }); + this._element.dispatchEvent(event); + return; + } + + let uri = node.uri; + + if (uri) { + let docURI = document.documentURI; + + if (node.containerOpen) { + Services.xulStore.removeValue( + docURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open" + ); + } else { + Services.xulStore.setValue( + docURI, + PlacesUIUtils.obfuscateUrlForXulStore(uri), + "open", + "true" + ); + } + } + + node.containerOpen = !node.containerOpen; + }, + + cycleHeader: function PTV_cycleHeader(aColumn) { + if (!this._result) { + throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED); + } + + // Sometimes you want a tri-state sorting, and sometimes you don't. This + // rule allows tri-state sorting when the root node is a folder. This will + // catch the most common cases. When you are looking at folders, you want + // the third state to reset the sorting to the natural bookmark order. When + // you are looking at history, that third state has no meaning so we try + // to disallow it. + // + // The problem occurs when you have a query that results in bookmark + // folders. One example of this is the subscriptions view. In these cases, + // this rule doesn't allow you to sort those sub-folders by their natural + // order. + let allowTriState = PlacesUtils.nodeIsFolder(this._result.root); + + let oldSort = this._result.sortingMode; + let newSort; + const NHQO = Ci.nsINavHistoryQueryOptions; + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + if (oldSort == NHQO.SORT_BY_TITLE_ASCENDING) { + newSort = NHQO.SORT_BY_TITLE_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_TITLE_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_TITLE_ASCENDING; + } + + break; + case this.COLUMN_TYPE_URI: + if (oldSort == NHQO.SORT_BY_URI_ASCENDING) { + newSort = NHQO.SORT_BY_URI_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_URI_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_URI_ASCENDING; + } + + break; + case this.COLUMN_TYPE_DATE: + if (oldSort == NHQO.SORT_BY_DATE_ASCENDING) { + newSort = NHQO.SORT_BY_DATE_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_DATE_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_DATE_ASCENDING; + } + + break; + case this.COLUMN_TYPE_VISITCOUNT: + // visit count default is unusual because we sort by descending + // by default because you are most likely to be looking for + // highly visited sites when you click it + if (oldSort == NHQO.SORT_BY_VISITCOUNT_DESCENDING) { + newSort = NHQO.SORT_BY_VISITCOUNT_ASCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_VISITCOUNT_ASCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + } + + break; + case this.COLUMN_TYPE_DATEADDED: + if (oldSort == NHQO.SORT_BY_DATEADDED_ASCENDING) { + newSort = NHQO.SORT_BY_DATEADDED_DESCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_DATEADDED_DESCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_DATEADDED_ASCENDING; + } + + break; + case this.COLUMN_TYPE_LASTMODIFIED: + if (oldSort == NHQO.SORT_BY_LASTMODIFIED_ASCENDING) { + newSort = NHQO.SORT_BY_LASTMODIFIED_DESCENDING; + } else if ( + allowTriState && + oldSort == NHQO.SORT_BY_LASTMODIFIED_DESCENDING + ) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_LASTMODIFIED_ASCENDING; + } + + break; + case this.COLUMN_TYPE_TAGS: + if (oldSort == NHQO.SORT_BY_TAGS_ASCENDING) { + newSort = NHQO.SORT_BY_TAGS_DESCENDING; + } else if (allowTriState && oldSort == NHQO.SORT_BY_TAGS_DESCENDING) { + newSort = NHQO.SORT_BY_NONE; + } else { + newSort = NHQO.SORT_BY_TAGS_ASCENDING; + } + + break; + default: + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + this._result.sortingMode = newSort; + }, + + isEditable: function PTV_isEditable(aRow, aColumn) { + // At this point we only support editing the title field. + if (aColumn.index != 0) { + return false; + } + + let node = this._rows[aRow]; + if (!node) { + console.error("isEditable called for an unbuilt row."); + return false; + } + let itemGuid = node.bookmarkGuid; + + // Only bookmark-nodes are editable. + if (!itemGuid) { + return false; + } + + // The following items are also not editable, even though they are bookmark + // items. + // * places-roots + // * the left pane special folders and queries (those are place: uri + // bookmarks) + // * separators + // + // Note that concrete itemIds aren't used intentionally. For example, we + // have no reason to disallow renaming a shortcut to the Bookmarks Toolbar, + // except for the one under All Bookmarks. + if ( + PlacesUtils.nodeIsSeparator(node) || + PlacesUtils.isRootItem(itemGuid) || + PlacesUtils.isQueryGeneratedFolder(node) + ) { + return false; + } + + return true; + }, + + setCellText: function PTV_setCellText(aRow, aColumn, aText) { + // We may only get here if the cell is editable. + let node = this._rows[aRow]; + if (node.title != aText) { + PlacesTransactions.EditTitle({ guid: node.bookmarkGuid, title: aText }) + .transact() + .catch(console.error); + } + }, + + toggleCutNode: function PTV_toggleCutNode(aNode, aValue) { + let currentVal = this._cuttingNodes.has(aNode); + if (currentVal != aValue) { + if (aValue) { + this._cuttingNodes.add(aNode); + } else { + this._cuttingNodes.delete(aNode); + } + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + } + }, + + selectionChanged() {}, + cycleCell(aRow, aColumn) {}, +}; diff --git a/browser/components/places/docs/Bookmarks.rst b/browser/components/places/docs/Bookmarks.rst new file mode 100644 index 0000000000..1c830c74ef --- /dev/null +++ b/browser/components/places/docs/Bookmarks.rst @@ -0,0 +1,50 @@ +Bookmarks.jsm +============= + +Asynchronous API for managing bookmarks. +Bookmarks are organized in a tree structure, and include URLs, folders and separators. Multiple bookmarks for the same URL are allowed. +To be able to use “Redo†and “Undo†the Firefox UI views are not using API directly but use :doc:`PlacesTransactions`. + +Structure +--------- + +Each bookmark-item is represented by an object having the following properties: + + * ``guid (string)`` - the globally unique identifier of the item + * ``parentGuid (string)`` - the globally unique identifier of the folder containing the item. This will be an empty string for the Places root folder. The root folder is never accessible to the user, it just contains other roots, like toolbar, menu, unfiled, mobile + * ``index (number)`` - the zero-based position of the item in the parent folder + * ``dateAdded (Date)`` - the time at which the item was added + * ``lastModified (Date)`` - the time at which the item was last modified + * ``type (number)`` - the item's type, either ``TYPE_BOOKMARK``, ``TYPE_FOLDER`` or ``TYPE_SEPARATOR`` + +The following properties are only valid for URLs or folders: + + * ``title (string)`` - the item's title, if any. Empty titles and null titles are considered the same. Titles longer than ``DB_TITLE_LENGTH_MAX`` will be truncated + +The following properties are only valid for URLs: + + * ``url (URL, href or nsIURI)`` - the item's URL. Note that while input objects can contain either an URL object, an href string, or an nsIURI, output objects will always contain an URL object. An URL cannot be longer than ``DB_URL_LENGTH_MAX``, methods will throw if a longer value is provided. + +Main functions +-------------- + + * Every bookmark has a globally unique identifier represented by ``string`` also known as ``Guid``. ``Guid`` helps us to locate our bookmark in database, change it, move, copy or delete. + + + * Creating new bookmark. When we inserting a bookmark-item into the bookmark tree, we need to set up a parentGuid property, which is a section where bookmark will be located, for example - Toolbar (``"toolbar_____"``). Location can be any other folder guid, or special root folder guids like: + + - rootGuid: ``"root________"`` + - menuGuid: ``"menu________"`` + - toolbarGuid: ``"toolbar_____"`` + - unfiledGuid: ``"unfiled_____"`` + - mobileGuid: ``"mobile______"`` + + * As well, you would have to specify bookmark type (for example, ``TYPE_BOOKMARK`` for a single bookmark, or ``TYPE_FOLDER`` for a folder). + + * Remove bookmark. For removing one or more bookmark items, you would have to specify ``guidOrInfo``. This may be a bookmark guid, an object representing an item, or an array of objects representing the items. You can specify options for removal - throwing an exception when attempting to remove a folder that is not empty and the change source forwarded to all bookmark observers. (Default source is pointing to nsINavBookmarksService::SOURCE_DEFAULT). Removing a bookmark returns a promise which could be: resolved by completion or rejected, in case there are no matching bookmarks to be removed. + + * Fetching information about a bookmark-item. Any successful call to this method resolves to a single bookmark-item (or null), even when multiple bookmarks may exist (e.g. fetching by url). If you wish to retrieve all of the bookmarks for a given match, you would have to use the callback. Input for fetching a bookamrk can be either a guid or an object with one of filtering properties (for example, ``url`` - retrieves the most recent bookmark having the given URL). Fetching returns the promise which is resolved on completion, rejects if any errors happens through fetch or throws an error if any of the specified arguments are invalid. + +Each successful operation is notified through the PlacesObservers :doc:`notifyObservers` interface. + +Full file with actual javadoc and description of each method - `Bookmarks.jsm`_ diff --git a/browser/components/places/docs/History.rst b/browser/components/places/docs/History.rst new file mode 100644 index 0000000000..5dbf3704dc --- /dev/null +++ b/browser/components/places/docs/History.rst @@ -0,0 +1,43 @@ +History.jsm +=========== + +Asynchronous API for managing history. + +Structure +--------- + +The API makes use of `PageInfo` and `VisitInfo` objects, defined as follows. + +A `PageInfo` object is any object that contains A *subset* of the following properties: + + * ``guid (string)`` - the globally unique id of the page + * ``url (URL, href or nsIURI)`` - the full URI of the page. Note that `PageInfo` values passed as argument may hold ``nsIURI`` or ``string`` values for property ``url``, but `PageInfo` objects returned by this module always hold ``URL`` values + * ``title (string)`` - the title associated with the page, if any + * ``description (string)`` - the description of the page, if any + * ``previewImageURL (URL, nsIURI or string)`` - the preview image URL of the page, if any + * ``frecency (number)`` - the frecency of the page, if any. Note that this property may not be used to change the actual frecency score of a page, only to retrieve it. In other words, any "frecency" field passed as argument to a function of this API will be ignored + * ``visits (Array<VisitInfo>)`` - all the visits for this page, if any + * ``annotations (Map)`` - a map containing key/value pairs of the annotations for this page, if any + +A `VisitInfo` object is any object that contains A SUBSET of the following properties: + + * ``date (Date)`` - the time the visit occurred + * ``transition (number)`` - how the user reached the page + * ``referrer (URL, nsIURI or string)`` - the referring URI of this visit. Note that `VisitInfo` passed as argument may hold ``nsIURI`` or ``string`` values for property ``referrer``, but `VisitInfo` objects returned by this module always hold ``URL`` values + +Main functions +-------------- + + * Fetching. Fetch the available information for one page. You would have to specify as a string a unique identifier (Guid) or url (url, nsIURI or href). Fetching step returns a promise which is successfully resolved when fetch is completed and page is found. Throws an error when Guid or url does not have an expected type or, if it is a string, url / Guid is not valid. + + * Removing pages / visits from database. You would have to specify the desired page / object visit to be removed. Returns a promise which is resolved with true boolean. Throws an error when 'pages' has an unexpected type or if there are no data for provided string / filter. + + * Determining if a page has been visited. Connects with database and inquries if specified page has been visited or not. Returns a promise which is resolved with a true boolean value if page has been visited, falsy value if not. Throws an error if provided data is not a valid Guid or uri. + + * Clearing all history. Returns a promise which is resolved when operation is completed. + + * Updating an information for a page. Currently, it supports updating the description, preview image URL and annotations for a page, any other fields will be ignored (this function will ignore the update if the target page has not yet been stored in the database. ``History.fetch`` could be used to check whether the page and its meta information exist or not). If a property 'description' is provided, the description of the page is updated. An empty string or null will clear the existing value in database, and description length should not be longer than ``DB_DESCRIPTION_LENGTH_MAX``. If a property 'siteName' is provided the site name of the page is updated, and 'previewImageURL' updates the preview image URL of the page. Applies same rules as ones for description regarding empty string clearing existing data and max length as ``DB_SITENAME_LENGTH_MAX`` for site name, ``DB_URL_LENGTH_MAX`` for preview image. Property 'annotations' uppdates the annotation. It should be a map, containign key/values pairs, if the value is falsy, the annotation will be removed. The keys must be Strings, the values should be Boolaen, Number or Strings. Updating information returns a promise which is rejected if operation was unsuccessful or resolved once the update is complete. Throws an error when 'pageInfo' has an unexpected type, invalid url / guid or has neither 'description' nor 'previewImageURL'. + +Each successful operation is notified through the PlacesObservers :doc:`notifyObservers` interface. + +Full file with actual javadoc and description of each method - `History.jsm`_ diff --git a/browser/components/places/docs/PlacesTransactions.rst b/browser/components/places/docs/PlacesTransactions.rst new file mode 100644 index 0000000000..fd6d7cc21a --- /dev/null +++ b/browser/components/places/docs/PlacesTransactions.rst @@ -0,0 +1,43 @@ +PlacesTransactions.jsm +====================== + +This module serves as the transactions manager for Places (hereinafter *PTM*). Generally, we need a transaction manager because History and Bookmark UI allows users to use `Undo / Redo` functions. To implement those transaction History and Bookmark needed to have a layer between Bookmark API and History API. This layer stores all requested changes in a stack and perform calls to API. That construction allows users perform `Undo / Redo` simply removing / adding that transaction and call to API from an array. Inside array we store changes from oldest to newest. + +Transactions implements all the elementary UI commands: creating items, editing their various properties, and so forth. All those commands stored in array and are completed one after another. + + +Constructing transactions +------------------------- + +Transactions are exposed by the module as constructors (e.g. PlacesTransactions.NewFolder). The input for these constructors is taken in the form of a single argument, a plain object consisting of the properties for the transaction. Input properties may be either required or optional (for example, *keyword* is required for the ``EditKeyword`` transaction, but optional for the ``NewBookmark`` transaction). + +Executing Transactions (the `transact` method of transactions) +-------------------------------------------------------------- + +Once a transaction is created, you must call it's *transact* method for it to be executed and take effect. *Transact* is an asynchronous method that takes no arguments, and returns a promise that resolves once the transaction is executed. + +Executing one of the transactions for creating items (``NewBookmark``, ``NewFolder``, ``NewSeparator``) resolves to the new item's *GUID*. + +There's no resolution value for other transactions. If a transaction fails to execute, *transact* rejects and the transactions history is not affected. As well, *transact* throws if it's called more than once (successfully or not) on the same transaction object. + +Batches +------- + +Sometimes it is useful to "batch" or "merge" transactions. + +For example, something like "Bookmark All Tabs" may be implemented as one NewFolder transaction followed by numerous NewBookmark transactions - all to be undone or redone in a single undo or redo command. + +Using ``PlacesTransactions.batch`` in such cases can take either an array of transactions which will be executed in the given order and later be treated a a single entry in the transactions history. Once the generator function is called a batch starts, and it lasts until the asynchronous generator iteration is complete. + +``PlacesTransactions.batch`` returns a promise that is to be resolved when the batch ends. “Nested" batches are not supported, if you call batch while another batch is still running, the new batch is enqueued with all other PTM work and thus not run until the running batch ends. The same goes for undo, redo and clearTransactionsHistory. + +It’s important not to await any promise from batch function for such methods, as: undo, redo, clearTransactionsHistory. Doing so causing a complete shutdown of PlacesTransactionManager, not allowing execution of any transactions. + +The transactions-history structure +---------------------------------- + +The transactions-history is a two-dimensional stack of transactions: the transactions are ordered in reverse to the order they were committed. It's two-dimensional because PTM allows batching transactions together for the purpose of undo or redo. + +The undoPosition property is set to the index of the top entry. If there is no entry at that index, there is nothing to undo. Entries prior to undoPosition, if any, are redo entries, the first one being the top redo entry. + +Full file with actual javadoc and description of each method - `PlacesTransactions.jsm`_ diff --git a/browser/components/places/docs/architecture-overview.rst b/browser/components/places/docs/architecture-overview.rst new file mode 100644 index 0000000000..5149a371fd --- /dev/null +++ b/browser/components/places/docs/architecture-overview.rst @@ -0,0 +1,105 @@ +Architecture Overview +===================== + +Because Mozilla used to ship a framework called XULRunner, Places is split into a Toolkit and a Browser part. The toolkit part is usually made up of product-agnostic APIs, those can be used by any kind of project, not just Web Browsers. The browser part is the one containing code specific to the Firefox browser. + +Codebase +-------- + +Most of the codebase is written in: + * **JavaScript** + * **C++** (for ex., database initialization code or some of the visit tracking code). + +Frontend +-------- + +The frontend part of the bookmarking experience includes various kind of views: + * `Trees`_ + * `Menus`_ + * `Toolbars`_ + + .. _Trees: https://searchfox.org/mozilla-central/source/browser/components/places/content/places-tree.js + .. _Menus: https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/browser/components/places/content/browserPlacesViews.js#1990 + .. _Toolbars: https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/browser/components/places/content/browserPlacesViews.js#894 + +All the views share a common `Controller`_ that is responsible to handle operations and commands required by the views. Each view creates a Result object and receives notifications about changes from it. + +As an example, removing a bookmark from a view will call into the controller that calls into PlacesTransactions to actually do the removal. The removal will notify a `Places event`_, that is caught by the Result, that will immediately update its internal representation of the bookmarks tree. Then the Result sends a notification to the view that will handle it, updating what the user is seeing. The system works according to the classical `Model-View-Controller`_ pattern. + +Fronted dialogs and panels are written using xhtml and shadow DOM. The bookmark dialogs in particular are wrappers around a common template, `editBookmarkPanel.inc.xhtml`_, it could be extended or overloaded like an object (overlay, similar to Web Component). + +Most of the logic for the edit bookmark overlay lives in the generic script `editBookmark.js`_. + +.. _Controller: https://searchfox.org/mozilla-central/source/browser/components/places/content/controller.js +.. _Places event: https://searchfox.org/mozilla-central/source/dom/chrome-webidl/PlacesEvent.webidl +.. _Model-View-Controller: https://en.wikipedia.org/wiki/Model–view–controller +.. _editBookmarkPanel.inc.xhtml: https://searchfox.org/mozilla-central/source/browser/components/places/content/editBookmarkPanel.inc.xhtml +.. _editBookmark.js: https://searchfox.org/mozilla-central/source/browser/components/places/content/editBookmark.js + +Structure of Frontend +^^^^^^^^^^^^^^^^^^^^^ + +Most part of frontend code is located in : `Browser/Components/Places/Content`_: + + - `BookmarkProperties`_ , BookmarkProperties.xhtml - responsible for editBookmarks & newBookmark Dialog. The panel is initialized based on data given in the js object passed as ``window.arguments[0]``. ``Window.arguments[0]`` is set to the guid of the item, if the dialog is accepted. + - BookmarksHistoyTooltip.xhtml - code responsible for tooltip + - `BookmarksSidebar`_, bookmarksSidebar.xhtml - code responsible for sidebar window. Searches through existing bookmarks tree for desired bookmark. + - `BrowserPlacesViews`_ - controls most views (menu, panels, toolbox). The base view implements everything that's common to the toolbar and menu views. + - `Controller`_ - controller shared by all places views. Connect UI and actual operations. + - `EditBookmark`_, editBookmarkPanel.inc.xhtml - controls edit bookmark panel. Observes changes for bookmarks and connects all UI manipulations with backend. + - `HistorySidebar`_, historySidebar.xhtml - code responsible for history sidebar window. Searches through existing tree for requested History. + - `Places-menupopup`_ - custom element definition for Places menus + - `Places-tree`_ - class ``MozPlacesTree`` - builds a custom element definition for the places tree. This is loaded into all XUL windows. Has to be wrapped in a block to prevent leaking to a window scope. + - Places.css, places.js, places.xhtml - responsible for Library window + - PlacesCommands.inc.xhtml - commands for multiple windows + - PlacesContextMenu.inc.xhtml - definition for context menu + - `TreeView`_ - implementation of the tree view + + .. _Browser/Components/Places/Content: https://searchfox.org/mozilla-central/source/browser/components/places/content + .. _BookmarkProperties: https://searchfox.org/mozilla-central/source/browser/components/places/content/bookmarkProperties.js + .. _BookmarksSidebar: https://searchfox.org/mozilla-central/source/browser/components/places/content/bookmarksSidebar.js + .. _BrowserPlacesViews: https://searchfox.org/mozilla-central/source/browser/components/places/content/browserPlacesViews.js + .. _EditBookmark: https://searchfox.org/mozilla-central/source/browser/components/places/content/editBookmark.js + .. _HistorySidebar: https://searchfox.org/mozilla-central/source/browser/components/places/content/historySidebar.js + .. _Places-menupopup: https://searchfox.org/mozilla-central/source/browser/components/places/content/places-menupopup.js + .. _Places-tree: https://searchfox.org/mozilla-central/source/browser/components/places/content/places-tree.js + .. _TreeView: https://searchfox.org/mozilla-central/source/browser/components/places/content/treeView.js + + +Backend +------- + +Because any bookmark operation done by the user in the frontend is undo-able, Firefox usually doesn’t directly invoke the Bookmarks / History API, it instead goes through a wrapper called PlacesTransactions.jsm. + +The scope of this module is to translate requests for bookmark changes into operation descriptors, and store them in a log. When a bookmark has been modified by the user, PlacesTransactions stores the previous state of it in the log; that state can be restored if the user picks Undo (or CTRL+Z) in the Library window. This prevents data loss in case the user removes bookmarks inadvertently. + +Toolkit Places also provides a way to query bookmarks, through Results. This is one of the oldest parts of the codebase that will likely be rewritten in the future. It uses XPCOM (Cross Platform Component Object Model) a Mozilla technology that allows C++ and Javascript to communicate. It’s a declarative system, where interfaces must be defined and documented in XPIDL files. In practice all the available methods and attributes are documented in nsINavHistoryService.idl. Querying bookmarks returns a nsINavHistoryResult object that has a root node. The root node has children representing bookmarks or folders. It works like a tree, containers must be opened and then one can inspect their children. This is the base used by most of the Firefox frontend bookmark views. + +Structure of Backend +^^^^^^^^^^^^^^^^^^^^ + +Most part of backend code is located in : `Toolkit/Components/Places`_: + + - :doc:`Bookmarks` - Asynchronous API for managing bookmarks + - :doc:`History` - Asynchronous API for managing history + - `PlacesUtils`_ - This module exports functions for Sync to use when applying remote records + - :doc:`PlacesTransactions` - This module serves as the transactions manager for Places + + .. _Toolkit/Components/Places: https://searchfox.org/mozilla-central/source/toolkit/components/places + .. _PlacesUtils: https://searchfox.org/mozilla-central/source/toolkit/components/places/PlacesUtils.jsm + +Storage +------- + +Places uses `SQLite`_ (C-language library with a stable, cross-platform, and backwards compatible file format) as its data storage backend. +All the data is contained in a places.sqlite file, in the roaming Firefox profile folder. The database is accessed using a wrapper of the SQLite library called `mozStorage`_. +For storing our favicons we use favicons.sqlite which is represented as ATTACH-ed to places.sqlite. That makes it easier to use our two separate sqlites as one single database. + +Synchronization +--------------- + +Places works in strict contact with `Firefox Sync`_, to synchronize bookmarks and history across devices, thus you can meet Sync specific code in various parts of the Places codebase. Some of the code may refer to Weave, the old project name for Sync. + +.. _SQLite: https://www.sqlite.org/index.html +.. _mozStorage: https://searchfox.org/mozilla-central/source/storage +.. _Firefox Sync: https://www.mozilla.org/en-US/firefox/sync/ diff --git a/browser/components/places/docs/assets/nontechnical-overview/bookmark-folder-menu.png b/browser/components/places/docs/assets/nontechnical-overview/bookmark-folder-menu.png Binary files differnew file mode 100644 index 0000000000..f8f9dad728 --- /dev/null +++ b/browser/components/places/docs/assets/nontechnical-overview/bookmark-folder-menu.png diff --git a/browser/components/places/docs/assets/nontechnical-overview/bookmark-undo-redo.png b/browser/components/places/docs/assets/nontechnical-overview/bookmark-undo-redo.png Binary files differnew file mode 100644 index 0000000000..7fffaa3f49 --- /dev/null +++ b/browser/components/places/docs/assets/nontechnical-overview/bookmark-undo-redo.png diff --git a/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-main-application.png b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-main-application.png Binary files differnew file mode 100644 index 0000000000..4a2028f4f3 --- /dev/null +++ b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-main-application.png diff --git a/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-menu.png b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-menu.png Binary files differnew file mode 100644 index 0000000000..63b4b8163b --- /dev/null +++ b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-menu.png diff --git a/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-toolbar.png b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-toolbar.png Binary files differnew file mode 100644 index 0000000000..0cdbe97f08 --- /dev/null +++ b/browser/components/places/docs/assets/nontechnical-overview/firefox-bookmarks-toolbar.png diff --git a/browser/components/places/docs/index.rst b/browser/components/places/docs/index.rst new file mode 100644 index 0000000000..dd6a64c05a --- /dev/null +++ b/browser/components/places/docs/index.rst @@ -0,0 +1,35 @@ +Places +====== + +This document describes the implementation of the Firefox Places component. + +It is a robust system to manage History and Bookmarks through a database on the backend side and a model-view-controller system that connects frontend UI user manipulation. + +History and Bookmarks +--------------------- + +In Firefox 2, History and Bookmarks used to be kept into separate databases in the Resource Description Framework format (`RDF format`_). + +However, Firefox 3 implemented the Places system. It has been done due to multiple reasons, such as: + + * **Performance**: certain search or maintenance operations were very slow + * **Reliability**: the filesystem facing side of RDF was not so robust, often causing corruption or unexpected states + * **Flexibility**: being able to cross data allows for interesting features, like the Awesome Bar + * **Maintainability**: the future of RDF was unclear, while SQLite is actively maintained and used by a large number of applications + + .. _RDF format: https://en.wikipedia.org/wiki/Resource_Description_Framework + +Where to Start +-------------- + +For the high-level, non-technical summary of how History and Bookmarks works, read :doc:`nontechnical-overview`. +For more specific, technical details of implementation, read :doc:`architecture-overview`. + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + nontechnical-overview + architecture-overview diff --git a/browser/components/places/docs/nontechnical-overview.rst b/browser/components/places/docs/nontechnical-overview.rst new file mode 100644 index 0000000000..e4da4b895e --- /dev/null +++ b/browser/components/places/docs/nontechnical-overview.rst @@ -0,0 +1,163 @@ +Nontechnical Overview +===================== + +This document provides a high level, nontechnical overview of Firefox’s Places component (Bookmarks and History). + +More information regarding Bookmarks - `Bookmarks in Firefox`_ + +.. _Bookmarks in Firefox: https://support.mozilla.org/en-US/kb/bookmarks-firefox + +There are multiple ways to access and manipulate Bookmarks, such as: + +.. contents:: + :depth: 2 + + +Firefox menu bar +---------------- + +To access bookmarks click on Bookmarks link in Firefox menu bar on the top of your screen. +If the Firefox Menu bar is missing, it is usually because Firefox is in full screen mode or it is not the selected application. + +In this case you can: + * Turn off full screen mode + * Select the Firefox application + +.. figure:: assets/nontechnical-overview/firefox-bookmarks-menu.png + :alt: Image of the Bookmarks Menu in Firefox menu bar + :scale: 50% + :align: center + + Firefox menu bar - Bookmarks Menu + +Main application button +----------------------- + +.. figure:: assets/nontechnical-overview/firefox-bookmarks-main-application.png + :alt: Image of the main Bookmarks application button + :scale: 45% + :align: center + + Firefox - Bookmarks Main Application + +Bookmarks Toolbar +----------------- + +.. figure:: assets/nontechnical-overview/firefox-bookmarks-toolbar.png + :alt: Image of the Bookmarks Toolbar + :scale: 50% + :align: center + + Firefox - Bookmarks Toolbar + +On the top of your Firefox screen just under the search bar - Bookmarks are on the left side. + +Firefox's toolbar provides easy access to common features: + * Click the menu button , click `More Tools` and choose `Customize Toolbar`. + + - **To turn on the Title bar:** Put a check mark next to `Title Bar` in the lower left. + - **To turn on the Bookmarks toolbar:** Click the Toolbars dropdown menu at the bottom of the screen and select Bookmarks Toolbar. + + * Click the `Done` button. + +Firefox sidebar +--------------- + +Firefox comes with a convenient Sidebar button which lets you access your bookmarks, history and synced tabs in one click. For using it, you have to: + +1. `Add the sidebar button to your controls`_. + +2. Toggle sidebar button on and off for preferable options (for ex., add bookmarks). + +.. _Add the sidebar button to your controls: https://support.mozilla.org/en-US/kb/use-firefox-sidebar-access-bookmarks-history-synced + +The Library window +------------------ +1. Click the `hamburger Menu` icon in the upper-right corner of your screen. +2. In the middle of the drop-down menu select `Library`. +3. In the `Library` menu select `Bookmarks`. +4. Press `Show All Bookmark` button. + +Keyboard shortcuts +------------------ + + * Show / Manage Bookmarks (Library Window) - :kbd:`Shift` + :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`O` + * Add / Edit Bookmark - :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`D` + * Bookmark all tabs into 1 bookmark folder - :kbd:`Shift` + :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`D` + * Delete bookmark / Bookmarks / Bookmarks folder - :kbd:`Delete` + * Show / Hide the Bookmarks toolbar - :kbd:`Shift` + :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`B` + * Focus Next Bookmark/Folder whose name (or sorted property) starts with a given character or character sequence - Type the character or quickly type the character sequence - in Bookmarks Library, Bookmarks Toolbar, Bookmarks Menu, Bookmarks Sidebar + +Firefox Context Menu +-------------------- + +Single // Multiple bookmarks on selection will allow you to perform different manipulations, such as: + + * Open (Open / Open in New Tab / Open in New Window / Open in New Private Window) + * Delete + * Edit (Cut / Copy / Paste) + * Add (Bookmark / Folder / Separator) + +.. figure:: assets/nontechnical-overview/bookmark-folder-menu.png + :alt: Image of the Bookmark Menu + :scale: 50% + :align: center + + Firefox - Bookmark Menu + +Undo / Redo +----------- + +Undo / Redo options available In Library Window and Sidebar Panel. +You can reverse your commands (creating bookmark, deleting bookmark, copy/paste etc.) with: + + * Keyboard combinations: + + - Undo - :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`Z` + - Redo - :kbd:`Shift` + :kbd:`Ctrl` / :kbd:`Cmd` + :kbd:`Z` + + * Choosing option in Menu - Edit - Undo / Redo + +.. figure:: assets/nontechnical-overview/bookmark-undo-redo.png + :alt: Image of the Undo/Redo options for Bookmark + :scale: 50% + :align: center + + Firefox - Undo / Redo for bookmark + +Import Bookmarks +---------------- + +There are various options to import bookmarks to Firefox. Some of them are: + + * `from Internet Explorer or Microsoft Edge`_ + * `from Google Chrome`_ + * `from an HTML file`_ + + .. _from Internet Explorer or Microsoft Edge: https://support.mozilla.org/en-US/kb/import-bookmarks-internet-explorer-or-microsoft-edge + .. _from Google Chrome: https://support.mozilla.org/en-US/kb/import-bookmarks-google-chrome + .. _from an HTML file: https://support.mozilla.org/en-US/kb/import-bookmarks-html-file + +Restore Bookmarks +----------------- + +Firefox automatically creates backups of your bookmarks and saves the last 15 backups for safekeeping. + +**To restore your bookmarks:** + +#. Click on *hamburger menu* button to open the Menu panel. +#. Go to *Bookmarks* - *Manage Bookmarks*. +#. Select the backup from which you want to restore: + + #. The dated entries are automatic bookmark backups. + #. From a manual backup ( *Choose file…* ). +#. After selecting the option and confirming your choice your bookmarks would be restored. + + +**For manually add backup:** + +#. Click on *hamburger menu* button to open the Menu panel. +#. Go to *Bookmarks* - *Manage Bookmarks*. +#. In the *Library window*, click the button and then select *Backup…*. +#. In the Bookmarks backup filename window that opens, choose a location to save the file, which is named ``bookmarks-date.json`` by default. The desktop is usually a good spot, but any place that is easy to remember will work. +#. Save the bookmarks json file. The Bookmarks backup filename window will close and then you can close the *Library* window. diff --git a/browser/components/places/docs/notifyObservers.rst b/browser/components/places/docs/notifyObservers.rst new file mode 100644 index 0000000000..5eb95f48e7 --- /dev/null +++ b/browser/components/places/docs/notifyObservers.rst @@ -0,0 +1,35 @@ +Observers +========= + +Historically, each successful operation is notified through the *nsINavBookmarksObserver* interface. To listen to such notifications you must register using *nsINavBookmarksService* addObserver and removeObserver methods. Note that bookmark addition or order changes won't notify bookmark-moved for items that have their indexes changed. +Similarly, lastModified changes not done explicitly (like changing another property) won't fire an onItemChanged notification for the lastModified property. + +However, right now we are in the process of implementing a Places Observers system to change the *nsINavBookmarksObserver* interface. + +Generally - the Observer pattern follows a subscription model. A subscriber (commonly referred to as the observer) subscribes to an event or an action handled by a publisher (commonly referred to as the subject) is notified when the event or action occurs. + +Each successful operation is noticed by observer for these events and passed to a subscriber. + +`PlacesObservers.webidl`_ a Global Singleton which provides utilities to observe or notify all events. +`PlacesEvent.webidl`_ states all types of possible events and describes their features. In our case, events are: + + - ``“page-visitedâ€`` - ``data: PlacesVisit`` Fired whenever a page is visited + - ``“bookmark-addedâ€`` - ``data: PlacesBookmarkAddition`` Fired whenever a bookmark (or a bookmark folder/separator) is created. + - ``“bookmark-removedâ€`` - ``data: PlacesBookmarkRemoved`` Fired whenever a bookmark (or a bookmark folder/separator) is removed. + - ``“bookmark-movedâ€`` - ``data: PlacesBookmarkMoved`` Fired whenever a bookmark (or a bookmark folder/separator) is moved. + - ``“bookmark-guid-changedâ€`` - ``data: PlacesBookmarkGuid`` Fired whenever a bookmark guid changes. + - ``“bookmark-keyword-changedâ€`` - ``data: PlacesBookmarkKeyword`` Fired whenever a bookmark keyword changes. + + - ``“bookmark-tags-changedâ€`` - ``data: PlacesBookmarkTags`` Fired whenever tags of bookmark changes. + - ``“bookmark-time-changedâ€`` - ``data: PlacesBookmarkTime`` Fired whenever dateAdded or lastModified of a bookmark is explicitly changed through the Bookmarks API. This notification doesn't fire when a bookmark is created, or when a property of a bookmark (e.g. title) is changed, even if lastModified will be updated as a consequence of that change. + - ``“bookmark-title-changedâ€`` - ``data: PlacesBookmarkTitle`` Fired whenever a bookmark title changes. + - ``“bookmark-url-changedâ€`` - ``data: PlacesBookmarkUrl`` Fired whenever a bookmark url changes. + - ``“favicon-changedâ€`` - ``data: PlacesFavicon`` Fired whenever a favicon changes. + - ``“page-title-changedâ€`` - ``data: PlacesVisitTitle`` Fired whenever a page title changes. + - ``“history-clearedâ€`` - ``data: PlacesHistoryCleared`` Fired whenever history is cleared. + - ``“page-rank-changedâ€`` - ``data: PlacesRanking`` Fired whenever pages ranking is changed. + - ``“page-removedâ€`` - ``data: PlacesVisitRemoved`` Fired whenever a page or its visits are removed. This may be invoked when a page is removed from the store because it has no more visits, nor bookmarks. It may also be invoked when all or some of the page visits are removed, but the page itself is not removed from the store, because it may be bookmarked. + - ``“purge-cachesâ€`` - ``data: PlacesPurgeCaches`` Fired whenever changes happened that could not be observed through other notifications, for example a database fixup. When received, observers, especially data views, should drop any caches and reload from scratch. + + .. _PlacesObservers.webidl: https://searchfox.org/mozilla-central/source/dom/chrome-webidl/PlacesObservers.webidl + .. _PlacesEvent.webidl: https://searchfox.org/mozilla-central/source/dom/chrome-webidl/PlacesEvent.webidl diff --git a/browser/components/places/jar.mn b/browser/components/places/jar.mn new file mode 100644 index 0000000000..7334914664 --- /dev/null +++ b/browser/components/places/jar.mn @@ -0,0 +1,27 @@ +# 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/. + +browser.jar: +# Provide another URI for the bookmarkProperties dialog so we can persist the +# attributes separately +* content/browser/places/places.xhtml (content/places.xhtml) + content/browser/places/places.js (content/places.js) + content/browser/places/places.css (content/places.css) +* content/browser/places/bookmarkProperties.xhtml (content/bookmarkProperties.xhtml) + content/browser/places/bookmarkProperties.js (content/bookmarkProperties.js) + content/browser/places/places-menupopup.js (content/places-menupopup.js) + content/browser/places/places-tree.js (content/places-tree.js) + content/browser/places/controller.js (content/controller.js) + content/browser/places/treeView.js (content/treeView.js) + content/browser/places/browserPlacesViews.js (content/browserPlacesViews.js) +* content/browser/places/historySidebar.xhtml (content/historySidebar.xhtml) + content/browser/places/historySidebar.js (content/historySidebar.js) +* content/browser/places/bookmarksSidebar.xhtml (content/bookmarksSidebar.xhtml) + content/browser/places/bookmarksSidebar.js (content/bookmarksSidebar.js) + content/browser/places/editBookmark.js (content/editBookmark.js) +#ifdef NIGHTLY_BUILD + content/browser/places/interactionsViewer.css (metadataViewer/interactionsViewer.css) + content/browser/places/interactionsViewer.html (metadataViewer/interactionsViewer.html) + content/browser/places/interactionsViewer.js (metadataViewer/interactionsViewer.js) +#endif diff --git a/browser/components/places/metadataViewer/interactionsViewer.css b/browser/components/places/metadataViewer/interactionsViewer.css new file mode 100644 index 0000000000..e54a934ec2 --- /dev/null +++ b/browser/components/places/metadataViewer/interactionsViewer.css @@ -0,0 +1,67 @@ +/* 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/. */ + +body { + padding: .5em 2em; + display: flex; + flex-wrap: wrap; +} + +.hidden { + display: none; +} + +.message-bar { + flex: 0 0 100%; +} + +.message-bar-icon { + vertical-align: middle; + -moz-context-properties: fill; + fill: #FFBF00; +} + +#categories { + padding-top: 0; + overflow-y: auto; + margin-bottom: 42px; + user-select: none; + /* Override common.css for widths, to give more room for tables. */ + width: auto; +} + +#categories > .category { + cursor: pointer; + display: flex; + flex-direction: column; + min-height: 42px; + /* Override common.css for widths and margins, to give more room for tables. */ + width: auto; + padding: 0; + margin: 0; +} + +.category-name { + margin: auto 0; + pointer-events: none; + /* Matches the button margins/padding/border defined in common.css. So that + the export history button text aligns nicely. */ + padding-inline: 16px; + margin-inline: 4px; +} + +.main-content { + flex: 1; +} + +#metadataLimit { + padding-bottom: 1em; +} + +#tableViewer > div { + padding: .3em 1em; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/browser/components/places/metadataViewer/interactionsViewer.html b/browser/components/places/metadataViewer/interactionsViewer.html new file mode 100644 index 0000000000..ae3d166f0e --- /dev/null +++ b/browser/components/places/metadataViewer/interactionsViewer.html @@ -0,0 +1,55 @@ +<!-- +# 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/. +--> +<!DOCTYPE html> + +<html> + <head> + <title>Interactions Debug Viewer</title> + <script + type="module" + src="chrome://browser/content/places/interactionsViewer.js" + ></script> + <link + rel="stylesheet" + type="text/css" + href="chrome://global/skin/in-content/common.css" + /> + <link + rel="stylesheet" + type="text/css" + href="chrome://browser/content/places/interactionsViewer.css" + /> + <style id="tableStyle" type="text/css"></style> + </head> + <body> + <div id="enabledWarning" class="message-bar message-bar-warning" hidden> + <img + class="message-bar-icon" + src="chrome://global/skin/icons/warning.svg" + /> + <descripton class="message-bar-description"> + You need to have <code>browser.places.interactions.enabled</code> + set to true (and restart) for metadata recording to be enabled. + </descripton> + </div> + <div id="categories"> + <div class="category selected" value="metadata"> + <span class="category-name">Interactions</span> + </div> + <div class="category" value="places-stats"> + <span class="category-name">Places Stats</span> + </div> + <div class="export-button-container"> + <button id="export">Export History</button> + </div> + </div> + <div id="main" class="main-content"> + <h1 id="title"></h1> + <div id="tableLimit"></div> + <div id="tableViewer"></div> + </div> + </body> +</html> diff --git a/browser/components/places/metadataViewer/interactionsViewer.js b/browser/components/places/metadataViewer/interactionsViewer.js new file mode 100644 index 0000000000..a6a3a0e4c1 --- /dev/null +++ b/browser/components/places/metadataViewer/interactionsViewer.js @@ -0,0 +1,427 @@ +/* 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 module */ + +const { Interactions } = ChromeUtils.importESModule( + "resource:///modules/Interactions.sys.mjs" +); +const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +const { PlacesDBUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesDBUtils.sys.mjs" +); + +/** + * Base class for the table display. Handles table layout and updates. + */ +class TableViewer { + /** + * Maximum number of rows to display by default. + * + * @type {number} + */ + maxRows = 100; + + /** + * The number of rows that we last filled in on the table. This allows + * tracking to know when to clear unused rows. + * + * @type {number} + */ + #lastFilledRows = 0; + + /** + * A map of columns that are displayed by default. This is set by sub-classes. + * + * - The key is the column name in the database. + * - The header is the column header on the table. + * - The modifier is a function to modify the returned value from the database + * for display. + * - includeTitle determines if the title attribute should be set on that + * column, for tooltips, e.g. if an element is likely to overflow. + * + * @type {Map<string, object>} + */ + columnMap; + + /** + * A reference for the current interval timer, if any. + * + * @type {number} + */ + #timer; + + /** + * Starts the display of the table. Setting up the table display and doing + * an initial output. Also starts the interval timer. + */ + async start() { + this.setupUI(); + await this.updateDisplay(); + this.#timer = setInterval(this.updateDisplay.bind(this), 10000); + } + + /** + * Pauses updates for this table, use start() to re-start. + */ + pause() { + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = null; + } + } + + /** + * Creates the initial table layout and sets the styles to match the number + * of columns. + */ + setupUI() { + document.getElementById("title").textContent = this.title; + + let viewer = document.getElementById("tableViewer"); + viewer.textContent = ""; + + // Set up the table styles. + let existingStyle = document.getElementById("tableStyle"); + let numColumns = this.columnMap.size; + let styleText = ` +#tableViewer { + display: grid; + grid-template-columns: ${this.cssGridTemplateColumns} +} + +/* Sets the first row of elements to bold. The number is the number of columns */ +#tableViewer > div:nth-child(-n+${numColumns}) { + font-weight: bold; + white-space: break-spaces; +} + +/* Highlights every other row to make visual scanning of the table easier. + The numbers need to be adapted if the number of columns changes. */ +`; + for (let i = numColumns + 1; i <= numColumns * 2 - 1; i++) { + styleText += `#tableViewer > div:nth-child(${numColumns}n+${i}):nth-child(${ + numColumns * 2 + }n+${i}),\n`; + } + styleText += `#tableViewer > div:nth-child(${numColumns}n+${ + numColumns * 2 + }):nth-child(${numColumns * 2}n+${numColumns * 2})\n +{ + background: var(--in-content-box-background-odd); +}`; + existingStyle.innerText = styleText; + + // Now set up the table itself with empty cells, this avoids having to + // create and delete rows all the time. + let tableBody = document.createDocumentFragment(); + let header = document.createDocumentFragment(); + for (let details of this.columnMap.values()) { + let columnDiv = document.createElement("div"); + columnDiv.textContent = details.header; + header.appendChild(columnDiv); + } + tableBody.appendChild(header); + + for (let i = 0; i < this.maxRows; i++) { + let row = document.createDocumentFragment(); + for (let j = 0; j < this.columnMap.size; j++) { + row.appendChild(document.createElement("div")); + } + tableBody.appendChild(row); + } + viewer.appendChild(tableBody); + + let limit = document.getElementById("tableLimit"); + limit.textContent = `Maximum rows displayed: ${this.maxRows}.`; + + this.#lastFilledRows = 0; + } + + /** + * Displays the provided data in the table. + * + * @param {object[]} rows + * An array of rows to display. The rows are objects with the values for + * the rows being the keys of the columnMap. + */ + displayData(rows) { + if (gCurrentHandler != this) { + /* Data is no more relevant for the current view. */ + return; + } + let viewer = document.getElementById("tableViewer"); + let index = this.columnMap.size; + for (let row of rows) { + for (let [column, details] of this.columnMap.entries()) { + let value = row[column]; + + if (details.includeTitle) { + viewer.children[index].setAttribute("title", value); + } + + viewer.children[index].textContent = details.modifier + ? details.modifier(value) + : value; + + index++; + } + } + let numRows = rows.length; + if (numRows < this.#lastFilledRows) { + for (let r = numRows; r < this.#lastFilledRows; r++) { + for (let c = 0; c < this.columnMap.size; c++) { + viewer.children[index].textContent = ""; + viewer.children[index].removeAttribute("title"); + index++; + } + } + } + this.#lastFilledRows = numRows; + } +} + +/** + * Viewer definition for the page metadata. + */ +const metadataHandler = new (class extends TableViewer { + title = "Interactions"; + cssGridTemplateColumns = + "max-content fit-content(100%) repeat(6, min-content) fit-content(100%);"; + + /** + * @see TableViewer.columnMap + */ + columnMap = new Map([ + ["id", { header: "ID" }], + ["url", { header: "URL", includeTitle: true }], + [ + "updated_at", + { + header: "Updated", + modifier: updatedAt => new Date(updatedAt).toLocaleString(), + }, + ], + [ + "total_view_time", + { + header: "View Time (s)", + modifier: totalViewTime => (totalViewTime / 1000).toFixed(2), + }, + ], + [ + "typing_time", + { + header: "Typing Time (s)", + modifier: typingTime => (typingTime / 1000).toFixed(2), + }, + ], + ["key_presses", { header: "Key Presses" }], + [ + "scrolling_time", + { + header: "Scroll Time (s)", + modifier: scrollingTime => (scrollingTime / 1000).toFixed(2), + }, + ], + ["scrolling_distance", { header: "Scroll Distance (pixels)" }], + ["referrer", { header: "Referrer", includeTitle: true }], + ]); + + /** + * A reference to the database connection. + * + * @type {mozIStorageConnection} + */ + #db = null; + + async #getRows(query, columns = [...this.columnMap.keys()]) { + if (!this.#db) { + this.#db = await PlacesUtils.promiseDBConnection(); + } + let rows = await this.#db.executeCached(query); + return rows.map(r => { + let result = {}; + for (let column of columns) { + result[column] = r.getResultByName(column); + } + return result; + }); + } + + /** + * Loads the current metadata from the database and updates the display. + */ + async updateDisplay() { + let rows = await this.#getRows( + `SELECT m.id AS id, h.url AS url, updated_at, total_view_time, + typing_time, key_presses, scrolling_time, scrolling_distance, h2.url as referrer + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id + ORDER BY updated_at DESC + LIMIT ${this.maxRows}` + ); + this.displayData(rows); + } + + export() { + // Export all data. We only export place_id and not url so users can share their exports + // without revealing the sites they have been visiting. + return this.#getRows( + `SELECT + m.id, + m.place_id, + m.referrer_place_id, + h.origin_id, + m.updated_at, + m.total_view_time, + h.visit_count, + h.frecency, + m.typing_time, + m.key_presses, + m.scrolling_time, + m.scrolling_distance, + vall.visit_dates, + vall.visit_types + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + JOIN + (SELECT + place_id, + group_concat(visit_date, ',') AS visit_dates, + group_concat(visit_type, ',') AS visit_types + FROM moz_historyvisits + GROUP BY place_id + ORDER BY visit_date DESC + ) vall ON vall.place_id = m.place_id + ORDER BY m.place_id DESC + `, + [ + "id", + "place_id", + "referrer_place_id", + "origin_id", + "updated_at", + "total_view_time", + "visit_count", + "frecency", + "typing_time", + "key_presses", + "scrolling_time", + "scrolling_distance", + "visit_dates", + "visit_types", + ] + ); + } +})(); + +/** + * Viewer definition for the Places database stats. + */ +const placesStatsHandler = new (class extends TableViewer { + title = "Places Database Statistics"; + cssGridTemplateColumns = "fit-content(100%) repeat(5, max-content);"; + + /** + * @see TableViewer.columnMap + */ + columnMap = new Map([ + ["entity", { header: "Entity" }], + ["count", { header: "Count" }], + [ + "sizeBytes", + { + header: "Size (KiB)", + modifier: c => c / 1024, + }, + ], + [ + "sizePerc", + { + header: "Size (Perc.)", + }, + ], + [ + "efficiencyPerc", + { + header: "Space Eff. (Perc.)", + }, + ], + [ + "sequentialityPerc", + { + header: "Sequentiality (Perc.)", + }, + ], + ]); + + /** + * Loads the current metadata from the database and updates the display. + */ + async updateDisplay() { + let data = await PlacesDBUtils.getEntitiesStatsAndCounts(); + this.displayData(data); + } +})(); + +function checkPrefs() { + if ( + !Services.prefs.getBoolPref("browser.places.interactions.enabled", false) + ) { + let warning = document.getElementById("enabledWarning"); + warning.hidden = false; + } +} + +function show(selectedButton) { + let currentButton = document.querySelector(".category.selected"); + if (currentButton == selectedButton) { + return; + } + + gCurrentHandler.pause(); + currentButton.classList.remove("selected"); + selectedButton.classList.add("selected"); + switch (selectedButton.getAttribute("value")) { + case "metadata": + (gCurrentHandler = metadataHandler).start(); + metadataHandler.start(); + break; + case "places-stats": + (gCurrentHandler = placesStatsHandler).start(); + break; + } +} + +function setupListeners() { + let menu = document.getElementById("categories"); + menu.addEventListener("click", e => { + if (e.target && e.target.parentNode == menu) { + show(e.target); + } + }); + document.getElementById("export").addEventListener("click", async e => { + e.preventDefault(); + const data = await metadataHandler.export(); + + const blob = new Blob([JSON.stringify(data)], { + type: "text/json;charset=utf-8", + }); + const a = document.createElement("a"); + a.setAttribute("download", `places-${Date.now()}.json`); + a.setAttribute("href", window.URL.createObjectURL(blob)); + a.click(); + a.remove(); + }); +} + +checkPrefs(); +// Set the initial handler here. +let gCurrentHandler = metadataHandler; +gCurrentHandler.start().catch(console.error); +setupListeners(); diff --git a/browser/components/places/moz.build b/browser/components/places/moz.build new file mode 100644 index 0000000000..c01962babf --- /dev/null +++ b/browser/components/places/moz.build @@ -0,0 +1,32 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/unit/xpcshell.ini", +] +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"] +BROWSER_CHROME_MANIFESTS += [ + "tests/browser/browser.ini", + "tests/browser/interactions/browser.ini", +] + +JAR_MANIFESTS += ["jar.mn"] + +SPHINX_TREES["/browser/places"] = "docs" + +EXTRA_JS_MODULES += [ + "Interactions.sys.mjs", + "InteractionsBlocklist.sys.mjs", + "PlacesUIUtils.sys.mjs", +] + +FINAL_TARGET_FILES.actors += [ + "InteractionsChild.sys.mjs", + "InteractionsParent.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Bookmarks & History") diff --git a/browser/components/places/tests/browser/bookmark_dummy_1.html b/browser/components/places/tests/browser/bookmark_dummy_1.html new file mode 100644 index 0000000000..c03e0c18c0 --- /dev/null +++ b/browser/components/places/tests/browser/bookmark_dummy_1.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Bookmark Dummy 1</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Bookmark Dummy 1</p> +</body> +</html> diff --git a/browser/components/places/tests/browser/bookmark_dummy_2.html b/browser/components/places/tests/browser/bookmark_dummy_2.html new file mode 100644 index 0000000000..229a730b32 --- /dev/null +++ b/browser/components/places/tests/browser/bookmark_dummy_2.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Bookmark Dummy 2</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Bookmark Dummy 2</p> +</body> +</html> diff --git a/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html b/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html new file mode 100644 index 0000000000..54a87d3247 --- /dev/null +++ b/browser/components/places/tests/browser/bookmarklet_windowOpen_dummy.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Bookmarklet windowOpen Dummy</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Bookmarklet windowOpen Dummy</p> +</body> +</html> diff --git a/browser/components/places/tests/browser/browser.ini b/browser/components/places/tests/browser/browser.ini new file mode 100644 index 0000000000..160b7ac09f --- /dev/null +++ b/browser/components/places/tests/browser/browser.ini @@ -0,0 +1,132 @@ +# 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/. + +[DEFAULT] +support-files = + head.js + framedPage.html + frameLeft.html + frameRight.html + sidebarpanels_click_test_page.html + keyword_form.html + +[browser_addBookmarkForFrame.js] +[browser_autoshow_bookmarks_toolbar.js] +[browser_bookmarkMenu_hiddenWindow.js] +skip-if = os != 'mac' # Mac-only functionality +[browser_bookmarkProperties_addFolderDefaultButton.js] +[browser_bookmarkProperties_addKeywordForThisSearch.js] +[browser_bookmarkProperties_bookmarkAllTabs.js] +[browser_bookmarkProperties_cancel.js] +[browser_bookmarkProperties_editFolder.js] +[browser_bookmarkProperties_editTagContainer.js] +[browser_bookmarkProperties_folderSelection.js] +[browser_bookmarkProperties_newFolder.js] +[browser_bookmarkProperties_no_user_actions.js] +[browser_bookmarkProperties_readOnlyRoot.js] +[browser_bookmarkProperties_remember_folders.js] +[browser_bookmarkProperties_speculativeConnection.js] +[browser_bookmarkProperties_xulStore.js] +[browser_bookmark_add_tags.js] +https_first_disabled = true +[browser_bookmark_all_tabs.js] +https_first_disabled = true +support-files = + bookmark_dummy_1.html + bookmark_dummy_2.html +[browser_bookmark_backup_export_import.js] +[browser_bookmark_change_location.js] +[browser_bookmark_context_menu_contents.js] +[browser_bookmark_copy_folder_tree.js] +[browser_bookmark_folder_moveability.js] +[browser_bookmark_menu_ctrl_click.js] +[browser_bookmark_popup.js] +skip-if = verify && os == "win" +[browser_bookmark_private_window.js] +[browser_bookmark_remove_tags.js] +[browser_bookmark_titles.js] +https_first_disabled = true +support-files = ../../../../base/content/test/general/dummy_page.html +[browser_bookmarklet_windowOpen.js] +support-files = + bookmarklet_windowOpen_dummy.html +[browser_bookmarksProperties.js] +[browser_bookmarks_change_title.js] +[browser_bookmarks_change_url.js] +[browser_bookmarks_sidebar_search.js] +support-files = + pageopeningwindow.html +[browser_bookmarks_toolbar_context_menu_view_options.js] +[browser_bookmarks_toolbar_telemetry.js] +[browser_bug427633_no_newfolder_if_noip.js] +[browser_bug485100-change-case-loses-tag.js] +[browser_bug631374_tags_selector_scroll.js] +support-files = + favicon-normal16.png +[browser_check_correct_controllers.js] +[browser_click_bookmarks_on_toolbar.js] +https_first_disabled = true +[browser_controller_onDrop.js] +[browser_controller_onDrop_query.js] +[browser_controller_onDrop_sidebar.js] +[browser_controller_onDrop_tagFolder.js] +[browser_copy_query_without_tree.js] +[browser_cutting_bookmarks.js] +[browser_default_bookmark_location.js] +[browser_drag_bookmarks_on_toolbar.js] +[browser_drag_folder_on_newTab.js] +https_first_disabled = true +[browser_editBookmark_keywords.js] +[browser_enable_toolbar_sidebar.js] +skip-if = verify && debug && os == 'win' +[browser_forgetthissite.js] +[browser_history_sidebar_search.js] +[browser_import_button.js] +[browser_library_bookmark_clear_visits.js] +[browser_library_bookmark_pages.js] +[browser_library_bulk_tag_bookmarks.js] +[browser_library_commands.js] +[browser_library_delete.js] +[browser_library_delete_bookmarks_in_tags.js] +[browser_library_delete_tags.js] +[browser_library_downloads.js] +[browser_library_left_pane_middleclick.js] +[browser_library_left_pane_select_hierarchy.js] +[browser_library_middleclick.js] +[browser_library_new_bookmark.js] +[browser_library_openFlatContainer.js] +[browser_library_open_all.js] +[browser_library_open_all_with_separator.js] +[browser_library_open_bookmark.js] +[browser_library_open_leak.js] +[browser_library_panel_leak.js] +[browser_library_search.js] +[browser_library_telemetry.js] +[browser_library_tree_leak.js] +[browser_library_views_liveupdate.js] +[browser_library_warnOnOpen.js] +[browser_markPageAsFollowedLink.js] +[browser_panelview_bookmarks_delete.js] +[browser_paste_bookmarks.js] +[browser_paste_into_tags.js] +[browser_paste_resets_cut_highlights.js] +[browser_remove_bookmarks.js] +[browser_sidebar_bookmarks_telemetry.js] +[browser_sidebar_history_telemetry.js] +[browser_sidebar_open_bookmarks.js] +[browser_sidebarpanels_click.js] +[browser_sort_in_library.js] +[browser_stayopenmenu.js] +[browser_toolbar_drop_bookmarklet.js] +[browser_toolbar_drop_multiple_flavors.js] +[browser_toolbar_drop_multiple_with_bookmarklet.js] +[browser_toolbar_drop_text.js] +[browser_toolbar_library_open_recent.js] +https_first_disabled = true +[browser_toolbar_other_bookmarks.js] +[browser_toolbar_overflow.js] +[browser_toolbarbutton_menu_context.js] +[browser_toolbarbutton_menu_show_in_folder.js] +[browser_views_iconsupdate.js] +[browser_views_liveupdate.js] diff --git a/browser/components/places/tests/browser/browser_addBookmarkForFrame.js b/browser/components/places/tests/browser/browser_addBookmarkForFrame.js new file mode 100644 index 0000000000..44a7f5e17a --- /dev/null +++ b/browser/components/places/tests/browser/browser_addBookmarkForFrame.js @@ -0,0 +1,150 @@ +/** + * Tests that the add bookmark for frame dialog functions correctly. + */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser"; +const PAGE_URL = BASE_URL + "/framedPage.html"; +const LEFT_URL = BASE_URL + "/frameLeft.html"; +const RIGHT_URL = BASE_URL + "/frameRight.html"; + +function activateBookmarkFrame(contentAreaContextMenu) { + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popuphidden" + ); + return async function () { + let frameMenuItem = document.getElementById("frame"); + let frameMenu = frameMenuItem.querySelector(":scope > menupopup"); + let frameMenuShown = BrowserTestUtils.waitForEvent(frameMenu, "popupshown"); + frameMenuItem.openMenu(true); + await frameMenuShown; + let bookmarkFrame = document.getElementById("context-bookmarkframe"); + frameMenu.activateItem(bookmarkFrame); + await popupHiddenPromise; + }; +} + +async function withAddBookmarkForFrame(taskFn) { + // Open a tab and wait for all the subframes to load. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#left", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShownPromise; + + await withBookmarksDialog( + true, + activateBookmarkFrame(contentAreaContextMenu), + taskFn + ); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_open_add_bookmark_for_frame() { + info("Test basic opening of the add bookmark for frame dialog."); + await withAddBookmarkForFrame(async dialogWin => { + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok(!namepicker.readOnly, "Name field is writable"); + Assert.equal(namepicker.value, "Left frame", "Name field is correct."); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + await TestUtils.waitForCondition( + () => folderPicker.selectedItem.label == expectedFolderName, + "Dialog: The folder is the expected one." + ); + + let tagsField = dialogWin.document.getElementById("editBMPanel_tagsField"); + Assert.equal(tagsField.value, "", "Dialog: The tags field should be empty"); + }); +}); + +add_task(async function test_move_bookmark_whilst_add_bookmark_open() { + info( + "EditBookmark: Test moving a bookmark whilst the add bookmark for frame dialog is open." + ); + await PlacesUtils.bookmarks.eraseEverything(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#left", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShownPromise; + + await withBookmarksDialog( + false, + activateBookmarkFrame(contentAreaContextMenu), + async function (dialogWin) { + let expectedGuid = await PlacesUIUtils.defaultParentGuid; + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + Assert.equal( + folderPicker.selectedItem.label, + expectedFolderName, + "EditBookmark: The folder is the expected one." + ); + + Assert.equal( + folderPicker.getAttribute("selectedGuid"), + expectedGuid, + "EditBookmark: Should have the correct default guid selected" + ); + + dialogWin.document.getElementById("editBMPanel_foldersExpander").click(); + let folderTree = dialogWin.document.getElementById( + "editBMPanel_folderTree" + ); + folderTree.selectItems([PlacesUtils.bookmarks.menuGuid]); + folderTree.blur(); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + let url = makeURI(LEFT_URL); + // Check the bookmark has been moved as expected. + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + + Assert.equal( + bookmark.parentGuid, + PlacesUtils.bookmarks.menuGuid, + "EditBookmark: The bookmark should be moved to the expected folder." + ); + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js b/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js new file mode 100644 index 0000000000..c841eb276b --- /dev/null +++ b/browser/components/places/tests/browser/browser_autoshow_bookmarks_toolbar.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LOCATION_PREF = "browser.bookmarks.defaultLocation"; +const TOOLBAR_VISIBILITY_PREF = "browser.toolbars.bookmarks.visibility"; +let bookmarkPanel; +let win; + +add_setup(async function () { + Services.prefs.clearUserPref(LOCATION_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + let oldTimeout = win.StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't interact badly with tests. + win.StarUI._autoCloseTimeout = 6000000; + + win.StarUI._createPanelIfNeeded(); + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + win.StarUI._autoCloseTimeout = oldTimeout; + await BrowserTestUtils.closeWindow(win); + win = null; + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(LOCATION_PREF); + }); +}); + +/** + * Helper to check we've shown the toolbar + * + * @param {object} options + * Options for the test + * @param {boolean} options.showToolbar + * If the toolbar should be shown or not + * @param {string} options.expectedFolder + * The expected folder to be shown + * @param {string} options.reason + * The reason the toolbar should be shown + */ +async function checkResponse({ showToolbar, expectedFolder, reason }) { + // Check folder. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have ${expectedFolder} selected ${reason}.` + ); + + // Check toolbar: + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + !toolbar.collapsed, + showToolbar, + `Toolbar should be ${showToolbar ? "visible" : "hidden"} ${reason}.` + ); + + // Confirm and close the dialog. + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +} + +/** + * Test that if we create a bookmark on the toolbar, we show the + * toolbar: + */ +add_task(async function test_new_on_toolbar() { + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/1" }, + async browser => { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + await TestUtils.waitForCondition( + () => !toolbar.collapsed, + "Toolbar should be shown." + ); + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when creating a bookmark there"; + await checkResponse({ showToolbar: true, expectedFolder, reason }); + } + ); +}); + +/** + * Test that if we create a bookmark on the toolbar, we do not + * show the toolbar if toolbar should never be shown: + */ +add_task(async function test_new_on_toolbar_never_show_toolbar() { + await SpecialPowers.pushPrefEnv({ + set: [[TOOLBAR_VISIBILITY_PREF, "never"]], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/1" }, + async browser => { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when the visibility pref is 'never'"; + await checkResponse({ showToolbar: false, expectedFolder, reason }); + } + ); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Test that if we edit an existing bookmark, we don't show the toolbar. + */ +add_task(async function test_existing_on_toolbar() { + // Create the bookmark first: + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Test for editing", + url: "https://example.com/editing-test", + }); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/editing-test" }, + async browser => { + await TestUtils.waitForCondition( + () => win.BookmarkingUI.status == BookmarkingUI.STATUS_STARRED, + "Page should be starred." + ); + + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.equal( + toolbar.collapsed, + true, + "Bookmarks toolbar should start out collapsed." + ); + await clickBookmarkStar(win); + let expectedFolder = "BookmarksToolbarFolderTitle"; + let reason = "when editing a bookmark there"; + await checkResponse({ showToolbar: false, expectedFolder, reason }); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js b/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js new file mode 100644 index 0000000000..4502b65e69 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkMenu_hiddenWindow.js @@ -0,0 +1,49 @@ +/* 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"; + +add_setup(async function () { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Test1", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_menu_in_hidden_window() { + let hwDoc = Services.appShell.hiddenDOMWindow.document; + let bmPopup = hwDoc.getElementById("bookmarksMenuPopup"); + var popupEvent = hwDoc.createEvent("MouseEvent"); + popupEvent.initMouseEvent( + "popupshowing", + true, + true, + Services.appShell.hiddenDOMWindow, + 0, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + 0, + null + ); + bmPopup.dispatchEvent(popupEvent); + + let testMenuitem = [...bmPopup.children].find( + node => node.getAttribute("label") == "Test1" + ); + Assert.ok( + testMenuitem, + "Should have found the test bookmark in the hidden window bookmark menu" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js b/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js new file mode 100644 index 0000000000..0c04dbd243 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js @@ -0,0 +1,68 @@ +"use strict"; + +add_task(async function add_folder_default_button() { + info( + "Bug 475529 - Add is the default button for the new folder dialog + " + + "Bug 1206376 - Changing properties of a new bookmark while adding it " + + "acts on the last bookmark in the current container" + ); + + // Add a new bookmark at index 0 in the unfiled folder. + let insertionIndex = 0; + let newBookmark = await PlacesUtils.bookmarks.insert({ + index: insertionIndex, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/", + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the new bookmark in the sidebar. + tree.selectItems([newBookmark.guid]); + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_new:folder"), + "'placesCmd_new:folder' on current selected node is enabled" + ); + + // Create a new folder. Since the new bookmark is selected, and new items + // are inserted at the index of the currently selected item, the new folder + // will be inserted at index 0. + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_new:folder"); + }, + async function test(dialogWin) { + const notifications = [ + PlacesTestUtils.waitForNotification("bookmark-added", events => + events.some(e => e.title === "n") + ), + PlacesTestUtils.waitForNotification("bookmark-moved", null), + ]; + + fillBookmarkTextField("editBMPanel_namePicker", "n", dialogWin, false); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await Promise.all(notifications); + + let newFolder = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: insertionIndex, + }); + + is(newFolder.title, "n", "folder name has been edited"); + + let bm = await PlacesUtils.bookmarks.fetch(newBookmark.guid); + Assert.equal( + bm.index, + insertionIndex + 1, + "Bookmark should have been shifted to the next index" + ); + + await PlacesUtils.bookmarks.remove(newFolder); + await PlacesUtils.bookmarks.remove(newBookmark); + } + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js b/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js new file mode 100644 index 0000000000..514519810a --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js @@ -0,0 +1,188 @@ +"use strict"; + +const TEST_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser/keyword_form.html"; + +let contentAreaContextMenu = document.getElementById("contentAreaContextMenu"); + +add_task(async function add_keyword() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form1 > input[name="search"]' + ); + + await withBookmarksDialog( + false, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + async function (dialogWin) { + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(acceptBtn.disabled, "Accept button is disabled"); + + let promiseKeywordNotification = PlacesTestUtils.waitForNotification( + "bookmark-keyword-changed", + events => events.some(event => event.keyword === "kw") + ); + + fillBookmarkTextField("editBMPanel_keywordField", "kw", dialogWin); + + Assert.ok(!acceptBtn.disabled, "Accept button is enabled"); + + acceptBtn.click(); + await promiseKeywordNotification; + + // After the notification, the keywords cache will update asynchronously. + info("Check the keyword entry has been created"); + let entry; + await TestUtils.waitForCondition(async function () { + entry = await PlacesUtils.keywords.fetch("kw"); + return !!entry; + }, "Unable to find the expected keyword"); + Assert.equal(entry.keyword, "kw", "keyword is correct"); + Assert.equal(entry.url.href, TEST_URL, "URL is correct"); + Assert.equal( + entry.postData, + "accenti%3D%E0%E8%EC%F2%F9&search%3D%25s", + "POST data is correct" + ); + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + bm.parentGuid, + await PlacesUIUtils.defaultParentGuid, + "Should have created the keyword in the right folder." + ); + + info("Check the charset has been saved"); + let pageInfo = await PlacesUtils.history.fetch(TEST_URL, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + "windows-1252", + "charset is correct" + ); + + // Now check getShortcutOrURI. + let data = await UrlbarUtils.getShortcutOrURIAndPostData("kw test"); + Assert.equal( + getPostDataString(data.postData), + "accenti=\u00E0\u00E8\u00EC\u00F2\u00F9&search=test", + "getShortcutOrURI POST data is correct" + ); + Assert.equal(data.url, TEST_URL, "getShortcutOrURI URL is correct"); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +add_task(async function reopen_same_field() { + await PlacesUtils.keywords.insert({ + url: TEST_URL, + keyword: "kw", + postData: "accenti%3D%E0%E8%EC%F2%F9&search%3D%25s", + }); + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("kw"); + }); + // Reopening on the same input field should show the existing keyword. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form1 > input[name="search"]' + ); + + await withBookmarksDialog( + true, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + async function (dialogWin) { + let elt = dialogWin.document.getElementById( + "editBMPanel_keywordField" + ); + Assert.equal(elt.value, "kw", "Keyword should be the previous value"); + + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + ok(!acceptBtn.disabled, "Accept button is enabled"); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +add_task(async function open_other_field() { + await PlacesUtils.keywords.insert({ + url: TEST_URL, + keyword: "kw2", + postData: "search%3D%25s", + }); + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("kw2"); + }); + // Reopening on another field of the same page that has different postData + // should not show the existing keyword. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + // We must wait for the context menu code to build metadata. + await openContextMenuForContentSelector( + browser, + '#form2 > input[name="search"]' + ); + + await withBookmarksDialog( + true, + function () { + AddKeywordForSearchField(); + contentAreaContextMenu.hidePopup(); + }, + function (dialogWin) { + let acceptBtn = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + ok(acceptBtn.disabled, "Accept button is disabled"); + + let elt = dialogWin.document.getElementById( + "editBMPanel_keywordField" + ); + is(elt.value, ""); + }, + () => PlacesUtils.bookmarks.eraseEverything() + ); + } + ); +}); + +function getPostDataString(stream) { + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + return sis.read(stream.available()).split("\n").pop(); +} diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js b/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js new file mode 100644 index 0000000000..805f9464e3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_bookmarkAllTabs.js @@ -0,0 +1,66 @@ +"use strict"; + +const TEST_URLS = ["about:robots", "about:mozilla"]; + +add_task(async function bookmark_all_tabs() { + let tabs = []; + for (let url of TEST_URLS) { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser, url)); + } + registerCleanupFunction(async function () { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withBookmarksDialog( + false, + function open() { + document.getElementById("Browser:BookmarkAllTabs").doCommand(); + }, + async dialog => { + let acceptBtn = dialog.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(!acceptBtn.disabled, "Accept button is enabled"); + + let namepicker = dialog.document.getElementById("editBMPanel_namePicker"); + Assert.ok(!namepicker.readOnly, "Name field is writable"); + let folderName = dialog.document + .getElementById("stringBundle") + .getString("bookmarkAllTabsDefault"); + Assert.equal(namepicker.value, folderName, "Name field is correct."); + + let promiseBookmarkAdded = + PlacesTestUtils.waitForNotification("bookmark-added"); + + fillBookmarkTextField("editBMPanel_namePicker", "folder", dialog); + + let folderPicker = dialog.document.getElementById( + "editBMPanel_folderMenuList" + ); + + let defaultParentGuid = await PlacesUIUtils.defaultParentGuid; + // Check the initial state of the folder picker. + await TestUtils.waitForCondition( + () => folderPicker.getAttribute("selectedGuid") == defaultParentGuid, + "The folder is the expected one." + ); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialog); + await promiseBookmarkAdded; + for (const url of TEST_URLS) { + const { parentGuid } = await PlacesUtils.bookmarks.fetch({ url }); + const folder = await PlacesUtils.bookmarks.fetch({ + guid: parentGuid, + }); + is( + folder.title, + "folder", + "Should have created the bookmark in the right folder." + ); + } + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js b/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js new file mode 100644 index 0000000000..5652358acb --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_cancel.js @@ -0,0 +1,126 @@ +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const sandbox = sinon.createSandbox(); + +registerCleanupFunction(async function () { + sandbox.restore(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +let bookmarks; // Bookmarks added via insertTree. + +add_setup(async function () { + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example.com", + }, + { + title: "bm2", + url: "http://example.com/2", + }, + ], + }); + + // Undo is called asynchronously - and not waited for. Since we're not + // expecting undo to be called, we can only tell this by stubbing it. + sandbox.stub(PlacesTransactions, "undo").returns(Promise.resolve()); +}); + +// Tests for bug 1391393 - Ensures that if the user cancels the bookmark properties +// dialog without having done any changes, then no undo is called. +add_task(async function test_cancel_with_no_changes() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([bookmarks[0].guid]); + + // Delete the bookmark to put something in the undo history. + // Rather than calling cmd_delete, we call the remove directly, so that we + // can await on it finishing, and be guaranteed that there's something + // in the history. + await tree.controller.remove("Remove Selection"); + + tree.selectItems([bookmarks[1].guid]); + + // Now open the bookmarks dialog and cancel it. + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + await TestUtils.waitForCondition( + () => !acceptButton.disabled, + "The accept button should be enabled" + ); + } + ); + + // Check the bookmark is still removed. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch(bookmarks[0].guid)), + "The originally removed bookmark should not exist." + ); + + Assert.ok( + await PlacesUtils.bookmarks.fetch(bookmarks[1].guid), + "The second bookmark should still exist" + ); + + Assert.ok( + PlacesTransactions.undo.notCalled, + "undo should not have been called" + ); + }); +}); + +add_task(async function test_cancel_with_changes() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([bookmarks[1].guid]); + + // Now open the bookmarks dialog and cancel it. + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + await TestUtils.waitForCondition( + () => !acceptButton.disabled, + "EditBookmark: The accept button should be enabled" + ); + + let namePicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + fillBookmarkTextField("editBMPanel_namePicker", "new_n", dialogWin); + + // Ensure that value in field has changed + Assert.equal( + namePicker.value, + "new_n", + "EditBookmark: The title is the expected one." + ); + } + ); + + let oldBookmark = await PlacesUtils.bookmarks.fetch(bookmarks[1].guid); + Assert.equal( + oldBookmark.title, + "bm2", + "EditBookmark: The title hasn't been changed" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js b/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js new file mode 100644 index 0000000000..fae9d01bec --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_editFolder.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the Properties dialog on a folder. + +add_task(async function test_bookmark_properties_dialog_on_folder() { + info("Bug 479348 - Properties on a root should be read-only."); + + let bm = await PlacesUtils.bookmarks.insert({ + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the new bookmark in the sidebar. + tree.selectItems([bm.guid]); + let folder = tree.selectedNode; + Assert.equal(folder.title, "folder", "Folder title is correct"); + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on folder is enabled" + ); + + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "EditBookmark: Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "folder", + "EditBookmark:Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "newname", dialogWin); + namepicker.blur(); + + Assert.equal( + namepicker.value, + "newname", + "EditBookmark: The title field has been changed" + ); + + // Confirm and close the dialog. + namepicker.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + // Ensure that the edit is finished before we hit cancel. + } + ); + + Assert.equal( + tree.selectedNode.title, + "newname", + "EditBookmark: The node has the correct title" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js b/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js new file mode 100644 index 0000000000..f4e3b3e3fe --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js @@ -0,0 +1,140 @@ +"use strict"; + +add_task(async function editTagContainer() { + info("Bug 479348 - Properties on a root should be read-only."); + let uri = Services.io.newURI("http://example.com/"); + let bm = await PlacesUtils.bookmarks.insert({ + url: uri.spec, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + PlacesUtils.tagging.tagURI(uri, ["tag1"]); + + let library = await promiseLibrary(); + let PlacesOrganizer = library.PlacesOrganizer; + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + PlacesOrganizer.selectLeftPaneBuiltIn("Tags"); + let tree = PlacesOrganizer._places; + let tagsContainer = tree.selectedNode; + tagsContainer.containerOpen = true; + let fooTag = tagsContainer.getChild(0); + let tagNode = fooTag; + tree.selectNode(fooTag); + Assert.equal(tagNode.title, "tag1", "EditBookmark: tagNode title is correct"); + + Assert.ok( + tree.controller.isCommandEnabled("placesCmd_show:info"), + "EditBookmark: 'placesCmd_show:info' on current selected node is enabled" + ); + + await withBookmarksDialog( + true, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "EditBookmark: Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "tag2", dialogWin); + + // Although we have received the expected notifications, we need + // to let everything resolve to ensure the UI is updated. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + Assert.equal( + namepicker.value, + "tag2", + "EditBookmark: The title field has been changed" + ); + + // Try to set an empty title, it should restore the previous one. + fillBookmarkTextField("editBMPanel_namePicker", "", dialogWin); + + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: The title field has been changed" + ); + } + ); + + // Check the tag change hasn't changed + let tags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(tags.length, 1, "EditBookmark: Found the right number of tags"); + Assert.deepEqual( + tags, + ["tag1"], + "EditBookmark: Found the expected unchanged tag" + ); + + await withBookmarksDialog( + false, + function openDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is not read-only. + Assert.ok( + !dialogWin.gEditItemOverlay.readOnly, + "Dialog should not be read-only" + ); + + // Check that name picker is not read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok( + !namepicker.readOnly, + "EditBookmark: Name field should not be read-only" + ); + Assert.equal( + namepicker.value, + "tag1", + "EditBookmark: Node title is correct" + ); + + fillBookmarkTextField("editBMPanel_namePicker", "tag2", dialogWin); + + Assert.equal( + namepicker.value, + "tag2", + "EditBookmark: The title field has been changed" + ); + namepicker.blur(); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + + tags = PlacesUtils.tagging.getTagsForURI(uri); + Assert.equal(tags.length, 1, "EditBookmark: Found the right number of tags"); + Assert.deepEqual( + tags, + ["tag2"], + "EditBookmark: Found the expected Y changed tag" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js b/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js new file mode 100644 index 0000000000..c40f0425cf --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_folderSelection.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = "about:robots"; +let bookmarkPanel; +let folders; +let win; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + Services.prefs.clearUserPref("browser.bookmarks.defaultLocation"); + + win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let oldTimeout = win.StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + win.StarUI._autoCloseTimeout = 6000000; + + win.StarUI._createPanelIfNeeded(); + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + win.StarUI._autoCloseTimeout = oldTimeout; + await BrowserTestUtils.closeWindow(win); + win = null; + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_selectChoose() { + await clickBookmarkStar(win); + + // Open folder selector. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + let folderTreeRow = win.document.getElementById("editBMPanel_folderTreeRow"); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedGuid = PlacesUtils.bookmarks.toolbarGuid; + + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have the expected bookmarks folder selected by default" + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + expectedGuid, + "Should have the correct default guid selected" + ); + Assert.equal( + folderTreeRow.hidden, + true, + "Should have the folder tree hidden" + ); + + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Click the choose item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_chooseFolderMenuItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => !folderTreeRow.hidden, + "Should show the folder tree" + ); + let folderTree = win.document.getElementById("editBMPanel_folderTree"); + Assert.ok(folderTree.view, "The view should have been connected"); + + Assert.equal( + menuList.getAttribute("selectedGuid"), + expectedGuid, + "Should still have the correct selected guid" + ); + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + "Should have kept the same menu label" + ); + + let input = folderTree.shadowRoot.querySelector("input"); + + let newFolderButton = win.document.getElementById( + "editBMPanel_newFolderButton" + ); + newFolderButton.click(); // This will start editing. + + // Wait for editing: + await TestUtils.waitForCondition(() => !input.hidden); + + // Click the arrow to collapse the list. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_foldersExpander"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => folderTreeRow.hidden, + "Should hide the folder tree" + ); + ok(input.hidden, "Folder tree should not be broken."); + + // Click the arrow to re-show the list. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_foldersExpander"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => !folderTreeRow.hidden, + "Should re-show the folder tree" + ); + ok(input.hidden, "Folder tree should still not be broken."); + + await hideBookmarksPanel(win); + Assert.ok(!folderTree.view, "The view should have been disconnected"); +}); + +add_task(async function test_selectBookmarksMenu() { + await clickBookmarkStar(win); + + // Open folder selector. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Click the bookmarks menu item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_bmRootItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => + menuList.getAttribute("selectedGuid") == PlacesUtils.bookmarks.menuGuid, + "Should select the menu folder item" + ); + + Assert.equal( + menuList.label, + PlacesUtils.getString("BookmarksMenuFolderTitle"), + "Should have updated the menu label" + ); + + await hideBookmarksPanel(win); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js b/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js new file mode 100644 index 0000000000..23eec11e1d --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_newFolder.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const TEST_URL = "about:robots"; +StarUI._createPanelIfNeeded(); +const bookmarkPanel = document.getElementById("editBookmarkPanel"); +let folders; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + bookmarkPanel.setAttribute("animate", false); + + let oldTimeout = StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + StarUI._autoCloseTimeout = 6000000; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + StarUI._autoCloseTimeout = oldTimeout; + BrowserTestUtils.removeTab(tab); + bookmarkPanel.removeAttribute("animate"); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_newFolder() { + let newBookmarkObserver = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + await clickBookmarkStar(); + + // Open folder selector. + document.getElementById("editBMPanel_foldersExpander").click(); + + let folderTree = document.getElementById("editBMPanel_folderTree"); + + // Create new folder. + let newFolderButton = document.getElementById("editBMPanel_newFolderButton"); + newFolderButton.click(); + + let newFolderGuid; + let newFolderObserver = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => { + for (let { guid, itemType } of events) { + newFolderGuid = guid; + if (itemType == PlacesUtils.bookmarks.TYPE_FOLDER) { + return true; + } + } + return false; + } + ); + + let menulist = document.getElementById("editBMPanel_folderMenuList"); + + await newFolderObserver; + + // Wait for the folder to be created and for editing to start. + await TestUtils.waitForCondition( + () => folderTree.hasAttribute("editing"), + "Should be in edit mode for the new folder" + ); + + Assert.equal( + menulist.selectedItem.label, + newFolderButton.label, + "Should have the new folder selected by default" + ); + + let renameObserver = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "f") + ); + + // Enter a new name. + EventUtils.synthesizeKey("f", {}, window); + EventUtils.synthesizeKey("VK_RETURN", {}, window); + + await renameObserver; + + await TestUtils.waitForCondition( + () => !folderTree.hasAttribute("editing"), + "Should have stopped editing the new folder" + ); + + Assert.equal( + menulist.selectedItem.label, + "f", + "Should have the new folder title" + ); + + await hideBookmarksPanel(); + await newBookmarkObserver; + let bookmark = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + + Assert.equal( + bookmark.parentGuid, + newFolderGuid, + "The bookmark should be parented by the new folder" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js b/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js new file mode 100644 index 0000000000..67d1406bc1 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_no_user_actions.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_task(async function test_change_title_from_BookmarkStar() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Before Edit", + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + StarUI._createPanelIfNeeded(); + let bookmarkPanel = document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + window.gEditItemOverlay.toggleFolderTreeVisibility(); + + let folderTree = document.getElementById("editBMPanel_folderTree"); + + // canDrop should always return false. + let bookmarkWithId = JSON.stringify( + Object.assign({ + url: "http://example.com", + title: "Fake BM", + }) + ); + + let dt = { + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return this.types; + }, + mozGetDataAt(i) { + return bookmarkWithId; + }, + }; + + Assert.ok( + !folderTree.view.canDrop(1, Ci.nsITreeView.DROP_BEFORE, dt), + "Should not be able to drop a bookmark" + ); + + // User Actions should be disabled. + const userActions = [ + "cmd_undo", + "cmd_redo", + "cmd_cut", + "cmd_copy", + "cmd_paste", + "cmd_delete", + "cmd_selectAll", + // Anything starting with placesCmd_ should also be disabled. + "placesCmd_", + ]; + for (let action of userActions) { + Assert.ok( + !folderTree.view._controller.supportsCommand(action), + `${action} should be disabled for the folder tree in bookmarks properties` + ); + } + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js b/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js new file mode 100644 index 0000000000..d98b7477ec --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js @@ -0,0 +1,67 @@ +"use strict"; + +add_task(async function test_dialog() { + info("Bug 479348 - Properties dialog on a root should be read-only."); + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + Assert.ok( + !tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on current selected node is disabled" + ); + + await withBookmarksDialog( + true, + function openDialog() { + // Even if the cmd is disabled, we can execute it regardless. + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check that the dialog is read-only. + Assert.ok(dialogWin.gEditItemOverlay.readOnly, "Dialog is read-only"); + // Check that accept button is disabled + let acceptButton = dialogWin.document + .getElementById("bookmarkpropertiesdialog") + .getButton("accept"); + Assert.ok(acceptButton.disabled, "Accept button is disabled"); + + // Check that name picker is read only + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.ok(namepicker.readOnly, "Name field is read-only"); + Assert.equal( + namepicker.value, + PlacesUtils.getString("OtherBookmarksFolderTitle"), + "Node title is correct" + ); + } + ); + }); +}); + +add_task(async function test_library() { + info("Bug 479348 - Library info pane on a root should be read-only."); + let library = await promiseLibrary("UnfiledBookmarks"); + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + let PlacesOrganizer = library.PlacesOrganizer; + let tree = PlacesOrganizer._places; + tree.focus(); + Assert.ok( + !tree.controller.isCommandEnabled("placesCmd_show:info"), + "'placesCmd_show:info' on current selected node is disabled" + ); + + // Check that the pane is read-only. + Assert.ok(library.gEditItemOverlay.readOnly, "Info pane is read-only"); + + // Check that name picker is read only + let namepicker = library.document.getElementById("editBMPanel_namePicker"); + Assert.ok(namepicker.readOnly, "Name field is read-only"); + Assert.equal( + namepicker.value, + PlacesUtils.getString("OtherBookmarksFolderTitle"), + "Node title is correct" + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js new file mode 100644 index 0000000000..99da75d62f --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +/** + * Tests that multiple tags can be added to a bookmark using the star-shaped button, the library and the sidebar. + */ + +StarUI._createPanelIfNeeded(); +const bookmarkPanel = document.getElementById("editBookmarkPanel"); +let folders; + +async function openPopupAndSelectFolder(guid, newBookmark = false) { + await clickBookmarkStar(); + + let notificationPromise; + if (!newBookmark) { + notificationPromise = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => events.some(e => guid === e.parentGuid) + ); + } + + // Expand the folder tree. + document.getElementById("editBMPanel_foldersExpander").click(); + document.getElementById("editBMPanel_folderTree").selectItems([guid]); + + await hideBookmarksPanel(); + if (!newBookmark) { + await notificationPromise; + } +} + +async function assertRecentFolders(expectedGuids, msg) { + await clickBookmarkStar(); + + let actualGuids = []; + function getGuids() { + actualGuids = []; + const folderMenuPopup = document.getElementById( + "editBMPanel_folderMenuList" + ).menupopup; + + let separatorFound = false; + // The list of folders goes from editBMPanel_foldersSeparator to the end. + for (let child of folderMenuPopup.children) { + if (separatorFound) { + actualGuids.push(child.folderGuid); + } else if (child.id == "editBMPanel_foldersSeparator") { + separatorFound = true; + } + } + } + + // The dialog fills in the folder list asnychronously, so we might need to wait + // for that to complete. + await TestUtils.waitForCondition(() => { + getGuids(); + return actualGuids.length == expectedGuids.length; + }, `Should have opened dialog with expected recent folders for: ${msg}`); + + Assert.deepEqual(actualGuids, expectedGuids, msg); + + await hideBookmarksPanel(); + + // Give the metadata chance to be written to the database before we attempt + // to open the dialog again. + let diskGuids = []; + await TestUtils.waitForCondition(async () => { + diskGuids = await PlacesUtils.metadata.get( + PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, + [] + ); + return diskGuids.length == expectedGuids.length; + }, `Should have written data to disk for: ${msg}`); + + Assert.deepEqual( + diskGuids, + expectedGuids, + `Should match the disk GUIDS for ${msg}` + ); +} + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY); + + bookmarkPanel.setAttribute("animate", false); + + let oldTimeout = StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't iteract badly with tests. + StarUI._autoCloseTimeout = 6000000; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:robots", + waitForStateStop: true, + }); + + folders = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Bob", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Place", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Delight", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Surprise", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Treble Bob", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Principal", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "Max Default Recent Folders", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "One Over Default Maximum Recent Folders", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + registerCleanupFunction(async () => { + StarUI._autoCloseTimeout = oldTimeout; + BrowserTestUtils.removeTab(tab); + bookmarkPanel.removeAttribute("animate"); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY); + }); +}); + +add_task(async function test_remember_last_folder() { + await assertRecentFolders([], "Should have no recent folders to start with."); + + await openPopupAndSelectFolder(folders[0].guid, true); + + await assertRecentFolders( + [folders[0].guid], + "Should have one folder in the list." + ); +}); + +add_task(async function test_forget_oldest_folder() { + // Add some more folders. + let expectedFolders = [folders[0].guid]; + for (let i = 1; i < folders.length; i++) { + await assertRecentFolders( + expectedFolders, + "Should have only the expected folders in the list" + ); + + await openPopupAndSelectFolder(folders[i].guid); + + expectedFolders.unshift(folders[i].guid); + if (expectedFolders.length > PlacesUIUtils.maxRecentFolders) { + expectedFolders.pop(); + } + } + + await assertRecentFolders( + expectedFolders, + "Should have expired the original folder" + ); +}); + +add_task(async function test_reorder_folders() { + let expectedFolders = [ + folders[2].guid, + folders[7].guid, + folders[6].guid, + folders[5].guid, + folders[4].guid, + folders[3].guid, + folders[1].guid, + ]; + + // Take an old one and put it at the front. + await openPopupAndSelectFolder(folders[2].guid); + + await assertRecentFolders( + expectedFolders, + "Should have correctly re-ordered the list" + ); +}); + +add_task(async function test_change_max_recent_folders_pref() { + let expectedFolders = [folders[0].guid]; + + Services.prefs.setIntPref("browser.bookmarks.editDialog.maxRecentFolders", 1); + + await openPopupAndSelectFolder(folders[1].guid); + await openPopupAndSelectFolder(folders[0].guid); + + await assertRecentFolders( + expectedFolders, + "Should have only one recent folder in the bookmark edit panel" + ); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref( + "browser.bookmarks.editDialog.maxRecentFolders" + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js b/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js new file mode 100644 index 0000000000..621ea19bb9 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_speculativeConnection.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test to ensure that on "mousedown" in Toolbar we set Speculative Connection + */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const sandbox = sinon.createSandbox(); +let spy = sandbox + .stub(PlacesUIUtils, "setupSpeculativeConnection") + .returns(Promise.resolve()); + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + sandbox.restore(); + }); +}); + +add_task(async function checkToolbarSpeculativeConnection() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "https://example.com/", + title: "Bookmark 1", + }); + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + info("Synthesize mousedown on selected bookmark"); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + type: "mousedown", + }); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + type: "mouseup", + }); + + Assert.ok(spy.called, "Speculative connection for Toolbar called"); + sandbox.restore(); +}); + +add_task(async function checkMenuSpeculativeConnection() { + await PlacesUtils.bookmarks.eraseEverything(); + + info("Placing a Menu widget"); + let origBMBlocation = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ); + // Ensure BMB is available in UI. + if (!origBMBlocation) { + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR + ); + } + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + // if BMB was not originally in UI, remove it. + if (!origBMBlocation) { + CustomizableUI.removeWidgetFromArea("bookmarks-menu-button"); + } + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Bookmark 2", + }); + + // Test Bookmarks Menu Button + let BMB = document.getElementById("bookmarks-menu-button"); + let BMBpopup = document.getElementById("BMB_bookmarksPopup"); + let promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BMB, {}); + await promiseEvent; + info("Popupshown on Bookmarks-Menu-Button"); + + let menuBookmark = [...BMBpopup.children].find( + node => node.label == "Bookmark 2" + ); + + EventUtils.synthesizeMouseAtCenter(menuBookmark, { + type: "mousedown", + }); + EventUtils.synthesizeMouseAtCenter(menuBookmark, { + type: "mouseup", + }); + + Assert.ok(spy.called, "Speculative connection for Menu Button called"); + sandbox.restore(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js b/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js new file mode 100644 index 0000000000..1ba6f56949 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarkProperties_xulStore.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +add_task(async function () { + let mainFolder = await PlacesUtils.bookmarks.insert({ + title: "mainFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + await PlacesUtils.bookmarks.insertTree({ + guid: mainFolder.guid, + children: [ + { + title: "firstFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + { + title: "secondFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + let firstFolder = tree.selectedNode.getChild(0); + + tree.selectNode(firstFolder); + info("Synthesize click on selected node to open it."); + synthesizeClickOnSelectedTreeCell(tree); + info(`Get the hashed uri starts with "place:" and hash key&value pairs.`); + let hashedKey = PlacesUIUtils.obfuscateUrlForXulStore(firstFolder.uri); + + let docUrl = "chrome://browser/content/places/bookmarksSidebar.xhtml"; + + let value = Services.xulStore.getValue(docUrl, hashedKey, "open"); + + Assert.ok(hashedKey.startsWith("place:"), "Sanity check the hashed key"); + Assert.equal(value, "true", "Check the expected xulstore value"); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_add_tags.js b/browser/components/places/tests/browser/browser_bookmark_add_tags.js new file mode 100644 index 0000000000..dc0151793a --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_add_tags.js @@ -0,0 +1,220 @@ +/* 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"; + +/** + * Tests that multiple tags can be added to a bookmark using the star-shaped button, the library and the sidebar. + */ +let bookmarkPanel; +let bookmarkStar; + +async function clickBookmarkStar() { + let shownPromise = promisePopupShown(bookmarkPanel); + bookmarkStar.click(); + await shownPromise; +} + +async function hideBookmarksPanel(callback) { + let hiddenPromise = promisePopupHidden(bookmarkPanel); + callback(); + await hiddenPromise; +} + +registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_bookmarkProperties() { + const TEST_URL = "about:robots"; + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + win.StarUI._createPanelIfNeeded(); + win.StarUI._autoCloseTimeout = 1000; + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + bookmarkStar = win.BookmarkingUI.star; + + // Cleanup. + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.closeWindow(win); + }); + + let bookmarkPanelTitle = win.document.getElementById( + "editBookmarkPanelTitle" + ); + + // The bookmarks panel is expected to auto-close after this step. + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => !url || url == TEST_URL) + ); + await hideBookmarksPanel(async () => { + // Click the bookmark star to bookmark the page. + await clickBookmarkStar(); + await TestUtils.waitForCondition( + () => + win.document.l10n.getAttributes(bookmarkPanelTitle).id === + "bookmarks-add-bookmark", + "Bookmark title is correct" + ); + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Page is starred" + ); + }); + await promiseNotification; + + // Click the bookmark star again to add tags. + await clickBookmarkStar(); + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await TestUtils.waitForCondition( + () => + win.document.l10n.getAttributes(bookmarkPanelTitle).id === + "bookmarks-edit-bookmark", + "Bookmark title is correct" + ); + await fillBookmarkTextField("editBMPanel_tagsField", "tag1", win); + const doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + await hideBookmarksPanel(() => doneButton.click()); + await promiseNotification; + Assert.equal( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)).length, + 1, + "Found the right number of tags" + ); + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)), + ["tag1"] + ); + + // Click the bookmark star again, add more tags. + await clickBookmarkStar(); + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2, tag3", win); + await hideBookmarksPanel(() => doneButton.click()); + await promiseNotification; + + const bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + Assert.equal(bookmarks.length, 1, "Only one bookmark should exist"); + Assert.equal( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)).length, + 3, + "Found the right number of tags" + ); + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)), + ["tag1", "tag2", "tag3"] + ); + + // Cleanup. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_library() { + const uri = "http://example.com/"; + + // Add a bookmark. + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + // Open the Library on "UnfiledBookmarks". + let library = await promiseLibrary("UnfiledBookmarks"); + + // Cleanup. + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Add a tag to the bookmark. + fillBookmarkTextField("editBMPanel_tagsField", "tag1", library); + + await TestUtils.waitForCondition( + () => bookmarkNode.tags === "tag1", + "Node tag is correct" + ); + + // Add a new tag to the bookmark. + fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2", library); + + await TestUtils.waitForCondition( + () => bookmarkNode.tags === "tag1, tag2", + "Node tag is correct" + ); + + // Check the tag change has been completed. + let tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(uri)); + Assert.equal(tags.length, 2, "Found the right number of tags"); + Assert.deepEqual(tags, ["tag1", "tag2"], "Found the expected tags"); + + await promiseLibraryClosed(library); + // Cleanup. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_tags_from_sidebar() { + const TEST_URL = "about:buildconfig"; + + let bookmarks = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Bookmark Title", + }); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks.guid]); + // Add one tag. + await addTags(["tag1"], tree, ["tag1"]); + // Add 2 more tags. + await addTags(["tag2", "tag3"], tree, ["tag1", "tag2", "tag3"]); + }); + + async function addTags(tagValue, tree, expected) { + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), tagValue); + let tags = PlacesUtils.tagging.getTagsForURI( + Services.io.newURI(TEST_URL) + ); + + Assert.deepEqual(tags, expected, "Tags field is correctly populated"); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + } + + // Cleanup. + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_all_tabs.js b/browser/components/places/tests/browser/browser_bookmark_all_tabs.js new file mode 100644 index 0000000000..2852bf4019 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_all_tabs.js @@ -0,0 +1,46 @@ +/** + * Test for Bug 446171 - Name field of bookmarks saved via 'Bookmark All Tabs' + * has '(null)' value if history is disabled or just in private browsing mode + */ +"use strict"; + +add_task(async function () { + const BASE_URL = + "http://example.org/browser/browser/components/places/tests/browser/"; + const TEST_PAGES = [ + BASE_URL + "bookmark_dummy_1.html", + BASE_URL + "bookmark_dummy_2.html", + BASE_URL + "bookmark_dummy_1.html", + ]; + + function promiseAddTab(url) { + return BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + + let tabs = await Promise.all(TEST_PAGES.map(promiseAddTab)); + + let URIs = PlacesCommandHook.uniqueCurrentPages; + is(URIs.length, 3, "Only unique pages are returned"); + + Assert.deepEqual( + URIs.map(URI => URI.uri.spec), + [ + "about:blank", + BASE_URL + "bookmark_dummy_1.html", + BASE_URL + "bookmark_dummy_2.html", + ], + "Correct URIs are returned" + ); + + Assert.deepEqual( + URIs.map(URI => URI.title), + ["New Tab", "Bookmark Dummy 1", "Bookmark Dummy 2"], + "Correct titles are returned" + ); + + registerCleanupFunction(async function () { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js b/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js new file mode 100644 index 0000000000..8b954a8469 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_backup_export_import.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests bookmarks backup export/import as JSON file. + */ + +const BASE_URL = "http://example.com/"; + +const PLACES = [ + { + guid: PlacesUtils.bookmarks.menuGuid, + prefix: "In Menu", + total: 5, + }, + { + guid: PlacesUtils.bookmarks.toolbarGuid, + prefix: "In Toolbar", + total: 7, + }, + { + guid: PlacesUtils.bookmarks.unfiledGuid, + prefix: "In Other", + total: 8, + }, +]; + +var importExportPicker, saveDir, actualBookmarks; + +async function generateTestBookmarks() { + actualBookmarks = []; + for (let place of PLACES) { + let currentPlaceChildren = []; + for (let i = 1; i <= place.total; i++) { + currentPlaceChildren.push({ + url: `${BASE_URL}${i}`, + title: `${place.prefix} Bookmark: ${i}`, + }); + } + await PlacesUtils.bookmarks.insertTree({ + guid: place.guid, + children: currentPlaceChildren, + }); + actualBookmarks = actualBookmarks.concat(currentPlaceChildren); + } +} + +async function validateImportedBookmarksByParent( + parentGuid, + expectedChildrenTotal +) { + let currentPlace = PLACES.filter(elem => { + return elem.guid === parentGuid.toString(); + })[0]; + + let bookmarksTree = await PlacesUtils.promiseBookmarksTree(parentGuid); + + Assert.equal( + bookmarksTree.children.length, + expectedChildrenTotal, + `Imported bookmarks length should be ${expectedChildrenTotal}` + ); + + for (let importedBookmark of bookmarksTree.children) { + Assert.equal( + importedBookmark.type, + PlacesUtils.TYPE_X_MOZ_PLACE, + `Exported bookmarks should be of type bookmark` + ); + + let doesTitleContain = importedBookmark.title + .toString() + .includes(`${currentPlace.prefix} Bookmark`); + Assert.equal( + doesTitleContain, + true, + `Bookmark title should contain text: ${currentPlace.prefix} Bookmark` + ); + + let doesUriContains = importedBookmark.uri.toString().includes(BASE_URL); + Assert.equal(doesUriContains, true, "Bookmark uri should contain base url"); + } +} + +async function validateImportedBookmarks(fromPlaces) { + for (let i = 0; i < fromPlaces.length; i++) { + let parentContainer = fromPlaces[i]; + await validateImportedBookmarksByParent( + parentContainer.guid, + parentContainer.total + ); + } +} + +async function promiseImportExport(aWindow) { + saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + saveDir.append("temp-bookmarks-export"); + if (!saveDir.exists()) { + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + importExportPicker.displayDirectory = saveDir; + + return new Promise(resolve => { + importExportPicker.showCallback = async () => { + let fileName = "bookmarks-backup.json"; + let destFile = saveDir.clone(); + destFile.append(fileName); + importExportPicker.setFiles([destFile]); + resolve(destFile); + }; + }); +} + +add_setup(async function () { + await promisePlacesInitComplete(); + await PlacesUtils.bookmarks.eraseEverything(); + await generateTestBookmarks(); + importExportPicker = SpecialPowers.MockFilePicker; + importExportPicker.init(window); + + registerCleanupFunction(async () => { + importExportPicker.cleanup(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +async function showMaintenancePopup(libraryWindow) { + let button = libraryWindow.document.getElementById("maintenanceButton"); + let popup = libraryWindow.document.getElementById("maintenanceButtonPopup"); + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + info("Clicking maintenance menu"); + + button.openMenu(true); + + await shown; + info("Maintenance popup shown"); + return popup; +} + +add_task(async function test_export_json() { + let libraryWindow = await promiseLibrary(); + let popup = await showMaintenancePopup(libraryWindow); + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + + info("Activating #backupBookmarks"); + + let backupPromise = promiseImportExport(); + + popup.activateItem(popup.querySelector("#backupBookmarks")); + await hidden; + + info("Popup hidden"); + + let backupFile = await backupPromise; + await TestUtils.waitForCondition( + backupFile.exists, + "Backup file should exist" + ); + await promiseLibraryClosed(libraryWindow); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function showFileRestorePopup(libraryWindow) { + let parentPopup = await showMaintenancePopup(libraryWindow); + let popup = parentPopup.querySelector("#fileRestorePopup"); + let shown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + parentPopup.querySelector("#fileRestoreMenu").openMenu(true); + await shown; + return popup; +} + +add_task(async function test_import_json() { + let libraryWindow = await promiseLibrary(); + let popup = await showFileRestorePopup(libraryWindow); + + let backupPromise = promiseImportExport(); + let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen("accept"); + + let hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.activateItem(popup.querySelector("#restoreFromFile")); + await hidden; + + await backupPromise; + await dialogPromise; + + let restored = 0; + let promiseBookmarksRestored = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(() => ++restored == actualBookmarks.length) + ); + + await promiseBookmarksRestored; + await validateImportedBookmarks(PLACES); + await promiseLibraryClosed(libraryWindow); + + registerCleanupFunction(async () => { + if (saveDir) { + saveDir.remove(true); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_change_location.js b/browser/components/places/tests/browser/browser_bookmark_change_location.js new file mode 100644 index 0000000000..3a82b67a93 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_change_location.js @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the bookmark location (url) can be changed from the toolbar and the sidebar. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const TEST_URL2 = "about:credits"; +const TEST_URL3 = "about:config"; + +// Setup. +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "always"]], + }); + + // The following initialization code is necessary to avoid a frequent + // intermittent failure in verify-fission where, due to timings, we may or + // may not import default bookmarks. We also want to avoid the empty toolbar + // placeholder shifting stuff around. + info("Ensure Places init is complete"); + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + Cc["@mozilla.org/browser/browserglue;1"] + .getService(Ci.nsIObserver) + .observe(null, "browser-glue-test", "places-browser-init-complete"); + await placesInitCompleteObserved; + info("Add a bookmark to avoid showing the empty toolbar placeholder."); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "initial", + url: TEST_URL, + }); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + info("Show the bookmarks toolbar"); + await promiseSetToolbarVisibility(toolbar, true); + info("Ensure toolbar visibility was updated"); + await BrowserTestUtils.waitForEvent( + toolbar, + "BookmarksToolbarVisibilityUpdated" + ); + } + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_change_location_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties); + }, + async function test(dialogWin) { + // Check the initial location. + let locationPicker = dialogWin.document.getElementById( + "editBMPanel_locationField" + ); + Assert.equal( + locationPicker.value, + TEST_URL, + "EditBookmark: The current location is the expected one." + ); + + // To check whether the lastModified field will be updated correctly. + let lastModified = _getLastModified(toolbarBookmark.guid); + + // Update the "location" field. + fillBookmarkTextField( + "editBMPanel_locationField", + TEST_URL2, + dialogWin, + false + ); + + locationPicker.blur(); + + Assert.equal( + locationPicker.value, + TEST_URL2, + "EditBookmark: The changed location is the expected one." + ); + + locationPicker.focus(); + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + + await TestUtils.waitForCondition( + () => _getLastModified(toolbarBookmark.guid) > lastModified, + "EditBookmark: The lastModified will be greater than before updating." + ); + } + ); + + let updatedBm = await PlacesUtils.bookmarks.fetch(toolbarBookmark.guid); + Assert.equal( + updatedBm.url, + TEST_URL2, + "EditBookmark: Should have updated the bookmark location in the database." + ); +}); + +add_task(async function test_change_location_from_Sidebar() { + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL2 }); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bm.guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + // Check the initial location. + let locationPicker = dialogWin.document.getElementById( + "editBMPanel_locationField" + ); + Assert.equal( + locationPicker.value, + TEST_URL2, + "Sidebar - EditBookmark: The current location is the expected one." + ); + + // To check whether the lastModified field will be updated correctly. + let lastModified = _getLastModified(bm.guid); + + // Update the "location" field. + fillBookmarkTextField( + "editBMPanel_locationField", + TEST_URL3, + dialogWin, + false + ); + + Assert.equal( + locationPicker.value, + TEST_URL3, + "Sidebar - EditBookmark: The location is changed in dialog for prefered one." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + + await TestUtils.waitForCondition( + () => _getLastModified(bm.guid) > lastModified, + "Sidebar - EditBookmark: The lastModified will be greater than before updating." + ); + } + ); + + let updatedBm = await PlacesUtils.bookmarks.fetch(bm.guid); + Assert.equal( + updatedBm.url, + TEST_URL3, + "Sidebar - EditBookmark: Should have updated the bookmark location in the database." + ); + }); +}); + +function _getLastModified(guid) { + const toolbarNode = PlacesUtils.getFolderContents( + PlacesUtils.bookmarks.toolbarGuid + ).root; + + try { + for (let i = 0; i < toolbarNode.childCount; i++) { + const node = toolbarNode.getChild(i); + if (node.bookmarkGuid === guid) { + return node.lastModified; + } + } + + throw new Error(`Node for ${guid} was not found`); + } finally { + toolbarNode.containerOpen = false; + } +} diff --git a/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js b/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js new file mode 100644 index 0000000000..7f656a33ee --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_context_menu_contents.js @@ -0,0 +1,798 @@ +/* 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"; + +/** + * Test removing bookmarks from the Bookmarks Toolbar and Library. + */ +const SECOND_BOOKMARK_TITLE = "Second Bookmark Title"; +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; +const TEST_URL = "about:mozilla"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "userContextEnabled", + "privacy.userContext.enabled" +); + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +let OptionItemExists = (elementId, doc = document) => { + let optionItem = doc.getElementById(elementId); + + Assert.ok(optionItem, `Context menu contains the menuitem ${elementId}`); + Assert.ok( + BrowserTestUtils.is_visible(optionItem), + `Context menu option ${elementId} is visible` + ); +}; + +let OptionsMatchExpected = (contextMenu, expectedOptionItems) => { + let idList = []; + for (let elem of contextMenu.children) { + if ( + BrowserTestUtils.is_visible(elem) && + elem.localName !== "menuseparator" + ) { + idList.push(elem.id); + } + } + + Assert.deepEqual( + idList.sort(), + expectedOptionItems.sort(), + "Content is the same across both lists" + ); +}; + +let checkContextMenu = async (cbfunc, optionItems, doc = document) => { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: SECOND_BOOKMARK_TITLE, + url: TEST_URL, + }); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + // Open and check the context menu twice, once with + // `browser.tabs.loadBookmarksInTabs` set to true and again with it set to + // false. + for (let loadBookmarksInNewTab of [true, false]) { + info( + `Running checkContextMenu: ` + JSON.stringify({ loadBookmarksInNewTab }) + ); + + Services.prefs.setBoolPref( + "browser.tabs.loadBookmarksInTabs", + loadBookmarksInNewTab + ); + + // When `loadBookmarksInTabs` is true, the usual placesContext_open:newtab + // item is hidden and placesContext_open is shown. The tasks in this test + // assume that `loadBookmarksInTabs` is false, so when a caller expects + // placesContext_open:newtab to appear but not placesContext_open, add it to + // the list of expected items when the pref is set. + let expectedOptionItems = [...optionItems]; + if ( + loadBookmarksInNewTab && + optionItems.includes("placesContext_open:newtab") && + !optionItems.includes("placesContext_open") + ) { + expectedOptionItems.push("placesContext_open"); + } + + // The caller is responsible for opening the menu, via `cbfunc()`. + let contextMenu = await cbfunc(bookmark); + + for (let item of expectedOptionItems) { + OptionItemExists(item, doc); + } + + OptionsMatchExpected(contextMenu, expectedOptionItems); + + // Check the "default" attributes on placesContext_open and + // placesContext_open:newtab. + if (expectedOptionItems.includes("placesContext_open")) { + Assert.equal( + doc.getElementById("placesContext_open").getAttribute("default"), + loadBookmarksInNewTab ? "" : "true", + `placesContext_open has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` + ); + } + if (expectedOptionItems.includes("placesContext_open:newtab")) { + Assert.equal( + doc.getElementById("placesContext_open:newtab").getAttribute("default"), + loadBookmarksInNewTab ? "true" : "", + `placesContext_open:newtab has the correct "default" attribute when loadBookmarksInTabs = ${loadBookmarksInNewTab}` + ); + } + + contextMenu.hidePopup(); + } + + Services.prefs.clearUserPref("browser.tabs.loadBookmarksInTabs"); + await PlacesUtils.bookmarks.eraseEverything(); +}; + +add_task(async function test_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await checkContextMenu(async function () { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); + + let tabs = []; + let contextMenuOnContent; + + await checkContextMenu(async function () { + info("Check context menu after opening context menu on content"); + const toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + info("Open context menu on about:config"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + tabs.push(tab); + contextMenuOnContent = document.getElementById("contentAreaContextMenu"); + const popupShownPromiseOnContent = BrowserTestUtils.waitForEvent( + contextMenuOnContent, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(tab.linkedBrowser, { + button: 2, + type: "contextmenu", + }); + await popupShownPromiseOnContent; + contextMenuOnContent.hidePopup(); + + info("Check context menu on bookmark"); + const toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + const contextMenu = document.getElementById("placesContext"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + + return contextMenu; + }, optionItems); + + // We need to do a thorough cleanup to avoid leaking the window of + // 'about:config'. + for (let tab of tabs) { + const tabClosed = BrowserTestUtils.waitForTabClosing(tab); + BrowserTestUtils.removeTab(tab); + await tabClosed; + } +}); + +add_task(async function test_empty_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_paste", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let contextMenu = document.getElementById("placesContext"); + let toolbar = document.querySelector("#PlacesToolbarItems"); + let openToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "shown" + ); + + // Use the end of the toolbar because the beginning (and even middle, on + // some resolutions) might be occluded by the empty toolbar message, which + // has a different context menu. + let bounds = toolbar.getBoundingClientRect(); + EventUtils.synthesizeMouse(toolbar, bounds.width - 5, 5, { + type: "contextmenu", + }); + + await openToolbarContextMenuPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_separator_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let sep = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + let toolbarNode = getToolbarNodeForItemGuid(sep.guid); + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_folder_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_sortBy:name", + "placesContext_showAllBookmarks", + "toggle_PersonalToolbar", + "show-other-bookmarks_PersonalToolbar", + ]; + + await checkContextMenu(async function () { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + let toolbarNode = getToolbarNodeForItemGuid(folder.guid); + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + return contextMenu; + }, optionItems); +}); + +add_task(async function test_sidebar_folder_contextmenu_contents() { + let optionItems = [ + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_openBookmarkContainer:tabs", + "placesContext_sortBy:name", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_folders_contextmenu_contents() { + let optionItems = [ + "placesContext_show_folder:info", + "placesContext_deleteFolder", + "placesContext_cut", + "placesContext_copy", + "placesContext_sortBy:name", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder 1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + let folder2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder1.guid, folder2.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + tree.selectItems([bookmark.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_bookmark_search_contextmenu_contents() { + let optionItems = [ + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_showInFolder", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + info("Checking bookmark sidebar menu contents in search context"); + // Perform a search first + let searchBox = + SidebarUI.browser.contentDocument.getElementById("search-box"); + searchBox.value = SECOND_BOOKMARK_TITLE; + searchBox.doCommand(); + tree.selectItems([bookmark.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_library_bookmark_contextmenu_contents() { + let optionItems = [ + "placesContext_open", + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([bookmark.guid]); + synthesizeClickOnSelectedTreeCell(right, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); + +add_task(async function test_library_bookmark_search_contextmenu_contents() { + let optionItems = [ + "placesContext_open", + "placesContext_open:newtab", + "placesContext_open:newcontainertab", + "placesContext_open:newwindow", + "placesContext_open:newprivatewindow", + "placesContext_showInFolder", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + ]; + if (!userContextEnabled) { + optionItems.splice( + optionItems.indexOf("placesContext_open:newcontainertab"), + 1 + ); + } + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + info("Checking bookmark library menu contents in search context"); + // Perform a search first + let searchBox = right.ownerDocument.getElementById("searchFilter"); + searchBox.value = SECOND_BOOKMARK_TITLE; + searchBox.doCommand(); + + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([bookmark.guid]); + synthesizeClickOnSelectedTreeCell(right, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); + +add_task(async function test_sidebar_mixedselection_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([bookmark.guid, folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_bookmarks_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkLinks:tabs", + "placesContext_show_bookmark:info", + "placesContext_deleteBookmark", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let bookmark2 = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + tree.selectItems([bookmark.guid, bookmark2.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_multiple_links_contextmenu_contents() { + let optionItems = [ + "placesContext_openLinks:tabs", + "placesContext_delete_history", + "placesContext_copy", + "placesContext_createBookmark", + ]; + + await withSidebarTree("history", async tree => { + await checkContextMenu( + async bookmark => { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + // Sort by last visited. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + tree.selectAll(); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_sidebar_mixed_bookmarks_contextmenu_contents() { + let optionItems = [ + "placesContext_delete", + "placesContext_cut", + "placesContext_copy", + "placesContext_paste_group", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + ]; + + await withSidebarTree("bookmarks", async tree => { + await checkContextMenu( + async bookmark => { + let folder = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + tree.selectItems([bookmark.guid, folder.guid]); + + let contextMenu = + SidebarUI.browser.contentDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await popupShownPromise; + return contextMenu; + }, + optionItems, + SidebarUI.browser.contentDocument + ); + }); +}); + +add_task(async function test_library_noselection_contextmenu_contents() { + let optionItems = [ + "placesContext_openBookmarkContainer:tabs", + "placesContext_new:bookmark", + "placesContext_new:folder", + "placesContext_new:separator", + "placesContext_paste", + ]; + + await withLibraryWindow("BookmarksToolbar", async ({ left, right }) => { + await checkContextMenu( + async bookmark => { + let contextMenu = right.ownerDocument.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + right.selectItems([]); + EventUtils.synthesizeMouseAtCenter( + right.body, + { type: "contextmenu" }, + right.ownerGlobal + ); + await popupShownPromise; + return contextMenu; + }, + optionItems, + right.ownerDocument + ); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js b/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js new file mode 100644 index 0000000000..71b947f7ac --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_copy_folder_tree.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + let mainFolder = await PlacesUtils.bookmarks.insert({ + title: "mainFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + const selectedNodeComparator = { + equalTitle: itemNode => { + Assert.equal( + tree.selectedNode.title, + itemNode.title, + "Select expected title" + ); + }, + equalNode: itemNode => { + Assert.equal( + tree.selectedNode.bookmarkGuid, + itemNode.guid, + "Selected the expected node" + ); + }, + equalType: itemType => { + Assert.equal(tree.selectedNode.type, itemType, "Correct type"); + }, + + equalChildCount: childrenAmount => { + Assert.equal( + tree.selectedNode.childCount, + childrenAmount, + `${childrenAmount} children` + ); + }, + }; + let urlType = Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + + info("Create tree of: folderA => subFolderA => 3 bookmarkItems"); + await PlacesUtils.bookmarks.insertTree({ + guid: mainFolder.guid, + children: [ + { + title: "FolderA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "subFolderA", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "firstBM", + url: "http://example.com/1", + }, + { + title: "secondBM", + url: "http://example.com/2", + }, + { + title: "thirdBM", + url: "http://example.com/3", + }, + ], + }, + ], + }, + ], + }); + + info("Sanity check folderA, subFolderA, bookmarkItems"); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(mainFolder); + selectedNodeComparator.equalChildCount(1); + + let sourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(sourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(sourceFolder); + selectedNodeComparator.equalChildCount(1); + + let subSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(subSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(subSourceFolder); + selectedNodeComparator.equalChildCount(3); + + let bm_2 = tree.selectedNode.getChild(1); + tree.selectNode(bm_2); + selectedNodeComparator.equalTitle(bm_2); + + info( + "Copy folder tree from sourceFolder (folderA, subFolderA, bookmarkItems)" + ); + tree.selectNode(sourceFolder); + await promiseClipboard(() => { + tree.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + tree.selectItems([mainFolder.guid]); + + info("Paste copy of folderA"); + await tree.controller.paste(); + + info("Sanity check copy/paste operation - mainFolder has 2 children"); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalChildCount(2); + + info("Sanity check copy of folderA"); + let copySourceFolder = tree.selectedNode.getChild(1); + tree.selectNode(copySourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySourceFolder); + selectedNodeComparator.equalChildCount(1); + + info("Sanity check copy subFolderA"); + let copySubSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(copySubSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySubSourceFolder); + selectedNodeComparator.equalChildCount(3); + + info("Sanity check copy BookmarkItem"); + let copyBm_1 = tree.selectedNode.getChild(0); + tree.selectNode(copyBm_1); + selectedNodeComparator.equalTitle(copyBm_1); + + info("Undo copy operation"); + await PlacesTransactions.undo(); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + info("Sanity check undo operation - mainFolder has 1 child"); + selectedNodeComparator.equalChildCount(1); + + info("Redo copy operation"); + await PlacesTransactions.redo(); + tree.selectItems([mainFolder.guid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + info("Sanity check redo operation - mainFolder has 2 children"); + selectedNodeComparator.equalChildCount(2); + + info("Sanity check copy of folderA"); + copySourceFolder = tree.selectedNode.getChild(1); + tree.selectNode(copySourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalChildCount(1); + + info("Sanity check copy subFolderA"); + copySubSourceFolder = tree.selectedNode.getChild(0); + tree.selectNode(copySubSourceFolder); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + selectedNodeComparator.equalTitle(copySubSourceFolder); + selectedNodeComparator.equalChildCount(3); + + info("Sanity check copy BookmarkItem"); + let copyBm_2 = tree.selectedNode.getChild(1); + tree.selectNode(copyBm_2); + selectedNodeComparator.equalTitle(copyBm_2); + selectedNodeComparator.equalType(urlType); + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js b/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js new file mode 100644 index 0000000000..d981aa4713 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_folder_moveability.js @@ -0,0 +1,139 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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"; + +add_task(async function () { + let root = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await withSidebarTree("bookmarks", async function (tree) { + info("Test a regular folder"); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + tree.selectItems([folder.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + folder.guid, + "Selected the expected node" + ); + Assert.equal(tree.selectedNode.type, 6, "node is a folder"); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move regular folder node" + ); + + info("Test a folder shortcut"); + let shortcut = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:parent=${folder.guid}`, + }); + tree.selectItems([shortcut.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + shortcut.guid, + "Selected the expected node" + ); + Assert.equal(tree.selectedNode.type, 9, "node is a folder shortcut"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(tree.selectedNode), + folder.guid, + "shortcut node guid and concrete guid match" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move folder shortcut node" + ); + + info("Test a query"); + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + url: "http://foo.com", + }); + tree.selectItems([bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + bookmark.guid, + "Selected the expected node" + ); + let query = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:terms=foo`, + }); + tree.selectItems([query.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + query.guid, + "Selected the expected node" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "can move query node" + ); + + info("Test a tag container"); + PlacesUtils.tagging.tagURI(bookmark.url.URI, ["bar"]); + // Add the tags root query. + let tagsQuery = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "", + url: "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT, + }); + tree.selectItems([tagsQuery.guid]); + PlacesUtils.asQuery(tree.selectedNode).containerOpen = true; + Assert.equal(tree.selectedNode.childCount, 1, "has tags"); + let tagNode = tree.selectedNode.getChild(0); + Assert.ok( + !tree.controller.canMoveNode(tagNode), + "should not be able to move tag container node" + ); + tree.selectedNode.containerOpen = false; + + info( + "Test that special folders and cannot be moved but other shortcuts can." + ); + let roots = [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.toolbarGuid, + ]; + + for (let guid of roots) { + tree.selectItems([guid]); + Assert.ok( + !tree.controller.canMoveNode(tree.selectedNode), + "shouldn't be able to move default shortcuts to roots" + ); + let s = await PlacesUtils.bookmarks.insert({ + parentGuid: root.guid, + title: "bar", + url: `place:parent=${guid}`, + }); + tree.selectItems([s.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + s.guid, + "Selected the expected node" + ); + Assert.ok( + tree.controller.canMoveNode(tree.selectedNode), + "should be able to move user-created shortcuts to roots" + ); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js b/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js new file mode 100644 index 0000000000..4e3d29cf21 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_menu_ctrl_click.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function onPopupEvent(popup, evt) { + let fullEvent = "popup" + evt; + return BrowserTestUtils.waitForEvent(popup, fullEvent, false, e => { + return e.target == popup; + }); +} + +add_task(async function test_bookmarks_menu() { + // On macOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. This happens via the `contextmenu` event which is created + // by widget code, so our simulated clicks do not do so, so we can't test + // anything on macOS. + if (AppConstants.platform == "macosx") { + ok(true, "The test is ignored on Mac"); + return; + } + + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 4 + ); + + const button = document.getElementById("bookmarks-menu-button"); + const popup = document.getElementById("BMB_bookmarksPopup"); + + let shownPromise = onPopupEvent(popup, "shown"); + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + await shownPromise; + ok(true, "Bookmarks menu shown after button pressed"); + + // Close bookmarks popup. + let hiddenPromise = onPopupEvent(popup, "hidden"); + popup.hidePopup(); + await hiddenPromise; + + CustomizableUI.reset(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_popup.js b/browser/components/places/tests/browser/browser_bookmark_popup.js new file mode 100644 index 0000000000..b5385e3a08 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_popup.js @@ -0,0 +1,712 @@ +/* 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-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; +requestLongerTimeout(2); + +/** + * Test opening and closing the bookmarks panel. + */ +let win; +let bookmarkPanel; +let bookmarkStar; +let bookmarkPanelTitle; +let bookmarkRemoveButton; +let editBookmarkPanelRemoveButtonRect; + +const TEST_URL = "data:text/html,<html><body></body></html>"; + +add_setup(async function () { + win = await BrowserTestUtils.openNewBrowserWindow(); + + win.StarUI._createPanelIfNeeded(); + win.StarUI._closePanelQuickForTesting = true; + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + bookmarkStar = win.BookmarkingUI.star; + bookmarkPanelTitle = win.document.getElementById("editBookmarkPanelTitle"); + bookmarkRemoveButton = win.document.getElementById( + "editBookmarkPanelRemoveButton" + ); + + registerCleanupFunction(async () => { + delete win.StarUI._closePanelQuickForTesting; + await BrowserTestUtils.closeWindow(win); + }); +}); + +function mouseout() { + let mouseOutPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mouseout" + ); + EventUtils.synthesizeNativeMouseEvent({ + type: "mousemove", + target: win.gURLBar.textbox, + offsetX: 0, + offsetY: 0, + win, + }); + EventUtils.synthesizeMouse(bookmarkPanel, 0, 0, { type: "mouseout" }, win); + info("Waiting for mouseout event"); + return mouseOutPromise; +} + +async function test_bookmarks_popup({ + isNewBookmark, + popupShowFn, + popupEditFn, + shouldAutoClose, + popupHideFn, + isBookmarkRemoved, +}) { + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: TEST_URL }, + async function (browser) { + try { + if (!isNewBookmark) { + await PlacesUtils.bookmarks.insert({ + parentGuid: await PlacesUIUtils.defaultParentGuid, + url: TEST_URL, + title: "Home Page", + }); + } + + info(`BookmarkingUI.status is ${win.BookmarkingUI.status}`); + await TestUtils.waitForCondition( + () => win.BookmarkingUI.status != win.BookmarkingUI.STATUS_UPDATING, + "BookmarkingUI should not be updating" + ); + + Assert.equal( + bookmarkStar.hasAttribute("starred"), + !isNewBookmark, + "Page should only be starred prior to popupshown if editing bookmark" + ); + Assert.equal( + bookmarkPanel.state, + "closed", + "Panel should be 'closed' to start test" + ); + let shownPromise = promisePopupShown(bookmarkPanel); + await popupShowFn(browser); + await shownPromise; + Assert.equal( + bookmarkPanel.state, + "open", + "Panel should be 'open' after shownPromise is resolved" + ); + + editBookmarkPanelRemoveButtonRect = + bookmarkRemoveButton.getBoundingClientRect(); + + if (popupEditFn) { + await popupEditFn(); + } + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Page is starred" + ); + Assert.equal( + bookmarkPanelTitle.dataset.l10nId, + isNewBookmark ? "bookmarks-add-bookmark" : "bookmarks-edit-bookmark", + "title should match isEditingBookmark state" + ); + Assert.equal( + bookmarkRemoveButton.dataset.l10nId, + isNewBookmark ? "bookmark-panel-cancel" : "bookmark-panel-remove", + "remove/cancel button label should match isEditingBookmark state" + ); + + if (!shouldAutoClose) { + await new Promise(resolve => setTimeout(resolve, 400)); + Assert.equal( + bookmarkPanel.state, + "open", + "Panel should still be 'open' for non-autoclose" + ); + } + + let defaultLocation = await PlacesUIUtils.defaultParentGuid; + const promises = []; + if (isNewBookmark && !isBookmarkRemoved) { + // Expect new bookmark to be created. + promises.push( + PlacesTestUtils.waitForNotification("bookmark-added", events => + events.some( + ({ parentGuid, url }) => + parentGuid == defaultLocation && TEST_URL == url + ) + ) + ); + } + if (!isNewBookmark && isBookmarkRemoved) { + // Expect existing bookmark to be removed. + promises.push( + PlacesTestUtils.waitForNotification("bookmark-removed", events => + events.some( + ({ parentGuid, url }) => + parentGuid == defaultLocation && TEST_URL == url + ) + ) + ); + } + + promises.push(promisePopupHidden(bookmarkPanel)); + if (popupHideFn) { + await popupHideFn(); + } else { + // Move the mouse out of the way so that the panel will auto-close. + await mouseout(); + } + await Promise.all(promises); + + Assert.equal( + bookmarkStar.hasAttribute("starred"), + !isBookmarkRemoved, + "Page is starred after closing" + ); + + // Count number of bookmarks. + let count = 0; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, () => count++); + const message = isBookmarkRemoved + ? "No bookmark should exist" + : "Only one bookmark should exist"; + Assert.equal(count, isBookmarkRemoved ? 0 : 1, message); + } finally { + let bookmark = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + !!bookmark, + !isBookmarkRemoved, + "bookmark should not be present if a panel action should've removed it" + ); + if (bookmark) { + await PlacesUtils.bookmarks.remove(bookmark); + } + } + } + ); +} + +add_task(async function panel_shown_for_new_bookmarks_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); +}); + +add_task( + async function panel_shown_once_for_doubleclick_on_new_bookmark_star_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + EventUtils.synthesizeMouse( + bookmarkStar, + 10, + 10, + { clickCount: 2 }, + win + ); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_once_for_slow_doubleclick_on_new_bookmark_star_and_autocloses() { + todo( + false, + "bug 1250267, may need to add some tracking state to " + + "browser-places.js for this." + ); + + /* + await test_bookmarks_popup({ + isNewBookmark: true, + *popupShowFn() { + EventUtils.synthesizeMouse(bookmarkStar, 10, 10, window); + await new Promise(resolve => setTimeout(resolve, 300)); + EventUtils.synthesizeMouse(bookmarkStar, 10, 10, window); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + */ + } +); + +add_task( + async function panel_shown_for_keyboardshortcut_on_new_bookmark_star_and_autocloses() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + isBookmarkRemoved: false, + }); + } +); + +add_task(async function panel_shown_for_new_bookmarks_mousemove_mouseout() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { type: "mousemove" }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + await new Promise(resolve => setTimeout(resolve, 400)); + is( + bookmarkPanel.state, + "open", + "Panel should still be open on mousemove" + ); + }, + async popupHideFn() { + await mouseout(); + info("Got mouseout event, should autoclose now"); + }, + shouldAutoClose: false, + isBookmarkRemoved: false, + }); +}); + +add_task(async function panel_shown_for_new_bookmark_close_with_ESC() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: true, + popupHideFn() { + EventUtils.synthesizeKey("VK_ESCAPE", { accelKey: true }, win); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function panel_shown_for_editing_no_autoclose_close_with_ESC() { + await test_bookmarks_popup({ + isNewBookmark: false, + popupShowFn() { + bookmarkStar.click(); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("VK_ESCAPE", { accelKey: true }, win); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function panel_shown_for_new_bookmark_keypress_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + popupEditFn() { + EventUtils.sendChar("VK_TAB", win); + }, + shouldAutoClose: false, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function bookmark_with_invalid_default_folder() { + await createAndRemoveDefaultFolder(); + + await test_bookmarks_popup({ + isNewBookmark: true, + shouldAutoClose: true, + async popupShowFn(browser) { + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + }); +}); + +add_task( + async function panel_shown_for_new_bookmark_compositionstart_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let compositionStartPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "compositionstart" + ); + EventUtils.synthesizeComposition({ type: "compositionstart" }, win); + info("Waiting for compositionstart event"); + await compositionStartPromise; + info("Got compositionstart event"); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeComposition( + { type: "compositioncommitasis" }, + win + ); + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_for_new_bookmark_compositionstart_mouseout_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { + type: "mousemove", + }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + let compositionStartPromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "compositionstart" + ); + EventUtils.synthesizeComposition({ type: "compositionstart" }, win); + info("Waiting for compositionstart event"); + await compositionStartPromise; + info("Got compositionstart event"); + + await mouseout(); + info("Got mouseout event, but shouldn't run autoclose"); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeComposition( + { type: "compositioncommitasis" }, + win + ); + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); + } +); + +add_task( + async function panel_shown_for_new_bookmark_compositionend_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn() { + bookmarkStar.click(); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { + type: "mousemove", + }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + EventUtils.synthesizeComposition( + { + type: "compositioncommit", + data: "committed text", + }, + win + ); + }, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + shouldAutoClose: false, + isBookmarkRemoved: false, + }); + } +); + +add_task(async function contextmenu_new_bookmark_keypress_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + async popupShowFn(browser) { + let contextMenu = win.document.getElementById("contentAreaContextMenu"); + let awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + let awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { + type: "contextmenu", + button: 2, + }, + browser + ); + await awaitPopupShown; + contextMenu.activateItem( + win.document.getElementById("context-bookmarkpage") + ); + await awaitPopupHidden; + }, + popupEditFn() { + EventUtils.sendChar("VK_TAB", win); + }, + shouldAutoClose: false, + popupHideFn() { + bookmarkPanel.hidePopup(); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function bookmarks_menu_new_bookmark_remove_bookmark() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + win.document.getElementById("menu_bookmarkThisPage").doCommand(); + }, + shouldAutoClose: true, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function ctrl_d_edit_bookmark_remove_bookmark() { + await test_bookmarks_popup({ + isNewBookmark: false, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function enter_on_remove_bookmark_should_remove_bookmark() { + if (AppConstants.platform == "macosx") { + // "Full Keyboard Access" is disabled by default, and thus doesn't allow + // keyboard navigation to the "Remove Bookmarks" button by default. + return; + } + + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: true, + popupHideFn() { + while ( + !win.document.activeElement || + win.document.activeElement.id != "editBookmarkPanelRemoveButton" + ) { + EventUtils.sendChar("VK_TAB", win); + } + EventUtils.sendChar("VK_RETURN", win); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function mouse_hovering_panel_should_prevent_autoclose() { + if (AppConstants.platform != "win") { + // This test requires synthesizing native mouse movement which is + // best supported on Windows. + return; + } + await test_bookmarks_popup({ + isNewBookmark: true, + async popupShowFn() { + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: win.document.documentElement, + offsetX: editBookmarkPanelRemoveButtonRect.left, + offsetY: editBookmarkPanelRemoveButtonRect.top, + }); + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + shouldAutoClose: false, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function ctrl_d_new_bookmark_mousedown_mouseout_no_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: true, + popupShowFn(browser) { + EventUtils.synthesizeKey("D", { accelKey: true }, win); + }, + async popupEditFn() { + let mouseMovePromise = BrowserTestUtils.waitForEvent( + bookmarkPanel, + "mousemove" + ); + EventUtils.synthesizeMouseAtCenter( + bookmarkPanel, + { type: "mousemove" }, + win + ); + info("Waiting for mousemove event"); + await mouseMovePromise; + info("Got mousemove event"); + + await new Promise(resolve => setTimeout(resolve, 400)); + is( + bookmarkPanel.state, + "open", + "Panel should still be open on mousemove" + ); + + EventUtils.synthesizeMouseAtCenter( + bookmarkPanelTitle, + { + button: 1, + type: "mousedown", + }, + win + ); + + await mouseout(); + }, + shouldAutoClose: false, + popupHideFn() { + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + }, + isBookmarkRemoved: true, + }); +}); + +add_task(async function enter_during_autocomplete_should_prevent_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: false, + async popupShowFn(browser) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), ["Abc"]); + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + async popupEditFn() { + // Start autocomplete with the registered tag. + let tagsField = win.document.getElementById("editBMPanel_tagsField"); + tagsField.value = ""; + let popup = win.document.getElementById("editBMPanel_tagsAutocomplete"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + tagsField.focus(); + EventUtils.sendString("a", win); + await promiseShown; + ok(promiseShown, "autocomplete shown"); + + // Select first candidate. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + // Type Enter key to choose the item. + EventUtils.synthesizeKey("KEY_Enter", {}, win); + + Assert.equal( + tagsField.value, + "Abc", + "Autocomplete should've inserted the selected item" + ); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + isBookmarkRemoved: false, + }); +}); + +add_task(async function escape_during_autocomplete_should_prevent_autoclose() { + await test_bookmarks_popup({ + isNewBookmark: false, + async popupShowFn(browser) { + PlacesUtils.tagging.tagURI(makeURI(TEST_URL), ["Abc"]); + EventUtils.synthesizeKey("d", { accelKey: true }, win); + }, + async popupEditFn() { + // Start autocomplete with the registered tag. + let tagsField = win.document.getElementById("editBMPanel_tagsField"); + tagsField.value = ""; + let popup = win.document.getElementById("editBMPanel_tagsAutocomplete"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + tagsField.focus(); + EventUtils.sendString("a", win); + await promiseShown; + ok(promiseShown, "autocomplete shown"); + + // Select first candidate. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + + // Type Escape key to close autocomplete. + EventUtils.synthesizeKey("KEY_Escape", {}, win); + + // The text reverts to what was typed. + // Note, it's important that this is different from the previously + // inserted tag, since it will test an untag/tag undo condition. + Assert.equal( + tagsField.value, + "a", + "Autocomplete should revert to what was typed" + ); + }, + shouldAutoClose: false, + popupHideFn() { + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + isBookmarkRemoved: false, + }); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_private_window.js b/browser/components/places/tests/browser/browser_bookmark_private_window.js new file mode 100644 index 0000000000..2557833816 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_private_window.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a website can be bookmarked from a private window. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; + +// Cleanup. +registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_add_bookmark_from_private_window() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + }); + + // Open the bookmark panel. + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + let bookmarkAddedPromise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + await shownPromise; + + // Check if the bookmark star changes its state after click. + Assert.equal( + bookmarkStar.getAttribute("starred"), + "true", + "Bookmark star changed its state correctly." + ); + + // Close the bookmark panel. + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; + await bookmarkAddedPromise; + + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + Assert.equal( + bm.url, + TEST_URL, + "The bookmark was successfully saved in the database." + ); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_remove_tags.js b/browser/components/places/tests/browser/browser_bookmark_remove_tags.js new file mode 100644 index 0000000000..ece5fdbce0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_remove_tags.js @@ -0,0 +1,256 @@ +/** + * Tests that the bookmark tags can be removed from the bookmark star, toolbar and sidebar. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const TEST_URI = Services.io.newURI(TEST_URL); +const TEST_TAG = "tag"; + +// Setup. +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_remove_tags_from_BookmarkStar() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + PlacesUtils.tagging.tagURI(TEST_URI, ["tag1", "tag2", "tag3", "tag4"]); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + }); + + StarUI._createPanelIfNeeded(); + await clickBookmarkStar(); + + // Check if the "Edit This Bookmark" panel is open. + let bookmarkPanelTitle = document.getElementById("editBookmarkPanelTitle"); + Assert.equal( + document.l10n.getAttributes(bookmarkPanelTitle).id, + "bookmarks-edit-bookmark", + "Bookmark panel title is correct." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField("editBMPanel_tagsField", "tag1, tag2, tag3", window); + let tagspicker = document.getElementById("editBMPanel_tagsField"); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1, tag2, tag3", + "Tags are correct after update." + ); + + let doneButton = document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1", "tag2", "tag3"], + "Should have updated the bookmark tags in the database." + ); +}); + +add_task(async function test_remove_tags_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_URL, + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties, {}); + }, + async function test(dialogWin) { + let tagspicker = dialogWin.document.getElementById( + "editBMPanel_tagsField" + ); + Assert.equal( + tagspicker.value, + "tag1, tag2, tag3", + "Tags are correct before update." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField( + "editBMPanel_tagsField", + "tag1, tag2", + dialogWin, + false + ); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1, tag2", + "Tags are correct after update." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1", "tag2"], + "Should have updated the bookmark tags in the database." + ); + } + ); +}); + +add_task(async function test_remove_tags_from_Sidebar() { + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks[0].guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let tagspicker = dialogWin.document.getElementById( + "editBMPanel_tagsField" + ); + Assert.equal( + tagspicker.value, + "tag1, tag2", + "Tags are correct before update." + ); + + let promiseTagsChange = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + // Update the "tags" field. + fillBookmarkTextField( + "editBMPanel_tagsField", + "tag1", + dialogWin, + false + ); + await TestUtils.waitForCondition( + () => tagspicker.value === "tag1", + "Tags are correct after update." + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTagsChange; + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + Assert.deepEqual( + tags, + ["tag1"], + "Should have updated the bookmark tags in the database." + ); + } + ); + }); +}); + +add_task(async function test_remove_tags_from_Library() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + PlacesUtils.tagging.tagURI(TEST_URI, [TEST_TAG]); + const getTags = () => PlacesUtils.tagging.getTagsForURI(TEST_URI); + + // Open the Library and select the tag. + const library = await promiseLibrary("place:tag=" + TEST_TAG); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + const contextMenu = library.document.getElementById("placesContext"); + const contextMenuDeleteTag = library.document.getElementById( + "placesContext_removeTag" + ); + + let firstColumn = library.ContentTree.view.columns[0]; + let firstBookmarkRect = library.ContentTree.view.getCoordsForCellItem( + 0, + firstColumn, + "bm0" + ); + + EventUtils.synthesizeMouse( + library.ContentTree.view.body, + firstBookmarkRect.x, + firstBookmarkRect.y, + { type: "contextmenu", button: 2 }, + library + ); + + await BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + + ok(getTags().includes(TEST_TAG), "Test tag exists before delete."); + + contextMenu.activateItem(contextMenuDeleteTag, {}); + + await PlacesTestUtils.waitForNotification("bookmark-tags-changed"); + await promiseLibraryClosed(library); + + ok( + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + "Bookmark still exists after removing tag." + ); + ok(!getTags().includes(TEST_TAG), "Test tag is removed after delete."); +}); diff --git a/browser/components/places/tests/browser/browser_bookmark_titles.js b/browser/components/places/tests/browser/browser_bookmark_titles.js new file mode 100644 index 0000000000..8b989b1958 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmark_titles.js @@ -0,0 +1,129 @@ +/* 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/. */ + +// This file is tests for the default titles that new bookmarks get. + +var tests = [ + // Common page. + { + url: "http://example.com/browser/browser/components/places/tests/browser/dummy_page.html", + title: "Dummy test page", + isError: false, + }, + // Data URI. + { + url: "data:text/html;charset=utf-8,<title>test%20data:%20url</title>", + title: "test data: url", + isError: false, + }, + // about:neterror + { + url: "data:application/xhtml+xml,", + title: "data:application/xhtml+xml,", + isError: true, + }, + // about:certerror + { + url: "https://untrusted.example.com/somepage.html", + title: "https://untrusted.example.com/somepage.html", + isError: true, + }, +]; + +SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.editDialog.showForNewBookmarks", false]], +}); + +add_task(async function check_default_bookmark_title() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://www.example.com/" + ); + let browser = tab.linkedBrowser; + + // Test that a bookmark of each URI gets the corresponding default title. + for (let { url, title, isError } of tests) { + let promiseLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + url, + isError + ); + BrowserTestUtils.loadURIString(browser, url); + await promiseLoaded; + + await checkBookmark(url, title); + } + + // Network failure test: now that dummy_page.html is in history, bookmarking + // it should give the last known page title as the default bookmark title. + + // Simulate a network outage with offline mode. (Localhost is still + // accessible in offline mode, so disable the test proxy as well.) + BrowserOffline.toggleOfflineStatus(); + let proxy = Services.prefs.getIntPref("network.proxy.type"); + Services.prefs.setIntPref("network.proxy.type", 0); + registerCleanupFunction(function () { + BrowserOffline.toggleOfflineStatus(); + Services.prefs.setIntPref("network.proxy.type", proxy); + }); + + // LOAD_FLAGS_BYPASS_CACHE isn't good enough. So clear the cache. + Services.cache2.clear(); + + let { url, title } = tests[0]; + + let promiseLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + null, + true + ); + BrowserTestUtils.loadURIString(browser, url); + await promiseLoaded; + + // The offline mode test is only good if the page failed to load. + await SpecialPowers.spawn(browser, [], function () { + Assert.equal( + content.document.documentURI.substring(0, 14), + "about:neterror", + "Offline mode successfully simulated network outage." + ); + }); + await checkBookmark(url, title); + + BrowserTestUtils.removeTab(tab); +}); + +// Bookmark the current page and confirm that the new bookmark has the expected +// title. (Then delete the bookmark.) +async function checkBookmark(url, expected_title) { + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + url, + "Trying to bookmark the expected uri" + ); + + let promiseBookmark = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => + events.some( + ({ url: eventUrl }) => + eventUrl == gBrowser.selectedBrowser.currentURI.spec + ) + ); + PlacesCommandHook.bookmarkPage(); + await promiseBookmark; + + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + + Assert.ok(bookmark, "Found the expected bookmark"); + Assert.equal( + bookmark.title, + expected_title, + "Bookmark got a good default title." + ); + + await PlacesUtils.bookmarks.remove(bookmark); +} diff --git a/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js b/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js new file mode 100644 index 0000000000..69f6298b8c --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js @@ -0,0 +1,79 @@ +"use strict"; + +let BASE_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "https://example.com/" +); +const TEST_URL = BASE_URL + "pageopeningwindow.html"; +const DUMMY_URL = BASE_URL + "bookmarklet_windowOpen_dummy.html"; + +function makeBookmarkFor(url, keyword) { + return Promise.all([ + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmarklet", + url, + }), + PlacesUtils.keywords.insert({ url, keyword }), + ]); +} + +add_task(async function openKeywordBookmarkWithWindowOpen() { + // This is the current default, but let's not assume that... + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.link.open_newwindow", 3], + ["dom.disable_open_during_load", true], + ], + }); + + let moztab; + let tabOpened = BrowserTestUtils.openNewForegroundTab( + gBrowser, + DUMMY_URL + ).then(tab => { + moztab = tab; + }); + let keywordForBM = "openmeatab"; + + let bookmarkInfo; + let bookmarkCreated = makeBookmarkFor( + "javascript:void open('" + TEST_URL + "')", + keywordForBM + ).then(values => { + bookmarkInfo = values[0]; + }); + await Promise.all([tabOpened, bookmarkCreated]); + + registerCleanupFunction(function () { + return Promise.all([ + PlacesUtils.bookmarks.remove(bookmarkInfo), + PlacesUtils.keywords.remove(keywordForBM), + ]); + }); + gURLBar.value = keywordForBM; + gURLBar.focus(); + + let tabCreatedPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter"); + + info("Waiting for tab being created"); + let { target: tab } = await tabCreatedPromise; + info("Got tab"); + let browser = tab.linkedBrowser; + if (!browser.currentURI || browser.currentURI.spec != TEST_URL) { + info("Waiting for browser load"); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + } + is( + browser.currentURI && browser.currentURI.spec, + TEST_URL, + "Tab with expected URL loaded." + ); + info("Waiting to remove tab"); + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(moztab); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarksProperties.js b/browser/components/places/tests/browser/browser_bookmarksProperties.js new file mode 100644 index 0000000000..c493a21a62 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarksProperties.js @@ -0,0 +1,526 @@ +/* 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/. */ + +/** + * Tests the bookmarks Properties dialog. + */ + +// DOM ids of Places sidebar trees. +const SIDEBAR_HISTORY_TREE_ID = "historyTree"; +const SIDEBAR_BOOKMARKS_TREE_ID = "bookmarks-view"; + +const SIDEBAR_HISTORY_ID = "viewHistorySidebar"; +const SIDEBAR_BOOKMARKS_ID = "viewBookmarksSidebar"; + +// For history sidebar. +const SIDEBAR_HISTORY_BYLASTVISITED_VIEW = "bylastvisited"; +const SIDEBAR_HISTORY_BYMOSTVISITED_VIEW = "byvisited"; +const SIDEBAR_HISTORY_BYDATE_VIEW = "byday"; +const SIDEBAR_HISTORY_BYSITE_VIEW = "bysite"; +const SIDEBAR_HISTORY_BYDATEANDSITE_VIEW = "bydateandsite"; + +// Action to execute on the current node. +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +// If action is ACTION_ADD, set type to one of those, to define what do you +// want to create. +const TYPE_FOLDER = 0; +const TYPE_BOOKMARK = 1; + +const TEST_URL = "http://www.example.com/"; + +const DIALOG_URL = "chrome://browser/content/places/bookmarkProperties.xhtml"; + +function add_bookmark(url) { + return PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: `bookmark/${url}`, + }); +} + +// Each test is an obj w/ a desc property and run method. +var gTests = []; + +// ------------------------------------------------------------------------------ +// Bug 462662 - Pressing Enter to select tag from autocomplete closes bookmarks properties dialog + +gTests.push({ + desc: "Bug 462662 - Pressing Enter to select tag from autocomplete closes bookmarks properties dialog", + sidebar: SIDEBAR_BOOKMARKS_ID, + action: ACTION_EDIT, + itemType: null, + window: null, + _bookmark: null, + _cleanShutdown: false, + + async setup() { + // Add a bookmark in unsorted bookmarks folder. + this._bookmark = await add_bookmark(TEST_URL); + Assert.ok(this._bookmark, "Correctly added a bookmark"); + + // Add a tag to this bookmark. + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["testTag"]); + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Correctly added a tag"); + }, + + selectNode(tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + tree.selectItems([this._bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + this._bookmark.guid, + "Bookmark has been selected" + ); + }, + + async run() { + // open tags autocomplete and press enter + var tagsField = this.window.document.getElementById( + "editBMPanel_tagsField" + ); + var self = this; + + let unloadPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + function (event) { + tagsField.popup.removeEventListener( + "popuphidden", + popupListener, + true + ); + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing Enter on the autocomplete popup" + ); + executeSoon(function () { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + var popupListener = { + handleEvent(aEvent) { + switch (aEvent.type) { + case "popuphidden": + // Everything worked fine, we can stop observing the window. + self._cleanShutdown = true; + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + break; + case "popupshown": + tagsField.popup.removeEventListener("popupshown", this, true); + // In case this test fails the window will close, the test will fail + // since we didn't set _cleanShutdown. + let richlistbox = tagsField.popup.richlistbox; + // Focus and select first result. + Assert.equal( + richlistbox.itemCount, + 1, + "We have 1 autocomplete result" + ); + tagsField.popup.selectedIndex = 0; + Assert.equal( + richlistbox.selectedItems.length, + 1, + "We have selected a tag from the autocomplete popup" + ); + info("About to focus the autocomplete results"); + richlistbox.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, self.window); + break; + default: + Assert.ok(false, "unknown event: " + aEvent.type); + } + }, + }; + tagsField.popup.addEventListener("popupshown", popupListener, true); + tagsField.popup.addEventListener("popuphidden", popupListener, true); + + // Open tags autocomplete popup. + info("About to focus the tagsField"); + executeSoon(() => { + tagsField.focus(); + tagsField.value = ""; + EventUtils.synthesizeKey("t", {}, this.window); + }); + await unloadPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + // Check tags have not changed. + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Tag on node has not changed"); + + // Cleanup. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["testTag"]); + await PlacesUtils.bookmarks.remove(this._bookmark); + let bm = await PlacesUtils.bookmarks.fetch(this._bookmark.guid); + Assert.ok(!bm, "should have been removed"); + }, +}); + +// ------------------------------------------------------------------------------ +// Bug 476020 - Pressing Esc while having the tag autocomplete open closes the bookmarks panel + +gTests.push({ + desc: "Bug 476020 - Pressing Esc while having the tag autocomplete open closes the bookmarks panel", + sidebar: SIDEBAR_BOOKMARKS_ID, + action: ACTION_EDIT, + itemType: null, + window: null, + _bookmark: null, + _cleanShutdown: false, + + async setup() { + // Add a bookmark in unsorted bookmarks folder. + this._bookmark = await add_bookmark(TEST_URL); + Assert.ok(this._bookmark, "Correctly added a bookmark"); + + // Add a tag to this bookmark. + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["testTag"]); + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Correctly added a tag"); + }, + + selectNode(tree) { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + tree.selectItems([this._bookmark.guid]); + Assert.equal( + tree.selectedNode.bookmarkGuid, + this._bookmark.guid, + "Bookmark has been selected" + ); + }, + + async run() { + // open tags autocomplete and press enter + var tagsField = this.window.document.getElementById( + "editBMPanel_tagsField" + ); + var self = this; + + let hiddenPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + function (event) { + tagsField.popup.removeEventListener( + "popuphidden", + popupListener, + true + ); + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing Escape on the autocomplete popup" + ); + executeSoon(function () { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + var popupListener = { + handleEvent(aEvent) { + switch (aEvent.type) { + case "popuphidden": + // Everything worked fine. + self._cleanShutdown = true; + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + break; + case "popupshown": + tagsField.popup.removeEventListener("popupshown", this, true); + // In case this test fails the window will close, the test will fail + // since we didn't set _cleanShutdown. + let richlistbox = tagsField.popup.richlistbox; + // Focus and select first result. + Assert.equal( + richlistbox.itemCount, + 1, + "We have 1 autocomplete result" + ); + tagsField.popup.selectedIndex = 0; + Assert.equal( + richlistbox.selectedItems.length, + 1, + "We have selected a tag from the autocomplete popup" + ); + info("About to focus the autocomplete results"); + richlistbox.focus(); + EventUtils.synthesizeKey("VK_ESCAPE", {}, self.window); + break; + default: + Assert.ok(false, "unknown event: " + aEvent.type); + } + }, + }; + tagsField.popup.addEventListener("popupshown", popupListener, true); + tagsField.popup.addEventListener("popuphidden", popupListener, true); + + // Open tags autocomplete popup. + info("About to focus the tagsField"); + tagsField.focus(); + tagsField.value = ""; + EventUtils.synthesizeKey("t", {}, this.window); + await hiddenPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + // Check tags have not changed. + var tags = PlacesUtils.tagging.getTagsForURI(Services.io.newURI(TEST_URL)); + Assert.equal(tags[0], "testTag", "Tag on node has not changed"); + + // Cleanup. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["testTag"]); + await PlacesUtils.bookmarks.remove(this._bookmark); + let bm = await PlacesUtils.bookmarks.fetch(this._bookmark.guid); + Assert.ok(!bm, "should have been removed"); + }, +}); + +// ------------------------------------------------------------------------------ +// Bug 491269 - Test that editing folder name in bookmarks properties dialog does not accept the dialog + +gTests.push({ + desc: `Bug 491269 - Test that editing folder name in bookmarks properties dialog does not accept the dialog`, + sidebar: SIDEBAR_HISTORY_ID, + dialogUrl: DIALOG_URL, + action: ACTION_ADD, + historyView: SIDEBAR_HISTORY_BYLASTVISITED_VIEW, + window: null, + + async setup() { + // Add a visit. + await PlacesTestUtils.addVisits(TEST_URL); + }, + + selectNode(tree) { + var visitNode = tree.view.nodeForTreeIndex(0); + tree.selectNode(visitNode); + Assert.equal( + tree.selectedNode.uri, + TEST_URL, + "The correct visit has been selected" + ); + Assert.equal( + tree.selectedNode.itemId, + -1, + "The selected node is not bookmarked" + ); + }, + + async run() { + // Open folder selector. + var foldersExpander = this.window.document.getElementById( + "editBMPanel_foldersExpander" + ); + var folderTree = this.window.gEditItemOverlay._folderTree; + var self = this; + + let unloadPromise = new Promise(resolve => { + this.window.addEventListener( + "unload", + event => { + Assert.ok( + self._cleanShutdown, + "Dialog window should not be closed by pressing ESC in folder name textbox" + ); + executeSoon(() => { + resolve(); + }); + }, + { capture: true, once: true } + ); + }); + + folderTree.addEventListener( + "DOMAttrModified", + function onDOMAttrModified(event) { + if (event.attrName != "place") { + return; + } + folderTree.removeEventListener("DOMAttrModified", onDOMAttrModified); + executeSoon(async function () { + // Create a new folder. + var newFolderButton = self.window.document.getElementById( + "editBMPanel_newFolderButton" + ); + newFolderButton.doCommand(); + + // Wait for the folder to be created and for editing to start. + await TestUtils.waitForCondition( + () => folderTree.hasAttribute("editing"), + "We are editing new folder name in folder tree" + ); + + // Press Escape to discard editing new folder name. + EventUtils.synthesizeKey("VK_ESCAPE", {}, self.window); + Assert.ok( + !folderTree.hasAttribute("editing"), + "We have finished editing folder name in folder tree" + ); + + self._cleanShutdown = true; + + self.window.document + .getElementById("bookmarkpropertiesdialog") + .cancelDialog(); + }); + } + ); + foldersExpander.doCommand(); + await unloadPromise; + }, + + finish() { + SidebarUI.hide(); + }, + + async cleanup() { + await PlacesTestUtils.promiseAsyncUpdates(); + + await PlacesUtils.history.clear(); + }, +}); + +// ------------------------------------------------------------------------------ + +add_task(async function test_setup() { + // This test can take some time, if we timeout too early it could run + // in the middle of other tests, or hang them. + requestLongerTimeout(2); +}); + +add_task(async function test_run() { + for (let test of gTests) { + info(`Start of test: ${test.desc}`); + await test.setup(); + + await execute_test_in_sidebar(test); + await test.run(); + + await test.cleanup(); + await test.finish(); + + info(`End of test: ${test.desc}`); + } +}); + +/** + * Global functions to run a test in Properties dialog context. + */ + +function execute_test_in_sidebar(test) { + return new Promise(resolve => { + var sidebar = document.getElementById("sidebar"); + sidebar.addEventListener( + "load", + function () { + // Need to executeSoon since the tree is initialized on sidebar load. + executeSoon(async () => { + await open_properties_dialog(test); + resolve(); + }); + }, + { capture: true, once: true } + ); + SidebarUI.show(test.sidebar); + }); +} + +async function promise_properties_window(dialogUrl = DIALOG_URL) { + let win = await BrowserTestUtils.promiseAlertDialogOpen(null, dialogUrl, { + isSubDialog: true, + }); + await SimpleTest.promiseFocus(win); + await win.document.mozSubdialogReady; + return win; +} + +async function open_properties_dialog(test) { + var sidebar = document.getElementById("sidebar"); + + // If this is history sidebar, set the required view. + if (test.sidebar == SIDEBAR_HISTORY_ID) { + sidebar.contentDocument.getElementById(test.historyView).doCommand(); + } + + // Get sidebar's Places tree. + var sidebarTreeID = + test.sidebar == SIDEBAR_BOOKMARKS_ID + ? SIDEBAR_BOOKMARKS_TREE_ID + : SIDEBAR_HISTORY_TREE_ID; + var tree = sidebar.contentDocument.getElementById(sidebarTreeID); + // The sidebar may take a moment to open from the doCommand, therefore wait + // until it has opened before continuing. + await TestUtils.waitForCondition(() => tree, "Sidebar tree has been loaded"); + + // Ask current test to select the node to edit. + test.selectNode(tree); + Assert.ok( + tree.selectedNode, + "We have a places node selected: " + tree.selectedNode.title + ); + + return new Promise(resolve => { + var command = null; + switch (test.action) { + case ACTION_EDIT: + command = "placesCmd_show:info"; + break; + case ACTION_ADD: + if (test.sidebar == SIDEBAR_BOOKMARKS_ID) { + if (test.itemType == TYPE_FOLDER) { + command = "placesCmd_new:folder"; + } else if (test.itemType == TYPE_BOOKMARK) { + command = "placesCmd_new:bookmark"; + } else { + Assert.ok( + false, + "You didn't set a valid itemType for adding an item" + ); + } + } else { + command = "placesCmd_createBookmark"; + } + break; + default: + Assert.ok(false, "You didn't set a valid action for this test"); + } + // Ensure command is enabled for this node. + Assert.ok( + tree.controller.isCommandEnabled(command), + " command '" + command + "' on current selected node is enabled" + ); + + promise_properties_window(test.dialogUrl).then(win => { + test.window = win; + resolve(); + }); + // This will open the dialog. For some reason this needs to be executed + // later, as otherwise opening the dialog throws an exception. + executeSoon(() => { + tree.controller.doCommand(command); + }); + }); +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_change_title.js b/browser/components/places/tests/browser/browser_bookmarks_change_title.js new file mode 100644 index 0000000000..0e24df188a --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_change_title.js @@ -0,0 +1,256 @@ +/** + * Tests that the title of a bookmark can be changed from the bookmark star, toolbar, sidebar, and library. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; +const titleAfterFirstUpdate = "BookmarkStar title"; + +function getToolbarNodeForItemGuid(aItemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (aItemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +// Setup. +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_change_title_from_BookmarkStar() { + let originalBm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: "Before Edit", + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + }); + + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + let bookmarkPanelTitle = win.document.getElementById( + "editBookmarkPanelTitle" + ); + await BrowserTestUtils.waitForCondition( + () => + bookmarkPanelTitle.textContent === + gFluentStrings.formatValueSync("bookmarks-edit-bookmark"), + "Wait until the bookmark panel title will be changed expectedly." + ); + Assert.ok(true, "Bookmark title is correct"); + + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === titleAfterFirstUpdate) + ); + + // Update the bookmark's title. + await fillBookmarkTextField( + "editBMPanel_namePicker", + titleAfterFirstUpdate, + win + ); + + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await promiseNotification; + + let updatedBm = await PlacesUtils.bookmarks.fetch(originalBm.guid); + Assert.equal( + updatedBm.title, + titleAfterFirstUpdate, + "Should have updated the bookmark title in the database" + ); + await PlacesUtils.bookmarks.remove(originalBm.guid); +}); + +add_task(async function test_change_title_from_Toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: titleAfterFirstUpdate, + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + await withBookmarksDialog( + false, + async function openPropertiesDialog() { + let placesContext = document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await promisePopup; + + let properties = document.getElementById( + "placesContext_show_bookmark:info" + ); + placesContext.activateItem(properties, {}); + }, + async function test(dialogWin) { + // Ensure the dialog has initialized. + await TestUtils.waitForCondition(() => dialogWin.document.title); + + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + + let editBookmarkDialogTitle = + dialogWin.document.getElementById("titleText"); + let bundle = dialogWin.document.getElementById("stringBundle"); + + Assert.equal( + bundle.getString("dialogTitleEditBookmark2"), + editBookmarkDialogTitle.textContent + ); + + Assert.equal( + namepicker.value, + titleAfterFirstUpdate, + "Name field is correct before update." + ); + + let promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "Toolbar title") + ); + + // Update the bookmark's title. + fillBookmarkTextField( + "editBMPanel_namePicker", + "Toolbar title", + dialogWin, + false + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTitleChange; + + let updatedBm = await PlacesUtils.bookmarks.fetch(toolbarBookmark.guid); + Assert.equal( + updatedBm.title, + "Toolbar title", + "Should have updated the bookmark title in the database" + ); + } + ); +}); + +add_task(async function test_change_title_from_Sidebar() { + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + + await withSidebarTree("bookmarks", async function (tree) { + tree.selectItems([bookmarks[0].guid]); + + await withBookmarksDialog( + false, + function openPropertiesDialog() { + tree.controller.doCommand("placesCmd_show:info"); + }, + async function test(dialogWin) { + let namepicker = dialogWin.document.getElementById( + "editBMPanel_namePicker" + ); + Assert.equal( + namepicker.value, + "Toolbar title", + "Name field is correct before update." + ); + + let promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === "Sidebar Title") + ); + + // Update the bookmark's title. + fillBookmarkTextField( + "editBMPanel_namePicker", + "Sidebar Title", + dialogWin, + false + ); + + // Confirm and close the dialog. + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + await promiseTitleChange; + + // Get updated bookmarks, check the new title. + bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }, bm => + bookmarks.push(bm) + ); + Assert.equal( + bookmarks[0].title, + "Sidebar Title", + "Should have updated the bookmark title in the database" + ); + Assert.equal(bookmarks.length, 1, "One bookmark should exist"); + } + ); + }); +}); + +add_task(async function test_change_title_from_Library() { + info("Open library and select the bookmark."); + const library = await promiseLibrary("BookmarksToolbar"); + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + library.ContentTree.view.selectNode( + library.ContentTree.view.view.nodeForTreeIndex(0) + ); + const newTitle = "Library"; + const promiseTitleChange = PlacesTestUtils.waitForNotification( + "bookmark-title-changed", + events => events.some(e => e.title === newTitle) + ); + info("Update the bookmark's title."); + fillBookmarkTextField("editBMPanel_namePicker", newTitle, library); + await promiseTitleChange; + info("The bookmark's title was updated."); + await promiseLibraryClosed(library); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarks_change_url.js b/browser/components/places/tests/browser/browser_bookmarks_change_url.js new file mode 100644 index 0000000000..ab5ad742d4 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_change_url.js @@ -0,0 +1,106 @@ +/* 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"; + +/* + * Test whether or not that url field in library window will update properly + * when changing it. + */ + +const TEST_URLS = ["https://example.com/", "https://example.org/"]; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function basic() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, TEST_URLS[0]); + assertRow(tree, 1, TEST_URLS[1]); + + info("Check the url"); + const newURL = `${TEST_URLS[0]}/?test`; + await updateURL(newURL, library); + + info("Check the update"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, newURL); + assertRow(tree, 1, TEST_URLS[1]); + + info("Close library window"); + await promiseLibraryClosed(library); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function whileFiltering() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 2); + assertRow(tree, 0, TEST_URLS[0]); + assertRow(tree, 1, TEST_URLS[1]); + + info("Filter by search chars"); + library.PlacesSearchBox.search("org"); + Assert.equal(tree.view.rowCount, 1); + assertRow(tree, 0, TEST_URLS[1]); + + info("Check the url"); + const newURL = `${TEST_URLS[1]}/?test`; + await updateURL(newURL, library); + + info("Check the update"); + Assert.equal(tree.view.rowCount, 1); + assertRow(tree, 0, newURL); + + info("Close library window"); + await promiseLibraryClosed(library); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +async function updateURL(newURL, library) { + const promiseUrlChange = PlacesTestUtils.waitForNotification( + "bookmark-url-changed", + () => true + ); + fillBookmarkTextField("editBMPanel_locationField", newURL, library); + await promiseUrlChange; +} + +function assertRow(tree, targeRow, expectedUrl) { + const url = tree.view.getCellText(targeRow, tree.columns.placesContentUrl); + Assert.equal(url, expectedUrl, "URL is correct"); +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js b/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js new file mode 100644 index 0000000000..a425a9b031 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_sidebar_search.js @@ -0,0 +1,213 @@ +/** + * Test searching for bookmarks (by title and by tag) from the Bookmarks sidebar. + */ +"use strict"; + +let sidebar = document.getElementById("sidebar"); + +const TEST_URI = "http://example.com/"; +const BOOKMARKS_COUNT = 4; +const TEST_PARENT_FOLDER = "testParentFolder"; +const TEST_SIF_URL = "http://testsif.example.com/"; +const TEST_SIF_TITLE = "TestSIF"; +const TEST_NEW_TITLE = "NewTestSIF"; + +function assertBookmarks(searchValue) { + let found = 0; + + let searchBox = sidebar.contentDocument.getElementById("search-box"); + + ok(searchBox, "search box is in context"); + + searchBox.value = searchValue; + searchBox.doCommand(); + + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + + for (let i = 0; i < tree.view.rowCount; i++) { + let cellText = tree.view.getCellText(i, tree.columns.getColumnAt(0)); + + if (cellText.includes("example page")) { + found++; + } + } + + info("Reset the search"); + searchBox.value = ""; + searchBox.doCommand(); + + is(found, BOOKMARKS_COUNT, "found expected site"); +} + +async function showInFolder(aSearchStr, aParentFolderGuid) { + let searchBox = sidebar.contentDocument.getElementById("search-box"); + + searchBox.value = aSearchStr; + searchBox.doCommand(); + + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let theNode = tree.view._getNodeForRow(0); + let bookmarkGuid = theNode.bookmarkGuid; + + Assert.equal(theNode.uri, TEST_SIF_URL, "Found expected bookmark"); + + info("Running Show in Folder command"); + tree.selectNode(theNode); + tree.controller.doCommand("placesCmd_showInFolder"); + + let treeNode = tree.selectedNode; + Assert.equal( + treeNode.parent.bookmarkGuid, + aParentFolderGuid, + "Containing folder node is correct" + ); + Assert.equal( + treeNode.bookmarkGuid, + bookmarkGuid, + "The searched bookmark guid matches selected node" + ); + Assert.equal( + treeNode.uri, + TEST_SIF_URL, + "The searched bookmark URL matches selected node" + ); + + info("Check the title will be applied the item when changing it"); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + title: TEST_NEW_TITLE, + }); + Assert.equal( + treeNode.title, + TEST_NEW_TITLE, + "New title is applied to the node" + ); +} + +add_task(async function testTree() { + // Add bookmarks and tags. + for (let i = 0; i < BOOKMARKS_COUNT; i++) { + let url = Services.io.newURI(TEST_URI + i); + + await PlacesUtils.bookmarks.insert({ + url, + title: "example page " + i, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + PlacesUtils.tagging.tagURI(url, ["test"]); + } + + await withSidebarTree("bookmarks", function () { + // Search a bookmark by its title. + assertBookmarks("example.com"); + // Search a bookmark by its tag. + assertBookmarks("test"); + }); + + // Cleanup before testing Show in Folder. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function testShowInFolder() { + // Now test Show in Folder + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await withSidebarTree("bookmarks", async function () { + await showInFolder(TEST_SIF_TITLE, parentFolder.guid); + }); + + // Cleanup + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function testRenameOnQueryResult() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await withSidebarTree("bookmarks", async function () { + const searchBox = sidebar.contentDocument.getElementById("search-box"); + + searchBox.value = TEST_SIF_TITLE; + searchBox.doCommand(); + + const tree = sidebar.contentDocument.getElementById("bookmarks-view"); + const theNode = tree.view._getNodeForRow(0); + + info("Check the found bookmark"); + Assert.equal(theNode.uri, TEST_SIF_URL, "URI of bookmark found is correct"); + Assert.equal( + theNode.title, + TEST_SIF_TITLE, + "Title of bookmark found is correct" + ); + + info("Check the title will be applied the item when changing it"); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + title: TEST_NEW_TITLE, + }); + + // As the query result is refreshed once then the node also is regenerated, + // need to get the result node from the tree again. + Assert.equal( + tree.view._getNodeForRow(0).bookmarkGuid, + theNode.bookmarkGuid, + "GUID of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).uri, + theNode.uri, + "URI of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).parentGuid, + theNode.parentGuid, + "parentGuid of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).title, + TEST_NEW_TITLE, + "New title is applied to the node" + ); + + info("Check the new date will be applied the item when changing it"); + const now = new Date(); + await PlacesUtils.bookmarks.update({ + guid: theNode.bookmarkGuid, + dateAdded: now, + lastModified: now, + }); + + Assert.equal( + tree.view._getNodeForRow(0).uri, + theNode.uri, + "URI of node regenerated is correct" + ); + Assert.equal( + tree.view._getNodeForRow(0).dateAdded, + now.getTime() * 1000, + "New dateAdded is applied to the node" + ); + Assert.equal( + tree.view._getNodeForRow(0).lastModified, + now.getTime() * 1000, + "New lastModified is applied to the node" + ); + }); + + // Cleanup before testing Show in Folder. + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js b/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js new file mode 100644 index 0000000000..6e1f5e93c2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_toolbar_context_menu_view_options.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function testPopup() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://example.com", + title: "firefox", + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.remove(bookmark); + Services.prefs.clearUserPref("browser.toolbars.bookmarks.visibility"); + }); + + for (let state of ["always", "newtab"]) { + info(`Testing with state set to '${state}'`); + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", state]], + }); + + let newtab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:newtab", + waitForLoad: false, + }); + + let bookmarksToolbar = document.getElementById("PersonalToolbar"); + await TestUtils.waitForCondition( + () => !bookmarksToolbar.collapsed, + "Wait for toolbar to become visible" + ); + ok(!bookmarksToolbar.collapsed, "Bookmarks toolbar should be visible"); + + // 1. Right-click on a bookmark and check that the submenu is visible + let bookmarkItem = bookmarksToolbar.querySelector( + `.bookmark-item[label="firefox"]` + ); + ok(bookmarkItem, "Got bookmark"); + let contextMenu = document.getElementById("placesContext"); + let popup = await openContextMenu(contextMenu, bookmarkItem); + ok( + !popup.target.querySelector("#toggle_PersonalToolbar").hidden, + "Bookmarks toolbar submenu should appear on a .bookmark-item" + ); + contextMenu.hidePopup(); + + // 2. Right-click on the empty area and check that the submenu is visible + popup = await openContextMenu(contextMenu, bookmarksToolbar); + ok( + !popup.target.querySelector("#toggle_PersonalToolbar").hidden, + "Bookmarks toolbar submenu should appear on the empty part of the toolbar" + ); + + let bookmarksToolbarMenu = document.querySelector( + "#toggle_PersonalToolbar" + ); + let subMenu = bookmarksToolbarMenu.querySelector("menupopup"); + bookmarksToolbarMenu.openMenu(true); + await BrowserTestUtils.waitForPopupEvent(subMenu, "shown"); + let menuitems = subMenu.querySelectorAll("menuitem"); + for (let menuitem of menuitems) { + let expected = menuitem.dataset.visibilityEnum == state; + is( + menuitem.getAttribute("checked"), + expected.toString(), + `The corresponding menuitem, ${menuitem.dataset.visibilityEnum}, ${ + expected ? "should" : "shouldn't" + } be checked if state=${state}` + ); + } + + contextMenu.hidePopup(); + + BrowserTestUtils.removeTab(newtab); + } +}); + +function openContextMenu(contextMenu, target) { + let popupPromise = BrowserTestUtils.waitForPopupEvent(contextMenu, "shown"); + EventUtils.synthesizeMouseAtCenter(target, { + type: "contextmenu", + button: 2, + }); + return popupPromise; +} diff --git a/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js b/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js new file mode 100644 index 0000000000..241710da3b --- /dev/null +++ b/browser/components/places/tests/browser/browser_bookmarks_toolbar_telemetry.js @@ -0,0 +1,162 @@ +/* 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"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const SCALAR_NAME = "browser.ui.customized_widgets"; +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; + +// Setup. +add_task(async function test_bookmarks_toolbar_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.bookmarks.visibility", "newtab"]], + }); + + // This is added during startup + await TestUtils.waitForCondition( + () => + keyedScalarExists( + "browser.ui.toolbar_widgets", + "bookmarks-bar_pinned_newtab", + true + ), + `Waiting for "bookmarks-bar_pinned_newtab" to appear in Telemetry snapshot` + ); + ok(true, `"bookmarks-bar_pinned_newtab"=true found in Telemetry`); + + await changeToolbarVisibilityViaContextMenu("never"); + await assertUIChange( + "bookmarks-bar_move_newtab_never_toolbar-context-menu", + 1 + ); + + await changeToolbarVisibilityViaContextMenu("newtab"); + await assertUIChange( + "bookmarks-bar_move_never_newtab_toolbar-context-menu", + 1 + ); + + await changeToolbarVisibilityViaContextMenu("always"); + await assertUIChange( + "bookmarks-bar_move_newtab_always_toolbar-context-menu", + 1 + ); + + // Extra windows are opened to make sure telemetry numbers aren't + // double counted since there will be multiple instances of the + // bookmarks toolbar. + let extraWindows = []; + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + + Services.telemetry.getSnapshotForScalars("main", true); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarksInfo, + }); + registerCleanupFunction(() => PlacesUtils.bookmarks.remove(bookmarks)); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_added", + 3, + "Bookmarks added value should be 3" + ); + + let newtab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => BrowserTestUtils.removeTab(newtab)); + let bookmarkToolbarButton = document.querySelector( + "#PlacesToolbarItems > toolbarbutton" + ); + bookmarkToolbarButton.click(); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_opened", + 1, + "Bookmarks opened value should be 1" + ); + + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + extraWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + + // Simulate dragging a bookmark within the toolbar to ensure + // that the bookmarks_toolbar_bookmark_added probe doesn't increment + let srcElement = document.querySelector("#PlacesToolbar .bookmark-item"); + let destElement = document.querySelector("#PlacesToolbar"); + await EventUtils.synthesizePlainDragAndDrop({ + srcElement, + destElement, + }); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent"), + "browser.engagement.bookmarks_toolbar_bookmark_added", + 3, + "Bookmarks added value should still be 3" + ); + + for (let win of extraWindows) { + await BrowserTestUtils.closeWindow(win); + } +}); + +async function changeToolbarVisibilityViaContextMenu(nextState) { + let contextMenu = document.querySelector("#toolbar-context-menu"); + let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + let menuButton = document.getElementById("PanelUI-menu-button"); + EventUtils.synthesizeMouseAtCenter( + menuButton, + { type: "contextmenu" }, + window + ); + await popupShown; + let bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar"); + let subMenu = bookmarksToolbarMenu.querySelector("menupopup"); + popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown"); + bookmarksToolbarMenu.openMenu(true); + await popupShown; + let menuItem = document.querySelector( + `menuitem[data-visibility-enum="${nextState}"]` + ); + subMenu.activateItem(menuItem); + contextMenu.hidePopup(); +} + +async function assertUIChange(key, value) { + await TestUtils.waitForCondition( + () => keyedScalarExists(SCALAR_NAME, key, value), + `Waiting for ${key} to appear in Telemetry snapshot` + ); + ok(true, `${key}=${value} found in Telemetry`); +} + +function keyedScalarExists(scalar, key, value) { + let snapshot = Services.telemetry.getSnapshotForKeyedScalars( + "main", + false + ).parent; + if (!snapshot.hasOwnProperty(scalar)) { + return false; + } + if (!snapshot[scalar].hasOwnProperty(key)) { + info(`Looking for ${key} in ${JSON.stringify(snapshot[scalar])}`); + return false; + } + return snapshot[scalar][key] == value; +} diff --git a/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js b/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js new file mode 100644 index 0000000000..6acb6fb04c --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug427633_no_newfolder_if_noip.js @@ -0,0 +1,50 @@ +"use strict"; + +/** + * Bug 427633 - Disable creating a New Folder in the bookmarks dialogs if + * insertionPoint is invalid. + */ + +const TEST_URL = "about:buildconfig"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + bookmarkPanel.removeAttribute("animate"); + await BrowserTestUtils.removeTab(tab); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + StarUI._createPanelIfNeeded(); + let bookmarkPanel = document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + ok(gEditItemOverlay, "gEditItemOverlay is in context"); + ok(gEditItemOverlay.initialized, "gEditItemOverlay is initialized"); + + window.gEditItemOverlay.toggleFolderTreeVisibility(); + + let tree = gEditItemOverlay._element("folderTree"); + + tree.view.selection.clearSelection(); + ok( + document.getElementById("editBMPanel_newFolderButton").disabled, + "New folder button is disabled if there's no selection" + ); + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +}); diff --git a/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js b/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js new file mode 100644 index 0000000000..e11ae1cfc2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug485100-change-case-loses-tag.js @@ -0,0 +1,74 @@ +"use strict"; + +const TEST_URL = "http://www.example.com/"; +const testTag = "foo"; +const testTagUpper = "Foo"; +const testURI = Services.io.newURI(TEST_URL); + +add_task(async function test() { + // Add a bookmark. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "mozilla", + url: testURI, + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + win.StarUI._createPanelIfNeeded(); + + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: TEST_URL, + }, + async () => { + // Init panel + await TestUtils.waitForCondition( + () => BookmarkingUI.status !== BookmarkingUI.STATUS_UPDATING + ); + await clickBookmarkStar(win); + + // add a tag + await fillBookmarkTextField("editBMPanel_tagsField", testTag, win); + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + await hideBookmarksPanel(win); + await promiseNotification; + + // test that the tag has been added in the backend + is(PlacesUtils.tagging.getTagsForURI(testURI)[0], testTag, "tags match"); + + // change the tag + await TestUtils.waitForCondition( + () => BookmarkingUI.status !== BookmarkingUI.STATUS_UPDATING + ); + await clickBookmarkStar(win); + await fillBookmarkTextField("editBMPanel_tagsField", testTagUpper, win); + // The old sync API doesn't notify a tags change, and fixing it would be + // quite complex, so we just wait for a title change until tags are + // refactored. + promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-title-changed" + ); + await hideBookmarksPanel(win); + await promiseNotification; + + // test that the tag has been added in the backend + is( + PlacesUtils.tagging.getTagsForURI(testURI)[0], + testTagUpper, + "tags match" + ); + + // Cleanup. + PlacesUtils.tagging.untagURI(testURI, [testTag]); + await PlacesUtils.bookmarks.remove(bm.guid); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js b/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js new file mode 100644 index 0000000000..5d35356a69 --- /dev/null +++ b/browser/components/places/tests/browser/browser_bug631374_tags_selector_scroll.js @@ -0,0 +1,162 @@ +/** + * This test checks that editing tags doesn't scroll the tags selector + * listbox to wrong positions. + */ + +const TEST_URL = "about:buildconfig"; + +function scrolledIntoView(item, parentItem) { + let itemRect = item.getBoundingClientRect(); + let parentItemRect = parentItem.getBoundingClientRect(); + let pointInView = y => parentItemRect.top < y && y < parentItemRect.bottom; + + // Partially visible items are also considered visible. + return pointInView(itemRect.top) || pointInView(itemRect.bottom); +} + +add_task(async function runTest() { + await PlacesUtils.bookmarks.eraseEverything(); + let tags = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "l", + "m", + "n", + "o", + "p", + ]; + + // Add a bookmark and tag it. + let uri1 = Services.io.newURI(TEST_URL); + let bm1 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "mozilla", + url: uri1.spec, + }); + PlacesUtils.tagging.tagURI(uri1, tags); + + // Add a second bookmark so that tags won't disappear when unchecked. + let uri2 = Services.io.newURI("http://www2.mozilla.org/"); + let bm2 = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "mozilla", + url: uri2.spec, + }); + PlacesUtils.tagging.tagURI(uri2, tags); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + registerCleanupFunction(async () => { + bookmarkPanel.removeAttribute("animate"); + await BrowserTestUtils.closeWindow(win); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + win.StarUI._createPanelIfNeeded(); + let bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + let shownPromise = promisePopupShown(bookmarkPanel); + + let bookmarkStar = win.BookmarkingUI.star; + bookmarkStar.click(); + + await shownPromise; + + // Init panel. + ok(win.gEditItemOverlay, "gEditItemOverlay is in context"); + ok(win.gEditItemOverlay.initialized, "gEditItemOverlay is initialized"); + + await openTagSelector(win); + let tagsSelector = win.document.getElementById("editBMPanel_tagsSelector"); + + // Go by two so there is some untouched tag in the middle. + for (let i = 8; i < tags.length; i += 2) { + tagsSelector.selectedIndex = i; + let listItem = tagsSelector.selectedItem; + isnot(listItem, null, "Valid listItem found"); + + tagsSelector.ensureElementIsVisible(listItem); + let scrollTop = tagsSelector.scrollTop; + + ok(listItem.hasAttribute("checked"), "Item is checked " + i); + let selectedTag = listItem.label; + + // Uncheck the tag. + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(listItem.firstElementChild, {}, win); + await promise; + is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change"); + + // The listbox is rebuilt, so we have to get the new element. + let newItem = tagsSelector.selectedItem; + isnot(newItem, null, "Valid new listItem found"); + ok(!newItem.hasAttribute("checked"), "New listItem is unchecked " + i); + is(newItem.label, selectedTag, "Correct tag is still selected"); + + // Check the tag. + promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(newItem.firstElementChild, {}, win); + await promise; + is(scrollTop, tagsSelector.scrollTop, "Scroll position did not change"); + } + + // Remove the second bookmark, then nuke some of the tags. + await PlacesUtils.bookmarks.remove(bm2); + + // Allow the tag updates to complete + await PlacesTestUtils.promiseAsyncUpdates(); + + // Doing this backwords tests more interesting paths. + for (let i = tags.length - 1; i >= 0; i -= 2) { + tagsSelector.selectedIndex = i; + let listItem = tagsSelector.selectedItem; + isnot(listItem, null, "Valid listItem found"); + + tagsSelector.ensureElementIsVisible(listItem); + + ok(listItem.hasAttribute("checked"), "Item is checked " + i); + + // Uncheck the tag. + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + EventUtils.synthesizeMouseAtCenter(listItem.firstElementChild, {}, win); + await promise; + } + + let hiddenPromise = promisePopupHidden(bookmarkPanel); + let doneButton = win.document.getElementById("editBookmarkPanelDoneButton"); + doneButton.click(); + await hiddenPromise; + // Cleanup. + await PlacesUtils.bookmarks.remove(bm1); +}); + +function openTagSelector(win) { + let promise = BrowserTestUtils.waitForEvent( + win.document.getElementById("editBMPanel_tagsSelector"), + "BookmarkTagsSelectorUpdated" + ); + // Open the tags selector. + win.document.getElementById("editBMPanel_tagsSelectorExpander").doCommand(); + return promise; +} diff --git a/browser/components/places/tests/browser/browser_check_correct_controllers.js b/browser/components/places/tests/browser/browser_check_correct_controllers.js new file mode 100644 index 0000000000..1c90667a19 --- /dev/null +++ b/browser/components/places/tests/browser/browser_check_correct_controllers.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_setup(async () => { + // Ensure all bookmarks cleared before the test starts. + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Plain Bob", + url: "http://example.com", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.remove(bookmark); + }); + + let sidebarBox = document.getElementById("sidebar-box"); + is(sidebarBox.hidden, true, "The sidebar should be hidden"); + + // Uncollapse the personal toolbar if needed. + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + let sidebar = await promiseLoadedSidebar("viewBookmarksSidebar"); + registerCleanupFunction(() => { + SidebarUI.hide(); + }); + + // Focus the tree and check if its controller is returned. + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + tree.focus(); + + let controller = PlacesUIUtils.getControllerForCommand( + window, + "placesCmd_copy" + ); + let treeController = + tree.controllers.getControllerForCommand("placesCmd_copy"); + ok(controller == treeController, "tree controller was returned"); + + // Open the context menu for a toolbar item, and check if the toolbar's + // controller is returned. + let toolbarItems = document.getElementById("PlacesToolbarItems"); + // Ensure the toolbar has displayed the bookmark. This might be async, so + // wait a little if necessary. + await TestUtils.waitForCondition( + () => toolbarItems.children.length == 1, + "Should have only one item on the toolbar" + ); + + let placesContext = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouse( + toolbarItems.children[0], + 4, + 4, + { type: "contextmenu", button: 2 }, + window + ); + await popupShownPromise; + + controller = PlacesUIUtils.getControllerForCommand(window, "placesCmd_copy"); + let toolbarController = document + .getElementById("PlacesToolbar") + .controllers.getControllerForCommand("placesCmd_copy"); + ok(controller == toolbarController, "the toolbar controller was returned"); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popuphidden" + ); + placesContext.hidePopup(); + await popupHiddenPromise; + + // Now that the context menu is closed, try to get the tree controller again. + tree.focus(); + controller = PlacesUIUtils.getControllerForCommand(window, "placesCmd_copy"); + ok(controller == treeController, "tree controller was returned"); + + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } +}); + +function promiseLoadedSidebar(cmd) { + return new Promise(resolve => { + let sidebar = document.getElementById("sidebar"); + sidebar.addEventListener( + "load", + function () { + executeSoon(() => resolve(sidebar)); + }, + { capture: true, once: true } + ); + + SidebarUI.show(cmd); + }); +} diff --git a/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js b/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js new file mode 100644 index 0000000000..5ecad12a06 --- /dev/null +++ b/browser/components/places/tests/browser/browser_click_bookmarks_on_toolbar.js @@ -0,0 +1,234 @@ +/* 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/. */ + +const PREF_LOAD_BOOKMARKS_IN_TABS = "browser.tabs.loadBookmarksInTabs"; +const EXAMPLE_PAGE = "http://example.com/"; +const TEST_PAGES = [ + "about:mozilla", + "about:robots", + "javascript:window.location=%22" + EXAMPLE_PAGE + "%22", +]; + +var gBookmarkElements = []; + +function waitForBookmarkElements(expectedCount) { + let container = document.getElementById("PlacesToolbarItems"); + if (container.childElementCount == expectedCount) { + return Promise.resolve(); + } + return new Promise(resolve => { + info("Waiting for bookmarks"); + let mut = new MutationObserver(mutations => { + info("Elements appeared"); + if (container.childElementCount == expectedCount) { + resolve(); + mut.disconnect(); + } + }); + + mut.observe(container, { childList: true }); + }); +} + +function getToolbarNodeForItemGuid(aItemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (aItemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +function waitForLoad(browser, url) { + return BrowserTestUtils.browserLoaded(browser, false, url); +} + +function waitForNewTab(url, inBackground) { + return BrowserTestUtils.waitForNewTab(gBrowser, url).then(tab => { + if (inBackground) { + Assert.notEqual( + tab, + gBrowser.selectedTab, + `The new tab is in the background` + ); + } else { + Assert.equal( + tab, + gBrowser.selectedTab, + `The new tab is in the foreground` + ); + } + + BrowserTestUtils.removeTab(tab); + }); +} + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let bookmarks = await Promise.all( + TEST_PAGES.map((url, index) => { + return PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: `Title ${index}`, + url, + }); + }) + ); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + await waitForBookmarkElements(TEST_PAGES.length); + for (let bookmark of bookmarks) { + let element = getToolbarNodeForItemGuid(bookmark.guid); + Assert.notEqual(element, null, "Found node on toolbar"); + + gBookmarkElements.push(element); + } + + registerCleanupFunction(async () => { + gBookmarkElements = []; + + await Promise.all( + bookmarks.map(bookmark => { + return PlacesUtils.bookmarks.remove(bookmark); + }) + ); + + // Note: hiding the toolbar before removing the bookmarks triggers + // bug 1766284. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + }); +}); + +add_task(async function click() { + let promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[0]); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + await promise; + + promise = waitForNewTab(TEST_PAGES[1], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 0, + accelKey: true, + }); + await promise; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:blank", + }); + promise = waitForLoad(gBrowser.selectedBrowser, EXAMPLE_PAGE); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[2], {}); + await promise; + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function middleclick() { + let promise = waitForNewTab(TEST_PAGES[0], true); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 1, + shiftKey: true, + }); + await promise; + + promise = waitForNewTab(TEST_PAGES[1], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 1, + }); + await promise; +}); + +add_task(async function clickWithPrefSet() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let promise = waitForNewTab(TEST_PAGES[0], false); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + await promise; + + // With loadBookmarksInTabs, reuse current tab if blank + for (let button of [0, 1]) { + await BrowserTestUtils.withNewTab({ gBrowser }, async tab => { + promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[1]); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button, + }); + await promise; + }); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function openInSameTabWithPrefSet() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let placesContext = document.getElementById("placesContext"); + let popupPromise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 2, + type: "contextmenu", + }); + info("Waiting for context menu"); + await popupPromise; + + let openItem = document.getElementById("placesContext_open"); + ok(BrowserTestUtils.is_visible(openItem), "Open item should be visible"); + + info("Waiting for page to load"); + let promise = waitForLoad(gBrowser.selectedBrowser, TEST_PAGES[0]); + openItem.doCommand(); + placesContext.hidePopup(); + await promise; + + await SpecialPowers.popPrefEnv(); +}); + +// Open a tab, then quickly open the context menu to ensure that the command +// enabled state of the menuitems is updated properly. +add_task(async function quickContextMenu() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, TEST_PAGES[0]); + + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[0], { + button: 0, + }); + let newTab = await tabPromise; + + let placesContext = document.getElementById("placesContext"); + let promise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gBookmarkElements[1], { + button: 2, + type: "contextmenu", + }); + await promise; + + Assert.ok( + !document.getElementById("placesContext_open:newtab").disabled, + "Commands in context menu are enabled" + ); + + promise = BrowserTestUtils.waitForEvent(placesContext, "popuphidden"); + placesContext.hidePopup(); + await promise; + BrowserTestUtils.removeTab(newTab); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop.js b/browser/components/places/tests/browser/browser_controller_onDrop.js new file mode 100644 index 0000000000..2fb932f300 --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop.js @@ -0,0 +1,125 @@ +/* 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 bookmarks; +var bookmarkIds; +var library; + +add_setup(async function () { + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await promiseLibraryClosed(library); + }); + + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example1.com", + }, + { + title: "bm2", + url: "http://example2.com", + }, + { + title: "bm3", + url: "http://example3.com", + }, + ], + }); + + bookmarkIds = await PlacesUtils.promiseManyItemIds([ + bookmarks[0].guid, + bookmarks[1].guid, + bookmarks[2].guid, + ]); + + library = await promiseLibrary("UnfiledBookmarks"); +}); + +async function run_drag_test(startBookmarkIndex, insertionIndex) { + let dragBookmark = bookmarks[startBookmarkIndex]; + + library.ContentTree.view.selectItems([dragBookmark.guid]); + + let dataTransfer = { + _data: [], + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + library.ContentTree.view.controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + Assert.equal( + dataObject.itemGuid, + dragBookmark.guid, + "Should have the correct guid." + ); + Assert.equal( + dataObject.title, + dragBookmark.title, + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, "move"); + + let ip = new PlacesInsertionPoint({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: insertionIndex, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); +} + +add_task(async function test_simple_move_down() { + let moveNotification = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => e.guid === bookmarks[0].guid && e.oldIndex == 0 && e.index == 1 + ) + ); + + await run_drag_test(0, 2); + + await moveNotification; +}); + +add_task(async function test_simple_move_up() { + await run_drag_test(2, 0); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_query.js b/browser/components/places/tests/browser/browser_controller_onDrop_query.js new file mode 100644 index 0000000000..29dba4e5d0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_query.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_setup(async function () { + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Simulating actual drag and drop is hard for a xul tree as we can't get the +// required source elements, so we have to do a lot more work by hand. +async function simulateDrop( + selectTargets, + sourceBm, + dropEffect, + targetGuid, + isVirtualRoot = false +) { + await withSidebarTree("bookmarks", async function (tree) { + for (let target of selectTargets) { + tree.selectItems([target]); + if (tree.selectedNode instanceof Ci.nsINavHistoryContainerResultNode) { + tree.selectedNode.containerOpen = true; + } + } + + let dataTransfer = { + _data: [], + dropEffect, + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + tree._controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + let guid = isVirtualRoot ? dataObject.concreteGuid : dataObject.itemGuid; + + Assert.equal(guid, sourceBm.guid, "Should have the correct guid."); + Assert.equal( + dataObject.title, + PlacesUtils.bookmarks.getLocalizedTitle(sourceBm), + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, dropEffect); + + let ip = new PlacesInsertionPoint({ + parentId: await PlacesUtils.promiseItemId(targetGuid), + parentGuid: targetGuid, + index: 0, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); + }); +} + +add_task(async function test_move_out_of_query() { + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Fake", + url: TEST_URL, + }); + + let queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let queries = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Query", + url: `place:queryType=${queryType}&terms=Fake`, + }, + ], + }); + await simulateDrop( + [queries[0].guid, bookmark.guid], + bookmark, + "move", + PlacesUtils.bookmarks.toolbarGuid + ); + + let newBm = await PlacesUtils.bookmarks.fetch(bookmark.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "should have moved the bookmark to a new folder." + ); + + let oldLocationBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + + Assert.ok(!oldLocationBm, "Should not have a bookmark at the old location."); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js b/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js new file mode 100644 index 0000000000..00a7e7ed39 --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_sidebar.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_setup(async function () { + // The following initialization code is necessary to avoid a frequent + // intermittent failure in verify-fission where, due to timings, we may or + // may not import default bookmarks. + info("Ensure Places init is complete"); + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + Cc["@mozilla.org/browser/browserglue;1"] + .getService(Ci.nsIObserver) + .observe(null, "browser-glue-test", "places-browser-init-complete"); + await placesInitCompleteObserved; + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Simulating actual drag and drop is hard for a xul tree as we can't get the +// required source elements, so we have to do a lot more work by hand. +async function simulateDrop( + selectTargets, + sourceBm, + dropEffect, + targetGuid, + isVirtualRoot = false +) { + await withSidebarTree("bookmarks", async function (tree) { + for (let target of selectTargets) { + tree.selectItems([target]); + if (tree.selectedNode instanceof Ci.nsINavHistoryContainerResultNode) { + tree.selectedNode.containerOpen = true; + } + } + + let dataTransfer = { + _data: [], + dropEffect, + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + let event = { + dataTransfer, + preventDefault() {}, + stopPropagation() {}, + }; + + tree._controller.setDataTransfer(event); + + Assert.equal( + dataTransfer.mozTypesAt(0), + PlacesUtils.TYPE_X_MOZ_PLACE, + "Should have x-moz-place as the first data type." + ); + + let dataObject = JSON.parse(dataTransfer.mozGetDataAt(0)); + + let guid = isVirtualRoot ? dataObject.concreteGuid : dataObject.itemGuid; + + Assert.equal(guid, sourceBm.guid, "Should have the correct guid."); + Assert.equal( + dataObject.title, + PlacesUtils.bookmarks.getLocalizedTitle(sourceBm), + "Should have the correct title." + ); + + Assert.equal(dataTransfer.dropEffect, dropEffect); + + let ip = new PlacesInsertionPoint({ + parentId: await PlacesUtils.promiseItemId(targetGuid), + parentGuid: targetGuid, + index: 0, + orientation: Ci.nsITreeView.DROP_ON, + }); + + await PlacesControllerDragHelper.onDrop(ip, dataTransfer); + }); +} + +add_task(async function test_move_normal_bm_in_sidebar() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Fake", + url: TEST_URL, + }); + + await simulateDrop([bm.guid], bm, "move", PlacesUtils.bookmarks.unfiledGuid); + + let newBm = await PlacesUtils.bookmarks.fetch(bm.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.unfiledGuid, + "Should have moved to the new parent." + ); + + let oldLocationBm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + Assert.ok(!oldLocationBm, "Should not have a bookmark at the old location."); +}); + +add_task(async function test_try_move_root_in_sidebar() { + let menuFolder = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + await simulateDrop( + [menuFolder.guid], + menuFolder, + "move", + PlacesUtils.bookmarks.toolbarGuid, + true + ); + + menuFolder = await PlacesUtils.bookmarks.fetch( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + menuFolder.parentGuid, + PlacesUtils.bookmarks.rootGuid, + "Should have remained in the root" + ); + + let newFolder = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + Assert.notEqual( + newFolder.guid, + menuFolder.guid, + "Should have created a different folder" + ); + Assert.equal( + newFolder.title, + PlacesUtils.bookmarks.getLocalizedTitle(menuFolder), + "Should have copied the folder title." + ); + Assert.equal( + newFolder.type, + PlacesUtils.bookmarks.TYPE_BOOKMARK, + "Should have a bookmark type (for a folder shortcut)." + ); + Assert.equal( + newFolder.url, + `place:parent=${PlacesUtils.bookmarks.menuGuid}`, + "Should have the correct url for the folder shortcut." + ); +}); + +add_task(async function test_try_move_bm_within_two_root_folder_queries() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Fake", + url: TEST_URL, + }); + + let queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + + let queries = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Query", + url: `place:queryType=${queryType}&terms=Fake`, + }, + ], + }); + + await simulateDrop( + [queries[0].guid, bookmark.guid], + bookmark, + "move", + PlacesUtils.bookmarks.toolbarGuid + ); + + let newBm = await PlacesUtils.bookmarks.fetch(bookmark.guid); + + Assert.equal( + newBm.parentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "should have moved the bookmark to a new folder." + ); +}); + +add_task(async function test_move_within_itself() { + await PlacesUtils.bookmarks.eraseEverything(); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://www.example.com/bookmark1.html", + }, + { + title: "bm2", + url: "http://www.example.com/bookmark2.html", + }, + { + title: "bm3", + url: "http://www.example.com/bookmark3.html", + }, + ], + }); + + await withSidebarTree("bookmarks", async function (tree) { + // Select the folder containing the bookmarks + // and save its index position + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + let unfiledFolderIndex = tree.view.treeIndexForNode(tree.selectedNode); + + let guids = bookmarks.map(bookmark => bookmark.guid); + tree.selectItems(guids); + let dataTransfer = { + _data: [], + dropEffect: "move", + mozCursor: "auto", + mozItemCount: bookmarks.length, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return [this._data[0].type]; + }, + mozGetDataAt(i) { + return this._data[0].data; + }, + mozSetDataAt(type, data, index) { + this._data.push({ + type, + data, + index, + }); + }, + }; + + bookmarks.forEach((bookmark, index) => { + // Index positions of the newly created bookmarks + bookmark.rowIndex = unfiledFolderIndex + index + 1; + bookmark.node = tree.view.nodeForTreeIndex(bookmark.rowIndex); + bookmark.cachedBookmarkIndex = bookmark.node.bookmarkIndex; + }); + + let assertBookmarksHaveNotChangedPosition = () => { + bookmarks.forEach(bookmark => { + Assert.equal( + bookmark.node.bookmarkIndex, + bookmark.cachedBookmarkIndex, + "should not have moved the bookmark." + ); + }); + }; + + // Mimic "drag" events + let dragStartEvent = new CustomEvent("dragstart", { + bubbles: true, + }); + dragStartEvent.dataTransfer = dataTransfer; + + let dragEndEvent = new CustomEvent("dragend", { + bubbles: true, + }); + + let treeChildren = tree.view._element.children[1]; + + treeChildren.dispatchEvent(dragStartEvent); + await tree.view.drop( + bookmarks[1].rowIndex, + Ci.nsITreeView.DROP_ON, + dataTransfer + ); + treeChildren.dispatchEvent(dragEndEvent); + assertBookmarksHaveNotChangedPosition(); + + treeChildren.dispatchEvent(dragStartEvent); + await tree.view.drop( + bookmarks[2].rowIndex, + Ci.nsITreeView.DROP_ON, + dataTransfer + ); + treeChildren.dispatchEvent(dragEndEvent); + assertBookmarksHaveNotChangedPosition(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js b/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js new file mode 100644 index 0000000000..fe6211fd8e --- /dev/null +++ b/browser/components/places/tests/browser/browser_controller_onDrop_tagFolder.js @@ -0,0 +1,125 @@ +/* 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"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const sandbox = sinon.createSandbox(); +const TAG_NAME = "testTag"; + +var bookmarks; +var bookmarkId; + +add_setup(async function () { + registerCleanupFunction(async function () { + sandbox.restore(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); + + sandbox.stub(PlacesTransactions, "batch"); + sandbox.stub(PlacesTransactions, "Tag"); + + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "http://example1.com", + }, + { + title: "bm2", + url: "http://example2.com", + tags: [TAG_NAME], + }, + ], + }); + bookmarkId = await PlacesUtils.promiseItemId(bookmarks[0].guid); +}); + +async function run_drag_test(startBookmarkIndex, newParentGuid) { + if (!newParentGuid) { + newParentGuid = PlacesUtils.bookmarks.unfiledGuid; + } + + // Reset the stubs so that previous test runs don't count against us. + PlacesTransactions.Tag.reset(); + PlacesTransactions.batch.reset(); + + let dragBookmark = bookmarks[startBookmarkIndex]; + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([PlacesUtils.bookmarks.unfiledGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + + // Simulating a drag-drop with a tree view turns out to be really difficult + // as you can't get a node for the source/target. Hence, we fake the + // insertion point and drag data and call the function direct. + let ip = new PlacesInsertionPoint({ + isTag: true, + tagName: TAG_NAME, + orientation: Ci.nsITreeView.DROP_ON, + }); + + let bookmarkWithId = JSON.stringify( + Object.assign( + { + id: bookmarkId, + itemGuid: dragBookmark.guid, + uri: dragBookmark.url, + }, + dragBookmark + ) + ); + + let dt = { + dropEffect: "move", + mozCursor: "auto", + mozItemCount: 1, + types: [PlacesUtils.TYPE_X_MOZ_PLACE], + mozTypesAt(i) { + return this.types; + }, + mozGetDataAt(i) { + return bookmarkWithId; + }, + }; + + await PlacesControllerDragHelper.onDrop(ip, dt); + + Assert.ok( + PlacesTransactions.Tag.calledOnce, + "Should have called PlacesTransactions.Tag at least once." + ); + + let arg = PlacesTransactions.Tag.args[0][0]; + + Assert.equal( + arg.urls.length, + 1, + "Should have called PlacesTransactions.Tag with an array of one url" + ); + Assert.equal( + arg.urls[0], + dragBookmark.url, + "Should have called PlacesTransactions.Tag with the correct url" + ); + Assert.equal( + arg.tag, + TAG_NAME, + "Should have called PlacesTransactions.Tag with the correct tag name" + ); + }); +} + +add_task(async function test_simple_drop_and_tag() { + // When we move items down the list, we'll get a drag index that is one higher + // than where we actually want to insert to - as the item is being moved up, + // everything shifts down one. Hence the index to pass to the transaction should + // be one less than the supplied index. + await run_drag_test(0, PlacesUtils.bookmarks.tagGuid); +}); diff --git a/browser/components/places/tests/browser/browser_copy_query_without_tree.js b/browser/components/places/tests/browser/browser_copy_query_without_tree.js new file mode 100644 index 0000000000..fc3adf31f2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_copy_query_without_tree.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/* test that copying a non movable query or folder shortcut makes a new query with the same url, not a deep copy */ + +const QUERY_URL = "place:sort=8&maxResults=10"; + +add_task(async function copy_toolbar_shortcut() { + await promisePlacesInitComplete(); + + let library = await promiseLibrary(); + + registerCleanupFunction(async () => { + library.close(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + await library.ContentTree.view.controller.paste(); + + let toolbarCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + toolbarCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "copy is still a folder shortcut" + ); + + await PlacesUtils.bookmarks.remove(toolbarCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "original is still a folder shortcut" + ); +}); + +add_task(async function copy_mobile_shortcut() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.showMobileBookmarks", true]], + }); + await promisePlacesInitComplete(); + + let library = await promiseLibrary(); + + registerCleanupFunction(async () => { + library.close(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualMobileGuid, + ]); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + await library.ContentTree.view.controller.paste(); + + let mobileCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + mobileCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "copy is still a folder shortcut" + ); + + await PlacesUtils.bookmarks.remove(mobileCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT, + "original is still a folder shortcut" + ); +}); + +add_task(async function copy_history_query() { + let library = await promiseLibrary(); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + + await promiseClipboard(function () { + library.PlacesOrganizer._places.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + await library.ContentTree.view.controller.paste(); + + let historyCopyNode = library.ContentTree.view.view.nodeForTreeIndex(0); + is( + historyCopyNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, + "copy is still a query" + ); + + await PlacesUtils.bookmarks.remove(historyCopyNode.bookmarkGuid); + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + is( + library.PlacesOrganizer._places.selectedNode.type, + Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY, + "original is still a query" + ); +}); diff --git a/browser/components/places/tests/browser/browser_cutting_bookmarks.js b/browser/components/places/tests/browser/browser_cutting_bookmarks.js new file mode 100644 index 0000000000..7c10229f7e --- /dev/null +++ b/browser/components/places/tests/browser/browser_cutting_bookmarks.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; + +add_task(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + let PlacesOrganizer = organizer.PlacesOrganizer; + let ContentTree = organizer.ContentTree; + + // Test with multiple entries to ensure they retain their order. + let bookmarks = []; + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "0", + }) + ); + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "1", + }) + ); + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URL, + title: "2", + }) + ); + + await selectBookmarksIn(organizer, bookmarks, "BookmarksToolbar"); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + await selectBookmarksIn(organizer, bookmarks, "UnfiledBookmarks"); +}); + +var selectBookmarksIn = async function (organizer, bookmarks, aLeftPaneQuery) { + let PlacesOrganizer = organizer.PlacesOrganizer; + let ContentTree = organizer.ContentTree; + info("Selecting " + aLeftPaneQuery + " in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn(aLeftPaneQuery); + + for (let { guid } of bookmarks) { + let bookmark = await PlacesUtils.bookmarks.fetch(guid); + is( + bookmark.parentGuid, + PlacesUtils.getConcreteItemGuid(PlacesOrganizer._places.selectedNode), + "Bookmark has the right parent" + ); + } + + info("Selecting the bookmarks in the right pane"); + ContentTree.view.selectItems(bookmarks.map(bm => bm.guid)); + + for (let node of ContentTree.view.selectedNodes) { + is( + "" + node.bookmarkIndex, + node.title, + "Found the expected bookmark in the expected position" + ); + } +}; diff --git a/browser/components/places/tests/browser/browser_default_bookmark_location.js b/browser/components/places/tests/browser/browser_default_bookmark_location.js new file mode 100644 index 0000000000..9b386fd2f0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_default_bookmark_location.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LOCATION_PREF = "browser.bookmarks.defaultLocation"; +const TEST_URL = "about:about"; +let bookmarkPanel; +let win; + +add_setup(async function () { + Services.prefs.clearUserPref(LOCATION_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + + win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let oldTimeout = win.StarUI._autoCloseTimeout; + // Make the timeout something big, so it doesn't interact badly with tests. + win.StarUI._autoCloseTimeout = 6000000; + + win.StarUI._createPanelIfNeeded(); + bookmarkPanel = win.document.getElementById("editBookmarkPanel"); + bookmarkPanel.setAttribute("animate", false); + + registerCleanupFunction(async () => { + bookmarkPanel = null; + win.StarUI._autoCloseTimeout = oldTimeout; + await BrowserTestUtils.closeWindow(win); + win = null; + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(LOCATION_PREF); + }); +}); + +async function cancelBookmarkCreationInPanel() { + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + // Confirm and close the dialog. + + win.document.getElementById("editBookmarkPanelRemoveButton").click(); + await hiddenPromise; +} + +/** + * Helper to check the selected folder is correct. + */ +async function checkSelection() { + // Open folder selector. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + let expectedFolder = "BookmarksToolbarFolderTitle"; + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have ${expectedFolder} selected by default` + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + await PlacesUIUtils.defaultParentGuid, + "Should have the correct default guid selected" + ); + + await cancelBookmarkCreationInPanel(); +} + +/** + * Verify that bookmarks created with the star button go to the default + * bookmark location. + */ +add_task(async function test_star_location() { + await clickBookmarkStar(win); + await checkSelection(); +}); + +/** + * Verify that bookmarks created with the shortcut go to the default bookmark + * location. + */ +add_task(async function test_shortcut_location() { + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.document.getElementById("Browser:AddBookmarkAs").doCommand(); + await shownPromise; + await checkSelection(); +}); + +// Note: Bookmarking frames is tested in browser_addBookmarkForFrame.js + +/** + * Verify that bookmarks created with the link context menu go to the default + * bookmark location. + */ +add_task(async function test_context_menu_link() { + for (let t = 0; t < 2; t++) { + if (t == 1) { + // For the second iteration, ensure that the default folder is invalid first. + await createAndRemoveDefaultFolder(); + } + + await withBookmarksDialog( + true, + async function openDialog() { + const contextMenu = win.document.getElementById( + "contentAreaContextMenu" + ); + is(contextMenu.state, "closed", "checking if popup is closed"); + let promisePopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + BrowserTestUtils.synthesizeMouseAtCenter( + "a[href*=config]", // Bookmark about:config + { type: "contextmenu", button: 2 }, + win.gBrowser.selectedBrowser + ); + await promisePopupShown; + contextMenu.activateItem( + win.document.getElementById("context-bookmarklink") + ); + }, + async function test(dialogWin) { + let expectedFolder = "BookmarksToolbarFolderTitle"; + let expectedFolderName = PlacesUtils.getString(expectedFolder); + + let folderPicker = dialogWin.document.getElementById( + "editBMPanel_folderMenuList" + ); + + // Check the initial state of the folder picker. + await TestUtils.waitForCondition( + () => folderPicker.selectedItem.label == expectedFolderName, + "The folder is the expected one." + ); + } + ); + } +}); + +/** + * Verify that if we change the location, we persist that selection. + */ +add_task(async function test_change_location_panel() { + await clickBookmarkStar(win); + + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + + let { toolbarGuid, menuGuid, unfiledGuid } = PlacesUtils.bookmarks; + + let expectedFolderGuid = toolbarGuid; + + info("Pref value: " + Services.prefs.getCharPref(LOCATION_PREF, "")); + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == expectedFolderGuid, + "Should initially select the unfiled or toolbar item" + ); + + // Now move this new bookmark to the menu: + let promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Make sure we wait for the bookmark to be added. + let itemAddedPromise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url === TEST_URL) + ); + + // Wait for the pref to change + let prefChangedPromise = TestUtils.waitForPrefChange(LOCATION_PREF); + + // Click the choose item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_bmRootItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == menuGuid, + "Should select the menu folder item" + ); + + info("Waiting for transactions to finish."); + await Promise.all(win.gEditItemOverlay.transactionPromises); + info("Moved; waiting to hide panel."); + + await hideBookmarksPanel(win); + info("Waiting for pref change."); + await prefChangedPromise; + info("Waiting for item to be added."); + await itemAddedPromise; + + // Check that it's in the menu, and remove the bookmark: + let bm = await PlacesUtils.bookmarks.fetch({ url: TEST_URL }); + is(bm?.parentGuid, menuGuid, "Bookmark was put in the menu."); + if (bm) { + await PlacesUtils.bookmarks.remove(bm); + } + + // Now create a new bookmark and check it starts in the menu + await clickBookmarkStar(win); + + let expectedFolder = "BookmarksMenuFolderTitle"; + Assert.equal( + menuList.label, + PlacesUtils.getString(expectedFolder), + `Should have menu folder selected by default` + ); + Assert.equal( + menuList.getAttribute("selectedGuid"), + menuGuid, + "Should have the correct default guid selected" + ); + + // Now select a different item. + promisePopup = BrowserTestUtils.waitForEvent( + menuList.menupopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(menuList, {}, win); + await promisePopup; + + // Click the toolbar item. + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("editBMPanel_toolbarFolderItem"), + {}, + win + ); + + await TestUtils.waitForCondition( + () => menuList.getAttribute("selectedGuid") == toolbarGuid, + "Should select the toolbar item" + ); + + await cancelBookmarkCreationInPanel(); + + is( + await PlacesUIUtils.defaultParentGuid, + menuGuid, + "Default folder should not change if we cancel the panel." + ); + + // Now open the panel for an existing bookmark whose parent doesn't match + // the default and check we don't overwrite the default folder. + let testBM = await PlacesUtils.bookmarks.insert({ + parentGuid: unfiledGuid, + title: "Yoink", + url: TEST_URL, + }); + await TestUtils.waitForCondition( + () => win.BookmarkingUI.star.hasAttribute("starred"), + "Wait for bookmark to show up for current page." + ); + await clickBookmarkStar(win); + + await hideBookmarksPanel(win); + is( + await PlacesUIUtils.defaultParentGuid, + menuGuid, + "Default folder should not change if we accept the panel, but didn't change folders." + ); + + await PlacesUtils.bookmarks.remove(testBM); +}); diff --git a/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js b/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js new file mode 100644 index 0000000000..ea96282f30 --- /dev/null +++ b/browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js @@ -0,0 +1,255 @@ +/* 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/. */ + +const TEST_URL = "http://www.mozilla.org"; +const TEST_TITLE = "example_title"; + +var gBookmarksToolbar = window.document.getElementById("PersonalToolbar"); +var dragDirections = { LEFT: 0, UP: 1, RIGHT: 2, DOWN: 3 }; + +/** + * Tests dragging on toolbar. + * + * We must test these 2 cases: + * - Dragging toward left, top, right should start a drag. + * - Dragging toward down should should open the container if the item is a + * container, drag the item otherwise. + * + * @param {object} aElement + * DOM node element we will drag + * @param {Array} aExpectedDragData + * Array of flavors and values in the form: + * [ ["text/plain: sometext", "text/html: <b>sometext</b>"], [...] ] + * Pass an empty array to check that drag even has been canceled. + * @param {number} aDirection + * Direction for the dragging gesture, see dragDirections helper object. + * @returns {Promise} Resolved once the drag gesture has been observed. + */ +function synthesizeDragWithDirection(aElement, aExpectedDragData, aDirection) { + let promise = new Promise(resolve => { + // Dragstart listener function. + gBookmarksToolbar.addEventListener("dragstart", function listener(event) { + info("A dragstart event has been trapped."); + var dataTransfer = event.dataTransfer; + is( + dataTransfer.mozItemCount, + aExpectedDragData.length, + "Number of dragged items should be the same." + ); + + for (var t = 0; t < dataTransfer.mozItemCount; t++) { + var types = dataTransfer.mozTypesAt(t); + var expecteditem = aExpectedDragData[t]; + is( + types.length, + expecteditem.length, + "Number of flavors for item " + t + " should be the same." + ); + + for (var f = 0; f < types.length; f++) { + is( + types[f], + expecteditem[f].substring(0, types[f].length), + "Flavor " + types[f] + " for item " + t + " should be the same." + ); + is( + dataTransfer.mozGetDataAt(types[f], t), + expecteditem[f].substring(types[f].length + 2), + "Contents for item " + + t + + " with flavor " + + types[f] + + " should be the same." + ); + } + } + + if (!aExpectedDragData.length) { + ok(event.defaultPrevented, "Drag has been canceled."); + } + + event.preventDefault(); + event.stopPropagation(); + + gBookmarksToolbar.removeEventListener("dragstart", listener); + + // This is likely to cause a click event, and, in case we are dragging a + // bookmark, an unwanted page visit. Prevent the click event. + aElement.addEventListener("click", prevent); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mouseup" } + ); + aElement.removeEventListener("click", prevent); + + // Cleanup eventually opened menus. + if (aElement.localName == "menu" && aElement.open) { + aElement.open = false; + } + resolve(); + }); + }); + + var prevent = function (aEvent) { + aEvent.preventDefault(); + }; + + var xIncrement = 0; + var yIncrement = 0; + + switch (aDirection) { + case dragDirections.LEFT: + xIncrement = -1; + break; + case dragDirections.RIGHT: + xIncrement = +1; + break; + case dragDirections.UP: + yIncrement = -1; + break; + case dragDirections.DOWN: + yIncrement = +1; + break; + } + + var rect = aElement.getBoundingClientRect(); + var startingPoint = { + x: (rect.right - rect.left) / 2, + y: (rect.bottom - rect.top) / 2, + }; + + EventUtils.synthesizeMouse(aElement, startingPoint.x, startingPoint.y, { + type: "mousedown", + }); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 1, + startingPoint.y + yIncrement * 1, + { type: "mousemove" } + ); + EventUtils.synthesizeMouse( + aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mousemove" } + ); + + return promise; +} + +function getToolbarNodeForItemId(itemGuid) { + var children = document.getElementById("PlacesToolbarItems").children; + for (let child of children) { + if (itemGuid == child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +function getExpectedDataForPlacesNode(aNode) { + var wrappedNode = []; + var flavors = [ + "text/x-moz-place", + "text/x-moz-url", + "text/plain", + "text/html", + ]; + + flavors.forEach(function (aFlavor) { + var wrappedFlavor = aFlavor + ": " + PlacesUtils.wrapNode(aNode, aFlavor); + wrappedNode.push(wrappedFlavor); + }); + + return [wrappedNode]; +} + +add_setup(async function () { + var toolbar = document.getElementById("PersonalToolbar"); + var wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + await promiseSetToolbarVisibility(toolbar, false); + }); +}); + +add_task(async function test_drag_folder_on_toolbar() { + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_TITLE, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + + var element = getToolbarNodeForItemId(folder.guid); + isnot(element, null, "Found node on toolbar"); + + isnot( + element._placesNode, + null, + "Toolbar node has an associated Places node." + ); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + info("Dragging left"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + + info("Dragging right"); + await synthesizeDragWithDirection( + element, + expectedData, + dragDirections.RIGHT + ); + + info("Dragging up"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + + info("Dragging down"); + await synthesizeDragWithDirection(element, [], dragDirections.DOWN); + + await PlacesUtils.bookmarks.remove(folder); +}); + +add_task(async function test_drag_bookmark_on_toolbar() { + var bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: TEST_TITLE, + url: TEST_URL, + }); + + var element = getToolbarNodeForItemId(bookmark.guid); + isnot(element, null, "Found node on toolbar"); + + isnot( + element._placesNode, + null, + "Toolbar node has an associated Places node." + ); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + info("Dragging left"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + + info("Dragging right"); + await synthesizeDragWithDirection( + element, + expectedData, + dragDirections.RIGHT + ); + + info("Dragging up"); + await synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + + info("Dragging down"); + synthesizeDragWithDirection(element, expectedData, dragDirections.DOWN); + + await PlacesUtils.bookmarks.remove(bookmark); +}); diff --git a/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js b/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js new file mode 100644 index 0000000000..455b52a9c2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_drag_folder_on_newTab.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Clean before and after so we don't have anything in the folders. + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async function () { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +const TEST_FOLDER_NAME = "Test folder"; + +add_task(async function test_change_location_from_Toolbar() { + let newTabButton = document.getElementById("tabs-newtab-button"); + + let children = [ + { + title: "first", + url: "http://www.mochi.test/first", + }, + { + title: "second", + url: "http://www.mochi.test/second", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "third", + url: "http://www.mochi.test/third", + }, + ]; + let guid = PlacesUtils.history.makeGuid(); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + guid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_FOLDER_NAME, + children, + }, + ], + }); + + let folder = getToolbarNodeForItemGuid(guid); + + let loadedPromises = children + .filter(item => "url" in item) + .map(item => + BrowserTestUtils.waitForNewTab(gBrowser, item.url, false, true) + ); + + let srcX = 10, + srcY = 10; + // We should drag upwards, since dragging downwards opens menu instead. + let stepX = 0, + stepY = -5; + + // We need to dispatch mousemove before dragging, to populate + // PlacesToolbar._cachedMouseMoveEvent, with the cursor position after the + // first step, so that the places code detects it as dragging upward. + EventUtils.synthesizeMouse(folder, srcX + stepX, srcY + stepY, { + type: "mousemove", + }); + + await EventUtils.synthesizePlainDragAndDrop({ + srcElement: folder, + destElement: newTabButton, + srcX, + srcY, + stepX, + stepY, + }); + + let tabs = await Promise.all(loadedPromises); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + + ok(true); +}); diff --git a/browser/components/places/tests/browser/browser_editBookmark_keywords.js b/browser/components/places/tests/browser/browser_editBookmark_keywords.js new file mode 100644 index 0000000000..5489a06165 --- /dev/null +++ b/browser/components/places/tests/browser/browser_editBookmark_keywords.js @@ -0,0 +1,64 @@ +"use strict"; + +const TEST_URL = "about:blank"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_URL, + waitForStateStop: true, + }); + + let library = await promiseLibrary("UnfiledBookmarks"); + registerCleanupFunction(async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + await BrowserTestUtils.removeTab(tab); + }); + + let keywordField = library.document.getElementById( + "editBMPanel_keywordField" + ); + + for (let i = 0; i < 2; ++i) { + let bm = await PlacesUtils.bookmarks.insert({ + url: `http://www.test${i}.me/`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + let node = library.ContentTree.view.view.nodeForTreeIndex(i); + is(node.bookmarkGuid, bm.guid, "Found the expected bookmark"); + // Select the bookmark. + library.ContentTree.view.selectNode(node); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view); + + is( + library.document.getElementById("editBMPanel_keywordField").value, + "", + "The keyword field should be empty" + ); + info("Add a keyword to the bookmark"); + const promise = PlacesTestUtils.waitForNotification( + "bookmark-keyword-changed" + ); + keywordField.focus(); + keywordField.value = "kw"; + EventUtils.sendString(i.toString(), library); + keywordField.blur(); + const events = await promise; + is(events.length, 1, "Number of events fired is correct"); + const keyword = events[0].keyword; + is(keyword, `kw${i}`, "The new keyword value is correct"); + } + + for (let i = 0; i < 2; ++i) { + let entry = await PlacesUtils.keywords.fetch({ + url: `http://www.test${i}.me/`, + }); + is( + entry.keyword, + `kw${i}`, + `The keyword for http://www.test${i}.me/ is correct` + ); + } +}); diff --git a/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js new file mode 100644 index 0000000000..8d4d650984 --- /dev/null +++ b/browser/components/places/tests/browser/browser_enable_toolbar_sidebar.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the Bookmarks Toolbar and Sidebar can be enabled from the Bookmarks Menu ("View history, + * saved bookmarks, and more" button. + */ + +// Cleanup. +registerCleanupFunction(async () => { + CustomizableUI.setToolbarVisibility("PersonalToolbar", false); + CustomizableUI.removeWidgetFromArea("library-button"); + SidebarUI.hide(); +}); + +async function selectAppMenuView(buttonId, viewId) { + let btn; + await TestUtils.waitForCondition(() => { + btn = document.getElementById(buttonId); + return btn; + }, "Should have the " + buttonId + " button"); + btn.click(); + let view = document.getElementById(viewId); + let viewPromise = BrowserTestUtils.waitForEvent(view, "ViewShown"); + await viewPromise; +} + +async function openBookmarkingPanelInLibraryToolbarButton() { + await selectAppMenuView("library-button", "appMenu-libraryView"); + await selectAppMenuView( + "appMenu-library-bookmarks-button", + "PanelUI-bookmarks" + ); +} + +add_task(async function test_enable_toolbar() { + CustomizableUI.addWidgetToArea("library-button", "nav-bar"); + + await openBookmarkingPanelInLibraryToolbarButton(); + let toolbar = document.getElementById("PersonalToolbar"); + Assert.ok(toolbar.collapsed, "Bookmarks Toolbar is hidden"); + + let viewBookmarksToolbarBtn; + await TestUtils.waitForCondition(() => { + viewBookmarksToolbarBtn = document.getElementById( + "panelMenu_viewBookmarksToolbar" + ); + return viewBookmarksToolbarBtn; + }, "Should have the library 'View Bookmarks Toolbar' button."); + viewBookmarksToolbarBtn.click(); + await TestUtils.waitForCondition( + () => !toolbar.collapsed, + "Should have the Bookmarks Toolbar enabled." + ); + Assert.ok(!toolbar.collapsed, "Bookmarks Toolbar is enabled"); +}); diff --git a/browser/components/places/tests/browser/browser_forgetthissite.js b/browser/components/places/tests/browser/browser_forgetthissite.js new file mode 100644 index 0000000000..380497aed7 --- /dev/null +++ b/browser/components/places/tests/browser/browser_forgetthissite.js @@ -0,0 +1,262 @@ +/* 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"; + +// Tests the "Forget About This Site" button from the libary view +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { ForgetAboutSite } = ChromeUtils.importESModule( + "resource://gre/modules/ForgetAboutSite.sys.mjs" +); + +const TEST_URIs = [ + { title: "0", uri: "http://example.com" }, + { title: "1", uri: "http://www.mozilla.org/test1" }, + { title: "2", uri: "http://www.mozilla.org/test2" }, + { title: "3", uri: "https://192.168.200.1/login.html" }, +]; + +async function setup() { + registerCleanupFunction(async function () { + // Clean up any leftover stubs. + sinon.restore(); + }); + + let places = []; + let transition = PlacesUtils.history.TRANSITION_TYPED; + TEST_URIs.forEach(({ title, uri }) => + places.push({ uri: Services.io.newURI(uri), transition, title }) + ); + await PlacesTestUtils.addVisits(places); +} + +async function teardown(organizer) { + // Close the library window. + await promiseLibraryClosed(organizer); + await PlacesUtils.history.clear(); +} + +// Selects the sites specified by sitesToSelect +// If multiple sites are selected they can't be forgotten +// Should forget selects the answer in the confirmation dialogue +// removedEntries specifies which entries should be forgotten +async function testForgetAboutThisSite( + sitesToSelect, + shouldForget, + removedEntries, + cancelConfirmWithEsc = false +) { + if (cancelConfirmWithEsc) { + ok( + !shouldForget, + "If cancelConfirmWithEsc is set we don't expect to clear entries." + ); + } + + ok(PlacesUtils, "checking PlacesUtils, running in chrome context?"); + await setup(); + let organizer = await promiseHistoryView(); + let doc = organizer.document; + let tree = doc.getElementById("placeContent"); + + //Sort by name in descreasing order + tree.view._result.sortingMode = + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING; + + let selection = tree.view.selection; + selection.clearSelection(); + sitesToSelect.forEach(index => selection.rangedSelect(index, index, true)); + + let selectionCount = sitesToSelect.length; + is( + selection.count, + selectionCount, + "The selected range is as big as expected" + ); + // Open the context menu. + let contextmenu = doc.getElementById("placesContext"); + let popupShown = promisePopupShown(contextmenu); + + // Get cell coordinates. + let rect = tree.getCoordsForCellItem( + sitesToSelect[0], + tree.columns[0], + "text" + ); + // Initiate a context menu for the selected cell. + EventUtils.synthesizeMouse( + tree.body, + rect.x + rect.width / 2, + rect.y + rect.height / 2, + { type: "contextmenu", button: 2 }, + organizer + ); + await popupShown; + + let forgetThisSite = doc.getElementById("placesContext_deleteHost"); + let hideForgetThisSite = selectionCount > 1; + is( + forgetThisSite.hidden, + hideForgetThisSite, + `The Forget this site menu item should ${ + hideForgetThisSite ? "" : "not " + }be hidden with ${selectionCount} items selected` + ); + if (hideForgetThisSite) { + // Close the context menu. + contextmenu.hidePopup(); + await teardown(organizer); + return; + } + + // Resolves once the confirmation prompt has been closed. + let promptPromise; + + // Cancel prompt via esc key. We have to get the prompt closed promise + // ourselves. + if (cancelConfirmWithEsc) { + promptPromise = PromptTestUtils.waitForPrompt(organizer, { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + promptType: "confirmEx", + }).then(dialog => { + let dialogWindow = dialog.ui.prompt; + let dialogClosedPromise = BrowserTestUtils.waitForEvent( + dialogWindow.opener, + "DOMModalDialogClosed" + ); + EventUtils.synthesizeKey("KEY_Escape", undefined, dialogWindow); + + return dialogClosedPromise; + }); + } else { + // Close prompt via buttons. PromptTestUtils supplies the closed promise. + promptPromise = PromptTestUtils.handleNextPrompt( + organizer, + { modalType: Services.prompt.MODAL_TYPE_WINDOW, promptType: "confirmEx" }, + { buttonNumClick: shouldForget ? 0 : 1 } + ); + } + + // If we cancel the prompt, create stubs to check that none of the clear + // methods are called. + if (!shouldForget) { + sinon.stub(ForgetAboutSite, "removeDataFromBaseDomain").resolves(); + sinon.stub(ForgetAboutSite, "removeDataFromDomain").resolves(); + } + + let pageRemovedEventPromise; + if (shouldForget) { + pageRemovedEventPromise = + PlacesTestUtils.waitForNotification("page-removed"); + } + + // Execute the delete command. + contextmenu.activateItem(forgetThisSite); + + // Wait for prompt to be handled. + await promptPromise; + + // If we expect to remove items, wait the page-removed event to fire. If we + // don't wait, we may test the list before any items have been removed. + await pageRemovedEventPromise; + + if (!shouldForget) { + ok( + ForgetAboutSite.removeDataFromBaseDomain.notCalled && + ForgetAboutSite.removeDataFromDomain.notCalled, + "Should not call ForgetAboutSite when the confirmation prompt is cancelled." + ); + // Remove the stubs. + sinon.restore(); + } + + // Check that the entries have been removed. + await Promise.all( + removedEntries.map(async ({ uri }) => { + Assert.ok( + !(await PlacesUtils.history.fetch(uri)), + `History entry for ${uri} has been correctly removed` + ); + }) + ); + await Promise.all( + TEST_URIs.filter(x => !removedEntries.includes(x)).map(async ({ uri }) => { + Assert.ok( + await PlacesUtils.history.fetch(uri), + `History entry for ${uri} has been kept` + ); + }) + ); + + // Cleanup. + await teardown(organizer); +} + +/* + * Opens the history view in the PlacesOrganziner window + * @returns {Promise} + * @resolves The PlacesOrganizer + */ +async function promiseHistoryView() { + let organizer = await promiseLibrary(); + + // Select History in the left pane. + let po = organizer.PlacesOrganizer; + po.selectLeftPaneBuiltIn("History"); + + let histContainer = po._places.selectedNode.QueryInterface( + Ci.nsINavHistoryContainerResultNode + ); + histContainer.containerOpen = true; + po._places.selectNode(histContainer.getChild(0)); + + return organizer; +} +/* + * @returns {Promise} + * @resolves once the popup is shown + */ +function promisePopupShown(popup) { + return new Promise(resolve => { + popup.addEventListener( + "popupshown", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); +} + +// This test makes sure that the Forget This Site command is hidden for multiple +// selections. +add_task(async function selectMultiple() { + await testForgetAboutThisSite([0, 1]); +}); + +// This test makes sure that forgetting "http://www.mozilla.org/test2" also removes "http://www.mozilla.org/test1" +add_task(async function forgettingBasedomain() { + await testForgetAboutThisSite([1], true, TEST_URIs.slice(1, 3)); +}); + +// This test makes sure that forgetting by IP address works +add_task(async function forgettingIPAddress() { + await testForgetAboutThisSite([3], true, TEST_URIs.slice(3, 4)); +}); + +// This test makes sure that forgetting file URLs works +add_task(async function dontAlwaysForget() { + await testForgetAboutThisSite([0], false, []); +}); + +// When cancelling the confirmation prompt via ESC key, no entries should be +// cleared. +add_task(async function cancelConfirmWithEsc() { + await testForgetAboutThisSite([0], false, [], true); +}); diff --git a/browser/components/places/tests/browser/browser_history_sidebar_search.js b/browser/components/places/tests/browser/browser_history_sidebar_search.js new file mode 100644 index 0000000000..44d51eec31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_history_sidebar_search.js @@ -0,0 +1,71 @@ +add_task(async function test() { + let sidebar = document.getElementById("sidebar"); + + // Visited pages listed by descending visit date. + let pages = [ + "http://sidebar.mozilla.org/a", + "http://sidebar.mozilla.org/b", + "http://sidebar.mozilla.org/c", + "http://www.mozilla.org/d", + ]; + + // Number of pages that will be filtered out by the search. + const FILTERED_COUNT = 1; + + await PlacesUtils.history.clear(); + + // Add some visited page. + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + await withSidebarTree("history", function () { + info("Set 'by last visited' view"); + sidebar.contentDocument.getElementById("bylastvisited").doCommand(); + let tree = sidebar.contentDocument.getElementById("historyTree"); + check_tree_order(tree, pages); + + // Set a search value. + let searchBox = sidebar.contentDocument.getElementById("search-box"); + ok(searchBox, "search box is in context"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + check_tree_order(tree, pages, -FILTERED_COUNT); + + info("Reset the search"); + searchBox.value = ""; + searchBox.doCommand(); + check_tree_order(tree, pages); + }); + + await PlacesUtils.history.clear(); +}); + +function check_tree_order(tree, pages, aNumberOfRowsDelta = 0) { + let treeView = tree.view; + let columns = tree.columns; + is(columns.count, 1, "There should be only 1 column in the sidebar"); + + let found = 0; + for (let i = 0; i < treeView.rowCount; i++) { + let node = treeView.nodeForTreeIndex(i); + // We could inherit delayed visits from previous tests, skip them. + if (!pages.includes(node.uri)) { + continue; + } + is( + node.uri, + pages[i], + "Node is in correct position based on its visit date" + ); + found++; + } + is(found, pages.length + aNumberOfRowsDelta, "Found all expected results"); +} diff --git a/browser/components/places/tests/browser/browser_import_button.js b/browser/components/places/tests/browser/browser_import_button.js new file mode 100644 index 0000000000..146fd746f8 --- /dev/null +++ b/browser/components/places/tests/browser/browser_import_button.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kPref = "browser.bookmarks.addedImportButton"; + +/** + * Verify that we add the import button only if there aren't enough bookmarks + * in the toolbar. + */ +add_task(async function test_bookmark_import_button() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + CustomizableUI.reset(); + + // Add some bookmarks. This should stop the import button from being inserted. + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = await Promise.all( + ["firefox", "rules", "yo"].map(n => + PlacesUtils.bookmarks.insert({ + parentGuid, + url: `https://example.com/${n}`, + title: n.toString(), + }) + ) + ); + + // Ensure we remove items after this task, or worst-case after this test + // file has completed. + let removeAllBookmarks = () => { + let removals = bookmarks.map(b => PlacesUtils.bookmarks.remove(b.guid)); + bookmarks = []; + return Promise.all(removals); + }; + registerCleanupFunction(removeAllBookmarks); + + await PlacesUIUtils.maybeAddImportButton(); + ok( + !document.getElementById("import-button"), + "Button should not be added if we have bookmarks." + ); + + // Just in case, for future tests we run: + CustomizableUI.reset(); + + await removeAllBookmarks(); +}); + +/** + * Verify the button gets removed when we import bookmarks successfully. + */ +add_task(async function test_bookmark_import_button_removal() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + Services.obs.notifyObservers( + null, + "Migration:ItemAfterMigrate", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is( + Services.prefs.getBoolPref(kPref, false), + true, + "Pref should stay without import." + ); + ok(document.getElementById("import-button"), "Button should still be there."); + + // OK, actually add some bookmarks: + MigrationUtils._importQuantities.bookmarks = 5; + Services.obs.notifyObservers( + null, + "Migration:ItemAfterMigrate", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); + ok( + !document.getElementById("import-button"), + "Button should have been removed." + ); + + // Reset this, otherwise subsequent tests are going to have a bad time. + MigrationUtils._importQuantities.bookmarks = 0; +}); + +/** + * Check that if the user removes the button, the next startup + * we clear the pref and stop monitoring to remove the item. + */ +add_task(async function test_bookmark_import_button_removal_cleanup() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + // Simulate the user removing the item. + CustomizableUI.removeWidgetFromArea("import-button"); + + // We'll call this next startup: + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + // And it should clean up the pref: + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); +}); + +/** + * Check that if migration (silently) errors, we still remove the button + * _if_ we imported any bookmarks. + */ +add_task(async function test_bookmark_import_button_errors() { + let bookmarkCount = ( + await PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid) + ).childCount; + Assert.less(bookmarkCount, 3, "we should start with less than 3 bookmarks"); + + ok( + !document.getElementById("import-button"), + "Shouldn't have button to start with." + ); + await PlacesUIUtils.maybeAddImportButton(); + ok(document.getElementById("import-button"), "Button should be added."); + is(Services.prefs.getBoolPref(kPref), true, "Pref should be set."); + + PlacesUIUtils.removeImportButtonWhenImportSucceeds(); + + Services.obs.notifyObservers( + null, + "Migration:ItemError", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is( + Services.prefs.getBoolPref(kPref, false), + true, + "Pref should stay when fatal error happens." + ); + ok(document.getElementById("import-button"), "Button should still be there."); + + // OK, actually add some bookmarks: + MigrationUtils._importQuantities.bookmarks = 5; + Services.obs.notifyObservers( + null, + "Migration:ItemError", + MigrationUtils.resourceTypes.BOOKMARKS + ); + + is(Services.prefs.prefHasUserValue(kPref), false, "Pref should be removed."); + ok( + !document.getElementById("import-button"), + "Button should have been removed." + ); + + MigrationUtils._importQuantities.bookmarks = 0; +}); diff --git a/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js b/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js new file mode 100644 index 0000000000..0e94ce847f --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bookmark_clear_visits.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * Test whether or not that Most Recent Visit of bookmark in library window will + * update properly when removing the history. + */ + +const TEST_URLS = ["https://example.com/", "https://example.org/"]; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[0], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URLS[1], + }); + PlacesUtils.tagging.tagURI(makeURI(TEST_URLS[0]), ["test"]); + PlacesUtils.tagging.tagURI(makeURI(TEST_URLS[1]), ["test"]); + + registerCleanupFunction(async () => { + PlacesUtils.tagging.untagURI(makeURI(TEST_URLS[0]), ["test"]); + PlacesUtils.tagging.untagURI(makeURI(TEST_URLS[1]), ["test"]); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function folder() { + info("Open bookmarked urls to update most recent visit time"); + await updateMostRecentVisitTime(); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Add Most Recent Visit column"); + await showMostRecentColumn(library); + + info("Check the initial content"); + const tree = library.document.getElementById("placeContent"); + Assert.equal(tree.view.rowCount, 3, "All bookmarks are shown"); + assertRow(tree, 0, TEST_URLS[0], true); + assertRow(tree, 1, TEST_URLS[1], true); + assertRow(tree, 2, TEST_URLS[1], true); + + info("Clear all visits data"); + await PlacesUtils.history.remove(TEST_URLS); + + info("Check whether or not the content are updated"); + assertRow(tree, 0, TEST_URLS[0], false); + assertRow(tree, 1, TEST_URLS[1], false); + assertRow(tree, 2, TEST_URLS[1], false); + + info("Close library window"); + await promiseLibraryClosed(library); +}); + +add_task(async function tags() { + info("Open bookmarked urls to update most recent visit time"); + await updateMostRecentVisitTime(); + + info("Open library for bookmarks toolbar"); + const library = await promiseLibrary("BookmarksToolbar"); + + info("Add Most Recent Visit column"); + await showMostRecentColumn(library); + + info("Open test tag"); + const PO = library.PlacesOrganizer; + PO.selectLeftPaneBuiltIn("Tags"); + const tagsNode = PO._places.selectedNode; + PlacesUtils.asContainer(tagsNode).containerOpen = true; + const tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + + info("Check the initial content"); + const tree = library.ContentTree.view; + Assert.equal(tree.view.rowCount, 3, "All bookmarks are shown"); + assertRow(tree, 0, TEST_URLS[0], true); + assertRow(tree, 1, TEST_URLS[1], true); + assertRow(tree, 2, TEST_URLS[1], true); + + info("Clear all visits data"); + await PlacesUtils.history.remove(TEST_URLS); + + info("Check whether or not the content are updated"); + assertRow(tree, 0, TEST_URLS[0], false); + assertRow(tree, 1, TEST_URLS[1], false); + assertRow(tree, 2, TEST_URLS[1], false); + + info("Close library window"); + await promiseLibraryClosed(library); +}); + +async function updateMostRecentVisitTime() { + for (const url of TEST_URLS) { + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser, false, url); + BrowserTestUtils.loadURIString(gBrowser, url); + await onLoaded; + } +} + +async function showMostRecentColumn(library) { + const viewMenu = library.document.getElementById("viewMenu"); + const viewMenuPopup = library.document.getElementById("viewMenuPopup"); + const onViewMenuPopup = new Promise(resolve => { + viewMenuPopup.addEventListener("popupshown", () => resolve()); + }); + EventUtils.synthesizeMouseAtCenter(viewMenu, {}, library); + await onViewMenuPopup; + + const viewColumns = library.document.getElementById("viewColumns"); + const viewColumnsPopup = viewColumns.querySelector("menupopup"); + const onViewColumnsPopup = new Promise(resolve => { + viewColumnsPopup.addEventListener("popupshown", () => resolve()); + }); + EventUtils.synthesizeMouseAtCenter(viewColumns, {}, library); + await onViewColumnsPopup; + + const mostRecentVisitColumnMenu = library.document.getElementById( + "menucol_placesContentDate" + ); + EventUtils.synthesizeMouseAtCenter(mostRecentVisitColumnMenu, {}, library); +} + +function assertRow(tree, targeRow, expectedUrl, expectMostRecentVisitHasValue) { + const url = tree.view.getCellText(targeRow, tree.columns.placesContentUrl); + Assert.equal(url, expectedUrl, "URL is correct"); + const mostRecentVisit = tree.view.getCellText( + targeRow, + tree.columns.placesContentDate + ); + Assert.equal( + !!mostRecentVisit, + expectMostRecentVisitHasValue, + "Most Recent Visit data is in the cell correctly" + ); +} diff --git a/browser/components/places/tests/browser/browser_library_bookmark_pages.js b/browser/components/places/tests/browser/browser_library_bookmark_pages.js new file mode 100644 index 0000000000..474adb956d --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bookmark_pages.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a new bookmark is correctly selected after being created via + * the bookmark dialog. + */ +"use strict"; + +const TEST_URIS = ["https://example1.com/", "https://example2.com/"]; +let library; + +add_setup(async function () { + await PlacesTestUtils.addVisits(TEST_URIS); + + library = await promiseLibrary("History"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_bookmark_page() { + library.ContentTree.view.selectPlaceURI(TEST_URIS[0]); + + await withBookmarksDialog( + true, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_createBookmark" + ); + placesContext.activateItem(properties); + }, + async dialogWin => { + Assert.strictEqual( + dialogWin.BookmarkPropertiesPanel._itemType, + 0, + "Should have loaded a bookmark dialog" + ); + Assert.equal( + dialogWin.document.getElementById("editBMPanel_locationField").value, + TEST_URIS[0], + "Should have opened the dialog with the correct uri to be bookmarked" + ); + } + ); +}); + +add_task(async function test_bookmark_pages() { + library.ContentTree.view.selectAll(); + + await withBookmarksDialog( + true, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_createBookmark" + ); + placesContext.activateItem(properties); + }, + async dialogWin => { + Assert.strictEqual( + dialogWin.BookmarkPropertiesPanel._itemType, + 1, + "Should have loaded a create bookmark folder dialog" + ); + Assert.deepEqual( + dialogWin.BookmarkPropertiesPanel._URIs.map(uri => uri.uri.spec), + // The list here is reversed, because that's the order they're shown + // in the view. + [TEST_URIS[1], TEST_URIS[0]], + "Should have got the correct URIs for adding to the folder" + ); + } + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js b/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js new file mode 100644 index 0000000000..23d3b30564 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_bulk_tag_bookmarks.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that a tag can be added to multiple bookmarks at once from the library. + */ +"use strict"; + +const TEST_URLS = ["about:buildconfig", "about:robots"]; + +add_task(async function test_bulk_tag_from_library() { + // Create multiple bookmarks. + for (const url of TEST_URLS) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + }); + } + + // Open library panel. + const library = await promiseLibrary("UnfiledBookmarks"); + const cleanupFn = async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }; + registerCleanupFunction(cleanupFn); + + // Add a tag to multiple bookmarks. + library.ContentTree.view.selectAll(); + const promiseAllTagsChanged = TEST_URLS.map(url => + PlacesTestUtils.waitForNotification("bookmark-tags-changed", events => + events.some(evt => evt.url === url) + ) + ); + const tag = "some, tag"; + const tagWithDuplicates = `${tag}, tag`; + fillBookmarkTextField("editBMPanel_tagsField", tagWithDuplicates, library); + await Promise.all(promiseAllTagsChanged); + await TestUtils.waitForCondition( + () => + library.document.getElementById("editBMPanel_tagsField").value === tag, + "Input field matches the new tags and duplicates are removed." + ); + + // Verify that the bookmarks were tagged successfully. + for (const url of TEST_URLS) { + Assert.deepEqual( + PlacesUtils.tagging.getTagsForURI(Services.io.newURI(url)), + ["some", "tag"], + url + " should have the correct tags." + ); + } + await cleanupFn(); +}); + +add_task(async function test_bulk_tag_tags_selector() { + // Create multiple bookmarks with a common tag. + for (const [i, url] of TEST_URLS.entries()) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), [ + "common", + `unique_${i}`, + ]); + } + + // Open library panel. + const library = await promiseLibrary("UnfiledBookmarks"); + const cleanupFn = async () => { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }; + registerCleanupFunction(cleanupFn); + + // Open tags selector. + library.document.getElementById("editBMPanel_tagsSelectorRow").hidden = false; + + // Select all bookmarks. + const tagsSelector = library.document.getElementById( + "editBMPanel_tagsSelector" + ); + library.ContentTree.view.selectAll(); + + // Verify that the input field only shows the common tag. + await TestUtils.waitForCondition( + () => + library.document.getElementById("editBMPanel_tagsField").value === + "common", + "Input field only shows the common tag." + ); + + // Verify that the tags selector shows all tags, and only the common one is + // checked. + async function checkTagsSelector(aAvailableTags, aCheckedTags) { + let tags = await PlacesUtils.bookmarks.fetchTags(); + is(tags.length, aAvailableTags.length, "Check tags list"); + let children = tagsSelector.children; + is( + children.length, + aAvailableTags.length, + "Found expected number of tags in the tags selector" + ); + + Array.prototype.forEach.call(children, function (aChild) { + let tag = aChild.querySelector("label").getAttribute("value"); + ok(true, "Found tag '" + tag + "' in the selector"); + ok(aAvailableTags.includes(tag), "Found expected tag"); + let checked = aChild.getAttribute("checked") == "true"; + is(checked, aCheckedTags.includes(tag), "Tag is correctly marked"); + }); + } + + async function promiseTagSelectorUpdated(task) { + let promise = BrowserTestUtils.waitForEvent( + tagsSelector, + "BookmarkTagsSelectorUpdated" + ); + + await task(); + return promise; + } + + info("Check the initial common tag."); + await checkTagsSelector(["common", "unique_0", "unique_1"], ["common"]); + + // Verify that the common tag can be edited. + await promiseTagSelectorUpdated(() => { + info("Edit the common tag."); + fillBookmarkTextField("editBMPanel_tagsField", "common_updated", library); + }); + await checkTagsSelector( + ["common_updated", "unique_0", "unique_1"], + ["common_updated"] + ); + + // Verify that the common tag can be removed. + await promiseTagSelectorUpdated(() => { + info("Remove the commmon tag."); + fillBookmarkTextField("editBMPanel_tagsField", "", library); + }); + await checkTagsSelector(["unique_0", "unique_1"], []); + + await cleanupFn(); +}); diff --git a/browser/components/places/tests/browser/browser_library_commands.js b/browser/components/places/tests/browser/browser_library_commands.js new file mode 100644 index 0000000000..254c9503c2 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_commands.js @@ -0,0 +1,335 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test enabled commands in the left pane folder of the Library. + */ + +const TEST_URI = NetUtil.newURI("http://www.mozilla.org/"); + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_date_container() { + let library = await promiseLibrary(); + info("Ensure date containers under History cannot be cut but can be deleted"); + + await PlacesTestUtils.addVisits(TEST_URI); + + // Select and open the left pane "History" query. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("History"); + Assert.notEqual( + PO._places.selectedNode, + null, + "We correctly selected History" + ); + + // Check that both delete and cut commands are disabled, cause this is + // a child of the left pane folder. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + let historyNode = PlacesUtils.asContainer(PO._places.selectedNode); + historyNode.containerOpen = true; + + // Check that we have a child container. It is "Today" container. + Assert.equal(historyNode.childCount, 1, "History node has one child"); + let todayNode = historyNode.getChild(0); + let todayNodeExpectedTitle = PlacesUtils.getString("finduri-AgeInDays-is-0"); + Assert.equal( + todayNode.title, + todayNodeExpectedTitle, + "History child is the expected container" + ); + + // Select "Today" container. + PO._places.selectNode(todayNode); + Assert.equal( + PO._places.selectedNode, + todayNode, + "We correctly selected Today container" + ); + // Check that delete command is enabled but cut command is disabled, cause + // this is an history item. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + // Execute the delete command and check visit has been removed. + const promiseURIRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URI.spec + ); + PO._places.controller.doCommand("cmd_delete"); + const removeEvents = await promiseURIRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + + // Test live update of "History" query. + Assert.equal(historyNode.childCount, 0, "History node has no more children"); + + historyNode.containerOpen = false; + + Assert.ok( + !(await PlacesUtils.history.hasVisits(TEST_URI)), + "Visit has been removed" + ); + + library.close(); +}); + +add_task(async function test_query_on_toolbar() { + let library = await promiseLibrary(); + info("Ensure queries can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("BookmarksToolbar"); + Assert.notEqual(PO._places.selectedNode, null, "We have a valid selection"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.toolbarGuid, + "We have correctly selected bookmarks toolbar node." + ); + + // Check that both cut and delete commands are disabled, cause this is a child + // of the All Bookmarks special query. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + + let toolbarNode = PlacesUtils.asContainer(PO._places.selectedNode); + toolbarNode.containerOpen = true; + + // Add an History query to the toolbar. + let query = await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "place:sort=4", + title: "special_query", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + // Get first child and check it is the just inserted query. + Assert.greater(toolbarNode.childCount, 0, "Toolbar node has children"); + let queryNode = toolbarNode.getChild(0); + Assert.equal( + queryNode.title, + "special_query", + "Query node is correctly selected" + ); + + // Select query node. + PO._places.selectNode(queryNode); + Assert.equal( + PO._places.selectedNode, + queryNode, + "We correctly selected query node" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is enabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + // Execute the delete command and check bookmark has been removed. + let promiseItemRemoved = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => query.guid == event.guid) + ); + PO._places.controller.doCommand("cmd_delete"); + await promiseItemRemoved; + + Assert.equal( + await PlacesUtils.bookmarks.fetch(query.guid), + null, + "Query node bookmark has been correctly removed" + ); + + toolbarNode.containerOpen = false; + + library.close(); +}); + +add_task(async function test_search_contents() { + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + + let library = await promiseLibrary(); + info("Ensure query contents can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("BookmarksToolbar"); + Assert.notEqual(PO._places.selectedNode, null, "We have a valid selection"); + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.toolbarGuid, + "We have correctly selected bookmarks toolbar node." + ); + + let searchBox = library.document.getElementById("searchFilter"); + searchBox.value = "example"; + library.PlacesSearchBox.search(searchBox.value); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_cut"), + "Cut command is enabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + library.close(); +}); + +add_task(async function test_tags() { + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://example.com/", + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + PlacesUtils.tagging.tagURI(NetUtil.newURI("http://example.com/"), ["test"]); + + let library = await promiseLibrary(); + info("Ensure query contents can be cut or deleted"); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "We have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Tags has been properly selected"); + + // Check that both cut and delete commands are disabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled" + ); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "The created tag has been properly selected" + ); + + // Check that cut is disabled but delete is enabled. + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + let bookmarkNode = library.ContentTree.view.selectedNode; + Assert.equal( + bookmarkNode.uri, + "http://example.com/", + "Found the expected bookmark" + ); + + // Check that both cut and delete commands are enabled. + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_copy"), + "Copy command is enabled" + ); + Assert.ok( + !library.ContentTree.view.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled" + ); + Assert.ok( + library.ContentTree.view.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete.js b/browser/components/places/tests/browser/browser_library_delete.js new file mode 100644 index 0000000000..17c8b0948a --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that Library correctly handles deletes. + */ + +const TEST_URL = "http://www.batch.delete.me/"; + +var gLibrary; + +add_task(async function test_setup() { + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + // Close Library window. + gLibrary.close(); + }); +}); + +add_task(async function test_create_and_remove_bookmarks() { + let bmChildren = []; + for (let i = 0; i < 10; i++) { + bmChildren.push({ + title: `bm${i}`, + url: TEST_URL, + }); + } + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "deleteme", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: bmChildren, + }, + { + title: "keepme", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + // Select and open the left pane "History" query. + let PO = gLibrary.PlacesOrganizer; + PO.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual(PO._places.selectedNode, null, "Selected unsorted bookmarks"); + + let unsortedNode = PlacesUtils.asContainer(PO._places.selectedNode); + Assert.equal(unsortedNode.childCount, 2, "Unsorted node has 2 children"); + let folderNode = unsortedNode.getChild(0); + Assert.equal( + folderNode.title, + "deleteme", + "Folder found in unsorted bookmarks" + ); + + // Check delete command is available. + PO._places.selectNode(folderNode); + Assert.equal( + PO._places.selectedNode.title, + "deleteme", + "Folder node selected" + ); + Assert.ok( + PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled" + ); + let promiseItemRemovedNotification = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.guid == folderNode.bookmarkGuid) + ); + + // Press the delete key and check that the bookmark has been removed. + gLibrary.document.getElementById("placesList").focus(); + EventUtils.synthesizeKey("VK_DELETE", {}, gLibrary); + + await promiseItemRemovedNotification; + + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ url: TEST_URL })), + "Bookmark has been correctly removed" + ); + // Test live update. + Assert.equal(unsortedNode.childCount, 1, "Unsorted node has 1 child"); + Assert.equal(PO._places.selectedNode.title, "keepme", "Folder node selected"); +}); + +add_task(async function test_ensure_correct_selection_and_functionality() { + let PO = gLibrary.PlacesOrganizer; + let ContentTree = gLibrary.ContentTree; + // Move selection forth and back. + PO.selectLeftPaneBuiltIn("History"); + PO.selectLeftPaneBuiltIn("UnfiledBookmarks"); + // Now select the "keepme" folder in the right pane and delete it. + ContentTree.view.selectNode(ContentTree.view.result.root.getChild(0)); + Assert.equal( + ContentTree.view.selectedNode.title, + "keepme", + "Found folder in content pane" + ); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bm", + url: TEST_URL, + }); + + Assert.equal( + ContentTree.view.result.root.childCount, + 2, + "Right pane was correctly updated" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js b/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js new file mode 100644 index 0000000000..ed124a047a --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete_bookmarks_in_tags.js @@ -0,0 +1,111 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test deleting bookmarks from within tags. + */ + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_tags() { + const uris = [ + Services.io.newURI("http://example.com/1"), + Services.io.newURI("http://example.com/2"), + Services.io.newURI("http://example.com/3"), + ]; + + let children = uris.map((uri, index, arr) => { + return { + title: `bm${index}`, + url: uri, + }; + }); + + // Note: we insert the uris in reverse order, so that we end up with the + // display in "logical" order of bm0 at the top, and bm2 at the bottom. + children = children.reverse(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children, + }); + + for (let uri of uris) { + PlacesUtils.tagging.tagURI(uri, ["test"]); + } + + let library = await promiseLibrary(); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "Should have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Should have selected the Tags node"); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "Should have selected the created tag" + ); + + let ContentTree = library.ContentTree; + + for (let i = 0; i < uris.length; i++) { + ContentTree.view.selectNode(ContentTree.view.result.root.getChild(0)); + + Assert.equal( + ContentTree.view.selectedNode.title, + `bm${i}`, + `Should have selected bm${i}` + ); + + let promiseNotification = PlacesTestUtils.waitForNotification( + "bookmark-tags-changed" + ); + + ContentTree.view.controller.doCommand("cmd_delete"); + + await promiseNotification; + + for (let j = 0; j < uris.length; j++) { + let tags = PlacesUtils.tagging.getTagsForURI(uris[j]); + if (j <= i) { + Assert.equal( + tags.length, + 0, + `There should be no tags for the URI: ${uris[j].spec}` + ); + } else { + Assert.equal( + tags.length, + 1, + `There should be one tag for the URI: ${uris[j].spec}` + ); + } + } + } + + // The tag should now not exist. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ tags: ["test"] }), + null, + "There should be no URIs remaining for the tag" + ); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_delete_tags.js b/browser/components/places/tests/browser/browser_library_delete_tags.js new file mode 100644 index 0000000000..d5f3eafc63 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_delete_tags.js @@ -0,0 +1,60 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test enabled commands in the left pane folder of the Library. + */ + +registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_tags() { + const TEST_URI = Services.io.newURI("http://example.com/"); + + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: TEST_URI, + title: "example page", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: 0, + }); + PlacesUtils.tagging.tagURI(TEST_URI, ["test"]); + + let library = await promiseLibrary(); + + // Select and open the left pane "Bookmarks Toolbar" folder. + let PO = library.PlacesOrganizer; + + PO.selectLeftPaneBuiltIn("Tags"); + let tagsNode = PO._places.selectedNode; + Assert.notEqual(tagsNode, null, "Should have a valid selection"); + let tagsTitle = PlacesUtils.getString("TagsFolderTitle"); + Assert.equal(tagsNode.title, tagsTitle, "Should have selected the Tags node"); + + // Now select the tag. + PlacesUtils.asContainer(tagsNode).containerOpen = true; + let tag = tagsNode.getChild(0); + PO._places.selectNode(tag); + Assert.equal( + PO._places.selectedNode.title, + "test", + "Should have selected the created tag" + ); + + PO._places.controller.doCommand("cmd_delete"); + + await PlacesTestUtils.promiseAsyncUpdates(); + + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URI); + + Assert.equal(tags.length, 0, "There should be no tags for the URI"); + + tagsNode.containerOpen = false; + + library.close(); +}); diff --git a/browser/components/places/tests/browser/browser_library_downloads.js b/browser/components/places/tests/browser/browser_library_downloads.js new file mode 100644 index 0000000000..3aa37b1fed --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_downloads.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/* + * Tests bug 564900: Add folder specifically for downloads to Library left pane. + * https://bugzilla.mozilla.org/show_bug.cgi?id=564900 + * This test visits various pages then opens the Library and ensures + * that both the Downloads folder shows up and that the correct visits + * are shown in it. + */ + +add_task(async function test() { + // Add visits. + await PlacesTestUtils.addVisits([ + { + uri: "http://mozilla.org", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: "http://google.com", + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + { + uri: "http://en.wikipedia.org", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: "http://ubuntu.org", + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + ]); + + let library = await promiseLibrary("Downloads"); + + registerCleanupFunction(async () => { + await library.close(); + await PlacesUtils.history.clear(); + }); + + // Make sure Downloads is present. + Assert.notEqual( + library.PlacesOrganizer._places.selectedNode, + null, + "Downloads is present and selected" + ); + + // Check results. + let testURIs = ["http://ubuntu.org/", "http://google.com/"]; + + await TestUtils.waitForCondition( + () => + library.ContentArea.currentView.associatedElement.itemChildren.length == + testURIs.length + ); + + for (let element of library.ContentArea.currentView.associatedElement + .itemChildren) { + Assert.equal( + element._shell.download.source.url, + testURIs.shift(), + "URI matches" + ); + } +}); diff --git a/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js b/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js new file mode 100644 index 0000000000..fc33963199 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_left_pane_middleclick.js @@ -0,0 +1,106 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests middle-clicking items in the Library. + */ + +const URIs = ["about:license", "about:mozilla"]; + +var gLibrary = null; + +add_task(async function test_setup() { + // Temporary disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +add_task(async function test_open_folder_in_tabs() { + let children = URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + // Create a new folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(folderNode.title, "Folder", "Found folder in the right pane"); + + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = true; + + // Now middle-click on the bookmark contained with it. + let promiseLoaded = Promise.all( + URIs.map(uri => BrowserTestUtils.waitForNewTab(gBrowser, uri, false, true)) + ); + + let bookmarkedNode = + gLibrary.PlacesOrganizer._places.selectedNode.getChild(0); + mouseEventOnCell( + gLibrary.PlacesOrganizer._places, + gLibrary.PlacesOrganizer._places.view.treeIndexForNode(bookmarkedNode), + 0, + { button: 1 } + ); + + let tabs = await promiseLoaded; + + Assert.ok(true, "Expected tabs were loaded"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js b/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js new file mode 100644 index 0000000000..6caaf5a9d4 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_task(async function () { + let hierarchy = ["AllBookmarks", "BookmarksMenu"]; + + let items = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: [ + { + title: "Folder 1", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Folder 2", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Bookmark", + url: "http://example.com", + }, + ], + }, + ], + }, + ], + }); + + hierarchy.push(items[0].guid, items[1].guid); + + let library = await promiseLibrary(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(items[0]); + await promiseLibraryClosed(library); + }); + + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy(hierarchy); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.bookmarkGuid, + items[1].guid, + "Found the expected left pane selected node" + ); + + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + items[2].guid, + "Found the expected right pane contents" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_middleclick.js b/browser/components/places/tests/browser/browser_library_middleclick.js new file mode 100644 index 0000000000..8eb0bfa008 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_middleclick.js @@ -0,0 +1,234 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests middle-clicking items in the Library. + */ + +const URIs = ["about:license", "about:mozilla"]; + +var gLibrary = null; +var gTests = []; + +add_task(async function test_setup() { + // Increase timeout, this test can be quite slow due to waitForFocus calls. + requestLongerTimeout(2); + + // Temporary disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Ensure the database is empty. + await PlacesUtils.bookmarks.eraseEverything(); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +gTests.push({ + desc: "Open bookmark in a new tab.", + URIs: ["about:buildconfig"], + _bookmark: null, + + async setup() { + // Add a new unsorted bookmark. + this._bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Title", + url: this.URIs[0], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + Assert.notEqual( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + + // Get our bookmark in the right pane. + var bookmarkNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + bookmarkNode.uri, + this.URIs[0], + "Found bookmark in the right pane" + ); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmark); + }, +}); + +// ------------------------------------------------------------------------------ +// Open a folder in tabs. +// +gTests.push({ + desc: "Open a folder in tabs.", + URIs: ["about:buildconfig", "about:mozilla"], + _bookmarks: null, + + async setup() { + // Create a new folder. + let children = this.URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + this._bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + isnot( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + is(folderNode.title, "Folder", "Found folder in the right pane"); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmarks[0]); + }, +}); + +// ------------------------------------------------------------------------------ +// Open a query in tabs. + +gTests.push({ + desc: "Open a query in tabs.", + URIs: ["about:buildconfig", "about:mozilla"], + _bookmarks: null, + _query: null, + + async setup() { + let children = this.URIs.map(url => { + return { + title: "Title", + url, + }; + }); + + this._bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + + // Create a bookmarks query containing our bookmarks. + var hs = PlacesUtils.history; + var options = hs.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + var query = hs.getNewQuery(); + // The colon included in the terms selects only about: URIs. If not included + // we also may get pages like about.html included in the query result. + query.searchTerms = "about:"; + var queryString = hs.queryToQueryString(query, options); + this._query = await PlacesUtils.bookmarks.insert({ + index: 0, // it must be the first + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Query", + url: queryString, + }); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + isnot( + gLibrary.PlacesOrganizer._places.selectedNode, + null, + "We correctly have selection in the Library left pane" + ); + // Get our bookmark in the right pane. + var folderNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + is(folderNode.title, "Query", "Found query in the right pane"); + }, + + async cleanup() { + await PlacesUtils.bookmarks.remove(this._bookmarks[0]); + await PlacesUtils.bookmarks.remove(this._query); + }, +}); + +async function runTest(test) { + info("Start of test: " + test.desc); + // Test setup will set Library so that the bookmark to be opened is the + // first node in the content (right pane) tree. + await test.setup(); + + // Middle click on first node in the content tree of the Library. + gLibrary.focus(); + await SimpleTest.promiseFocus(gLibrary); + + // Now middle-click on the bookmark contained with it. + let promiseLoaded = Promise.all( + test.URIs.map(uri => + BrowserTestUtils.waitForNewTab(gBrowser, uri, false, true) + ) + ); + + mouseEventOnCell(gLibrary.ContentTree.view, 0, 0, { button: 1 }); + + let tabs = await promiseLoaded; + + Assert.ok(true, "Expected tabs were loaded"); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + + await test.cleanup(); +} + +add_task(async function test_all() { + for (let test of gTests) { + await runTest(test); + } +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_library_new_bookmark.js b/browser/components/places/tests/browser/browser_library_new_bookmark.js new file mode 100644 index 0000000000..dff7accc44 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_new_bookmark.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a new bookmark is correctly selected after being created via + * the bookmark dialog. + */ +"use strict"; + +let bookmarks = [ + { + url: "https://example1.com", + title: "bm1", + }, + { + url: "https://example2.com", + title: "bm2", + }, + { + url: "https://example3.com", + title: "bm3", + }, +]; + +add_task(async function test_open_bookmark_from_library() { + let bm = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarks, + }); + + let library = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + let bmLibrary = library.ContentTree.view.view.nodeForTreeIndex(1); + Assert.equal( + bmLibrary.title, + bm[1].title, + "EditBookmark: Found bookmark in the right pane" + ); + + library.ContentTree.view.selectNode(bmLibrary); + + let beforeUpdatedPRTime; + await withBookmarksDialog( + false, + async () => { + // Open the context menu. + let placesContext = library.document.getElementById("placesContext"); + let promisePopup = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promisePopup; + let properties = library.document.getElementById( + "placesContext_new:bookmark" + ); + placesContext.activateItem(properties, {}); + }, + async dialogWin => { + beforeUpdatedPRTime = Date.now() * 1000; + + fillBookmarkTextField( + "editBMPanel_locationField", + "https://example4.com/", + dialogWin, + false + ); + + EventUtils.synthesizeKey("VK_RETURN", {}, dialogWin); + } + ); + let node = library.ContentTree.view.selectedNode; + Assert.ok(node, "EditBookmark: Should have a selectedNode"); + Assert.equal( + node.uri, + "https://example4.com/", + "EditBookmark: Should have selected the newly created bookmark" + ); + Assert.greater( + node.lastModified, + beforeUpdatedPRTime, + "EditBookmark: The lastModified should be greater than the time of before updating" + ); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_library_openFlatContainer.js b/browser/components/places/tests/browser/browser_library_openFlatContainer.js new file mode 100644 index 0000000000..1d9cc61f2f --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_openFlatContainer.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test opening a flat container in the right pane even if its parent in the + * left pane is closed. + */ + +var library; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await promiseLibraryClosed(library); + }); +}); + +add_task(async function test_open_built_in_folder() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + title: "testBM", + url: "http://example.com/1", + }); + + library = await promiseLibrary("AllBookmarks"); + + library.ContentTree.view.selectItems([PlacesUtils.bookmarks.menuGuid]); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.bookmarkGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + "Should have the bookmarks menu selected in the left pane." + ); + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + bm.guid, + "Should have the expected bookmark selected in the right pane" + ); +}); + +add_task(async function test_open_new_folder_in_unfiled() { + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "Bookmark", + url: "http://example.com", + }, + ], + }, + ], + }); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + // Ensure the container is closed. + library.PlacesOrganizer._places.selectedNode.containerOpen = false; + + let folderNode = library.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + folderNode.bookmarkGuid, + bookmarks[0].guid, + "Found the expected folder in the right pane" + ); + // Select the folder node in the right pane. + library.ContentTree.view.selectNode(folderNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).bookmarkGuid, + bookmarks[1].guid, + "Found the expected bookmark in the right pane" + ); +}); + +add_task(async function test_open_history_query() { + const todayTitle = PlacesUtils.getString("finduri-AgeInDays-is-0"); + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "Whittingtons", + }, + ]); + + library.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + + // Ensure the container is closed. + library.PlacesOrganizer._places.selectedNode.containerOpen = false; + + let query = library.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(query.title, todayTitle, "Should have the today query"); + + library.ContentTree.view.selectNode(query); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + Assert.equal( + library.PlacesOrganizer._places.selectedNode.title, + todayTitle, + "Should have selected the today query in the left-pane." + ); + Assert.equal( + library.ContentTree.view.view.nodeForTreeIndex(0).title, + "Whittingtons", + "Found the expected history item in the right pane" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_all.js b/browser/components/places/tests/browser/browser_library_open_all.js new file mode 100644 index 0000000000..18f0014936 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_all.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + const TEST_EXAMPLE_URL = "http://example.com/"; + const TEST_EXAMPLE_PARAMS = "?foo=1|2"; + const TEST_EXAMPLE_TITLE = "Example Domain"; + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_EXAMPLE_URL + TEST_EXAMPLE_PARAMS, + title: TEST_EXAMPLE_TITLE, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_EXAMPLE_URL, + title: TEST_EXAMPLE_TITLE, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_all_in_tabs_from_library() { + let gLibrary = await promiseLibrary("AllBookmarks"); + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + gLibrary.ContentTree.view.selectAll(); + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + await promiseContextMenu; + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseWaitForWindow = BrowserTestUtils.waitForNewWindow(); + placesContext.activateItem(openTabs, { shiftKey: true }); + let newWindow = await promiseWaitForWindow; + + Assert.equal( + newWindow.browserDOMWindow.tabCount, + 2, + "Expected number of tabs opened in new window" + ); + + await BrowserTestUtils.closeWindow(newWindow); + await promiseLibraryClosed(gLibrary); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_all_with_separator.js b/browser/components/places/tests/browser/browser_library_open_all_with_separator.js new file mode 100644 index 0000000000..a68158a1ba --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_all_with_separator.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "Example One", + url: "https://example.com/1/", + }, + { + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }, + { + title: "Example Two", + url: "https://example.com/2/", + }, + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_all_without_separator() { + let gLibrary = await promiseLibrary("AllBookmarks"); + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + gLibrary.ContentTree.view.selectAll(); + + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + await promiseContextMenu; + + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseWaitForWindow = BrowserTestUtils.waitForNewWindow(); + placesContext.activateItem(openTabs, { shiftKey: true }); + let newWindow = await promiseWaitForWindow; + + Assert.equal( + newWindow.browserDOMWindow.tabCount, + 2, + "Expected number of tabs opened in new window" + ); + + await BrowserTestUtils.closeWindow(newWindow); + await promiseLibraryClosed(gLibrary); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_bookmark.js b/browser/components/places/tests/browser/browser_library_open_bookmark.js new file mode 100644 index 0000000000..7532d7c1c9 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_bookmark.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Test that the a bookmark can be opened from the Library by mouse double click. + */ +"use strict"; + +const TEST_URL = "about:buildconfig"; + +add_task(async function test_open_bookmark_from_library() { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let gLibrary = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(gLibrary); + await PlacesUtils.bookmarks.eraseEverything(); + await BrowserTestUtils.removeTab(tab); + }); + + let bmLibrary = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal(bmLibrary.title, bm.title, "Found bookmark in the right pane"); + + gLibrary.ContentTree.view.selectNode(bmLibrary); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + clickCount: 2, + }); + + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + Assert.ok(true, "Expected tab was loaded"); +}); diff --git a/browser/components/places/tests/browser/browser_library_open_leak.js b/browser/components/places/tests/browser/browser_library_open_leak.js new file mode 100644 index 0000000000..02a0874c76 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_open_leak.js @@ -0,0 +1,22 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 474831 + * https://bugzilla.mozilla.org/show_bug.cgi?id=474831 + * + * Tests for leaks caused by simply opening and closing the Places Library + * window. Opens the Places Library window, waits for it to load, closes it, + * and finishes. + */ + +add_task(async function test_open_and_close() { + let library = await promiseLibrary(); + + Assert.ok(true, "Library has been correctly opened"); + + await promiseLibraryClosed(library); +}); diff --git a/browser/components/places/tests/browser/browser_library_panel_leak.js b/browser/components/places/tests/browser/browser_library_panel_leak.js new file mode 100644 index 0000000000..f8b536cecc --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_panel_leak.js @@ -0,0 +1,71 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 433231 - Places Library leaks the nsGlobalWindow when closed with a + * history entry selected. + * https://bugzilla.mozilla.org/show_bug.cgi?id=433231 + * + * STRs: Open Library, select an history entry in History, close Library. + * ISSUE: We were adding a bookmarks observer when editing a bookmark, when + * selecting an history entry the panel was not un-initialized, and + * since an history entry does not have an itemId, the observer was + * never removed. + */ + +const TEST_URI = "http://www.mozilla.org/"; + +add_task(async function test_no_leak_closing_library_with_history_selected() { + // Add an history entry. + await PlacesTestUtils.addVisits(TEST_URI); + + let organizer = await promiseLibrary(); + + let contentTree = organizer.document.getElementById("placeContent"); + Assert.notEqual( + contentTree, + null, + "Sanity check: placeContent tree should exist" + ); + Assert.notEqual( + organizer.PlacesOrganizer, + null, + "Sanity check: PlacesOrganizer should exist" + ); + Assert.notEqual( + organizer.gEditItemOverlay, + null, + "Sanity check: gEditItemOverlay should exist" + ); + + Assert.ok( + organizer.gEditItemOverlay.initialized, + "gEditItemOverlay is initialized" + ); + Assert.notEqual( + organizer.gEditItemOverlay._paneInfo.itemGuid, + "", + "Editing a bookmark" + ); + + // Select History in the left pane. + organizer.PlacesOrganizer.selectLeftPaneBuiltIn("History"); + // Select the first history entry. + let selection = contentTree.view.selection; + selection.clearSelection(); + selection.rangedSelect(0, 0, true); + // Check the panel is editing the history entry. + Assert.equal( + organizer.gEditItemOverlay._paneInfo.itemGuid, + "", + "Editing an history entry" + ); + // Close Library window. + organizer.close(); + + // Clean up history. + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/places/tests/browser/browser_library_search.js b/browser/components/places/tests/browser/browser_library_search.js new file mode 100644 index 0000000000..898f664269 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_search.js @@ -0,0 +1,206 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 451151 + * https://bugzilla.mozilla.org/show_bug.cgi?id=451151 + * + * Summary: + * Tests frontend Places Library searching -- search, search reset, search scope + * consistency. + * + * Details: + * Each test below + * 1. selects a folder in the left pane and ensures that the content tree is + * appropriately updated, + * 2. performs a search and ensures that the content tree is correct for the + * folder and search and that the search UI is visible and appropriate to + * folder, + * 5. resets the search and ensures that the content tree is correct and that + * the search UI is hidden, and + * 6. if folder scope was clicked, searches again and ensures folder scope + * remains selected. + */ + +const TEST_URL = "http://dummy.mozilla.org/"; +const TEST_DOWNLOAD_URL = "http://dummy.mozilla.org/dummy.pdf"; +const TEST_PARENT_FOLDER = "testParentFolder"; +const TEST_SIF_URL = "http://testsif.example.com/"; +const TEST_SIF_TITLE = "TestSIF"; + +var gLibrary; + +/** + * Performs a search for a given folder and search string and ensures that the + * URI of the right pane's content tree is as expected for the folder and search + * string. Also ensures that the search scope button is as expected after the + * search. + * + * @param {string} aFolderGuid + * the item guid of a node in the left pane's tree + * @param {string} aSearchStr + * the search text; may be empty to reset the search + */ +async function search(aFolderGuid, aSearchStr) { + let doc = gLibrary.document; + let folderTree = doc.getElementById("placesList"); + let contentTree = doc.getElementById("placeContent"); + + // First, ensure that selecting the folder in the left pane updates the + // content tree properly. + if (aFolderGuid) { + folderTree.selectItems([aFolderGuid]); + Assert.notEqual( + folderTree.selectedNode, + null, + "Sanity check: left pane tree should have selection after selecting!" + ); + + // The downloads folder never quite matches the url of the contentTree, + // probably due to the way downloads are loaded. + if (aFolderGuid !== PlacesUtils.virtualDownloadsGuid) { + Assert.equal( + folderTree.selectedNode.uri, + contentTree.place, + "Content tree's folder should be what was selected in the left pane" + ); + } + } + + // Second, ensure that searching updates the content tree and search UI + // properly. + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = aSearchStr; + gLibrary.PlacesSearchBox.search(searchBox.value); + let query = {}; + PlacesUtils.history.queryStringToQuery( + contentTree.result.root.uri, + query, + {} + ); + if (aSearchStr) { + Assert.equal( + query.value.searchTerms, + aSearchStr, + "Content tree's searchTerms should be text in search box" + ); + } else { + Assert.equal( + query.value.hasSearchTerms, + false, + "Content tree's searchTerms should not exist after search reset" + ); + } +} + +async function showInFolder(aFolderGuid, aSearchStr, aParentFolderGuid) { + let doc = gLibrary.document; + let folderTree = doc.getElementById("placesList"); + let contentTree = doc.getElementById("placeContent"); + + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = aSearchStr; + gLibrary.PlacesSearchBox.search(searchBox.value); + let theNode = contentTree.view._getNodeForRow(0); + let bookmarkGuid = theNode.bookmarkGuid; + + Assert.equal(theNode.uri, TEST_SIF_URL, "Found expected bookmark"); + + contentTree.selectNode(theNode); + info("Executing showInFolder"); + info("Waiting for showInFolder to select folder in tree"); + let folderSelected = BrowserTestUtils.waitForEvent(folderTree, "select"); + contentTree.controller.doCommand("placesCmd_showInFolder"); + await folderSelected; + + let treeNode = folderTree.selectedNode; + let contentNode = contentTree.selectedNode; + Assert.equal( + treeNode.bookmarkGuid, + aParentFolderGuid, + "Containing folder node selected on left tree pane" + ); + Assert.equal( + contentNode.bookmarkGuid, + bookmarkGuid, + "The searched bookmark guid matches selected node in content pane" + ); + Assert.equal( + contentNode.uri, + TEST_SIF_URL, + "The searched bookmark URL matches selected node in content pane" + ); +} + +add_task(async function test() { + // Add visits, a bookmark and a tag. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI(TEST_URL), + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI(TEST_DOWNLOAD_URL), + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_DOWNLOAD, + }, + ]); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "dummy", + url: TEST_URL, + }); + + PlacesUtils.tagging.tagURI(Services.io.newURI(TEST_URL), ["dummyTag"]); + + gLibrary = await promiseLibrary(); + + const rootsToTest = [ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.virtualHistoryGuid, + PlacesUtils.virtualDownloadsGuid, + ]; + + for (let root of rootsToTest) { + await search(root, "dummy"); + } + + await promiseLibraryClosed(gLibrary); + + // Cleanup before testing Show in Folder. + PlacesUtils.tagging.untagURI(Services.io.newURI(TEST_URL), ["dummyTag"]); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + // Now test Show in Folder + gLibrary = await promiseLibrary(); + info("Test Show in Folder"); + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + title: TEST_SIF_TITLE, + url: TEST_SIF_URL, + }); + + await showInFolder( + PlacesUtils.virtualAllBookmarksGuid, + TEST_SIF_TITLE, + parentFolder.guid + ); + + // Cleanup + await promiseLibraryClosed(gLibrary); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/places/tests/browser/browser_library_telemetry.js b/browser/components/places/tests/browser/browser_library_telemetry.js new file mode 100644 index 0000000000..00ca4635d8 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_telemetry.js @@ -0,0 +1,413 @@ +/* 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"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +// Visited pages listed by descending visit date. +const pages = [ + "https://library.mozilla.org/a", + "https://library.mozilla.org/b", + "https://library.mozilla.org/c", + "https://www.mozilla.org/d", +]; + +// The prompt returns 1 for cancelled and 0 for accepted. +let gResponse = 1; +(function replacePromptService() { + let originalPromptService = Services.prompt; + Services.prompt = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIPromptService]), + confirmEx: () => gResponse, + }; + registerCleanupFunction(() => { + Services.prompt = originalPromptService; + }); +})(); + +async function searchHistory(gLibrary, searchTerm) { + let doc = gLibrary.document; + let contentTree = doc.getElementById("placeContent"); + + let searchBox = doc.getElementById("searchFilter"); + searchBox.value = searchTerm; + gLibrary.PlacesSearchBox.search(searchBox.value); + let query = {}; + PlacesUtils.history.queryStringToQuery( + contentTree.result.root.uri, + query, + {} + ); + Assert.equal( + query.value.searchTerms, + searchTerm, + "Content tree's searchTerms should be text in search box" + ); +} + +function searchBookmarks(gLibrary, searchTerm) { + let searchBox = gLibrary.document.getElementById("searchFilter"); + searchBox.value = searchTerm; + gLibrary.PlacesSearchBox.search(searchBox.value); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add some visited pages to history + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "Mozilla", + url: "https://www.mozilla.org/", + }, + { + title: "Example", + url: "https://sidebar.mozilla.org/", + }, + ], + }); + await registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_library_history_telemetry() { + Services.telemetry.clearScalars(); + let cumulativeSearchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_HISTORY_SEARCHES" + ); + + let gLibrary = await promiseLibrary("History"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.opened", + "history", + 1 + ); + + let currentSelectedLeftPaneNode = + gLibrary.PlacesOrganizer._places.selectedNode; + if ( + currentSelectedLeftPaneNode.title == "History" && + currentSelectedLeftPaneNode.hasChildren && + currentSelectedLeftPaneNode.getChild(0).title == "Today" + ) { + // Select "Today" node under History if not already selected + gLibrary.PlacesOrganizer._places.selectNode( + currentSelectedLeftPaneNode.getChild(0) + ); + } + + Assert.equal( + gLibrary.PlacesOrganizer._places.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + await searchHistory(gLibrary, "mozilla"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "history", + 1 + ); + + let firstHistoryNode = gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + Assert.equal( + firstHistoryNode.uri, + pages[0], + "Found history item in the right pane" + ); + + // Double click first History link to open it + gLibrary.ContentTree.view.selectNode(firstHistoryNode); + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + info("Cumulative search telemetry looks right"); + + cumulativeSearchesHistogram.clear(); + + // Close and reopen Libary window + await promiseLibraryClosed(gLibrary); + gLibrary = await promiseLibrary("History"); + + Assert.equal( + gLibrary.PlacesOrganizer._places.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + // if a user tries to open all history entries and they cancel opening + // those entries in new tabs (due to max tab limit warning), + // no telemetry should be recorded + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.maxOpenBeforeWarn", 4]], + }); + + // Reject opening all tabs when prompted + gResponse = 1; + + // Open all history entries + synthesizeClickOnSelectedTreeCell(gLibrary.PlacesOrganizer._places, { + button: 1, + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + // Make 4 searches before opening History links + await searchHistory(gLibrary, "library a", 1); + info("First search was performed."); + await searchHistory(gLibrary, "library b", 2); + info("Second search was performed."); + await searchHistory(gLibrary, "library c", 3); + info("Third search was performed."); + await searchHistory(gLibrary, "mozilla", 4); + info("Fourth search was performed."); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "history", + 4 + ); + + // Accept opening all tabs when prompted + gResponse = 0; + + // Open all history entries + synthesizeClickOnSelectedTreeCell(gLibrary.PlacesOrganizer._places, { + button: 1, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 4 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 4, 1); + info("Cumulative search telemetry looks right"); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openOption = document.getElementById("placesContext_open"); + openOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewTabOption = document.getElementById("placesContext_open:newtab"); + openNewTabOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newWin); + + let newPrivateWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link" + ); + + let openNewPrivateWindowOption = document.getElementById( + "placesContext_open:newprivatewindow" + ); + openNewPrivateWindowOption.click(); + + let newPrivateWin = await newPrivateWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newPrivateWin); + + cumulativeSearchesHistogram.clear(); + await promiseLibraryClosed(gLibrary); + await SpecialPowers.popPrefEnv(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_library_bookmarks_telemetry() { + Services.telemetry.clearScalars(); + let cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + + let library = await promiseLibrary("AllBookmarks"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.opened", + "bookmarks", + 1 + ); + + searchBookmarks(library, "mozilla"); + + // reset + searchBookmarks(library, ""); + + // search again + searchBookmarks(library, "moz"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "bookmarks", + 2 + ); + + let firstNode = library.ContentTree.view.view.nodeForTreeIndex(0); + library.ContentTree.view.selectNode(firstNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "bookmarks", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + + cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + + // do another search to make sure everything has been cleared + searchBookmarks(library, "moz"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.search", + "bookmarks", + 1 + ); + + firstNode = library.ContentTree.view.view.nodeForTreeIndex(0); + library.ContentTree.view.selectNode(firstNode); + + synthesizeClickOnSelectedTreeCell(library.ContentTree.view, { + clickCount: 2, + }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "library.link", + "bookmarks", + 1 + ); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + + cumulativeSearchesHistogram.clear(); + await promiseLibraryClosed(library); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_library_tree_leak.js b/browser/components/places/tests/browser/browser_library_tree_leak.js new file mode 100644 index 0000000000..9e552f5c31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_tree_leak.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +add_task(async function bookmark_leak_window() { + // A library window has two trees after selecting a bookmark item: + // A left tree (#placesList) and a right tree (#placeContent). + // Upon closing the window, both trees are destructed, in an unspecified + // order. In bug 1520047, a memory leak was observed when the left tree + // was destroyed last. + + let library = await promiseLibrary("BookmarksToolbar"); + let tree = library.document.getElementById("placesList"); + tree.selectItems(["toolbar_____"]); + + await synthesizeClickOnSelectedTreeCell(tree); + await promiseLibraryClosed(library); + + Assert.ok( + true, + "Closing a window after selecting a node in the tree should not cause a leak" + ); +}); diff --git a/browser/components/places/tests/browser/browser_library_views_liveupdate.js b/browser/components/places/tests/browser/browser_library_views_liveupdate.js new file mode 100644 index 0000000000..593b0bd243 --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_views_liveupdate.js @@ -0,0 +1,255 @@ +/* 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/. */ + +/** + * Tests Library Left pane view for liveupdate. + */ + +let gLibrary = null; + +add_setup(async function () { + gLibrary = await promiseLibrary(); + + await PlacesUtils.bookmarks.eraseEverything(); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + await promiseLibraryClosed(gLibrary); + }); +}); + +async function testInFolder(folderGuid, prefix) { + let addedBookmarks = []; + + let item = await insertAndCheckItem({ + parentGuid: folderGuid, + title: `${prefix}1`, + url: `http://${prefix}1.mozilla.org/`, + }); + item.title = `${prefix}1_edited`; + await updateAndCheckItem(item); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}2`, + url: "place:", + }, + 0 + ); + + item.title = `${prefix}2_edited`; + await updateAndCheckItem(item, 0); + addedBookmarks.push(item); + + item = await insertAndCheckItem({ + parentGuid: folderGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + addedBookmarks.push(item); + + item = await insertAndCheckItem( + { + parentGuid: folderGuid, + title: `${prefix}f`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + 1 + ); + + item.title = `${prefix}f_edited`; + await updateAndCheckItem(item, 1); + + item.index = 0; + await updateAndCheckItem(item, 0); + addedBookmarks.push(item); + + let folderGuid1 = item.guid; + + item = await insertAndCheckItem({ + parentGuid: folderGuid1, + title: `${prefix}f1`, + url: `http://${prefix}f1.mozilla.org/`, + }); + addedBookmarks.push(item); + + item = await insertAndCheckItem({ + parentGuid: folderGuid1, + title: `${prefix}f12`, + url: `http://${prefix}f12.mozilla.org/`, + }); + addedBookmarks.push(item); + + item.index = 0; + await updateAndCheckItem(item); + + return addedBookmarks; +} + +add_task(async function test() { + let addedBookmarks = []; + + info("*** Acting on menu bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.menuGuid, "bm") + ); + + info("*** Acting on toolbar bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.toolbarGuid, "tb") + ); + + info("*** Acting on unsorted bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.unfiledGuid, "ub") + ); + + // Remove bookmarks in reverse order, so that the effects are correct. + for (let i = addedBookmarks.length - 1; i >= 0; i--) { + await removeAndCheckItem(addedBookmarks[i]); + } +}); + +async function insertAndCheckItem(itemData, expectedIndex) { + let item = await PlacesUtils.bookmarks.insert(itemData); + + let [node, index, title] = getNodeForTreeItem( + item.guid, + gLibrary.PlacesOrganizer._places + ); + // Left pane should not be updated for normal bookmarks or separators. + switch (itemData.type || PlacesUtils.bookmarks.TYPE_BOOKMARK) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + let uriString = itemData.url; + let isQuery = uriString.substr(0, 6) == "place:"; + if (isQuery) { + Assert.ok(node, "Should have a new query in the left pane."); + break; + } + // Fallthrough if this isn't a query + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + Assert.ok( + !node, + "Should not have added a bookmark or separator to the left pane." + ); + break; + default: + Assert.ok( + node, + "Should have added a new node in the left pane for a folder." + ); + } + + if (node) { + Assert.equal(title, itemData.title, "Should have the correct title"); + Assert.equal(index, expectedIndex, "Should have the expected index"); + } + + return item; +} + +async function updateAndCheckItem(newItemData, expectedIndex) { + await PlacesUtils.bookmarks.update(newItemData); + + let [node, index, title] = getNodeForTreeItem( + newItemData.guid, + gLibrary.PlacesOrganizer._places + ); + + // Left pane should not be updated for normal bookmarks or separators. + switch (newItemData.type || PlacesUtils.bookmarks.TYPE_BOOKMARK) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + let isQuery = newItemData.url.protocol == "place:"; + if (isQuery) { + Assert.ok(node, "Should be able to find the updated node"); + break; + } + // Fallthrough if this isn't a query + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + Assert.ok(!node, "Should not be able to find the updated node"); + break; + default: + Assert.ok(node, "Should be able to find the updated node"); + } + + if (node) { + Assert.equal(title, newItemData.title, "Should have the correct title"); + Assert.equal(index, expectedIndex, "Should have the expected index"); + } +} + +async function removeAndCheckItem(itemData) { + await PlacesUtils.bookmarks.remove(itemData); + let [node] = getNodeForTreeItem( + itemData.guid, + gLibrary.PlacesOrganizer._places + ); + Assert.ok(!node, "Should not be able to find the removed node"); +} + +/** + * Get places node, index and cell text for a guid in a tree view. + * + * @param {string} aItemGuid + * item guid of the item to search. + * @param {object} aTree + * Tree to search in. + * @returns {Array} + * [node, index, cellText] or [null, null, ""] if not found. + */ +function getNodeForTreeItem(aItemGuid, aTree) { + function findNode(aContainerIndex) { + if (aTree.view.isContainerEmpty(aContainerIndex)) { + return [null, null, ""]; + } + + // The rowCount limit is just for sanity, but we will end looping when + // we have checked the last child of this container or we have found node. + for (let i = aContainerIndex + 1; i < aTree.view.rowCount; i++) { + let node = aTree.view.nodeForTreeIndex(i); + + if (node.bookmarkGuid == aItemGuid) { + // Minus one because we want relative index inside the container. + let tree = gLibrary.PlacesOrganizer._places; + let cellText = tree.view.getCellText(i, tree.columns.getColumnAt(0)); + return [node, i - aContainerIndex - 1, cellText]; + } + + if (PlacesUtils.nodeIsFolder(node)) { + // Open container. + aTree.view.toggleOpenState(i); + // Search inside it. + let foundNode = findNode(i); + // Close container. + aTree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + + // We have finished walking this container. + if (!aTree.view.hasNextSibling(aContainerIndex + 1, i)) { + break; + } + } + return [null, null, ""]; + } + + // Root node is hidden, so we need to manually walk the first level. + for (let i = 0; i < aTree.view.rowCount; i++) { + // Open container. + aTree.view.toggleOpenState(i); + // Search inside it. + let foundNode = findNode(i); + // Close container. + aTree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + return [null, null, ""]; +} diff --git a/browser/components/places/tests/browser/browser_library_warnOnOpen.js b/browser/components/places/tests/browser/browser_library_warnOnOpen.js new file mode 100644 index 0000000000..4289d414df --- /dev/null +++ b/browser/components/places/tests/browser/browser_library_warnOnOpen.js @@ -0,0 +1,159 @@ +/* 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/. */ + +/* + * Bug 1435562 - Test that browser.tabs.warnOnOpen is respected when + * opening multiple items from the Library. */ + +"use strict"; + +var gLibrary = null; + +add_setup(async function () { + // Temporarily disable history, so we won't record pages navigation. + await SpecialPowers.pushPrefEnv({ set: [["places.history.enabled", false]] }); + + // Open Library window. + gLibrary = await promiseLibrary(); + + registerCleanupFunction(async () => { + // We must close "Other Bookmarks" ready for other tests. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = false; + + await PlacesUtils.bookmarks.eraseEverything(); + + // Close Library window. + await promiseLibraryClosed(gLibrary); + }); +}); + +add_task(async function test_warnOnOpenFolder() { + // Generate a list of links larger than browser.tabs.maxOpenBeforeWarn + const MAX_LINKS = 16; + let children = []; + for (let i = 0; i < MAX_LINKS; i++) { + children.push({ + title: `Folder Target ${i}`, + url: `http://example${i}.com`, + }); + } + + // Create a new folder containing our links. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bigFolder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children, + }, + ], + }); + info("Pushed test folder into the bookmarks tree"); + + // Select unsorted bookmarks root in the left pane. + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + info("Got selection in the Library left pane"); + + // Get our bookmark in the right pane. + gLibrary.ContentTree.view.view.nodeForTreeIndex(0); + info("Got bigFolder in the right pane"); + + gLibrary.PlacesOrganizer._places.selectedNode.containerOpen = true; + + // Middle-click on folder (opens all links in folder) and then cancel opening in the dialog + let promiseLoaded = BrowserTestUtils.promiseAlertDialog("cancel"); + let bookmarkedNode = + gLibrary.PlacesOrganizer._places.selectedNode.getChild(0); + mouseEventOnCell( + gLibrary.PlacesOrganizer._places, + gLibrary.PlacesOrganizer._places.view.treeIndexForNode(bookmarkedNode), + 0, + { button: 1 } + ); + + await promiseLoaded; + + Assert.ok( + true, + "Expected dialog was shown when attempting to open folder with lots of links" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_warnOnOpenLinks() { + // Generate a list of links larger than browser.tabs.maxOpenBeforeWarn + const MAX_LINKS = 16; + let children = []; + for (let i = 0; i < MAX_LINKS; i++) { + children.push({ + title: `Highlighted Target ${i}`, + url: `http://example${i}.com`, + }); + } + + // Insert the links into the tree + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children, + }); + info("Pushed test folder into the bookmarks tree"); + + gLibrary.PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + info("Got selection in the Library left pane"); + + // Select all the links + gLibrary.ContentTree.view.selectAll(); + + let placesContext = gLibrary.document.getElementById("placesContext"); + let promiseContextMenu = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + + // Open up the context menu and select "Open All In Tabs" (the first item in the list) + synthesizeClickOnSelectedTreeCell(gLibrary.ContentTree.view, { + button: 2, + type: "contextmenu", + }); + + await promiseContextMenu; + info("Context menu opened as expected"); + + let openTabs = gLibrary.document.getElementById( + "placesContext_openBookmarkLinks:tabs" + ); + let promiseLoaded = BrowserTestUtils.promiseAlertDialog("cancel"); + + placesContext.activateItem(openTabs, {}); + + await promiseLoaded; + + Assert.ok( + true, + "Expected dialog was shown when attempting to open lots of selected links" + ); + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +function mouseEventOnCell(aTree, aRowIndex, aColumnIndex, aEventDetails) { + var selection = aTree.view.selection; + selection.select(aRowIndex); + aTree.ensureRowIsVisible(aRowIndex); + var column = aTree.columns[aColumnIndex]; + + // get cell coordinates + var rect = aTree.getCoordsForCellItem(aRowIndex, column, "text"); + + EventUtils.synthesizeMouse( + aTree.body, + rect.x, + rect.y, + aEventDetails, + gLibrary + ); +} diff --git a/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js b/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js new file mode 100644 index 0000000000..75ed89ddea --- /dev/null +++ b/browser/components/places/tests/browser/browser_markPageAsFollowedLink.js @@ -0,0 +1,75 @@ +/** + * Tests that visits across frames are correctly represented in the database. + */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser"; +const PAGE_URL = BASE_URL + "/framedPage.html"; +const LEFT_URL = BASE_URL + "/frameLeft.html"; +const RIGHT_URL = BASE_URL + "/frameRight.html"; + +add_task(async function test() { + // We must wait for both frames to be loaded and the visits to be registered. + let deferredLeftFrameVisit = PromiseUtils.defer(); + let deferredRightFrameVisit = PromiseUtils.defer(); + + Services.obs.addObserver(function observe(subject) { + (async function () { + let url = subject.QueryInterface(Ci.nsIURI).spec; + if (url == LEFT_URL) { + is( + await getTransitionForUrl(url), + null, + "Embed visits should not get a database entry." + ); + deferredLeftFrameVisit.resolve(); + } else if (url == RIGHT_URL) { + is( + await getTransitionForUrl(url), + PlacesUtils.history.TRANSITION_FRAMED_LINK, + "User activated visits should get a FRAMED_LINK transition." + ); + Services.obs.removeObserver(observe, "uri-visit-saved"); + deferredRightFrameVisit.resolve(); + } + })(); + }, "uri-visit-saved"); + + // Open a tab and wait for all the subframes to load. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + + // Wait for the left frame visit to be registered. + info("Waiting left frame visit"); + await deferredLeftFrameVisit.promise; + + // Click on the link in the left frame to cause a page load in the + // right frame. + info("Clicking link"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + content.frames[0].document.getElementById("clickme").click(); + }); + + // Wait for the right frame visit to be registered. + info("Waiting right frame visit"); + await deferredRightFrameVisit.promise; + + BrowserTestUtils.removeTab(tab); +}); + +function getTransitionForUrl(url) { + return PlacesUtils.withConnectionWrapper( + "browser_markPageAsFollowedLink", + async db => { + let rows = await db.execute( + ` + SELECT visit_type + FROM moz_historyvisits + JOIN moz_places h ON place_id = h.id + WHERE url_hash = hash(:url) AND url = :url + `, + { url } + ); + return rows.length ? rows[0].getResultByName("visit_type") : null; + } + ); +} diff --git a/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js b/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js new file mode 100644 index 0000000000..3a5527a689 --- /dev/null +++ b/browser/components/places/tests/browser/browser_panelview_bookmarks_delete.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +const TEST_URL = "https://www.example.com/"; + +/** + * Checks that the Bookmarks subview is updated after deleting an item. + */ +add_task(async function test_panelview_bookmarks_delete() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_URL, + title: TEST_URL, + }); + + await gCUITestUtils.openMainMenu(); + + document.getElementById("appMenu-bookmarks-button").click(); + let bookmarksView = document.getElementById("PanelUI-bookmarks"); + let promise = BrowserTestUtils.waitForEvent(bookmarksView, "ViewShown"); + await promise; + + let list = document.getElementById("panelMenu_bookmarksMenu"); + let listItem = [...list.children].find(node => node.label == TEST_URL); + + let placesContext = document.getElementById("placesContext"); + promise = BrowserTestUtils.waitForEvent(placesContext, "popupshown"); + EventUtils.synthesizeMouseAtCenter(listItem, { + button: 2, + type: "contextmenu", + }); + await promise; + + promise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + if (listItem.parentNode == null) { + Assert.ok(true, "The bookmarks list item was removed."); + observer.disconnect(); + resolve(); + } + }); + observer.observe(list, { childList: true }); + }); + let placesContextDelete = document.getElementById( + "placesContext_deleteBookmark" + ); + placesContext.activateItem(placesContextDelete, {}); + await promise; + + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/places/tests/browser/browser_paste_bookmarks.js b/browser/components/places/tests/browser/browser_paste_bookmarks.js new file mode 100644 index 0000000000..cec58bb305 --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_bookmarks.js @@ -0,0 +1,418 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; +const TEST_URL1 = "https://example.com/otherbrowser/"; + +var PlacesOrganizer; +var ContentTree; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + PlacesOrganizer = organizer.PlacesOrganizer; + ContentTree = organizer.ContentTree; +}); + +add_task(async function paste() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_URL, + title: "0", + }); + + ContentTree.view.selectItems([bookmark.guid]); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal(tree.children[0].title, "0", "Should have the correct title"); + Assert.equal(tree.children[0].uri, TEST_URL, "Should have the correct URL"); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_check_indexes() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + let targetChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + targetChildren.push({ + url: `${TEST_URL1}${i}`, + title: `Target ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + let targetBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: targetChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + ContentTree.view.selectItems([targetBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + const expectedBookmarkOrder = [ + targetBookmarks[0].guid, + targetBookmarks[1].guid, + targetBookmarks[2].guid, + targetBookmarks[3].guid, + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + targetBookmarks[4].guid, + targetBookmarks[5].guid, + targetBookmarks[6].guid, + targetBookmarks[7].guid, + targetBookmarks[8].guid, + targetBookmarks[9].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function paste_check_indexes_same_folder() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + ContentTree.view.selectItems([copyBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.toolbarGuid + ); + + // Although we've inserted at index 4, we've taken out two items below it, so + // we effectively insert after the third item. + const expectedBookmarkOrder = [ + copyBookmarks[1].guid, + copyBookmarks[2].guid, + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + copyBookmarks[4].guid, + copyBookmarks[5].guid, + copyBookmarks[7].guid, + copyBookmarks[8].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function paste_from_different_instance() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + + // Fake data on the clipboard to pretend this is from a different instance + // of Firefox. + let data = { + title: "test", + id: 32, + instanceId: "FAKEFAKEFAKE", + itemGuid: "ZBf_TYkrYGvW", + parent: 452, + dateAdded: 1464866275853000, + lastModified: 1507638113352000, + type: "text/x-moz-place", + uri: TEST_URL1, + }; + data = JSON.stringify(data); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE); + xferable.setTransferData( + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.toISupportsString(data) + ); + + Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal(tree.children[0].title, "test", "Should have the correct title"); + Assert.equal(tree.children[0].uri, TEST_URL1, "Should have the correct URL"); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_separator_from_different_instance() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + + // Fake data on the clipboard to pretend this is from a different instance + // of Firefox. + let data = { + title: "test", + id: 32, + instanceId: "FAKEFAKEFAKE", + itemGuid: "ZBf_TYkrYGvW", + parent: 452, + dateAdded: 1464866275853000, + lastModified: 1507638113352000, + type: PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + }; + data = JSON.stringify(data); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE); + xferable.setTransferData( + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.toISupportsString(data) + ); + + Services.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + Assert.equal( + tree.children.length, + 1, + "Should be one bookmark in the unfiled folder." + ); + Assert.equal( + tree.children[0].type, + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + "Should have the correct type" + ); + + await PlacesUtils.bookmarks.remove(tree.children[0].guid); +}); + +add_task(async function paste_copy_check_indexes() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let copyChildren = []; + let targetChildren = []; + for (let i = 0; i < 10; i++) { + copyChildren.push({ + url: `${TEST_URL}${i}`, + title: `Copy ${i}`, + }); + targetChildren.push({ + url: `${TEST_URL1}${i}`, + title: `Target ${i}`, + }); + } + + let copyBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: copyChildren, + }); + + let targetBookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: targetChildren, + }); + + ContentTree.view.selectItems([ + copyBookmarks[0].guid, + copyBookmarks[3].guid, + copyBookmarks[6].guid, + copyBookmarks[9].guid, + ]); + + await promiseClipboard(() => { + info("Cutting multiple selection"); + ContentTree.view.controller.copy(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + info("Selecting UnfiledBookmarks in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + ContentTree.view.selectItems([targetBookmarks[4].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + let tree = await PlacesUtils.promiseBookmarksTree( + PlacesUtils.bookmarks.unfiledGuid + ); + + const expectedBookmarkOrder = [ + targetBookmarks[0].guid, + targetBookmarks[1].guid, + targetBookmarks[2].guid, + targetBookmarks[3].guid, + 0, + 3, + 6, + 9, + targetBookmarks[4].guid, + targetBookmarks[5].guid, + targetBookmarks[6].guid, + targetBookmarks[7].guid, + targetBookmarks[8].guid, + targetBookmarks[9].guid, + ]; + + Assert.equal( + tree.children.length, + expectedBookmarkOrder.length, + "Should be the expected amount of bookmarks in the unfiled folder." + ); + + for (let i = 0; i < expectedBookmarkOrder.length; ++i) { + if (i > 3 && i <= 7) { + // Items 4 - 7 are copies of the original, so we need to compare data, rather + // than their guids. + Assert.equal( + tree.children[i].title, + copyChildren[expectedBookmarkOrder[i]].title, + `Should have the correct bookmark title at index ${i}` + ); + Assert.equal( + tree.children[i].uri, + copyChildren[expectedBookmarkOrder[i]].url, + `Should have the correct bookmark URL at index ${i}` + ); + } else { + Assert.equal( + tree.children[i].guid, + expectedBookmarkOrder[i], + `Should be the expected item at index ${i}` + ); + } + } + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/places/tests/browser/browser_paste_into_tags.js b/browser/components/places/tests/browser/browser_paste_into_tags.js new file mode 100644 index 0000000000..d159fa5646 --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_into_tags.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_URL = Services.io.newURI("http://example.com/"); +const MOZURISPEC = Services.io.newURI("http://mozilla.com/"); + +add_task(async function () { + let organizer = await promiseLibrary(); + + ok(PlacesUtils, "PlacesUtils in scope"); + ok(PlacesUIUtils, "PlacesUIUtils in scope"); + + let PlacesOrganizer = organizer.PlacesOrganizer; + ok(PlacesOrganizer, "Places organizer in scope"); + + let ContentTree = organizer.ContentTree; + ok(ContentTree, "ContentTree is in scope"); + + let visits = { + uri: MOZURISPEC, + transition: PlacesUtils.history.TRANSITION_TYPED, + }; + await PlacesTestUtils.addVisits(visits); + + // create an initial tag to work with + let newBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + title: "bookmark/" + TEST_URL.spec, + url: TEST_URL, + }); + + ok(newBookmark, "A bookmark was added"); + PlacesUtils.tagging.tagURI(TEST_URL, ["foo"]); + let tags = PlacesUtils.tagging.getTagsForURI(TEST_URL); + is(tags[0], "foo", "tag is foo"); + + // focus the new tag + focusTag(PlacesOrganizer); + + let populate = () => copyHistNode(PlacesOrganizer, ContentTree); + await promiseClipboard(populate, PlacesUtils.TYPE_X_MOZ_PLACE); + + focusTag(PlacesOrganizer); + await PlacesOrganizer._places.controller.paste(); + + // re-focus the history again + PlacesOrganizer.selectLeftPaneBuiltIn("History"); + let histContainer = PlacesOrganizer._places.selectedNode; + PlacesUtils.asContainer(histContainer); + histContainer.containerOpen = true; + PlacesOrganizer._places.selectNode(histContainer.getChild(0)); + let histNode = ContentTree.view.view.nodeForTreeIndex(0); + ok(histNode, "histNode exists: " + histNode.title); + + // check to see if the history node is tagged! + tags = PlacesUtils.tagging.getTagsForURI(MOZURISPEC); + ok(tags.length == 1, "history node is tagged: " + tags.length); + + // check if a bookmark was created + let bookmarks = []; + await PlacesUtils.bookmarks.fetch({ url: MOZURISPEC }, bm => { + bookmarks.push(bm); + }); + ok(!!bookmarks.length, "bookmark exists for the tagged history item"); + + // is the bookmark visible in the UI? + // get the Unsorted Bookmarks node + PlacesOrganizer.selectLeftPaneBuiltIn("UnfiledBookmarks"); + + // now we can see what is in the ContentTree tree + let unsortedNode = ContentTree.view.view.nodeForTreeIndex(1); + ok(unsortedNode, "unsortedNode is not null: " + unsortedNode.uri); + is(unsortedNode.uri, MOZURISPEC.spec, "node uri's are the same"); + + await promiseLibraryClosed(organizer); + + // Remove new Places data we created. + PlacesUtils.tagging.untagURI(MOZURISPEC, ["foo"]); + PlacesUtils.tagging.untagURI(TEST_URL, ["foo"]); + tags = PlacesUtils.tagging.getTagsForURI(TEST_URL); + is(tags.length, 0, "tags are gone"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +function focusTag(PlacesOrganizer) { + PlacesOrganizer.selectLeftPaneBuiltIn("Tags"); + let tags = PlacesOrganizer._places.selectedNode; + tags.containerOpen = true; + let fooTag = tags.getChild(0); + let tagNode = fooTag; + PlacesOrganizer._places.selectNode(fooTag); + is(tagNode.title, "foo", "tagNode title is foo"); + let ip = PlacesOrganizer._places.insertionPoint; + ok(ip.isTag, "IP is a tag"); +} + +function copyHistNode(PlacesOrganizer, ContentTree) { + // focus the history object + PlacesOrganizer.selectLeftPaneBuiltIn("History"); + let histContainer = PlacesOrganizer._places.selectedNode; + PlacesUtils.asContainer(histContainer); + histContainer.containerOpen = true; + PlacesOrganizer._places.selectNode(histContainer.getChild(0)); + let histNode = ContentTree.view.view.nodeForTreeIndex(0); + ContentTree.view.selectNode(histNode); + is(histNode.uri, MOZURISPEC.spec, "historyNode exists: " + histNode.uri); + // copy the history node + ContentTree.view.controller.copy(); +} diff --git a/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js b/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js new file mode 100644 index 0000000000..caa73d137f --- /dev/null +++ b/browser/components/places/tests/browser/browser_paste_resets_cut_highlights.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "http://example.com/"; +const TEST_URL1 = "https://example.com/otherbrowser/"; + +var PlacesOrganizer; +var ContentTree; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + let organizer = await promiseLibrary(); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(organizer); + await PlacesUtils.bookmarks.eraseEverything(); + }); + + PlacesOrganizer = organizer.PlacesOrganizer; + ContentTree = organizer.ContentTree; +}); + +add_task(async function paste() { + info("Selecting BookmarksToolbar in the left pane"); + PlacesOrganizer.selectLeftPaneBuiltIn("BookmarksToolbar"); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + url: TEST_URL, + title: "0", + }, + { + url: TEST_URL1, + title: "1", + }, + ], + }); + + Assert.equal( + ContentTree.view.view.rowCount, + 2, + "Should have the right amount of items in the view" + ); + + ContentTree.view.selectItems([bookmarks[0].guid]); + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + assertItemsHighlighted(1); + + ContentTree.view.selectItems([bookmarks[1].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + assertItemsHighlighted(0); + + // And now repeat the other way around to make sure. + + await promiseClipboard(() => { + info("Cutting selection"); + ContentTree.view.controller.cut(); + }, PlacesUtils.TYPE_X_MOZ_PLACE); + + assertItemsHighlighted(1); + + ContentTree.view.selectItems([bookmarks[0].guid]); + + info("Pasting clipboard"); + await ContentTree.view.controller.paste(); + + assertItemsHighlighted(0); +}); + +function assertItemsHighlighted(expectedItems) { + let column = ContentTree.view.view._tree.columns[0]; + // Check the properties of the cells to make sure nothing has a cut highlight. + let highlighedItems = 0; + for (let i = 0; i < ContentTree.view.view.rowCount; i++) { + if ( + ContentTree.view.view.getCellProperties(i, column).includes("cutting") + ) { + highlighedItems++; + } + } + + Assert.equal( + highlighedItems, + expectedItems, + "Should have the correct amount of items highlighed" + ); +} diff --git a/browser/components/places/tests/browser/browser_remove_bookmarks.js b/browser/components/places/tests/browser/browser_remove_bookmarks.js new file mode 100644 index 0000000000..8889ea11c0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_remove_bookmarks.js @@ -0,0 +1,155 @@ +/* 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"; + +/** + * Test removing bookmarks from the Bookmarks Toolbar and Library. + */ + +const TEST_URL = "about:mozilla"; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_remove_bookmark_from_toolbar() { + let toolbarBookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + title: "Bookmark Title", + url: TEST_URL, + }); + + let toolbarNode = getToolbarNodeForItemGuid(toolbarBookmark.guid); + + let contextMenu = document.getElementById("placesContext"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + EventUtils.synthesizeMouseAtCenter(toolbarNode, { + button: 2, + type: "contextmenu", + }); + await popupShownPromise; + + let contextMenuDeleteBookmark = document.getElementById( + "placesContext_deleteBookmark" + ); + + let removePromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.url == TEST_URL) + ); + + contextMenu.activateItem(contextMenuDeleteBookmark, {}); + + await removePromise; + + Assert.deepEqual( + PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + {}, + "Should have removed the bookmark from the database" + ); +}); + +add_task(async function test_remove_bookmark_from_library() { + const uris = [ + "http://example.com/1", + "http://example.com/2", + "http://example.com/3", + ]; + + let children = uris.map((uri, index) => { + return { + title: `bm${index}`, + url: uri, + }; + }); + + // Insert bookmarks. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children, + }); + + // Open the Library and select the "UnfiledBookmarks". + let library = await promiseLibrary("UnfiledBookmarks"); + + registerCleanupFunction(async function () { + await promiseLibraryClosed(library); + }); + + let PO = library.PlacesOrganizer; + + Assert.equal( + PlacesUtils.getConcreteItemGuid(PO._places.selectedNode), + PlacesUtils.bookmarks.unfiledGuid, + "Should have selected unfiled bookmarks." + ); + + let contextMenu = library.document.getElementById("placesContext"); + let contextMenuDeleteBookmark = library.document.getElementById( + "placesContext_deleteBookmark" + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + let firstColumn = library.ContentTree.view.columns[0]; + let firstBookmarkRect = library.ContentTree.view.getCoordsForCellItem( + 0, + firstColumn, + "bm0" + ); + + EventUtils.synthesizeMouse( + library.ContentTree.view.body, + firstBookmarkRect.x, + firstBookmarkRect.y, + { type: "contextmenu", button: 2 }, + library + ); + + await popupShownPromise; + + Assert.equal( + library.ContentTree.view.result.root.childCount, + 3, + "Number of bookmarks before removal is right" + ); + + let removePromise = PlacesTestUtils.waitForNotification( + "bookmark-removed", + events => events.some(event => event.url == uris[0]) + ); + contextMenu.activateItem(contextMenuDeleteBookmark, {}); + + await removePromise; + + Assert.equal( + library.ContentTree.view.result.root.childCount, + 2, + "Should have removed the bookmark from the display" + ); +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js b/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js new file mode 100644 index 0000000000..10f82db286 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_bookmarks_telemetry.js @@ -0,0 +1,141 @@ +/* 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"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +let bookmarks; +let folder; + +add_setup(async function () { + folder = await PlacesUtils.bookmarks.insert({ + title: "Sidebar Test Folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: folder.guid, + children: [ + { + title: "Mozilla", + url: "https://www.mozilla.org/", + }, + { + title: "Example", + url: "https://sidebar.mozilla.org/", + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_multiple_bookmarks() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([folder.guid]); + + is( + tree.selectedNode.title, + "Sidebar Test Folder", + "The sidebar test bookmarks folder is selected" + ); + + // open all bookmarks in this folder (which is two) + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + // expand the "Other bookmarks" folder + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + tree.selectItems([bookmarks[0].guid]); + + is(tree.selectedNode.title, "Mozilla", "The first bookmark is selected"); + + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "sidebar.link", + "bookmarks", + 3 + ); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + // open a bookmark in new window via context menu + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + // total bookmarks opened + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "bookmarks", + 4 + ); + + Services.telemetry.clearScalars(); + BrowserTestUtils.closeWindow(newWin); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + }); +}); + +add_task(async function test_bookmarks_search() { + let cumulativeSearchesHistogram = TelemetryTestUtils.getAndClearHistogram( + "PLACES_BOOKMARKS_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + cumulativeSearchesHistogram.clear(); + + await withSidebarTree("bookmarks", async tree => { + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "example"; + searchBox.doCommand(); + + searchBox.value = ""; + searchBox.doCommand(); + info("Search was reset"); + + // Perform a second search. + searchBox.value = "mozilla"; + searchBox.doCommand(); + info("Second search was performed"); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(0)); + + synthesizeClickOnSelectedTreeCell(tree, { button: 0 }); + info("First link was selected and then clicked on"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "bookmarks", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + info("Cumulative search probe is recorded"); + + cumulativeSearchesHistogram.clear(); + Services.telemetry.clearScalars(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js b/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js new file mode 100644 index 0000000000..cb27bf66ef --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_history_telemetry.js @@ -0,0 +1,285 @@ +/* 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"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const firstNodeIndex = 0; + +// The prompt returns 1 for cancelled and 0 for accepted. +let gResponse = 1; +(function replacePromptService() { + let originalPromptService = Services.prompt; + Services.prompt = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIPromptService]), + confirmEx: () => gResponse, + }; + registerCleanupFunction(() => { + Services.prompt = originalPromptService; + }); +})(); + +add_setup(async function () { + await PlacesUtils.history.clear(); + + // Visited pages listed by descending visit date. + let pages = [ + "https://sidebar.mozilla.org/a", + "https://sidebar.mozilla.org/b", + "https://sidebar.mozilla.org/c", + "https://www.mozilla.org/d", + ]; + + // Add some visited page. + let time = Date.now(); + let places = []; + for (let i = 0; i < pages.length; i++) { + places.push({ + uri: NetUtil.newURI(pages[i]), + visitDate: (time - i) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(places); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_click_multiple_history_entries() { + await withSidebarTree("history", async tree => { + tree.ownerDocument.getElementById("byday").doCommand(); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + is( + tree.selectedNode.title, + "Today", + "The Today history sidebar button is selected" + ); + + // if a user tries to open all history items and they cancel opening + // those items in new tabs (due to max tab limit warning), + // no telemetry should be recorded + gResponse = 1; + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.maxOpenBeforeWarn", 4]], + }); + + // open multiple history items with a single click + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + // if they proceed with opening history multiple history items despite the warning, + // telemetry should be recorded + gResponse = 0; + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 4 + ); + + let parentNode = tree.selectedNode; + if (!parentNode.containerOpen) { + // Only need to open/expand container node on first run + synthesizeClickOnSelectedTreeCell(tree); + } + if (parentNode.title == "Today" && parentNode.hasChildren) { + info(`Selecting node with title ${parentNode?.getChild(0)?.title}`); + tree.selectNode(parentNode.getChild(0)); + } + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewTabOption = document.getElementById("placesContext_open:newtab"); + openNewTabOption.click(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + if (parentNode.title == "Today" && parentNode.hasChildren) { + tree.selectNode(parentNode.getChild(0)); + } + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewWindowOption = document.getElementById( + "placesContext_open:newwindow" + ); + openNewWindowOption.click(); + + let newWin = await newWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newWin); + + if (parentNode.title == "Today" && parentNode.hasChildren) { + tree.selectNode(parentNode.getChild(0)); + } + + let newPrivateWinOpened = BrowserTestUtils.waitForNewWindow(); + + synthesizeClickOnSelectedTreeCell(tree, { + button: 2, + type: "contextmenu", + }); + + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link" + ); + + let openNewPrivateWindowOption = document.getElementById( + "placesContext_open:newprivatewindow" + ); + openNewPrivateWindowOption.click(); + + let newPrivateWin = await newPrivateWinOpened; + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + + await BrowserTestUtils.closeWindow(newPrivateWin); + }); + + await SpecialPowers.popPrefEnv(); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_search_and_filter() { + let cumulativeSearchesHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_SEARCHES" + ); + cumulativeSearchesHistogram.clear(); + let cumulativeFilterCountHistogram = Services.telemetry.getHistogramById( + "PLACES_SEARCHBAR_CUMULATIVE_FILTER_COUNT" + ); + cumulativeFilterCountHistogram.clear(); + + await withSidebarTree("history", async tree => { + // Apply a search filter. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + info("Search filter was changed to bylastvisited"); + + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + info("Tree was searched with sting sidebar.mozilla"); + + searchBox.value = ""; + searchBox.doCommand(); + info("Search was reset"); + + // Perform a second search. + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + info("Second search was performed"); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + info("First link was selected and then clicked on"); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 2, 1); + info("Cumulative search telemetry looks right"); + + TelemetryTestUtils.assertHistogram(cumulativeFilterCountHistogram, 1, 1); + info("Cumulative search filter telemetry looks right"); + + cumulativeSearchesHistogram.clear(); + cumulativeFilterCountHistogram.clear(); + + await withSidebarTree("history", async tree => { + // Apply a search filter. + tree.ownerDocument.getElementById("byday").doCommand(); + info("First search filter applied"); + + // Apply another search filter. + tree.ownerDocument.getElementById("bylastvisited").doCommand(); + info("Second search filter applied"); + + // Apply a search filter. + tree.ownerDocument.getElementById("byday").doCommand(); + info("Third search filter applied"); + + // Search the tree. + let searchBox = tree.ownerDocument.getElementById("search-box"); + searchBox.value = "sidebar.mozilla"; + searchBox.doCommand(); + + // Select the first link and click on it. + tree.selectNode(tree.view.nodeForTreeIndex(firstNodeIndex)); + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "sidebar.link", + "history", + 1 + ); + }); + + TelemetryTestUtils.assertHistogram(cumulativeSearchesHistogram, 1, 1); + TelemetryTestUtils.assertHistogram(cumulativeFilterCountHistogram, 3, 1); + + cumulativeSearchesHistogram.clear(); + cumulativeFilterCountHistogram.clear(); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } +}); diff --git a/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js b/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js new file mode 100644 index 0000000000..92f98b898c --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebar_open_bookmarks.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const PREF_LOAD_BOOKMARKS_IN_TABS = "browser.tabs.loadBookmarksInTabs"; + +var gBms; + +add_setup(async function () { + gBms = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "bm1", + url: "about:buildconfig", + }, + { + title: "bm2", + url: "about:mozilla", + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_open_bookmark_from_sidebar() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[0].guid]); + + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[0].url + ); + + tree.controller.doCommand("placesCmd_open"); + + await loadedPromise; + + // An assert to make the test happy. + Assert.ok(true, "The bookmark was loaded successfully."); + }); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_open_bookmark_from_sidebar_keypress() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[1].guid]); + + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[1].url + ); + + tree.focus(); + EventUtils.sendKey("return"); + + await loadedPromise; + + // An assert to make the test happy. + Assert.ok(true, "The bookmark was loaded successfully."); + }); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_open_bookmark_in_tab_from_sidebar() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_LOAD_BOOKMARKS_IN_TABS, true]], + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async initialTab => { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([gBms[0].guid]); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + gBms[0].url + ); + tree.focus(); + EventUtils.sendKey("return"); + await loadedPromise; + Assert.ok(true, "The bookmark reused the empty tab."); + + tree.selectItems([gBms[1].guid]); + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, gBms[1].url); + tree.focus(); + EventUtils.sendKey("return"); + let newTab = await newTabPromise; + Assert.ok(true, "The bookmark was opened in a new tab."); + BrowserTestUtils.removeTab(newTab); + }); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_open_bookmark_folder_from_sidebar() { + await withSidebarTree("bookmarks", async tree => { + tree.selectItems([PlacesUtils.bookmarks.virtualUnfiledGuid]); + + Assert.equal( + tree.view.selection.getRangeCount(), + 1, + "Should only have one range selected" + ); + + let loadedPromises = []; + + for (let bm of gBms) { + loadedPromises.push( + BrowserTestUtils.waitForNewTab(gBrowser, bm.url, false, true) + ); + } + + synthesizeClickOnSelectedTreeCell(tree, { button: 1 }); + + let tabs = await Promise.all(loadedPromises); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } + }); +}); diff --git a/browser/components/places/tests/browser/browser_sidebarpanels_click.js b/browser/components/places/tests/browser/browser_sidebarpanels_click.js new file mode 100644 index 0000000000..4b231c92b0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sidebarpanels_click.js @@ -0,0 +1,172 @@ +/* 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/. */ + +// This test makes sure that the items in the bookmarks and history sidebar +// panels are clickable in both LTR and RTL modes. + +var sidebar; + +function pushPref(name, val) { + return SpecialPowers.pushPrefEnv({ set: [[name, val]] }); +} + +function popPref() { + return SpecialPowers.popPrefEnv(); +} + +add_task(async function test_sidebarpanels_click() { + ignoreAllUncaughtExceptions(); + + const BOOKMARKS_SIDEBAR_ID = "viewBookmarksSidebar"; + const BOOKMARKS_SIDEBAR_TREE_ID = "bookmarks-view"; + const HISTORY_SIDEBAR_ID = "viewHistorySidebar"; + const HISTORY_SIDEBAR_TREE_ID = "historyTree"; + const TEST_URL = + "http://mochi.test:8888/browser/browser/components/places/tests/browser/sidebarpanels_click_test_page.html"; + + // If a sidebar is already open, close it. + if (!document.getElementById("sidebar-box").hidden) { + ok( + false, + "Unexpected sidebar found - a previous test failed to cleanup correctly" + ); + SidebarUI.hide(); + } + + // Ensure history is clean before starting the test. + await PlacesUtils.history.clear(); + + sidebar = document.getElementById("sidebar"); + let tests = []; + + tests.push({ + _bookmark: null, + async init() { + // Add a bookmark to the Unfiled Bookmarks folder. + this._bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test", + url: TEST_URL, + }); + }, + prepare() {}, + async selectNode(tree) { + tree.selectItems([this._bookmark.guid]); + }, + cleanup(aCallback) { + return PlacesUtils.bookmarks.remove(this._bookmark); + }, + sidebarName: BOOKMARKS_SIDEBAR_ID, + treeName: BOOKMARKS_SIDEBAR_TREE_ID, + desc: "Bookmarks sidebar test", + }); + + tests.push({ + async init() { + // Add a history entry. + let uri = Services.io.newURI(TEST_URL); + await PlacesTestUtils.addVisits({ + uri, + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + }, + prepare() { + sidebar.contentDocument.getElementById("byvisited").doCommand(); + }, + selectNode(tree) { + tree.selectNode(tree.view.nodeForTreeIndex(0)); + is( + tree.selectedNode.uri, + TEST_URL, + "The correct visit has been selected" + ); + is(tree.selectedNode.itemId, -1, "The selected node is not bookmarked"); + }, + cleanup(aCallback) { + return PlacesUtils.history.clear(); + }, + sidebarName: HISTORY_SIDEBAR_ID, + treeName: HISTORY_SIDEBAR_TREE_ID, + desc: "History sidebar test", + }); + + for (let test of tests) { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + info("Running " + test.desc + " in LTR mode"); + await testPlacesPanel(test); + + await pushPref("intl.l10n.pseudo", "bidi"); + info("Running " + test.desc + " in RTL mode"); + await testPlacesPanel(test); + await popPref(); + + // Remove tabs created by sub-tests. + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + } +}); + +async function testPlacesPanel(testInfo) { + await testInfo.init(); + + let promise = new Promise(resolve => { + sidebar.addEventListener( + "load", + function () { + executeSoon(async function () { + testInfo.prepare(); + + let tree = sidebar.contentDocument.getElementById(testInfo.treeName); + + // Select the inserted places item. + await testInfo.selectNode(tree); + + let promiseAlert = promiseAlertDialogObserved(); + + synthesizeClickOnSelectedTreeCell(tree); + // Now, wait for the observer to catch the alert dialog. + // If something goes wrong, the test will time out at this stage. + // Note that for the history sidebar, the URL itself is not opened, + // and Places will show the load-js-data-url-error prompt as an alert + // box, which means that the click actually worked, so it's good enough + // for the purpose of this test. + + await promiseAlert; + + executeSoon(async function () { + SidebarUI.hide(); + await testInfo.cleanup(); + resolve(); + }); + }); + }, + { capture: true, once: true } + ); + }); + + SidebarUI.show(testInfo.sidebarName); + + return promise; +} + +function promiseAlertDialogObserved() { + return new Promise(resolve => { + async function observer(subject) { + info("alert dialog observed as expected"); + Services.obs.removeObserver(observer, "common-dialog-loaded"); + Services.obs.removeObserver(observer, "tabmodal-dialog-loaded"); + + if (subject.Dialog) { + subject.Dialog.ui.button0.click(); + } else { + subject.querySelector(".tabmodalprompt-button0").click(); + } + resolve(); + } + Services.obs.addObserver(observer, "common-dialog-loaded"); + Services.obs.addObserver(observer, "tabmodal-dialog-loaded"); + }); +} diff --git a/browser/components/places/tests/browser/browser_sort_in_library.js b/browser/components/places/tests/browser/browser_sort_in_library.js new file mode 100644 index 0000000000..2eea3a3f31 --- /dev/null +++ b/browser/components/places/tests/browser/browser_sort_in_library.js @@ -0,0 +1,248 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests the following bugs: + * + * Bug 443745 - View>Sort>of "alpha" sort items is default to Z>A instead of A>Z + * https://bugzilla.mozilla.org/show_bug.cgi?id=443745 + * + * Bug 444179 - Library>Views>Sort>Sort by Tags does nothing + * https://bugzilla.mozilla.org/show_bug.cgi?id=444179 + * + * Basically, fully tests sorting the placeContent tree in the Places Library + * window. Sorting is verified by comparing the nsINavHistoryResult returned by + * placeContent.result to the expected sort values. + */ + +// Two properties of nsINavHistoryResult control the sort of the tree: +// sortingMode. sortingMode's value is one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants. +// +// This lookup table maps the possible values of anonid's of the treecols to +// objects that represent the treecols' correct state after the user sorts the +// previously unsorted tree by selecting a column from the Views > Sort menu. +// sortingMode is constructed from the key and dir properties (i.e., +// SORT_BY_<key>_<dir>). +const SORT_LOOKUP_TABLE = { + title: { key: "TITLE", dir: "ASCENDING" }, + tags: { key: "TAGS", dir: "ASCENDING" }, + url: { key: "URI", dir: "ASCENDING" }, + date: { key: "DATE", dir: "DESCENDING" }, + visitCount: { key: "VISITCOUNT", dir: "DESCENDING" }, + dateAdded: { key: "DATEADDED", dir: "DESCENDING" }, + lastModified: { key: "LASTMODIFIED", dir: "DESCENDING" }, +}; + +// This is the column that's sorted if one is not specified and the tree is +// currently unsorted. Set it to a key substring in the name of one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants, e.g., "TITLE", "URI". +// Method ViewMenu.setSortColumn in browser/components/places/content/places.js +// determines this value. +const DEFAULT_SORT_KEY = "TITLE"; + +// Part of the test is checking that sorts stick, so each time we sort we need +// to remember it. +var prevSortDir = null; +var prevSortKey = null; + +/** + * Ensures that the sort of aTree is aSortingMode + * + * @param {object} aTree + * the tree to check + * @param {Ci.nsINavHistoryQueryOptions} aSortingMode + * one of the Ci.nsINavHistoryQueryOptions.SORT_BY_* constants + */ +function checkSort(aTree, aSortingMode) { + // The placeContent tree's sort is determined by the nsINavHistoryResult it + // stores. Get it and check that the sort is what the caller expects. + let res = aTree.result; + isnot(res, null, "sanity check: placeContent.result should not return null"); + + // Check sortingMode. + is( + res.sortingMode, + aSortingMode, + "column should now have sortingMode " + aSortingMode + ); +} + +/** + * Sets the sort of aTree. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aTree + * the tree to sort + * @param {boolean} aUnsortFirst + * true if the sort should be set to SORT_BY_NONE before sorting by aCol + * and aDir + * @param {boolean} aShouldFail + * true if setSortColumn should fail on aCol or aDir + * @param {object} aCol + * the column of aTree by which to sort + * @param {string} aDir + * either "ascending" or "descending" + */ +function setSort(aOrganizerWin, aTree, aUnsortFirst, aShouldFail, aCol, aDir) { + if (aUnsortFirst) { + aOrganizerWin.ViewMenu.setSortColumn(); + checkSort(aTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); + + // Remember the sort key and direction. + prevSortKey = null; + prevSortDir = null; + } + + let failed = false; + try { + aOrganizerWin.ViewMenu.setSortColumn(aCol, aDir); + + // Remember the sort key and direction. + if (!aCol && !aDir) { + prevSortKey = null; + prevSortDir = null; + } else { + if (aCol) { + prevSortKey = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].key; + } else if (prevSortKey === null) { + prevSortKey = DEFAULT_SORT_KEY; + } + + if (aDir) { + prevSortDir = aDir.toUpperCase(); + } else if (prevSortDir === null) { + prevSortDir = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].dir; + } + } + } catch (exc) { + failed = true; + } + + is( + failed, + !!aShouldFail, + "setSortColumn on column " + + (aCol ? aCol.getAttribute("anonid") : "(no column)") + + " with direction " + + (aDir || "(no direction)") + + " and table previously " + + (aUnsortFirst ? "unsorted" : "sorted") + + " should " + + (aShouldFail ? "" : "not ") + + "fail" + ); +} + +/** + * Tries sorting by an invalid column and sort direction. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + */ +function testInvalid(aOrganizerWin, aPlaceContentTree) { + // Invalid column should fail by throwing an exception. + let bogusCol = document.createXULElement("treecol"); + bogusCol.setAttribute("anonid", "bogusColumn"); + setSort(aOrganizerWin, aPlaceContentTree, true, true, bogusCol, "ascending"); + + // Invalid direction reverts to SORT_BY_NONE. + setSort(aOrganizerWin, aPlaceContentTree, false, false, null, "bogus dir"); + checkSort(aPlaceContentTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); +} + +/** + * Tests sorting aPlaceContentTree by column only and then by both column + * and direction. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param {boolean} aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByColAndDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + let cols = aPlaceContentTree.getElementsByTagName("treecol"); + ok(!!cols.length, "sanity check: placeContent should contain columns"); + + for (let i = 0; i < cols.length; i++) { + let col = cols.item(i); + ok( + col.hasAttribute("anonid"), + "sanity check: column " + col.id + " should have anonid" + ); + + let colId = col.getAttribute("anonid"); + ok( + colId in SORT_LOOKUP_TABLE, + "sanity check: unexpected placeContent column anonid" + ); + + let sortStr = + "SORT_BY_" + + SORT_LOOKUP_TABLE[colId].key + + "_" + + (aUnsortFirst ? SORT_LOOKUP_TABLE[colId].dir : prevSortDir); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + + // Test sorting by only a column. + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col); + checkSort(aPlaceContentTree, expectedSortMode); + + // Test sorting by both a column and a direction. + ["ascending", "descending"].forEach(function (dir) { + sortStr = + "SORT_BY_" + SORT_LOOKUP_TABLE[colId].key + "_" + dir.toUpperCase(); + expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col, dir); + checkSort(aPlaceContentTree, expectedSortMode); + }); + } +} + +/** + * Tests sorting aPlaceContentTree by direction only. + * + * @param {object} aOrganizerWin + * the Places window + * @param {object} aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param {boolean} aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + ["ascending", "descending"].forEach(function (dir) { + let key = aUnsortFirst ? DEFAULT_SORT_KEY : prevSortKey; + let sortStr = "SORT_BY_" + key + "_" + dir.toUpperCase(); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortStr]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, null, dir); + checkSort(aPlaceContentTree, expectedSortMode, ""); + }); +} + +function test() { + waitForExplicitFinish(); + + openLibrary(function (win) { + let tree = win.document.getElementById("placeContent"); + isnot(tree, null, "sanity check: placeContent tree should exist"); + // Run the tests. + testSortByColAndDir(win, tree, true); + testSortByColAndDir(win, tree, false); + testSortByDir(win, tree, true); + testSortByDir(win, tree, false); + testInvalid(win, tree); + // Reset the sort to SORT_BY_NONE. + setSort(win, tree, false, false); + // Close the window and finish. + win.close(); + finish(); + }); +} diff --git a/browser/components/places/tests/browser/browser_stayopenmenu.js b/browser/components/places/tests/browser/browser_stayopenmenu.js new file mode 100644 index 0000000000..ee2eb8bbd4 --- /dev/null +++ b/browser/components/places/tests/browser/browser_stayopenmenu.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Menus should stay open (if pref is set) after ctrl-click, middle-click, +// and contextmenu's "Open in a new tab" click. + +async function locateBookmarkAndTestCtrlClick(menupopup) { + let testMenuitem = [...menupopup.children].find( + node => node.label == "Test1" + ); + ok(testMenuitem, "Found test bookmark."); + ok(BrowserTestUtils.is_visible(testMenuitem), "Should be visible"); + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(testMenuitem, { accelKey: true }); + let newTab = await promiseTabOpened; + ok(true, "Bookmark ctrl-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + return testMenuitem; +} + +async function testContextmenu(menuitem) { + let doc = menuitem.ownerDocument; + let cm = doc.getElementById("placesContext"); + let promiseEvent = BrowserTestUtils.waitForEvent(cm, "popupshown"); + EventUtils.synthesizeMouseAtCenter(menuitem, { + type: "contextmenu", + button: 2, + }); + await promiseEvent; + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + let hidden = BrowserTestUtils.waitForEvent(cm, "popuphidden"); + cm.activateItem(doc.getElementById("placesContext_open:newtab")); + await hidden; + let newTab = await promiseTabOpened; + return newTab; +} + +add_setup(async function () { + // Ensure BMB is available in UI. + let origBMBlocation = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ); + if (!origBMBlocation) { + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR + ); + } + + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.openInTabClosesMenu", false]], + }); + // Ensure menubar visible. + let menubar = document.getElementById("toolbar-menubar"); + let menubarVisible = isToolbarVisible(menubar); + if (!menubarVisible) { + setToolbarVisibility(menubar, true); + info("Menubar made visible"); + } + // Ensure Bookmarks Toolbar Visible. + let toolbar = document.getElementById("PersonalToolbar"); + let toolbarHidden = toolbar.collapsed; + if (toolbarHidden) { + await promiseSetToolbarVisibility(toolbar, true); + info("Bookmarks toolbar made visible"); + } + // Create our test bookmarks. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: "http://example.com/", + title: "Test1", + }); + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "TEST_TITLE", + index: 0, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + url: "http://example.com/", + title: "Test1", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + // if BMB was not originally in UI, remove it. + if (!origBMBlocation) { + CustomizableUI.removeWidgetFromArea("bookmarks-menu-button"); + } + // Restore menubar to original visibility. + setToolbarVisibility(menubar, menubarVisible); + // Restore original bookmarks toolbar visibility. + if (toolbarHidden) { + await promiseSetToolbarVisibility(toolbar, false); + } + }); +}); + +add_task(async function testStayopenBookmarksClicks() { + // Test Bookmarks Menu Button stayopen clicks - Ctrl-click. + let BMB = document.getElementById("bookmarks-menu-button"); + let BMBpopup = document.getElementById("BMB_bookmarksPopup"); + let promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BMB, {}); + await promiseEvent; + info("Popupshown on Bookmarks-Menu-Button"); + var menuitem = await locateBookmarkAndTestCtrlClick(BMBpopup); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + + // Test Bookmarks Menu Button stayopen clicks: middle-click. + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + let newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + + // Test Bookmarks Menu Button stayopen clicks - 'Open in new tab' on context menu. + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark contextmenu opened new tab."); + ok(BMB.open, "Bookmarks Menu Button's Popup should still be open."); + promiseEvent = BrowserTestUtils.waitForEvent(BMBpopup, "popuphidden"); + BMB.open = false; + await promiseEvent; + info("Closing menu"); + BrowserTestUtils.removeTab(newTab); + + // Test App Menu's Bookmarks Library stayopen clicks. + let appMenu = document.getElementById("PanelUI-menu-button"); + let appMenuPopup = document.getElementById("appMenu-popup"); + let PopupShownPromise = BrowserTestUtils.waitForEvent( + appMenuPopup, + "popupshown" + ); + appMenu.click(); + await PopupShownPromise; + + let BMview; + document.getElementById("appMenu-bookmarks-button").click(); + BMview = document.getElementById("PanelUI-bookmarks"); + let promise = BrowserTestUtils.waitForEvent(BMview, "ViewShown"); + await promise; + info("Bookmarks panel shown."); + + // Test App Menu's Bookmarks Library stayopen clicks: Ctrl-click. + let menu = document.getElementById("panelMenu_bookmarksMenu"); + var testMenuitem = await locateBookmarkAndTestCtrlClick(menu); + ok(appMenu.open, "Menu should remain open."); + + // Test App Menu's Bookmarks Library stayopen clicks: middle-click. + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(testMenuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok( + PanelView.forNode(BMview).active, + "Should still show the bookmarks subview" + ); + ok(appMenu.open, "Menu should remain open."); + + // Close the App Menu + appMenuPopup.hidePopup(); + ok(!appMenu.open, "The menu should now be closed."); + + // Disable the rest of the tests on Mac due to Mac's handling of menus being + // slightly different to the other platforms. + if (AppConstants.platform === "macosx") { + return; + } + + // Test Bookmarks Menu (menubar) stayopen clicks: Ctrl-click. + let BM = document.getElementById("bookmarksMenu"); + let BMpopup = document.getElementById("bookmarksMenuPopup"); + promiseEvent = BrowserTestUtils.waitForEvent(BMpopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(BM, {}); + await promiseEvent; + info("Popupshowing on Bookmarks Menu"); + menuitem = await locateBookmarkAndTestCtrlClick(BMpopup); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + + // Test Bookmarks Menu (menubar) stayopen clicks: middle-click. + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok(true, "Bookmark middle-click opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + + // Test Bookmarks Menu (menubar) stayopen clicks: 'Open in new tab' on context menu. + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark contextmenu opened new tab."); + BrowserTestUtils.removeTab(newTab); + ok(BM.open, "Bookmarks Menu's Popup should still be open."); + promiseEvent = BrowserTestUtils.waitForEvent(BMpopup, "popuphidden"); + BM.open = false; + await promiseEvent; + + // Test Bookmarks Toolbar stayopen clicks - Ctrl-click. + let BT = document.getElementById("PlacesToolbarItems"); + let toolbarbutton = BT.firstElementChild; + ok(toolbarbutton, "Folder should be first item on Bookmarks Toolbar."); + let buttonMenupopup = toolbarbutton.firstElementChild; + ok( + buttonMenupopup.tagName == "menupopup", + "Found toolbar button's menupopup." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + menuitem = buttonMenupopup.firstElementChild.nextElementSibling; + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { ctrlKey: true }); + newTab = await promiseTabOpened; + ok( + true, + "Bookmark in folder on bookmark's toolbar ctrl-click opened new tab." + ); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); + + // Test Bookmarks Toolbar stayopen clicks: middle-click. + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null); + EventUtils.synthesizeMouseAtCenter(menuitem, { button: 1 }); + newTab = await promiseTabOpened; + ok( + true, + "Bookmark in folder on Bookmarks Toolbar middle-click opened new tab." + ); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); + + // Test Bookmarks Toolbar stayopen clicks: 'Open in new tab' on context menu. + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(toolbarbutton, {}); + await promiseEvent; + ok(true, "Bookmarks toolbar folder's popup is open."); + newTab = await testContextmenu(menuitem); + ok(true, "Bookmark on Bookmarks Toolbar contextmenu opened new tab."); + ok( + toolbarbutton.open, + "Popup of folder on bookmark's toolbar should still be open." + ); + promiseEvent = BrowserTestUtils.waitForEvent(buttonMenupopup, "popuphidden"); + toolbarbutton.open = false; + await promiseEvent; + BrowserTestUtils.removeTab(newTab); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js b/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js new file mode 100644 index 0000000000..9815bd595d --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_bookmarklet.js @@ -0,0 +1,200 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const sandbox = sinon.createSandbox(); + +const URL1 = "https://example.com/1/"; +const URL2 = "https://example.com/2/"; +const BOOKMARKLET_URL = `javascript: (() => {alert('Hello, World!');})();`; +let bookmarks; + +registerCleanupFunction(async function () { + sandbox.restore(); +}); + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + await PlacesUtils.bookmarks.eraseEverything(); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + Assert.ok(placesItems, "PlacesToolbarItems should not be null"); + + /** + * Simulates a drop of a bookmarklet URI onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + */ + let simulateDragDrop = async function (aEffect) { + info("Simulates drag/drop of a new javascript:URL to the bookmarks"); + await withBookmarksDialog( + true, + function openDialog() { + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: "text/x-moz-url", data: BOOKMARKLET_URL }]], + aEffect, + window + ); + }, + async function testNameField(dialogWin) { + info("Checks that there is a javascript:URL in ShowBookmarksDialog"); + + let location = dialogWin.document.getElementById( + "editBMPanel_locationField" + ).value; + + Assert.equal( + location, + BOOKMARKLET_URL, + "Should have opened the ShowBookmarksDialog with the correct bookmarklet url to be bookmarked" + ); + } + ); + + info("Simulates drag/drop of a new URL to the bookmarks"); + let spy = sandbox + .stub(PlacesUIUtils, "showBookmarkDialog") + .returns(Promise.resolve()); + + let promise = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url == URL1) + ); + + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: "text/x-moz-url", data: URL1 }]], + aEffect, + window + ); + + await promise; + Assert.ok(spy.notCalled, "ShowBookmarksDialog on drop not called for url"); + sandbox.restore(); + }; + + let effects = ["copy", "link"]; + for (let effect of effects) { + await simulateDragDrop(effect); + } + + info("Move of existing bookmark / bookmarklet on toolbar"); + // Clean previous bookmarks to ensure right ids count. + await PlacesUtils.bookmarks.eraseEverything(); + + info("Insert list of bookamrks to have bookmarks (ids) for moving"); + // Ensure bookmarks are visible on the toolbar. + let promiseBookmarksOnToolbar = BrowserTestUtils.waitForMutationCondition( + placesItems, + { childList: true }, + () => placesItems.childNodes.length == 3 + ); + bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: [ + { + title: "bm1", + url: URL1, + }, + { + title: "bm2", + url: URL2, + }, + { + title: "bookmarklet", + url: BOOKMARKLET_URL, + }, + ], + }); + await promiseBookmarksOnToolbar; + + let spy = sandbox + .stub(PlacesUIUtils, "showBookmarkDialog") + .returns(Promise.resolve()); + + info("Moving existing Bookmark from position [1] to [0] on Toolbar"); + let urlMoveNotification = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => + e.parentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldParentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldIndex == 1 && + e.index == 0 + ) + ); + + EventUtils.synthesizeDrop( + placesItems, + placesItems.childNodes[0], + [ + [ + { + type: "text/x-moz-place", + data: PlacesUtils.wrapNode( + placesItems.childNodes[1]._placesNode, + "text/x-moz-place" + ), + }, + ], + ], + "move", + window + ); + + await urlMoveNotification; + Assert.ok(spy.notCalled, "ShowBookmarksDialog not called on move for url"); + + info("Moving existing Bookmarklet from position [2] to [1] on Toolbar"); + let bookmarkletMoveNotificatio = PlacesTestUtils.waitForNotification( + "bookmark-moved", + events => + events.some( + e => + e.parentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldParentGuid === PlacesUtils.bookmarks.toolbarGuid && + e.oldIndex == 2 && + e.index == 1 + ) + ); + + EventUtils.synthesizeDrop( + toolbar, + placesItems.childNodes[1], + [ + [ + { + type: "text/x-moz-place", + data: PlacesUtils.wrapNode( + placesItems.childNodes[2]._placesNode, + "text/x-moz-place" + ), + }, + ], + ], + "move", + window + ); + + await bookmarkletMoveNotificatio; + Assert.ok(spy.notCalled, "ShowBookmarksDialog not called on move for url"); + sandbox.restore(); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js new file mode 100644 index 0000000000..1f958989cf --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_flavors.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Check flavors priority when dropping a url/title tuple on the toolbar. + +function getDataForType(url, title, type) { + switch (type) { + case "text/x-moz-url": + return `${url}\n${title}`; + case "text/plain": + return url; + case "text/html": + return `<a href="${url}">${title}</a>`; + } + throw new Error("Unknown mime type"); +} + +async function testDragDrop(effect, mimeTypes) { + const url = "https://www.mozilla.org/drag_drop_test/"; + const title = "Drag & Drop Test"; + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(e => e.url == url) + ); + + // Ensure there's no bookmark initially + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(!bookmark, "There should not be a bookmark to the given URL"); + + // We use the toolbar as the drag source, as we just need almost any node + // to simulate the drag. + let toolbar = document.getElementById("PersonalToolbar"); + EventUtils.synthesizeDrop( + toolbar, + document.getElementById("PlacesToolbarItems"), + [mimeTypes.map(type => ({ type, data: getDataForType(url, title, type) }))], + effect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark. + bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(bookmark, "There should be exactly one bookmark"); + Assert.equal(bookmark.url, url, "Check bookmark URL is correct"); + Assert.equal(bookmark.title, title, "Check bookmark title was preserved"); + await PlacesUtils.bookmarks.remove(bookmark); +} + +add_task(async function test() { + registerCleanupFunction(() => PlacesUtils.bookmarks.eraseEverything()); + + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + + // Test a bookmark drop for all of the mime types and effects. + let mimeTypes = ["text/plain", "text/html", "text/x-moz-url"]; + let effects = ["move", "copy", "link"]; + for (let effect of effects) { + await testDragDrop(effect, mimeTypes); + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js new file mode 100644 index 0000000000..729e456ca0 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_multiple_with_bookmarklet.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + await PlacesUtils.bookmarks.eraseEverything(); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + Assert.ok(placesItems, "PlacesToolbarItems should not be null"); + let simulateDragDrop = async function (aEffect, aMimeType) { + let urls = [ + "https://example.com/1/", + `javascript: (() => {alert('Hello, World!');})();`, + "https://example.com/2/", + ]; + + let data = urls.map(spec => spec + "\n" + spec).join("\n"); + + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data }]], + aEffect, + window + ); + await PlacesTestUtils.promiseAsyncUpdates(); + for (let url of urls) { + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(!bookmark, "There should be no bookmark"); + } + }; + + // Simulate a bookmark drop for all of the mime types and effects. + let mimeType = ["text/x-moz-url"]; + let effects = ["copy", "link"]; + for (let effect of effects) { + await simulateDragDrop(effect, mimeType); + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_drop_text.js b/browser/components/places/tests/browser/browser_toolbar_drop_text.js new file mode 100644 index 0000000000..3e3ff84b39 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_drop_text.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +// Instead of loading EventUtils.js into the test scope in browser-test.js for all tests, +// we only need EventUtils.js for a few files which is why we are using loadSubScript. +var EventUtils = {}; +Services.scriptloader.loadSubScript( + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", + EventUtils +); + +add_task(async function test() { + // Make sure the bookmarks bar is visible and restore its state on cleanup. + let toolbar = document.getElementById("PersonalToolbar"); + ok(toolbar, "PersonalToolbar should not be null"); + + if (toolbar.collapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(function () { + return promiseSetToolbarVisibility(toolbar, false); + }); + } + + // Setup the node we will use to be dropped. The actual node used does not + // matter because we will set its data, effect, and mimeType manually. + let placesItems = document.getElementById("PlacesToolbarItems"); + ok(placesItems, "PlacesToolbarItems should not be null"); + ok( + placesItems.localName == "scrollbox", + "PlacesToolbarItems should not be null" + ); + + /** + * Simulates a drop of a URI onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + * @param {string} aMimeType + * The mime type to use for the drop operation. + */ + let simulateDragDrop = async function (aEffect, aMimeType) { + const url = "http://www.mozilla.org/D1995729-A152-4e30-8329-469B01F30AA7"; + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url: eventUrl }) => eventUrl == url) + ); + + // We use the toolbar as the drag source, as we just need almost any node + // to simulate the drag. The actual data for the drop is passed via the + // drag data. Note: The toolbar is used rather than another bookmark node, + // as we need something that is immovable from a places perspective, as this + // forces the move into a copy. + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data: url }]], + aEffect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark. + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.ok(bookmark, "There should be exactly one bookmark"); + + await PlacesUtils.bookmarks.remove(bookmark.guid); + + // Verify that we removed the bookmark successfully. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ url }), + null, + "URI should be removed" + ); + }; + + /** + * Simulates a drop of multiple URIs onto the bookmarks bar. + * + * @param {string} aEffect + * The effect to use for the drop operation: move, copy, or link. + * @param {string} aMimeType + * The mime type to use for the drop operation. + */ + let simulateDragDropMultiple = async function (aEffect, aMimeType) { + const urls = [ + "http://www.mozilla.org/C54263C6-A484-46CF-8E2B-FE131586348A", + "http://www.mozilla.org/71381257-61E6-4376-AF7C-BF3C5FD8870D", + "http://www.mozilla.org/091A88BD-5743-4C16-A005-3D2EA3A3B71E", + ]; + let data; + if (aMimeType == "text/x-moz-url") { + data = urls.map(spec => spec + "\n" + spec).join("\n"); + } else { + data = urls.join("\n"); + } + + let promiseItemAddedNotification = PlacesTestUtils.waitForNotification( + "bookmark-added", + events => events.some(({ url }) => url == urls[2]) + ); + + // See notes for EventUtils.synthesizeDrop in simulateDragDrop(). + EventUtils.synthesizeDrop( + toolbar, + placesItems, + [[{ type: aMimeType, data }]], + aEffect, + window + ); + + await promiseItemAddedNotification; + + // Verify that the drop produces exactly one bookmark per each URL. + for (let url of urls) { + let bookmark = await PlacesUtils.bookmarks.fetch({ url }); + Assert.equal( + typeof bookmark, + "object", + "There should be exactly one bookmark" + ); + + await PlacesUtils.bookmarks.remove(bookmark.guid); + + // Verify that we removed the bookmark successfully. + Assert.equal( + await PlacesUtils.bookmarks.fetch({ url }), + null, + "URI should be removed" + ); + } + }; + + // Simulate a bookmark drop for all of the mime types and effects. + let mimeTypes = ["text/plain", "text/x-moz-url"]; + let effects = ["move", "copy", "link"]; + for (let effect of effects) { + for (let mimeType of mimeTypes) { + await simulateDragDrop(effect, mimeType); + await simulateDragDropMultiple(effect, mimeType); + } + } +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js b/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js new file mode 100644 index 0000000000..5bedd02a83 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_library_open_recent.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that recently added bookmarks can be opened. + */ + +const BASE_URL = + "http://example.org/browser/browser/components/places/tests/browser/"; +const bookmarkItems = [ + { + url: `${BASE_URL}bookmark_dummy_1.html`, + title: "Custom Title 1", + }, + { + url: `${BASE_URL}bookmark_dummy_2.html`, + title: "Custom Title 2", + }, +]; +let openedTabs = []; + +async function openBookmarksPanelInLibraryToolbarButton() { + let libraryBtn = document.getElementById("library-button"); + libraryBtn.click(); + let libView = document.getElementById("appMenu-libraryView"); + let viewShownPromise = BrowserTestUtils.waitForEvent(libView, "ViewShown"); + await viewShownPromise; + + let bookmarksButton; + await TestUtils.waitForCondition(() => { + bookmarksButton = document.getElementById( + "appMenu-library-bookmarks-button" + ); + return bookmarksButton; + }, "Should have the library bookmarks button"); + bookmarksButton.click(); + + let BookmarksView = document.getElementById("PanelUI-bookmarks"); + let viewRecentPromise = BrowserTestUtils.waitForEvent( + BookmarksView, + "ViewShown" + ); + await viewRecentPromise; +} + +async function openBookmarkedItemInNewTab(itemFromMenu) { + let placesContext = document.getElementById("placesContext"); + let openContextMenuPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(itemFromMenu, { + button: 2, + type: "contextmenu", + }); + await openContextMenuPromise; + info("Opened context menu"); + + let tabCreatedPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + let openInNewTabOption = document.getElementById("placesContext_open:newtab"); + placesContext.activateItem(openInNewTabOption); + info("Click open in new tab"); + + let lastOpenedTab = await tabCreatedPromise; + Assert.equal( + lastOpenedTab.linkedBrowser.currentURI.spec, + itemFromMenu._placesNode.uri, + "Should have opened the correct URI" + ); + openedTabs.push(lastOpenedTab); +} + +async function closeLibraryMenu() { + let libView = document.getElementById("appMenu-libraryView"); + let viewHiddenPromise = BrowserTestUtils.waitForEvent(libView, "ViewHiding"); + EventUtils.synthesizeKey("KEY_Escape", {}, window); + await viewHiddenPromise; +} + +async function closeTabs() { + for (var i = 0; i < openedTabs.length; i++) { + await gBrowser.removeTab(openedTabs[i]); + } + Assert.equal(gBrowser.tabs.length, 1, "Should close all opened tabs"); +} + +async function getRecentlyBookmarkedItems() { + let historyMenu = document.getElementById("panelMenu_bookmarksMenu"); + let items = historyMenu.querySelectorAll("toolbarbutton"); + Assert.ok(items, "Recently bookmarked items should exists"); + + await TestUtils.waitForCondition( + () => items[0].attributes !== "undefined", + "Custom bookmark exists" + ); + + if (items) { + Assert.equal( + items[0]._placesNode.uri, + bookmarkItems[1].url, + "Should match the expected url" + ); + Assert.equal( + items[0].getAttribute("label"), + bookmarkItems[1].title, + "Should be the expected title" + ); + Assert.equal( + items[1]._placesNode.uri, + bookmarkItems[0].url, + "Should match the expected url" + ); + Assert.equal( + items[1].getAttribute("label"), + bookmarkItems[0].title, + "Should be the expected title" + ); + } + return Array.from(items).slice(0, 2); +} + +add_setup(async function () { + let libraryButton = CustomizableUI.getPlacementOfWidget("library-button"); + if (!libraryButton) { + CustomizableUI.addWidgetToArea( + "library-button", + CustomizableUI.AREA_NAVBAR + ); + } + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarkItems, + }); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + CustomizableUI.reset(); + }); +}); + +add_task(async function test_recently_added() { + await openBookmarksPanelInLibraryToolbarButton(); + + let historyItems = await getRecentlyBookmarkedItems(); + + for (let item of historyItems) { + await openBookmarkedItemInNewTab(item); + } + + await closeLibraryMenu(); + + registerCleanupFunction(async () => { + await closeTabs(); + }); +}); diff --git a/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js b/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js new file mode 100644 index 0000000000..89171deb1f --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_other_bookmarks.js @@ -0,0 +1,601 @@ +/* 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-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const bookmarksInfo = [ + { + title: "firefox", + url: "http://example.com", + }, + { + title: "rules", + url: "http://example.com/2", + }, + { + title: "yo", + url: "http://example.com/2", + }, +]; + +/** + * Test showing the "Other Bookmarks" folder in the bookmarks toolbar. + */ + +// Setup. +add_setup(async function () { + // Disable window occlusion. See bug 1733955 / bug 1779559. + if (navigator.platform.indexOf("Win") == 0) { + await SpecialPowers.pushPrefEnv({ + set: [["widget.windows.window_occlusion_tracking.enabled", false]], + }); + } + + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + await setupBookmarksToolbar(); + + // Cleanup. + registerCleanupFunction(async () => { + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Test the "Other Bookmarks" folder is shown in the toolbar when +// bookmarks are stored under that folder. +add_task(async function testShowingOtherBookmarksInToolbar() { + info("Check the initial state of the Other Bookmarks folder."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await setupBookmarksToolbar(win); + ok( + !win.document.getElementById("OtherBookmarks"), + "Shouldn't have an Other Bookmarks button." + ); + await BrowserTestUtils.closeWindow(win); + + info("Check visibility of an empty Other Bookmarks folder."); + await testIsOtherBookmarksHidden(true); + + info("Ensure folder appears in toolbar when a new bookmark is added."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + await testIsOtherBookmarksHidden(false); + + info("Ensure folder disappears from toolbar when no bookmarks are present."); + await PlacesUtils.bookmarks.remove(bookmarks); + await testIsOtherBookmarksHidden(true); +}); + +// Test that folder visibility is correct when moving bookmarks to an empty +// "Other Bookmarks" folder and vice versa. +add_task(async function testOtherBookmarksVisibilityWhenMovingBookmarks() { + info("Add bookmarks to Bookmarks Toolbar."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarksInfo, + }); + await testIsOtherBookmarksHidden(true); + + info("Move toolbar bookmarks to Other Bookmarks folder."); + await PlacesUtils.bookmarks.moveToFolder( + bookmarks.map(b => b.guid), + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.DEFAULT_INDEX + ); + await testIsOtherBookmarksHidden(false); + + info("Move bookmarks from Other Bookmarks back to the toolbar."); + await PlacesUtils.bookmarks.moveToFolder( + bookmarks.map(b => b.guid), + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.DEFAULT_INDEX + ); + await testIsOtherBookmarksHidden(true); +}); + +// Test OtherBookmarksPopup in toolbar. +add_task(async function testOtherBookmarksMenuPopup() { + info("Add bookmarks to Other Bookmarks folder."); + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + info("Check the popup menu has correct number of children."); + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 3); + await closeMenuPopup("#OtherBookmarksPopup"); + + info("Remove a bookmark."); + await PlacesUtils.bookmarks.remove(bookmarks[0]); + + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 2); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +// Test that folders in the Other Bookmarks folder expand +add_task(async function testFolderPopup() { + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: [ + { + title: "example", + url: "http://example.com/3", + }, + ], + }, + ], + }); + + info("Check for popup showing event when folder menuitem is selected."); + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + await openMenuPopup( + "#OtherBookmarksPopup menu menupopup", + "#OtherBookmarksPopup menu" + ); + ok(true, "Folder menu stored in Other Bookmarks expands."); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup menu menupopup", 1); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +add_task(async function testOnlyShowOtherFolderInBookmarksToolbar() { + await setupBookmarksToolbar(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + // Test that moving the personal-bookmarks widget out of the + // Bookmarks Toolbar will hide the "Other Bookmarks" folder. + let widgetId = "personal-bookmarks"; + CustomizableUI.addWidgetToArea(widgetId, CustomizableUI.AREA_NAVBAR); + await testIsOtherBookmarksHidden(true); + + CustomizableUI.reset(); + await testIsOtherBookmarksHidden(false); +}); + +// Test that the menu popup updates when menu items are deleted from it while +// it's open. +add_task(async function testDeletingMenuItems() { + await setupBookmarksToolbar(); + + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + + await testIsOtherBookmarksHidden(false); + + await openMenuPopup("#OtherBookmarksPopup", "#OtherBookmarks"); + testNumberOfMenuPopupChildren("#OtherBookmarksPopup", 3); + + info("Open context menu for popup."); + let placesContext = document.getElementById("placesContext"); + let popupEventPromise = BrowserTestUtils.waitForPopupEvent( + placesContext, + "shown" + ); + let menuitem = document.querySelector("#OtherBookmarksPopup menuitem"); + EventUtils.synthesizeMouseAtCenter(menuitem, { type: "contextmenu" }); + await popupEventPromise; + + info("Delete bookmark menu item from popup."); + let deleteMenuBookmark = document.getElementById( + "placesContext_deleteBookmark" + ); + placesContext.activateItem(deleteMenuBookmark); + + await TestUtils.waitForCondition(() => { + let popup = document.querySelector("#OtherBookmarksPopup"); + let items = popup.querySelectorAll("menuitem"); + return items.length === 2; + }, "Failed to delete bookmark menuitem. Expected 2 menu items after deletion."); + ok(true, "Menu item was removed from the popup."); + await closeMenuPopup("#OtherBookmarksPopup"); +}); + +add_task(async function no_errors_when_bookmarks_placed_in_palette() { + CustomizableUI.removeWidgetFromArea("personal-bookmarks"); + + let consoleErrors = 0; + + let errorListener = { + observe(error) { + ok(false, `${error.message}, ${error.stack}, ${JSON.stringify(error)}`); + consoleErrors++; + }, + }; + Services.console.registerListener(errorListener); + + let bookmarks = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: bookmarksInfo, + }); + is(consoleErrors, 0, "There should be no console errors"); + + Services.console.unregisterListener(errorListener); + await PlacesUtils.bookmarks.remove(bookmarks); + CustomizableUI.reset(); +}); + +// Test "Show Other Bookmarks" menu item visibility in toolbar context menu. +add_task(async function testShowingOtherBookmarksContextMenuItem() { + await setupBookmarksToolbar(); + + info("Add bookmark to Other Bookmarks."); + let bookmark = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + + info("'Show Other Bookmarks' menu item should be checked by default."); + await testOtherBookmarksCheckedState(true); + + info("Toggle off showing the Other Bookmarks folder."); + await selectShowOtherBookmarksMenuItem(); + await testOtherBookmarksCheckedState(false); + await testIsOtherBookmarksHidden(true); + + info("Toggle on showing the Other Bookmarks folder."); + await selectShowOtherBookmarksMenuItem(); + await testOtherBookmarksCheckedState(true); + await testIsOtherBookmarksHidden(false); + + info( + "Ensure 'Show Other Bookmarks' isn't shown when Other Bookmarks is empty." + ); + await PlacesUtils.bookmarks.remove(bookmark); + await testIsOtherBookmarksMenuItemEnabled(false); + + info("Add a bookmark to the empty Other Bookmarks folder."); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + await testIsOtherBookmarksMenuItemEnabled(true); + + info( + "Ensure that displaying Other Bookmarks is consistent across separate windows." + ); + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return otherBookmarks && !otherBookmarks.hidden; + }, "Other Bookmarks folder failed to show in other window."); + + info("Hide the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return !otherBookmarks || otherBookmarks.hidden; + }, "Other Bookmarks folder failed to be hidden in other window."); + ok(true, "Other Bookmarks was successfully hidden in other window."); + + info("Show the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem(); + + await TestUtils.waitForCondition(() => { + let otherBookmarks = newWin.document.getElementById("OtherBookmarks"); + return otherBookmarks && !otherBookmarks.hidden; + }, "Other Bookmarks folder failed to be shown in other window."); + ok(true, "Other Bookmarks was successfully shown in other window."); + + await BrowserTestUtils.closeWindow(newWin); +}); + +// Test 'Show Other Bookmarks' isn't shown when pref is false. +add_task(async function showOtherBookmarksMenuItemPrefDisabled() { + await setupBookmarksToolbar(); + await testIsOtherBookmarksMenuItemEnabled(false); +}); + +// Test that node visibility for toolbar overflow is consisten when the "Other Bookmarks" +// folder is shown/hidden. +add_task(async function testOtherBookmarksToolbarOverFlow() { + await setupBookmarksToolbar(); + + info( + "Ensure that visible nodes when showing/hiding Other Bookmarks is consistent across separate windows." + ); + // Add bookmarks to other bookmarks + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [{ title: "firefox", url: "http://example.com" }], + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + // Add bookmarks to the toolbar + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: Array(100) + .fill("") + .map((_, i) => ({ title: `test ${i}`, url: `http:example.com/${i}` })), + }); + + info("Hide the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem("#OtherBookmarks"); + await BrowserTestUtils.waitForEvent( + document.getElementById("PersonalToolbar"), + "BookmarksToolbarVisibilityUpdated" + ); + ok(true, "Nodes successfully updated for both windows."); + await testUpdatedNodeVisibility(newWin); + + info("Show the Other Bookmarks folder from the original window."); + await selectShowOtherBookmarksMenuItem("#PlacesChevron"); + await BrowserTestUtils.waitForEvent( + document.getElementById("PersonalToolbar"), + "BookmarksToolbarVisibilityUpdated" + ); + ok(true, "Nodes successfully updated for both windows."); + await testUpdatedNodeVisibility(newWin); + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * Tests whether or not the "Other Bookmarks" folder is visible. + * + * @param {boolean} expected + * The expected state of the Other Bookmarks folder. There are 3: + * - the folder node isn't initialized and is therefore not visible, + * - the folder node is initialized and is hidden + * - the folder node is initialized and is visible + */ +async function testIsOtherBookmarksHidden(expected) { + info("Test whether or not the 'Other Bookmarks' folder is visible."); + + // Ensure the toolbar is visible. + let toolbar = document.getElementById("PersonalToolbar"); + await promiseSetToolbarVisibility(toolbar, true); + + let otherBookmarks = document.getElementById("OtherBookmarks"); + + await TestUtils.waitForCondition(() => { + otherBookmarks = document.getElementById("OtherBookmarks"); + let isHidden = !otherBookmarks || otherBookmarks.hidden; + return isHidden === expected; + }, "Other Bookmarks folder failed to change hidden state."); + + ok(true, `Other Bookmarks folder "hidden" state should be ${expected}.`); +} + +/** + * Tests number of menu items in Other Bookmarks popup. + * + * @param {string} selector + * The selector for getting the menupopup element we want to test. + * @param {number} expected + * The expected number of menuitem elements inside the menupopup. + */ +function testNumberOfMenuPopupChildren(selector, expected) { + let popup = document.querySelector(selector); + let items = popup.querySelectorAll("menuitem"); + + is( + items.length, + expected, + `Number of menu items for ${selector} should be ${expected}.` + ); +} + +/** + * Test helper for checking the 'checked' state of the "Show Other Bookmarks" menu item + * after selecting it from the context menu. + * + * @param {boolean} expectedCheckedState + * Whether or not the menu item is checked. + */ +async function testOtherBookmarksCheckedState(expectedCheckedState) { + info("Check 'Show Other Bookmarks' menu item state"); + await openToolbarContextMenu(); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + + is( + otherBookmarksMenuItem.getAttribute("checked"), + `${expectedCheckedState}`, + `Other Bookmarks item's checked state should be ${expectedCheckedState}` + ); + + await closeToolbarContextMenu(); +} + +/** + * Test helper for checking whether or not the 'Show Other Bookmarks' menu item + * is enabled in the toolbar's context menu. + * + * @param {boolean} expected + * Whether or not the menu item is enabled in the toolbar conext menu. + */ +async function testIsOtherBookmarksMenuItemEnabled(expected) { + await openToolbarContextMenu(); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + + is( + !otherBookmarksMenuItem.disabled, + expected, + "'Show Other Bookmarks' menu item appearance state is correct." + ); + + await closeToolbarContextMenu(); +} + +/** + * Helper for opening a menu popup. + * + * @param {string} popupSelector + * The selector for the menupopup element we want to open. + * @param {string} targetSelector + * The selector for the element with the popup showing event. + */ +async function openMenuPopup(popupSelector, targetSelector) { + let popup = document.querySelector(popupSelector); + let target = document.querySelector(targetSelector); + + EventUtils.synthesizeMouseAtCenter(target, {}); + + await BrowserTestUtils.waitForPopupEvent(popup, "shown"); +} + +/** + * Helper for closing a menu popup. + * + * @param {string} popupSelector + * The selector for the menupopup element we want to close. + */ +async function closeMenuPopup(popupSelector) { + let popup = document.querySelector(popupSelector); + + info("Closing menu popup."); + popup.hidePopup(); + await BrowserTestUtils.waitForPopupEvent(popup, "hidden"); +} + +/** + * Helper for opening the toolbar context menu. + * + * @param {string} toolbarSelector + * Optional. The selector for the toolbar context menu. + * Defaults to #PlacesToolbarItems. + */ +async function openToolbarContextMenu(toolbarSelector = "#PlacesToolbarItems") { + let contextMenu = document.getElementById("placesContext"); + let toolbar = document.querySelector(toolbarSelector); + let openToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "shown" + ); + + // Use the end of the toolbar because the beginning (and even middle, on + // some resolutions) might be occluded by the empty toolbar message, which + // has a different context menu. + let bounds = toolbar.getBoundingClientRect(); + EventUtils.synthesizeMouse(toolbar, bounds.width - 5, 5, { + type: "contextmenu", + }); + + await openToolbarContextMenuPromise; +} + +/** + * Helper for closing the toolbar context menu. + */ +async function closeToolbarContextMenu() { + let contextMenu = document.getElementById("placesContext"); + let closeToolbarContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "hidden" + ); + contextMenu.hidePopup(); + await closeToolbarContextMenuPromise; +} + +/** + * Helper for setting up the bookmarks toolbar state. This ensures the beginning + * of a task will always have the bookmark toolbar in a state that makes the + * Other Bookmarks folder testable. + * + * @param {object} [win] + * The window object to use. + */ +async function setupBookmarksToolbar(win = window) { + let toolbar = win.document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + await PlacesUtils.bookmarks.eraseEverything(); +} + +/** + * Helper for selecting the "Show Other Bookmarks" menu item from the bookmarks + * toolbar context menu. + * + * @param {string} selector + * Optional. The selector for the node that triggers showing the + * "Show Other Bookmarks" context menu item in the toolbar. + * Defaults to #PlacesToolbarItem when `openToolbarContextMenu` is + * called. + */ +async function selectShowOtherBookmarksMenuItem(selector) { + info("Select 'Show Other Bookmarks' menu item"); + await openToolbarContextMenu(selector); + + let otherBookmarksMenuItem = document.querySelector( + "#show-other-bookmarks_PersonalToolbar" + ); + let contextMenu = document.getElementById("placesContext"); + + contextMenu.activateItem(otherBookmarksMenuItem); + + await BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden"); + await closeToolbarContextMenu(); +} + +/** + * Test helper for node visibility in the bookmarks toolbar between two windows. + * + * @param {Window} otherWin + * The other window whose toolbar items we want to compare with. + */ +function testUpdatedNodeVisibility(otherWin) { + // Get visible toolbar nodes for both the current and other windows. + let toolbarItems = document.getElementById("PlacesToolbarItems"); + let currentVisibleNodes = []; + + for (let node of toolbarItems.children) { + if (node.style.visibility === "visible") { + currentVisibleNodes.push(node); + } + } + + let otherToolbarItems = + otherWin.document.getElementById("PlacesToolbarItems"); + let otherVisibleNodes = []; + + for (let node of otherToolbarItems.children) { + if (node.style.visibility === "visible") { + otherVisibleNodes.push(node); + } + } + + let lastIdx = otherVisibleNodes.length - 1; + + is( + currentVisibleNodes[lastIdx]?.bookmarkGuid, + otherVisibleNodes[lastIdx]?.bookmarkGuid, + "Last visible toolbar bookmark is the same in both windows." + ); +} diff --git a/browser/components/places/tests/browser/browser_toolbar_overflow.js b/browser/components/places/tests/browser/browser_toolbar_overflow.js new file mode 100644 index 0000000000..03d340b799 --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbar_overflow.js @@ -0,0 +1,436 @@ +/* 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/. */ + +/** + * Tests the bookmarks toolbar overflow. + */ + +var gToolbar = document.getElementById("PersonalToolbar"); +var gChevron = document.getElementById("PlacesChevron"); + +const BOOKMARKS_COUNT = 250; + +add_setup(async function () { + let wasCollapsed = gToolbar.collapsed; + await PlacesUtils.bookmarks.eraseEverything(); + + // Add bookmarks. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: Array(BOOKMARKS_COUNT) + .fill("") + .map((_, i) => ({ url: `http://test.places.${i}/` })), + }); + + // Toggle the bookmarks toolbar so that we start from a stable situation and + // are not affected by all bookmarks removal. + await toggleToolbar(false); + await toggleToolbar(true); + + registerCleanupFunction(async () => { + if (wasCollapsed) { + await toggleToolbar(false); + } + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_overflow() { + // Check that the overflow chevron is visible. + Assert.ok(!gChevron.collapsed, "The overflow chevron should be visible"); + let children = getPlacesChildren(); + Assert.ok( + children.length < BOOKMARKS_COUNT, + "Not all the nodes should be built by default" + ); + let visibleNodes = []; + for (let node of children) { + if (getComputedStyle(node).visibility == "visible") { + visibleNodes.push(node); + } + } + Assert.ok( + visibleNodes.length < children.length, + `The number of visible nodes (${visibleNodes.length}) should be smaller than the number of built nodes (${children.length})` + ); + + await test_index( + "Node at the last visible index", + visibleNodes.length - 1, + "visible" + ); + await test_index( + "Node at the first invisible index", + visibleNodes.length, + "hidden" + ); + await test_index("First non-built node", children.length, undefined); + await test_index("Later non-built node", children.length + 1, undefined); + + await test_move_index( + "Move node from last visible to first hidden", + visibleNodes.length - 1, + visibleNodes.length, + "visible", + "hidden" + ); + await test_move_index( + "Move node from fist visible to last built", + 0, + children.length - 1, + "visible", + "hidden" + ); + await test_move_index( + "Move node from fist visible to first non built", + 0, + children.length, + "visible", + undefined + ); +}); + +add_task(async function test_separator_first() { + await toggleToolbar(false); + // Check that if a separator is the first node, we still calculate overflow + // properly. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await toggleToolbar(true, 2); + + let children = getPlacesChildren(); + Assert.greater(children.length, 2, "Multiple elements are visible"); + Assert.equal( + children[1]._placesNode.uri, + "http://test.places.0/", + "Found the first bookmark" + ); + Assert.equal( + getComputedStyle(children[1]).visibility, + "visible", + "The first bookmark is visible" + ); + + await PlacesUtils.bookmarks.remove(bm); +}); + +add_task(async function test_newWindow_noOverflow() { + info( + "Check toolbar in a new widow when it was already visible and not overflowed" + ); + Assert.ok(!gToolbar.collapsed, "Toolbar is not collapsed in original window"); + await PlacesUtils.bookmarks.eraseEverything(); + // Add a single bookmark. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://toolbar.overflow/", + title: "Example", + }); + // Add a favicon for the bookmark. + let favicon = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + await PlacesTestUtils.addFavicons( + new Map([["http://toolbar.overflow/", favicon]]) + ); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + try { + let toolbar = win.document.getElementById("PersonalToolbar"); + Assert.ok(!toolbar.collapsed, "The toolbar is not collapsed"); + let content = win.document.getElementById("PlacesToolbarItems"); + await TestUtils.waitForCondition(() => { + return ( + content.children.length == 1 && + content.children[0].hasAttribute("image") + ); + }); + let chevron = win.document.getElementById("PlacesChevron"); + Assert.ok(chevron.collapsed, "The chevron should be collapsed"); + } finally { + await BrowserTestUtils.closeWindow(win); + } +}); + +async function test_index(desc, index, expected) { + info(desc); + let children = getPlacesChildren(); + let originalLen = children.length; + let nodeExisted = children.length > index; + let previousNodeIsVisible = + nodeExisted && + getComputedStyle(children[index - 1]).visibility == "visible"; + let promise = promiseUpdateVisibility( + expected == "visible" || previousNodeIsVisible + ); + + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://test.places.added/", + index, + }); + Assert.equal(bm.index, index, "Sanity check the bookmark index"); + await promise; + children = getPlacesChildren(); + + if (!expected) { + Assert.ok( + children.length <= index, + "The new node should not have been added" + ); + } else { + Assert.equal( + children[index]._placesNode.bookmarkGuid, + bm.guid, + "Found the added bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[index]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); + + info("Remove the node"); + promise = promiseUpdateVisibility(expected == "visible"); + + await PlacesUtils.bookmarks.remove(bm); + await promise; + children = getPlacesChildren(); + + if (expected && nodeExisted) { + Assert.equal( + children[index]._placesNode.uri, + `http://test.places.${index}/`, + "Found the previous bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[index]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); +} + +async function test_move_index(desc, fromIndex, toIndex, original, expected) { + info(desc); + let children = getPlacesChildren(); + let originalLen = children.length; + let movedGuid = children[fromIndex]._placesNode.bookmarkGuid; + let existingGuid = children[toIndex] + ? children[toIndex]._placesNode.bookmarkGuid + : null; + let existingIndex = fromIndex < toIndex ? toIndex - 1 : toIndex + 1; + + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The bookmark node should be ${original}` + ); + let promise = promiseUpdateVisibility( + original == "visible" || expected == "visible" + ); + + await PlacesUtils.bookmarks.update({ + guid: movedGuid, + index: toIndex, + }); + await promise; + children = getPlacesChildren(); + + if (!expected) { + Assert.ok( + children.length <= toIndex, + "Node in the new position is not expected" + ); + Assert.ok( + children[originalLen - 1], + "We should keep number of built nodes consistent" + ); + } else { + Assert.equal( + children[toIndex]._placesNode.bookmarkGuid, + movedGuid, + "Found the moved bookmark at the expected position" + ); + Assert.equal( + getComputedStyle(children[toIndex]).visibility, + expected, + `The destination bookmark node should be ${expected}` + ); + } + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The origin bookmark node should be ${original}` + ); + if (existingGuid) { + Assert.equal( + children[existingIndex]._placesNode.bookmarkGuid, + existingGuid, + "Found the pushed away bookmark at the expected position" + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); + + info("Moving back the node"); + promise = promiseUpdateVisibility( + original == "visible" || expected == "visible" + ); + + await PlacesUtils.bookmarks.update({ + guid: movedGuid, + index: fromIndex, + }); + await promise; + children = getPlacesChildren(); + + Assert.equal( + children[fromIndex]._placesNode.bookmarkGuid, + movedGuid, + "Found the moved bookmark at the expected position" + ); + if (expected) { + Assert.equal( + getComputedStyle(children[toIndex]).visibility, + expected, + `The bookmark node should be ${expected}` + ); + } + Assert.equal( + getComputedStyle(children[fromIndex]).visibility, + original, + `The bookmark node should be ${original}` + ); + if (existingGuid) { + Assert.equal( + children[toIndex]._placesNode.bookmarkGuid, + existingGuid, + "Found the pushed away bookmark at the expected position" + ); + } + Assert.equal( + children.length, + originalLen, + "Number of built nodes should stay the same" + ); +} + +add_task(async function test_separator_first() { + // Check that if there are only separators, we still show nodes properly. + await toggleToolbar(false); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + index: 0, + }); + await toggleToolbar(true, 2); + + let children = getPlacesChildren(); + Assert.equal(children.length, 2, "The expected elements are visible"); + Assert.equal( + getComputedStyle(children[0]).visibility, + "visible", + "The first bookmark is visible" + ); + Assert.equal( + getComputedStyle(children[1]).visibility, + "visible", + "The second bookmark is visible" + ); + + await toggleToolbar(false); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +/** + * If the passed-in condition is fulfilled, awaits for the toolbar nodes + * visibility to have been updated. + * + * @param {boolean} [condition] Awaits for visibility only if this condition is true. + * @returns {Promise} resolved when the condition is not fulfilled or the + * visilibily update happened. + */ +function promiseUpdateVisibility(condition = true) { + if (condition) { + return BrowserTestUtils.waitForEvent( + gToolbar, + "BookmarksToolbarVisibilityUpdated" + ); + } + return Promise.resolve(); +} + +/** + * Returns an array of toolbar children that are Places nodes, ignoring things + * like the chevron or other additional buttons. + * + * @returns {Array} An array of Places element nodes. + */ +function getPlacesChildren() { + return Array.prototype.filter.call( + document.getElementById("PlacesToolbarItems").children, + c => c._placesNode?.itemId + ); +} + +/** + * Toggles the toolbar on or off. + * + * @param {boolean} show Whether to show or hide the toolbar. + * @param {number} [expectedMinChildCount] Optional number of Places nodes that + * should be visible on the toolbar. + */ +async function toggleToolbar(show, expectedMinChildCount = 0) { + let promiseReady = Promise.resolve(); + if (show) { + promiseReady = promiseUpdateVisibility(); + } + + await promiseSetToolbarVisibility(gToolbar, show); + await promiseReady; + + if (show) { + if (getPlacesChildren().length < expectedMinChildCount) { + await new Promise(resolve => { + info("Waiting for bookmark elements to appear"); + let mut = new MutationObserver(mutations => { + let children = getPlacesChildren(); + info(`${children.length} bookmark elements appeared`); + if (children.length >= expectedMinChildCount) { + resolve(); + mut.disconnect(); + } + }); + mut.observe(document.getElementById("PlacesToolbarItems"), { + childList: true, + }); + }); + } + } +} diff --git a/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js b/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js new file mode 100644 index 0000000000..a87f26caab --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js @@ -0,0 +1,72 @@ +CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 4 +); +var bookmarksMenuButton = document.getElementById("bookmarks-menu-button"); +var BMB_menuPopup = document.getElementById("BMB_bookmarksPopup"); +var BMB_showAllBookmarks = document.getElementById("BMB_bookmarksShowAll"); +var contextMenu = document.getElementById("placesContext"); +var newBookmarkItem = document.getElementById("placesContext_new:bookmark"); + +waitForExplicitFinish(); +add_task(async function testPopup() { + info("Checking popup context menu before moving the bookmarks button"); + await checkPopupContextMenu(); + let pos = CustomizableUI.getPlacementOfWidget( + "bookmarks-menu-button" + ).position; + let target = CustomizableUI.AREA_FIXED_OVERFLOW_PANEL; + CustomizableUI.addWidgetToArea("bookmarks-menu-button", target); + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + pos + ); + info("Checking popup context menu after moving the bookmarks button"); + await checkPopupContextMenu(); + CustomizableUI.reset(); +}); + +async function checkPopupContextMenu() { + let clickTarget = bookmarksMenuButton; + BMB_menuPopup.setAttribute("style", "transition: none;"); + let popupShownPromise = onPopupEvent(BMB_menuPopup, "shown"); + EventUtils.synthesizeMouseAtCenter(clickTarget, {}); + info("Waiting for bookmarks menu to be shown."); + await popupShownPromise; + let contextMenuShownPromise = onPopupEvent(contextMenu, "shown"); + EventUtils.synthesizeMouseAtCenter(BMB_showAllBookmarks, { + type: "contextmenu", + button: 2, + }); + info("Waiting for context menu on bookmarks menu to be shown."); + await contextMenuShownPromise; + ok( + !newBookmarkItem.hasAttribute("disabled"), + "New bookmark item shouldn't be disabled" + ); + let contextMenuHiddenPromise = onPopupEvent(contextMenu, "hidden"); + contextMenu.hidePopup(); + BMB_menuPopup.removeAttribute("style"); + info("Waiting for context menu on bookmarks menu to be hidden."); + await contextMenuHiddenPromise; + let popupHiddenPromise = onPopupEvent(BMB_menuPopup, "hidden"); + // Can't use synthesizeMouseAtCenter because the dropdown panel is in the way + EventUtils.synthesizeKey("KEY_Escape"); + info("Waiting for bookmarks menu to be hidden."); + await popupHiddenPromise; +} + +function onPopupEvent(popup, evt) { + let fullEvent = "popup" + evt; + return new Promise(resolve => { + let onPopupHandler = e => { + if (e.target == popup) { + popup.removeEventListener(fullEvent, onPopupHandler); + resolve(); + } + }; + popup.addEventListener(fullEvent, onPopupHandler); + }); +} diff --git a/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js b/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js new file mode 100644 index 0000000000..02720cfa2e --- /dev/null +++ b/browser/components/places/tests/browser/browser_toolbarbutton_menu_show_in_folder.js @@ -0,0 +1,92 @@ +/** + * This test checks that the Show in Folder context menu item in the + * bookmarks menu under the app menu actually shows the bookmark in + * its folder location in the sidebar. + */ +"use strict"; + +const TEST_PARENT_FOLDER = "The Parent Folder"; +const TEST_URL = "https://example.com/"; +const TEST_TITLE = "Test Bookmark"; + +let appMenuButton = document.getElementById("PanelUI-menu-button"); +let bookmarksAppMenu = document.getElementById("PanelUI-bookmarks"); +let sidebarWasAlreadyOpen = SidebarUI.isOpen; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_task(async function toolbarBookmarkShowInFolder() { + let parentFolder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: TEST_PARENT_FOLDER, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: parentFolder.guid, + url: TEST_URL, + title: TEST_TITLE, + }); + + // Open app menu and select bookmarks view + await gCUITestUtils.openMainMenu(); + let appMenuBookmarks = document.getElementById("appMenu-bookmarks-button"); + appMenuBookmarks.click(); + let bookmarksView = document.getElementById("PanelUI-bookmarks"); + let bmViewPromise = BrowserTestUtils.waitForEvent(bookmarksView, "ViewShown"); + await bmViewPromise; + + // Find the test bookmark and open the context menu on it + let list = document.getElementById("panelMenu_bookmarksMenu"); + let listItem = [...list.children].find(node => node.label == TEST_TITLE); + let placesContext = document.getElementById("placesContext"); + let contextPromise = BrowserTestUtils.waitForEvent( + placesContext, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(listItem, { + button: 2, + type: "contextmenu", + }); + await contextPromise; + + // Select Show in Folder and wait for the sidebar to show up + let sidebarShownPromise = BrowserTestUtils.waitForEvent( + window, + "SidebarShown" + ); + placesContext.activateItem( + document.getElementById("placesContext_showInFolder") + ); + await sidebarShownPromise; + + // Get the sidebar tree element and find the selected node + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let treeNode = tree.selectedNode; + + Assert.equal( + treeNode.parent.bookmarkGuid, + parentFolder.guid, + "Containing folder node is correct" + ); + Assert.equal( + treeNode.title, + listItem.label, + "The bookmark title matches selected node" + ); + Assert.equal( + treeNode.uri, + TEST_URL, + "The bookmark URL matches selected node" + ); + + // Cleanup + await PlacesUtils.bookmarks.eraseEverything(); + if (!sidebarWasAlreadyOpen) { + SidebarUI.hide(); + } + await gCUITestUtils.hideMainMenu(); +}); diff --git a/browser/components/places/tests/browser/browser_views_iconsupdate.js b/browser/components/places/tests/browser/browser_views_iconsupdate.js new file mode 100644 index 0000000000..1799a9665b --- /dev/null +++ b/browser/components/places/tests/browser/browser_views_iconsupdate.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +/** + * Tests Places views (toolbar, tree) for icons update. + * The menu is not tested since it uses the same code as the toolbar. + */ + +add_task(async function () { + const PAGE_URI = NetUtil.newURI("http://places.test/"); + const ICON_URI = NetUtil.newURI( + "http://mochi.test:8888/browser/browser/components/places/tests/browser/favicon-normal16.png" + ); + + info("Uncollapse the personal toolbar if needed"); + let toolbar = document.getElementById("PersonalToolbar"); + let wasCollapsed = toolbar.collapsed; + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + registerCleanupFunction(async function () { + await promiseSetToolbarVisibility(toolbar, false); + }); + } + + info("Open the bookmarks sidebar"); + let sidebar = document.getElementById("sidebar"); + let promiseSidebarLoaded = new Promise(resolve => { + sidebar.addEventListener("load", resolve, { capture: true, once: true }); + }); + SidebarUI.show("viewBookmarksSidebar"); + registerCleanupFunction(() => { + SidebarUI.hide(); + }); + await promiseSidebarLoaded; + + // Add a bookmark to the bookmarks toolbar. + let bm = await PlacesUtils.bookmarks.insert({ + url: PAGE_URI, + title: "test icon", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + // The icon is read asynchronously from the network, we don't have an easy way + // to wait for that. + await new Promise(resolve => { + setTimeout(resolve, 3000); + }); + + let toolbarElt = getNodeForToolbarItem(bm.guid); + let toolbarShot1 = TestUtils.screenshotArea(toolbarElt, window); + let sidebarRect = await getRectForSidebarItem(bm.guid); + let sidebarShot1 = TestUtils.screenshotArea(sidebarRect, window); + + info("Toolbar: " + toolbarShot1); + info("Sidebar: " + sidebarShot1); + + let iconURI = await new Promise(resolve => { + PlacesUtils.favicons.setAndFetchFaviconForPage( + PAGE_URI, + ICON_URI, + true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + resolve, + Services.scriptSecurityManager.getSystemPrincipal() + ); + }); + Assert.ok(iconURI.equals(ICON_URI), "Succesfully set the icon"); + + // The icon is read asynchronously from the network, we don't have an easy way + // to wait for that, thus we must poll. + await TestUtils.waitForCondition(() => { + // Assert.notEqual truncates the strings, so it is unusable here for failure + // debugging purposes. + let toolbarShot2 = TestUtils.screenshotArea(toolbarElt, window); + if (toolbarShot1 != toolbarShot2) { + info("After toolbar: " + toolbarShot2); + } + return toolbarShot1 != toolbarShot2; + }, "Waiting for the toolbar icon to update"); + + await TestUtils.waitForCondition(() => { + let sidebarShot2 = TestUtils.screenshotArea(sidebarRect, window); + if (sidebarShot1 != sidebarShot2) { + info("After sidebar: " + sidebarShot2); + } + return sidebarShot1 != sidebarShot2; + }, "Waiting for the sidebar icon to update"); +}); + +/** + * Get Element for a bookmark in the bookmarks toolbar. + * + * @param {string} guid + * GUID of the item to search. + * @returns {object} DOM Node of the element. + */ +function getNodeForToolbarItem(guid) { + return Array.from( + document.getElementById("PlacesToolbarItems").children + ).find(child => child._placesNode && child._placesNode.bookmarkGuid == guid); +} + +/** + * Get a rect for a bookmark in the bookmarks sidebar + * + * @param {string} guid + * GUID of the item to search. + * @returns {object} DOM Node of the element. + */ +async function getRectForSidebarItem(guid) { + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + tree.selectItems([guid]); + let treerect = tree.getBoundingClientRect(); + let cellrect = tree.getCoordsForCellItem( + tree.currentIndex, + tree.columns[0], + "cell" + ); + + // Adjust the position for the tree and sidebar. + return { + left: treerect.left + cellrect.left + sidebar.getBoundingClientRect().left, + top: treerect.top + cellrect.top + sidebar.getBoundingClientRect().top, + width: cellrect.width, + height: cellrect.height, + }; +} diff --git a/browser/components/places/tests/browser/browser_views_liveupdate.js b/browser/components/places/tests/browser/browser_views_liveupdate.js new file mode 100644 index 0000000000..cce35941f3 --- /dev/null +++ b/browser/components/places/tests/browser/browser_views_liveupdate.js @@ -0,0 +1,493 @@ +/* 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/. */ + +/** + * Tests Places views (menu, toolbar, tree) for liveupdate. + */ + +var toolbar = document.getElementById("PersonalToolbar"); +var wasCollapsed = toolbar.collapsed; + +/** + * Simulates popup opening causing it to populate. + * We cannot just use menu.open, since it would not work on Mac due to native menubar. + * + * @param {object} aPopup + * The popup element + */ +function fakeOpenPopup(aPopup) { + var popupEvent = document.createEvent("MouseEvent"); + popupEvent.initMouseEvent( + "popupshowing", + true, + true, + window, + 0, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + 0, + null + ); + aPopup.dispatchEvent(popupEvent); +} + +async function testInFolder(folderGuid, prefix) { + let addedBookmarks = []; + let item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}1`, + url: `http://${prefix}1.mozilla.org/`, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}1_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}2`, + url: "place:", + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}2_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + type: PlacesUtils.bookmarks.TYPE_SEPARATOR, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: folderGuid, + title: `${prefix}f`, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + item.title = `${prefix}f_edited`; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item = await PlacesUtils.bookmarks.insert({ + parentGuid: item.guid, + title: `${prefix}f1`, + url: `http://${prefix}f1.mozilla.org/`, + }); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + addedBookmarks.push(item); + + item.index = 0; + item.parentGuid = folderGuid; + await PlacesUtils.bookmarks.update(item); + await bookmarksObserver.assertViewsUpdatedCorrectly(); + + return addedBookmarks; +} + +add_task(async function test() { + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, true); + } + + // Open bookmarks menu. + var popup = document.getElementById("bookmarksMenuPopup"); + ok(popup, "Menu popup element exists"); + fakeOpenPopup(popup); + + // Open bookmarks sidebar. + await withSidebarTree("bookmarks", async () => { + // Add observers. + bookmarksObserver.handlePlacesEvents = + bookmarksObserver.handlePlacesEvents.bind(bookmarksObserver); + PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed"], + bookmarksObserver.handlePlacesEvents + ); + var addedBookmarks = []; + + // MENU + info("*** Acting on menu bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.menuGuid, "bm") + ); + + // TOOLBAR + info("*** Acting on toolbar bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.toolbarGuid, "tb") + ); + + // UNSORTED + info("*** Acting on unsorted bookmarks"); + addedBookmarks = addedBookmarks.concat( + await testInFolder(PlacesUtils.bookmarks.unfiledGuid, "ub") + ); + + // Remove all added bookmarks. + for (let bm of addedBookmarks) { + // If we remove an item after its containing folder has been removed, + // this will throw, but we can ignore that. + try { + await PlacesUtils.bookmarks.remove(bm); + } catch (ex) {} + await bookmarksObserver.assertViewsUpdatedCorrectly(); + } + + // Remove observers. + PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed"], + bookmarksObserver.handlePlacesEvents + ); + }); + + // Collapse the personal toolbar if needed. + if (wasCollapsed) { + await promiseSetToolbarVisibility(toolbar, false); + } +}); + +/** + * The observer is where magic happens, for every change we do it will look for + * nodes positions in the affected views. + */ +var bookmarksObserver = { + _notifications: [], + + handlePlacesEvents(events) { + for (let { type, parentGuid, guid, index } of events) { + switch (type) { + case "bookmark-added": + this._notifications.push([ + "assertItemAdded", + parentGuid, + guid, + index, + ]); + break; + case "bookmark-removed": + this._notifications.push(["assertItemRemoved", parentGuid, guid]); + break; + } + } + }, + + async assertViewsUpdatedCorrectly() { + for (let notification of this._notifications) { + let assertFunction = notification.shift(); + + let views = await getViewsForFolder(notification.shift()); + Assert.greater( + views.length, + 0, + "Should have found one or more views for the parent folder." + ); + + await this[assertFunction](views, ...notification); + } + + this._notifications = []; + }, + + async assertItemAdded(views, guid, expectedIndex) { + for (let i = 0; i < views.length; i++) { + let [node, index] = searchItemInView(guid, views[i]); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + index, + expectedIndex, + "Should have found the node at the expected index" + ); + } + }, + + async assertItemRemoved(views, guid) { + for (let i = 0; i < views.length; i++) { + let [node] = searchItemInView(guid, views[i]); + Assert.equal(node, null, "Should not have found the node"); + } + }, + + async assertItemMoved(views, guid, newIndex) { + // Check that item has been moved in the correct position. + for (let i = 0; i < views.length; i++) { + let [node, index] = searchItemInView(guid, views[i]); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + index, + newIndex, + "Should have found the node at the expected index" + ); + } + }, + + async assertItemChanged(views, guid, newValue) { + let validator = function (aElementOrTreeIndex) { + if (typeof aElementOrTreeIndex == "number") { + let sidebar = document.getElementById("sidebar"); + let tree = sidebar.contentDocument.getElementById("bookmarks-view"); + let cellText = tree.view.getCellText( + aElementOrTreeIndex, + tree.columns.getColumnAt(0) + ); + if (!newValue) { + return ( + cellText == + PlacesUIUtils.getBestTitle( + tree.view.nodeForTreeIndex(aElementOrTreeIndex), + true + ) + ); + } + return cellText == newValue; + } + if (!newValue && aElementOrTreeIndex.localName != "toolbarbutton") { + return ( + aElementOrTreeIndex.getAttribute("label") == + PlacesUIUtils.getBestTitle(aElementOrTreeIndex._placesNode) + ); + } + return aElementOrTreeIndex.getAttribute("label") == newValue; + }; + + for (let i = 0; i < views.length; i++) { + let [node, , valid] = searchItemInView(guid, views[i], validator); + Assert.notEqual(node, null, "Should have found the view in " + views[i]); + Assert.equal( + node.title, + newValue, + "Node should have the correct new title" + ); + Assert.ok(valid, "Node element should have the correct label"); + } + }, +}; + +/** + * Search an item guid in a view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {string} view + * either "toolbar", "menu" or "sidebar" + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index, valid] or [null, null, false] if not found. + */ +function searchItemInView(itemGuid, view, validator) { + switch (view) { + case "toolbar": + return getNodeForToolbarItem(itemGuid, validator); + case "menu": + return getNodeForMenuItem(itemGuid, validator); + case "sidebar": + return getNodeForSidebarItem(itemGuid, validator); + } + + return [null, null, false]; +} + +/** + * Get places node and index for an itemGuid in bookmarks toolbar view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForToolbarItem(itemGuid, validator) { + var placesToolbarItems = document.getElementById("PlacesToolbarItems"); + + function findNode(aContainer) { + var children = aContainer.children; + for (var i = 0, staticNodes = 0; i < children.length; i++) { + var child = children[i]; + + // Is this a Places node? + if (!child._placesNode) { + staticNodes++; + continue; + } + + if (child._placesNode.bookmarkGuid == itemGuid) { + let valid = validator ? validator(child) : true; + return [child._placesNode, i - staticNodes, valid]; + } + + // Don't search in queries, they could contain our item in a + // different position. Search only folders + if (PlacesUtils.nodeIsFolder(child._placesNode)) { + var popup = child.menupopup; + popup.openPopup(); + var foundNode = findNode(popup); + popup.hidePopup(); + if (foundNode[0] != null) { + return foundNode; + } + } + } + return [null, null]; + } + + return findNode(placesToolbarItems); +} + +/** + * Get places node and index for an itemGuid in bookmarks menu view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForMenuItem(itemGuid, validator) { + var menu = document.getElementById("bookmarksMenu"); + + function findNode(aContainer) { + var children = aContainer.children; + for (var i = 0, staticNodes = 0; i < children.length; i++) { + var child = children[i]; + + // Is this a Places node? + if (!child._placesNode) { + staticNodes++; + continue; + } + + if (child._placesNode.bookmarkGuid == itemGuid) { + let valid = validator ? validator(child) : true; + return [child._placesNode, i - staticNodes, valid]; + } + + // Don't search in queries, they could contain our item in a + // different position. Search only folders + if (PlacesUtils.nodeIsFolder(child._placesNode)) { + var popup = child.lastElementChild; + fakeOpenPopup(popup); + var foundNode = findNode(popup); + + child.open = false; + if (foundNode[0] != null) { + return foundNode; + } + } + } + return [null, null, false]; + } + + return findNode(menu.lastElementChild); +} + +/** + * Get places node and index for an itemGuid in sidebar tree view. + * + * @param {string} itemGuid + * item guid of the item to search. + * @param {Function} validator + * function to check validity of the found node element. + * @returns {Array} + * [node, index] or [null, null] if not found. + */ +function getNodeForSidebarItem(itemGuid, validator) { + var sidebar = document.getElementById("sidebar"); + var tree = sidebar.contentDocument.getElementById("bookmarks-view"); + + function findNode(aContainerIndex) { + if (tree.view.isContainerEmpty(aContainerIndex)) { + return [null, null, false]; + } + + // The rowCount limit is just for sanity, but we will end looping when + // we have checked the last child of this container or we have found node. + for (var i = aContainerIndex + 1; i < tree.view.rowCount; i++) { + var node = tree.view.nodeForTreeIndex(i); + + if (node.bookmarkGuid == itemGuid) { + // Minus one because we want relative index inside the container. + let valid = validator ? validator(i) : true; + return [node, i - tree.view.getParentIndex(i) - 1, valid]; + } + + if (PlacesUtils.nodeIsFolder(node)) { + // Open container. + tree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + tree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + + // We have finished walking this container. + if (!tree.view.hasNextSibling(aContainerIndex + 1, i)) { + break; + } + } + return [null, null, false]; + } + + // Root node is hidden, so we need to manually walk the first level. + for (var i = 0; i < tree.view.rowCount; i++) { + // Open container. + tree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + tree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) { + return foundNode; + } + } + return [null, null, false]; +} + +/** + * Get views affected by changes to a folder. + * + * @param {string} folderGuid + * item guid of the folder we have changed. + * @returns {Array<"toolbar" | "menu" | "sidebar">} + * subset of views: ["toolbar", "menu", "sidebar"] + */ +async function getViewsForFolder(folderGuid) { + let rootGuid = folderGuid; + while (!PlacesUtils.isRootItem(rootGuid)) { + let itemData = await PlacesUtils.bookmarks.fetch(rootGuid); + rootGuid = itemData.parentGuid; + } + + switch (rootGuid) { + case PlacesUtils.bookmarks.toolbarGuid: + return ["toolbar", "sidebar"]; + case PlacesUtils.bookmarks.menuGuid: + return ["menu", "sidebar"]; + case PlacesUtils.bookmarks.unfiledGuid: + return ["sidebar"]; + } + return []; +} diff --git a/browser/components/places/tests/browser/favicon-normal16.png b/browser/components/places/tests/browser/favicon-normal16.png Binary files differnew file mode 100644 index 0000000000..62b69a3d03 --- /dev/null +++ b/browser/components/places/tests/browser/favicon-normal16.png diff --git a/browser/components/places/tests/browser/frameLeft.html b/browser/components/places/tests/browser/frameLeft.html new file mode 100644 index 0000000000..5a54fe353b --- /dev/null +++ b/browser/components/places/tests/browser/frameLeft.html @@ -0,0 +1,8 @@ +<html> + <head> + <title>Left frame</title> + </head> + <body> + <a id="clickme" href="frameRight.html" target="right">Open page in the right frame.</a> + </body> +</html> diff --git a/browser/components/places/tests/browser/frameRight.html b/browser/components/places/tests/browser/frameRight.html new file mode 100644 index 0000000000..226accc349 --- /dev/null +++ b/browser/components/places/tests/browser/frameRight.html @@ -0,0 +1,8 @@ +<html> + <head> + <title>Right Frame</title> + </head> + <body> + This is the right frame. + </body> +</html> diff --git a/browser/components/places/tests/browser/framedPage.html b/browser/components/places/tests/browser/framedPage.html new file mode 100644 index 0000000000..58c5bbd79e --- /dev/null +++ b/browser/components/places/tests/browser/framedPage.html @@ -0,0 +1,9 @@ +<html> + <head> + <title>Framed page</title> + </head> + <frameset cols="*,*"> + <frame id="left" name="left" src="frameLeft.html"> + <frame id="right" name="right" src="about:mozilla"> + </frameset> +</html> diff --git a/browser/components/places/tests/browser/head.js b/browser/components/places/tests/browser/head.js new file mode 100644 index 0000000000..1827d84cbc --- /dev/null +++ b/browser/components/places/tests/browser/head.js @@ -0,0 +1,533 @@ +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "gFluentStrings", function () { + return new Localization(["branding/brand.ftl", "browser/browser.ftl"], true); +}); + +function openLibrary(callback, aLeftPaneRoot) { + let library = window.openDialog( + "chrome://browser/content/places/places.xhtml", + "", + "chrome,toolbar=yes,dialog=no,resizable", + aLeftPaneRoot + ); + waitForFocus(function () { + checkLibraryPaneVisibility(library, aLeftPaneRoot); + callback(library); + }, library); + + return library; +} + +/** + * Returns a handle to a Library window. + * If one is opens returns itm otherwise it opens a new one. + * + * @param {object} aLeftPaneRoot + * Hierarchy to open and select in the left pane. + * @returns {Promise} + * Resolves to the handle to the library window. + */ +function promiseLibrary(aLeftPaneRoot) { + return new Promise(resolve => { + let library = Services.wm.getMostRecentWindow("Places:Organizer"); + if (library && !library.closed) { + if (aLeftPaneRoot) { + library.PlacesOrganizer.selectLeftPaneContainerByHierarchy( + aLeftPaneRoot + ); + } + checkLibraryPaneVisibility(library, aLeftPaneRoot); + resolve(library); + } else { + openLibrary(resolve, aLeftPaneRoot); + } + }); +} + +function promiseLibraryClosed(organizer) { + return new Promise(resolve => { + if (organizer.closed) { + resolve(); + return; + } + // Wait for the Organizer window to actually be closed + organizer.addEventListener( + "unload", + function () { + executeSoon(resolve); + }, + { once: true } + ); + + // Close Library window. + organizer.close(); + }); +} + +function checkLibraryPaneVisibility(library, selectedPane) { + // Make sure right view is shown + if (selectedPane == "Downloads") { + Assert.ok( + library.ContentTree.view.hidden, + "Bookmark/History tree is hidden" + ); + Assert.ok( + !library.document.getElementById("downloadsListBox").hidden, + "Downloads are shown" + ); + } else { + Assert.ok( + !library.ContentTree.view.hidden, + "Bookmark/History tree is shown" + ); + Assert.ok( + library.document.getElementById("downloadsListBox").hidden, + "Downloads are hidden" + ); + } + + // Check currentView getter + Assert.ok(!library.ContentArea.currentView.hidden, "Current view is shown"); +} + +/** + * Waits for a clipboard operation to complete, looking for the expected type. + * + * @see waitForClipboard + * + * @param {Function} aPopulateClipboardFn + * Function to populate the clipboard. + * @param {string} aFlavor + * Data flavor to expect. + * @returns {Promise} + * A promise that is resolved with the data. + */ +function promiseClipboard(aPopulateClipboardFn, aFlavor) { + return new Promise((resolve, reject) => { + waitForClipboard( + data => !!data, + aPopulateClipboardFn, + resolve, + reject, + aFlavor + ); + }); +} + +function synthesizeClickOnSelectedTreeCell(aTree, aOptions) { + if (aTree.view.selection.count < 1) { + throw new Error("The test node should be successfully selected"); + } + // Get selection rowID. + let min = {}, + max = {}; + aTree.view.selection.getRangeAt(0, min, max); + let rowID = min.value; + aTree.ensureRowIsVisible(rowID); + // Calculate the click coordinates. + var rect = aTree.getCoordsForCellItem(rowID, aTree.columns[0], "text"); + var x = rect.x + rect.width / 2; + var y = rect.y + rect.height / 2; + // Simulate the click. + EventUtils.synthesizeMouse( + aTree.body, + x, + y, + aOptions || {}, + aTree.ownerGlobal + ); +} + +/** + * Makes the specified toolbar visible or invisible and returns a Promise object + * that is resolved when the toolbar has completed any animations associated + * with hiding or showing the toolbar. + * + * Note that this code assumes that changes to a toolbar's visibility trigger + * a transition on the max-height property of the toolbar element. + * Changes to this styling could cause the returned Promise object to be + * resolved too early or not at all. + * + * @param {object} aToolbar + * The toolbar to update. + * @param {boolean} aVisible + * True to make the toolbar visible, false to make it hidden. + * + * @returns {Promise} Any animation associated with updating the toolbar's + * visibility has finished. + */ +function promiseSetToolbarVisibility(aToolbar, aVisible) { + if (isToolbarVisible(aToolbar) != aVisible) { + let visibilityChanged = TestUtils.waitForCondition( + () => aToolbar.collapsed != aVisible + ); + setToolbarVisibility(aToolbar, aVisible, undefined, false); + return visibilityChanged; + } + return Promise.resolve(); +} + +/** + * Helper function to determine if the given toolbar is in the visible + * state according to its autohide/collapsed attribute. + * + * @param {object} aToolbar The toolbar to query. + * + * @returns {boolean} True if the relevant attribute on |aToolbar| indicates it is + * visible, false otherwise. + */ +function isToolbarVisible(aToolbar) { + let hidingAttribute = + aToolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; + let hidingValue = aToolbar.getAttribute(hidingAttribute).toLowerCase(); + // Check for both collapsed="true" and collapsed="collapsed" + return hidingValue !== "true" && hidingValue !== hidingAttribute; +} + +/** + * Executes a task after opening the bookmarks dialog, then cancels the dialog. + * + * @param {boolean} autoCancel + * whether to automatically cancel the dialog at the end of the task + * @param {Function} openFn + * generator function causing the dialog to open + * @param {Function} taskFn + * the task to execute once the dialog is open + * @param {Function} closeFn + * A function to be used to wait for pending work when the dialog is + * closing. It is passed the dialog window handle and should return a promise. + * @returns {string} guid + * Bookmark guid + */ +var withBookmarksDialog = async function (autoCancel, openFn, taskFn, closeFn) { + let dialogUrl = "chrome://browser/content/places/bookmarkProperties.xhtml"; + let closed = false; + // We can't show the in-window prompt for windows which don't have + // gDialogBox, like the library (Places:Organizer) window. + let hasDialogBox = !!Services.wm.getMostRecentWindow("").gDialogBox; + let dialogPromise; + if (hasDialogBox) { + dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(null, dialogUrl, { + isSubDialog: true, + }); + } else { + dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null, win => { + return win.document.documentURI.startsWith(dialogUrl); + }).then(win => { + ok( + win.location.href.startsWith(dialogUrl), + "The bookmark properties dialog is open: " + win.location.href + ); + // This is needed for the overlay. + return SimpleTest.promiseFocus(win).then(() => win); + }); + } + let dialogClosePromise = dialogPromise.then(win => { + if (!hasDialogBox) { + return BrowserTestUtils.domWindowClosed(win); + } + let container = win.top.document.getElementById("window-modal-dialog"); + return BrowserTestUtils.waitForEvent(container, "close").then(() => { + return BrowserTestUtils.waitForMutationCondition( + container, + { childList: true, attributes: true }, + () => !container.hasChildNodes() && !container.open + ); + }); + }); + dialogClosePromise.then(() => { + closed = true; + }); + + info("withBookmarksDialog: opening the dialog"); + // The dialog might be modal and could block our events loop, so executeSoon. + executeSoon(openFn); + + info("withBookmarksDialog: waiting for the dialog"); + let dialogWin = await dialogPromise; + + // Ensure overlay is loaded + info("waiting for the overlay to be loaded"); + await dialogWin.document.mozSubdialogReady; + + // Check the first input is focused. + let doc = dialogWin.document; + let elt = doc.querySelector('input:not([hidden="true"])'); + ok(elt, "There should be an input to focus."); + + if (elt) { + info("waiting for focus on the first textfield"); + await TestUtils.waitForCondition( + () => doc.activeElement == elt, + "The first non collapsed input should have been focused" + ); + } + + info("withBookmarksDialog: executing the task"); + + let closePromise = () => Promise.resolve(); + if (closeFn) { + closePromise = closeFn(dialogWin); + } + let guid; + try { + await taskFn(dialogWin); + } finally { + if (!closed && autoCancel) { + info("withBookmarksDialog: canceling the dialog"); + doc.getElementById("bookmarkpropertiesdialog").cancelDialog(); + await closePromise; + } + guid = await PlacesUIUtils.lastBookmarkDialogDeferred.promise; + // Give the dialog a little time to close itself. + await dialogClosePromise; + } + return guid; +}; + +/** + * Opens the contextual menu on the element pointed by the given selector. + * + * @param {object} browser + * The associated browser element. + * @param {object} selector + * Valid selector syntax + * @returns {Promise} + * Returns a Promise that resolves once the context menu has been + * opened. + */ +var openContextMenuForContentSelector = async function (browser, selector) { + info("wait for the context menu"); + let contextPromise = BrowserTestUtils.waitForEvent( + document.getElementById("contentAreaContextMenu"), + "popupshown" + ); + await SpecialPowers.spawn(browser, [{ selector }], async function (args) { + let doc = content.document; + let elt = doc.querySelector(args.selector); + dump(`openContextMenuForContentSelector: found ${elt}\n`); + + /* Open context menu so chrome can access the element */ + const domWindowUtils = content.windowUtils; + let rect = elt.getBoundingClientRect(); + let left = rect.left + rect.width / 2; + let top = rect.top + rect.height / 2; + domWindowUtils.sendMouseEvent( + "contextmenu", + left, + top, + 2, + 1, + 0, + false, + 0, + 0, + true + ); + }); + await contextPromise; +}; + +/** + * Fills a bookmarks dialog text field ensuring to cause expected edit events. + * + * @param {string} id + * id of the text field + * @param {string} text + * text to fill in + * @param {object} win + * dialog window + * @param {boolean} [blur] + * whether to blur at the end. + */ +function fillBookmarkTextField(id, text, win, blur = true) { + let elt = win.document.getElementById(id); + elt.focus(); + elt.select(); + if (!text) { + EventUtils.synthesizeKey("VK_DELETE", {}, win); + } else { + for (let c of text.split("")) { + EventUtils.synthesizeKey(c, {}, win); + } + } + if (blur) { + elt.blur(); + } +} + +/** + * Executes a task after opening the bookmarks or history sidebar. Takes care + * of closing the sidebar once done. + * + * @param {string} type + * either "bookmarks" or "history". + * @param {Function} taskFn + * The task to execute once the sidebar is ready. Will get the Places + * tree view as input. + */ +var withSidebarTree = async function (type, taskFn) { + let sidebar = document.getElementById("sidebar"); + info("withSidebarTree: waiting sidebar load"); + let sidebarLoadedPromise = new Promise(resolve => { + sidebar.addEventListener( + "load", + function () { + executeSoon(resolve); + }, + { capture: true, once: true } + ); + }); + let sidebarId = + type == "bookmarks" ? "viewBookmarksSidebar" : "viewHistorySidebar"; + SidebarUI.show(sidebarId); + await sidebarLoadedPromise; + + let treeId = type == "bookmarks" ? "bookmarks-view" : "historyTree"; + let tree = sidebar.contentDocument.getElementById(treeId); + + // Need to executeSoon since the tree is initialized on sidebar load. + info("withSidebarTree: executing the task"); + try { + await taskFn(tree); + } finally { + SidebarUI.hide(); + } +}; + +/** + * Executes a task after opening the Library on a given root. Takes care + * of closing the library once done. + * + * @param {string} hierarchy + * The left pane hierarchy to open. + * @param {Function} taskFn + * The task to execute once the Library is ready. + * Will get { left, right } trees as argument. + */ +var withLibraryWindow = async function (hierarchy, taskFn) { + let library = await promiseLibrary(hierarchy); + let left = library.document.getElementById("placesList"); + let right = library.document.getElementById("placeContent"); + info("withLibrary: executing the task"); + try { + await taskFn({ left, right }); + } finally { + await promiseLibraryClosed(library); + } +}; + +function promisePlacesInitComplete() { + const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + + let placesInitCompleteObserved = TestUtils.topicObserved( + "places-browser-init-complete" + ); + + gBrowserGlue.observe( + null, + "browser-glue-test", + "places-browser-init-complete" + ); + + return placesInitCompleteObserved; +} + +// Function copied from browser/base/content/test/general/head.js. +function promisePopupShown(popup) { + return new Promise(resolve => { + if (popup.state == "open") { + resolve(); + } else { + let onPopupShown = event => { + popup.removeEventListener("popupshown", onPopupShown); + resolve(); + }; + popup.addEventListener("popupshown", onPopupShown); + } + }); +} + +// Function copied from browser/base/content/test/general/head.js. +function promisePopupHidden(popup) { + return new Promise(resolve => { + let onPopupHidden = event => { + popup.removeEventListener("popuphidden", onPopupHidden); + resolve(); + }; + popup.addEventListener("popuphidden", onPopupHidden); + }); +} + +// Identify a bookmark node in the Bookmarks Toolbar by its guid. +function getToolbarNodeForItemGuid(itemGuid) { + let children = document.getElementById("PlacesToolbarItems").childNodes; + for (let child of children) { + if (itemGuid === child._placesNode.bookmarkGuid) { + return child; + } + } + return null; +} + +// Open the bookmarks Star UI by clicking the star button on the address bar. +async function clickBookmarkStar(win = window) { + let shownPromise = promisePopupShown( + win.document.getElementById("editBookmarkPanel") + ); + win.BookmarkingUI.star.click(); + await shownPromise; + + // Additionally await for the async init to complete. + let menuList = win.document.getElementById("editBMPanel_folderMenuList"); + await BrowserTestUtils.waitForMutationCondition( + menuList, + { attributes: true }, + () => !!menuList.getAttribute("selectedGuid"), + "Should select the menu folder item" + ); +} + +// Close the bookmarks Star UI by clicking the "Done" button. +async function hideBookmarksPanel(win = window) { + let hiddenPromise = promisePopupHidden( + win.document.getElementById("editBookmarkPanel") + ); + // Confirm and close the dialog. + win.document.getElementById("editBookmarkPanelDoneButton").click(); + await hiddenPromise; +} + +// Create a temporary folder, set it as the default folder, +// then remove the folder. This is used to ensure that the +// default folder gets reset properly. +async function createAndRemoveDefaultFolder() { + let tempFolder = await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.unfiledGuid, + children: [ + { + title: "temp folder", + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }, + ], + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.bookmarks.defaultLocation", tempFolder[0].guid]], + }); + + let defaultGUID = await PlacesUIUtils.defaultParentGuid; + is(defaultGUID, tempFolder[0].guid, "check default guid"); + + await PlacesUtils.bookmarks.remove(tempFolder); +} + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.bookmarks.defaultLocation"); + await PlacesTransactions.clearTransactionsHistory(true, true); +}); diff --git a/browser/components/places/tests/browser/interactions/browser.ini b/browser/components/places/tests/browser/interactions/browser.ini new file mode 100644 index 0000000000..79be009cbf --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser.ini @@ -0,0 +1,30 @@ +# 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/. + +[DEFAULT] +prefs = + browser.places.interactions.enabled=true + browser.places.interactions.log=true + browser.places.interactions.scrolling_timeout_ms=50 + general.smoothScroll=false + +support-files = + head.js + ../keyword_form.html + scrolling.html + scrolling_subframe.html +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure + +[browser_interactions_blocklist.js] +[browser_interactions_referrer.js] +[browser_interactions_scrolling.js] +skip-if = + apple_silicon && fission # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs +[browser_interactions_scrolling_dom_history.js] +skip-if = os == 'mac' # Bug 1756157: Randomly times out on macOS +[browser_interactions_typing.js] +[browser_interactions_typing_dom_history.js] +[browser_interactions_view_time.js] +[browser_interactions_view_time_dom_history.js] diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js b/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js new file mode 100644 index 0000000000..c174024ef5 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_blocklist.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that interactions are not recorded for sites on the blocklist. + */ + +const ALLOWED_TEST_URL = "http://mochi.test:8888/"; +const BLOCKED_TEST_URL = "https://example.com/browser"; + +ChromeUtils.defineESModuleGetters(this, { + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + FilterAdult: "resource://activity-stream/lib/FilterAdult.jsm", +}); + +add_setup(async function () { + let oldBlocklistValue = Services.prefs.getStringPref( + "places.interactions.customBlocklist", + "[]" + ); + Services.prefs.setStringPref("places.interactions.customBlocklist", "[]"); + + registerCleanupFunction(async () => { + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + oldBlocklistValue + ); + }); +}); +/** + * Loads the blocked URL, then loads about:blank to trigger the end of the + * interaction with the blocked URL. + * + * @param {boolean} expectRecording + * True if we expect the blocked URL to have been recorded in the database. + */ +async function loadBlockedUrl(expectRecording) { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(ALLOWED_TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.loadURIString(browser, BLOCKED_TEST_URL); + await BrowserTestUtils.browserLoaded(browser, false, BLOCKED_TEST_URL); + + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + if (expectRecording) { + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + { + url: BLOCKED_TEST_URL, + totalViewTime: 20000, + }, + ]); + } else { + await assertDatabaseValues([ + { + url: ALLOWED_TEST_URL, + totalViewTime: 10000, + }, + ]); + } + }); +} + +add_task(async function test_regexp() { + info("Record BLOCKED_TEST_URL because it is not yet blocklisted."); + await loadBlockedUrl(true); + + info("Add BLOCKED_TEST_URL to the blocklist and verify it is not recorded."); + let blockedRegex = /^(https?:\/\/)?example\.com\/browser/i; + InteractionsBlocklist.addRegexToBlocklist(blockedRegex); + Assert.equal( + Services.prefs.getStringPref("places.interactions.customBlocklist", "[]"), + JSON.stringify([blockedRegex.toString()]) + ); + await loadBlockedUrl(false); + + info("Remove BLOCKED_TEST_URL from the blocklist and verify it is recorded."); + InteractionsBlocklist.removeRegexFromBlocklist(blockedRegex); + Assert.equal( + Services.prefs.getStringPref("places.interactions.customBlocklist", "[]"), + JSON.stringify([]) + ); + await loadBlockedUrl(true); +}); + +add_task(async function test_adult() { + FilterAdult.addDomainToList("https://example.com/browser"); + await loadBlockedUrl(false); + FilterAdult.removeDomainFromList("https://example.com/browser"); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js b/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js new file mode 100644 index 0000000000..fda93bfc5a --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_referrer.js @@ -0,0 +1,45 @@ +/* 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/. */ + +/** + * Tests page view time recording for interactions. + */ + +const TEST_REFERRER_URL = "https://example.org/browser"; +const TEST_URL = "https://example.org/browser/browser"; + +add_task(async function test_interactions_referrer() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_REFERRER_URL, async browser => { + let ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + + // Load a new URI with a specific referrer. + let referrerInfo1 = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + Services.io.newURI(TEST_REFERRER_URL) + ); + browser.loadURI(Services.io.newURI(TEST_URL), { + referrerInfo: referrerInfo1, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + await BrowserTestUtils.browserLoaded(browser, true, TEST_URL); + }); + await assertDatabaseValues([ + { + url: TEST_REFERRER_URL, + referrer_url: null, + }, + { + url: TEST_URL, + referrer_url: TEST_REFERRER_URL, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js new file mode 100644 index 0000000000..925d427ff5 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling.js @@ -0,0 +1,162 @@ +/* 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/. */ + +/** + * Test reporting of scrolling interactions. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/interactions/scrolling.html"; +const TEST_URL2 = "https://example.com/browser"; + +async function waitForScrollEvent(aBrowser, aTask) { + let promise = BrowserTestUtils.waitForContentEvent(aBrowser, "scroll"); + + // This forces us to send a message to the browser's process and receive a response which ensures + // that the message sent to register the scroll event listener will also have been processed by + // the content process. Without this it is possible for our scroll task to send a higher priority + // message which can be processed by the content process before the message to register the scroll + // event listener. + await SpecialPowers.spawn(aBrowser, [], () => {}); + + await aTask(); + await promise; +} + +add_task(async function test_no_scrolling() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_arrow_key_down_scroll() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scrollIntoView() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("middleHeading"); + heading.scrollIntoView(); + }) + ); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // JS-triggered scrolling should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_anchor_click() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + const anchor = content.document.getElementById("to_bottom_anchor"); + anchor.click(); + }) + ); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from clicking on an anchor should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_window_scrollBy() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + content.scrollBy(0, 100); + }) + ); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from the window.scrollBy() call should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_window_scrollTo() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await waitForScrollEvent(browser, () => + SpecialPowers.spawn(browser, [], function () { + content.scrollTo(0, 200); + }) + ); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + // The scrolling resulting from the window.scrollTo() call should not be reported + await assertDatabaseValues([ + { + url: TEST_URL, + exactscrollingDistance: 0, + exactscrollingTime: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js new file mode 100644 index 0000000000..e272f6c866 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_scrolling_dom_history.js @@ -0,0 +1,208 @@ +/* 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/. */ + +/** + * Test reporting of scrolling interactions after DOM history API use. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/interactions/scrolling.html"; +const TEST_URL2 = "https://example.com/browser"; + +async function waitForScrollEvent(aBrowser, aTask) { + let promise = BrowserTestUtils.waitForContentEvent(aBrowser, "scroll"); + + // This forces us to send a message to the browser's process and receive a response which ensures + // that the message sent to register the scroll event listener will also have been processed by + // the content process. Without this it is possible for our scroll task to send a higher priority + // message which can be processed by the content process before the message to register the scroll + // event listener. + await SpecialPowers.spawn(aBrowser, [], () => {}); + + await aTask(); + await promise; +} + +add_task(async function test_scroll_pushState() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_pushState_sameUrl() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + // As the page hasn't changed there will be no interactions saved yet. + await assertDatabaseValues([]); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_replaceState() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_replaceState_sameUrl() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + // As the page hasn't changed there will be no interactions saved yet. + await assertDatabaseValues([]); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_scroll_hashchange() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await SpecialPowers.spawn(browser, [], function () { + const heading = content.document.getElementById("heading"); + heading.focus(); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.history.replaceState(null, "", url); + }); + + await waitForScrollEvent(browser, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + scrollingDistanceIsGreaterThan: 0, + scrollingTimeIsGreaterThan: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_typing.js b/browser/components/places/tests/browser/interactions/browser_interactions_typing.js new file mode 100644 index 0000000000..6561a4d334 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_typing.js @@ -0,0 +1,410 @@ +/* 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/. */ + +/** + * Tests reporting of typing interactions. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL2 = "https://example.com/browser"; +const TEST_URL3 = + "https://example.com/browser/browser/base/content/test/contextMenu/subtst_contextmenu_input.html"; + +const sentence = "The quick brown fox jumps over the lazy dog."; +const sentenceFragments = [ + "The quick", + " brown fox", + " jumps over the lazy dog.", +]; +const longSentence = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas ut purus a libero cursus scelerisque. In hac habitasse platea dictumst. Quisque posuere ante sed consequat volutpat."; + +// For tests where it matters reduces the maximum time between keypresses to a length that we can +// afford to delay the test by. +const PREF_TYPING_DELAY = "browser.places.interactions.typing_timeout_ms"; +const POST_TYPING_DELAY = 150; + +async function sendTextToInput(browser, text) { + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; // Reset to later verify that the provided text matches the value + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_task(async function test_load_and_navigate_away_no_keypresses() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + { + url: TEST_URL2, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_load_type_and_navigate_away() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + }); +}); + +add_task(async function test_no_typing_close_tab() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => {}); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); +}); + +add_task(async function test_typing_close_tab() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); +}); + +add_task(async function test_single_key_typing_and_delay() { + await Interactions.reset(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + // Single keystrokes with a delay between each, are not considered typing + const text = ["T", "h", "e"]; + + for (let i = 0; i < text.length; i++) { + await sendTextToInput(browser, text[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + // Since we typed single keys with delays between each, there should be no typing added to the database + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); +}); + +add_task(async function test_double_key_typing_and_delay() { + await Interactions.reset(); + + // Test three 2-key typing bursts. + const text = ["Ab", "cd", "ef"]; + + const testStartTime = Cu.now(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + for (let i = 0; i < text.length; i++) { + await sendTextToInput(browser, text[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + // All keys should be recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: text.reduce( + (accumulator, current) => accumulator + current.length, + 0 + ), + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); +}); + +add_task(async function test_typing_and_delay() { + await Interactions.reset(); + + const testStartTime = Cu.now(); + + Services.prefs.setIntPref(PREF_TYPING_DELAY, POST_TYPING_DELAY); + + try { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + for (let i = 0; i < sentenceFragments.length; i++) { + await sendTextToInput(browser, sentenceFragments[i]); + + // We do need to wait here because typing is defined as a series of keystrokes followed by a delay. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, POST_TYPING_DELAY * 2)); + } + }); + } finally { + Services.prefs.clearUserPref(PREF_TYPING_DELAY); + } + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments.reduce( + (accumulator, current) => accumulator + current.length, + 0 + ), + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); +}); + +add_task(async function test_typing_and_reload() { + await Interactions.reset(); + + const testStartTime = Cu.now(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentenceFragments[0]); + + info("reload"); + browser.reload(); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // First typing should have been recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments[0].length, + typingTimeIsGreaterThan: 0, + }, + ]); + + await sendTextToInput(browser, sentenceFragments[1]); + + info("reload"); + browser.reload(); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL); + + // Second typing should have been recorded + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentenceFragments[0].length, + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + { + url: TEST_URL, + keypresses: sentenceFragments[1].length, + typingTimeIsGreaterThan: 0, + typingTimeIsLessThan: Cu.now() - testStartTime, + }, + ]); + }); +}).skip(); // Bug 1749328 - intermittent failure: dropping the 2nd interaction after the 2nd reload + +add_task(async function test_switch_tabs_no_typing() { + await Interactions.reset(); + + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + info("Switch to second tab"); + gBrowser.selectedTab = tab2; + + // Only the interaction of the first tab should be recorded so far, and with no typing + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_typing_switch_tabs() { + await Interactions.reset(); + + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + await sendTextToInput(tab1.linkedBrowser, sentence); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL3); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL3); + + info("Switch to second tab"); + await BrowserTestUtils.switchTab(gBrowser, tab2); + + // Only the interaction of the first tab should be recorded so far + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + const tab1TyingTime = await getDatabaseValue(TEST_URL, "typingTime"); + + info("Switch back to first tab"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + // The interaction of the second tab should now be recorded (no typing) + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + info("Switch back to the second tab"); + await BrowserTestUtils.switchTab(gBrowser, tab2); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: 0, + exactTypingTime: 0, + }, + ]); + + // Typing into the second tab + await SpecialPowers.spawn(tab2.linkedBrowser, [], function () { + const input = content.document.getElementById("input_text"); + input.focus(); + }); + await EventUtils.sendString(longSentence); + await TestUtils.waitForTick(); + + info("Switch back to first tab"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + // The interaction of the second tab should now also be recorded (with typing) + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + exactTypingTime: tab1TyingTime, + }, + { + url: TEST_URL3, + keypresses: longSentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js new file mode 100644 index 0000000000..7462a5080a --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_typing_dom_history.js @@ -0,0 +1,171 @@ +/* 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/. */ + +/** + * Tests reporting of typing interactions after DOM history API usage. + */ + +"use strict"; + +const TEST_URL = + "https://example.com/browser/browser/components/places/tests/browser/keyword_form.html"; +const TEST_URL2 = "https://example.com/browser"; + +const sentence = "The quick brown fox jumps over the lazy dog."; + +async function sendTextToInput(browser, text) { + await SpecialPowers.spawn(browser, [], function () { + const input = content.document.querySelector( + "#form1 > input[name='search']" + ); + input.focus(); + input.value = ""; // Reset to later verify that the provided text matches the value + }); + + EventUtils.sendString(text); + + await SpecialPowers.spawn(browser, [{ text }], async function (args) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form1 > input[name='search']").value == + args.text, + "Text has been set on input" + ); + }); +} + +add_task(async function test_typing_pushState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_pushState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_replaceState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + { + url: TEST_URL2, + keypresses: sentence.length, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_replaceState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); + +add_task(async function test_typing_hashchange() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + await sendTextToInput(browser, sentence); + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.location = url; + }); + + await sendTextToInput(browser, sentence); + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + keypresses: sentence.length * 2, + typingTimeIsGreaterThan: 0, + }, + ]); + }); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js b/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js new file mode 100644 index 0000000000..3bb76288eb --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_view_time.js @@ -0,0 +1,398 @@ +/* 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/. */ + +/** + * Tests page view time recording for interactions. + */ + +const TEST_URL = "https://example.com/"; +const TEST_URL2 = "https://example.com/browser"; +const TEST_URL3 = "https://example.com/browser/browser"; +const TEST_URL4 = "https://example.com/browser/browser/components"; + +add_task(async function test_interactions_simple_load_and_navigate_away() { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.loadURIString(browser, TEST_URL2); + await BrowserTestUtils.browserLoaded(browser, false, TEST_URL2); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.loadURIString(browser, "about:blank"); + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + }); +}); + +add_task(async function test_interactions_simple_load_and_change_to_non_http() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.loadURIString(browser, "about:support"); + await BrowserTestUtils.browserLoaded(browser, false, "about:support"); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + }); +}); + +add_task(async function test_interactions_close_tab() { + await Interactions.reset(); + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_background_tab() { + await Interactions.reset(); + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.removeTab(tab2); + + // This is checking a non-action, so let the event queue clear to try and + // detect any unexpected database writes. We wait for a few ticks to + // make it more likely. however if this fails it may show up as an + // intermittent. + await TestUtils.waitForTick(); + await TestUtils.waitForTick(); + await TestUtils.waitForTick(); + + await assertDatabaseValues([]); + + BrowserTestUtils.removeTab(tab1); + + // Only the interaction in the visible tab should have been recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); +}); + +add_task(async function test_interactions_switch_tabs() { + await Interactions.reset(); + let tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser, false, TEST_URL2); + + info("Switch to second tab"); + Interactions._pageViewStartTime = Cu.now() - 10000; + gBrowser.selectedTab = tab2; + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let tab1ViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + info("Switch back to first tab"); + Interactions._pageViewStartTime = Cu.now() - 20000; + gBrowser.selectedTab = tab1; + + // The interaction of the second tab should now be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: tab1ViewTime, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + + info("Switch to second tab again"); + Interactions._pageViewStartTime = Cu.now() - 30000; + gBrowser.selectedTab = tab2; + + // The interaction of the second tab should now be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: tab1ViewTime + 30000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_interactions_switch_windows() { + await Interactions.reset(); + + // Open a tab in the first window. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + // and then load the second window. + Interactions._pageViewStartTime = Cu.now() - 10000; + + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + + BrowserTestUtils.loadURIString(otherWin.gBrowser.selectedBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + await SimpleTest.promiseFocus(otherWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let originalWindowViewTime = await getDatabaseValue( + TEST_URL, + "totalViewTime" + ); + + info("Switch back to original window"); + Interactions._pageViewStartTime = Cu.now() - 20000; + await SimpleTest.promiseFocus(window); + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: originalWindowViewTime, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); + let newWindowViewTime = await getDatabaseValue(TEST_URL2, "totalViewTime"); + + info("Switch back to new window"); + Interactions._pageViewStartTime = Cu.now() - 30000; + await SimpleTest.promiseFocus(otherWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: originalWindowViewTime + 30000, + }, + { + url: TEST_URL2, + exactTotalViewTime: newWindowViewTime, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(otherWin); +}); + +add_task(async function test_interactions_loading_in_unfocused_windows() { + await Interactions.reset(); + + let otherWin = await BrowserTestUtils.openNewBrowserWindow(); + + BrowserTestUtils.loadURIString(otherWin.gBrowser.selectedBrowser, TEST_URL); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL + ); + + Interactions._pageViewStartTime = Cu.now() - 10000; + + BrowserTestUtils.loadURIString(otherWin.gBrowser.selectedBrowser, TEST_URL2); + await BrowserTestUtils.browserLoaded( + otherWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let newWindowViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + // Open a tab in the background window, and then navigate somewhere else, + // this should not record an intereaction. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL3, + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + BrowserTestUtils.loadURIString(tabInOriginalWindow.linkedBrowser, TEST_URL4); + await BrowserTestUtils.browserLoaded( + tabInOriginalWindow.linkedBrowser, + false, + TEST_URL4 + ); + + // Only the interaction of the first tab should be recorded so far. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: newWindowViewTime, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(otherWin); +}); + +add_task(async function test_interactions_private_browsing() { + await Interactions.reset(); + + // Open a tab in the first window. + let tabInOriginalWindow = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + }); + + // and then load the second window. + Interactions._pageViewStartTime = Cu.now() - 10000; + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + BrowserTestUtils.loadURIString( + privateWin.gBrowser.selectedBrowser, + TEST_URL2 + ); + await BrowserTestUtils.browserLoaded( + privateWin.gBrowser.selectedBrowser, + false, + TEST_URL2 + ); + await SimpleTest.promiseFocus(privateWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + let originalWindowViewTime = await getDatabaseValue( + TEST_URL, + "totalViewTime" + ); + + info("Switch back to original window"); + Interactions._pageViewStartTime = Cu.now() - 20000; + // As we're checking for a non-action, wait for the focus to have definitely + // completed, and then let the event queues clear. + await SimpleTest.promiseFocus(window); + await TestUtils.waitForTick(); + + // The private window site should not be recorded. + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: originalWindowViewTime, + }, + ]); + + info("Switch back to new window"); + Interactions._pageViewStartTime = Cu.now() - 30000; + await SimpleTest.promiseFocus(privateWin); + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: originalWindowViewTime + 30000, + }, + ]); + + BrowserTestUtils.removeTab(tabInOriginalWindow); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_interactions_idle() { + await Interactions.reset(); + let lastViewTime; + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + Interactions.observe(null, "idle", ""); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + ]); + lastViewTime = await getDatabaseValue(TEST_URL, "totalViewTime"); + + Interactions._pageViewStartTime = Cu.now() - 20000; + + Interactions.observe(null, "active", ""); + + await assertDatabaseValues([ + { + url: TEST_URL, + exactTotalViewTime: lastViewTime, + }, + ]); + + Interactions._pageViewStartTime = Cu.now() - 30000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: lastViewTime + 30000, + maxViewTime: lastViewTime + 30000 + 10000, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js b/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js new file mode 100644 index 0000000000..857a846417 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/browser_interactions_view_time_dom_history.js @@ -0,0 +1,123 @@ +/* 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/. */ + +/** + * Tests page view time recording for interactions after DOM history API usage. + */ + +const TEST_URL = "https://example.com/"; +const TEST_URL2 = "https://example.com/browser"; + +add_task(async function test_interactions_pushState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.pushState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_pushState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.pushState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_replaceState() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL2, url => { + content.history.replaceState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 10000, + }, + { + url: TEST_URL2, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_replaceState_sameUrl() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL, url => { + content.history.replaceState(null, "", url); + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); + +add_task(async function test_interactions_hashchange() { + await Interactions.reset(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + Interactions._pageViewStartTime = Cu.now() - 10000; + + await ContentTask.spawn(browser, TEST_URL + "#foo", url => { + content.location = url; + }); + + Interactions._pageViewStartTime = Cu.now() - 20000; + }); + + await assertDatabaseValues([ + { + url: TEST_URL, + totalViewTime: 20000, + }, + ]); +}); diff --git a/browser/components/places/tests/browser/interactions/head.js b/browser/components/places/tests/browser/interactions/head.js new file mode 100644 index 0000000000..91aff9d100 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/head.js @@ -0,0 +1,200 @@ +/* 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/. */ + +const { Interactions } = ChromeUtils.importESModule( + "resource:///modules/Interactions.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "pageViewIdleTime", + "browser.places.interactions.pageViewIdleTime", + 60 +); + +add_setup(async function global_setup() { + // Disable idle management because it interacts with our code, causing + // unexpected intermittent failures, we'll fake idle notifications when + // we need to test it. + let idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + idleService.removeIdleObserver(Interactions, pageViewIdleTime); + registerCleanupFunction(() => { + idleService.addIdleObserver(Interactions, pageViewIdleTime); + }); + + // Clean interactions for each test. + await Interactions.reset(); + registerCleanupFunction(async () => { + await Interactions.reset(); + }); +}); + +/** + * Ensures that a list of interactions have been permanently stored. + * + * @param {Array} expected list of interactions to be found. + * @param {boolean} [dontFlush] Avoid flushing pending data. + */ +async function assertDatabaseValues(expected, { dontFlush = false } = {}) { + await Interactions.interactionUpdatePromise; + if (!dontFlush) { + await Interactions.store.flush(); + } + + let interactions = await PlacesUtils.withConnectionWrapper( + "head.js::assertDatabaseValues", + async db => { + let rows = await db.execute(` + SELECT h.url AS url, h2.url as referrer_url, total_view_time, key_presses, typing_time, scrolling_time, scrolling_distance + FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + LEFT JOIN moz_places h2 ON h2.id = m.referrer_place_id + ORDER BY created_at ASC + `); + return rows.map(r => ({ + url: r.getResultByName("url"), + referrerUrl: r.getResultByName("referrer_url"), + keypresses: r.getResultByName("key_presses"), + typingTime: r.getResultByName("typing_time"), + totalViewTime: r.getResultByName("total_view_time"), + scrollingTime: r.getResultByName("scrolling_time"), + scrollingDistance: r.getResultByName("scrolling_distance"), + })); + } + ); + info( + `Found ${interactions.length} interactions:\n ${JSON.stringify( + interactions + )}` + ); + Assert.equal( + interactions.length, + expected.length, + "Found the expected number of entries" + ); + for (let i = 0; i < Math.min(expected.length, interactions.length); i++) { + let actual = interactions[i]; + Assert.equal( + actual.url, + expected[i].url, + "Should have saved the page into the database" + ); + + if (expected[i].exactTotalViewTime != undefined) { + Assert.equal( + actual.totalViewTime, + expected[i].exactTotalViewTime, + "Should have kept the exact time." + ); + } else if (expected[i].totalViewTime != undefined) { + Assert.greaterOrEqual( + actual.totalViewTime, + expected[i].totalViewTime, + "Should have stored the interaction time" + ); + } + + if (expected[i].maxViewTime != undefined) { + Assert.less( + actual.totalViewTime, + expected[i].maxViewTime, + "Should have recorded an interaction below the maximum expected" + ); + } + + if (expected[i].keypresses != undefined) { + Assert.equal( + actual.keypresses, + expected[i].keypresses, + "Should have saved the keypresses into the database" + ); + } + + if (expected[i].exactTypingTime != undefined) { + Assert.equal( + actual.typingTime, + expected[i].exactTypingTime, + "Should have stored the exact typing time." + ); + } else if (expected[i].typingTimeIsGreaterThan != undefined) { + Assert.greater( + actual.typingTime, + expected[i].typingTimeIsGreaterThan, + "Should have stored at least this amount of typing time." + ); + } else if (expected[i].typingTimeIsLessThan != undefined) { + Assert.less( + actual.typingTime, + expected[i].typingTimeIsLessThan, + "Should have stored less than this amount of typing time." + ); + } + + if (expected[i].exactScrollingDistance != undefined) { + Assert.equal( + actual.scrollingDistance, + expected[i].exactScrollingDistance, + "Should have scrolled by exactly least this distance" + ); + } else if (expected[i].exactScrollingTime != undefined) { + Assert.greater( + actual.scrollingTime, + expected[i].exactScrollingTime, + "Should have scrolled for exactly least this duration" + ); + } + + if (expected[i].scrollingDistanceIsGreaterThan != undefined) { + Assert.greater( + actual.scrollingDistance, + expected[i].scrollingDistanceIsGreaterThan, + "Should have scrolled by at least this distance" + ); + } else if (expected[i].scrollingTimeIsGreaterThan != undefined) { + Assert.greater( + actual.scrollingTime, + expected[i].scrollingTimeIsGreaterThan, + "Should have scrolled for at least this duration" + ); + } + } +} + +/** + * Ensures that a list of interactions have been permanently stored. + * + * @param {string} url The url to query. + * @param {string} property The property to extract. + */ +async function getDatabaseValue(url, property) { + await Interactions.store.flush(); + const PROP_TRANSLATOR = { + totalViewTime: "total_view_time", + keypresses: "key_presses", + typingTime: "typing_time", + }; + property = PROP_TRANSLATOR[property] || property; + + return PlacesUtils.withConnectionWrapper( + "head.js::getDatabaseValue", + async db => { + let rows = await db.execute( + ` + SELECT * FROM moz_places_metadata m + JOIN moz_places h ON h.id = m.place_id + WHERE url = :url + ORDER BY created_at DESC + `, + { url } + ); + return rows?.[0].getResultByName(property); + } + ); +} diff --git a/browser/components/places/tests/browser/interactions/scrolling.html b/browser/components/places/tests/browser/interactions/scrolling.html new file mode 100644 index 0000000000..ce435097e6 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/scrolling.html @@ -0,0 +1,121 @@ +<!DOCTYPE HTML> + +<html lang="en"> +<head> + <meta http-equiv="Content-Type" content="text/html;charset=windows-1252"> + <style> + .div { + height: 250px; + width: 250px; + overflow: auto; + } + + .content { + height: 800px; + width: 2000px; + background-color: coral; + } + </style> +</head> +<body> + + <h1 id="heading" >Scrolling interaction tests</h1> + + <br><br><br> + + <div class="div" id="scroll_div" > + <div class="content" id="scrollable_div_content" tabindex="-1"> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. <br> Maecenas vitae condimentum erat, vel luctus nulla. <br>Praesent nulla magna, rhoncus quis velit eu, dapibus efficitur lectus. <br>Duis varius eleifend ex. <br>Vivamus tristique faucibus tortor, in commodo felis posuere eu. <br>Sed aliquam tristique enim at volutpat. <br>Phasellus a aliquam quam, ac hendrerit libero. <br>Vivamus erat mi, mattis sit amet gravida id, malesuada ac lacus. <br>Ut commodo lobortis egestas. <br>Vivamus vulputate nisi non ligula ullamcorper pulvinar. <br>Phasellus tincidunt leo et venenatis auctor. <br>Proin ac urna et risus ullamcorper viverra. <br>Morbi non nunc at augue tincidunt scelerisque. <br>Proin vel ligula erat. <br>Aliquam dignissim pharetra leo, sit amet molestie sem efficitur non. + Donec mattis porttitor consectetur. <br>Nulla vulputate metus quis tellus pharetra, id maximus lacus mattis. <br>Donec a mi mattis, feugiat lectus a, rutrum leo. <br>Suspendisse et tellus urna. <br>Quisque porta ex at efficitur venenatis. <br>Sed volutpat malesuada mauris, sed dapibus dolor faucibus sed. <br>Pellentesque tristique tortor a nisl imperdiet convallis. <br>Curabitur ornare pharetra lacus at luctus. <br> + </div> + </div> + + <br> + + <br><br> + + <a href="#bottom" id="to_bottom_anchor">click to scroll to bottom</a> + <a id="top"></a> + + <br> + <iframe title="subframe" src="scrolling_subframe.html" id="subframe"> </iframe> + + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + + <div id="lowerDiv" class="div" > + <div class="content"> Scroll inside me!</div> + </div> + + <h1 id="middleHeading" > Middle </h1> + This is the middle anchor #1<a id="middleAnchor"></a> + + <a href="#top">click to scroll to top</a> + + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + <br><br><br><br><br><br><br><br><br><br> + This is the bottom anchor #3<a id="bottom"></a> + +</body> +</html> diff --git a/browser/components/places/tests/browser/interactions/scrolling_subframe.html b/browser/components/places/tests/browser/interactions/scrolling_subframe.html new file mode 100644 index 0000000000..55ad70f295 --- /dev/null +++ b/browser/components/places/tests/browser/interactions/scrolling_subframe.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> +<html lang="en"> +<head> + <meta http-equiv="Content-Type" content="text/html;charset=windows-1252"> + <style> + .div { + height: 250px; + width: 250px; + overflow: auto; + } + .content { + height: 800px; + width: 2000px; + background-color: coral; + } + </style> +</head> +<body> + + Subframe Content<br> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. <br> Maecenas vitae condimentum erat, vel luctus nulla. <br>Praesent nulla magna, rhoncus quis velit eu, dapibus efficitur lectus. <br>Duis varius eleifend ex. <br>Vivamus tristique faucibus tortor, in commodo felis posuere eu. <br>Sed aliquam tristique enim at volutpat. <br>Phasellus a aliquam quam, ac hendrerit libero. <br>Vivamus erat mi, mattis sit amet gravida id, malesuada ac lacus. <br>Ut commodo lobortis egestas. <br>Vivamus vulputate nisi non ligula ullamcorper pulvinar. <br>Phasellus tincidunt leo et venenatis auctor. <br>Proin ac urna et risus ullamcorper viverra. <br>Morbi non nunc at augue tincidunt scelerisque. <br>Proin vel ligula erat. <br>Aliquam dignissim pharetra leo, sit amet molestie sem efficitur non. + Donec mattis porttitor consectetur. <br>Nulla vulputate metus quis tellus pharetra, id maximus lacus mattis. <br>Donec a mi mattis, feugiat lectus a, rutrum leo. <br>Suspendisse et tellus urna. <br>Quisque porta ex at efficitur venenatis. <br>Sed volutpat malesuada mauris, sed dapibus dolor faucibus sed. <br>Pellentesque tristique tortor a nisl imperdiet convallis. <br>Curabitur ornare pharetra lacus at luctus. <br> + +</body> +</html> diff --git a/browser/components/places/tests/browser/keyword_form.html b/browser/components/places/tests/browser/keyword_form.html new file mode 100644 index 0000000000..a881c0d5ad --- /dev/null +++ b/browser/components/places/tests/browser/keyword_form.html @@ -0,0 +1,17 @@ +<!DOCTYPE HTML> + +<html lang="en"> +<head> + <meta http-equiv="Content-Type" content="text/html;charset=windows-1252"> +</head> +<body> + <form id="form1" method="POST" action="keyword_form.html"> + <input type="hidden" name="accenti" value="àèìòù"> + <input type="text" name="search"> + </form> + <form id="form2" method="POST" action="keyword_form.html"> + <input type="hidden" name="accenti" value="ùòìèà"> + <input type="text" name="search"> + </form> +</body> +</html> diff --git a/browser/components/places/tests/browser/pageopeningwindow.html b/browser/components/places/tests/browser/pageopeningwindow.html new file mode 100644 index 0000000000..e98c7242ab --- /dev/null +++ b/browser/components/places/tests/browser/pageopeningwindow.html @@ -0,0 +1,11 @@ +<meta charset="UTF-8"> +Hi, I was opened via a <script> +// eslint-disable-next-line no-unsanitized/method +document.write(location.search ? + "popup call from the opened window... uh oh, that shouldn't happen!" : + "bookmarklet, and I will open a new window myself.");</script><br> +<script> + if (!location.search) { + open(location.href + "?donotopen=true", "_blank"); + } +</script> diff --git a/browser/components/places/tests/browser/sidebarpanels_click_test_page.html b/browser/components/places/tests/browser/sidebarpanels_click_test_page.html new file mode 100644 index 0000000000..c73eaa5403 --- /dev/null +++ b/browser/components/places/tests/browser/sidebarpanels_click_test_page.html @@ -0,0 +1,7 @@ +<html> +<head> + <title>browser_sidebarpanels_click.js test page</title> +</head> +<body onload="alert('test');"> +</body> +</html> diff --git a/browser/components/places/tests/chrome/chrome.ini b/browser/components/places/tests/chrome/chrome.ini new file mode 100644 index 0000000000..3ca7ebdd60 --- /dev/null +++ b/browser/components/places/tests/chrome/chrome.ini @@ -0,0 +1,10 @@ +[DEFAULT] +support-files = head.js + +[test_0_bug510634.xhtml] +[test_bug1163447_selectItems_through_shortcut.xhtml] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1532775 +[test_bug549192.xhtml] +[test_bug549491.xhtml] +[test_selectItems_on_nested_tree.xhtml] +[test_treeview_date.xhtml]
\ No newline at end of file diff --git a/browser/components/places/tests/chrome/head.js b/browser/components/places/tests/chrome/head.js new file mode 100644 index 0000000000..6a19fd89d3 --- /dev/null +++ b/browser/components/places/tests/chrome/head.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Services.scriptloader.loadSubScript( + "chrome://global/content/globalOverlay.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://browser/content/utilityOverlay.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +ChromeUtils.defineESModuleGetters(window, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", +}); + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyScriptGetter( + window, + ["PlacesTreeView"], + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + window, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); diff --git a/browser/components/places/tests/chrome/test_0_bug510634.xhtml b/browser/components/places/tests/chrome/test_0_bug510634.xhtml new file mode 100644 index 0000000000..8cac56ff7c --- /dev/null +++ b/browser/components/places/tests/chrome/test_0_bug510634.xhtml @@ -0,0 +1,100 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="510634: Wrong icons on bookmarks sidebar" + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"> + <![CDATA[ + + /** + * Bug 510634 - Wrong icons on bookmarks sidebar + * https://bugzilla.mozilla.org/show_bug.cgi?id=510634 + * + * Ensures that properties for special queries are set on their tree nodes. + */ + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`; + + // The query-property is set on the title column for each row. + let titleColumn = tree.columns.getColumnAt(0); + + // Open All Bookmarks + tree.selectItems([PlacesUtils.virtualAllBookmarksGuid]); + PlacesUtils.asContainer(tree.selectedNode).containerOpen = true; + is(tree.selectedNode.uri, + "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY, + "Opened All Bookmarks"); + + const topLevelGuids = [ + PlacesUtils.virtualHistoryGuid, + PlacesUtils.virtualDownloadsGuid, + PlacesUtils.virtualTagsGuid, + PlacesUtils.virtualAllBookmarksGuid + ]; + + for (let queryName of topLevelGuids) { + let found = false; + for (let i = 0; i < tree.view.rowCount && !found; i++) { + let rowProperties = tree.view.getCellProperties(i, titleColumn).split(" "); + found = rowProperties.includes("OrganizerQuery_" + queryName); + } + ok(found, `OrganizerQuery_${queryName} is set`); + } + + const folderGuids = [ + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.unfiledGuid, + ]; + + for (let guid of folderGuids) { + let found = false; + for (let i = 0; i < tree.view.rowCount && !found; i++) { + let rowProperties = tree.view.getCellProperties(i, titleColumn).split(" "); + found = rowProperties.includes("queryFolder_" + guid); + } + ok(found, `queryFolder_${guid} is set`); + } + + // Close the root node + tree.result.root.containerOpen = false; + + SimpleTest.finish(); + } + + ]]> + </script> +</window> diff --git a/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml b/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml new file mode 100644 index 0000000000..03f5d92572 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xhtml @@ -0,0 +1,88 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="1163447: selectItems in Places no longer selects items within Toolbar or Sidebar folders" + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"><![CDATA[ + + /** + * Bug 1163447: places-tree should be able to select an item within the toolbar, and + * unfiled bookmarks. Yet not follow recursive folder-shortcuts infinitely. + */ + + function runTest() { + SimpleTest.waitForExplicitFinish(); + + (async function() { + let bmu = PlacesUtils.bookmarks; + + await bmu.insert({ + parentGuid: bmu.toolbarGuid, + index: bmu.DEFAULT_INDEX, + type: bmu.TYPE_BOOKMARK, + url: `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`, + title: "shortcut to self - causing infinite recursion if not handled properly" + }); + + await bmu.insert({ + parentGuid: bmu.toolbarGuid, + index: bmu.DEFAULT_INDEX, + type: bmu.TYPE_BOOKMARK, + url: `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`, + title: "shortcut to unfiled, within toolbar" + }); + + let folder = await bmu.insert({ + parentGuid: bmu.unfiledGuid, + index: bmu.DEFAULT_INDEX, + type: bmu.TYPE_FOLDER, + title: "folder within unfiled" + }); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`; + + // Select the folder via the selectItems(folder.guid) API being tested + tree.selectItems([folder.guid]); + + is(tree.selectedNode && tree.selectedNode.bookmarkGuid, folder.guid, "The node was selected through the shortcut"); + + // Cleanup + await bmu.eraseEverything(); + + })().catch(err => { + ok(false, `Uncaught error: ${err}`); + }).then(SimpleTest.finish); + } + ]]></script> +</window> diff --git a/browser/components/places/tests/chrome/test_bug549192.xhtml b/browser/components/places/tests/chrome/test_bug549192.xhtml new file mode 100644 index 0000000000..9f00e8b9c5 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug549192.xhtml @@ -0,0 +1,130 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="549192: History view not updated after deleting entry" + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"><![CDATA[ + /** + * Bug 874407 + * Ensures that history views are updated properly after visits. + * Bug 549192 + * Ensures that history views are updated after deleting entries. + */ + + function runTest() { + SimpleTest.waitForExplicitFinish(); + + (async function() { + await PlacesUtils.history.clear(); + + // Add some visits. + let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000); + + function newTimeInMicroseconds() { + timeInMicroseconds = timeInMicroseconds + 1000; + return timeInMicroseconds; + } + + const transition = PlacesUtils.history.TRANSITIONS.TYPED; + let places = + [{ uri: Services.io.newURI("http://example.tld/"), + visitDate: newTimeInMicroseconds(), transition }, + { uri: Services.io.newURI("http://example2.tld/"), + visitDate: newTimeInMicroseconds(), transition }, + { uri: Services.io.newURI("http://example3.tld/"), + visitDate: newTimeInMicroseconds(), transition }]; + + await PlacesTestUtils.addVisits(places); + + // Make a history query. + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let queryURI = PlacesUtils.history.queryToQueryString(query, opts); + + // Setup the places tree contents. + var tree = document.getElementById("tree"); + tree.place = queryURI; + + // loop through the rows and check them. + let treeView = tree.view; + let selection = treeView.selection; + let rc = treeView.rowCount; + + for (let i = 0; i < rc; i++) { + selection.select(i); + let node = tree.selectedNode; + is(node.uri, places[rc - i - 1].uri.spec, + "Found expected node at position " + i + "."); + } + + is(rc, 3, "Found expected number of rows."); + + // First check live-update of the view when adding visits. + places.forEach(place => place.visitDate = newTimeInMicroseconds()); + await PlacesTestUtils.addVisits(places); + + for (let i = 0; i < rc; i++) { + selection.select(i); + let node = tree.selectedNode; + is(node.uri, places[rc - i - 1].uri.spec, + "Found expected node at position " + i + "."); + } + + // Now remove the pages and verify live-update again. + for (let i = 0; i < rc; i++) { + selection.select(0); + let node = tree.selectedNode; + + const promiseRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === node.uri + ); + + tree.controller.remove(); + + const removeEvents = await promiseRemoved; + ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + ok(treeView.treeIndexForNode(node) == -1, node.uri + " removed."); + is(treeView.rowCount, rc - i - 1, "Rows count decreased"); + } + + // Cleanup. + await PlacesUtils.history.clear(); + })().then(() => SimpleTest.finish()); + } + ]]></script> +</window> diff --git a/browser/components/places/tests/chrome/test_bug549491.xhtml b/browser/components/places/tests/chrome/test_bug549491.xhtml new file mode 100644 index 0000000000..03fee4cc06 --- /dev/null +++ b/browser/components/places/tests/chrome/test_bug549491.xhtml @@ -0,0 +1,78 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="549491: 'The root node is never visible' exception when details of the root node are modified " + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Date" anonid="date" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"><![CDATA[ + /** + * Bug 549491 + * https://bugzilla.mozilla.org/show_bug.cgi?id=549491 + * + * Ensures that changing the details of places tree's root-node doesn't + * throw. + */ + + function runTest() { + SimpleTest.waitForExplicitFinish(); + + (async function() { + await PlacesUtils.history.clear(); + + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://example.tld/"), + transition: PlacesUtils.history.TRANSITION_TYPED + }); + + // Make a history query. + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + let queryURI = PlacesUtils.history.queryToQueryString(query, opts); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = queryURI; + + let rootNode = tree.result.root; + let obs = tree.view.QueryInterface(Ci.nsINavHistoryResultObserver); + obs.nodeHistoryDetailsChanged(rootNode, rootNode.time, rootNode.accessCount); + obs.nodeTitleChanged(rootNode, rootNode.title); + ok(true, "No exceptions thrown"); + + // Cleanup. + await PlacesUtils.history.clear(); + })().then(SimpleTest.finish); + } + ]]></script> +</window> diff --git a/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml b/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml new file mode 100644 index 0000000000..6dc2d33041 --- /dev/null +++ b/browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xhtml @@ -0,0 +1,85 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="549192: History view not updated after deleting entry" + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"><![CDATA[ + /** + * Ensure that selectItems doesn't recurse infinitely in nested trees. + */ + + function runTest() { + SimpleTest.waitForExplicitFinish(); + + (async function() { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`, + title: "shortcut" + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `place:parent=${PlacesUtils.bookmarks.unfiledGuid}&maxResults=10`, + title: "query" + }); + + let folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "folder" + }); + + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: folder.guid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://www.mozilla.org/", + title: "bookmark" + }); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`; + + // Select the last bookmark. + tree.selectItems([bm.guid]); + is (tree.selectedNode.bookmarkGuid, bm.guid, "The right node was selected"); + })().then(SimpleTest.finish); + } + ]]></script> +</window> diff --git a/browser/components/places/tests/chrome/test_treeview_date.xhtml b/browser/components/places/tests/chrome/test_treeview_date.xhtml new file mode 100644 index 0000000000..8a7853194d --- /dev/null +++ b/browser/components/places/tests/chrome/test_treeview_date.xhtml @@ -0,0 +1,159 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="435322: Places tree view's formatting" + onload="runTest();"> + + <script type="application/javascript" + src="chrome://browser/content/places/places-tree.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript" src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + is="places-tree" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" style="order: 1;" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Tags" id="tags" anonid="tags" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Url" id="url" anonid="url" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Visit Date" id="date" anonid="date" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Visit Count" id="visitCount" anonid="visitCount" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script type="application/javascript"> + <![CDATA[ + + /** + * Bug 435322 + * https://bugzilla.mozilla.org/show_bug.cgi?id=435322 + * + * Ensures that date in places treeviews is correctly formatted. + */ + + function runTest() { + SimpleTest.waitForExplicitFinish(); + + function uri(spec) { + return Services.io.newURI(spec); + } + + (async function() { + await PlacesUtils.history.clear(); + + let midnight = new Date(); + midnight.setHours(0); + midnight.setMinutes(0); + midnight.setSeconds(0); + midnight.setMilliseconds(0); + + // Add a visit 1ms before midnight, a visit at midnight, and + // a visit 1ms after midnight. + await PlacesTestUtils.addVisits([ + {uri: uri("http://before.midnight.com/"), + visitDate: (midnight.getTime() - 1) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED}, + {uri: uri("http://at.midnight.com/"), + visitDate: (midnight.getTime()) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED}, + {uri: uri("http://after.midnight.com/"), + visitDate: (midnight.getTime() + 1) * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED} + ]); + + // add a bookmark to the midnight visit + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + url: "http://at.midnight.com/", + title: "A bookmark at midnight", + type: PlacesUtils.bookmarks.TYPE_BOOKMARK + }); + + // Make a history query. + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + let queryURI = PlacesUtils.history.queryToQueryString(query, opts); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = queryURI; + + // loop through the rows and check formatting + let treeView = tree.view; + let rc = treeView.rowCount; + ok(rc >= 3, "Rows found"); + let columns = tree.columns; + ok(columns.count > 0, "Columns found"); + for (let r = 0; r < rc; r++) { + let node = treeView.nodeForTreeIndex(r); + ok(node, "Places node found"); + for (let ci = 0; ci < columns.count; ci++) { + let c = columns.getColumnAt(ci); + let text = treeView.getCellText(r, c); + switch (c.element.getAttribute("anonid")) { + case "title": + // The title can differ, we did not set any title so we would + // expect null, but in such a case the view will generate a title + // through PlacesUIUtils.getBestTitle. + if (node.title) + is(text, node.title, "Title is correct"); + break; + case "url": + is(text, node.uri, "Uri is correct"); + break; + case "date": + let timeObj = new Date(node.time / 1000); + // Default is short date format. + let dtOptions = { + dateStyle: "short", + timeStyle: "short" + }; + + // For today's visits we don't show date portion. + if (node.uri == "http://at.midnight.com/" || + node.uri == "http://after.midnight.com/") { + dtOptions.dateStyle = undefined; + } else if (node.uri != "http://before.midnight.com/") { + // Avoid to test spurious uris, due to how the test works + // a redirecting uri could be put in the tree while we test. + break; + } + let timeStr = new Services.intl.DateTimeFormat(undefined, dtOptions).format(timeObj); + + is(text, timeStr, "Date format is correct"); + break; + case "visitCount": + is(text, 1, "Visit count is correct"); + break; + } + } + } + + // Cleanup. + await PlacesUtils.bookmarks.remove(bm.guid); + await PlacesUtils.history.clear(); + })().then(SimpleTest.finish); + } + ]]> + </script> +</window> diff --git a/browser/components/places/tests/unit/bookmarks.glue.html b/browser/components/places/tests/unit/bookmarks.glue.html new file mode 100644 index 0000000000..07b22e9b3f --- /dev/null +++ b/browser/components/places/tests/unit/bookmarks.glue.html @@ -0,0 +1,16 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1>Bookmarks Menu</H1> + +<DL><p> + <DT><A HREF="http://example.com/" ADD_DATE="1233157972" LAST_MODIFIED="1233157984">example</A> + <DT><H3 ADD_DATE="1233157910" LAST_MODIFIED="1233157972" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> +<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="http://example.com/" ADD_DATE="1233157972" LAST_MODIFIED="1233157984">example</A> + </DL><p> +</DL><p> diff --git a/browser/components/places/tests/unit/bookmarks.glue.json b/browser/components/places/tests/unit/bookmarks.glue.json new file mode 100644 index 0000000000..069f605d29 --- /dev/null +++ b/browser/components/places/tests/unit/bookmarks.glue.json @@ -0,0 +1,83 @@ +{ + "title": "", + "id": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157955206833, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "title": "Bookmarks Menu", + "id": 2, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157993171424, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "title": "examplejson", + "id": 27, + "parent": 2, + "dateAdded": 1233157972101126, + "lastModified": 1233157984999673, + "type": "text/x-moz-place", + "uri": "http://example.com/" + } + ] + }, + { + "index": 1, + "title": "Bookmarks Toolbar", + "id": 3, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157972101126, + "annos": [ + { + "name": "bookmarkProperties/description", + "flags": 0, + "expires": 4, + "mimeType": null, + "type": 3, + "value": "Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar" + } + ], + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "title": "examplejson", + "id": 26, + "parent": 3, + "dateAdded": 1233157972101126, + "lastModified": 1233157984999673, + "type": "text/x-moz-place", + "uri": "http://example.com/" + } + ] + }, + { + "index": 2, + "title": "Tags", + "id": 4, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157910582667, + "type": "text/x-moz-place-container", + "root": "tagsFolder", + "children": [] + }, + { + "index": 3, + "title": "Other Bookmarks", + "id": 5, + "parent": 1, + "dateAdded": 1233157910552624, + "lastModified": 1233157911033315, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder", + "children": [] + } + ] +} diff --git a/browser/components/places/tests/unit/corruptDB.sqlite b/browser/components/places/tests/unit/corruptDB.sqlite Binary files differnew file mode 100644 index 0000000000..b234246cac --- /dev/null +++ b/browser/components/places/tests/unit/corruptDB.sqlite diff --git a/browser/components/places/tests/unit/distribution.ini b/browser/components/places/tests/unit/distribution.ini new file mode 100644 index 0000000000..a25c40fed3 --- /dev/null +++ b/browser/components/places/tests/unit/distribution.ini @@ -0,0 +1,30 @@ +# Distribution Configuration File +# Bug 516444 demo + +[Global] +id=516444 +version=1.0 +about=Test distribution file + +[BookmarksToolbar] +item.1.title=Toolbar Link Before +item.1.link=https://example.org/toolbar/before/ +item.1.icon=https://example.org/favicon.png +item.1.iconData=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg== +item.2.type=default +item.3.type=folder +item.3.title=Toolbar Folder After +item.3.folderId=1 + +[BookmarksMenu] +item.1.title=Menu Link Before +item.1.link=https://example.org/menu/before/ +item.1.icon=https://example.org/favicon.png +item.1.iconData=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg== +item.2.type=default +item.3.title=Menu Link After +item.3.link=https://example.org/menu/after/ + +[BookmarksFolder-1] +item.1.title=Toolbar Link Folder +item.1.link=https://example.org/toolbar/folder/ diff --git a/browser/components/places/tests/unit/head_bookmarks.js b/browser/components/places/tests/unit/head_bookmarks.js new file mode 100644 index 0000000000..8aabfb3832 --- /dev/null +++ b/browser/components/places/tests/unit/head_bookmarks.js @@ -0,0 +1,89 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +// Import common head. +/* import-globals-from ../../../../../toolkit/components/places/tests/head_common.js */ +var commonFile = do_get_file( + "../../../../../toolkit/components/places/tests/head_common.js", + false +); +if (commonFile) { + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + +ChromeUtils.defineESModuleGetters(this, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", +}); + +// Needed by some test that relies on having an app registered. +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "PlacesTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +// Default bookmarks constants. +const DEFAULT_BOOKMARKS_ON_TOOLBAR = 1; +const DEFAULT_BOOKMARKS_ON_MENU = 1; + +function checkItemHasAnnotation(guid, name) { + return PlacesUtils.promiseItemId(guid).then(id => { + let hasAnnotation = PlacesUtils.annotations.itemHasAnnotation(id, name); + Assert.ok(hasAnnotation, `Expected annotation ${name}`); + }); +} + +var createCorruptDB = async function () { + let dbPath = PathUtils.join(PathUtils.profileDir, "places.sqlite"); + await IOUtils.remove(dbPath); + + // Create a corrupt database. + let src = PathUtils.join(do_get_cwd().path, "corruptDB.sqlite"); + await IOUtils.copy(src, dbPath); + + // Check there's a DB now. + Assert.ok(await IOUtils.exists(dbPath), "should have a DB now"); +}; + +const SINGLE_TRY_TIMEOUT = 100; +const NUMBER_OF_TRIES = 30; + +/** + * Similar to waitForConditionPromise, but poll for an asynchronous value + * every SINGLE_TRY_TIMEOUT ms, for no more than tryCount times. + * + * @param {Function} promiseFn + * A function to generate a promise, which resolves to the expected + * asynchronous value. + * @param {msg} timeoutMsg + * The reason to reject the returned promise with. + * @param {number} [tryCount] + * Maximum times to try before rejecting the returned promise with + * timeoutMsg, defaults to NUMBER_OF_TRIES. + * @returns {Promise} to the asynchronous value being polled. + * @throws if the asynchronous value is not available after tryCount attempts. + */ +var waitForResolvedPromise = async function ( + promiseFn, + timeoutMsg, + tryCount = NUMBER_OF_TRIES +) { + let tries = 0; + do { + try { + let value = await promiseFn(); + return value; + } catch (ex) {} + await new Promise(resolve => do_timeout(SINGLE_TRY_TIMEOUT, resolve)); + } while (++tries <= tryCount); + throw new Error(timeoutMsg); +}; diff --git a/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js b/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js new file mode 100644 index 0000000000..58b68d7574 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_batchUpdatesForNode.js @@ -0,0 +1,107 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +/* eslint-disable mozilla/use-chromeutils-generateqi */ + +add_task(async function test_no_result_node() { + let functionSpy = sinon.stub().returns(Promise.resolve()); + + await PlacesUIUtils.batchUpdatesForNode(null, 1, functionSpy); + + Assert.ok( + functionSpy.calledOnce, + "Passing a null result node should still call the wrapped function" + ); +}); + +add_task(async function test_under_batch_threshold() { + let functionSpy = sinon.stub().returns(Promise.resolve()); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + await PlacesUIUtils.batchUpdatesForNode(resultNode, 1, functionSpy); + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.notCalled, + "onBeginUpdateBatch should not have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.notCalled, + "onEndUpdateBatch should not have been called" + ); +}); + +add_task(async function test_over_batch_threshold() { + let functionSpy = sinon.stub().callsFake(() => { + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called before the function" + ); + Assert.ok( + resultNode.onEndUpdateBatch.notCalled, + "onEndUpdateBatch should not have been called before the function" + ); + + return Promise.resolve(); + }); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + await PlacesUIUtils.batchUpdatesForNode(resultNode, 100, functionSpy); + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.calledOnce, + "onEndUpdateBatch should have been called" + ); +}); + +add_task(async function test_wrapped_function_throws() { + let error = new Error("Failed!"); + let functionSpy = sinon.stub().throws(error); + let resultNode = { + QueryInterface() { + return this; + }, + onBeginUpdateBatch: sinon.spy(), + onEndUpdateBatch: sinon.spy(), + }; + + let raisedError; + try { + await PlacesUIUtils.batchUpdatesForNode(resultNode, 100, functionSpy); + } catch (ex) { + raisedError = ex; + } + + Assert.ok(functionSpy.calledOnce, "Wrapped function should be called once"); + Assert.ok( + resultNode.onBeginUpdateBatch.calledOnce, + "onBeginUpdateBatch should have been called" + ); + Assert.ok( + resultNode.onEndUpdateBatch.calledOnce, + "onEndUpdateBatch should have been called" + ); + Assert.equal( + raisedError, + error, + "batchUpdatesForNode should have raised the error from the wrapped function" + ); +}); diff --git a/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js b/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js new file mode 100644 index 0000000000..db213971e9 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_setCharsetForPage.js @@ -0,0 +1,141 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); + +const UTF8 = "UTF-8"; +const UTF16 = "UTF-16"; + +const TEST_URI = "http://foo.com"; +const TEST_BOOKMARKED_URI = "http://bar.com"; + +add_task(function setup() { + let savedIsWindowPrivateFunc = PrivateBrowsingUtils.isWindowPrivate; + PrivateBrowsingUtils.isWindowPrivate = () => false; + + registerCleanupFunction(() => { + PrivateBrowsingUtils.isWindowPrivate = savedIsWindowPrivateFunc; + }); +}); + +add_task(async function test_simple_add() { + // add pages to history + await PlacesTestUtils.addVisits(TEST_URI); + await PlacesTestUtils.addVisits(TEST_BOOKMARKED_URI); + + // create bookmarks on TEST_BOOKMARKED_URI + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: TEST_BOOKMARKED_URI, + title: TEST_BOOKMARKED_URI.spec, + }); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: TEST_BOOKMARKED_URI, + title: TEST_BOOKMARKED_URI.spec, + }); + + // set charset on not-bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + // set charset on bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_BOOKMARKED_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a not-bookmarked page" + ); + + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a bookmarked page" + ); + + await PlacesUtils.history.clear(); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.ok( + !pageInfo, + "Should not return pageInfo for a page after history cleared" + ); + + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Charset should still be set for a bookmarked page after history clear" + ); + + await PlacesUIUtils.setCharsetForPage(TEST_BOOKMARKED_URI, ""); + pageInfo = await PlacesUtils.history.fetch(TEST_BOOKMARKED_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should not have a charset after it has been removed from the page" + ); +}); + +add_task(async function test_utf8_clears_saved_anno() { + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(TEST_URI); + + // set charset on bookmarked page + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.equal( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + UTF16, + "Should return correct charset for a not-bookmarked page" + ); + + // Now set the bookmark to a UTF-8 charset. + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF8, {}); + + pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should have removed the charset for a UTF-8 page." + ); +}); + +add_task(async function test_private_browsing_not_saved() { + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(TEST_URI); + + // set charset on bookmarked page, but pretend this is a private browsing window. + PrivateBrowsingUtils.isWindowPrivate = () => true; + await PlacesUIUtils.setCharsetForPage(TEST_URI, UTF16, {}); + + let pageInfo = await PlacesUtils.history.fetch(TEST_URI, { + includeAnnotations: true, + }); + Assert.strictEqual( + pageInfo.annotations.get(PlacesUtils.CHARSET_ANNO), + undefined, + "Should not have set the charset in a private browsing window." + ); +}); diff --git a/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js b/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js new file mode 100644 index 0000000000..aaa4db6bb2 --- /dev/null +++ b/browser/components/places/tests/unit/test_PUIU_title_difference_spotter.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the difference indication for titles. + */ + +const TESTS = [ + { + title: null, + expected: undefined, + }, + { + title: "Short title", + expected: undefined, + }, + { + title: "Short title2", + expected: undefined, + }, + { + title: "Long title same as another", + expected: undefined, + }, + { + title: "Long title same as another", + expected: undefined, + }, + { + title: "Long title with difference at the end 1", + expected: 38, + }, + { + title: "Long title with difference at the end 2", + expected: 38, + }, + { + title: "A long title with difference 123456 in the middle.", + expected: 30, + }, + { + title: "A long title with difference 135246 in the middle.", + expected: 30, + }, + { + title: + "Some long titles with variable 12345678 differences to 13572468 other titles", + expected: 32, + }, + { + title: + "Some long titles with variable 12345678 differences to 15263748 other titles", + expected: 32, + }, + { + title: + "Some long titles with variable 15263748 differences to 12345678 other titles", + expected: 32, + }, + { + title: "One long title which will be shorter than the other one", + expected: 40, + }, + { + title: + "One long title which will be shorter that the other one (not this one)", + expected: 40, + }, +]; + +add_task(async function test_difference_finding() { + PlacesUIUtils.insertTitleStartDiffs(TESTS); + + for (let result of TESTS) { + Assert.equal( + result.titleDifferentIndex, + result.expected, + `Should have returned the correct index for "${result.title}"` + ); + } +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js b/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js new file mode 100644 index 0000000000..65a038487a --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js @@ -0,0 +1,33 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsBrowserGlue correctly exports bookmarks.html at shutdown if + * browser.bookmarks.autoExportHTML is set to true. + */ + +add_task(async function () { + remove_bookmarks_html(); + + Services.prefs.setBoolPref("browser.bookmarks.autoExportHTML", true); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.bookmarks.autoExportHTML") + ); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Initialize Places through the History Service. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + + Services.obs.addObserver(function observer() { + Services.obs.removeObserver(observer, "profile-before-change"); + check_bookmarks_html(); + }, "profile-before-change"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt.js b/browser/components/places/tests/unit/test_browserGlue_corrupt.js new file mode 100644 index 0000000000..0514b06bbc --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt.js @@ -0,0 +1,53 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsBrowserGlue correctly restores bookmarks from a JSON backup if + * database is corrupt and one backup is available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + run_next_test(); +} + +registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + return PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_main() { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once restore has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that JSON backup has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "examplejson"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js new file mode 100644 index 0000000000..a0bc93c74c --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js @@ -0,0 +1,47 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsBrowserGlue correctly imports from bookmarks.html if database + * is corrupt but a JSON backup is not available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + run_next_test(); +} + +registerCleanupFunction(remove_bookmarks_html); + +add_task(async function () { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once import has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that bookmarks html has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "example"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js new file mode 100644 index 0000000000..031d82d9e2 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js @@ -0,0 +1,50 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsBrowserGlue correctly restores default bookmarks if database is + * corrupt, nor a JSON backup nor bookmarks.html are available. + */ + +function run_test() { + // Remove bookmarks.html from profile. + remove_bookmarks_html(); + + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + run_next_test(); +} + +add_task(async function () { + // Create a corrupt database. + await createCorruptDB(); + + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Check the database was corrupt. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CORRUPT + ); + + // The test will continue once import has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that default bookmarks have been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + + // Bug 1283076: Nightly bookmark points to Get Involved page, not Getting Started one + let chanTitle = AppConstants.NIGHTLY_BUILD + ? "Get Involved" + : "Getting Started"; + Assert.equal(bm.title, chanTitle); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_distribution.js b/browser/components/places/tests/unit/test_browserGlue_distribution.js new file mode 100644 index 0000000000..e2bee06bcb --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_distribution.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue correctly imports bookmarks from distribution.ini. + */ + +const PREF_BMPROCESSED = "distribution.516444.bookmarksProcessed"; +const PREF_DISTRIBUTION_ID = "distribution.id"; + +const TOPICDATA_DISTRIBUTION_CUSTOMIZATION = "force-distribution-customization"; +const TOPIC_CUSTOMIZATION_COMPLETE = "distribution-customization-complete"; +const TOPIC_BROWSERGLUE_TEST = "browser-glue-test"; + +function run_test() { + // Set special pref to load distribution.ini from the profile folder. + Services.prefs.setBoolPref("distribution.testing.loadFromProfile", true); + + // Copy distribution.ini file to the profile dir. + let distroDir = gProfD.clone(); + distroDir.leafName = "distribution"; + let iniFile = distroDir.clone(); + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + iniFile.remove(false); + print("distribution.ini already exists, did some test forget to cleanup?"); + } + + let testDistributionFile = gTestDir.clone(); + testDistributionFile.append("distribution.ini"); + testDistributionFile.copyTo(distroDir, "distribution.ini"); + Assert.ok(testDistributionFile.exists()); + + run_next_test(); +} + +registerCleanupFunction(function () { + // Remove the distribution file, even if the test failed, otherwise all + // next tests will import it. + let iniFile = gProfD.clone(); + iniFile.leafName = "distribution"; + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + iniFile.remove(false); + } + Assert.ok(!iniFile.exists()); +}); + +add_task(async function () { + let { DistributionCustomizer } = ChromeUtils.import( + "resource:///modules/distribution.js" + ); + let distribution = new DistributionCustomizer(); + + let glue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + // Initialize Places through the History Service and check that a new + // database has been created. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + // Force distribution. + glue.observe( + null, + TOPIC_BROWSERGLUE_TEST, + TOPICDATA_DISTRIBUTION_CUSTOMIZATION + ); + + // Test will continue on customization complete notification. + await promiseTopicObserved(TOPIC_CUSTOMIZATION_COMPLETE); + + // Check the custom bookmarks exist on menu. + let menuItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + Assert.equal(menuItem.title, "Menu Link Before"); + Assert.ok( + menuItem.guid.startsWith(distribution.BOOKMARK_GUID_PREFIX), + "Guid of this bookmark has expected prefix" + ); + + menuItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 1 + DEFAULT_BOOKMARKS_ON_MENU, + }); + Assert.equal(menuItem.title, "Menu Link After"); + + // Check no favicon exists for this bookmark + await Assert.rejects( + waitForResolvedPromise( + () => { + return PlacesUtils.promiseFaviconData(menuItem.url.href); + }, + "Favicon not found", + 10 + ), + /Favicon\snot\sfound/, + "Favicon not found" + ); + + // Check the custom bookmarks exist on toolbar. + let toolbarItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(toolbarItem.title, "Toolbar Link Before"); + + // Check the custom favicon exist for this bookmark + let faviconItem = await waitForResolvedPromise( + () => { + return PlacesUtils.promiseFaviconData(toolbarItem.url.href); + }, + "Favicon not found", + 10 + ); + Assert.equal(faviconItem.uri.spec, "https://example.org/favicon.png"); + Assert.greater(faviconItem.dataLen, 0); + Assert.equal(faviconItem.mimeType, "image/png"); + + let base64Icon = + "data:image/png;base64," + + base64EncodeString(String.fromCharCode.apply(String, faviconItem.data)); + Assert.equal(base64Icon, SMALLPNG_DATA_URI.spec); + + toolbarItem = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 1 + DEFAULT_BOOKMARKS_ON_TOOLBAR, + }); + Assert.equal(toolbarItem.title, "Toolbar Folder After"); + Assert.ok( + toolbarItem.guid.startsWith(distribution.FOLDER_GUID_PREFIX), + "Guid of this folder has expected prefix" + ); + + // Check the bmprocessed pref has been created. + Assert.ok(Services.prefs.getBoolPref(PREF_BMPROCESSED)); + + // Check distribution prefs have been created. + Assert.equal(Services.prefs.getCharPref(PREF_DISTRIBUTION_ID), "516444"); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_migrate.js b/browser/components/places/tests/unit/test_browserGlue_migrate.js new file mode 100644 index 0000000000..f0f88d2d17 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_migrate.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue does not overwrite bookmarks imported from the + * migrators. They usually run before nsBrowserGlue, so if we find any + * bookmark on init, we should not try to import. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + // Remove current database file. + clearDB(); + + run_next_test(); +} + +registerCleanupFunction(remove_bookmarks_html); + +add_task(async function test_migrate_bookmarks() { + // Initialize Places through the History Service and check that a new + // database has been created. + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + + // A migrator would run before nsBrowserGlue Places initialization, so mimic + // that behavior adding a bookmark and notifying the migration. + let bg = Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsIObserver); + bg.observe(null, "initial-migration-will-import-default-bookmarks", null); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: PlacesUtils.bookmarks.DEFAULT_INDEX, + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: "http://mozilla.org/", + title: "migrated", + }); + + let promise = promiseTopicObserved("places-browser-init-complete"); + bg.observe(null, "initial-migration-did-import-default-bookmarks", null); + await promise; + + // Check the created bookmark still exists. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 0, + }); + Assert.equal(bm.title, "migrated"); + + // Check that we have not imported any new bookmark. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + index: 1, + })) + ); + + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_prefs.js b/browser/components/places/tests/unit/test_browserGlue_prefs.js new file mode 100644 index 0000000000..af1dc3db0e --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_prefs.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that nsBrowserGlue is correctly interpreting the preferences settable + * by the user or by other components. + */ + +const PREF_IMPORT_BOOKMARKS_HTML = "browser.places.importBookmarksHTML"; +const PREF_RESTORE_DEFAULT_BOOKMARKS = + "browser.bookmarks.restore_default_bookmarks"; +const PREF_AUTO_EXPORT_HTML = "browser.bookmarks.autoExportHTML"; + +const TOPIC_BROWSERGLUE_TEST = "browser-glue-test"; +const TOPICDATA_FORCE_PLACES_INIT = "test-force-places-init"; + +var bg = Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsIObserver); + +add_task(async function setup() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + + return PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +function simulatePlacesInit() { + info("Simulate Places init"); + // Force nsBrowserGlue::_initPlaces(). + bg.observe(null, TOPIC_BROWSERGLUE_TEST, TOPICDATA_FORCE_PLACES_INIT); + return promiseTopicObserved("places-browser-init-complete"); +} + +add_task(async function test_checkPreferences() { + // Initialize Places through the History Service and check that a new + // database has been created. + let promiseComplete = promiseTopicObserved("places-browser-init-complete"); + Assert.equal( + PlacesUtils.history.databaseStatus, + PlacesUtils.history.DATABASE_STATUS_CREATE + ); + await promiseComplete; + + // Ensure preferences status. + Assert.ok(!Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + + Assert.throws( + () => Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML), + /NS_ERROR_UNEXPECTED/ + ); + Assert.throws( + () => Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS), + /NS_ERROR_UNEXPECTED/ + ); +}); + +add_task(async function test_import() { + info("Import from bookmarks.html if importBookmarksHTML is true."); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been imported. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "example"); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); +}); + +add_task(async function test_restore() { + info( + "restore from default bookmarks.html if " + + "restore_default_bookmarks is true." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been restored. + Assert.ok( + await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }) + ); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); +}); + +add_task(async function test_restore_import() { + info( + "setting both importBookmarksHTML and " + + "restore_default_bookmarks should restore defaults." + ); + + await PlacesUtils.bookmarks.eraseEverything(); + + // Sanity check: we should not have any bookmark on the toolbar. + Assert.ok( + !(await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + })) + ); + + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + await simulatePlacesInit(); + + // Check bookmarks.html has been restored. + Assert.ok( + await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }) + ); + + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); +}); diff --git a/browser/components/places/tests/unit/test_browserGlue_restore.js b/browser/components/places/tests/unit/test_browserGlue_restore.js new file mode 100644 index 0000000000..98c8d1d2d6 --- /dev/null +++ b/browser/components/places/tests/unit/test_browserGlue_restore.js @@ -0,0 +1,55 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsBrowserGlue correctly restores bookmarks from a JSON backup if + * database has been created and one backup is available. + */ + +function run_test() { + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + + remove_all_JSON_backups(); + + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + + // Remove current database file. + clearDB(); + + run_next_test(); +} + +registerCleanupFunction(function () { + remove_bookmarks_html(); + remove_all_JSON_backups(); + return PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_main() { + // Initialize nsBrowserGlue before Places. + Cc["@mozilla.org/browser/browserglue;1"].getService(Ci.nsISupports); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); + + // Check a new database has been created. + // nsBrowserGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CREATE); + + // The test will continue once restore has finished. + await promiseTopicObserved("places-browser-init-complete"); + + // Check that JSON backup has been restored. + let bm = await PlacesUtils.bookmarks.fetch({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + index: 0, + }); + Assert.equal(bm.title, "examplejson"); +}); diff --git a/browser/components/places/tests/unit/test_clearHistory_shutdown.js b/browser/components/places/tests/unit/test_clearHistory_shutdown.js new file mode 100644 index 0000000000..27b432e569 --- /dev/null +++ b/browser/components/places/tests/unit/test_clearHistory_shutdown.js @@ -0,0 +1,183 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that requesting clear history at shutdown will really clear history. + */ + +const URIS = [ + "http://a.example1.com/", + "http://b.example1.com/", + "http://b.example2.com/", + "http://c.example3.com/", +]; + +const FTP_URL = "ftp://localhost/clearHistoryOnShutdown/"; + +const { Sanitizer } = ChromeUtils.importESModule( + "resource:///modules/Sanitizer.sys.mjs" +); + +// Send the profile-after-change notification to the form history component to ensure +// that it has been initialized. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +var timeInMicroseconds = Date.now() * 1000; + +add_task(async function test_execute() { + info("Initialize browserglue before Places"); + + // Avoid default bookmarks import. + let glue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + glue.observe(null, "initial-migration-will-import-default-bookmarks", null); + Sanitizer.onStartup(); + + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cache", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cookies", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "offlineApps", + true + ); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "history", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "downloads", + true + ); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "cookies", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "formData", true); + Services.prefs.setBoolPref(Sanitizer.PREF_SHUTDOWN_BRANCH + "sessions", true); + Services.prefs.setBoolPref( + Sanitizer.PREF_SHUTDOWN_BRANCH + "siteSettings", + true + ); + + Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true); + + info("Add visits."); + for (let aUrl of URIS) { + await PlacesTestUtils.addVisits({ + uri: uri(aUrl), + visitDate: timeInMicroseconds++, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + info("Add cache."); + await storeCache(FTP_URL, "testData"); + info("Add form history."); + await addFormHistory(); + Assert.equal(await getFormHistoryCount(), 1, "Added form history"); + + info("Simulate and wait shutdown."); + await shutdownPlaces(); + + Assert.equal(await getFormHistoryCount(), 0, "Form history cleared"); + + let stmt = DBConn(true).createStatement( + "SELECT id FROM moz_places WHERE url = :page_url " + ); + + try { + URIS.forEach(function (aUrl) { + stmt.params.page_url = aUrl; + Assert.ok(!stmt.executeStep()); + stmt.reset(); + }); + } finally { + stmt.finalize(); + } + + info("Check cache"); + // Check cache. + await checkCache(FTP_URL); +}); + +function addFormHistory() { + let now = Date.now() * 1000; + return FormHistory.update({ + op: "add", + fieldname: "testfield", + value: "test", + timesUsed: 1, + firstUsed: now, + lastUsed: now, + }); +} + +async function getFormHistoryCount() { + return FormHistory.count({ fieldname: "testfield" }); +} + +function storeCache(aURL, aContent) { + let cache = Services.cache2; + let storage = cache.diskCacheStorage(Services.loadContextInfo.default); + + return new Promise(resolve => { + let storeCacheListener = { + onCacheEntryCheck(entry) { + return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED; + }, + + onCacheEntryAvailable(entry, isnew, status) { + Assert.equal(status, Cr.NS_OK); + + entry.setMetaDataElement("servertype", "0"); + var os = entry.openOutputStream(0, -1); + + var written = os.write(aContent, aContent.length); + if (written != aContent.length) { + do_throw( + "os.write has not written all data!\n" + + " Expected: " + + written + + "\n" + + " Actual: " + + aContent.length + + "\n" + ); + } + os.close(); + entry.close(); + resolve(); + }, + }; + + storage.asyncOpenURI( + Services.io.newURI(aURL), + "", + Ci.nsICacheStorage.OPEN_NORMALLY, + storeCacheListener + ); + }); +} + +function checkCache(aURL) { + let cache = Services.cache2; + let storage = cache.diskCacheStorage(Services.loadContextInfo.default); + + return new Promise(resolve => { + let checkCacheListener = { + onCacheEntryAvailable(entry, isnew, status) { + Assert.equal(status, Cr.NS_ERROR_CACHE_KEY_NOT_FOUND); + resolve(); + }, + }; + + storage.asyncOpenURI( + Services.io.newURI(aURL), + "", + Ci.nsICacheStorage.OPEN_READONLY, + checkCacheListener + ); + }); +} diff --git a/browser/components/places/tests/unit/test_interactions_blocklist.js b/browser/components/places/tests/unit/test_interactions_blocklist.js new file mode 100644 index 0000000000..0e81f80af2 --- /dev/null +++ b/browser/components/places/tests/unit/test_interactions_blocklist.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that blocked sites are caught by InteractionsBlocklist. + */ + +ChromeUtils.defineESModuleGetters(this, { + InteractionsBlocklist: "resource:///modules/InteractionsBlocklist.sys.mjs", +}); + +let BLOCKED_URLS = [ + "https://www.bing.com/search?q=mozilla", + "https://duckduckgo.com/?q=a+test&kp=1&t=ffab", + "https://www.google.com/search?q=mozilla", + "https://www.google.ca/search?q=test", + "https://mozilla.zoom.us/j/123456789", + "https://yandex.az/search/?text=mozilla", + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&tn=baidu&bar=&wd=mozilla&rn=&fenlei=256&oq=&rsv_pq=970f2b8f001757b9&rsv_t=1f5d2V2o80HPdZtZnhodwkc7nZXTvDI1zwdPy%2FAeomnvFFGIrU1F3D9WoK4&rqlang=cn", + "https://accounts.google.com/o/oauth2/v2/auth/identifier/foobar", + "https://auth.mozilla.auth0.com/login/foobar", + "https://accounts.google.com/signin/oauth/consent/foobar", + "https://accounts.google.com/o/oauth2/v2/auth?client_id=ZZZ", + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize/foobar", +]; + +let ALLOWED_URLS = [ + "https://example.com", + "https://zoom.us/pricing", + "https://www.google.ca/maps/place/Toronto,+ON/@43.7181557,-79.5181414,11z/data=!3m1!4b1!4m5!3m4!1s0x89d4cb90d7c63ba5:0x323555502ab4c477!8m2!3d43.653226!4d-79.3831843", + "https://example.com/https://auth.mozilla.auth0.com/login/foobar", +]; + +// Tests that initializing InteractionsBlocklist loads the regexes from the +// customBlocklist pref on initialization. This subtest should always be the +// first one in this file. +add_task(async function blockedOnInit() { + Services.prefs.setStringPref( + "places.interactions.customBlocklist", + '["^(https?:\\\\/\\\\/)?mochi.test"]' + ); + Assert.ok( + InteractionsBlocklist.isUrlBlocklisted("https://mochi.test"), + "mochi.test is blocklisted." + ); + InteractionsBlocklist.removeRegexFromBlocklist("^(https?:\\/\\/)?mochi.test"); + Assert.ok( + !InteractionsBlocklist.isUrlBlocklisted("https://mochi.test"), + "mochi.test is not blocklisted." + ); +}); + +add_task(async function test() { + for (let url of BLOCKED_URLS) { + Assert.ok( + InteractionsBlocklist.isUrlBlocklisted(url), + `${url} is blocklisted.` + ); + } + + for (let url of ALLOWED_URLS) { + Assert.ok( + !InteractionsBlocklist.isUrlBlocklisted(url), + `${url} is not blocklisted.` + ); + } +}); diff --git a/browser/components/places/tests/unit/test_invalid_defaultLocation.js b/browser/components/places/tests/unit/test_invalid_defaultLocation.js new file mode 100644 index 0000000000..e533d051d0 --- /dev/null +++ b/browser/components/places/tests/unit/test_invalid_defaultLocation.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Test that if browser.bookmarks.defaultLocation contains an invalid GUID, + * PlacesUIUtils.defaultParentGuid will return a proper default value. + */ + +add_task(async function () { + Services.prefs.setCharPref( + "browser.bookmarks.defaultLocation", + "useOtherBookmarks" + ); + + info( + "Checking that default parent guid was set back to the toolbar because of invalid preferable guid" + ); + Assert.equal( + await PlacesUIUtils.defaultParentGuid, + PlacesUtils.bookmarks.toolbarGuid, + "Default parent guid is a toolbar guid" + ); +}); diff --git a/browser/components/places/tests/unit/xpcshell.ini b/browser/components/places/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..4c0d0f7c92 --- /dev/null +++ b/browser/components/places/tests/unit/xpcshell.ini @@ -0,0 +1,24 @@ +[DEFAULT] +head = head_bookmarks.js +firefox-appdir = browser +skip-if = toolkit == 'android' # bug 1730213 +support-files = + bookmarks.glue.html + bookmarks.glue.json + corruptDB.sqlite + distribution.ini + +[test_browserGlue_bookmarkshtml.js] +[test_browserGlue_corrupt.js] +[test_browserGlue_corrupt_nobackup.js] +[test_browserGlue_corrupt_nobackup_default.js] +[test_browserGlue_distribution.js] +[test_browserGlue_migrate.js] +[test_browserGlue_prefs.js] +[test_browserGlue_restore.js] +[test_clearHistory_shutdown.js] +[test_interactions_blocklist.js] +[test_invalid_defaultLocation.js] +[test_PUIU_batchUpdatesForNode.js] +[test_PUIU_setCharsetForPage.js] +[test_PUIU_title_difference_spotter.js] |