/* ***** 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 * wanabe * Tetsuharu OHZEKI * Xidorn Quan (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; } });