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

2252 lines
74 KiB
JavaScript

/* ***** BEGIN LICENSE BLOCK *****
* Version: MPL 1.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is the Tree Style Tab.
*
* The Initial Developer of the Original Code is YUKI "Piro" Hiroshi.
* Portions created by the Initial Developer are Copyright (C) 2011-2025
* the Initial Developer. All Rights Reserved.
*
* Contributor(s): YUKI "Piro" Hiroshi <piro.outsider.reflex@gmail.com>
* wanabe <https://github.com/wanabe>
* Tetsuharu OHZEKI <https://github.com/saneyuki>
* Xidorn Quan <https://github.com/upsuper> (Firefox 40+ support)
* lv7777 (https://github.com/lv7777)
*
* ***** END LICENSE BLOCK ******/
'use strict';
import EventListenerManager from '/extlib/EventListenerManager.js';
import {
log as internalLogger,
wait,
dumpTab,
mapAndFilter,
configs,
shouldApplyAnimation,
getWindowParamsFromSource,
isFirefoxViewTab,
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from '/common/constants.js';
import * as SidebarConnection from '/common/sidebar-connection.js';
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TreeBehavior from '/common/tree-behavior.js';
import * as TSTAPI from '/common/tst-api.js';
import * as UserOperationBlocker from '/common/user-operation-blocker.js';
import MetricsData from '/common/MetricsData.js';
import { Tab, TreeItem } from '/common/TreeItem.js';
import Window from '/common/Window.js';
import * as TabsMove from './tabs-move.js';
function log(...args) {
internalLogger('background/tree', ...args);
}
function logCollapseExpand(...args) {
internalLogger('sidebar/collapse-expand', ...args);
}
export const onAttached = new EventListenerManager();
export const onDetached = new EventListenerManager();
export const onSubtreeCollapsedStateChanging = new EventListenerManager();
export const onSubtreeCollapsedStateChanged = new EventListenerManager();
const mUnattachableTabIds = new Set();
export function markTabIdAsUnattachable(id) {
mUnattachableTabIds.add(id);
}
export function clearUnattachableTabId(id) {
mUnattachableTabIds.delete(id);
}
function isTabIdUnattachable(id) {
return mUnattachableTabIds.has(id);
}
// return moved (or not)
export async function attachTabTo(child, parent, options = {}) {
parent = TabsStore.ensureLivingItem(parent);
child = TabsStore.ensureLivingItem(child);
if (!parent || !child) {
log('missing information: ', { parent, child });
return false;
}
if (isFirefoxViewTab(parent)) {
log('Firefox View tab could not be a parent of other tabs');
return false;
}
log('attachTabTo: ', {
child: child.id,
parent: parent.id,
children: parent.$TST.getAttribute(Constants.kCHILDREN),
insertAt: options.insertAt,
insertBefore: options.insertBefore?.id,
insertAfter: options.insertAfter?.id,
lastRelatedTab: options.lastRelatedTab?.id,
dontMove: options.dontMove,
dontUpdateIndent: options.dontUpdateIndent,
forceExpand: options.forceExpand,
dontExpand: options.dontExpand,
delayedMove: options.delayedMove,
dontSyncParentToOpenerTab: options.dontSyncParentToOpenerTab,
broadcast: options.broadcast,
broadcasted: options.broadcasted,
stack: `${configs.debug && new Error().stack}\n${options.stack || ''}`
});
if (isTabIdUnattachable(child.id)) {
log('=> do not attach an unattachable tab to another (maybe already removed)');
return false;
}
if (isTabIdUnattachable(parent.id)) {
log('=> do not attach to an unattachable tab (maybe already removed)');
return false;
}
if (parent.pinned || child.pinned) {
log('=> pinned tabs cannot be attached');
return false;
}
if (parent.windowId != child.windowId) {
log('=> could not attach tab to a parent in different window');
return false;
}
const ancestors = [parent].concat(parent.$TST.ancestors);
if (ancestors.includes(child)) {
log('=> canceled for recursive request');
return false;
}
if (options.dontMove) {
log('=> do not move');
options.insertBefore = child.$TST.nextTab;
if (!options.insertBefore)
options.insertAfter = child.$TST.previousTab;
}
if (!options.insertBefore && !options.insertAfter) {
const refTabs = getReferenceTabsForNewChild(child, parent, options);
options.insertBefore = refTabs.insertBefore;
options.insertAfter = refTabs.insertAfter;
log('=> calculate reference tabs ', refTabs);
}
options.insertAfter = options.insertAfter || parent;
log(`reference tabs for ${child.id}: `, {
insertBefore: options.insertBefore,
insertAfter: options.insertAfter
});
if (!options.synchronously)
await Tab.waitUntilTrackedAll(child.windowId);
parent = TabsStore.ensureLivingItem(parent);
child = TabsStore.ensureLivingItem(child);
if (!parent || !child) {
log('attachTabTo: parent or child is closed before attaching.');
return false;
}
if (isTabIdUnattachable(child.id) || isTabIdUnattachable(parent.id)) {
log('attachTabTo: parent or child is marked as unattachable (maybe already removed)');
return false;
}
parent.$TST.invalidateCache();
child.$TST.invalidateCache();
const newIndex = Tab.calculateNewTabIndex({
insertBefore: options.insertBefore,
insertAfter: options.insertAfter,
ignoreTabs: [child]
});
const moved = newIndex != child.index;
log(`newIndex for ${child.id}: `, newIndex);
const newlyAttached = (
!parent.$TST.childIds.includes(child.id) ||
child.$TST.parentId != parent.id
);
if (!newlyAttached)
log('=> already attached');
if (newlyAttached) {
detachTab(child, {
...options,
// Don't broadcast this detach operation, because this "attachTabTo" can be
// broadcasted. If we broadcast this detach operation, the tab is detached
// twice in the sidebar!
broadcast: false
});
log('attachTabTo: setting child information to ', parent.id);
// we need to set its children via the "children" setter, to invalidate cached information.
parent.$TST.children = parent.$TST.childIds.concat([child.id]);
// We don't need to update its parent information, because the parent's
// "children" setter updates the child itself automatically.
const parentLevel = parseInt(parent.$TST.getAttribute(Constants.kLEVEL) || 0);
if (!options.dontUpdateIndent)
updateTabsIndent(child, parentLevel + 1, { justNow: options.synchronously });
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED,
windowId: parent.windowId,
tabId: parent.id,
childIds: parent.$TST.childIds,
addedChildIds: [child.id],
removedChildIds: [],
newlyAttached
});
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_ATTACHED)) {
const cache = {};
TSTAPI.broadcastMessage({
type: TSTAPI.kNOTIFY_TREE_ATTACHED,
tab: child,
parent,
}, { tabProperties: ['tab', 'parent'], cache }).catch(_error => {});
TSTAPI.clearCache(cache);
}
}
if (child.openerTabId != parent.id &&
!options.dontSyncParentToOpenerTab &&
configs.syncParentTabAndOpenerTab) {
log(`openerTabId of ${child.id} is changed by TST!: ${child.openerTabId} (original) => ${parent.id} (changed by TST)`, new Error().stack);
child.openerTabId = parent.id;
child.$TST.updatingOpenerTabIds.push(parent.id);
browser.tabs.update(child.id, { openerTabId: parent.id })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
wait(200).then(() => {
const index = child.$TST.updatingOpenerTabIds.findIndex(id => id == parent.id);
child.$TST.updatingOpenerTabIds.splice(index, 1);
});
}
if (newlyAttached)
await collapseExpandForAttachedTab(child, parent, options);
if (!options.dontMove) {
let nextTab = options.insertBefore;
let prevTab = options.insertAfter;
if (!nextTab && !prevTab) {
nextTab = Tab.getTabAt(child.windowId, newIndex);
if (!nextTab)
prevTab = Tab.getTabAt(child.windowId, newIndex - 1);
}
log('move newly attached child: ', dumpTab(child), {
next: dumpTab(nextTab),
prev: dumpTab(prevTab)
});
if (!nextTab ||
// We should not use a descendant of the "child" tab as the reference tab
// when we are going to attach the "child" and its descendants to the new
// parent.
// See also: https://github.com/piroor/treestyletab/issues/2892#issuecomment-862424942
nextTab.$TST.parent == child) {
await moveTabSubtreeAfter(child, prevTab, {
...options,
broadcast: true
});
}
else {
await moveTabSubtreeBefore(child, nextTab, {
...options,
broadcast: true
});
}
}
child.$TST.opened.then(() => {
if (!TabsStore.ensureLivingItem(child) || // not removed while waiting
child.$TST.parent != parent) // not detached while waiting
return;
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_COMPLETELY,
windowId: child.windowId,
childId: child.id,
parentId: parent.id,
newlyAttached
});
});
onAttached.dispatch(child, {
...options,
parent,
insertBefore: options.insertBefore,
insertAfter: options.insertAfter,
newIndex, newlyAttached
});
return !options.dontMove && moved;
}
async function collapseExpandForAttachedTab(tab, parent, options = {}) {
// Because the tab is possibly closing for "reopen" operation,
// we need to apply "forceExpand" immediately. Otherwise, when
// the tab is closed with "subtree collapsed" state, descendant
// tabs are also closed even if "forceExpand" is "true".
log('collapseExpandForAttachedTab: newly attached tab ', { tab, parent, options });
if (parent.$TST.subtreeCollapsed &&
!options.forceExpand) {
log(' the tree is collapsed, but keep collapsed by forceExpand option');
collapseExpandTabAndSubtree(tab, {
collapsed: true,
justNow: true,
broadcast: true
});
}
const isNewTreeCreatedManually = !options.justNow && parent.$TST.childIds.length == 1;
let parentTreeCollasped = parent.$TST.subtreeCollapsed;
let parentCollasped = parent.$TST.collapsed;
const cache = {};
const allowed = (options.forceExpand || !options.dontExpand) && await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD,
{ tab: parent,
child: tab },
{ tabProperties: ['tab', 'child'], cache }
);
TSTAPI.clearCache(cache);
if (!TabsStore.ensureLivingItem(tab)) {
log(' not living tab, do nothing');
return;
}
if (options.forceExpand && allowed) {
log(` expand tab ${tab.id} by forceExpand option`);
if (parentTreeCollasped)
collapseExpandSubtree(parent, {
...options,
collapsed: false,
broadcast: true
});
else
collapseExpandTabAndSubtree(tab, {
...options,
collapsed: false,
broadcast: true
});
parentTreeCollasped = false;
}
else {
log(' not forceExpanded');
}
if (!options.dontExpand) {
if (allowed) {
if (configs.autoCollapseExpandSubtreeOnAttach &&
(isNewTreeCreatedManually ||
parent.$TST.isAutoExpandable)) {
log(' collapse others by collapseExpandTreesIntelligentlyFor');
await collapseExpandTreesIntelligentlyFor(parent, {
broadcast: true
});
}
if (configs.autoCollapseExpandSubtreeOnSelect ||
isNewTreeCreatedManually ||
parent.$TST.isAutoExpandable ||
options.forceExpand) {
log(' expand ancestor tabs');
parentTreeCollasped = false;
parentCollasped = false;
await Promise.all([parent].concat(parent.$TST.ancestors).map(async ancestor => {
if (!ancestor.$TST.subtreeCollapsed)
return;
const allowed = await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD,
{ tab: ancestor,
child: tab },
{ tabProperties: ['tab', 'child'], cache }
);
TSTAPI.clearCache(cache);
if (!allowed) {
parentTreeCollasped = true;
parentCollasped = true;
return;
}
if (!TabsStore.ensureLivingItem(tab))
return;
collapseExpandSubtree(ancestor, {
...options,
collapsed: false,
broadcast: true
});
parentTreeCollasped = false;
}));
if (!TabsStore.ensureLivingItem(tab))
return;
}
if (!parent.$TST.subtreeCollapsed &&
tab.$TST.collapsed) {
log(' moved from collapsed tree to expanded tree');
collapseExpandTabAndSubtree(tab, {
...options,
collapsed: false,
broadcast: true,
});
}
}
else {
log(' not allowed to expand');
}
}
else if (parent.$TST.isAutoExpandable ||
parent.$TST.collapsed) {
log(' collapse auto expanded tree');
collapseExpandTabAndSubtree(tab, {
...options,
collapsed: true,
broadcast: true
});
}
else {
log(' nothing to do');
}
if (parentTreeCollasped || parentCollasped) {
log(' collapse tab because the parent is collapsed');
collapseExpandTabAndSubtree(tab, {
...options,
collapsed: true,
forceExpand: false,
broadcast: true
});
}
}
export function getReferenceTabsForNewChild(child, parent, { insertAt, ignoreTabs, lastRelatedTab, children, descendants } = {}) {
log('getReferenceTabsForNewChild ', { child, parent, insertAt, ignoreTabs, lastRelatedTab, children, descendants });
if (typeof insertAt !== 'number')
insertAt = configs.insertNewChildAt;
log(' insertAt = ', insertAt);
if (parent && !descendants)
descendants = parent.$TST.descendants;
if (ignoreTabs)
descendants = descendants.filter(tab => !ignoreTabs.includes(tab));
log(' descendants = ', descendants);
let insertBefore, insertAfter;
if (descendants.length > 0) {
const firstChild = descendants[0];
const lastDescendant = descendants[descendants.length - 1];
switch (insertAt) {
case Constants.kINSERT_END:
default:
insertAfter = lastDescendant;
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_END)`);
break;
case Constants.kINSERT_TOP:
insertBefore = firstChild;
log(` insert ${child?.id} before firstChild ${insertBefore?.id} (insertAt=kINSERT_TOP)`);
break;
case Constants.kINSERT_NEAREST: {
const allTabs = Tab.getOtherTabs((child || parent).windowId, ignoreTabs);
const index = child ? allTabs.indexOf(child) : -1;
log(' insertAt=kINSERT_NEAREST ', { allTabs, index });
if (index < allTabs.indexOf(firstChild)) {
insertBefore = firstChild;
insertAfter = parent;
log(` insert ${child?.id} between parent ${insertAfter?.id} and firstChild ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`);
}
else if (index > allTabs.indexOf(lastDescendant)) {
insertAfter = lastDescendant;
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`);
}
else { // inside the tree
if (parent && !children)
children = parent.$TST.children;
if (ignoreTabs)
children = children.filter(tab => !ignoreTabs.includes(tab));
for (const child of children) {
if (index > allTabs.indexOf(child))
continue;
insertBefore = child;
log(` insert ${child?.id} before nearest following child ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`);
break;
}
if (!insertBefore) {
insertAfter = lastDescendant;
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`);
}
}
}; break;
case Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB: {
// Simulates Firefox's default behavior with `browser.tabs.insertRelatedAfterCurrent`=`true`.
// The result will become same to kINSERT_NO_CONTROL case,
// but this is necessary for environments with disabled the preference.
if ((lastRelatedTab === undefined) && parent)
lastRelatedTab = child && parent.$TST.lastRelatedTabId == child.id ? parent.$TST.previousLastRelatedTab : parent.$TST.lastRelatedTab; // it could be updated already...
if (lastRelatedTab) {
insertAfter = lastRelatedTab.$TST.lastDescendant || lastRelatedTab;
log(` insert ${child?.id} after lastRelatedTab ${lastRelatedTab.id} (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`);
}
else {
insertBefore = firstChild;
log(` insert ${child?.id} before firstChild (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`);
}
}; break;
case Constants.kINSERT_NO_CONTROL:
break;
}
}
else {
insertAfter = parent;
log(` insert ${child?.id} after parent`);
}
if (insertBefore == child) {
// Return unsafe tab, to avoid placing the child after hidden tabs
// (too far from the place it should be.)
insertBefore = insertBefore?.$TST.unsafeNextTab;
log(` => insert ${child?.id} before next tab ${insertBefore?.id} of the child tab itelf`);
}
if (insertAfter == child) {
insertAfter = insertAfter?.$TST.previousTab;
log(` => insert ${child?.id} after previous tab ${insertAfter?.id} of the child tab itelf`);
}
// disallow to place tab in invalid position
if (insertBefore) {
if (parent && insertBefore.index <= parent.index) {
insertBefore = null;
log(` => do not put ${child?.id} before a tab preceding to the parent`);
}
//TODO: we need to reject more cases...
}
if (insertAfter) {
const allTabsInTree = [...descendants];
if (parent)
allTabsInTree.unshift(parent);
const lastMember = allTabsInTree[allTabsInTree.length - 1];
if (lastMember != insertAfter &&
insertAfter.index >= lastMember.index) {
insertAfter = lastMember;
log(` => do not put ${child?.id} after the last tab ${insertAfter?.id} in the tree`);
}
//TODO: we need to reject more cases...
}
return { insertBefore, insertAfter };
}
export function getReferenceTabsForNewNextSibling(base, options = {}) {
log('getReferenceTabsForNewNextSibling ', base);
let insertBefore = base.$TST.nextSiblingTab;
if (insertBefore?.pinned &&
!options.pinned) {
insertBefore = Tab.getFirstNormalTab(base.windowId);
}
let insertAfter = base.$TST.lastDescendant || base;
if (insertAfter &&
!insertAfter.pinned &&
options.pinned) {
insertAfter = Tab.getLastPinnedTab(base.windowId);
}
return { insertBefore, insertAfter };
}
export function detachTab(child, options = {}) {
log('detachTab: ', child.id, options,
{ stack: `${configs.debug && new Error().stack}\n${options.stack || ''}` });
// the "parent" option is used for removing child.
const parent = TabsStore.ensureLivingItem(options.parent) || child.$TST.parent;
if (parent) {
// we need to set children and parent via setters, to invalidate cached information.
parent.$TST.children = parent.$TST.childIds.filter(id => id != child.id);
parent.$TST.invalidateCache();
log('detachTab: children information is updated ', parent.id, parent.$TST.childIds);
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED,
windowId: parent.windowId,
tabId: parent.id,
childIds: parent.$TST.childIds,
addedChildIds: [],
removedChildIds: [child.id],
detached: true
});
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_DETACHED)) {
const cache = {};
TSTAPI.broadcastMessage({
type: TSTAPI.kNOTIFY_TREE_DETACHED,
tab: child,
oldParent: parent,
}, { tabProperties: ['tab', 'oldParent'], cache }).catch(_error => {});
TSTAPI.clearCache(cache);
}
// We don't need to clear its parent information, because the old parent's
// "children" setter removes the parent ifself from the detached child
// automatically.
}
else {
log(` => parent(${child.$TST.parentId}) is already removed, or orphan tab`);
// This can happen when the parent tab was detached via the native tab bar
// or Firefox's built-in command to detach tab from window.
}
if (!options.toBeRemoved && !options.toBeDetached)
updateTabsIndent(child);
if (child.openerTabId &&
!options.dontSyncParentToOpenerTab &&
configs.syncParentTabAndOpenerTab) {
log(`openerTabId of ${child.id} is cleared by TST!: ${child.openerTabId} (original)`, configs.debug && new Error().stack);
child.openerTabId = child.id;
browser.tabs.update(child.id, { openerTabId: child.id }) // set self id instead of null, because it requires any valid tab id...
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
child.$TST.invalidateCache();
onDetached.dispatch(child, {
oldParentTab: parent,
toBeRemoved: !!options.toBeRemoved,
toBeDetached: !!options.toBeDetached
});
}
export function getWholeTree(rootTabs) {
if (!Array.isArray(rootTabs))
rootTabs = [rootTabs];
const wholeTree = [...rootTabs];
for (const rootTab of rootTabs) {
wholeTree.push(...rootTab.$TST.descendants);
}
return TreeItem.sort([...new Set(wholeTree)]);
}
export async function detachTabsFromTree(tabs, options = {}) {
if (!Array.isArray(tabs))
tabs = [tabs];
tabs = Array.from(tabs).reverse();
// you should specify this option if you already call "Tree.getWholeTree()" for the tabs.
const partial = 'partial' in options ?
options.partial :
getWholeTree(tabs).length != tabs.length;
const promisedAttach = [];
const tabsSet = new Set(tabs);
for (const tab of tabs) {
let behavior = partial ?
TreeBehavior.getParentTabOperationBehavior(tab, {
context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE,
}) :
Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE)
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
promisedAttach.push(detachAllChildren(tab, {
...options,
behavior,
ignoreTabs: tabs,
}));
if (options.fromParent &&
!tabsSet.has(tab.$TST.parent)) {
promisedAttach.push(detachTab(tab, options));
}
}
if (promisedAttach.length > 0)
await Promise.all(promisedAttach);
}
export async function detachAllChildren(
tab = null,
{ windowId, children, descendants, parent, nearestFollowingRootTab, newParent, ignoreTabs, behavior, dontExpand, dontSyncParentToOpenerTab,
...options } = {}
) {
if (tab) {
windowId = tab.$TST.windowId;
parent = tab.$TST.parent;
children = tab.$TST.children;
descendants = tab.$TST.descendants;
}
log('detachAllChildren: ',
tab?.id,
{ children, parent, nearestFollowingRootTab, newParent, behavior, dontExpand, dontSyncParentToOpenerTab },
options);
// the "children" option is used for removing tab.
children = children ? children.map(TabsStore.ensureLivingItem) : tab.$TST.children;
const ignoreTabsSet = new Set(ignoreTabs || []);
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD &&
newParent &&
!children.includes(newParent))
children.unshift(newParent);
if (!children.length)
return;
log(' => children to be detached: ', () => children.map(dumpTab));
if (behavior === undefined)
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN;
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE)
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
options.dontUpdateInsertionPositionInfo = true;
// the "parent" option is used for removing tab.
parent = TabsStore.ensureLivingItem(parent) || tab?.$TST.parent;
while (ignoreTabsSet.has(parent)) {
parent = parent.$TST.parent;
}
if (tab?.$TST.isGroupTab &&
Tab.getRemovingTabs(tab.windowId).length == children.length) {
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN;
options.dontUpdateIndent = false;
}
let previousTab = null;
let nextTab = null;
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN &&
!configs.moveTabsToBottomWhenDetachedFromClosedParent) {
nextTab = nearestFollowingRootTab !== undefined ?
nearestFollowingRootTab :
tab?.$TST.nearestFollowingRootTab;
previousTab = nextTab ?
nextTab.$TST.previousTab :
Tab.getLastTab(windowId || tab.windowId);
const descendantsSet = new Set(descendants || tab.$TST.descendants);
while (previousTab && (!tab || descendantsSet.has(previousTab))) {
previousTab = previousTab.$TST.previousTab;
}
}
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) {
// open new group tab and replace the detaching tab with it.
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN;
}
if (!dontExpand &&
((tab && !tab.$TST.collapsed) ||
(behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE &&
behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB))) {
if (tab) {
await collapseExpandSubtree(tab, {
...options,
collapsed: false
});
}
else {
for (const child of children) {
await collapseExpandTabAndSubtree(child, {
...options,
collapsed: false,
forceExpand: behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN,
});
}
}
}
let count = 0;
for (const child of children) {
if (!child)
continue;
const promises = [];
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN) {
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
// reference tabs can be closed while waiting...
if (nextTab?.$TST.removing)
nextTab = null;
if (previousTab?.$TST.removing)
previousTab = null;
if (nextTab) {
promises.push(moveTabSubtreeBefore(child, nextTab, options));
}
else {
promises.push(moveTabSubtreeAfter(child, previousTab, options));
previousTab = child.$TST.lastDescendant || child;
}
}
else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD) {
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
if (count == 0) {
if (parent) {
promises.push(attachTabTo(child, parent, {
...options,
dontSyncParentToOpenerTab,
dontExpand: true,
dontMove: true
}));
}
promises.push(collapseExpandSubtree(child, {
...options,
collapsed: false
}));
//deleteTabValue(child, Constants.kTAB_STATE_SUBTREE_COLLAPSED);
}
else {
promises.push(attachTabTo(child, children[0], {
...options,
dontSyncParentToOpenerTab,
dontExpand: true,
dontMove: true
}));
}
}
else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN &&
parent) {
promises.push(attachTabTo(child, parent, {
...options,
dontSyncParentToOpenerTab,
dontExpand: true,
dontMove: true
}));
}
else { // behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
}
count++;
await Promise.all(promises);
}
}
// returns moved (or not)
export async function behaveAutoAttachedTab(
tab,
{ baseTab, behavior, broadcast, dontMove } = {}
) {
if (!configs.autoAttach)
return false;
baseTab = baseTab || Tab.getActiveTab(TabsStore.getCurrentWindowId() || tab.windowId);
log('behaveAutoAttachedTab ', tab.id, baseTab.id, { baseTab, behavior });
if (baseTab?.$TST.ancestors.includes(tab)) {
log(' => ignore possibly restored ancestor tab to avoid cyclic references');
return false;
}
if (baseTab.pinned) {
if (!tab.pinned)
return false;
behavior = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING;
log(' => override behavior for pinned tabs');
}
switch (behavior) {
default:
return false;
case Constants.kNEWTAB_OPEN_AS_ORPHAN:
log(' => kNEWTAB_OPEN_AS_ORPHAN');
detachTab(tab, {
broadcast
});
if (tab.$TST.nextTab)
return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), {
delayedMove: true
});
return false;
case Constants.kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB:
log(' => kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB');
const lastRelatedTab = baseTab.$TST.lastRelatedTab;
if (lastRelatedTab) {
log(` place after last related tab ${dumpTab(lastRelatedTab)}`);
await TabsMove.moveTabAfter(tab, lastRelatedTab.$TST.lastDescendant || lastRelatedTab, {
delayedMove: true,
broadcast: true
});
return attachTabTo(tab, baseTab, {
insertAfter: lastRelatedTab,
lastRelatedTab,
forceExpand: true,
delayedMove: true,
broadcast
});
}
log(` no lastRelatedTab: fallback to kNEWTAB_OPEN_AS_CHILD`);
case Constants.kNEWTAB_OPEN_AS_CHILD:
log(' => kNEWTAB_OPEN_AS_CHILD');
return attachTabTo(tab, baseTab, {
dontMove: dontMove || configs.insertNewChildAt == Constants.kINSERT_NO_CONTROL,
forceExpand: true,
delayedMove: true,
broadcast
});
case Constants.kNEWTAB_OPEN_AS_CHILD_TOP:
log(' => kNEWTAB_OPEN_AS_CHILD_TOP');
return attachTabTo(tab, baseTab, {
dontMove,
forceExpand: true,
delayedMove: true,
insertAt: Constants.kINSERT_TOP,
broadcast
});
case Constants.kNEWTAB_OPEN_AS_CHILD_END:
log(' => kNEWTAB_OPEN_AS_CHILD_END');
return attachTabTo(tab, baseTab, {
dontMove,
forceExpand: true,
delayedMove: true,
insertAt: Constants.kINSERT_END,
broadcast
});
case Constants.kNEWTAB_OPEN_AS_SIBLING: {
log(' => kNEWTAB_OPEN_AS_SIBLING');
const parent = baseTab.$TST.parent;
if (parent) {
await attachTabTo(tab, parent, {
delayedMove: true,
broadcast
});
return true;
}
else {
detachTab(tab, {
broadcast
});
return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), {
delayedMove: true
});
}
};
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING:
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER: {
log(' => kNEWTAB_OPEN_AS_NEXT_SIBLING(_WITH_INHERITED_CONTAINER)');
let nextSibling = baseTab.$TST.nextSiblingTab;
if (nextSibling == tab)
nextSibling = null;
const parent = baseTab.$TST.parent;
if (parent) {
return attachTabTo(tab, parent, {
insertBefore: nextSibling,
insertAfter: baseTab.$TST.lastDescendant || baseTab,
delayedMove: true,
broadcast
});
}
else {
detachTab(tab, {
broadcast
});
if (nextSibling)
return TabsMove.moveTabBefore(tab, nextSibling, {
delayedMove: true,
broadcast
});
else
return TabsMove.moveTabAfter(tab, baseTab.$TST.lastDescendant, {
delayedMove: true,
broadcast
});
}
};
}
}
export async function behaveAutoAttachedTabs(tabs, options = {}) {
switch (options.behavior) {
default:
return false;
case Constants.kNEWTAB_OPEN_AS_ORPHAN:
if (options.baseTabs && !options.baseTab)
options.baseTab = options.baseTabs[options.baseTabs.length - 1];
for (const tab of tabs) {
await behaveAutoAttachedTab(tab, options);
}
return false;
case Constants.kNEWTAB_OPEN_AS_CHILD:
case Constants.kNEWTAB_OPEN_AS_CHILD_TOP:
case Constants.kNEWTAB_OPEN_AS_CHILD_END: {
if (options.baseTabs && !options.baseTab)
options.baseTab = options.baseTabs[0];
let moved = false;
for (const tab of tabs) {
moved = (await behaveAutoAttachedTab(tab, options)) || moved;
}
return moved;
};
case Constants.kNEWTAB_OPEN_AS_SIBLING:
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: {
if (options.baseTabs && !options.baseTab)
options.baseTab = options.baseTabs[options.baseTabs.length - 1];
let moved = false;
for (const tab of tabs.reverse()) {
moved = (await behaveAutoAttachedTab(tab, options)) || moved;
}
return moved;
};
}
}
function updateTabsIndent(tabs, level = undefined, options = {}) {
if (!tabs)
return;
if (!Array.isArray(tabs))
tabs = [tabs];
if (!tabs.length)
return;
if (level === undefined)
level = tabs[0].$TST.ancestors.length;
for (let i = 0, maxi = tabs.length; i < maxi; i++) {
const item = tabs[i];
if (!item || item.pinned)
continue;
updateTabIndent(item, level, options);
}
}
// this is called multiple times on a session restoration, so this should be throttled for better performance
function updateTabIndent(tab, level = undefined, options = {}) {
let timer = updateTabIndent.delayed.get(tab.id);
if (timer)
clearTimeout(timer);
if (options.justNow || !shouldApplyAnimation()) {
return updateTabIndentNow(tab, level, options);
}
timer = setTimeout(() => {
updateTabIndent.delayed.delete(tab.id);
updateTabIndentNow(tab, level);
}, 100);
updateTabIndent.delayed.set(tab.id, timer);
}
updateTabIndent.delayed = new Map();
function updateTabIndentNow(tab, level = undefined, options = {}) {
if (!TabsStore.ensureLivingItem(tab))
return;
tab.$TST.setAttribute(Constants.kLEVEL, level);
updateTabsIndent(tab.$TST.children, level + 1, options);
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TAB_LEVEL_CHANGED,
windowId: tab.windowId,
tabId: tab.id,
level
});
}
// collapse/expand tabs
// returns an array of tab ids which are changed their visibility
export async function collapseExpandSubtree(tab, params = {}) {
params.collapsed = !!params.collapsed;
if (!tab || !TabsStore.ensureLivingItem(tab))
return [];
if (!TabsStore.ensureLivingItem(tab)) // it was removed while waiting
return [];
params.stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`;
logCollapseExpand('collapseExpandSubtree: ', dumpTab(tab), tab.$TST.subtreeCollapsed, params);
const visibilityChangedTabIds = await collapseExpandSubtreeInternal(tab, params);
onSubtreeCollapsedStateChanged.dispatch(tab, { collapsed: !!params.collapsed });
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED)) {
TSTAPI.broadcastMessage({
type: TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED,
tab,
collapsed: !!params.collapsed
}, { tabProperties: ['tab'] }).catch(_error => {});
}
return visibilityChangedTabIds;
}
async function collapseExpandSubtreeInternal(tab, params = {}) {
if (!params.force &&
tab.$TST.subtreeCollapsed == params.collapsed)
return [];
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGING,
windowId: tab.windowId,
tabId: tab.id,
collapsed: !!params.collapsed,
});
if (params.collapsed) {
tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED);
tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
}
else {
tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED);
}
//setTabValue(tab, Constants.kTAB_STATE_SUBTREE_COLLAPSED, params.collapsed);
const isInViewport = await browser.runtime.sendMessage({
type: Constants.kCOMMAND_ASK_TAB_IS_IN_VIEWPORT,
windowId: tab.windowId,
tabId: tab.id,
allowPartial: true,
}).catch(_error => false);
const anchor = isInViewport ? tab : null;
const childTabs = tab.$TST.children;
const lastExpandedTabIndex = childTabs.length - 1;
const allVisibilityChangedTabIds = [];
for (let i = 0, maxi = childTabs.length; i < maxi; i++) {
const childTab = childTabs[i];
if (i == lastExpandedTabIndex &&
!params.collapsed) {
allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, {
collapsed: params.collapsed,
justNow: params.justNow,
anchor,
last: true,
broadcast: false
})));
}
else {
allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, {
collapsed: params.collapsed,
justNow: params.justNow,
broadcast: false
})));
}
}
const visibilityChangedTabIds = [...new Set(allVisibilityChangedTabIds)];
onSubtreeCollapsedStateChanging.dispatch(tab, { collapsed: params.collapsed });
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED,
windowId: tab.windowId,
tabId: tab.id,
collapsed: !!params.collapsed,
justNow: params.justNow,
anchorId: anchor?.id,
visibilityChangedTabIds,
last: true
});
return visibilityChangedTabIds;
}
// returns an array of tab ids which are changed their visibility
export function manualCollapseExpandSubtree(tab, params = {}) {
params.manualOperation = true;
const visibilityChangedTabIds = collapseExpandSubtree(tab, params);
if (!params.collapsed) {
tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
//setTabValue(tab, Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY, true);
}
return visibilityChangedTabIds;
}
// returns an array of tab ids which are changed their visibility
export async function collapseExpandTabAndSubtree(tab, params = {}) {
log('collapseExpandTabAndSubtree ', tab, params);
const visibilityChangedTabIds = [];
if (!tab) {
log(' no target');
return visibilityChangedTabIds;
}
// allow to expand root collapsed tab
if (!tab.$TST.collapsed &&
!tab.$TST.parent) {
log(' no parent');
return visibilityChangedTabIds;
}
if (collapseExpandTab(tab, params))
visibilityChangedTabIds.push(tab.id);
if (params.collapsed &&
tab.active &&
configs.unfocusableCollapsedTab) {
logCollapseExpand('current tree is going to be collapsed');
const allowed = await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_MOVE_FOCUS_FROM_COLLAPSING_TREE,
{ tab },
{ tabProperties: ['tab'] }
);
if (allowed) {
let newSelection = tab.$TST.nearestVisibleAncestorOrSelf;
if (configs.avoidDiscardedTabToBeActivatedIfPossible && newSelection.discarded)
newSelection = newSelection.$TST.nearestLoadedTabInTree ||
newSelection.$TST.nearestLoadedTab ||
newSelection;
logCollapseExpand('=> switch to ', newSelection.id);
TabsInternalOperation.activateTab(newSelection, { silently: true });
}
}
if (!tab.$TST.subtreeCollapsed) {
const children = tab.$TST.children;
const allVisibilityChangedTabs = await Promise.all(children.map((child, index) => {
const last = params.last &&
(index == children.length - 1);
return collapseExpandTabAndSubtree(child, {
...params,
collapsed: params.collapsed,
justNow: params.justNow,
anchor: last && params.anchor,
last: last,
broadcast: params.broadcast
});
}));
visibilityChangedTabIds.push(...allVisibilityChangedTabs.flat());
}
return [...new Set(visibilityChangedTabIds)];
}
// returns true if the tab's visibility is changed
export async function collapseExpandTab(tab, params = {}) {
if (tab.pinned && params.collapsed) {
log('CAUTION: a pinned tab is going to be collapsed, but canceled.',
dumpTab(tab), { stack: configs.debug && new Error().stack });
params.collapsed = false;
}
// When an asynchronous "expand" operation is processed after a
// synchronous "collapse" operation, it can produce an expanded
// child tab under "subtree-collapsed" parent. So this is a failsafe.
if (!params.forceExpand &&
!params.collapsed &&
tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed)) {
log('collapseExpandTab: canceled to avoid expansion under collapsed tree ',
tab.$TST.ancestors.find(ancestor => ancestor.$TST.subtreeCollapsed));
return false;
}
const visibilityChanged = tab.$TST.collapsed != params.collapsed;
const stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`;
logCollapseExpand(`collapseExpandTab ${tab.id} `, params, { stack })
const last = params.last &&
(!tab.$TST.hasChild || tab.$TST.subtreeCollapsed);
const byAncestor = tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed) == params.collapsed;
const collapseExpandInfo = {
...params,
anchor: last && params.anchor,
last
};
if (params.collapsed) {
tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED);
TabsStore.removeVisibleTab(tab);
TabsStore.removeExpandedTab(tab);
}
else {
tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED);
TabsStore.addVisibleTab(tab);
TabsStore.addExpandedTab(tab);
}
Tab.onCollapsedStateChanged.dispatch(tab, collapseExpandInfo);
// the message is called multiple times on a session restoration, so it should be throttled for better performance
let timer = collapseExpandTab.delayedNotify.get(tab.id);
if (timer)
clearTimeout(timer);
timer = setTimeout(() => {
collapseExpandTab.delayedNotify.delete(tab.id);
if (!TabsStore.ensureLivingItem(tab))
return;
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED,
windowId: tab.windowId,
tabId: tab.id,
anchorId: collapseExpandInfo.anchor?.id,
justNow: params.justNow,
collapsed: params.collapsed,
last,
stack,
byAncestor
});
}, shouldApplyAnimation() ? 100 : 0);
collapseExpandTab.delayedNotify.set(tab.id, timer);
return visibilityChanged;
}
collapseExpandTab.delayedNotify = new Map();
export async function collapseExpandTreesIntelligentlyFor(tab, options = {}) {
if (!tab)
return;
logCollapseExpand('collapseExpandTreesIntelligentlyFor ', tab);
const win = TabsStore.windows.get(tab.windowId);
if (win.doingIntelligentlyCollapseExpandCount > 0) {
logCollapseExpand('=> done by others');
return;
}
win.doingIntelligentlyCollapseExpandCount++;
try {
const expandedAncestors = [tab.id]
.concat(tab.$TST.ancestors.map(ancestor => ancestor.id))
.concat(tab.$TST.descendants.map(descendant => descendant.id));
const collapseTabs = Tab.getSubtreeCollapsedTabs(tab.windowId, {
'!id': expandedAncestors
});
logCollapseExpand(`${collapseTabs.length} tabs can be collapsed, ancestors: `, expandedAncestors);
const allowedToCollapse = new Set();
await Promise.all(collapseTabs.map(async tab => {
const allowed = await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_COLLAPSE_TREE_FROM_OTHER_EXPANSION,
{ tab },
{ tabProperties: ['tab'] }
);
if (allowed)
allowedToCollapse.add(tab);
}));
for (const collapseTab of collapseTabs) {
if (!allowedToCollapse.has(collapseTab))
continue;
let dontCollapse = false;
const parentTab = collapseTab.$TST.parent;
if (parentTab) {
dontCollapse = true;
if (!parentTab.$TST.subtreeCollapsed) {
for (const ancestor of collapseTab.$TST.ancestors) {
if (!expandedAncestors.includes(ancestor.id))
continue;
dontCollapse = false;
break;
}
}
}
logCollapseExpand(`${collapseTab.id}: dontCollapse = ${dontCollapse}`);
const manuallyExpanded = collapseTab.$TST.states.has(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
if (!dontCollapse &&
!manuallyExpanded &&
collapseTab.$TST.descendants.every(tab => !tab.$TST.canBecomeSticky))
collapseExpandSubtree(collapseTab, {
...options,
collapsed: true
});
}
collapseExpandSubtree(tab, {
...options,
collapsed: false
});
}
catch(error) {
log(`failed to collapse/expand tree under ${tab.id}: ${String(error)}`, error);
}
win.doingIntelligentlyCollapseExpandCount--;
}
export async function fixupSubtreeCollapsedState(tab, options = {}) {
let fixed = false;
if (!tab.$TST.hasChild)
return fixed;
const firstChild = tab.$TST.firstChild;
const childrenCollapsed = firstChild.$TST.collapsed;
const collapsedStateMismatched = tab.$TST.subtreeCollapsed != childrenCollapsed;
const nextIsFirstChild = tab.$TST.nextTab == firstChild;
log('fixupSubtreeCollapsedState ', {
tab: tab.id,
childrenCollapsed,
collapsedStateMismatched,
nextIsFirstChild
});
if (collapsedStateMismatched) {
log(' => set collapsed state');
await collapseExpandSubtree(tab, {
...options,
collapsed: childrenCollapsed
});
fixed = true;
}
if (!nextIsFirstChild) {
log(' => move child tabs');
await followDescendantsToMovedRoot(tab, options);
fixed = true;
}
return fixed;
}
// operate tabs based on tree information
export async function moveTabSubtreeBefore(tab, nextTab, options = {}) {
if (!tab)
return;
if (nextTab?.$TST.isAllPlacedBeforeSelf([tab].concat(tab.$TST.descendants))) {
log('moveTabSubtreeBefore:no need to move');
return;
}
log('moveTabSubtreeBefore: ', tab.id, nextTab?.id);
const win = TabsStore.windows.get(tab.windowId);
win.subTreeMovingCount++;
try {
await TabsMove.moveTabInternallyBefore(tab, nextTab, options);
if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting
throw new Error('the tab was removed before moving of descendants');
await followDescendantsToMovedRoot(tab, options);
}
catch(error) {
log(`failed to move subtree: ${String(error)}`, error);
}
await wait(0);
win.subTreeMovingCount--;
}
export async function moveTabSubtreeAfter(tab, previousTab, options = {}) {
if (!tab)
return;
log('moveTabSubtreeAfter: ', tab.id, previousTab?.id);
if (previousTab?.$TST.isAllPlacedAfterSelf([tab].concat(tab.$TST.descendants))) {
log(' => no need to move');
return;
}
const win = TabsStore.windows.get(tab.windowId);
win.subTreeMovingCount++;
try {
await TabsMove.moveTabInternallyAfter(tab, previousTab, options);
if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting
throw new Error('the tab was removed before moving of descendants');
await followDescendantsToMovedRoot(tab, options);
}
catch(error) {
log(`failed to move subtree: ${String(error)}`, error);
}
await wait(0);
win.subTreeMovingCount--;
}
async function followDescendantsToMovedRoot(tab, options = {}) {
if (!tab.$TST.hasChild)
return;
log('followDescendantsToMovedRoot: ', tab);
const win = TabsStore.windows.get(tab.windowId);
win.subTreeChildrenMovingCount++;
win.subTreeMovingCount++;
try {
await TabsMove.moveTabsAfter(tab.$TST.descendants, tab, options);
}
catch(error) {
log(`failed to move descendants of ${tab.id}: ${String(error)}`, error);
}
win.subTreeChildrenMovingCount--;
win.subTreeMovingCount--;
}
// before https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 is fixed (Firefox 67 or older)
let mSlowDuplication = false;
browser.runtime.getBrowserInfo().then(browserInfo => {
if (parseInt(browserInfo.version.split('.')[0]) < 68)
mSlowDuplication = true;
});
export async function moveTabs(tabs, { duplicate, ...options } = {}) {
tabs = tabs.filter(TabsStore.ensureLivingItem);
if (tabs.length == 0)
return [];
log('moveTabs: ', () => ({ tabs: tabs.map(dumpTab), duplicate, options }));
const windowId = parseInt(tabs[0].windowId || TabsStore.getCurrentWindowId());
let newWindow = options.destinationPromisedNewWindow;
let destinationWindowId = options.destinationWindowId;
if (!destinationWindowId && !newWindow) {
destinationWindowId = TabsStore.getCurrentWindowId() || windowId;
}
const isAcrossWindows = windowId != destinationWindowId || !!newWindow;
log('moveTabs: isAcrossWindows = ', isAcrossWindows, `${windowId} => ${destinationWindowId}`);
options.insertAfter = options.insertAfter || Tab.getLastTab(destinationWindowId);
let movedTabs = tabs;
const structure = TreeBehavior.getTreeStructureFromTabs(tabs);
log('original tree structure: ', structure);
let hasActive = false;
for (const tab of movedTabs) {
if (tab.active)
hasActive = true;
if (isAcrossWindows &&
!duplicate)
tab.$TST.temporaryMetadata.set('movingAcrossWindows', true);
}
if (!duplicate)
await detachTabsFromTree(tabs, options);
if (isAcrossWindows || duplicate) {
if (mSlowDuplication)
UserOperationBlocker.blockIn(windowId, { throbber: true });
try {
let win;
const prepareWindow = () => {
win = Window.init(destinationWindowId);
if (isAcrossWindows) {
win.toBeOpenedTabsWithPositions += tabs.length;
win.toBeOpenedOrphanTabs += tabs.length;
for (const tab of tabs) {
win.toBeAttachedTabs.add(tab.id);
}
}
};
if (newWindow) {
newWindow = newWindow.then(win => {
log('moveTabs: destination window is ready, ', win);
destinationWindowId = win.id;
prepareWindow();
return win;
});
}
else {
prepareWindow();
}
let movedTabIds = tabs.map(tab => tab.id);
await Promise.all([
newWindow,
(async () => {
const sourceWindow = TabsStore.windows.get(tabs[0].windowId);
if (duplicate) {
sourceWindow.toBeOpenedTabsWithPositions += tabs.length;
sourceWindow.toBeOpenedOrphanTabs += tabs.length;
sourceWindow.duplicatingTabsCount += tabs.length;
}
if (isAcrossWindows) {
for (const tab of tabs) {
sourceWindow.toBeDetachedTabs.add(tab.id);
}
}
log('preparing tabs');
if (duplicate) {
const startTime = Date.now();
// This promise will be resolved with very large delay.
// (See also https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 )
const promisedDuplicatedTabs = Promise.all(movedTabIds.map(async (id, _index) => {
try {
return await browser.tabs.duplicate(id).catch(ApiTabs.createErrorHandler());
}
catch(e) {
ApiTabs.handleMissingTabError(e);
return null;
}
})).then(tabs => {
log(`ids from API responses are resolved in ${Date.now() - startTime}msec: `, () => tabs.map(dumpTab));
return tabs;
});
movedTabs = await promisedDuplicatedTabs;
if (mSlowDuplication)
UserOperationBlocker.setProgress(50, windowId);
movedTabs = movedTabs.map(tab => Tab.get(tab.id));
movedTabIds = movedTabs.map(tab => tab.id);
}
else {
const movedTabIdsSet = new Set(movedTabIds);
for (const tab of movedTabs) {
tab.$TST.temporaryMetadata.set('movingAcrossWindows', true);
if (tab.$TST.parentId &&
!movedTabIdsSet.has(tab.$TST.parentId))
detachTab(tab, {
broadcast: true,
toBeDetached: true
});
}
}
})()
]);
log('moveTabs: all windows and tabs are ready, ', movedTabIds, destinationWindowId);
let toIndex = (tabs.some(tab => tab.pinned) ? Tab.getPinnedTabs(destinationWindowId) : Tab.getAllTabs(destinationWindowId)).length;
log('toIndex = ', toIndex);
if (options.insertBefore?.windowId == destinationWindowId) {
try {
toIndex = Tab.get(options.insertBefore.id).index;
}
catch(e) {
ApiTabs.handleMissingTabError(e);
log('options.insertBefore is unavailable');
}
}
else if (options.insertAfter?.windowId == destinationWindowId) {
try {
toIndex = Tab.get(options.insertAfter.id).index + 1;
}
catch(e) {
ApiTabs.handleMissingTabError(e);
log('options.insertAfter is unavailable');
}
}
if (!isAcrossWindows &&
movedTabs[0].index < toIndex)
toIndex--;
log(' => ', toIndex);
if (isAcrossWindows) {
let temporaryFocusHolderTab = null;
if (hasActive) {
// Blur to-be-moved tab, otherwise tabs.move() will activate them for each
// while the moving process and all dicarded tabs are unexpectedly restored.
const nextActiveTab = await TabsInternalOperation.blurTab(movedTabs, {
silently: true,
});
if (!nextActiveTab) {
// There is no focusible left tab, so we move focus to a tmeporary tab.
// It will be removed automatically after tabs are moved.
temporaryFocusHolderTab = await browser.tabs.create({
url: 'about:blank',
active: true,
windowId
});
}
}
movedTabs = await browser.tabs.move(movedTabIds, {
windowId: destinationWindowId,
index: toIndex
});
if (temporaryFocusHolderTab) {
const leftTabsInSourceWindow = await browser.tabs.query({ windowId });
if (leftTabsInSourceWindow.length == 1)
browser.windows.remove(windowId);
else
browser.tabs.remove(temporaryFocusHolderTab.id);
}
movedTabs = movedTabs.map(tab => Tab.get(tab.id));
movedTabIds = movedTabs.map(tab => tab.id);
for (const tab of movedTabs) {
tab.$TST.temporaryMetadata.delete('movingAcrossWindows');
tab.windowId = destinationWindowId;
}
log('moved across windows: ', movedTabIds);
}
log('applying tree structure', structure);
// wait until tabs.onCreated are processed (for safety)
let newTabs;
const startTime = Date.now();
const maxDelay = configs.maximumAcceptableDelayForTabDuplication;
while (Date.now() - startTime < maxDelay) {
newTabs = mapAndFilter(movedTabs,
tab => Tab.get(tab.id) || undefined);
if (mSlowDuplication)
UserOperationBlocker.setProgress(Math.round(newTabs.length / tabs.length * 50) + 50, windowId);
if (newTabs.length < tabs.length) {
log('retrying: ', movedTabIds, newTabs.length, tabs.length);
await wait(100);
continue;
}
await Promise.all(newTabs.map(tab => tab.$TST.opened));
await applyTreeStructureToTabs(newTabs, structure, {
broadcast: true
});
if (duplicate) {
for (const tab of newTabs) {
tab.$TST.removeState(Constants.kTAB_STATE_DUPLICATING, { broadcast: true });
TabsStore.removeDuplicatingTab(tab);
}
}
break;
}
if (!newTabs) {
log('failed to move tabs (timeout)');
newTabs = [];
}
movedTabs = newTabs;
}
catch(e) {
if (configs.debug)
console.log('failed to move/duplicate tabs ', e, new Error().stack);
throw e;
}
finally {
if (mSlowDuplication)
UserOperationBlocker.unblockIn(windowId, { throbber: true });
}
}
movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined);
if (options.insertBefore) {
await TabsMove.moveTabsBefore(
movedTabs,
options.insertBefore,
options
);
}
else if (options.insertAfter) {
await TabsMove.moveTabsAfter(
movedTabs,
options.insertAfter,
options
);
}
else {
log('no move: just duplicate or import');
}
// Tabs can be removed while waiting, so we need to
// refresh the array of tabs.
movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined);
if (isAcrossWindows) {
for (const tab of movedTabs) {
if (tab.$TST.parent ||
parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0) == 0)
continue;
updateTabIndent(tab, 0);
}
}
return movedTabs;
}
export async function openNewWindowFromTabs(tabs, options = {}) {
if (tabs.length == 0)
return [];
log('openNewWindowFromTabs: ', tabs, options);
const sourceWindow = await browser.windows.get(tabs[0].windowId);
const sourceParams = getWindowParamsFromSource(sourceWindow, options);
const windowParams = {
//active: true, // not supported in Firefox...
url: 'about:blank',
...sourceParams,
};
// positions are not provided for a maximized or fullscren window!
if (typeof sourceParams.left == 'number')
sourceParams.left += 20;
if (typeof sourceParams.top == 'number')
sourceParams.top += 20;
let newWindow;
const promsiedNewWindow = browser.windows.create(windowParams)
.then(createdWindow => {
newWindow = createdWindow;
log('openNewWindowFromTabs: new window is ready, ', newWindow, windowParams);
UserOperationBlocker.blockIn(newWindow.id);
return newWindow;
})
.catch(ApiTabs.createErrorHandler());
tabs = tabs.filter(TabsStore.ensureLivingItem);
const movedTabs = await moveTabs(tabs, {
...options,
destinationPromisedNewWindow: promsiedNewWindow
});
log('closing needless tabs');
browser.windows.get(newWindow.id, { populate: true })
.then(win => {
const movedTabIds = new Set(movedTabs.map(tab => tab.id));
log('moved tabs: ', movedTabIds);
const removeTabs = mapAndFilter(win.tabs, tab =>
!movedTabIds.has(tab.id) && Tab.get(tab.id) || undefined
);
log('removing tabs: ', removeTabs);
TabsInternalOperation.removeTabs(removeTabs);
UserOperationBlocker.unblockIn(newWindow.id);
})
.catch(ApiTabs.createErrorSuppressor());
return movedTabs;
}
/* "treeStructure" is an array of integers, meaning:
[A] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree)
[B] => 0 (parent is 1st item in this tree)
[C] => 0 (parent is 1st item in this tree)
[D] => 2 (parent is 2nd in this tree)
[E] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree, and this creates another tree)
[F] => 0 (parent is 1st item in this another tree)
See also getTreeStructureFromTabs() in tree-behavior.js
*/
export async function applyTreeStructureToTabs(tabs, treeStructure, options = {}) {
if (!tabs || !treeStructure)
return;
MetricsData.add('applyTreeStructureToTabs: start');
log('applyTreeStructureToTabs: ', () => ({ tabs: tabs.map(dumpTab), treeStructure, options }));
tabs = tabs.slice(0, treeStructure.length);
treeStructure = treeStructure.slice(0, tabs.length);
let expandStates = tabs.map(tab => !!tab);
expandStates = expandStates.slice(0, tabs.length);
while (expandStates.length < tabs.length)
expandStates.push(TreeBehavior.STRUCTURE_NO_PARENT);
MetricsData.add('applyTreeStructureToTabs: preparation');
let parent = null;
let tabsInTree = [];
const promises = [];
for (let i = 0, maxi = tabs.length; i < maxi; i++) {
const tab = tabs[i];
/*
if (tab.$TST.collapsed)
collapseExpandTabAndSubtree(tab, {
...options,
collapsed: false,
justNow: true
});
*/
const structureInfo = treeStructure[i];
let parentIndexInTree = TreeBehavior.STRUCTURE_NO_PARENT;
if (typeof structureInfo == 'number') { // legacy format
parentIndexInTree = structureInfo;
}
else {
parentIndexInTree = structureInfo.parent;
expandStates[i] = !structureInfo.collapsed;
}
log(` applyTreeStructureToTabs: parent for ${tab.id} => ${parentIndexInTree}`);
if (parentIndexInTree == TreeBehavior.STRUCTURE_NO_PARENT ||
parentIndexInTree == TreeBehavior.STRUCTURE_KEEP_PARENT) {
// there is no parent, so this is a new parent!
parent = null;
tabsInTree = [tab];
}
else {
tabsInTree.push(tab);
parent = parentIndexInTree < tabsInTree.length ? tabsInTree[parentIndexInTree] : null;
}
log(' => parent = ', parent);
if (parentIndexInTree != TreeBehavior.STRUCTURE_KEEP_PARENT)
detachTab(tab, { justNow: true });
if (parent && tab != parent) {
parent.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); // prevent focus changing by "current tab attached to collapsed tree"
promises.push(attachTabTo(tab, parent, {
...options,
dontExpand: true,
dontMove: true,
justNow: true
}));
}
}
if (promises.length > 0)
await Promise.all(promises);
MetricsData.add('applyTreeStructureToTabs: attach/detach');
log('expandStates: ', expandStates);
for (let i = tabs.length - 1; i > -1; i--) {
const tab = tabs[i];
const expanded = expandStates[i];
collapseExpandSubtree(tab, {
...options,
collapsed: expanded === undefined ? !tab.$TST.hasChild : !expanded ,
justNow: true,
force: true
});
}
MetricsData.add('applyTreeStructureToTabs: collapse/expand');
}
//===================================================================
// Fixup tree structure for unexpectedly inserted tabs
//===================================================================
class TabActionForNewPosition {
constructor(action, { tab, parent, insertBefore, insertAfter, isTabCreating, isMovingByShortcut, mustToApply } = {}) {
this.action = action || null;
this.tab = tab;
this.parent = parent;
this.insertBefore = insertBefore;
this.insertAfter = insertAfter;
this.isTabCreating = isTabCreating;
this.isMovingByShortcut = isMovingByShortcut;
this.mustToApply = mustToApply;
}
async applyIfNeeded() {
if (!this.mustToApply)
return;
return this.apply();
}
async apply() {
log('TabActionForNewPosition: applying ', this);
switch (this.action) {
case 'invalid':
throw new Error('invalid action: this must not happen!');
case 'attach': {
const attached = attachTabTo(this.tab, Tab.get(this.parent), {
insertBefore: Tab.get(this.insertBefore),
insertAfter: Tab.get(this.insertAfter),
forceExpand: this.isTabCreating || this.isMovingByShortcut,
broadcast: true,
synchronously: this.isTabCreating,
});
if (!this.isTabCreating)
await attached;
followDescendantsToMovedRoot(this.tab);
}; break;
case 'detach':
detachTab(this.tab, { broadcast: true });
followDescendantsToMovedRoot(this.tab);
if (!this.insertBefore && !this.insertAfter)
break;
case 'move':
if (this.insertBefore) {
moveTabSubtreeBefore(
this.tab,
Tab.get(this.insertBefore),
{ broadcast: true }
);
return;
}
else if (this.insertAfter) {
moveTabSubtreeAfter(
this.tab,
Tab.get(this.insertAfter),
{ broadcast: true }
);
return;
}
default:
followDescendantsToMovedRoot(this.tab);
break;
}
}
}
export function detectTabActionFromNewPosition(tab, moveInfo = {}) {
const isTabCreating = !!moveInfo?.isTabCreating;
const isMovingByShortcut = !!moveInfo?.isMovingByShortcut;
if (tab.pinned)
return new TabActionForNewPosition(tab.$TST.parentId ? 'detach' : 'move', {
tab,
isTabCreating,
isMovingByShortcut,
});
log('detectTabActionFromNewPosition: ', dumpTab(tab), moveInfo);
const tree = moveInfo.treeForActionDetection || snapshotForActionDetection(tab);
const target = tree.target;
log(' calculate new position: ', tab, tree);
const toIndex = moveInfo.toIndex;
const fromIndex = moveInfo.fromIndex;
if (toIndex == fromIndex) { // no move?
log('=> no move');
return new TabActionForNewPosition();
}
const prevTab = tree.tabsById[target.previous];
const nextTab = tree.tabsById[target.next];
// When multiple tabs are moved at once by outside of TST (e.g. moving of multiselected tabs)
// this method may be called multiple times asynchronously before previous operation finishes.
// Thus we need to refer the calculated "parent" if it is given.
const futurePrevParent = Tab.get(Tab.get(prevTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo'));
const futureNextParent = Tab.get(Tab.get(nextTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo'));
const prevParent = prevTab && tree.tabsById[prevTab.parent] ||
snapshotTab(Tab.get(prevTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe
snapshotTab(futurePrevParent);
const nextParent = nextTab && tree.tabsById[nextTab.parent] ||
snapshotTab(Tab.get(nextTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe
snapshotTab(futureNextParent);
if (prevParent)
tree.tabsById[prevParent.id] = prevParent;
if (nextParent)
tree.tabsById[nextParent.id] = nextParent;
// Given treeForActionDetection may not contain the parent tab, so we fixup the information.
if (prevTab &&
!prevTab.parent &&
prevParent) {
prevTab.parent = prevParent.id;
prevTab.level = prevParent.level + 1;
}
if (nextTab &&
!nextTab.parent &&
nextParent) {
nextTab.parent = nextParent.id;
nextTab.level = nextParent.level + 1;
}
log('prevTab: ', dumpTab(prevTab), `parent: ${prevTab?.parent}`);
log('nextTab: ', dumpTab(nextTab), `parent: ${nextTab?.parent}`);
const prevLevel = prevTab ? prevTab.level : -1 ;
const nextLevel = nextTab ? nextTab.level : -1 ;
log('prevLevel: '+prevLevel);
log('nextLevel: '+nextLevel);
const oldParent = tree.tabsById[target.parent] || snapshotTab(Tab.get(target.parent));
if (oldParent)
tree.tabsById[oldParent.id] = oldParent;
let newParent = null;
let mustToApply = false;
if (!oldParent &&
(!nextTab ||
!nextParent)) {
if (!nextTab)
log('=> A root level tab, placed at the end of tabs. We should keep it in the root level.');
else
log(' => A root level tab, placed before another root level tab. We should keep it in the root level.');
return new TabActionForNewPosition('move', {
tab,
isTabCreating,
isMovingByShortcut,
insertAfter: prevTab?.id,
mustToApply,
});
}
if (target.mayBeReplacedWithContainer) {
log('=> replaced by Firefox Multi-Acount Containers or Temporary Containers');
newParent = prevLevel < nextLevel ? prevTab : prevParent;
mustToApply = true;
}
else if (oldParent &&
prevTab &&
oldParent?.id == prevTab?.id) {
log('=> no need to fix case');
newParent = oldParent;
}
else if (!prevTab) {
log('=> moved to topmost position');
newParent = null;
mustToApply = !!oldParent;
}
else if (!nextTab) {
log('=> moved to last position');
let ancestor = oldParent;
while (ancestor) {
if (ancestor.id == prevParent?.id) {
log(' => moving in related tree: keep it attached in existing tree');
newParent = prevParent;
break;
}
ancestor = tree.tabsById[ancestor.parent];
}
if (!newParent) {
log(' => moving from other tree: keep it orphaned');
}
mustToApply = !!oldParent && newParent?.id != oldParent.id;
}
else if (prevParent?.id == nextParent?.id) {
log('=> moved into existing tree');
newParent = prevParent;
mustToApply = !oldParent || newParent?.id != oldParent.id;
}
else if (prevLevel > nextLevel &&
nextTab?.parent != tab.id) {
log('=> moved to end of existing tree');
if (!target.active &&
target.children.length == 0 &&
(Date.now() - target.trackedAt) < 500) {
log('=> maybe newly opened tab');
newParent = prevParent;
}
else {
log('=> maybe drag and drop (or opened with active state and position)');
const realDelta = Math.abs(toIndex - fromIndex);
newParent = realDelta < 2 ? prevParent : (oldParent || nextParent) ;
}
while (newParent?.collapsed) {
log('=> the tree is collapsed, up to parent tree')
newParent = tree.tabsById[newParent.parent];
}
mustToApply = !!oldParent && newParent?.id != oldParent.id;
}
else if (prevLevel < nextLevel &&
nextTab?.parent == prevTab?.id) {
log('=> moved to first child position of existing tree');
newParent = prevTab || oldParent || nextParent;
mustToApply = !!oldParent && newParent?.id != oldParent.id;
}
log('calculated parent: ', {
old: oldParent?.id,
new: newParent?.id
});
if (newParent) {
let ancestor = newParent;
while (ancestor) {
if (ancestor.id == target.id) {
if (moveInfo.toIndex - moveInfo.fromIndex == 1) {
log('=> maybe move-down by keyboard shortcut or something.');
let nearestForeigner = tab.$TST.nearestFollowingForeignerTab;
if (nearestForeigner &&
nearestForeigner == tab)
nearestForeigner = nearestForeigner.$TST.nextTab;
log('nearest foreigner tab: ', nearestForeigner?.id);
if (nearestForeigner) {
if (nearestForeigner.$TST.hasChild)
return new TabActionForNewPosition('attach', {
tab,
isTabCreating,
isMovingByShortcut,
parent: nearestForeigner.id,
insertAfter: nearestForeigner.id,
mustToApply,
});
return new TabActionForNewPosition(tab.$TST.parent ? 'detach' : 'move', {
tab,
isTabCreating,
isMovingByShortcut,
insertAfter: nearestForeigner.id,
mustToApply,
});
}
}
log('=> invalid move: a parent is moved inside its own tree!');
return new TabActionForNewPosition('invalid');
}
ancestor = tree.tabsById[ancestor.parent];
}
}
if (newParent != oldParent) {
if (newParent) {
return new TabActionForNewPosition('attach', {
tab,
isTabCreating,
isMovingByShortcut,
parent: newParent.id,
insertBefore: nextTab?.id,
insertAfter: prevTab?.id,
mustToApply,
});
}
else {
return new TabActionForNewPosition('detach', {
tab,
isTabCreating,
isMovingByShortcut,
mustToApply,
});
}
}
return new TabActionForNewPosition('move', {
tab,
isTabCreating,
isMovingByShortcut,
mustToApply,
});
}
//===================================================================
// Take snapshot
//===================================================================
export function snapshotForActionDetection(targetTab) {
const prevTab = targetTab.$TST.nearestCompletelyOpenedNormalPrecedingTab;
const nextTab = targetTab.$TST.nearestCompletelyOpenedNormalFollowingTab;
const tabs = Array.from(new Set([
...(prevTab?.$TST?.ancestors || []),
prevTab,
targetTab,
nextTab,
targetTab.$TST.parent,
]))
.filter(TabsStore.ensureLivingItem)
.sort((a, b) => a.index - b.index);
return snapshotTree(targetTab, tabs);
}
function snapshotTree(targetTab, tabs) {
const allTabs = tabs || Tab.getTabs(targetTab.windowId);
const snapshotById = {};
function snapshotChild(tab) {
if (!TabsStore.ensureLivingItem(tab) || tab.pinned)
return null;
return snapshotById[tab.id] = snapshotTab(tab);
}
const snapshotArray = allTabs.map(tab => snapshotChild(tab));
for (const tab of allTabs) {
const item = snapshotById[tab.id];
if (!item)
continue;
item.parent = tab.$TST.parent?.id;
item.next = tab.$TST.nearestCompletelyOpenedNormalFollowingTab?.id;
item.previous = tab.$TST.nearestCompletelyOpenedNormalPrecedingTab?.id;
}
const activeTab = Tab.getActiveTab(targetTab.windowId);
return {
target: snapshotById[targetTab.id],
active: activeTab && snapshotById[activeTab.id],
tabs: snapshotArray,
tabsById: snapshotById,
};
}
function snapshotTab(tab) {
if (!tab)
return null;
return {
id: tab.id,
url: tab.url,
cookieStoreId: tab.cookieStoreId,
active: tab.active,
children: tab.$TST.children.map(child => child.id),
collapsed: tab.$TST.subtreeCollapsed,
pinned: tab.pinned,
level: tab.$TST.level, // parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0), // we need to use the number of real ancestors instead of a cached "level", because it will be updated with delay
trackedAt: tab.$TST.trackedAt,
mayBeReplacedWithContainer: tab.$TST.mayBeReplacedWithContainer,
};
}
SidebarConnection.onMessage.addListener(async (windowId, message) => {
switch (message.type) {
case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE: {
await Tab.waitUntilTracked(message.tabId);
const tab = Tab.get(message.tabId);
if (!tab)
return;
const params = {
collapsed: message.collapsed,
justNow: message.justNow,
broadcast: true,
stack: message.stack
};
if (message.manualOperation)
manualCollapseExpandSubtree(tab, params);
else
collapseExpandSubtree(tab, params);
}; break;
case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE_INTELLIGENTLY_FOR: {
await Tab.waitUntilTracked(message.tabId);
const tab = Tab.get(message.tabId);
if (tab)
await collapseExpandTreesIntelligentlyFor(tab);
}; break;
case Constants.kCOMMAND_NEW_WINDOW_FROM_TABS: {
log('new window requested: ', message);
await Tab.waitUntilTracked(message.tabIds);
const tabs = message.tabIds.map(id => TabsStore.tabs.get(id));
openNewWindowFromTabs(tabs, message);
}; break;
}
});