summaryrefslogtreecommitdiffstats
path: root/browser/components/syncedtabs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/syncedtabs/EventEmitter.sys.mjs36
-rw-r--r--browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs173
-rw-r--r--browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs52
-rw-r--r--browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs90
-rw-r--r--browser/components/syncedtabs/SyncedTabsListStore.sys.mjs253
-rw-r--r--browser/components/syncedtabs/TabListComponent.sys.mjs149
-rw-r--r--browser/components/syncedtabs/TabListView.sys.mjs653
-rw-r--r--browser/components/syncedtabs/jar.mn7
-rw-r--r--browser/components/syncedtabs/moz.build23
-rw-r--r--browser/components/syncedtabs/sidebar.js41
-rw-r--r--browser/components/syncedtabs/sidebar.xhtml157
-rw-r--r--browser/components/syncedtabs/test/browser/browser.ini4
-rw-r--r--browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js646
-rw-r--r--browser/components/syncedtabs/test/browser/head.js3
-rw-r--r--browser/components/syncedtabs/test/xpcshell/head.js12
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js36
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js263
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js69
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js289
-rw-r--r--browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js190
-rw-r--r--browser/components/syncedtabs/test/xpcshell/xpcshell.ini10
-rw-r--r--browser/components/syncedtabs/util.sys.mjs8
22 files changed, 3164 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/EventEmitter.sys.mjs b/browser/components/syncedtabs/EventEmitter.sys.mjs
new file mode 100644
index 0000000000..ed026dc173
--- /dev/null
+++ b/browser/components/syncedtabs/EventEmitter.sys.mjs
@@ -0,0 +1,36 @@
+/* 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/. */
+
+// Simple event emitter abstraction for storage objects to use.
+export function EventEmitter() {
+ this._events = new Map();
+}
+
+EventEmitter.prototype = {
+ on(event, listener) {
+ if (this._events.has(event)) {
+ this._events.get(event).add(listener);
+ } else {
+ this._events.set(event, new Set([listener]));
+ }
+ },
+ off(event, listener) {
+ if (!this._events.has(event)) {
+ return;
+ }
+ this._events.get(event).delete(listener);
+ },
+ emit(event, ...args) {
+ if (!this._events.has(event)) {
+ return;
+ }
+ for (let listener of this._events.get(event).values()) {
+ try {
+ listener.apply(this, args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+};
diff --git a/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs
new file mode 100644
index 0000000000..47571f789d
--- /dev/null
+++ b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs
@@ -0,0 +1,173 @@
+/* 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 { SyncedTabsDeckStore } from "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs";
+import { SyncedTabsDeckView } from "resource:///modules/syncedtabs/SyncedTabsDeckView.sys.mjs";
+import { SyncedTabsListStore } from "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs";
+import { TabListComponent } from "resource:///modules/syncedtabs/TabListComponent.sys.mjs";
+import { TabListView } from "resource:///modules/syncedtabs/TabListView.sys.mjs";
+import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs";
+import { UIState } from "resource://services-sync/UIState.sys.mjs";
+
+/* SyncedTabsDeckComponent
+ * This component instantiates views and storage objects as well as defines
+ * behaviors that will be passed down to the views. This helps keep the views
+ * isolated and easier to test.
+ */
+
+export function SyncedTabsDeckComponent({
+ window,
+ SyncedTabs,
+ deckStore,
+ listStore,
+ listComponent,
+ DeckView,
+ getChromeWindowMock,
+}) {
+ this._window = window;
+ this._SyncedTabs = SyncedTabs;
+ this._DeckView = DeckView || SyncedTabsDeckView;
+ // used to stub during tests
+ this._getChromeWindow = getChromeWindowMock || getChromeWindow;
+
+ this._deckStore = deckStore || new SyncedTabsDeckStore();
+ this._syncedTabsListStore = listStore || new SyncedTabsListStore(SyncedTabs);
+ this.tabListComponent =
+ listComponent ||
+ new TabListComponent({
+ window: this._window,
+ store: this._syncedTabsListStore,
+ View: TabListView,
+ SyncedTabs,
+ clipboardHelper: Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ ),
+ getChromeWindow: this._getChromeWindow,
+ });
+}
+
+SyncedTabsDeckComponent.prototype = {
+ PANELS: {
+ TABS_CONTAINER: "tabs-container",
+ TABS_FETCHING: "tabs-fetching",
+ LOGIN_FAILED: "reauth",
+ NOT_AUTHED_INFO: "notAuthedInfo",
+ SYNC_DISABLED: "syncDisabled",
+ SINGLE_DEVICE_INFO: "singleDeviceInfo",
+ TABS_DISABLED: "tabs-disabled",
+ UNVERIFIED: "unverified",
+ },
+
+ get container() {
+ return this._deckView ? this._deckView.container : null;
+ },
+
+ init() {
+ Services.obs.addObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED);
+ Services.obs.addObserver(this, UIState.ON_UPDATE);
+
+ // Add app locale change support for HTML sidebar
+ Services.obs.addObserver(this, "intl:app-locales-changed");
+ this.updateDir();
+
+ // Go ahead and trigger sync
+ this._SyncedTabs.syncTabs().catch(console.error);
+
+ this._deckView = new this._DeckView(this._window, this.tabListComponent, {
+ onConnectDeviceClick: event => this.openConnectDevice(event),
+ onSyncPrefClick: event => this.openSyncPrefs(event),
+ });
+
+ this._deckStore.on("change", state => this._deckView.render(state));
+ // Trigger the initial rendering of the deck view
+ // Object.values only in nightly
+ this._deckStore.setPanels(
+ Object.keys(this.PANELS).map(k => this.PANELS[k])
+ );
+ // Set the initial panel to display
+ this.updatePanel();
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED);
+ Services.obs.removeObserver(this, UIState.ON_UPDATE);
+ Services.obs.removeObserver(this, "intl:app-locales-changed");
+ this._deckView.destroy();
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case this._SyncedTabs.TOPIC_TABS_CHANGED:
+ this._syncedTabsListStore.getData();
+ this.updatePanel();
+ break;
+ case UIState.ON_UPDATE:
+ this.updatePanel();
+ break;
+ case "intl:app-locales-changed":
+ this.updateDir();
+ break;
+ default:
+ break;
+ }
+ },
+
+ async getPanelStatus() {
+ try {
+ const state = UIState.get();
+ const { status } = state;
+ if (status == UIState.STATUS_NOT_CONFIGURED) {
+ return this.PANELS.NOT_AUTHED_INFO;
+ } else if (status == UIState.STATUS_LOGIN_FAILED) {
+ return this.PANELS.LOGIN_FAILED;
+ } else if (status == UIState.STATUS_NOT_VERIFIED) {
+ return this.PANELS.UNVERIFIED;
+ } else if (!state.syncEnabled) {
+ return this.PANELS.SYNC_DISABLED;
+ } else if (!this._SyncedTabs.isConfiguredToSyncTabs) {
+ return this.PANELS.TABS_DISABLED;
+ } else if (!this._SyncedTabs.hasSyncedThisSession) {
+ return this.PANELS.TABS_FETCHING;
+ }
+ const clients = await this._SyncedTabs.getTabClients();
+ if (clients.length) {
+ return this.PANELS.TABS_CONTAINER;
+ }
+ return this.PANELS.SINGLE_DEVICE_INFO;
+ } catch (err) {
+ console.error(err);
+ return this.PANELS.NOT_AUTHED_INFO;
+ }
+ },
+
+ updateDir() {
+ // If the HTML document doesn't exist, we can't update the window
+ if (!this._window.document) {
+ return;
+ }
+
+ if (Services.locale.isAppLocaleRTL) {
+ this._window.document.body.dir = "rtl";
+ } else {
+ this._window.document.body.dir = "ltr";
+ }
+ },
+
+ updatePanel() {
+ // return promise for tests
+ return this.getPanelStatus()
+ .then(panelId => this._deckStore.selectPanel(panelId))
+ .catch(console.error);
+ },
+
+ openSyncPrefs() {
+ this._getChromeWindow(this._window).gSync.openPrefs("tabs-sidebar");
+ },
+
+ openConnectDevice() {
+ this._getChromeWindow(this._window).gSync.openConnectAnotherDevice(
+ "tabs-sidebar"
+ );
+ },
+};
diff --git a/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs
new file mode 100644
index 0000000000..392e0fea99
--- /dev/null
+++ b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs
@@ -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/. */
+
+import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs";
+
+/**
+ * SyncedTabsDeckStore
+ *
+ * This store keeps track of the deck view state, including the panels and which
+ * one is selected. The view listens for change events on the store, which are
+ * triggered whenever the state changes. If it's a small change, the state
+ * will have `isUpdatable` set to true so the view can skip rerendering the whole
+ * DOM.
+ */
+export function SyncedTabsDeckStore() {
+ EventEmitter.call(this);
+ this._panels = [];
+}
+
+Object.assign(SyncedTabsDeckStore.prototype, EventEmitter.prototype, {
+ _change(isUpdatable = false) {
+ let panels = this._panels.map(panel => {
+ return { id: panel, selected: panel === this._selectedPanel };
+ });
+ this.emit("change", { panels, isUpdatable });
+ },
+
+ /**
+ * Sets the selected panelId and triggers a change event.
+ * @param {String} panelId - ID of the panel to select.
+ */
+ selectPanel(panelId) {
+ if (!this._panels.includes(panelId) || this._selectedPanel === panelId) {
+ return;
+ }
+ this._selectedPanel = panelId;
+ this._change(true);
+ },
+
+ /**
+ * Update the set of panels in the deck and trigger a change event.
+ * @param {Array} panels - an array of IDs for each panel in the deck.
+ */
+ setPanels(panels) {
+ if (panels === this._panels) {
+ return;
+ }
+ this._panels = panels || [];
+ this._change();
+ },
+});
diff --git a/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs
new file mode 100644
index 0000000000..aacae71fa5
--- /dev/null
+++ b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs
@@ -0,0 +1,90 @@
+/* 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/. */
+
+/**
+ * SyncedTabsDeckView
+ *
+ * Instances of SyncedTabsDeckView render DOM nodes from a given state.
+ * No state is kept internaly and the DOM will completely
+ * rerender unless the state flags `isUpdatable`, which helps
+ * make small changes without the overhead of a full rerender.
+ */
+export const SyncedTabsDeckView = function (window, tabListComponent, props) {
+ this.props = props;
+
+ this._window = window;
+ this._doc = window.document;
+
+ this._tabListComponent = tabListComponent;
+ this._deckTemplate = this._doc.getElementById("deck-template");
+ this.container = this._doc.createElement("div");
+};
+
+SyncedTabsDeckView.prototype = {
+ render(state) {
+ if (state.isUpdatable) {
+ this.update(state);
+ } else {
+ this.create(state);
+ }
+ },
+
+ create(state) {
+ let deck = this._doc.importNode(
+ this._deckTemplate.content,
+ true
+ ).firstElementChild;
+ this._clearChilden();
+
+ let tabListWrapper = this._doc.createElement("div");
+ tabListWrapper.className = "tabs-container sync-state";
+ this._tabListComponent.init();
+ tabListWrapper.appendChild(this._tabListComponent.container);
+ deck.appendChild(tabListWrapper);
+ this.container.appendChild(deck);
+
+ this._attachListeners();
+ this.update(state);
+ },
+
+ destroy() {
+ this._tabListComponent.uninit();
+ this.container.remove();
+ },
+
+ update(state) {
+ // Note that we may also want to update elements that are outside of the
+ // deck, so use the document to find the class names rather than our
+ // container.
+ for (let panel of state.panels) {
+ if (panel.selected) {
+ Array.prototype.map.call(
+ this._doc.getElementsByClassName(panel.id),
+ item => item.classList.add("selected")
+ );
+ } else {
+ Array.prototype.map.call(
+ this._doc.getElementsByClassName(panel.id),
+ item => item.classList.remove("selected")
+ );
+ }
+ }
+ },
+
+ _clearChilden() {
+ while (this.container.firstChild) {
+ this.container.firstChild.remove();
+ }
+ },
+
+ _attachListeners() {
+ let syncPrefLinks = this.container.querySelectorAll(".sync-prefs");
+ for (let link of syncPrefLinks) {
+ link.addEventListener("click", this.props.onSyncPrefClick);
+ }
+ this.container
+ .querySelector(".connect-device")
+ .addEventListener("click", this.props.onConnectDeviceClick);
+ },
+};
diff --git a/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs
new file mode 100644
index 0000000000..67adcfdace
--- /dev/null
+++ b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs
@@ -0,0 +1,253 @@
+/* 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 { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs";
+
+/**
+ * SyncedTabsListStore
+ *
+ * Instances of this store encapsulate all of the state associated with a synced tabs list view.
+ * The state includes the clients, their tabs, the row that is currently selected,
+ * and the filtered query.
+ */
+export function SyncedTabsListStore(SyncedTabs) {
+ EventEmitter.call(this);
+ this._SyncedTabs = SyncedTabs;
+ this.data = [];
+ this._closedClients = {};
+ this._selectedRow = [-1, -1];
+ this.filter = "";
+ this.inputFocused = false;
+}
+
+Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, {
+ // This internal method triggers the "change" event that views
+ // listen for. It denormalizes the state so that it's easier for
+ // the view to deal with. updateType hints to the view what
+ // actually needs to be rerendered or just updated, and can be
+ // empty (to (re)render everything), "searchbox" (to rerender just the tab list),
+ // or "all" (to skip rendering and just update all attributes of existing nodes).
+ _change(updateType) {
+ let selectedParent = this._selectedRow[0];
+ let selectedChild = this._selectedRow[1];
+ let rowSelected = false;
+ // clone the data so that consumers can't mutate internal storage
+ let data = Cu.cloneInto(this.data, {});
+ let tabCount = 0;
+
+ data.forEach((client, index) => {
+ client.closed = !!this._closedClients[client.id];
+
+ if (rowSelected || selectedParent < 0) {
+ return;
+ }
+ if (this.filter) {
+ if (selectedParent < tabCount + client.tabs.length) {
+ client.tabs[selectedParent - tabCount].selected = true;
+ client.tabs[selectedParent - tabCount].focused = !this.inputFocused;
+ rowSelected = true;
+ } else {
+ tabCount += client.tabs.length;
+ }
+ return;
+ }
+ if (selectedParent === index && selectedChild === -1) {
+ client.selected = true;
+ client.focused = !this.inputFocused;
+ rowSelected = true;
+ } else if (selectedParent === index) {
+ client.tabs[selectedChild].selected = true;
+ client.tabs[selectedChild].focused = !this.inputFocused;
+ rowSelected = true;
+ }
+ });
+
+ // If this were React the view would be smart enough
+ // to not re-render the whole list unless necessary. But it's
+ // not, so updateType is a hint to the view of what actually
+ // needs to be rerendered.
+ this.emit("change", {
+ clients: data,
+ canUpdateAll: updateType === "all",
+ canUpdateInput: updateType === "searchbox",
+ filter: this.filter,
+ inputFocused: this.inputFocused,
+ });
+ },
+
+ /**
+ * Moves the row selection from a child to its parent,
+ * which occurs when the parent of a selected row closes.
+ */
+ _selectParentRow() {
+ this._selectedRow[1] = -1;
+ },
+
+ _toggleBranch(id, closed) {
+ this._closedClients[id] = closed;
+ if (this._closedClients[id]) {
+ this._selectParentRow();
+ }
+ this._change("all");
+ },
+
+ _isOpen(client) {
+ return !this._closedClients[client.id];
+ },
+
+ moveSelectionDown() {
+ let branchRow = this._selectedRow[0];
+ let childRow = this._selectedRow[1];
+ let branch = this.data[branchRow];
+
+ if (this.filter) {
+ this.selectRow(branchRow + 1);
+ return;
+ }
+
+ if (branchRow < 0) {
+ this.selectRow(0, -1);
+ } else if (
+ (!branch.tabs.length ||
+ childRow >= branch.tabs.length - 1 ||
+ !this._isOpen(branch)) &&
+ branchRow < this.data.length
+ ) {
+ this.selectRow(branchRow + 1, -1);
+ } else if (childRow < branch.tabs.length) {
+ this.selectRow(branchRow, childRow + 1);
+ }
+ },
+
+ moveSelectionUp() {
+ let branchRow = this._selectedRow[0];
+ let childRow = this._selectedRow[1];
+
+ if (this.filter) {
+ this.selectRow(branchRow - 1);
+ return;
+ }
+
+ if (branchRow < 0) {
+ this.selectRow(0, -1);
+ } else if (childRow < 0 && branchRow > 0) {
+ let prevBranch = this.data[branchRow - 1];
+ let newChildRow = this._isOpen(prevBranch)
+ ? prevBranch.tabs.length - 1
+ : -1;
+ this.selectRow(branchRow - 1, newChildRow);
+ } else if (childRow >= 0) {
+ this.selectRow(branchRow, childRow - 1);
+ }
+ },
+
+ // Selects a row and makes sure the selection is within bounds
+ selectRow(parent, child) {
+ let maxParentRow = this.filter ? this._tabCount() : this.data.length;
+ let parentRow = parent;
+ if (parent <= -1) {
+ parentRow = 0;
+ } else if (parent >= maxParentRow) {
+ return;
+ }
+
+ let childRow = child;
+ if (
+ parentRow === -1 ||
+ this.filter ||
+ typeof child === "undefined" ||
+ child < -1
+ ) {
+ childRow = -1;
+ } else if (child >= this.data[parentRow].tabs.length) {
+ childRow = this.data[parentRow].tabs.length - 1;
+ }
+
+ if (
+ this._selectedRow[0] === parentRow &&
+ this._selectedRow[1] === childRow
+ ) {
+ return;
+ }
+
+ this._selectedRow = [parentRow, childRow];
+ this.inputFocused = false;
+ this._change("all");
+ // Record the telemetry event
+ let extraOptions = {
+ tab_pos: this._selectedRow[1].toString(),
+ filter: this.filter,
+ };
+ this._SyncedTabs.recordSyncedTabsTelemetry(
+ "synced_tabs_sidebar",
+ "click",
+ extraOptions
+ );
+ },
+
+ _tabCount() {
+ return this.data.reduce((prev, curr) => curr.tabs.length + prev, 0);
+ },
+
+ toggleBranch(id) {
+ this._toggleBranch(id, !this._closedClients[id]);
+ },
+
+ closeBranch(id) {
+ this._toggleBranch(id, true);
+ },
+
+ openBranch(id) {
+ this._toggleBranch(id, false);
+ },
+
+ focusInput() {
+ this.inputFocused = true;
+ // A change type of "all" updates rather than rebuilds, which is what we
+ // want here - only the selection/focus has changed.
+ this._change("all");
+ },
+
+ blurInput() {
+ this.inputFocused = false;
+ // A change type of "all" updates rather than rebuilds, which is what we
+ // want here - only the selection/focus has changed.
+ this._change("all");
+ },
+
+ clearFilter() {
+ this.filter = "";
+ this._selectedRow = [-1, -1];
+ return this.getData();
+ },
+
+ // Fetches data from the SyncedTabs module and triggers
+ // and update
+ getData(filter) {
+ let updateType;
+ let hasFilter = typeof filter !== "undefined";
+ if (hasFilter) {
+ this.filter = filter;
+ this._selectedRow = [-1, -1];
+
+ // When a filter is specified we tell the view that only the list
+ // needs to be rerendered so that it doesn't disrupt the input
+ // field's focus.
+ updateType = "searchbox";
+ }
+
+ // return promise for tests
+ return this._SyncedTabs
+ .getTabClients(this.filter)
+ .then(result => {
+ if (!hasFilter) {
+ // Only sort clients and tabs if we're rendering the whole list.
+ this._SyncedTabs.sortTabClientsByLastUsed(result);
+ }
+ this.data = result;
+ this._change(updateType);
+ })
+ .catch(console.error);
+ },
+});
diff --git a/browser/components/syncedtabs/TabListComponent.sys.mjs b/browser/components/syncedtabs/TabListComponent.sys.mjs
new file mode 100644
index 0000000000..60b473675b
--- /dev/null
+++ b/browser/components/syncedtabs/TabListComponent.sys.mjs
@@ -0,0 +1,149 @@
+/* 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";
+
+let log = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+).Log.repository.getLogger("Sync.RemoteTabs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.jsm",
+});
+
+/**
+ * TabListComponent
+ *
+ * The purpose of this component is to compose the view, state, and actions.
+ * It defines high level actions that act on the state and passes them to the
+ * view for it to trigger during user interaction. It also subscribes the view
+ * to state changes so it can rerender.
+ */
+
+export function TabListComponent({
+ window,
+ store,
+ View,
+ SyncedTabs,
+ clipboardHelper,
+ getChromeWindow,
+}) {
+ this._window = window;
+ this._store = store;
+ this._View = View;
+ this._clipboardHelper = clipboardHelper;
+ this._getChromeWindow = getChromeWindow;
+ // used to trigger Sync from context menu
+ this._SyncedTabs = SyncedTabs;
+}
+
+TabListComponent.prototype = {
+ get container() {
+ return this._view.container;
+ },
+
+ init() {
+ log.debug("Initializing TabListComponent");
+
+ this._view = new this._View(this._window, {
+ onSelectRow: (...args) => this.onSelectRow(...args),
+ onOpenTab: (...args) => this.onOpenTab(...args),
+ onOpenTabs: (...args) => this.onOpenTabs(...args),
+ onMoveSelectionDown: (...args) => this.onMoveSelectionDown(...args),
+ onMoveSelectionUp: (...args) => this.onMoveSelectionUp(...args),
+ onToggleBranch: (...args) => this.onToggleBranch(...args),
+ onBookmarkTab: (...args) => this.onBookmarkTab(...args),
+ onCopyTabLocation: (...args) => this.onCopyTabLocation(...args),
+ onSyncRefresh: (...args) => this.onSyncRefresh(...args),
+ onFilter: (...args) => this.onFilter(...args),
+ onClearFilter: (...args) => this.onClearFilter(...args),
+ onFilterFocus: (...args) => this.onFilterFocus(...args),
+ onFilterBlur: (...args) => this.onFilterBlur(...args),
+ });
+
+ this._store.on("change", state => this._view.render(state));
+ this._view.render({ clients: [] });
+ // get what's already available...
+ this._store.getData();
+ this._store.focusInput();
+ },
+
+ uninit() {
+ this._view.destroy();
+ },
+
+ onFilter(query) {
+ this._store.getData(query);
+ },
+
+ onClearFilter() {
+ this._store.clearFilter();
+ },
+
+ onFilterFocus() {
+ this._store.focusInput();
+ },
+
+ onFilterBlur() {
+ this._store.blurInput();
+ },
+
+ onSelectRow(position) {
+ this._store.selectRow(position[0], position[1]);
+ },
+
+ onMoveSelectionDown() {
+ this._store.moveSelectionDown();
+ },
+
+ onMoveSelectionUp() {
+ this._store.moveSelectionUp();
+ },
+
+ onToggleBranch(id) {
+ this._store.toggleBranch(id);
+ },
+
+ onBookmarkTab(uri, title) {
+ this._window.top.PlacesCommandHook.bookmarkLink(uri, title).catch(
+ console.error
+ );
+ },
+
+ onOpenTab(url, where, params) {
+ this._window.openTrustedLinkIn(url, where, params);
+ },
+
+ onOpenTabs(urls, where) {
+ if (!lazy.OpenInTabsUtils.confirmOpenInTabs(urls.length, this._window)) {
+ return;
+ }
+ if (where == "window") {
+ this._window.openDialog(
+ this._window.AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,dialog=no,all",
+ urls.join("|")
+ );
+ } else {
+ let loadInBackground = where == "tabshifted";
+ this._getChromeWindow(this._window).gBrowser.loadTabs(urls, {
+ inBackground: loadInBackground,
+ replace: false,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+ },
+
+ onCopyTabLocation(url) {
+ this._clipboardHelper.copyString(url);
+ },
+
+ onSyncRefresh() {
+ this._SyncedTabs.syncTabs(true);
+ },
+};
diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs
new file mode 100644
index 0000000000..b50c2253a8
--- /dev/null
+++ b/browser/components/syncedtabs/TabListView.sys.mjs
@@ -0,0 +1,653 @@
+/* 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",
+});
+
+import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs";
+
+function getContextMenu(window) {
+ return getChromeWindow(window).document.getElementById(
+ "SyncedTabsSidebarContext"
+ );
+}
+
+function getTabsFilterContextMenu(window) {
+ return getChromeWindow(window).document.getElementById(
+ "SyncedTabsSidebarTabsFilterContext"
+ );
+}
+
+/*
+ * TabListView
+ *
+ * Given a state, this object will render the corresponding DOM.
+ * It maintains no state of it's own. It listens for DOM events
+ * and triggers actions that may cause the state to change and
+ * ultimately the view to rerender.
+ */
+export function TabListView(window, props) {
+ this.props = props;
+
+ this._window = window;
+ this._doc = this._window.document;
+
+ this._tabsContainerTemplate = this._doc.getElementById(
+ "tabs-container-template"
+ );
+ this._clientTemplate = this._doc.getElementById("client-template");
+ this._emptyClientTemplate = this._doc.getElementById("empty-client-template");
+ this._tabTemplate = this._doc.getElementById("tab-template");
+ this.tabsFilter = this._doc.querySelector(".tabsFilter");
+
+ this.container = this._doc.createElement("div");
+
+ this._attachFixedListeners();
+
+ this._setupContextMenu();
+}
+
+TabListView.prototype = {
+ render(state) {
+ // Don't rerender anything; just update attributes, e.g. selection
+ if (state.canUpdateAll) {
+ this._update(state);
+ return;
+ }
+ // Rerender the tab list
+ if (state.canUpdateInput) {
+ this._updateSearchBox(state);
+ this._createList(state);
+ return;
+ }
+ // Create the world anew
+ this._create(state);
+ },
+
+ // Create the initial DOM from templates
+ _create(state) {
+ let wrapper = this._doc.importNode(
+ this._tabsContainerTemplate.content,
+ true
+ ).firstElementChild;
+ this._clearChilden();
+ this.container.appendChild(wrapper);
+
+ this.list = this.container.querySelector(".list");
+
+ this._createList(state);
+ this._updateSearchBox(state);
+
+ this._attachListListeners();
+ },
+
+ _createList(state) {
+ this._clearChilden(this.list);
+ for (let client of state.clients) {
+ if (state.filter) {
+ this._renderFilteredClient(client);
+ } else {
+ this._renderClient(client);
+ }
+ }
+ if (this.list.firstElementChild) {
+ const firstTab = this.list.firstElementChild.querySelector(
+ ".item.tab:first-child .item-title"
+ );
+ if (firstTab) {
+ firstTab.setAttribute("tabindex", 2);
+ }
+ }
+ },
+
+ destroy() {
+ this._teardownContextMenu();
+ this.container.remove();
+ },
+
+ _update(state) {
+ this._updateSearchBox(state);
+ for (let client of state.clients) {
+ let clientNode = this._doc.getElementById("item-" + client.id);
+ if (clientNode) {
+ this._updateClient(client, clientNode);
+ }
+
+ client.tabs.forEach((tab, index) => {
+ let tabNode = this._doc.getElementById(
+ "tab-" + client.id + "-" + index
+ );
+ this._updateTab(tab, tabNode, index);
+ });
+ }
+ },
+
+ // Client rows are hidden when the list is filtered
+ _renderFilteredClient(client, filter) {
+ client.tabs.forEach((tab, index) => {
+ let node = this._renderTab(client, tab, index);
+ this.list.appendChild(node);
+ });
+ },
+
+ _updateLastSyncTitle(lastModified, itemNode) {
+ let lastSync = new Date(lastModified);
+ let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate(
+ lastSync
+ );
+ itemNode.setAttribute("title", lastSyncTitle);
+ },
+
+ _renderClient(client) {
+ let itemNode = client.tabs.length
+ ? this._createClient(client)
+ : this._createEmptyClient(client);
+
+ itemNode.addEventListener("mouseover", () =>
+ this._updateLastSyncTitle(client.lastModified, itemNode)
+ );
+
+ this._updateClient(client, itemNode);
+
+ let tabsList = itemNode.querySelector(".item-tabs-list");
+ client.tabs.forEach((tab, index) => {
+ let node = this._renderTab(client, tab, index);
+ tabsList.appendChild(node);
+ });
+
+ this.list.appendChild(itemNode);
+ return itemNode;
+ },
+
+ _renderTab(client, tab, index) {
+ let itemNode = this._createTab(tab);
+ this._updateTab(tab, itemNode, index);
+ return itemNode;
+ },
+
+ _createClient() {
+ return this._doc.importNode(this._clientTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _createEmptyClient() {
+ return this._doc.importNode(this._emptyClientTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _createTab() {
+ return this._doc.importNode(this._tabTemplate.content, true)
+ .firstElementChild;
+ },
+
+ _clearChilden(node) {
+ let parent = node || this.container;
+ while (parent.firstChild) {
+ parent.firstChild.remove();
+ }
+ },
+
+ // These listeners are attached only once, when we initialize the view
+ _attachFixedListeners() {
+ this.tabsFilter.addEventListener("command", this.onFilter.bind(this));
+ this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this));
+ this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this));
+ },
+
+ // These listeners have to be re-created every time since we re-create the list
+ _attachListListeners() {
+ this.list.addEventListener("click", this.onClick.bind(this));
+ this.list.addEventListener("mouseup", this.onMouseUp.bind(this));
+ this.list.addEventListener("keydown", this.onKeyDown.bind(this));
+ },
+
+ _updateSearchBox(state) {
+ this.tabsFilter.value = state.filter;
+ if (state.inputFocused) {
+ this.tabsFilter.focus();
+ }
+ },
+
+ /**
+ * Update the element representing an item, ensuring it's in sync with the
+ * underlying data.
+ * @param {client} item - Item to use as a source.
+ * @param {Element} itemNode - Element to update.
+ */
+ _updateClient(item, itemNode) {
+ itemNode.setAttribute("id", "item-" + item.id);
+ this._updateLastSyncTitle(item.lastModified, itemNode);
+ if (item.closed) {
+ itemNode.classList.add("closed");
+ } else {
+ itemNode.classList.remove("closed");
+ }
+ if (item.selected) {
+ itemNode.classList.add("selected");
+ } else {
+ itemNode.classList.remove("selected");
+ }
+ if (item.focused) {
+ itemNode.focus();
+ }
+ itemNode.setAttribute("clientType", item.clientType);
+ itemNode.dataset.id = item.id;
+ itemNode.querySelector(".item-title").textContent = item.name;
+ },
+
+ /**
+ * Update the element representing a tab, ensuring it's in sync with the
+ * underlying data.
+ * @param {tab} item - Item to use as a source.
+ * @param {Element} itemNode - Element to update.
+ */
+ _updateTab(item, itemNode, index) {
+ itemNode.setAttribute("title", `${item.title}\n${item.url}`);
+ itemNode.setAttribute("id", "tab-" + item.client + "-" + index);
+ if (item.selected) {
+ itemNode.classList.add("selected");
+ } else {
+ itemNode.classList.remove("selected");
+ }
+ if (item.focused) {
+ itemNode.focus();
+ }
+ itemNode.dataset.url = item.url;
+
+ itemNode.querySelector(".item-title").textContent = item.title;
+
+ if (item.icon) {
+ let icon = itemNode.querySelector(".item-icon-container");
+ icon.style.backgroundImage = "url(" + item.icon + ")";
+ }
+ },
+
+ onMouseUp(event) {
+ if (event.which == 2) {
+ // Middle click
+ this.onClick(event);
+ }
+ },
+
+ onClick(event) {
+ let itemNode = this._findParentItemNode(event.target);
+ if (!itemNode) {
+ return;
+ }
+
+ if (itemNode.classList.contains("tab")) {
+ let url = itemNode.dataset.url;
+ if (url) {
+ this.onOpenSelected(url, event);
+ }
+ }
+
+ // Middle click on a client
+ if (itemNode.classList.contains("client")) {
+ let where = getChromeWindow(this._window).whereToOpenLink(event);
+ if (where != "current") {
+ this._openAllClientTabs(itemNode, where);
+ }
+ }
+
+ if (
+ event.target.classList.contains("item-twisty-container") &&
+ event.which != 2
+ ) {
+ this.props.onToggleBranch(itemNode.dataset.id);
+ return;
+ }
+
+ let position = this._getSelectionPosition(itemNode);
+ this.props.onSelectRow(position);
+ },
+
+ /**
+ * Handle a keydown event on the list box.
+ * @param {Event} event - Triggering event.
+ */
+ onKeyDown(event) {
+ if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) {
+ event.preventDefault();
+ this.props.onMoveSelectionDown();
+ } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) {
+ event.preventDefault();
+ this.props.onMoveSelectionUp();
+ } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) {
+ let selectedNode = this.container.querySelector(".item.selected");
+ if (selectedNode.dataset.url) {
+ this.onOpenSelected(selectedNode.dataset.url, event);
+ } else if (selectedNode) {
+ this.props.onToggleBranch(selectedNode.dataset.id);
+ }
+ }
+ },
+
+ onBookmarkTab() {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ let title = item.querySelector(".item-title").textContent;
+ this.props.onBookmarkTab(item.dataset.url, title);
+ }
+ },
+
+ onCopyTabLocation() {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ this.props.onCopyTabLocation(item.dataset.url);
+ }
+ },
+
+ onOpenSelected(url, event) {
+ let where = getChromeWindow(this._window).whereToOpenLink(event);
+ this.props.onOpenTab(url, where, {});
+ },
+
+ onOpenSelectedFromContextMenu(event) {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ let where = event.target.getAttribute("where");
+ let params = {
+ private: event.target.hasAttribute("private"),
+ };
+ this.props.onOpenTab(item.dataset.url, where, params);
+ }
+ },
+
+ onOpenSelectedInContainerTab(event) {
+ let item = this._getSelectedTabNode();
+ if (item) {
+ this.props.onOpenTab(item.dataset.url, "tab", {
+ userContextId: parseInt(event.target?.dataset.usercontextid),
+ });
+ }
+ },
+
+ onOpenAllInTabs() {
+ let item = this._getSelectedClientNode();
+ if (item) {
+ this._openAllClientTabs(item, "tab");
+ }
+ },
+
+ onFilter(event) {
+ let query = event.target.value;
+ if (query) {
+ this.props.onFilter(query);
+ } else {
+ this.props.onClearFilter();
+ }
+ },
+
+ onFilterFocus() {
+ this.props.onFilterFocus();
+ },
+ onFilterBlur() {
+ this.props.onFilterBlur();
+ },
+
+ _getSelectedTabNode() {
+ let item = this.container.querySelector(".item.selected");
+ if (this._isTab(item) && item.dataset.url) {
+ return item;
+ }
+ return null;
+ },
+
+ _getSelectedClientNode() {
+ let item = this.container.querySelector(".item.selected");
+ if (this._isClient(item)) {
+ return item;
+ }
+ return null;
+ },
+
+ // Set up the custom context menu
+ _setupContextMenu() {
+ Services.els.addSystemEventListener(
+ this._window,
+ "contextmenu",
+ this,
+ false
+ );
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.addEventListener("popupshowing", this, true);
+ menu.addEventListener("command", this, true);
+ }
+ },
+
+ _teardownContextMenu() {
+ // Tear down context menu
+ Services.els.removeSystemEventListener(
+ this._window,
+ "contextmenu",
+ this,
+ false
+ );
+ for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+ let menu = getMenu(this._window);
+ menu.removeEventListener("popupshowing", this, true);
+ menu.removeEventListener("command", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "contextmenu":
+ this.handleContextMenu(event);
+ break;
+
+ case "popupshowing": {
+ if (
+ event.target.getAttribute("id") ==
+ "SyncedTabsSidebarTabsFilterContext"
+ ) {
+ this.handleTabsFilterContextMenuShown(event);
+ }
+ break;
+ }
+
+ case "command": {
+ let menu = event.target.closest("menupopup");
+ switch (menu.getAttribute("id")) {
+ case "SyncedTabsSidebarContext":
+ this.handleContentContextMenuCommand(event);
+ break;
+
+ case "SyncedTabsOpenSelectedInContainerTabMenu":
+ this.onOpenSelectedInContainerTab(event);
+ break;
+
+ case "SyncedTabsSidebarTabsFilterContext":
+ this.handleTabsFilterContextMenuCommand(event);
+ break;
+ }
+ break;
+ }
+ }
+ },
+
+ handleTabsFilterContextMenuShown(event) {
+ let document = event.target.ownerDocument;
+ let focusedElement = document.commandDispatcher.focusedElement;
+ if (focusedElement != this.tabsFilter.inputField) {
+ this.tabsFilter.focus();
+ }
+ for (let item of event.target.children) {
+ if (!item.hasAttribute("cmd")) {
+ continue;
+ }
+ let command = item.getAttribute("cmd");
+ let controller =
+ document.commandDispatcher.getControllerForCommand(command);
+ if (controller.isCommandEnabled(command)) {
+ item.removeAttribute("disabled");
+ } else {
+ item.setAttribute("disabled", "true");
+ }
+ }
+ },
+
+ handleContentContextMenuCommand(event) {
+ let id = event.target.getAttribute("id");
+ switch (id) {
+ case "syncedTabsOpenSelected":
+ case "syncedTabsOpenSelectedInTab":
+ case "syncedTabsOpenSelectedInWindow":
+ case "syncedTabsOpenSelectedInPrivateWindow":
+ this.onOpenSelectedFromContextMenu(event);
+ break;
+ case "syncedTabsOpenAllInTabs":
+ this.onOpenAllInTabs();
+ break;
+ case "syncedTabsBookmarkSelected":
+ this.onBookmarkTab();
+ break;
+ case "syncedTabsCopySelected":
+ this.onCopyTabLocation();
+ break;
+ case "syncedTabsRefresh":
+ case "syncedTabsRefreshFilter":
+ this.props.onSyncRefresh();
+ break;
+ }
+ },
+
+ handleTabsFilterContextMenuCommand(event) {
+ let command = event.target.getAttribute("cmd");
+ let dispatcher = getChromeWindow(this._window).document.commandDispatcher;
+ let controller =
+ dispatcher.focusedElement.controllers.getControllerForCommand(command);
+ controller.doCommand(command);
+ },
+
+ handleContextMenu(event) {
+ let menu;
+
+ if (event.target == this.tabsFilter) {
+ menu = getTabsFilterContextMenu(this._window);
+ } else {
+ let itemNode = this._findParentItemNode(event.target);
+ if (itemNode) {
+ let position = this._getSelectionPosition(itemNode);
+ this.props.onSelectRow(position);
+ }
+ menu = getContextMenu(this._window);
+ this.adjustContextMenu(menu);
+ }
+
+ menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
+ },
+
+ adjustContextMenu(menu) {
+ let item = this.container.querySelector(".item.selected");
+ let showTabOptions = this._isTab(item);
+
+ let el = menu.firstElementChild;
+
+ while (el) {
+ let show = false;
+ if (showTabOptions) {
+ if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") {
+ show = lazy.PrivateBrowsingUtils.enabled;
+ } else if (
+ el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab"
+ ) {
+ show =
+ Services.prefs.getBoolPref("privacy.userContext.enabled", false) &&
+ !lazy.PrivateBrowsingUtils.isWindowPrivate(
+ getChromeWindow(this._window)
+ );
+ } else if (
+ el.getAttribute("id") != "syncedTabsOpenAllInTabs" &&
+ el.getAttribute("id") != "syncedTabsManageDevices"
+ ) {
+ show = true;
+ }
+ } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") {
+ const tabs = item.querySelectorAll(".item-tabs-list > .item.tab");
+ show = !!tabs.length;
+ } else if (el.getAttribute("id") == "syncedTabsRefresh") {
+ show = true;
+ } else if (el.getAttribute("id") == "syncedTabsManageDevices") {
+ show = true;
+ }
+ el.hidden = !show;
+
+ el = el.nextElementSibling;
+ }
+ },
+
+ /**
+ * Find the parent item element, from a given child element.
+ * @param {Element} node - Child element.
+ * @return {Element} Element for the item, or null if not found.
+ */
+ _findParentItemNode(node) {
+ while (
+ node &&
+ node !== this.list &&
+ node !== this._doc.documentElement &&
+ !node.classList.contains("item")
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node !== this.list && node !== this._doc.documentElement) {
+ return node;
+ }
+
+ return null;
+ },
+
+ _findParentBranchNode(node) {
+ while (
+ node &&
+ !node.classList.contains("list") &&
+ node !== this._doc.documentElement &&
+ !node.parentNode.classList.contains("list")
+ ) {
+ node = node.parentNode;
+ }
+
+ if (node !== this.list && node !== this._doc.documentElement) {
+ return node;
+ }
+
+ return null;
+ },
+
+ _getSelectionPosition(itemNode) {
+ let parent = this._findParentBranchNode(itemNode);
+ let parentPosition = this._indexOfNode(parent.parentNode, parent);
+ let childPosition = -1;
+ // if the node is not a client, find its position within the parent
+ if (parent !== itemNode) {
+ childPosition = this._indexOfNode(itemNode.parentNode, itemNode);
+ }
+ return [parentPosition, childPosition];
+ },
+
+ _indexOfNode(parent, child) {
+ return Array.prototype.indexOf.call(parent.children, child);
+ },
+
+ _isTab(item) {
+ return item && item.classList.contains("tab");
+ },
+
+ _isClient(item) {
+ return item && item.classList.contains("client");
+ },
+
+ _openAllClientTabs(clientNode, where) {
+ const tabs = clientNode.querySelector(".item-tabs-list").children;
+ const urls = [...tabs].map(tab => tab.dataset.url);
+ this.props.onOpenTabs(urls, where);
+ },
+};
diff --git a/browser/components/syncedtabs/jar.mn b/browser/components/syncedtabs/jar.mn
new file mode 100644
index 0000000000..ba2b105a17
--- /dev/null
+++ b/browser/components/syncedtabs/jar.mn
@@ -0,0 +1,7 @@
+# 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:
+ content/browser/syncedtabs/sidebar.xhtml
+ content/browser/syncedtabs/sidebar.js
diff --git a/browser/components/syncedtabs/moz.build b/browser/components/syncedtabs/moz.build
new file mode 100644
index 0000000000..53451d81b6
--- /dev/null
+++ b/browser/components/syncedtabs/moz.build
@@ -0,0 +1,23 @@
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"]
+
+EXTRA_JS_MODULES.syncedtabs += [
+ "EventEmitter.sys.mjs",
+ "SyncedTabsDeckComponent.sys.mjs",
+ "SyncedTabsDeckStore.sys.mjs",
+ "SyncedTabsDeckView.sys.mjs",
+ "SyncedTabsListStore.sys.mjs",
+ "TabListComponent.sys.mjs",
+ "TabListView.sys.mjs",
+ "util.sys.mjs",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Sync")
diff --git a/browser/components/syncedtabs/sidebar.js b/browser/components/syncedtabs/sidebar.js
new file mode 100644
index 0000000000..bf65bd96d5
--- /dev/null
+++ b/browser/components/syncedtabs/sidebar.js
@@ -0,0 +1,41 @@
+/* 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 { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+const { SyncedTabsDeckComponent } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsDeckComponent.sys.mjs"
+);
+
+var syncedTabsDeckComponent = new SyncedTabsDeckComponent({
+ window,
+ SyncedTabs,
+});
+
+let onLoaded = () => {
+ window.top.MozXULElement.insertFTLIfNeeded("browser/syncedTabs.ftl");
+ window.top.document
+ .getElementById("SyncedTabsSidebarContext")
+ .querySelectorAll("[data-lazy-l10n-id]")
+ .forEach(el => {
+ el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
+ el.removeAttribute("data-lazy-l10n-id");
+ });
+ syncedTabsDeckComponent.init();
+ document
+ .getElementById("template-container")
+ .appendChild(syncedTabsDeckComponent.container);
+};
+
+let onUnloaded = () => {
+ removeEventListener("DOMContentLoaded", onLoaded);
+ removeEventListener("unload", onUnloaded);
+ syncedTabsDeckComponent.uninit();
+};
+
+addEventListener("DOMContentLoaded", onLoaded);
+addEventListener("unload", onUnloaded);
diff --git a/browser/components/syncedtabs/sidebar.xhtml b/browser/components/syncedtabs/sidebar.xhtml
new file mode 100644
index 0000000000..8091f61aee
--- /dev/null
+++ b/browser/components/syncedtabs/sidebar.xhtml
@@ -0,0 +1,157 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+>
+ <head>
+ <script src="chrome://browser/content/syncedtabs/sidebar.js" />
+ <script src="chrome://browser/content/utilityOverlay.js" />
+ <script src="chrome://browser/content/contentTheme.js" />
+
+ <link
+ rel="stylesheet"
+ media="all"
+ href="chrome://browser/skin/syncedtabs/sidebar.css"
+ />
+ <link rel="localization" href="browser/syncedTabs.ftl" />
+ <link rel="localization" href="toolkit/branding/accounts.ftl" />
+ <title data-l10n-id="synced-tabs-sidebar-title" />
+ </head>
+
+ <body role="application">
+ <template id="client-template">
+ <div class="item client" role="option" tabindex="-1">
+ <div class="item-title-container">
+ <div class="item-twisty-container"></div>
+ <div class="item-icon-container"></div>
+ <p class="item-title"></p>
+ </div>
+ <div class="item-tabs-list"></div>
+ </div>
+ </template>
+ <template id="empty-client-template">
+ <div class="item empty client" role="option" tabindex="-1">
+ <div class="item-title-container">
+ <div class="item-twisty-container"></div>
+ <div class="item-icon-container"></div>
+ <p class="item-title"></p>
+ </div>
+ <div class="item-tabs-list">
+ <div class="item empty" role="option" tabindex="-1">
+ <div class="item-title-container">
+ <div class="item-icon-container"></div>
+ <p
+ class="item-title"
+ data-l10n-id="synced-tabs-sidebar-notabs"
+ ></p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </template>
+ <template id="tab-template">
+ <div class="item tab" role="option" tabindex="-1">
+ <div class="item-title-container">
+ <div class="item-icon-container"></div>
+ <p class="item-title"></p>
+ </div>
+ </div>
+ </template>
+
+ <template id="tabs-container-template">
+ <div class="tabs-container">
+ <div class="list" role="listbox"></div>
+ </div>
+ </template>
+
+ <template id="deck-template">
+ <div class="deck">
+ <div class="tabs-fetching sync-state">
+ <!-- Show intentionally blank panel, see bug 1239845 -->
+ </div>
+ <div class="notAuthedInfo sync-state">
+ <div class="syncIllustration"></div>
+ <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p>
+ <button
+ class="button sync-prefs"
+ data-l10n-id="synced-tabs-fxa-sign-in"
+ ></button>
+ </div>
+ <div class="syncDisabled sync-state">
+ <div class="syncIllustration"></div>
+ <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p>
+ <button
+ class="button sync-prefs"
+ data-l10n-id="synced-tabs-turn-on-sync"
+ ></button>
+ </div>
+ <div class="reauth sync-state">
+ <div class="syncIllustrationIssue"></div>
+ <p class="instructions" data-l10n-id="synced-tabs-sidebar-intro"></p>
+ <button
+ class="button sync-prefs"
+ data-l10n-id="synced-tabs-fxa-sign-in"
+ ></button>
+ </div>
+ <div class="unverified sync-state">
+ <div class="syncIllustrationIssue"></div>
+ <p
+ class="instructions"
+ data-l10n-id="synced-tabs-sidebar-unverified"
+ ></p>
+ <button
+ class="button sync-prefs"
+ data-l10n-id="synced-tabs-sidebar-open-settings"
+ ></button>
+ </div>
+ <div class="singleDeviceInfo sync-state">
+ <div class="syncIllustrationIssue"></div>
+ <p
+ class="instructions"
+ data-l10n-id="synced-tabs-sidebar-noclients-subtitle"
+ ></p>
+ <button
+ class="button connect-device"
+ data-l10n-id="synced-tabs-sidebar-connect-another-device"
+ ></button>
+ </div>
+ <div class="tabs-disabled sync-state">
+ <div class="syncIllustrationIssue"></div>
+ <p
+ class="instructions"
+ data-l10n-id="synced-tabs-sidebar-tabsnotsyncing"
+ ></p>
+ <button
+ class="button sync-prefs"
+ data-l10n-id="synced-tabs-sidebar-open-settings"
+ ></button>
+ </div>
+ </div>
+ </template>
+
+ <div class="content-container">
+ <!-- the non-scrollable header -->
+ <div class="content-header">
+ <div class="sidebar-search-container tabs-container sync-state">
+ <xul:search-textbox
+ class="tabsFilter"
+ tabindex="1"
+ data-l10n-id="synced-tabs-sidebar-search"
+ data-l10n-attrs="placeholder"
+ />
+ </div>
+ </div>
+ <!-- the scrollable content area where our templates are inserted -->
+ <div
+ id="template-container"
+ class="content-scrollable"
+ tabindex="-1"
+ ></div>
+ </div>
+ </body>
+</html>
diff --git a/browser/components/syncedtabs/test/browser/browser.ini b/browser/components/syncedtabs/test/browser/browser.ini
new file mode 100644
index 0000000000..02fa364f10
--- /dev/null
+++ b/browser/components/syncedtabs/test/browser/browser.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+support-files = head.js
+
+[browser_sidebar_syncedtabslist.js]
diff --git a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
new file mode 100644
index 0000000000..0874a50019
--- /dev/null
+++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
@@ -0,0 +1,646 @@
+"use strict";
+
+const { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+const FIXTURE = [
+ {
+ id: "7cqCr77ptzX3",
+ type: "client",
+ lastModified: 1492201200,
+ name: "zcarter's Nightly on MacBook-Pro-25",
+ clientType: "desktop",
+ tabs: [
+ {
+ type: "tab",
+ title:
+ "Firefox for Android — Mobile Web browser — More ways to customize and protect your privacy — Mozilla",
+ url: "https://www.mozilla.org/en-US/firefox/android/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar",
+ icon: "chrome://global/skin/icons/defaultFavicon.svg",
+ client: "7cqCr77ptzX3",
+ lastUsed: 1452124677,
+ },
+ ],
+ },
+ {
+ id: "2xU5h-4bkWqA",
+ type: "client",
+ lastModified: 1492201200,
+ name: "laptop",
+ clientType: "desktop",
+ tabs: [
+ {
+ type: "tab",
+ title:
+ "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla",
+ url: "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar",
+ icon: "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico",
+ client: "2xU5h-4bkWqA",
+ lastUsed: 1451519425,
+ },
+ {
+ type: "tab",
+ title: "Firefox Nightly First Run Page",
+ url: "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1",
+ icon: "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png",
+ client: "2xU5h-4bkWqA",
+ lastUsed: 1451519420,
+ },
+ {
+ // Should appear first for this client.
+ type: "tab",
+ title: "Mozilla Developer Network",
+ url: "https://developer.mozilla.org/en-US/",
+ icon: "moz-anno:favicon:https://developer.cdn.mozilla.net/static/img/favicon32.e02854fdcf73.png",
+ client: "2xU5h-4bkWqA",
+ lastUsed: 1451519725,
+ },
+ ],
+ },
+ {
+ id: "OL3EJCsdb2JD",
+ type: "client",
+ lastModified: 1492201200,
+ name: "desktop",
+ clientType: "desktop",
+ tabs: [],
+ },
+];
+
+function setupSyncedTabsStubs({
+ uiState = { status: UIState.STATUS_SIGNED_IN, syncEnabled: true },
+ isConfiguredToSyncTabs = true,
+ hasSyncedThisSession = true,
+ tabClients = Cu.cloneInto(FIXTURE, {}),
+} = {}) {
+ sinon.stub(UIState, "get").returns(uiState);
+ sinon.stub(SyncedTabs._internal, "getTabClients").resolves(tabClients);
+ sinon.stub(SyncedTabs._internal, "syncTabs").resolves();
+ sinon
+ .stub(SyncedTabs._internal, "isConfiguredToSyncTabs")
+ .value(isConfiguredToSyncTabs);
+ sinon
+ .stub(SyncedTabs._internal, "hasSyncedThisSession")
+ .value(hasSyncedThisSession);
+}
+
+async function testClean() {
+ sinon.restore();
+ await new Promise(resolve => {
+ window.SidebarUI.browser.contentWindow.addEventListener(
+ "unload",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ SidebarUI.hide();
+ });
+}
+
+add_task(async function testSyncedTabsSidebarList() {
+ await SidebarUI.show("viewTabsSidebar");
+
+ Assert.equal(
+ SidebarUI.currentID,
+ "viewTabsSidebar",
+ "Sidebar should have SyncedTabs loaded"
+ );
+
+ let syncedTabsDeckComponent =
+ SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+
+ Assert.ok(syncedTabsDeckComponent, "component exists");
+
+ setupSyncedTabsStubs();
+
+ await syncedTabsDeckComponent.updatePanel();
+ // This is a hacky way of waiting for the view to render. The view renders
+ // after the following promise (a different instance of which is triggered
+ // in updatePanel) resolves, so we wait for it here as well
+ await syncedTabsDeckComponent.tabListComponent._store.getData();
+
+ Assert.ok(SyncedTabs._internal.getTabClients.called, "get clients called");
+
+ let selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+
+ Assert.ok(
+ selectedPanel.classList.contains("tabs-container"),
+ "tabs panel is selected"
+ );
+
+ Assert.equal(
+ selectedPanel.querySelectorAll(".tab").length,
+ 4,
+ "four tabs listed"
+ );
+ Assert.equal(
+ selectedPanel.querySelectorAll(".client").length,
+ 3,
+ "three clients listed"
+ );
+ Assert.equal(
+ selectedPanel.querySelectorAll(".client")[2].querySelectorAll(".empty")
+ .length,
+ 1,
+ "third client is empty"
+ );
+
+ // Verify that the tabs are sorted by last used time.
+ var expectedTabIndices = [[0], [2, 0, 1]];
+ Array.prototype.forEach.call(
+ selectedPanel.querySelectorAll(".client"),
+ (clientNode, i) => {
+ checkItem(clientNode, FIXTURE[i]);
+ Array.prototype.forEach.call(
+ clientNode.querySelectorAll(".tab"),
+ (tabNode, j) => {
+ let tabIndex = expectedTabIndices[i][j];
+ checkItem(tabNode, FIXTURE[i].tabs[tabIndex]);
+ }
+ );
+ }
+ );
+});
+
+add_task(testClean);
+
+add_task(async function testSyncedTabsSidebarFilteredList() {
+ await SidebarUI.show("viewTabsSidebar");
+ let syncedTabsDeckComponent =
+ window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+
+ Assert.ok(syncedTabsDeckComponent, "component exists");
+
+ setupSyncedTabsStubs();
+
+ await syncedTabsDeckComponent.updatePanel();
+
+ let filterInput =
+ syncedTabsDeckComponent._window.document.querySelector(".tabsFilter");
+ filterInput.value = "filter text";
+ filterInput.blur();
+
+ // This is a hacky way of waiting for the view to render. The view renders
+ // after the following promise (a different instance of which is triggered
+ // in updatePanel) resolves, so we wait for it here as well
+ await syncedTabsDeckComponent.tabListComponent._store.getData("filter text");
+
+ let selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("tabs-container"),
+ "tabs panel is selected"
+ );
+
+ Assert.equal(
+ selectedPanel.querySelectorAll(".tab").length,
+ 4,
+ "four tabs listed"
+ );
+ Assert.equal(
+ selectedPanel.querySelectorAll(".client").length,
+ 0,
+ "no clients are listed"
+ );
+
+ Assert.equal(
+ filterInput.value,
+ "filter text",
+ "filter text box has correct value"
+ );
+
+ // Tabs should not be sorted when filter is active.
+ let FIXTURE_TABS = FIXTURE.reduce(
+ (prev, client) => prev.concat(client.tabs),
+ []
+ );
+
+ Array.prototype.forEach.call(
+ selectedPanel.querySelectorAll(".tab"),
+ (tabNode, i) => {
+ checkItem(tabNode, FIXTURE_TABS[i]);
+ }
+ );
+
+ // Removing the filter should resort tabs.
+ FIXTURE_TABS.sort((a, b) => b.lastUsed - a.lastUsed);
+ await syncedTabsDeckComponent.tabListComponent._store.getData();
+ Array.prototype.forEach.call(
+ selectedPanel.querySelectorAll(".tab"),
+ (tabNode, i) => {
+ checkItem(tabNode, FIXTURE_TABS[i]);
+ }
+ );
+});
+
+add_task(testClean);
+
+add_task(async function testSyncedTabsSidebarStatus() {
+ await SidebarUI.show("viewTabsSidebar");
+ let syncedTabsDeckComponent =
+ window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+
+ Assert.ok(syncedTabsDeckComponent, "component exists");
+
+ setupSyncedTabsStubs({
+ uiState: { status: UIState.STATUS_NOT_CONFIGURED },
+ isConfiguredToSyncTabs: false,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ let selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("notAuthedInfo"),
+ "not-authed panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ uiState: { status: UIState.STATUS_NOT_VERIFIED },
+ isConfiguredToSyncTabs: false,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("unverified"),
+ "unverified panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ uiState: { status: UIState.STATUS_SIGNED_IN, syncEnabled: false },
+ isConfiguredToSyncTabs: false,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("syncDisabled"),
+ "sync disabled panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ uiState: { status: UIState.STATUS_LOGIN_FAILED },
+ isConfiguredToSyncTabs: false,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("reauth"),
+ "reauth panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ isConfiguredToSyncTabs: false,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("tabs-disabled"),
+ "tabs disabled panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ isConfiguredToSyncTabs: true,
+ hasSyncedThisSession: false,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("tabs-fetching"),
+ "tabs fetch panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ isConfiguredToSyncTabs: true,
+ hasSyncedThisSession: true,
+ tabClients: [],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("singleDeviceInfo"),
+ "tabs fetch panel is selected"
+ );
+ sinon.restore();
+
+ setupSyncedTabsStubs({
+ isConfiguredToSyncTabs: true,
+ hasSyncedThisSession: true,
+ tabClients: [{ id: "mock" }],
+ });
+ await syncedTabsDeckComponent.updatePanel();
+ selectedPanel = syncedTabsDeckComponent.container.querySelector(
+ ".sync-state.selected"
+ );
+ Assert.ok(
+ selectedPanel.classList.contains("tabs-container"),
+ "tabs panel is selected"
+ );
+});
+
+add_task(testClean);
+
+add_task(async function testSyncedTabsSidebarContextMenu() {
+ await SidebarUI.show("viewTabsSidebar");
+ let syncedTabsDeckComponent =
+ window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+
+ Assert.ok(syncedTabsDeckComponent, "component exists");
+
+ setupSyncedTabsStubs();
+
+ await syncedTabsDeckComponent.updatePanel();
+ // This is a hacky way of waiting for the view to render. The view renders
+ // after the following promise (a different instance of which is triggered
+ // in updatePanel) resolves, so we wait for it here as well
+ await syncedTabsDeckComponent.tabListComponent._store.getData();
+
+ info("Right-clicking the search box should show text-related actions");
+ let filterMenuItems = [
+ "menuitem[cmd=cmd_undo]",
+ "menuseparator",
+ // We don't check whether the commands are enabled due to platform
+ // differences. On OS X and Windows, "cut" and "copy" are always enabled
+ // for HTML inputs; on Linux, they're only enabled if text is selected.
+ "menuitem[cmd=cmd_cut]",
+ "menuitem[cmd=cmd_copy]",
+ "menuitem[cmd=cmd_paste]",
+ "menuitem[cmd=cmd_delete]",
+ "menuseparator",
+ "menuitem[cmd=cmd_selectAll]",
+ "menuseparator",
+ "menuitem#syncedTabsRefreshFilter",
+ ];
+ await testContextMenu(
+ syncedTabsDeckComponent,
+ "#SyncedTabsSidebarTabsFilterContext",
+ ".tabsFilter",
+ filterMenuItems
+ );
+
+ info("Right-clicking a tab should show additional actions");
+ let tabMenuItems = [
+ ["menuitem#syncedTabsOpenSelected", { hidden: false }],
+ ["menuitem#syncedTabsOpenSelectedInTab", { hidden: false }],
+ [
+ "menu#syncedTabsOpenSelectedInContainerTab",
+ {
+ hidden:
+ !Services.prefs.getBoolPref("privacy.userContext.enabled", false) ||
+ PrivateBrowsingUtils.isWindowPrivate(window),
+ },
+ ],
+ ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: false }],
+ ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: false }],
+ ["menuseparator", { hidden: false }],
+ ["menuitem#syncedTabsBookmarkSelected", { hidden: false }],
+ ["menuitem#syncedTabsCopySelected", { hidden: false }],
+ ["menuseparator", { hidden: false }],
+ ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }],
+ ["menuitem#syncedTabsManageDevices", { hidden: true }],
+ ["menuitem#syncedTabsRefresh", { hidden: false }],
+ ];
+ await testContextMenu(
+ syncedTabsDeckComponent,
+ "#SyncedTabsSidebarContext",
+ "#tab-7cqCr77ptzX3-0",
+ tabMenuItems
+ );
+
+ info(
+ "Right-clicking a client should show the Open All in Tabs and Manage devices actions"
+ );
+ let sidebarMenuItems = [
+ ["menuitem#syncedTabsOpenSelected", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }],
+ ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }],
+ ["menuseparator", { hidden: true }],
+ ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
+ ["menuitem#syncedTabsCopySelected", { hidden: true }],
+ ["menuseparator", { hidden: true }],
+ ["menuitem#syncedTabsOpenAllInTabs", { hidden: false }],
+ ["menuitem#syncedTabsManageDevices", { hidden: false }],
+ ["menuitem#syncedTabsRefresh", { hidden: false }],
+ ];
+ await testContextMenu(
+ syncedTabsDeckComponent,
+ "#SyncedTabsSidebarContext",
+ "#item-7cqCr77ptzX3",
+ sidebarMenuItems
+ );
+
+ info(
+ "Right-clicking a client without any tabs should not show the Open All in Tabs action"
+ );
+ let menuItems = [
+ ["menuitem#syncedTabsOpenSelected", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }],
+ ["menu#syncedTabsOpenSelectedInContainerTab", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }],
+ ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }],
+ ["menuseparator", { hidden: true }],
+ ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
+ ["menuitem#syncedTabsCopySelected", { hidden: true }],
+ ["menuseparator", { hidden: true }],
+ ["menuitem#syncedTabsOpenAllInTabs", { hidden: true }],
+ ["menuitem#syncedTabsManageDevices", { hidden: false }],
+ ["menuitem#syncedTabsRefresh", { hidden: false }],
+ ];
+ await testContextMenu(
+ syncedTabsDeckComponent,
+ "#SyncedTabsSidebarContext",
+ "#item-OL3EJCsdb2JD",
+ menuItems
+ );
+});
+
+add_task(testClean);
+
+function checkItem(node, item) {
+ Assert.ok(node.classList.contains("item"), "Node should have .item class");
+ if (item.client) {
+ // tab items
+ Assert.equal(
+ node.querySelector(".item-title").textContent,
+ item.title,
+ "Node's title element's text should match item title"
+ );
+ Assert.ok(node.classList.contains("tab"), "Node should have .tab class");
+ Assert.equal(
+ node.dataset.url,
+ item.url,
+ "Node's URL should match item URL"
+ );
+ Assert.equal(
+ node.getAttribute("title"),
+ item.title + "\n" + item.url,
+ "Tab node should have correct title attribute"
+ );
+ } else {
+ // client items
+ Assert.equal(
+ node.querySelector(".item-title").textContent,
+ item.name,
+ "Node's title element's text should match client name"
+ );
+ Assert.ok(
+ node.classList.contains("client"),
+ "Node should have .client class"
+ );
+ Assert.equal(node.dataset.id, item.id, "Node's ID should match item ID");
+ }
+}
+
+async function testContextMenu(
+ syncedTabsDeckComponent,
+ contextSelector,
+ triggerSelector,
+ menuSelectors
+) {
+ let contextMenu = document.querySelector(contextSelector);
+ let triggerElement =
+ syncedTabsDeckComponent._window.document.querySelector(triggerSelector);
+ let isClosed = triggerElement.classList.contains("closed");
+
+ let promisePopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ let chromeWindow = triggerElement.ownerGlobal.top;
+ let rect = triggerElement.getBoundingClientRect();
+ let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect();
+ // The offsets in `rect` are relative to the content window, but
+ // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`,
+ // which interprets the offsets relative to the containing *chrome* window.
+ // This means we need to account for the width and height of any elements
+ // outside the `browser` element, like `#sidebar-header`.
+ let offsetX = contentRect.x + rect.x + rect.width / 2;
+ let offsetY = contentRect.y + rect.y + rect.height / 4;
+
+ await EventUtils.synthesizeMouseAtPoint(
+ offsetX,
+ offsetY,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ chromeWindow
+ );
+ await promisePopupShown;
+ is(
+ triggerElement.classList.contains("closed"),
+ isClosed,
+ "Showing the context menu shouldn't toggle the tab list"
+ );
+ let menuitemClicked = await checkChildren(contextMenu, menuSelectors);
+ if (!menuitemClicked) {
+ let promisePopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await promisePopupHidden;
+ }
+}
+
+function checkChildren(node, selectors) {
+ is(node.children.length, selectors.length, "Menu item count doesn't match");
+ let containerMenuShown;
+ for (let index = 0; index < node.children.length; index++) {
+ let child = node.children[index];
+ let [selector, props] = [].concat(selectors[index]);
+ ok(selector, `Node at ${index} should have selector`);
+ ok(child.matches(selector), `Node ${index} should match ${selector}`);
+ if (props) {
+ Object.keys(props).forEach(prop => {
+ is(child[prop], props[prop], `${prop} value at ${index} should match`);
+ });
+ }
+ if (
+ selector === "menu#syncedTabsOpenSelectedInContainerTab" &&
+ !props.hidden
+ ) {
+ containerMenuShown = child;
+ }
+ }
+ if (containerMenuShown) {
+ return testContainerMenu(containerMenuShown);
+ }
+ return false;
+}
+
+async function testContainerMenu(menu) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+ let menupopup = menu.getElementsByTagName("menupopup")[0];
+ let menupopupShown = BrowserTestUtils.waitForEvent(menupopup, "popupshown");
+ menu.openMenu(true);
+ await menupopupShown;
+ let shown = [1, 2, 3, 4];
+ let hidden = [0];
+ for (let id of shown) {
+ ok(
+ menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`),
+ `User context id ${id} should exist`
+ );
+ }
+ for (let id of hidden) {
+ ok(
+ !menupopup.querySelector(`menuitem[data-usercontextid="${id}"]`),
+ `User context id ${id} shouldn't exist`
+ );
+ }
+ const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ menupopup.activateItem(
+ menupopup.querySelector(
+ `menuitem[data-usercontextid="${shown[shown.length - 1]}"]`
+ )
+ );
+ let newTab = await newTabPromise;
+ ok(
+ newTab.hasAttribute("usercontextid"),
+ `Tab with usercontextid = ${shown[shown.length - 1]} should be opened`
+ );
+ registerCleanupFunction(() => BrowserTestUtils.removeTab(newTab));
+ return true;
+}
diff --git a/browser/components/syncedtabs/test/browser/head.js b/browser/components/syncedtabs/test/browser/head.js
new file mode 100644
index 0000000000..bd90d22f03
--- /dev/null
+++ b/browser/components/syncedtabs/test/browser/head.js
@@ -0,0 +1,3 @@
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
diff --git a/browser/components/syncedtabs/test/xpcshell/head.js b/browser/components/syncedtabs/test/xpcshell/head.js
new file mode 100644
index 0000000000..c390b71011
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/head.js
@@ -0,0 +1,12 @@
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
+ return ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+});
+
+do_get_profile(); // fxa needs a profile directory for storage.
diff --git a/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js
new file mode 100644
index 0000000000..07f2a1b296
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js
@@ -0,0 +1,36 @@
+"use strict";
+
+let { EventEmitter } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/EventEmitter.sys.mjs"
+);
+
+add_task(async function testSingleListener() {
+ let eventEmitter = new EventEmitter();
+ let spy = sinon.spy();
+
+ eventEmitter.on("click", spy);
+ eventEmitter.emit("click", "foo", "bar");
+ Assert.ok(spy.calledOnce);
+ Assert.ok(spy.calledWith("foo", "bar"));
+
+ eventEmitter.off("click", spy);
+ eventEmitter.emit("click");
+ Assert.ok(spy.calledOnce);
+});
+
+add_task(async function testMultipleListeners() {
+ let eventEmitter = new EventEmitter();
+ let spy1 = sinon.spy();
+ let spy2 = sinon.spy();
+
+ eventEmitter.on("some_event", spy1);
+ eventEmitter.on("some_event", spy2);
+ eventEmitter.emit("some_event");
+ Assert.ok(spy1.calledOnce);
+ Assert.ok(spy2.calledOnce);
+
+ eventEmitter.off("some_event", spy1);
+ eventEmitter.emit("some_event");
+ Assert.ok(spy1.calledOnce);
+ Assert.ok(spy2.calledTwice);
+});
diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js
new file mode 100644
index 0000000000..9162325081
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js
@@ -0,0 +1,263 @@
+"use strict";
+
+let { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+let { SyncedTabsDeckComponent } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsDeckComponent.sys.mjs"
+);
+let { SyncedTabsListStore } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs"
+);
+let { SyncedTabsDeckStore } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs"
+);
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+add_task(async function testInitUninit() {
+ let deckStore = new SyncedTabsDeckStore();
+ let listComponent = {};
+ let mockWindow = {};
+
+ let ViewMock = sinon.stub();
+ let view = { render: sinon.spy(), destroy: sinon.spy(), container: {} };
+ ViewMock.returns(view);
+
+ sinon.stub(SyncedTabs, "syncTabs").callsFake(() => Promise.resolve());
+
+ sinon.spy(deckStore, "on");
+ sinon.stub(deckStore, "setPanels");
+
+ let component = new SyncedTabsDeckComponent({
+ window: mockWindow,
+ deckStore,
+ listComponent,
+ SyncedTabs,
+ DeckView: ViewMock,
+ });
+
+ sinon.stub(component, "updatePanel");
+
+ component.init();
+
+ Assert.ok(SyncedTabs.syncTabs.called);
+ SyncedTabs.syncTabs.restore();
+
+ Assert.ok(ViewMock.calledWithNew(), "view is instantiated");
+ Assert.equal(ViewMock.args[0][0], mockWindow);
+ Assert.equal(ViewMock.args[0][1], listComponent);
+ Assert.ok(
+ ViewMock.args[0][2].onConnectDeviceClick,
+ "view is passed onConnectDeviceClick prop"
+ );
+ Assert.ok(
+ ViewMock.args[0][2].onSyncPrefClick,
+ "view is passed onSyncPrefClick prop"
+ );
+
+ Assert.equal(
+ component.container,
+ view.container,
+ "component returns view's container"
+ );
+
+ Assert.ok(deckStore.on.calledOnce, "listener is added to store");
+ Assert.equal(deckStore.on.args[0][0], "change");
+ // Object.values only in nightly
+ let values = Object.keys(component.PANELS).map(k => component.PANELS[k]);
+ Assert.ok(
+ deckStore.setPanels.calledWith(values),
+ "panels are set on deck store"
+ );
+
+ Assert.ok(component.updatePanel.called);
+
+ deckStore.emit("change", "mock state");
+ Assert.ok(
+ view.render.calledWith("mock state"),
+ "view.render is called on state change"
+ );
+
+ component.uninit();
+
+ Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit");
+});
+
+add_task(async function testObserver() {
+ let deckStore = new SyncedTabsDeckStore();
+ let listStore = new SyncedTabsListStore(SyncedTabs);
+ let listComponent = {};
+ let mockWindow = {};
+
+ let ViewMock = sinon.stub();
+ let view = { render: sinon.spy(), destroy: sinon.spy(), container: {} };
+ ViewMock.returns(view);
+
+ sinon.stub(SyncedTabs, "syncTabs").callsFake(() => Promise.resolve());
+
+ sinon.spy(deckStore, "on");
+ sinon.stub(deckStore, "setPanels");
+
+ sinon.stub(listStore, "getData");
+
+ let component = new SyncedTabsDeckComponent({
+ window: mockWindow,
+ deckStore,
+ listStore,
+ listComponent,
+ SyncedTabs,
+ DeckView: ViewMock,
+ });
+
+ sinon.spy(component, "observe");
+ sinon.stub(component, "updatePanel");
+ sinon.stub(component, "updateDir");
+
+ component.init();
+ SyncedTabs.syncTabs.restore();
+ Assert.ok(component.updatePanel.called, "triggers panel update during init");
+ Assert.ok(
+ component.updateDir.called,
+ "triggers UI direction update during init"
+ );
+
+ Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED);
+
+ Assert.ok(
+ component.observe.calledWith(null, SyncedTabs.TOPIC_TABS_CHANGED),
+ "component is notified"
+ );
+
+ Assert.ok(listStore.getData.called, "gets list data");
+ Assert.ok(component.updatePanel.calledTwice, "triggers panel update");
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ Assert.ok(
+ component.observe.calledWith(null, UIState.ON_UPDATE),
+ "component is notified of FxA/Sync UI Update"
+ );
+ Assert.equal(
+ component.updatePanel.callCount,
+ 3,
+ "triggers panel update again"
+ );
+
+ Services.locale.availableLocales = ["ab-CD"];
+ Services.locale.requestedLocales = ["ab-CD"];
+
+ Assert.ok(
+ component.updateDir.calledTwice,
+ "locale change triggers UI direction update"
+ );
+
+ Services.prefs.setStringPref("intl.l10n.pseudo", "bidi");
+
+ Assert.equal(
+ component.updateDir.callCount,
+ 3,
+ "pref change triggers UI direction update"
+ );
+});
+
+add_task(async function testPanelStatus() {
+ let deckStore = new SyncedTabsDeckStore();
+ let listStore = new SyncedTabsListStore();
+ let listComponent = {};
+ let SyncedTabsMock = {
+ getTabClients() {},
+ };
+
+ sinon.stub(listStore, "getData");
+
+ let component = new SyncedTabsDeckComponent({
+ deckStore,
+ listComponent,
+ SyncedTabs: SyncedTabsMock,
+ });
+
+ sinon.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_CONFIGURED });
+ let result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.NOT_AUTHED_INFO);
+ UIState.get.restore();
+
+ sinon.stub(UIState, "get").returns({ status: UIState.STATUS_NOT_VERIFIED });
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.UNVERIFIED);
+ UIState.get.restore();
+
+ sinon.stub(UIState, "get").returns({ status: UIState.STATUS_LOGIN_FAILED });
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.LOGIN_FAILED);
+ UIState.get.restore();
+
+ sinon
+ .stub(UIState, "get")
+ .returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: false });
+ SyncedTabsMock.isConfiguredToSyncTabs = true;
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.SYNC_DISABLED);
+ UIState.get.restore();
+
+ sinon
+ .stub(UIState, "get")
+ .returns({ status: UIState.STATUS_SIGNED_IN, syncEnabled: true });
+ SyncedTabsMock.isConfiguredToSyncTabs = false;
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.TABS_DISABLED);
+
+ SyncedTabsMock.isConfiguredToSyncTabs = true;
+
+ SyncedTabsMock.hasSyncedThisSession = false;
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.TABS_FETCHING);
+
+ SyncedTabsMock.hasSyncedThisSession = true;
+
+ let clients = [];
+ sinon
+ .stub(SyncedTabsMock, "getTabClients")
+ .callsFake(() => Promise.resolve(clients));
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.SINGLE_DEVICE_INFO);
+
+ clients = ["mock-client"];
+ result = await component.getPanelStatus();
+ Assert.equal(result, component.PANELS.TABS_CONTAINER);
+
+ sinon
+ .stub(component, "getPanelStatus")
+ .callsFake(() => Promise.resolve("mock-panelId"));
+ sinon.spy(deckStore, "selectPanel");
+ await component.updatePanel();
+ Assert.ok(deckStore.selectPanel.calledWith("mock-panelId"));
+});
+
+add_task(async function testActions() {
+ let windowMock = {};
+ let chromeWindowMock = {
+ gSync: {
+ openPrefs() {},
+ openConnectAnotherDevice() {},
+ },
+ };
+ sinon.spy(chromeWindowMock.gSync, "openPrefs");
+ sinon.spy(chromeWindowMock.gSync, "openConnectAnotherDevice");
+
+ let getChromeWindowMock = sinon.stub();
+ getChromeWindowMock.returns(chromeWindowMock);
+
+ let component = new SyncedTabsDeckComponent({
+ window: windowMock,
+ getChromeWindowMock,
+ });
+
+ component.openConnectDevice();
+ Assert.ok(chromeWindowMock.gSync.openConnectAnotherDevice.called);
+
+ component.openSyncPrefs();
+ Assert.ok(getChromeWindowMock.calledWith(windowMock));
+ Assert.ok(chromeWindowMock.gSync.openPrefs.called);
+});
diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js
new file mode 100644
index 0000000000..a75ded8c8a
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js
@@ -0,0 +1,69 @@
+"use strict";
+
+let { SyncedTabsDeckStore } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs"
+);
+
+add_task(async function testSelectUnkownPanel() {
+ let deckStore = new SyncedTabsDeckStore();
+ let spy = sinon.spy();
+
+ deckStore.on("change", spy);
+ deckStore.selectPanel("foo");
+
+ Assert.ok(!spy.called);
+});
+
+add_task(async function testSetPanels() {
+ let deckStore = new SyncedTabsDeckStore();
+ let spy = sinon.spy();
+
+ deckStore.on("change", spy);
+ deckStore.setPanels(["panel1", "panel2"]);
+
+ Assert.ok(
+ spy.calledWith({
+ panels: [
+ { id: "panel1", selected: false },
+ { id: "panel2", selected: false },
+ ],
+ isUpdatable: false,
+ })
+ );
+});
+
+add_task(async function testSelectPanel() {
+ let deckStore = new SyncedTabsDeckStore();
+ let spy = sinon.spy();
+
+ deckStore.setPanels(["panel1", "panel2"]);
+
+ deckStore.on("change", spy);
+ deckStore.selectPanel("panel2");
+
+ Assert.ok(
+ spy.calledWith({
+ panels: [
+ { id: "panel1", selected: false },
+ { id: "panel2", selected: true },
+ ],
+ isUpdatable: true,
+ })
+ );
+
+ deckStore.selectPanel("panel2");
+ Assert.ok(spy.calledOnce, "doesn't trigger unless panel changes");
+});
+
+add_task(async function testSetPanelsSameArray() {
+ let deckStore = new SyncedTabsDeckStore();
+ let spy = sinon.spy();
+ deckStore.on("change", spy);
+
+ let panels = ["panel1", "panel2"];
+
+ deckStore.setPanels(panels);
+ deckStore.setPanels(panels);
+
+ Assert.ok(spy.calledOnce, "doesn't trigger unless set of panels changes");
+});
diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js
new file mode 100644
index 0000000000..36138aace3
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js
@@ -0,0 +1,289 @@
+"use strict";
+
+let { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+let { SyncedTabsListStore } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs"
+);
+
+const FIXTURE = [
+ {
+ id: "2xU5h-4bkWqA",
+ type: "client",
+ lastModified: 1492201200,
+ name: "laptop",
+ isMobile: false,
+ tabs: [
+ {
+ type: "tab",
+ title:
+ "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla",
+ url: "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar",
+ icon: "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico",
+ client: "2xU5h-4bkWqA",
+ lastUsed: 1451519425,
+ },
+ {
+ type: "tab",
+ title: "Firefox Nightly First Run Page",
+ url: "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1",
+ icon: "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png",
+ client: "2xU5h-4bkWqA",
+ lastUsed: 1451519420,
+ },
+ ],
+ },
+ {
+ id: "OL3EJCsdb2JD",
+ type: "client",
+ lastModified: 1492201200,
+ name: "desktop",
+ isMobile: false,
+ tabs: [],
+ },
+];
+
+add_task(async function testGetDataEmpty() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve([]);
+ });
+ store.on("change", spy);
+
+ await store.getData();
+
+ Assert.ok(SyncedTabs.getTabClients.calledWith(""));
+ Assert.ok(
+ spy.calledWith({
+ clients: [],
+ canUpdateAll: false,
+ canUpdateInput: false,
+ filter: "",
+ inputFocused: false,
+ })
+ );
+
+ await store.getData("filter");
+
+ Assert.ok(SyncedTabs.getTabClients.calledWith("filter"));
+ Assert.ok(
+ spy.calledWith({
+ clients: [],
+ canUpdateAll: false,
+ canUpdateInput: true,
+ filter: "filter",
+ inputFocused: false,
+ })
+ );
+
+ SyncedTabs.getTabClients.restore();
+});
+
+add_task(async function testRowSelectionWithoutFilter() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(FIXTURE);
+ });
+
+ await store.getData();
+ SyncedTabs.getTabClients.restore();
+
+ store.on("change", spy);
+
+ store.selectRow(0, -1);
+ Assert.ok(spy.args[0][0].canUpdateAll, "can update the whole view");
+ Assert.ok(spy.args[0][0].clients[0].selected, "first client is selected");
+
+ store.moveSelectionUp();
+ Assert.ok(
+ spy.calledOnce,
+ "can't move up past first client, no change triggered"
+ );
+
+ store.selectRow(0, 0);
+ Assert.ok(
+ spy.args[1][0].clients[0].tabs[0].selected,
+ "first tab of first client is selected"
+ );
+
+ store.selectRow(0, 0);
+ Assert.ok(spy.calledTwice, "selecting same row doesn't trigger change");
+
+ store.selectRow(0, 1);
+ Assert.ok(
+ spy.args[2][0].clients[0].tabs[1].selected,
+ "second tab of first client is selected"
+ );
+
+ store.selectRow(1);
+ Assert.ok(spy.args[3][0].clients[1].selected, "second client is selected");
+
+ store.moveSelectionDown();
+ Assert.equal(
+ spy.callCount,
+ 4,
+ "can't move selection down past last client, no change triggered"
+ );
+
+ store.moveSelectionUp();
+ Assert.equal(spy.callCount, 5, "changed");
+ Assert.ok(
+ spy.args[4][0].clients[0].tabs[FIXTURE[0].tabs.length - 1].selected,
+ "move selection up from client selects last tab of previous client"
+ );
+
+ store.moveSelectionUp();
+ Assert.ok(
+ spy.args[5][0].clients[0].tabs[FIXTURE[0].tabs.length - 2].selected,
+ "move selection up from tab selects previous tab of client"
+ );
+});
+
+add_task(async function testToggleBranches() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(FIXTURE);
+ });
+
+ await store.getData();
+ SyncedTabs.getTabClients.restore();
+
+ store.selectRow(0);
+ store.on("change", spy);
+
+ let clientId = FIXTURE[0].id;
+ store.closeBranch(clientId);
+ Assert.ok(spy.args[0][0].clients[0].closed, "first client is closed");
+
+ store.openBranch(clientId);
+ Assert.ok(!spy.args[1][0].clients[0].closed, "first client is open");
+
+ store.toggleBranch(clientId);
+ Assert.ok(spy.args[2][0].clients[0].closed, "first client is toggled closed");
+
+ store.moveSelectionDown();
+ Assert.ok(
+ spy.args[3][0].clients[1].selected,
+ "selection skips tabs if client is closed"
+ );
+
+ store.moveSelectionUp();
+ Assert.ok(
+ spy.args[4][0].clients[0].selected,
+ "selection skips tabs if client is closed"
+ );
+});
+
+add_task(async function testRowSelectionWithFilter() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(FIXTURE);
+ });
+
+ await store.getData("filter");
+ SyncedTabs.getTabClients.restore();
+
+ store.on("change", spy);
+
+ store.selectRow(0);
+ Assert.ok(
+ spy.args[0][0].clients[0].tabs[0].selected,
+ "first tab is selected"
+ );
+
+ store.moveSelectionUp();
+ Assert.ok(
+ spy.calledOnce,
+ "can't move up past first tab, no change triggered"
+ );
+
+ store.moveSelectionDown();
+ Assert.ok(
+ spy.args[1][0].clients[0].tabs[1].selected,
+ "selection skips tabs if client is closed"
+ );
+
+ store.moveSelectionDown();
+ Assert.equal(
+ spy.callCount,
+ 2,
+ "can't move selection down past last tab, no change triggered"
+ );
+
+ store.selectRow(1);
+ Assert.equal(spy.callCount, 2, "doesn't trigger change if same row selected");
+});
+
+add_task(async function testFilterAndClearFilter() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(FIXTURE);
+ });
+ store.on("change", spy);
+
+ await store.getData("filter");
+
+ Assert.ok(SyncedTabs.getTabClients.calledWith("filter"));
+ Assert.ok(!spy.args[0][0].canUpdateAll, "can't update all");
+ Assert.ok(spy.args[0][0].canUpdateInput, "can update just input");
+
+ store.selectRow(0);
+
+ Assert.equal(spy.args[1][0].filter, "filter");
+ Assert.ok(spy.args[1][0].clients[0].tabs[0].selected, "tab is selected");
+
+ await store.clearFilter();
+
+ Assert.ok(SyncedTabs.getTabClients.calledWith(""));
+ Assert.ok(!spy.args[2][0].canUpdateAll, "can't update all");
+ Assert.ok(!spy.args[2][0].canUpdateInput, "can't just update input");
+
+ Assert.equal(spy.args[2][0].filter, "");
+ Assert.ok(
+ !spy.args[2][0].clients[0].tabs[0].selected,
+ "tab is no longer selected"
+ );
+
+ SyncedTabs.getTabClients.restore();
+});
+
+add_task(async function testFocusBlurInput() {
+ let store = new SyncedTabsListStore(SyncedTabs);
+ let spy = sinon.spy();
+
+ sinon.stub(SyncedTabs, "getTabClients").callsFake(() => {
+ return Promise.resolve(FIXTURE);
+ });
+ store.on("change", spy);
+
+ await store.getData();
+ SyncedTabs.getTabClients.restore();
+
+ Assert.ok(!spy.args[0][0].canUpdateAll, "must rerender all");
+
+ store.selectRow(0);
+ Assert.ok(!spy.args[1][0].inputFocused, "input is not focused");
+ Assert.ok(spy.args[1][0].clients[0].selected, "client is selected");
+ Assert.ok(spy.args[1][0].clients[0].focused, "client is focused");
+
+ store.focusInput();
+ Assert.ok(spy.args[2][0].inputFocused, "input is focused");
+ Assert.ok(spy.args[2][0].clients[0].selected, "client is still selected");
+ Assert.ok(!spy.args[2][0].clients[0].focused, "client is no longer focused");
+
+ store.blurInput();
+ Assert.ok(!spy.args[3][0].inputFocused, "input is not focused");
+ Assert.ok(spy.args[3][0].clients[0].selected, "client is selected");
+ Assert.ok(spy.args[3][0].clients[0].focused, "client is focused");
+});
diff --git a/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js
new file mode 100644
index 0000000000..734baac254
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js
@@ -0,0 +1,190 @@
+"use strict";
+
+let { SyncedTabs } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncedTabs.sys.mjs"
+);
+let { TabListComponent } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/TabListComponent.sys.mjs"
+);
+let { SyncedTabsListStore } = ChromeUtils.importESModule(
+ "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs"
+);
+
+const ACTION_METHODS = [
+ "onSelectRow",
+ "onOpenTab",
+ "onOpenTabs",
+ "onMoveSelectionDown",
+ "onMoveSelectionUp",
+ "onToggleBranch",
+ "onBookmarkTab",
+ "onSyncRefresh",
+ "onFilter",
+ "onClearFilter",
+ "onFilterFocus",
+ "onFilterBlur",
+];
+
+add_task(async function testInitUninit() {
+ let store = new SyncedTabsListStore();
+ let ViewMock = sinon.stub();
+ let view = { render() {}, destroy() {} };
+ let mockWindow = {};
+
+ ViewMock.returns(view);
+
+ sinon.spy(view, "render");
+ sinon.spy(view, "destroy");
+
+ sinon.spy(store, "on");
+ sinon.stub(store, "getData");
+ sinon.stub(store, "focusInput");
+
+ let component = new TabListComponent({
+ window: mockWindow,
+ store,
+ View: ViewMock,
+ SyncedTabs,
+ });
+
+ for (let action of ACTION_METHODS) {
+ sinon.stub(component, action);
+ }
+
+ component.init();
+
+ Assert.ok(ViewMock.calledWithNew(), "view is instantiated");
+ Assert.ok(store.on.calledOnce, "listener is added to store");
+ Assert.equal(store.on.args[0][0], "change");
+ Assert.ok(
+ view.render.calledWith({ clients: [] }),
+ "render is called on view instance"
+ );
+ Assert.ok(store.getData.calledOnce, "store gets initial data");
+ Assert.ok(store.focusInput.calledOnce, "input field is focused");
+
+ for (let method of ACTION_METHODS) {
+ let action = ViewMock.args[0][1][method];
+ Assert.ok(action, method + " action is passed to View");
+ action("foo", "bar");
+ Assert.ok(
+ component[method].calledWith("foo", "bar"),
+ method + " action passed to View triggers the component method with args"
+ );
+ }
+
+ store.emit("change", "mock state");
+ Assert.ok(
+ view.render.secondCall.calledWith("mock state"),
+ "view.render is called on state change"
+ );
+
+ component.uninit();
+ Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit");
+});
+
+add_task(async function testActions() {
+ let store = new SyncedTabsListStore();
+ let chromeWindowMock = {
+ gBrowser: {
+ loadTabs() {},
+ },
+ };
+ let getChromeWindowMock = sinon.stub();
+ getChromeWindowMock.returns(chromeWindowMock);
+ let clipboardHelperMock = {
+ copyString() {},
+ };
+ let windowMock = {
+ top: {
+ PlacesCommandHook: {
+ bookmarkLink() {
+ return Promise.resolve();
+ },
+ },
+ },
+ openDialog() {},
+ openTrustedLinkIn() {},
+ };
+ let component = new TabListComponent({
+ window: windowMock,
+ store,
+ View: null,
+ SyncedTabs,
+ clipboardHelper: clipboardHelperMock,
+ getChromeWindow: getChromeWindowMock,
+ });
+
+ sinon.stub(store, "getData");
+ component.onFilter("query");
+ Assert.ok(store.getData.calledWith("query"));
+
+ sinon.stub(store, "clearFilter");
+ component.onClearFilter();
+ Assert.ok(store.clearFilter.called);
+
+ sinon.stub(store, "focusInput");
+ component.onFilterFocus();
+ Assert.ok(store.focusInput.called);
+
+ sinon.stub(store, "blurInput");
+ component.onFilterBlur();
+ Assert.ok(store.blurInput.called);
+
+ sinon.stub(store, "selectRow");
+ component.onSelectRow([-1, -1]);
+ Assert.ok(store.selectRow.calledWith(-1, -1));
+
+ sinon.stub(store, "moveSelectionDown");
+ component.onMoveSelectionDown();
+ Assert.ok(store.moveSelectionDown.called);
+
+ sinon.stub(store, "moveSelectionUp");
+ component.onMoveSelectionUp();
+ Assert.ok(store.moveSelectionUp.called);
+
+ sinon.stub(store, "toggleBranch");
+ component.onToggleBranch("foo-id");
+ Assert.ok(store.toggleBranch.calledWith("foo-id"));
+
+ sinon.spy(windowMock.top.PlacesCommandHook, "bookmarkLink");
+ component.onBookmarkTab("uri", "title");
+ Assert.equal(windowMock.top.PlacesCommandHook.bookmarkLink.args[0][0], "uri");
+ Assert.equal(
+ windowMock.top.PlacesCommandHook.bookmarkLink.args[0][1],
+ "title"
+ );
+
+ sinon.spy(windowMock, "openTrustedLinkIn");
+ component.onOpenTab("uri", "where", "params");
+ Assert.ok(windowMock.openTrustedLinkIn.calledWith("uri", "where", "params"));
+
+ sinon.spy(chromeWindowMock.gBrowser, "loadTabs");
+ let tabsToOpen = ["uri1", "uri2"];
+ component.onOpenTabs(tabsToOpen, "where");
+ Assert.ok(getChromeWindowMock.calledWith(windowMock));
+ Assert.ok(
+ chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, {
+ inBackground: false,
+ replace: false,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ })
+ );
+ component.onOpenTabs(tabsToOpen, "tabshifted");
+ Assert.ok(
+ chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, {
+ inBackground: true,
+ replace: false,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ })
+ );
+
+ sinon.spy(clipboardHelperMock, "copyString");
+ component.onCopyTabLocation("uri");
+ Assert.ok(clipboardHelperMock.copyString.calledWith("uri"));
+
+ sinon.stub(SyncedTabs, "syncTabs");
+ component.onSyncRefresh();
+ Assert.ok(SyncedTabs.syncTabs.calledWith(true));
+ SyncedTabs.syncTabs.restore();
+});
diff --git a/browser/components/syncedtabs/test/xpcshell/xpcshell.ini b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..5b18e0757e
--- /dev/null
+++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+head = head.js
+firefox-appdir = browser
+
+[test_EventEmitter.js]
+[test_SyncedTabsDeckStore.js]
+[test_SyncedTabsListStore.js]
+[test_SyncedTabsDeckComponent.js]
+[test_TabListComponent.js]
diff --git a/browser/components/syncedtabs/util.sys.mjs b/browser/components/syncedtabs/util.sys.mjs
new file mode 100644
index 0000000000..7c93733c72
--- /dev/null
+++ b/browser/components/syncedtabs/util.sys.mjs
@@ -0,0 +1,8 @@
+/* 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/. */
+
+// Get the chrome (ie, browser) window hosting this content.
+export function getChromeWindow(window) {
+ return window.browsingContext.topChromeWindow;
+}