/* 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); }, });