/* # 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'; import EventListenerManager from '/extlib/EventListenerManager.js'; import TabFavIconHelper from '/extlib/TabFavIconHelper.js'; import { log as internalLogger, dumpTab, mapAndFilter, mapAndFilterUniq, toLines, sanitizeForHTMLText, sanitizeForRegExpSource, isNewTabCommandTab, isFirefoxViewTab, configs, doProgressively, } from './common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Constants from './constants.js'; import * as ContextualIdentities from './contextual-identities.js'; import * as SidebarConnection from './sidebar-connection.js'; import * as TabsStore from './tabs-store.js'; import * as UniqueId from './unique-id.js'; import Window from './Window.js'; function log(...args) { internalLogger('common/TreeItem', ...args); } function successorTabLog(...args) { internalLogger('background/successor-tab', ...args); } // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab export const kPERMISSION_ACTIVE_TAB = 'activeTab'; export const kPERMISSION_TABS = 'tabs'; export const kPERMISSION_COOKIES = 'cookies'; export const kPERMISSION_INCOGNITO = 'incognito'; // only for internal use export const kPERMISSIONS_ALL = new Set([ kPERMISSION_TABS, kPERMISSION_COOKIES, kPERMISSION_INCOGNITO ]); const mOpenedResolvers = new Map(); const mIncompletelyTrackedTabs = new Map(); const mMovingTabs = new Map(); const mPromisedTrackedTabs = new Map(); browser.windows.onRemoved.addListener(windowId => { mIncompletelyTrackedTabs.delete(windowId); mMovingTabs.delete(windowId); }); export class TreeItem { static TYPE_TAB = 'tab'; static TYPE_GROUP = 'group'; static TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER = 'group-collapsed-members-counter'; static onElementBound = new EventListenerManager(); // The list of properties which should be ignored when synchronization from the // background to sidebars. static UNSYNCHRONIZABLE_PROPERTIES = new Set([ 'id', // Ignore "index" on synchronization, because it maybe wrong for the sidebar. // Index of tabs are managed and fixed by other sections like handling of // "kCOMMAND_NOTIFY_TAB_CREATING", Window.prototype.trackTab, and others. // See also: https://github.com/piroor/treestyletab/issues/2119 'index', 'reindexedBy' ]); // key = addon ID // value = Set of states static autoStickyStates = new Map(); static allAutoStickyStates = new Set(); constructor(raw) { raw.$TST = this; this.raw = raw; this.id = raw.id; raw.type = this.type || 'unknown'; this.trackedAt = Date.now(); this.opened = Promise.resolve(true); // We should not change the shape of the object, so temporary data should be held in this map. this.temporaryMetadata = new Map(); this.highPriorityTooltipTexts = new Map(); this.lowPriorityTooltipTexts = new Map(); this.$exportedForAPI = null; this.$exportedForAPIWithPermissions = new Map(); this.element = null; this.classList = null; this.promisedElement = new Promise((resolve, _reject) => { this._promisedElementResolver = resolve; }); this.states = new Set(); this.clear(); this.uniqueId = { id: null, originalId: null, originalTabId: null }; this.promisedUniqueId = Promise.resolve(null); } destroy() { if (this.element && this.element.parentNode) this.element.parentNode.removeChild(this.element); this.unbindElement(); // this.raw.$TST = null; // raw.$TST is used by destruction processes. this.raw = null; this.promisedUniqueId = null; this.uniqueId = null; this.destroyed = true; } clear() { this.states.clear(); this.attributes = {}; } bindElement(element) { element.$TST = this; element.apiRaw = this.raw; this.element = element; this.classList = element.classList; // wait until initialization processes are completed (Constants.IS_BACKGROUND ? setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. window.requestAnimationFrame)(() => { this._promisedElementResolver(element); if (!element) { // reset for the next binding this.promisedElement = new Promise((resolve, _reject) => { this._promisedElementResolver = resolve; }); } if (!this.raw) // unbound while waiting! return; TreeItem.onElementBound.dispatch(this.raw); }, 0); } unbindElement() { if (this.element) { for (const state of this.states) { this.element.classList.remove(state); if (state == Constants.kTAB_STATE_HIGHLIGHTED) this.element.removeAttribute('aria-selected'); } for (const name of Object.keys(this.attributes)) { this.element.removeAttribute(name); } this.element.$TST = null; this.element.apiRaw = null; } this.element = null; this.classList = null; } startMoving() { return Promise.resolve(); } updateUniqueId(_options = {}) { return Promise.resolve(null); } get type() { return null; } get renderingId() { return `${this.type}:${this.id}`; } get title() { return this.raw.title; } //=================================================================== // status of tree item //=================================================================== get collapsed() { return this.states.has(Constants.kTAB_STATE_COLLAPSED); } get collapsedCompletely() { return this.states.has(Constants.kTAB_STATE_COLLAPSED_DONE); } get subtreeCollapsed() { return this.states.has(Constants.kTAB_STATE_SUBTREE_COLLAPSED); } get isSubtreeCollapsable() { return this.hasChild && !this.collapsed && !this.subtreeCollapsed; } get isAutoExpandable() { return this.hasChild && this.subtreeCollapsed; } get duplicating() { return this.states.has(Constants.kTAB_STATE_DUPLICATING); } get removing() { return this.states.has(Constants.kTAB_STATE_REMOVING); } get sticky() { return this.states.has(Constants.kTAB_STATE_STICKY); } get stuck() { return this.element?.parentNode?.classList.contains('sticky-tabs-container'); } get canBecomeSticky() { if (this.collapsed || this.states.has(Constants.kTAB_STATE_EXPANDING) || this.states.has(Constants.kTAB_STATE_COLLAPSING)) return false; if (this.sticky) return true; if ((new Set([...this.states, ...TreeItem.allAutoStickyStates])).size < this.states.size + TreeItem.allAutoStickyStates.size) { return true; } return false; } get promisedPossibleOpenerBookmarks() { return Promise.resolve(null); } get defaultTooltipText() { return this.raw.title; } get tooltipTextWithDescendants() { const tooltip = [`* ${this.defaultTooltipText}`]; for (const child of this.children) { if (!child) continue; tooltip.push(child.$TST.tooltipTextWithDescendants.replace(/^/gm, ' ')); } return tooltip.join('\n'); } get tooltipHtml() { return `${sanitizeForHTMLText(this.raw.title)}`; } get tooltipHtmlWithDescendants() { return ``; } generateTooltipHtmlWithDescendants() { let tooltip = `
  • ${this.tooltipHtml}`; const children = []; for (const child of this.children) { if (!child) continue; children.push(child.$TST.generateTooltipHtmlWithDescendants()); } if (children.length > 0) tooltip += ``; return `${tooltip}
  • `; } registerTooltipText(ownerId, text, isHighPriority = false) { if (isHighPriority) { this.highPriorityTooltipTexts.set(ownerId, text); this.lowPriorityTooltipTexts.delete(ownerId); } else { this.highPriorityTooltipTexts.delete(ownerId); this.lowPriorityTooltipTexts.set(ownerId, text); } } unregisterTooltipText(ownerId) { this.highPriorityTooltipTexts.delete(ownerId); this.lowPriorityTooltipTexts.delete(ownerId); } get highPriorityTooltipText() { if (this.highPriorityTooltipTexts.size == 0) return null; return [...this.highPriorityTooltipTexts.values()][this.highPriorityTooltipTexts.size - 1]; } get lowPriorityTooltipText() { if (this.lowPriorityTooltipTexts.size == 0) return null; return [...this.lowPriorityTooltipTexts.values()][this.lowPriorityTooltipTexts.size - 1]; } //=================================================================== // neighbor tabs //=================================================================== get nextTab() { return null; } get previousTab() { return null; } get unsafeNextTab() { return null; } get unsafePreviousTab() { return null; } get nearestCompletelyOpenedNormalFollowingTab() { return null; } get nearestCompletelyOpenedNormalPrecedingTab() { return null; } get nearestVisibleFollowingTab() { return null; } get unsafeNearestExpandedFollowingTab() { return null; } get nearestVisiblePrecedingTab() { return null; } get unsafeNearestExpandedPrecedingTab() { return null; } get nearestLoadedTab() { return null; } get nearestLoadedTabInTree() { return null; } get nearestLoadedSiblingTab() { return null; } get nearestSameTypeRenderedTab() { return null; } //=================================================================== // tree relations //=================================================================== get parent() { return null; } get hasParent() { return false; } get ancestorIds() { return []; } get ancestors() { return []; } get level() { return 0; } get rootTab() { return null; } get topmostSubtreeCollapsedAncestor() { return null; } get nearestVisibleAncestorOrSelf() { return null; } get nearestFollowingRootTab() { return null; } get nearestFollowingForeignerTab() { const base = this.lastDescendant || this.raw; return base?.$TST.nextTab; } get unsafeNearestFollowingForeignerTab() { const base = this.lastDescendant || this.raw; return base?.$TST.unsafeNextTab; } get children() { return []; } get firstChild() { const children = this.children; return children.length > 0 ? children[0] : null ; } get firstVisibleChild() { const firstChild = this.firstChild; return firstChild && !firstChild.$TST.collapsed && !firstChild.hidden && firstChild; } get lastChild() { const children = this.children; return children.length > 0 ? children[children.length - 1] : null ; } get hasChild() { return false; } get descendants() { return []; } get lastDescendant() { const descendants = this.descendants; return descendants.length ? descendants[descendants.length-1] : null ; } get nextSiblingTab() { return null; } get nextVisibleSiblingTab() { const nextSibling = this.nextSiblingTab; return nextSibling && !nextSibling.$TST.collapsed && !nextSibling.hidden && nextSibling; } get previousSiblingTab() { return null; } get needToBeGroupedSiblings() { return []; } //=================================================================== // other relations //=================================================================== findSuccessor(_options = {}) { return null; } // if all items are aldeardy placed at there, we don't need to move them. isAllPlacedBeforeSelf(items) { if (!this.raw || items.length == 0) return true; let nextItem = this.raw; if (items[items.length - 1] == nextItem) nextItem = nextItem.$TST.unsafeNextTab; if (!nextItem && !items[items.length - 1].$TST.unsafeNextTab) return true; items = Array.from(items); let previousItem = items.shift(); for (const item of items) { if (item.$TST.unsafePreviousTab != previousItem) return false; previousItem = item; } return !nextItem || !previousItem || previousItem.$TST.unsafeNextTab == nextItem; } isAllPlacedAfterSelf(items) { if (!this.raw || items.length == 0) return true; let previousItem = this.raw; if (items[0] == previousItem) previousItem = previousItem.$TST.unsafePreviousTab; if (!previousItem && !items[0].$TST.unsafePreviousTab) return true; items = Array.from(items).reverse(); let nextItem = items.shift(); for (const item of items) { if (item.$TST.unsafeNextTab != nextItem) return false; nextItem = item; } return !previousItem || !nextItem || nextItem.$TST.unsafePreviousTab == previousItem; } detach() {} //=================================================================== // State //=================================================================== async toggleState(state, condition, { permanently, toTab, broadcast } = {}) { if (condition) return this.addState(state, { permanently, toTab, broadcast }); else return this.removeState(state, { permanently, toTab, broadcast }); } async addState(state) { state = state && String(state) || undefined; if (!this.raw || !state) return; if (this.classList) { this.classList.add(state); } if (this.states) { this.states.add(state); } } async removeState(state) { state = state && String(state) || undefined; if (!this.raw || !state) return; if (this.classList) { this.classList.remove(state); } if (this.states) { this.states.delete(state); } } async getPermanentStates() { return Promise.resolve([]); } inheritSoundStateFromChildren() {} inheritSharingStateFromChildren() {} onNativeGroupModified() {} setAttribute(attribute, value) { if (this.element) this.element.setAttribute(attribute, value); this.attributes[attribute] = value; } getAttribute(attribute) { return this.attributes[attribute]; } removeAttribute(attribute) { if (this.element) this.element.removeAttribute(attribute); delete this.attributes[attribute]; } resolveOpened() {} rejectOpened() {} memorizeNeighbors(hint) { if (!this.raw) // already closed tab return; log(`memorizeNeighbors ${this.raw.id} as ${hint}`); this.lastPreviousTabId = this.unsafePreviousTab?.id; this.lastNextTabId = this.unsafeNextTab?.id; } // https://github.com/piroor/treestyletab/issues/2309#issuecomment-518583824 get movedInBulk() { const previousTab = this.unsafePreviousTab; if (this.lastPreviousTabId && this.lastPreviousTabId != previousTab?.id) { log(`not bulkMoved lastPreviousTabId=${this.lastNextTabId}, previousTab=${previousTab?.id}`); return false; } const nextTab = this.unsafeNextTab; if (this.lastNextTabId && this.lastNextTabId != nextTab?.id) { log(`not bulkMoved lastNextTabId=${this.lastNextTabId}, nextTab=${nextTab?.id}`); return false; } return true; } get sanitized() { if (!this.raw) return {}; const sanitized = { ...this.raw, '$possibleInitialUrl': null, '$TST': null, '$exportedForAPI': null, '$exportedForAPIWithPermissions': null, }; delete sanitized.$TST; return sanitized; } export(full) { const exported = { id: this.id, uniqueId: this.uniqueId, states: Array.from(this.states), attributes: this.attributes, parentId: this.parentId, childIds: this.childIds, collapsed: this.collapsed, subtreeCollapsed: this.subtreeCollapsed }; if (full) return { ...this.sanitized, $TST: exported }; return exported; } apply(exported) { this.raw.title = exported.title; } // This function is complex a little, but we should not make a custom class for this purpose, // bacause instances of the class will be very short-life and increases RAM usage on // massive tabs case. async exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey } = {}) { const permissionsKey = [...permissions].sort().join(','); if (!light && configs.cacheAPITreeItems && this.$exportedForAPIWithPermissions.has(permissionsKey)) return this.$exportedForAPIWithPermissions.get(permissionsKey); let exportedTreeItem = configs.cacheAPITreeItems && light ? this.$exportedForAPI : null; if (!exportedTreeItem) { const children = await doProgressively( this.raw.$TST.children, child => child.$TST.exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey }), interval ); const tabStates = this.raw.$TST.states; exportedTreeItem = { id: this.raw.id, windowId: this.raw.windowId, type: this.type, states: tabStates && tabStates.size > 0 &&Constants.kTAB_SAFE_STATES_ARRAY.filter(state => tabStates.has(state)) || [], indent: parseInt(this.raw.$TST.getAttribute(Constants.kLEVEL) || 0), children, ancestorTabIds: this.raw.$TST.ancestorIds || [], bundledTabId: this.raw.$TST.bundledTabId, }; if (this.stuck) exportedTreeItem.states.push(Constants.kTAB_STATE_STUCK); if (configs.cacheAPITreeItems && light) this.$exportedForAPI = exportedTreeItem; } if (light) return exportedTreeItem; const fullExportedTreeItem = { ...exportedTreeItem }; await this.exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, interval, permissions, cache }); if (configs.cacheAPITreeItems) this.$exportedForAPIWithPermissions.set(permissionsKey, fullExportedTreeItem) return fullExportedTreeItem; } exportFullTreeItemProperties() {} invalidateCache() { this.$exportedForAPI = null; this.$exportedForAPIWithPermissions.clear(); } applyStatesToElement() { if (!this.element) return; this.applyAttributesToElement(); for (const state of this.states) { this.element.classList.add(state); } for (const [name, value] of Object.entries(this.attributes)) { this.element.setAttribute(name, value); } } applyAttributesToElement() { if (!this.element) return; this.element.applyAttributes(); } /* element utilities */ invalidateElement(targets) { if (this.element?.invalidate) this.element.invalidate(targets); } updateElement(targets) { if (this.element?.update) this.element.update(targets); } //=================================================================== // class methods //=================================================================== static registerAutoStickyState(providerId, statesToAdd) { if (!statesToAdd) { statesToAdd = providerId; providerId = browser.runtime.id; } const states = TreeItem.autoStickyStates.get(providerId) || new Set(); if (!Array.isArray(statesToAdd)) statesToAdd = [statesToAdd]; for (const state of statesToAdd) { states.add(state) } if (states.size == 0) return; TreeItem.autoStickyStates.set(providerId, states); for (const state of states) { TreeItem.allAutoStickyStates.add(state); } TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId()); if (Constants.IS_BACKGROUND) { SidebarConnection.sendMessage({ type: Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE, providerId, add: [...statesToAdd], }); } } static unregisterAutoStickyState(providerId, statesToRemove) { if (!statesToRemove) { statesToRemove = providerId; providerId = browser.runtime.id; } const states = TreeItem.autoStickyStates.get(providerId); if (!states) return; if (!Array.isArray(statesToRemove)) statesToRemove = [statesToRemove]; for (const state of statesToRemove) { states.delete(state) } if (states.size > 0) TreeItem.autoStickyStates.set(providerId, states); else TreeItem.autoStickyStates.delete(providerId); TreeItem.allAutoStickyStates = new Set([ ...TreeItem.autoStickyStates.values(), ].flat()); TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId()); if (Constants.IS_BACKGROUND) { SidebarConnection.sendMessage({ type: Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE, providerId, remove: [...statesToRemove], }); } } static async updateCanBecomeStickyTabsIndex(windowId) { const tabs = await (windowId ? browser.tabs.query({ windowId }) : browser.tabs.query({})); for (const tab of tabs) { const item = TreeItem.get(tab); if (!item) { continue; } if (item.$TST.canBecomeSticky) TabsStore.addCanBecomeStickyTab(item); else TabsStore.removeCanBecomeStickyTab(item); } } static uniqTabsAndDescendantsSet(tabs) { if (!Array.isArray(tabs)) tabs = [tabs]; return Array.from(new Set(tabs.map(tab => [tab].concat(tab.$TST.descendants)).flat())).sort(TreeItem.compare); } static compare(a, b) { const delta = a.index - b.index; if (delta == 0) { return (a.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER) ? 1 : (a.type == TreeItem.TYPE_GROUP || !!a.color) ? -1 : 1; } return delta; } static sort(tabs) { return tabs.length == 0 ? tabs : tabs.sort(TreeItem.compare); } } export class TabGroupCollapsedMembersCounter extends TreeItem { constructor(raw) { super(raw); raw.type = TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER; this.reindex(); } destroy() { super.destroy(); this.raw.group = null; } get type() { return TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER; } reindex(maybeLastMember) { const lastMember = this.raw.group.$TST.lastMember || maybeLastMember; if (lastMember) { this.raw.index = lastMember.index; } } get group() { return this.raw.group; } get nativeTabGroup() { return this.raw.group; } update() { this.raw.color = this.raw.group.color; this.raw.windowId = this.raw.group.windowId; this.reindex(); } get title() { const collapsedItemsCount = Math.max(0, this.raw.group.$TST.members.length - 1); return `+${collapsedItemsCount}`; } get sanitized() { if (!this.raw) return {}; const sanitized = { ...super.sanitized, group: this.raw.group.$TST.sanitized, }; return sanitized; } export(full) { const exported = super.export(full); exported.group = this.raw.group.$TST.export(full); if (full) return { ...this.sanitized, $TST: exported }; return exported; } } export class TabGroup extends TreeItem { constructor(raw) { super(raw); TabsStore.tabGroups.set(raw.id, raw); TabsStore.windows.get(raw.windowId)?.tabGroups.set(raw.id, raw); this.reindex(); } destroy() { const win = TabsStore.windows.get(this.raw.windowId); if (win) { win.tabGroups.delete(this.id); } TabsStore.tabGroups.delete(this.id); if (this._collapsedMembersCounterItem) { this._collapsedMembersCounterItem.destroy(); this._collapsedMembersCounterItem = null; } super.destroy(); } get type() { return TreeItem.TYPE_GROUP; } get group() { return this.raw; } get nearestVisibleAncestorOrSelf() { return this.raw; } get members() { return TabGroup.getMembers(this.raw.id); } get firstMember() { return TabGroup.getFirstMember(this.raw.id); } get lastMember() { return TabGroup.getLastMember(this.raw.id); } get children() { return this.members.filter(tab => !tab.$TST.parentId); } get hasChild() { return !!this.firstMember; } get descendants() { return this.members; } reindex(maybeFirstMember) { const firstMember = TabGroup.getFirstMember(this.raw.id) || maybeFirstMember; if (firstMember) { this.raw.index = firstMember.index; } } apply(exported) { super.apply(exported); this.raw.color = exported.color; this.raw.collapsed = exported.collapsed; } get createParams() { return { title: this.raw.title, color: this.raw.color, collapsed: this.raw.collapsed, windowId: this.raw.windowId, }; } get collapsedMembersCounterItem() { if (this._collapsedMembersCounterItem) { return this._collapsedMembersCounterItem; } this._collapsedMembersCounterItem = { id: this.raw.id, windowId: this.raw.windowId, color: this.raw.color, type: TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER, group: this.raw, }; new TabGroupCollapsedMembersCounter(this._collapsedMembersCounterItem); return this._collapsedMembersCounterItem; } //=================================================================== // class methods //=================================================================== static get(groupId) { return TabsStore.tabGroups.get(groupId); } static init(group) { if (group.$TST instanceof TabGroup) { return group; } if ('index' in group) { group.index = -1; } if ('incognito' in group) { group.incognito = false; } group.$TST = new TabGroup(group); return group; } static getMembers(groupId, options = {}) { const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), living: true, groupId, ordered: true, ...options }); } static getFirstMember(groupId, options = {}) { const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); return TabsStore.query({ windowId, tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), living: true, groupId, ...options, ordered: true, first: true, }); } static getLastMember(groupId, options = {}) { const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); return TabsStore.query({ windowId, tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), living: true, groupId, ...options, ordered: true, last: true, }); } // https://searchfox.org/mozilla-central/rev/578d9c83f046d8c361ac6b98b297c27990d468fd/browser/components/tabbrowser/content/tabgroup-menu.js#25 static COLORS = [ 'blue', 'purple', 'cyan', 'orange', 'yellow', 'pink', 'green', 'gray', 'red', ]; static getNextUnusedColor(windowId = null) { if (!windowId) { windowId = TabsStore.getCurrentWindowId(); } const unusedColors = new Set(TabGroup.COLORS); for (const group of TabsStore.windows.get(windowId).tabGroups.values()) { unusedColors.delete(group.color); } if (unusedColors.size > 0) { return [...unusedColors][0]; } // all colors are used const index = Math.floor(Math.random() * TabGroup.COLORS.length); return TabGroup.COLORS[index]; } } export class Tab extends TreeItem { //=================================================================== // tab tracking events //=================================================================== static onTracked = new EventListenerManager(); static onDestroyed = new EventListenerManager(); static onInitialized = new EventListenerManager(); //=================================================================== // general tab events //=================================================================== static onGroupTabDetected = new EventListenerManager(); static onLabelUpdated = new EventListenerManager(); static onStateChanged = new EventListenerManager(); static onPinned = new EventListenerManager(); static onUnpinned = new EventListenerManager(); static onHidden = new EventListenerManager(); static onShown = new EventListenerManager(); static onTabInternallyMoved = new EventListenerManager(); static onCollapsedStateChanged = new EventListenerManager(); static onMutedStateChanged = new EventListenerManager(); static onAutoplayBlockedStateChanged = new EventListenerManager(); static onSharingStateChanged = new EventListenerManager(); static onBeforeCreate = new EventListenerManager(); static onCreating = new EventListenerManager(); static onCreated = new EventListenerManager(); static onRemoving = new EventListenerManager(); static onRemoved = new EventListenerManager(); static onMoving = new EventListenerManager(); static onMoved = new EventListenerManager(); static onActivating = new EventListenerManager(); static onActivated = new EventListenerManager(); static onUnactivated = new EventListenerManager(); static onUpdated = new EventListenerManager(); static onRestored = new EventListenerManager(); static onWindowRestoring = new EventListenerManager(); static onAttached = new EventListenerManager(); static onDetached = new EventListenerManager(); static onMultipleTabsRemoving = new EventListenerManager(); static onMultipleTabsRemoved = new EventListenerManager(); static onChangeMultipleTabsRestorability = new EventListenerManager(); static onStateChanged = new EventListenerManager(); static onNativeGroupModified = new EventListenerManager(); constructor(raw) { const alreadyTracked = Tab.get(raw.id); if (alreadyTracked) return alreadyTracked.$TST; log(`tab ${dumpTab(raw)} is newly tracked: `, raw); super(raw); this.promisedUniqueId = new Promise((resolve, _reject) => { this.onUniqueIdGenerated = resolve; }); this.index = raw.index; this.updatingOpenerTabIds = []; // this must be an array, because same opener tab id can appear multiple times. this.newRelatedTabsCount = 0; this.lastSoundStateCounts = { soundPlaying: 0, muted: 0, autoPlayBlocked: 0, }; this.soundPlayingChildrenIds = new Set(); this.maybeSoundPlayingChildrenIds = new Set(); this.mutedChildrenIds = new Set(); this.maybeMutedChildrenIds = new Set(); this.autoplayBlockedChildrenIds = new Set(); this.maybeAutoplayBlockedChildrenIds = new Set(); this.lastSharingStateCounts = { camera: 0, microphone: 0, screen: 0, }; this.sharingCameraChildrenIds = new Set(); this.maybeSharingCameraChildrenIds = new Set(); this.sharingMicrophoneChildrenIds = new Set(); this.maybeSharingMicrophoneChildrenIds = new Set(); this.sharingScreenChildrenIds = new Set(); this.maybeSharingScreenChildrenIds = new Set(); this.opened = new Promise((resolve, reject) => { const resolvers = mOpenedResolvers.get(raw.id) || new Set(); resolvers.add({ resolve, reject }); mOpenedResolvers.set(raw.id, resolvers); }); TabsStore.tabs.set(raw.id, raw); const win = TabsStore.windows.get(raw.windowId) || new Window(raw.windowId); win.trackTab(raw); // Don't update indexes here, instead Window.prototype.trackTab() // updates indexes because indexes are bound to windows. // TabsStore.updateIndexesForTab(raw); if (raw.active) { TabsStore.activeTabInWindow.set(raw.windowId, raw); TabsStore.activeTabsInWindow.get(raw.windowId).add(raw); } else { TabsStore.activeTabsInWindow.get(raw.windowId).delete(raw); } setTimeout(() => { if (!TabsStore.ensureLivingItem(raw)) { return; } if (raw.active) { Tab.onActivated.dispatch(raw); } else { Tab.onUnactivated.dispatch(raw); } }, 0); const incompletelyTrackedTabsPerWindow = mIncompletelyTrackedTabs.get(raw.windowId) || new Set(); incompletelyTrackedTabsPerWindow.add(raw); mIncompletelyTrackedTabs.set(raw.windowId, incompletelyTrackedTabsPerWindow); this.promisedUniqueId.then(() => { incompletelyTrackedTabsPerWindow.delete(raw); Tab.onTracked.dispatch(raw); }); // We should initialize private properties with blank value for better performance with a fixed shape. this.delayedInheritSoundStateFromChildren = null; } destroy() { mPromisedTrackedTabs.delete(`${this.id}:true`); mPromisedTrackedTabs.delete(`${this.id}:false`); Tab.onDestroyed.dispatch(this.raw); this.detach(); if (this.temporaryMetadata.has('reservedCleanupNeedlessGroupTab')) { clearTimeout(this.temporaryMetadata.get('reservedCleanupNeedlessGroupTab')); this.temporaryMetadata.delete('reservedCleanupNeedlessGroupTab'); } TabsStore.tabs.delete(this.id); if (this.uniqueId) TabsStore.tabsByUniqueId.delete(this.uniqueId.id); TabsStore.removeTabFromIndexes(this.raw); super.destroy(); } clear() { super.clear(); this.parentId = null; this.childIds = []; this.cachedAncestorIds = null; this.cachedDescendantIds = null; } startMoving() { let onTabMoved; const promisedMoved = new Promise((resolve, _reject) => { onTabMoved = resolve; }); const movingTabs = mMovingTabs.get(this.raw.windowId) || new Set(); movingTabs.add(promisedMoved); mMovingTabs.set(this.raw.windowId, movingTabs); promisedMoved.then(() => { movingTabs.delete(promisedMoved); }); return onTabMoved; } updateUniqueId(options = {}) { if (!this.raw) { const error = new Error('FATAL ERROR: updateUniqueId() is unavailable for an invalid tab'); console.log(error); throw error; } if (options.id) { if (this.uniqueId.id) TabsStore.tabsByUniqueId.delete(this.uniqueId.id); this.uniqueId.id = options.id; TabsStore.tabsByUniqueId.set(options.id, this.raw); this.setAttribute(Constants.kPERSISTENT_ID, options.id); return Promise.resolve(this.uniqueId); } return UniqueId.request(this.raw, options).then(uniqueId => { if (uniqueId && TabsStore.ensureLivingItem(this.raw)) { // possibly removed from document while waiting this.uniqueId = uniqueId; TabsStore.tabsByUniqueId.set(uniqueId.id, this.raw); this.setAttribute(Constants.kPERSISTENT_ID, uniqueId.id); } return uniqueId || {}; }).catch(error => { console.log(`FATAL ERROR: Failed to get unique id for a tab ${this.id}: `, error); return {}; }); } get type() { return TreeItem.TYPE_TAB; } get tab() { return this.raw; } get nativeTabGroup() { if (this.raw.groupId == -1) { return null; } return TabGroup.get(this.raw.groupId); } //=================================================================== // status of tab //=================================================================== get soundPlaying() { return !!(this.raw?.audible && !this.raw?.mutedInfo.muted); } get maybeSoundPlaying() { return (this.soundPlaying || (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER) && this.hasChild)); } get muted() { return !!(this.raw?.mutedInfo?.muted); } get maybeMuted() { return (this.muted || (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER) && this.hasChild)); } get autoplayBlocked() { return this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED); } get maybeAutoplayBlocked() { return (this.autoplayBlocked || (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER) && this.hasChild)); } get sharingCamera() { return !!(this.raw?.sharingState?.camera); } get maybeSharingCamera() { return (this.sharingCamera || (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER) && this.hasChild)); } get sharingMicrophone() { return !!(this.raw?.sharingState?.microphone); } get maybeSharingMicrophone() { return (this.sharingMicrophone || (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER) && this.hasChild)); } get sharingScreen() { return !!(this.raw?.sharingState?.screen); } get maybeSharingScreen() { return (this.sharingScreen || (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER) && this.hasChild)); } get precedesPinnedTab() { const following = this.nearestVisibleFollowingTab; return following?.pinned; } get followsUnpinnedTab() { const preceding = this.nearestVisiblePrecedingTab; return preceding && !preceding.pinned; } get isNewTabCommandTab() { if (!this.raw || !configs.guessNewOrphanTabAsOpenedByNewTabCommand) return false; if (this.raw.$isNewTabCommandTab) return true; // Firefox sets "New Tab" title to a new tab command tab, even if // "Blank Page" is chosen as the new tab page. So we can detect the case // safely here. // (confirmed on Firefox 124) if (isNewTabCommandTab(this.raw)) return true; // Firefox always opens a blank tab as the placeholder, when trying to // open a bookmark in a new tab. So, we cannot determine the tab is // "really opened as a new blank tab" or "just as a placeholder for an // Open in New Tab operation", when the user choose the "Blank Page" // as the new tab page and the new tab page is opened without the title // "New Tab" due to any reason. // But, when "Blank Page" is chosen as the new tab page, Firefox loads // "about:blank" into a newly opened blank tab. As the result both current // URL and the previous URL become "about:blank". This is an important // difference between "a new blank tab" and "a blank tab opened for an // Open in New Tab command". // (confirmed on Firefox 124) if (this.raw.url == 'about:blank' && this.raw.previousUrl != 'about:blank') return false; return false; } get isGroupTab() { return this.states.has(Constants.kTAB_STATE_GROUP_TAB) || this.hasGroupTabURL; } get hasGroupTabURL() { return !!(this.raw?.url?.indexOf(Constants.kGROUP_TAB_URI) == 0); } get isTemporaryGroupTab() { if (!this.raw || !this.isGroupTab) return false; return (new URL(this.raw.url)).searchParams.get('temporary') == 'true'; } get isTemporaryAggressiveGroupTab() { if (!this.raw || !this.isGroupTab) return false; return (new URL(this.raw.url)).searchParams.get('temporaryAggressive') == 'true'; } get replacedParentGroupTabCount() { if (!this.raw || !this.isGroupTab) return 0; const count = parseInt((new URL(this.raw.url)).searchParams.get('replacedParentCount')); return isNaN(count) ? 0 : count; } // Firefox Multi-Account Containers // https://addons.mozilla.org/firefox/addon/multi-account-containers/ // Temporary Containers // https://addons.mozilla.org/firefox/addon/temporary-containers/ get mayBeReplacedWithContainer() { return !!( this.$possiblePredecessorPreviousTab || this.$possiblePredecessorNextTab ); } get $possiblePredecessorPreviousTab() { const prevTab = this.unsafePreviousTab; return ( prevTab && this.raw && this.raw.cookieStoreId != prevTab.cookieStoreId && this.raw.url == prevTab.url ) ? prevTab : null; } get $possiblePredecessorNextTab() { const nextTab = this.unsafeNextTab; return ( nextTab && this.raw && this.raw.cookieStoreId != nextTab.cookieStoreId && this.raw.url == nextTab.url ) ? nextTab : null; } get possibleSuccessorWithDifferentContainer() { const firstChild = this.firstChild; const nextTab = this.nextTab; const prevTab = this.previousTab; return ( (firstChild && firstChild.$TST.$possiblePredecessorPreviousTab == this.raw && firstChild) || (nextTab && !nextTab.$TST.temporaryMetadata.has('openedCompletely') && nextTab.$TST.$possiblePredecessorPreviousTab == this.raw && nextTab) || (prevTab && !prevTab.$TST.temporaryMetadata.has('openedCompletely') && prevTab.$TST.$possiblePredecessorNextTab == this.raw && prevTab) ); } get selected() { return this.states.has(Constants.kTAB_STATE_SELECTED) || (this.hasOtherHighlighted && !!(this.raw?.highlighted)); } get multiselected() { return this.raw && this.selected && (this.hasOtherHighlighted || TabsStore.selectedTabsInWindow.get(this.raw.windowId).size > 1); } get hasOtherHighlighted() { const highlightedTabs = this.raw && TabsStore.highlightedTabsInWindow.get(this.raw.windowId); return !!(highlightedTabs && highlightedTabs.size > 1); } get canBecomeSticky() { if (this.raw?.pinned) { return false; } return super.canBecomeSticky; } get promisedPossibleOpenerBookmarks() { if ('possibleOpenerBookmarks' in this) return Promise.resolve(this.possibleOpenerBookmarks); return new Promise(async (resolve, _reject) => { if (!browser.bookmarks || !this.raw) return resolve(this.possibleOpenerBookmarks = []); // A new tab from bookmark is opened with a title: its URL without the scheme part. const url = this.raw.$possibleInitialUrl; try { const possibleBookmarks = await Promise.all([ this._safeSearchBookmstksWithUrl(`http://${url}`), this._safeSearchBookmstksWithUrl(`http://www.${url}`), this._safeSearchBookmstksWithUrl(`https://${url}`), this._safeSearchBookmstksWithUrl(`https://www.${url}`), this._safeSearchBookmstksWithUrl(`ftp://${url}`), this._safeSearchBookmstksWithUrl(`moz-extension://${url}`), this._safeSearchBookmstksWithUrl(url), // about:* and so on ]); log(`promisedPossibleOpenerBookmarks for tab ${this.id} (${url}): `, possibleBookmarks); resolve(this.possibleOpenerBookmarks = possibleBookmarks.flat()); } catch(error) { log(`promisedPossibleOpenerBookmarks for the tab {this.id} (${url}): `, error); // If it is detected as "not a valid URL", then // it cannot be a tab opened from a bookmark. resolve(this.possibleOpenerBookmarks = []); } }); } async _safeSearchBookmstksWithUrl(url) { try { return await browser.bookmarks.search({ url }); } catch(error) { log(`_searchBookmstksWithUrl failed: tab ${this.id} (${url}): `, error); try { // bookmarks.search() does not accept "moz-extension:" URL // via a queyr with "url" on Firefox 105 and later - it raises an error as // "Uncaught Error: Type error for parameter query (Value must either: // be a string value, or .url must match the format "url") for bookmarks.search." // Thus we use a query with "query" to avoid the error. // See also: https://github.com/piroor/treestyletab/issues/3203 // https://bugzilla.mozilla.org/show_bug.cgi?id=1791313 const bookmarks = await browser.bookmarks.search({ query: url }).catch(_error => []); return bookmarks.filter(bookmark => bookmark.url == url); } catch(_error) { return []; } } } get cookieStoreName() { const identity = this.raw?.cookieStoreId && ContextualIdentities.get(this.raw.cookieStoreId); return identity ? identity.name : null; } get defaultTooltipText() { return this.cookieStoreName ? `${this.raw.title} - ${this.cookieStoreName}` : super.defaultTooltipText; } get tooltipHtml() { return this.cookieStoreName ? `${sanitizeForHTMLText(this.raw.title)}${sanitizeForHTMLText(this.cookieStoreName)}` : super.tooltipHtml; } registerTooltipText(ownerId, text, isHighPriority = false) { super.registerTooltipText(ownerId, text, isHighPriority); if (Constants.IS_BACKGROUND) Tab.broadcastTooltipText(this.raw); } unregisterTooltipText(ownerId) { super.unregisterTooltipText(ownerId); if (Constants.IS_BACKGROUND) Tab.broadcastTooltipText(this.raw); } get collapsedByParent() { return this._shouldBeCollapsedByParent(); } get promisedCollapsedByParent() { if (this.raw.groupId == -1) { return this.collapsedByParent; } return browser.tabGroups.get(this.raw.groupId).then(group => { return this._shouldBeCollapsedByParent(group) }); } _shouldBeCollapsedByParent(group) { if (this.raw.groupId == -1) { return !!this.topmostSubtreeCollapsedAncestor; } if (this.raw.active) { // simulate "visible active tab in collapsed tab group" behavior of Firefox itself return false; } if (this.topmostSubtreeCollapsedAncestor) { return true; } return (group || this.nativeTabGroup)?.collapsed; } //=================================================================== // neighbor tabs //=================================================================== get nextTab() { return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.controllableTabsInWindow.get(this.raw.windowId), fromId: this.id, controllable: true, index: (index => index > this.raw.index) }); } get previousTab() { return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.controllableTabsInWindow.get(this.raw.windowId), fromId: this.id, controllable: true, index: (index => index < this.raw.index), last: true }); } get unsafeNextTab() { return this.raw && TabsStore.query({ windowId: this.raw.windowId, fromId: this.id, index: (index => index > this.raw.index) }); } get unsafePreviousTab() { return this.raw && TabsStore.query({ windowId: this.raw.windowId, fromId: this.id, index: (index => index < this.raw.index), last: true }); } get nearestCompletelyOpenedNormalFollowingTab() { // including hidden tabs! return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.unpinnedTabsInWindow.get(this.raw.windowId), states: [Constants.kTAB_STATE_CREATING, false], fromId: this.id, living: true, index: (index => index > this.raw.index) }); } get nearestCompletelyOpenedNormalPrecedingTab() { // including hidden tabs! return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.unpinnedTabsInWindow.get(this.raw.windowId), states: [Constants.kTAB_STATE_CREATING, false], fromId: this.id, living: true, index: (index => index < this.raw.index), last: true }); } get nearestVisibleFollowingTab() { // visible, not-collapsed return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.visibleTabsInWindow.get(this.raw.windowId), fromId: this.id, visible: true, index: (index => index > this.raw.index) }); } get unsafeNearestExpandedFollowingTab() { // not-collapsed, possibly hidden return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.expandedTabsInWindow.get(this.raw.windowId), fromId: this.id, index: (index => index > this.raw.index) }); } get nearestVisiblePrecedingTab() { // visible, not-collapsed return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.visibleTabsInWindow.get(this.raw.windowId), fromId: this.id, visible: true, index: (index => index < this.raw.index), last: true }); } get unsafeNearestExpandedPrecedingTab() { // not-collapsed, possibly hidden return this.raw && TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.expandedTabsInWindow.get(this.raw.windowId), fromId: this.id, index: (index => index < this.raw.index), last: true }); } get nearestLoadedTab() { const tabs = this.raw && TabsStore.visibleTabsInWindow.get(this.raw.windowId); return this.raw && ( // nearest following tab TabsStore.query({ windowId: this.raw.windowId, tabs, discarded: false, fromId: this.id, visible: true, index: (index => index > this.raw.index) }) || // nearest preceding tab TabsStore.query({ windowId: this.raw.windowId, tabs, discarded: false, fromId: this.id, visible: true, index: (index => index < this.raw.index), last: true }) ); } get nearestLoadedTabInTree() { if (!this.raw) return null; let tab = this.raw; const tabs = TabsStore.visibleTabsInWindow.get(tab.windowId); let lastLastDescendant; while (tab) { const parent = tab.$TST.parent; if (!parent) return null; const lastDescendant = parent.$TST.lastDescendant; const loadedTab = ( // nearest following tab TabsStore.query({ windowId: tab.windowId, tabs, descendantOf: parent.id, discarded: false, '!id': this.id, fromId: (lastLastDescendant || this.raw).id, toId: lastDescendant.id, visible: true, index: (index => index > this.raw.index) }) || // nearest preceding tab TabsStore.query({ windowId: tab.windowId, tabs, descendantOf: parent.id, discarded: false, '!id': this.id, fromId: tab.id, toId: parent.$TST.firstChild.id, visible: true, index: (index => index < tab.index), last: true }) ); if (loadedTab) return loadedTab; if (!parent.discarded) return parent; lastLastDescendant = lastDescendant; tab = tab.$TST.parent; } return null; } get nearestLoadedSiblingTab() { const parent = this.parent; if (!parent || !this.raw) return null; const tabs = TabsStore.visibleTabsInWindow.get(this.raw.windowId); return ( // nearest following tab TabsStore.query({ windowId: this.raw.windowId, tabs, childOf: parent.id, discarded: false, fromId: this.id, toId: parent.$TST.lastChild.id, visible: true, index: (index => index > this.raw.index) }) || // nearest preceding tab TabsStore.query({ windowId: this.raw.windowId, tabs, childOf: parent.id, discarded: false, fromId: this.id, toId: parent.$TST.firstChild.id, visible: true, index: (index => index < this.raw.index), last: true }) ); } get nearestSameTypeRenderedTab() { let tab = this.raw; const pinned = tab.pinned; while (tab.$TST.unsafeNextTab) { tab = tab.$TST.unsafeNextTab; if (tab.pinned != pinned) return null; if (tab.$TST.element && tab.$TST.element.parentNode) return tab; } return null; } //=================================================================== // tree relations //=================================================================== set parent(tab) { const newParentId = tab && (typeof tab == 'number' ? tab : tab.id); if (!this.raw || newParentId == this.parentId) return tab; const oldParent = this.parent; this.parentId = newParentId; this.invalidateCachedAncestors(); const parent = this.parent; if (parent) { this.setAttribute(Constants.kPARENT, parent.id); parent.$TST.invalidateCachedDescendants(); if (this.states.has(Constants.kTAB_STATE_SOUND_PLAYING)) parent.$TST.soundPlayingChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER)) parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_MUTED)) parent.$TST.mutedChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER)) parent.$TST.maybeMutedChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED)) parent.$TST.autoplayBlockedChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER)) parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); parent.$TST.inheritSoundStateFromChildren(); if (this.states.has(Constants.kTAB_STATE_SHARING_CAMERA)) parent.$TST.sharingCameraChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER)) parent.$TST.maybeSharingCameraChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_SHARING_MICROPHONE)) parent.$TST.sharingMicrophoneChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER)) parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_SHARING_SCREEN)) parent.$TST.sharingScreenChildrenIds.add(this.id); if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER)) parent.$TST.maybeSharingScreenChildrenIds.add(this.id); parent.$TST.inheritSharingStateFromChildren(); TabsStore.removeRootTab(this.raw); } else { this.removeAttribute(Constants.kPARENT); TabsStore.addRootTab(this.raw); } if (oldParent && oldParent.id != this.parentId) { oldParent.$TST.soundPlayingChildrenIds.delete(this.id); oldParent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); oldParent.$TST.mutedChildrenIds.delete(this.id); oldParent.$TST.maybeMutedChildrenIds.delete(this.id); oldParent.$TST.autoplayBlockedChildrenIds.delete(this.id); oldParent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); oldParent.$TST.inheritSoundStateFromChildren(); oldParent.$TST.sharingCameraChildrenIds.delete(this.id); oldParent.$TST.maybeSharingCameraChildrenIds.delete(this.id); oldParent.$TST.sharingMicrophoneChildrenIds.delete(this.id); oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id); oldParent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id); oldParent.$TST.inheritSharingStateFromChildren(); oldParent.$TST.children = oldParent.$TST.childIds.filter(id => id != this.id); } return tab; } get parent() { return this.raw && this.parentId && TabsStore.ensureLivingItem(Tab.get(this.parentId)); } get hasParent() { return !!this.parentId; } get ancestorIds() { if (!this.cachedAncestorIds) this.updateAncestors(); return this.cachedAncestorIds; } get ancestors() { return mapAndFilter(this.ancestorIds, id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); } updateAncestors() { const ancestors = []; this.cachedAncestorIds = []; if (!this.raw) return ancestors; let descendant = this.raw; while (true) { const parent = Tab.get(descendant.$TST.parentId); if (!parent) break; ancestors.push(parent); this.cachedAncestorIds.push(parent.id); descendant = parent; } return ancestors; } get level() { return this.ancestorIds.length; } invalidateCachedAncestors() { this.cachedAncestorIds = null; for (const child of this.children) { child.$TST.invalidateCachedAncestors(); } this.invalidateCache(); } get rootTab() { const ancestors = this.ancestors; return ancestors.length > 0 ? ancestors[ancestors.length-1] : this.raw ; } get topmostSubtreeCollapsedAncestor() { for (const ancestor of [...this.ancestors].reverse()) { if (ancestor.$TST.subtreeCollapsed) return ancestor; } return null; } get nearestVisibleAncestorOrSelf() { for (const ancestor of this.ancestors) { if (!ancestor.$TST.collapsed) return ancestor; } if (!this.collapsed) return this.raw; return null; } get nearestFollowingRootTab() { return TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), fromId: this.id, living: true, index: (index => index > this.raw.index), hasParent: false, first: true }); } set children(tabs) { if (!this.raw) return tabs; const ancestorIds = this.ancestorIds; const newChildIds = mapAndFilter(tabs, tab => { const id = typeof tab == 'number' ? tab : tab?.id; if (!ancestorIds.includes(id)) return TabsStore.ensureLivingItem(Tab.get(id)) ? id : undefined; console.log('FATAL ERROR: Cyclic tree structure has detected and prevented. ', { ancestorsOfSelf: this.ancestors, tabs, tab, stack: new Error().stack }); return undefined; }); if (newChildIds.join('|') == this.childIds.join('|')) return tabs; const oldChildren = this.children; this.childIds = newChildIds; this.sortAndInvalidateChildren(); if (this.childIds.length > 0) { this.setAttribute(Constants.kCHILDREN, `|${this.childIds.join('|')}|`); if (this.isSubtreeCollapsable) TabsStore.addSubtreeCollapsableTab(this.raw); } else { this.removeAttribute(Constants.kCHILDREN); TabsStore.removeSubtreeCollapsableTab(this.raw); } for (const child of Array.from(new Set(this.children.concat(oldChildren)))) { if (this.childIds.includes(child.id)) child.$TST.parent = this.id; else child.$TST.parent = null; } return tabs; } get children() { return mapAndFilter(this.childIds, id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); } sortAndInvalidateChildren() { // Tab.get(tabId) calls into TabsStore.tabs.get(tabId), which is just a // Map. This is acceptable to repeat in order to avoid two array copies, // especially on larger tab sets. this.childIds.sort((a, b) => TreeItem.compare(Tab.get(a), Tab.get(b))); this.invalidateCachedDescendants(); } get hasChild() { return this.childIds.length > 0; } get descendants() { if (!this.cachedDescendantIds) return this.updateDescendants(); return mapAndFilter(this.cachedDescendantIds, id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); } updateDescendants() { let descendants = []; this.cachedDescendantIds = []; for (const child of this.children) { descendants.push(child); descendants = descendants.concat(child.$TST.descendants); this.cachedDescendantIds.push(child.id); this.cachedDescendantIds = this.cachedDescendantIds.concat(child.$TST.cachedDescendantIds); } return descendants; } invalidateCachedDescendants() { this.cachedDescendantIds = null; const parent = this.parent; if (parent) parent.$TST.invalidateCachedDescendants(); this.invalidateCache(); } get nextSiblingTab() { if (!this.raw) return null; const parent = this.parent; if (parent) { const siblingIds = parent.$TST.childIds; const index = siblingIds.indexOf(this.id); const siblingId = index < siblingIds.length - 1 ? siblingIds[index + 1] : null ; if (!siblingId) return null; return Tab.get(siblingId); } else { const nextSibling = TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), fromId: this.id, living: true, index: (index => index > this.raw.index), hasParent: false, first: true }); // We should treat only pinned tab as the next sibling tab of a pinned // tab. For example, if the last pinned tab is closed, Firefox moves // focus to the first normal tab. But the previous pinned tab looks // natural on TST because pinned tabs are visually grouped. if (nextSibling && nextSibling.pinned != this.raw.pinned) return null; return nextSibling; } } get previousSiblingTab() { if (!this.raw) return null; const parent = this.parent; if (parent) { const siblingIds = parent.$TST.childIds; const index = siblingIds.indexOf(this.id); const siblingId = index > 0 ? siblingIds[index - 1] : null ; if (!siblingId) return null; return Tab.get(siblingId); } else { return TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), fromId: this.id, living: true, index: (index => index < this.raw.index), hasParent: false, last: true }); } } get needToBeGroupedSiblings() { if (!this.raw) return []; const openerTabUniqueId = this.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID); if (!openerTabUniqueId) return []; return TabsStore.queryAll({ windowId: this.raw.windowId, tabs: TabsStore.toBeGroupedTabsInWindow.get(this.raw.windowId), normal: true, '!id': this.id, attributes: [ Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID, openerTabUniqueId, Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, '' ], ordered: true }); } get precedingCanBecomeStickyTabs() { return TabsStore.queryAll({ windowId: this.raw.windowId, tabs: TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId), normal: true, '!id': this.id, ordered: true, fromId: this.id, reversed: true, }); } get followingCanBecomeStickyTabs() { return TabsStore.queryAll({ windowId: this.raw.windowId, tabs: TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId), normal: true, '!id': this.id, ordered: true, fromId: this.id, }); } //=================================================================== // other relations //=================================================================== get openerTab() { if (this.raw?.openerTabId == this.id) return null; if (!this.raw?.openerTabId) return Tab.getOpenerFromGroupTab(this.raw); return TabsStore.query({ windowId: this.raw.windowId, tabs: TabsStore.livingTabsInWindow.get(this.raw.windowId), id: this.raw.openerTabId, living: true }); } get hasPinnedOpener() { return this.openerTab?.pinned; } get hasFirefoxViewOpener() { return isFirefoxViewTab(this.openerTab); } get bundledTab() { if (!this.raw) return null; const substance = Tab.getSubstanceFromAliasGroupTab(this.raw); if (substance) return substance; if (this.raw.pinned) return Tab.getGroupTabForOpener(this.raw); if (this.isGroupTab) return Tab.getOpenerFromGroupTab(this.raw); return null; } get bundledTabId() { const tab = this.bundledTab; return tab ? tab.id : -1; } findSuccessor(options = {}) { if (!this.raw) return null; if (typeof options != 'object') options = {}; const ignoredTabs = (options.ignoredTabs || []).slice(0); let foundTab = this.raw; do { ignoredTabs.push(foundTab); foundTab = foundTab.$TST.nextTab; } while (foundTab && ignoredTabs.includes(foundTab)); if (!foundTab) { foundTab = this.raw; do { ignoredTabs.push(foundTab); foundTab = foundTab.$TST.nearestVisiblePrecedingTab; } while (foundTab && ignoredTabs.includes(foundTab)); } return foundTab; } get lastRelatedTab() { return Tab.get(this.lastRelatedTabId) || null; } set lastRelatedTab(relatedTab) { if (!this.raw) return relatedTab; const previousLastRelatedTabId = this.lastRelatedTabId; const win = TabsStore.windows.get(this.raw.windowId); if (relatedTab) { win.lastRelatedTabs.set(this.id, relatedTab.id); this.newRelatedTabsCount++; successorTabLog(`set lastRelatedTab for ${this.id}: ${previousLastRelatedTabId} => ${relatedTab.id} (${this.newRelatedTabsCount})`); } else { win.lastRelatedTabs.delete(this.id); this.newRelatedTabsCount = 0; successorTabLog(`clear lastRelatedTab for ${this.id} (${previousLastRelatedTabId})`); } win.previousLastRelatedTabs.set(this.id, previousLastRelatedTabId); return relatedTab; } get lastRelatedTabId() { if (!this.raw) return 0; const win = TabsStore.windows.get(this.raw.windowId); return win.lastRelatedTabs.get(this.id) || 0; } get previousLastRelatedTab() { if (!this.raw) return null; const win = TabsStore.windows.get(this.raw.windowId); return Tab.get(win.previousLastRelatedTabs.get(this.id)) || null; } detach() { this.parent = null; this.children = []; } //=================================================================== // State //=================================================================== async addState(state, { permanently, toTab, broadcast } = {}) { state = state && String(state) || undefined; if (!this.raw || !state) return; const modified = this.states && !this.states.has(state); super.addState(state); switch (state) { case Constants.kTAB_STATE_HIGHLIGHTED: TabsStore.addHighlightedTab(this.raw); if (this.element) this.element.setAttribute('aria-selected', 'true'); if (toTab) this.raw.highlighted = true; break; case Constants.kTAB_STATE_SELECTED: TabsStore.addSelectedTab(this.raw); break; case Constants.kTAB_STATE_COLLAPSED: case Constants.kTAB_STATE_SUBTREE_COLLAPSED: if (this.isSubtreeCollapsable) TabsStore.addSubtreeCollapsableTab(this.raw); else TabsStore.removeSubtreeCollapsableTab(this.raw); break; case Constants.kTAB_STATE_HIDDEN: TabsStore.removeVisibleTab(this.raw); TabsStore.removeControllableTab(this.raw); if (toTab) this.raw.hidden = true; break; case Constants.kTAB_STATE_PINNED: TabsStore.addPinnedTab(this.raw); TabsStore.removeUnpinnedTab(this.raw); if (toTab) this.raw.pinned = true; break; case Constants.kTAB_STATE_BUNDLED_ACTIVE: TabsStore.addBundledActiveTab(this.raw); break; case Constants.kTAB_STATE_SOUND_PLAYING: { const parent = this.parent; if (parent) parent.$TST.soundPlayingChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_AUDIBLE: if (toTab) this.raw.audible = true; break; case Constants.kTAB_STATE_MUTED: { const parent = this.parent; if (parent) parent.$TST.mutedChildrenIds.add(this.id); if (toTab) this.raw.mutedInfo.muted = true; } break; case Constants.kTAB_STATE_HAS_MUTED_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeMutedChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: { const parent = this.parent; if (parent) { parent.$TST.autoplayBlockedChildrenIds.add(this.id); parent.$TST.inheritSoundStateFromChildren(); } } break; case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: { const parent = this.parent; if (parent) { parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); parent.$TST.inheritSoundStateFromChildren(); } } break; case Constants.kTAB_STATE_SHARING_CAMERA: { const parent = this.parent; if (parent) parent.$TST.sharingCameraChildrenIds.add(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.camera = true; } break; case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingCameraChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_SHARING_MICROPHONE: { const parent = this.parent; if (parent) parent.$TST.sharingMicrophoneChildrenIds.add(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.microphone = true; } break; case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_SHARING_SCREEN: { const parent = this.parent; if (parent) parent.$TST.sharingScreenChildrenIds.add(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.screen = 'Something'; } break; case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingScreenChildrenIds.add(this.id); } break; case Constants.kTAB_STATE_GROUP_TAB: TabsStore.addGroupTab(this.raw); break; case Constants.kTAB_STATE_PRIVATE_BROWSING: if (toTab) this.raw.incognito = true; break; case Constants.kTAB_STATE_ATTENTION: if (toTab) this.raw.attention = true; break; case Constants.kTAB_STATE_DISCARDED: if (toTab) this.raw.discarded = true; break; case 'loading': TabsStore.addLoadingTab(this.raw); if (toTab) this.raw.status = state; break; case 'complete': TabsStore.removeLoadingTab(this.raw); if (toTab) this.raw.status = state; break; } if (TreeItem.allAutoStickyStates.has(state)) { if (this.canBecomeSticky) TabsStore.addCanBecomeStickyTab(this.raw); else TabsStore.removeCanBecomeStickyTab(this.raw); } if (this.raw && modified && state != Constants.kTAB_STATE_ACTIVE && Constants.IS_BACKGROUND && broadcast !== false) Tab.broadcastState(this.raw, { add: [state], }); if (permanently) { const states = await this.getPermanentStates(); if (!states.includes(state)) { states.push(state); await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor()); } } if (modified) { this.invalidateCache(); if (this.raw) Tab.onStateChanged.dispatch(this.raw, state, true); } } async removeState(state, { permanently, toTab, broadcast } = {}) { state = state && String(state) || undefined; if (!this.raw || !state) return; const modified = this.states?.has(state); super.removeState(state); switch (state) { case Constants.kTAB_STATE_HIGHLIGHTED: TabsStore.removeHighlightedTab(this.raw); if (this.element) this.element.setAttribute('aria-selected', 'false'); if (toTab) this.raw.highlighted = false; break; case Constants.kTAB_STATE_SELECTED: TabsStore.removeSelectedTab(this.raw); break; case Constants.kTAB_STATE_COLLAPSED: case Constants.kTAB_STATE_SUBTREE_COLLAPSED: if (this.isSubtreeCollapsable) TabsStore.addSubtreeCollapsableTab(this.raw); else TabsStore.removeSubtreeCollapsableTab(this.raw); break; case Constants.kTAB_STATE_HIDDEN: if (!this.collapsed) TabsStore.addVisibleTab(this.raw); TabsStore.addControllableTab(this.raw); if (toTab) this.raw.hidden = false; break; case Constants.kTAB_STATE_PINNED: TabsStore.removePinnedTab(this.raw); TabsStore.addUnpinnedTab(this.raw); if (toTab) this.raw.pinned = false; break; case Constants.kTAB_STATE_BUNDLED_ACTIVE: TabsStore.removeBundledActiveTab(this.raw); break; case Constants.kTAB_STATE_SOUND_PLAYING: { const parent = this.parent; if (parent) parent.$TST.soundPlayingChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_AUDIBLE: if (toTab) this.raw.audible = false; break; case Constants.kTAB_STATE_MUTED: { const parent = this.parent; if (parent) parent.$TST.mutedChildrenIds.delete(this.id); if (toTab) this.raw.mutedInfo.muted = false; } break; case Constants.kTAB_STATE_HAS_MUTED_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeMutedChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: { const parent = this.parent; if (parent) { parent.$TST.autoplayBlockedChildrenIds.delete(this.id); parent.$TST.inheritSoundStateFromChildren(); } } break; case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: { const parent = this.parent; if (parent) { parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); parent.$TST.inheritSoundStateFromChildren(); } } break; case Constants.kTAB_STATE_SHARING_CAMERA: { const parent = this.parent; if (parent) parent.$TST.sharingCameraChildrenIds.delete(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.camera = false; } break; case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingCameraChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_SHARING_MICROPHONE: { const parent = this.parent; if (parent) parent.$TST.sharingMicrophoneChildrenIds.delete(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.microphone = false; } break; case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_SHARING_SCREEN: { const parent = this.parent; if (parent) parent.$TST.sharingScreenChildrenIds.delete(this.id); if (toTab && this.raw.sharingState) this.raw.sharingState.screen = undefined; } break; case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: { const parent = this.parent; if (parent) parent.$TST.maybeSharingScreenChildrenIds.delete(this.id); } break; case Constants.kTAB_STATE_GROUP_TAB: TabsStore.removeGroupTab(this.raw); break; case Constants.kTAB_STATE_PRIVATE_BROWSING: if (toTab) this.raw.incognito = false; break; case Constants.kTAB_STATE_ATTENTION: if (toTab) this.raw.attention = false; break; case Constants.kTAB_STATE_DISCARDED: if (toTab) this.raw.discarded = false; break; } if (TreeItem.allAutoStickyStates.has(state)) { if (this.canBecomeSticky) TabsStore.addCanBecomeStickyTab(this.raw); else TabsStore.removeCanBecomeStickyTab(this.raw); } if (modified && state != Constants.kTAB_STATE_ACTIVE && Constants.IS_BACKGROUND && broadcast !== false) Tab.broadcastState(this.raw, { remove: [state], }); if (permanently) { const states = await this.getPermanentStates(); const index = states.indexOf(state); if (index > -1) { states.splice(index, 1); await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor()); } } if (modified) { this.invalidateCache(); Tab.onStateChanged.dispatch(this.raw, state, false); } } async getPermanentStates() { const states = this.raw && await browser.sessions.getTabValue(this.id, Constants.kPERSISTENT_STATES).catch(ApiTabs.handleMissingTabError); // We need to cleanup invalid values stored accidentally. // See also: https://github.com/piroor/treestyletab/issues/2882 return states && mapAndFilterUniq(states, state => state && String(state) || undefined) || []; } inheritSoundStateFromChildren() { if (!this.raw) return; // this is called too many times on a session restoration, so this should be throttled for better performance if (this.delayedInheritSoundStateFromChildren) clearTimeout(this.delayedInheritSoundStateFromChildren); this.delayedInheritSoundStateFromChildren = setTimeout(() => { this.delayedInheritSoundStateFromChildren = null; if (!TabsStore.ensureLivingItem(this.raw)) return; const parent = this.parent; let modifiedCount = 0; const soundPlayingCount = this.soundPlayingChildrenIds.size + this.maybeSoundPlayingChildrenIds.size; if (soundPlayingCount != this.lastSoundStateCounts.soundPlaying) { this.lastSoundStateCounts.soundPlaying = soundPlayingCount; this.toggleState(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER, soundPlayingCount > 0); if (parent) { if (soundPlayingCount > 0) parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); else parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); } modifiedCount++; } const mutedCount = this.mutedChildrenIds.size + this.maybeMutedChildrenIds.size; if (mutedCount != this.lastSoundStateCounts.muted) { this.lastSoundStateCounts.muted = mutedCount; this.toggleState(Constants.kTAB_STATE_HAS_MUTED_MEMBER, mutedCount > 0); if (parent) { if (mutedCount > 0) parent.$TST.maybeMutedChildrenIds.add(this.id); else parent.$TST.maybeMutedChildrenIds.delete(this.id); } modifiedCount++; } const autoplayBlockedCount = this.autoplayBlockedChildrenIds.size + this.maybeAutoplayBlockedChildrenIds.size; if (autoplayBlockedCount != this.lastSoundStateCounts.autoplayBlocked) { this.lastSoundStateCounts.autoplayBlocked = autoplayBlockedCount; this.toggleState(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER, autoplayBlockedCount > 0); if (parent) { if (autoplayBlockedCount > 0) parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); else parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); } modifiedCount++; } if (modifiedCount == 0) return; if (parent) parent.$TST.inheritSoundStateFromChildren(); SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TAB_SOUND_STATE_UPDATED, windowId: this.raw.windowId, tabId: this.id, hasSoundPlayingMember: this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER), hasMutedMember: this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER), hasAutoplayBlockedMember: this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER), }); }, 100); } inheritSharingStateFromChildren() { if (!this.raw) return; // this is called too many times on a session restoration, so this should be throttled for better performance if (this.delayedInheritSharingStateFromChildren) clearTimeout(this.delayedInheritSharingStateFromChildren); this.delayedInheritSharingStateFromChildren = setTimeout(() => { this.delayedInheritSharingStateFromChildren = null; if (!TabsStore.ensureLivingItem(this.raw)) return; const parent = this.parent; let modifiedCount = 0; const sharingCameraCount = this.sharingCameraChildrenIds.size + this.maybeSharingCameraChildrenIds.size; if (sharingCameraCount != this.lastSharingStateCounts.sharingCamera) { this.lastSharingStateCounts.sharingCamera = sharingCameraCount; this.toggleState(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER, sharingCameraCount > 0); if (parent) { if (sharingCameraCount > 0) parent.$TST.maybeSharingCameraChildrenIds.add(this.id); else parent.$TST.maybeSharingCameraChildrenIds.delete(this.id); } modifiedCount++; } const sharingMicrophoneCount = this.sharingMicrophoneChildrenIds.size + this.maybeSharingMicrophoneChildrenIds.size; if (sharingMicrophoneCount != this.lastSharingStateCounts.sharingMicrophone) { this.lastSharingStateCounts.sharingMicrophone = sharingMicrophoneCount; this.toggleState(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER, sharingMicrophoneCount > 0); if (parent) { if (sharingMicrophoneCount > 0) parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); else parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); } modifiedCount++; } const sharingScreenCount = this.sharingScreenChildrenIds.size + this.maybeSharingScreenChildrenIds.size; if (sharingScreenCount != this.lastSharingStateCounts.sharingScreen) { this.lastSharingStateCounts.sharingScreen = sharingScreenCount; this.toggleState(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER, sharingScreenCount > 0); if (parent) { if (sharingScreenCount > 0) parent.$TST.maybeSharingScreenChildrenIds.add(this.id); else parent.$TST.maybeSharingScreenChildrenIds.delete(this.id); } modifiedCount++; } if (modifiedCount == 0) return; if (parent) parent.$TST.inheritSharingStateFromChildren(); SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TAB_SHARING_STATE_UPDATED, windowId: this.raw.windowId, tabId: this.id, hasSharingCameraMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER), hasSharingMicrophoneMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER), hasSharingScreenMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER), }); }, 100); } onNativeGroupModified(oldGroupId) { if (this.raw.groupId == -1) { TabsStore.removeNativelyGroupedTab(this.raw); } else { TabsStore.addNativelyGroupedTab(this.raw); } this.setAttribute(Constants.kGROUP_ID, this.raw.groupId); const group = this.nativeTabGroup; if (group) { group.incognito = this.tab.incognito; group.$TST.reindex(this.raw); } if (oldGroupId && oldGroupId != -1) { TabGroup.get(oldGroupId)?.$TST.reindex(); } Tab.onNativeGroupModified.dispatch(this.raw); } setAttribute(attribute, value) { super.setAttribute(attribute, value); this.invalidateCache(); } removeAttribute(attribute) { super.removeAttribute(attribute); this.invalidateCache(); } resolveOpened() { if (!mOpenedResolvers.has(this.id)) return; for (const resolver of mOpenedResolvers.get(this.id)) { resolver.resolve(); } mOpenedResolvers.delete(this.id); } rejectOpened() { if (!mOpenedResolvers.has(this.id)) return; for (const resolver of mOpenedResolvers.get(this.id)) { resolver.reject(); } mOpenedResolvers.delete(this.id); } apply(exported) { // not optimized and unsafe yet! if (!this.raw) return; TabsStore.removeTabFromIndexes(this.raw); for (const key of Object.keys(exported)) { if (key == '$TST') continue; if (key in this.raw) this.raw[key] = exported[key]; } this.uniqueId = exported.$TST.uniqueId; this.promisedUniqueId = Promise.resolve(this.uniqueId); this.states = new Set(exported.$TST.states); this.attributes = exported.$TST.attributes; this.parent = exported.$TST.parentId; this.children = exported.$TST.childIds || []; TabsStore.updateIndexesForTab(this.raw); } async exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, permissions, cache } = {}) { const favIconUrl = await ( (!permissions || (!permissions.has(kPERMISSION_TABS) && (!permissions.has(kPERMISSION_ACTIVE_TAB) || !this.raw?.active))) ? null : (this.raw?.id in cache.effectiveFavIconUrls) ? cache.effectiveFavIconUrls[this.raw?.id] : this.raw?.favIconUrl?.startsWith('data:') ? this.raw?.favIconUrl : TabFavIconHelper.getLastEffectiveFavIconURL(this.raw).catch(ApiTabs.handleMissingTabError) ); if (!(this.raw.id in cache.effectiveFavIconUrls)) cache.effectiveFavIconUrls[this.raw.id] = favIconUrl; const allowedProperties = new Set([ // basic tabs.Tab properties 'active', 'attention', 'audible', 'autoDiscardable', 'discarded', 'height', 'hidden', 'highlighted', //'id', 'incognito', 'index', 'isArticle', 'isInReaderMode', 'lastAccessed', 'mutedInfo', 'openerTabId', 'pinned', 'selected', 'sessionId', 'sharingState', 'status', 'successorId', 'width', //'windowId', ]); if (permissions.has(kPERMISSION_TABS) || (permissions.has(kPERMISSION_ACTIVE_TAB) && (this.raw.active || isContextTab))) { // specially allowed with "tabs" or "activeTab" permission allowedProperties.add('favIconUrl'); allowedProperties.add('title'); allowedProperties.add('url'); fullExportedTreeItem.effectiveFavIconUrl = favIconUrl; } if (permissions.has(kPERMISSION_COOKIES)) { allowedProperties.add('cookieStoreId'); fullExportedTreeItem.cookieStoreName = this.raw.$TST.cookieStoreName; } for (const property of allowedProperties) { if (property in this.raw) fullExportedTreeItem[property] = this.raw[property]; } } applyStatesToElement() { if (!this.element) return; super.applyStatesToElement(); if (this.states.has(Constants.kTAB_STATE_HIGHLIGHTED)) { this.element.setAttribute('aria-selected', 'true'); } } set favIconUrl(url) { if (this.element && 'favIconUrl' in this.element) this.element.favIconUrl = url; this.invalidateCache(); } //=================================================================== // class methods //=================================================================== static track(tab) { const trackedTab = Tab.get(tab.id); if (!trackedTab || !(tab.$TST instanceof Tab)) { new Tab(tab); } else { if (trackedTab) tab = trackedTab; const win = TabsStore.windows.get(tab.windowId); win.trackTab(tab); } return trackedTab || tab; } static untrack(tabId) { const tab = Tab.get(tabId); if (!tab) // already untracked return; const win = TabsStore.windows.get(tab.windowId); if (win) win.untrackTab(tabId); } static isTracked(tabId) { return TabsStore.tabs.has(tabId); } static get(tabId) { if (!tabId) { return null; } if (tabId && typeof tabId.color !== 'undefined') { // for backward compatibility return TabGroup.get(tabId.id); } return TabsStore.tabs.get(typeof tabId == 'number' ? tabId : tabId?.id); } static getByUniqueId(id) { if (!id) return null; return TabsStore.ensureLivingItem(TabsStore.tabsByUniqueId.get(id)); } static needToWaitTracked(windowId) { if (windowId) { const tabs = mIncompletelyTrackedTabs.get(windowId); return tabs && tabs.size > 0; } for (const tabs of mIncompletelyTrackedTabs.values()) { if (tabs && tabs.size > 0) return true; } return false; } static async waitUntilTrackedAll(windowId, options = {}) { const tabSets = windowId ? [mIncompletelyTrackedTabs.get(windowId)] : [...mIncompletelyTrackedTabs.values()]; return Promise.all(tabSets.map(tabs => { if (!tabs) return; let tabIds = Array.from(tabs, tab => tab.id); if (options.exceptionTabId) tabIds = tabIds.filter(id => id != options.exceptionTabId); return Tab.waitUntilTracked(tabIds, options); })); } static async waitUntilTracked(tabId, options = {}) { if (!tabId) return null; if (Array.isArray(tabId)) return Promise.all(tabId.map(id => Tab.waitUntilTracked(id, options))); const windowId = TabsStore.getCurrentWindowId(); if (windowId) { const tabs = TabsStore.removedTabsInWindow.get(windowId); if (tabs?.has(tabId)) return null; // already removed tab } const key = `${tabId}:${!!options.element}`; if (mPromisedTrackedTabs.has(key)) return mPromisedTrackedTabs.get(key); const promisedTracked = waitUntilTracked(tabId, options); mPromisedTrackedTabs.set(key, promisedTracked); return promisedTracked.then(tab => { // Don't claer the last promise, because it is required to process following "waitUntilTracked" callbacks sequentically. //if (mPromisedTrackedTabs.get(key) == promisedTracked) // mPromisedTrackedTabs.delete(key); return tab; }).catch(_error => { //if (mPromisedTrackedTabs.get(key) == promisedTracked) // mPromisedTrackedTabs.delete(key); return null; }); } static needToWaitMoved(windowId) { if (windowId) { const tabs = mMovingTabs.get(windowId); return tabs && tabs.size > 0; } for (const tabs of mMovingTabs.values()) { if (tabs && tabs.size > 0) return true; } return false; } static async waitUntilMovedAll(windowId) { const tabSets = []; if (windowId) { tabSets.push(mMovingTabs.get(windowId)); } else { for (const tabs of mMovingTabs.values()) { tabSets.push(tabs); } } return Promise.all(tabSets.map(tabs => tabs && Promise.all(tabs))); } static init(tab, options = {}) { log('initalize tab ', tab); if (!tab) { const error = new Error('Fatal error: invalid tab is given to Tab.init()'); console.log(error, error.stack); throw error; } const trackedTab = Tab.get(tab.id); if (trackedTab) tab = trackedTab; tab.$TST = trackedTab?.$TST || new Tab(tab); tab.$TST.updateUniqueId().then(tab.$TST.onUniqueIdGenerated); if (tab.active) tab.$TST.addState(Constants.kTAB_STATE_ACTIVE); // When a new "child" tab was opened and the "parent" tab was closed // immediately by someone outside of TST, both new "child" and the // "parent" were closed by TST because all new tabs had // "subtree-collapsed" state initially and such an action was detected // as "closing of a collapsed tree". // The initial state was introduced in old versions, but I forgot why // it was required. "When new child tab is attached, collapse other // tree" behavior works as expected even if the initial state is not // there. Thus I remove the initial state for now, to avoid the // annoying problem. // See also: https://github.com/piroor/treestyletab/issues/2162 // tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); Tab.onInitialized.dispatch(tab, options); if (options.existing) { tab.$TST.addState(Constants.kTAB_STATE_ANIMATION_READY); tab.$TST.opened = Promise.resolve(true).then(() => { tab.$TST.resolveOpened(); }); tab.$TST.temporaryMetadata.delete('opening'); tab.$TST.temporaryMetadata.set('openedCompletely', true); } else { tab.$TST.temporaryMetadata.set('opening', true); tab.$TST.temporaryMetadata.delete('openedCompletely'); tab.$TST.opened = new Promise((resolve, reject) => { tab.$TST.opening = false; const resolvers = mOpenedResolvers.get(tab.id) || new Set(); resolvers.add({ resolve, reject }); mOpenedResolvers.set(tab.id, resolvers); }).then(() => { tab.$TST.temporaryMetadata.set('openedCompletely', true); }); } return tab; } static import(tab) { const existingTab = Tab.get(tab.id); if (!existingTab) { return Tab.init(tab); } existingTab.$TST.apply(tab); return existingTab; } //=================================================================== // get single tab //=================================================================== // Note that this function can return null if it is the first tab of // a new window opened by the "move tab to new window" command. static getActiveTab(windowId) { return TabsStore.ensureLivingItem(TabsStore.activeTabInWindow.get(windowId)); } static getFirstTab(windowId) { return TabsStore.query({ windowId, tabs: TabsStore.livingTabsInWindow.get(windowId), living: true, ordered: true }); } static getLastTab(windowId) { return TabsStore.query({ windowId, tabs: TabsStore.livingTabsInWindow.get(windowId), living: true, last: true }); } static getFirstVisibleTab(windowId) { // visible, not-collapsed, not-hidden return TabsStore.query({ windowId, tabs: TabsStore.visibleTabsInWindow.get(windowId), visible: true, ordered: true }); } static getLastVisibleTab(windowId) { // visible, not-collapsed, not-hidden return TabsStore.query({ windowId, tabs: TabsStore.visibleTabsInWindow.get(windowId), visible: true, last: true, }); } static getLastOpenedTab(windowId) { const tabs = Tab.getTabs(windowId); return tabs.length > 0 ? tabs.sort((a, b) => b.id - a.id)[0] : null ; } static getLastPinnedTab(windowId) { // visible, pinned return TabsStore.query({ windowId, tabs: TabsStore.pinnedTabsInWindow.get(windowId), living: true, ordered: true, last: true }); } static getFirstUnpinnedTab(windowId) { // not-pinned return TabsStore.query({ windowId, tabs: TabsStore.unpinnedTabsInWindow.get(windowId), ordered: true }); } static getLastUnpinnedTab(windowId) { // not-pinned return TabsStore.query({ windowId, tabs: TabsStore.unpinnedTabsInWindow.get(windowId), ordered: true, last: true }); } static getFirstNormalTab(windowId) { // visible, not-collapsed, not-pinned return TabsStore.query({ windowId, tabs: TabsStore.unpinnedTabsInWindow.get(windowId), normal: true, ordered: true }); } static getGroupTabForOpener(opener) { if (!opener) return null; TabsStore.assertValidTab(opener); const groupTab = TabsStore.query({ windowId: opener.windowId, tabs: TabsStore.groupTabsInWindow.get(opener.windowId), living: true, attributes: [ Constants.kCURRENT_URI, new RegExp(`openerTabId=${opener.$TST.uniqueId.id}($|[#&])`) ] }); if (!groupTab || groupTab == opener || groupTab.pinned == opener.pinned) return null; return groupTab; } static getOpenerFromGroupTab(groupTab) { if (!groupTab.$TST.isGroupTab) return null; TabsStore.assertValidTab(groupTab); const openerTabId = (new URL(groupTab.url)).searchParams.get('openerTabId'); const openerTab = Tab.getByUniqueId(openerTabId); if (!openerTab || openerTab == groupTab || openerTab.pinned == groupTab.pinned) return null; return openerTab; } static getSubstanceFromAliasGroupTab(groupTab) { if (!groupTab.$TST.isGroupTab) return null; TabsStore.assertValidTab(groupTab); const aliasTabId = (new URL(groupTab.url)).searchParams.get('aliasTabId'); const aliasTab = Tab.getByUniqueId(aliasTabId); if (!aliasTab || aliasTab == groupTab || aliasTab.pinned == groupTab.pinned) return null; return aliasTab; } //=================================================================== // grap tabs //=================================================================== static getActiveTabs() { return Array.from(TabsStore.activeTabInWindow.values(), TabsStore.ensureLivingItem); } static getAllTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getTabAt(windowId, index) { const tabs = TabsStore.livingTabsInWindow.get(windowId); const allTabs = TabsStore.windows.get(windowId).tabs; return TabsStore.query({ windowId, tabs, living: true, fromIndex: Math.max(0, index - (allTabs.size - tabs.size)), logicalIndex: index, first: true }); } static getTabs(windowId = null, options = {}) { // only visible, including collapsed and pinned return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, windowId), controllable: true, ordered: true, ...options }); } static getTabsBetween(begin, end) { if (!begin || !TabsStore.ensureLivingItem(begin) || !end || !TabsStore.ensureLivingItem(end)) throw new Error('getTabsBetween requires valid two tabs'); if (begin.windowId != end.windowId) throw new Error('getTabsBetween requires two tabs in same window'); if (begin == end) return []; if (begin.index > end.index) [begin, end] = [end, begin]; return TabsStore.queryAll({ windowId: begin.windowId, tabs: TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, begin.windowId), id: (id => id != begin.id && id != end.id), fromId: begin.id, toId: end.id }); } static getNormalTabs(windowId = null, options = {}) { // only visible, including collapsed, not pinned return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId), normal: true, ordered: true, ...options }); } static getVisibleTabs(windowId = null, options = {}) { // visible, not-collapsed, not-hidden return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.visibleTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getHiddenTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), living: true, ordered: true, hidden: true, ...options }); } static getPinnedTabs(windowId = null, options = {}) { // visible, pinned return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.pinnedTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getUnpinnedTabs(windowId = null, options = {}) { // visible, not pinned return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getRootTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.rootTabsInWindow, windowId), controllable: true, ordered: true, ...options }); } static getLastRootTab(windowId, options = {}) { const tabs = Tab.getRootTabs(windowId, options); return tabs[tabs.length - 1]; } static collectRootTabs(tabs) { const tabsSet = new Set(tabs); return tabs.filter(tab => { if (!TabsStore.ensureLivingItem(tab)) return false; const parent = tab.$TST.parent; return !parent || !tabsSet.has(parent); }); } static getSubtreeCollapsedTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.subtreeCollapsableTabsInWindow, windowId), living: true, hidden: false, ordered: true, ...options }); } static getGroupTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.groupTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getLoadingTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getDraggingTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.draggingTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getRemovingTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.removingTabsInWindow, windowId), ordered: true, ...options }); } static getDuplicatingTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.duplicatingTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getHighlightedTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId), living: true, ordered: true, ...options }); } static getSelectedTabs(windowId = null, options = {}) { const tabs = TabsStore.getTabsMap(TabsStore.selectedTabsInWindow, windowId); const selectedTabs = TabsStore.queryAll({ windowId, tabs, living: true, ordered: true, ...options }); const highlightedTabs = TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId); if (!highlightedTabs || highlightedTabs.size < 2) return selectedTabs; if (options.iterator) return (function* () { const alreadyReturnedTabs = new Set(); for (const tab of selectedTabs) { yield tab; alreadyReturnedTabs.add(tab); } for (const tab of highlightedTabs.values()) { if (!alreadyReturnedTabs.has(tab)) yield tab; } })(); else return TreeItem.sort(Array.from(new Set([...selectedTabs, ...Array.from(highlightedTabs.values())]))); } static getNeedToBeSynchronizedTabs(windowId = null, options = {}) { return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId), visible: true, ...options }); } static hasNeedToBeSynchronizedTab(windowId) { return !!TabsStore.query({ windowId, tabs: TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId), visible: true }); } static hasLoadingTab(windowId) { return !!TabsStore.query({ windowId, tabs: TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId), visible: true }); } static hasDuplicatedTabs(windowId, options = {}) { const tabs = TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), living: true, ...options, iterator: true }); const tabKeys = new Set(); for (const tab of tabs) { const key = `${tab.cookieStoreId}\n${tab.url}`; if (tabKeys.has(key)) return true; tabKeys.add(key); } return false; } static hasMultipleTabs(windowId, options = {}) { const tabs = TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), living: true, ...options, iterator: true }); let count = 0; // eslint-disable-next-line no-unused-vars for (const tab of tabs) { count++; if (count > 1) return true; } return false; } // "Recycled tab" is an existing but reused tab for session restoration. static getRecycledTabs(windowId = null, options = {}) { const userNewTabUrls = configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.split('|').map(part => sanitizeForRegExpSource(part.trim())).join('|'); return TabsStore.queryAll({ windowId, tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), living: true, states: [Constants.kTAB_STATE_RESTORED, false], attributes: [Constants.kCURRENT_URI, new RegExp(`^(|${userNewTabUrls}|about:newtab|about:blank|about:privatebrowsing)$`)], ...options }); } //=================================================================== // utilities //=================================================================== static bufferedTooltipTextChanges = new Map(); static broadcastTooltipText(tabs) { if (!Constants.IS_BACKGROUND || !Tab.broadcastTooltipText.enabled) return; if (!Array.isArray(tabs)) tabs = [tabs]; if (tabs.length == 0) return; for (const tab of tabs) { Tab.bufferedTooltipTextChanges.set(tab.id, { windowId: tab.windowId, tabId: tab.id, high: tab.$TST.highPriorityTooltipText, low: tab.$TST.lowPriorityTooltipText, }); } const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; Tab.broadcastTooltipText.triedAt = triedAt; (Constants.IS_BACKGROUND ? setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. window.requestAnimationFrame)(() => { if (Tab.broadcastTooltipText.triedAt != triedAt) return; // Let's flush buffered changes! const messageForWindows = new Map(); for (const change of Tab.bufferedTooltipTextChanges.values()) { const message = messageForWindows.get(change.windowId) || { type: Constants.kCOMMAND_BROADCAST_TAB_TOOLTIP_TEXT, windowId: change.windowId, tabIds: [], changes: [], }; message.tabIds.push(change.tabId); message.changes.push(change); } for (const message of messageForWindows) { SidebarConnection.sendMessage(message); } Tab.bufferedTooltipTextChanges.clear(); }, 0); } static bufferedStatesChanges = new Map(); static broadcastState(tabs, { add, remove } = {}) { if (!Constants.IS_BACKGROUND || !Tab.broadcastState.enabled) return; if (!Array.isArray(tabs)) tabs = [tabs]; if (tabs.length == 0) return; for (const tab of tabs) { const message = Tab.bufferedStatesChanges.get(tab.id) || { windowId: tab.windowId, tabId: tab.id, add: new Set(), remove: new Set(), }; if (add) for (const state of add) { message.add.add(state); message.remove.delete(state); } if (remove) for (const state of remove) { message.add.delete(state); message.remove.add(state); } Tab.bufferedStatesChanges.set(tab.id, message); } const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; Tab.broadcastState.triedAt = triedAt; (Constants.IS_BACKGROUND ? setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. window.requestAnimationFrame)(() => { if (Tab.broadcastState.triedAt != triedAt) return; // Let's flush buffered changes! // Unify buffered changes only if same type changes are consecutive. // Otherwise the order of changes would be mixed and things may become broken. const unifiedMessages = []; let lastKey; let unifiedMessage = null; for (const message of Tab.bufferedStatesChanges.values()) { const key = `${message.windowId}/add:${[...message.add]}/remove:${[...message.remove]}`; if (key != lastKey) { if (unifiedMessage) unifiedMessages.push(unifiedMessage); unifiedMessage = null; } lastKey = key; unifiedMessage = unifiedMessage || { type: Constants.kCOMMAND_BROADCAST_TAB_STATE, windowId: message.windowId, tabIds: new Set(), add: message.add, remove: message.remove, }; unifiedMessage.tabIds.add(message.tabId); } if (unifiedMessage) unifiedMessages.push(unifiedMessage); Tab.bufferedStatesChanges.clear(); // SidebarConnection.sendMessage() has its own bulk-send mechanism, // so we don't need to bundle them like an array. for (const message of unifiedMessages) { SidebarConnection.sendMessage({ type: Constants.kCOMMAND_BROADCAST_TAB_STATE, windowId: message.windowId, tabIds: [...message.tabIds], add: [...message.add], remove: [...message.remove], }); } }, 0); } static getOtherTabs(windowId, ignoreTabs, options = {}) { const query = { windowId: windowId, tabs: TabsStore.livingTabsInWindow.get(windowId), ordered: true }; if (Array.isArray(ignoreTabs) && ignoreTabs.length > 0) query['!id'] = ignoreTabs.map(tab => tab.id); return TabsStore.queryAll({ ...query, ...options }); }; static getIndex(tab, { ignoreTabs } = {}) { if (!TabsStore.ensureLivingItem(tab)) return -1; TabsStore.assertValidTab(tab); return Tab.getOtherTabs(tab.windowId, ignoreTabs).indexOf(tab); } static calculateNewTabIndex({ insertAfter, insertBefore, ignoreTabs } = {}) { // We need to calculate new index based on "insertAfter" at first, to avoid // placing of the new tab after hidden tabs (too far from the location it // should be.) if (insertAfter) return Tab.getIndex(insertAfter, { ignoreTabs }) + 1; if (insertBefore) return Tab.getIndex(insertBefore, { ignoreTabs }); return -1; } static async doAndGetNewTabs(asyncTask, windowId) { const tabsQueryOptions = { windowType: 'normal' }; if (windowId) { tabsQueryOptions.windowId = windowId; } const beforeTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler()); const beforeIds = mapAndFilterUniq(beforeTabs, tab => tab.id, { set: true }); await asyncTask(); const afterTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler()); const addedTabs = mapAndFilter(afterTabs, tab => !beforeIds.has(tab.id) && Tab.get(tab.id) || undefined); return addedTabs; } static dumpAll(windowId) { if (!configs.debug) return; let output = 'dumpAllTabs'; for (const tab of Tab.getAllTabs(windowId, {iterator: true })) { output += '\n' + toLines([...tab.$TST.ancestors.reverse(), tab], tab => `${tab.id}${tab.pinned ? ' [pinned]' : ''}`, ' => '); } log(output); } } const mWaitingTasks = new Map(); function destroyWaitingTabTask(task) { const tasks = mWaitingTasks.get(task.tabId); if (tasks) tasks.delete(task); if (task.timeout) clearTimeout(task.timeout); const resolve = task.resolve; const stack = task.stack; task.tabId = undefined; task.resolve = undefined; task.timeout = undefined; task.stack = undefined; return { resolve, stack }; } function onWaitingTabTracked(tab) { if (!tab) return; const tasks = mWaitingTasks.get(tab.id); if (!tasks) return; mWaitingTasks.delete(tab.id); for (const task of tasks) { tasks.delete(task); const { resolve } = destroyWaitingTabTask(task); if (!resolve) continue; resolve(tab); } } TreeItem.onElementBound.addListener(onWaitingTabTracked); Tab.onTracked.addListener(onWaitingTabTracked); function onWaitingTabDestroyed(tab) { if (!tab) return; const tasks = mWaitingTasks.get(tab.id); if (!tasks) return; mWaitingTasks.delete(tab.id); const scope = TabsStore.getCurrentWindowId() || 'bg'; for (const task of tasks) { tasks.delete(task); const { resolve, stack } = destroyWaitingTabTask(task); if (!resolve) continue; log(`Tab.waitUntilTracked: ${tab.id} is destroyed while waiting (in ${scope})\n${stack}`); resolve(null); } } Tab.onDestroyed.addListener(onWaitingTabDestroyed); function onWaitingTabRemoved(removedTabId, _removeInfo) { const tasks = mWaitingTasks.get(removedTabId); if (!tasks) return; mWaitingTasks.delete(removedTabId); const scope = TabsStore.getCurrentWindowId() || 'bg'; for (const task of tasks) { tasks.delete(task); const { resolve, stack } = destroyWaitingTabTask(task); if (!resolve) continue; log(`Tab.waitUntilTracked: ${removedTabId} is removed while waiting (in ${scope})\n${stack}`); resolve(null); } } browser.tabs.onRemoved.addListener(onWaitingTabRemoved); async function waitUntilTracked(tabId, options = {}) { if (!tabId) { return null; } const stack = configs.debug && new Error().stack; const tab = Tab.get(tabId); if (tab) { onWaitingTabTracked(tab); if (options.element) return tab.$TST.promisedElement; return tab; } const tasks = mWaitingTasks.get(tabId) || new Set(); const task = { tabId, stack, }; tasks.add(task); mWaitingTasks.set(tabId, tasks); return new Promise((resolve, _reject) => { task.resolve = resolve; task.timeout = setTimeout(() => { const { resolve } = destroyWaitingTabTask(task); if (resolve) { log(`Tab.waitUntilTracked for ${tabId} is timed out (in ${TabsStore.getCurrentWindowId() || 'bg'})\b${stack}`); resolve(null); } }, configs.maximumDelayUntilTabIsTracked); // Tabs.moveTabs() between windows may take much time browser.tabs.get(tabId).catch(_error => null).then(tab => { if (tab) { if (Tab.get(tabId)) onWaitingTabTracked(tab); return; } const { resolve } = destroyWaitingTabTask(task); if (resolve) { log('waitUntilTracked was called for unexisting tab'); resolve(null); } }); }).then(() => destroyWaitingTabTask(task)); } Tab.broadcastTooltipText.enabled = false; Tab.broadcastState.enabled = false; // utility TreeItem.get = item => { if (!item) { return null; } switch (item?.type) { case TreeItem.TYPE_TAB: return Tab.get(item.id); case TreeItem.TYPE_GROUP: return TabGroup.get(item.id); case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER: return TabGroup.get(item.id).$TST.collapsedMembersCounterItem; default: return TabGroup.get(item) || Tab.get(item); } };