Source code

Revision control

Copy as Markdown

Other Tools

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