Files
tubestation/waterfox/browser/components/sidebar/common/TreeItem.js
2025-11-06 14:13:52 +00:00

3934 lines
116 KiB
JavaScript

/*
# 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 `<span class="title-line"
><span class="title"
>${sanitizeForHTMLText(this.raw.title)}</span></span>`;
}
get tooltipHtmlWithDescendants() {
return `<ul>${this.generateTooltipHtmlWithDescendants()}</ul>`;
}
generateTooltipHtmlWithDescendants() {
let tooltip = `<li>${this.tooltipHtml}`;
const children = [];
for (const child of this.children) {
if (!child)
continue;
children.push(child.$TST.generateTooltipHtmlWithDescendants());
}
if (children.length > 0)
tooltip += `<ul>${children.join('')}</ul>`;
return `${tooltip}</li>`;
}
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 ?
`<span class="title-line"
><span class="title"
>${sanitizeForHTMLText(this.raw.title)}</span
><span class="cookieStoreName"
>${sanitizeForHTMLText(this.cookieStoreName)}</span></span>` :
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);
}
};