SyncedTabsListStore.sys.mjs (7256B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs"; 6 7 /** 8 * SyncedTabsListStore 9 * 10 * Instances of this store encapsulate all of the state associated with a synced tabs list view. 11 * The state includes the clients, their tabs, the row that is currently selected, 12 * and the filtered query. 13 */ 14 export function SyncedTabsListStore(SyncedTabs) { 15 EventEmitter.call(this); 16 this._SyncedTabs = SyncedTabs; 17 this.data = []; 18 this._closedClients = {}; 19 this._selectedRow = [-1, -1]; 20 this.filter = ""; 21 this.inputFocused = false; 22 } 23 24 Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, { 25 // This internal method triggers the "change" event that views 26 // listen for. It denormalizes the state so that it's easier for 27 // the view to deal with. updateType hints to the view what 28 // actually needs to be rerendered or just updated, and can be 29 // empty (to (re)render everything), "searchbox" (to rerender just the tab list), 30 // or "all" (to skip rendering and just update all attributes of existing nodes). 31 _change(updateType) { 32 let selectedParent = this._selectedRow[0]; 33 let selectedChild = this._selectedRow[1]; 34 let rowSelected = false; 35 // clone the data so that consumers can't mutate internal storage 36 let data = Cu.cloneInto(this.data, {}); 37 let tabCount = 0; 38 39 data.forEach((client, index) => { 40 client.closed = !!this._closedClients[client.id]; 41 42 if (rowSelected || selectedParent < 0) { 43 return; 44 } 45 if (this.filter) { 46 if (selectedParent < tabCount + client.tabs.length) { 47 client.tabs[selectedParent - tabCount].selected = true; 48 client.tabs[selectedParent - tabCount].focused = !this.inputFocused; 49 rowSelected = true; 50 } else { 51 tabCount += client.tabs.length; 52 } 53 return; 54 } 55 if (selectedParent === index && selectedChild === -1) { 56 client.selected = true; 57 client.focused = !this.inputFocused; 58 rowSelected = true; 59 } else if (selectedParent === index) { 60 client.tabs[selectedChild].selected = true; 61 client.tabs[selectedChild].focused = !this.inputFocused; 62 rowSelected = true; 63 } 64 }); 65 66 // If this were React the view would be smart enough 67 // to not re-render the whole list unless necessary. But it's 68 // not, so updateType is a hint to the view of what actually 69 // needs to be rerendered. 70 this.emit("change", { 71 clients: data, 72 canUpdateAll: updateType === "all", 73 canUpdateInput: updateType === "searchbox", 74 filter: this.filter, 75 inputFocused: this.inputFocused, 76 }); 77 }, 78 79 /** 80 * Moves the row selection from a child to its parent, 81 * which occurs when the parent of a selected row closes. 82 */ 83 _selectParentRow() { 84 this._selectedRow[1] = -1; 85 }, 86 87 _toggleBranch(id, closed) { 88 this._closedClients[id] = closed; 89 if (this._closedClients[id]) { 90 this._selectParentRow(); 91 } 92 this._change("all"); 93 }, 94 95 _isOpen(client) { 96 return !this._closedClients[client.id]; 97 }, 98 99 moveSelectionDown() { 100 let branchRow = this._selectedRow[0]; 101 let childRow = this._selectedRow[1]; 102 let branch = this.data[branchRow]; 103 104 if (this.filter) { 105 this.selectRow(branchRow + 1); 106 return; 107 } 108 109 if (branchRow < 0) { 110 this.selectRow(0, -1); 111 } else if ( 112 (!branch.tabs.length || 113 childRow >= branch.tabs.length - 1 || 114 !this._isOpen(branch)) && 115 branchRow < this.data.length 116 ) { 117 this.selectRow(branchRow + 1, -1); 118 } else if (childRow < branch.tabs.length) { 119 this.selectRow(branchRow, childRow + 1); 120 } 121 }, 122 123 moveSelectionUp() { 124 let branchRow = this._selectedRow[0]; 125 let childRow = this._selectedRow[1]; 126 127 if (this.filter) { 128 this.selectRow(branchRow - 1); 129 return; 130 } 131 132 if (branchRow < 0) { 133 this.selectRow(0, -1); 134 } else if (childRow < 0 && branchRow > 0) { 135 let prevBranch = this.data[branchRow - 1]; 136 let newChildRow = this._isOpen(prevBranch) 137 ? prevBranch.tabs.length - 1 138 : -1; 139 this.selectRow(branchRow - 1, newChildRow); 140 } else if (childRow >= 0) { 141 this.selectRow(branchRow, childRow - 1); 142 } 143 }, 144 145 // Selects a row and makes sure the selection is within bounds 146 selectRow(parent, child) { 147 let maxParentRow = this.filter ? this._tabCount() : this.data.length; 148 let parentRow = parent; 149 if (parent <= -1) { 150 parentRow = 0; 151 } else if (parent >= maxParentRow) { 152 return; 153 } 154 155 let childRow = child; 156 if ( 157 parentRow === -1 || 158 this.filter || 159 typeof child === "undefined" || 160 child < -1 161 ) { 162 childRow = -1; 163 } else if (child >= this.data[parentRow].tabs.length) { 164 childRow = this.data[parentRow].tabs.length - 1; 165 } 166 167 if ( 168 this._selectedRow[0] === parentRow && 169 this._selectedRow[1] === childRow 170 ) { 171 return; 172 } 173 174 this._selectedRow = [parentRow, childRow]; 175 this.inputFocused = false; 176 this._change("all"); 177 // Record the telemetry event 178 let extraOptions = { 179 tab_pos: this._selectedRow[1].toString(), 180 filter: this.filter, 181 }; 182 this._SyncedTabs.recordSyncedTabsTelemetry( 183 "synced_tabs_sidebar", 184 "click", 185 extraOptions 186 ); 187 }, 188 189 _tabCount() { 190 return this.data.reduce((prev, curr) => curr.tabs.length + prev, 0); 191 }, 192 193 toggleBranch(id) { 194 this._toggleBranch(id, !this._closedClients[id]); 195 }, 196 197 closeBranch(id) { 198 this._toggleBranch(id, true); 199 }, 200 201 openBranch(id) { 202 this._toggleBranch(id, false); 203 }, 204 205 focusInput() { 206 this.inputFocused = true; 207 // A change type of "all" updates rather than rebuilds, which is what we 208 // want here - only the selection/focus has changed. 209 this._change("all"); 210 }, 211 212 blurInput() { 213 this.inputFocused = false; 214 // A change type of "all" updates rather than rebuilds, which is what we 215 // want here - only the selection/focus has changed. 216 this._change("all"); 217 }, 218 219 clearFilter() { 220 this.filter = ""; 221 this._selectedRow = [-1, -1]; 222 return this.getData(); 223 }, 224 225 // Fetches data from the SyncedTabs module and triggers 226 // and update 227 getData(filter) { 228 let updateType; 229 let hasFilter = typeof filter !== "undefined"; 230 if (hasFilter) { 231 this.filter = filter; 232 this._selectedRow = [-1, -1]; 233 234 // When a filter is specified we tell the view that only the list 235 // needs to be rerendered so that it doesn't disrupt the input 236 // field's focus. 237 updateType = "searchbox"; 238 } 239 240 // return promise for tests 241 return this._SyncedTabs 242 .getTabClients(this.filter) 243 .then(result => { 244 if (!hasFilter) { 245 // Only sort clients and tabs if we're rendering the whole list. 246 this._SyncedTabs.sortTabClientsByLastUsed(result); 247 } 248 this.data = result; 249 this._change(updateType); 250 }) 251 .catch(console.error); 252 }, 253 });