summaryrefslogtreecommitdiffstats
path: root/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/syncedtabs/SyncedTabsListStore.sys.mjs')
-rw-r--r--browser/components/syncedtabs/SyncedTabsListStore.sys.mjs253
1 files changed, 253 insertions, 0 deletions
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);
+ },
+});