/* # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; import { log as internalLogger, dumpTab, configs, wait, isMacOS, } from '/common/common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Constants from '/common/constants.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 { Tab } from '/common/TreeItem.js'; import * as Commands from './commands.js'; import * as NativeTabGroups from './native-tab-groups.js'; import * as TabsGroup from './tabs-group.js'; import * as Tree from './tree.js'; import * as TreeStructure from './tree-structure.js'; function log(...args) { internalLogger('background/handle-moved-tabs', ...args); } function logApiTabs(...args) { internalLogger('common/api-tabs', ...args); } let mMaybeTabMovingByShortcut = false; Tab.onCreated.addListener((tab, info = {}) => { if (!info.mayBeReplacedWithContainer && (info.duplicated || info.restored || info.skipFixupTree || // do nothing for already attached tabs (tab.openerTabId && tab.$TST.parent == Tab.get(tab.openerTabId)))) { log('skip to fixup tree for replaced/duplicated/restored tab ', tab, info); return; } // if the tab is opened inside existing tree by someone, we must fixup the tree. if (!(info.positionedBySelf || info.movedBySelfWhileCreation) && (tab.$TST.nearestCompletelyOpenedNormalFollowingTab || tab.$TST.nearestCompletelyOpenedNormalPrecedingTab || (info.treeForActionDetection?.target && (info.treeForActionDetection.target.next || info.treeForActionDetection.target.previous)))) { tryFixupTreeForInsertedTab(tab, { toIndex: tab.index, fromIndex: Tab.getLastTab(tab.windowId).index, treeForActionDetection: info.treeForActionDetection, isTabCreating: true }); } else { log('no need to fixup tree for newly created tab ', tab, info); } }); Tab.onMoving.addListener((tab, moveInfo) => { // avoid TabMove produced by browser.tabs.insertRelatedAfterCurrent=true or something. const win = TabsStore.windows.get(tab.windowId); const isNewlyOpenedTab = win.openingTabs.has(tab.id); const positionControlled = configs.insertNewChildAt != Constants.kINSERT_NO_CONTROL; if (!isNewlyOpenedTab || !positionControlled || moveInfo.byInternalOperation || moveInfo.alreadyMoved || !moveInfo.movedInBulk) return true; // if there is no valid opener, it can be a restored initial tab in a restored window // and can be just moved as a part of window restoration process. if (!tab.$TST.openerTab) return true; log('onTabMove for new child tab: move back '+moveInfo.toIndex+' => '+moveInfo.fromIndex); moveBack(tab, moveInfo); return false; }); async function tryFixupTreeForInsertedTab(tab, moveInfo = {}) { const internalGroupMoveCount = NativeTabGroups.internallyMovingNativeTabGroups.get(tab.groupId); if (internalGroupMoveCount) { log('ignore internal move of tab groups ', internalGroupMoveCount); return; } const parentTabOperationBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { context: Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE, ...moveInfo, }); log('tryFixupTreeForInsertedTab ', { tab: tab.id, parentTabOperationBehavior, moveInfo, childIds: tab.$TST.childIds, parentId: tab.$TST.parentId, }); if (!moveInfo.isTabCreating && parentTabOperationBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) { if (tab.$TST.hasChild) tab.$TST.temporaryMetadata.set('childIdsBeforeMoved', tab.$TST.childIds.slice(0)); tab.$TST.temporaryMetadata.set('parentIdBeforeMoved', tab.$TST.parentId); const replacedGroupTab = (parentTabOperationBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) ? await TabsGroup.tryReplaceTabWithGroup(tab, { insertBefore: tab.$TST.firstChild }) : null; if (!replacedGroupTab && tab.$TST.hasChild) { if (tab.$TST.isGroupTab) await TabsGroup.clearTemporaryState(tab); await Tree.detachAllChildren(tab, { behavior: parentTabOperationBehavior, nearestFollowingRootTab: tab.$TST.firstChild.$TST.nearestFollowingRootTab, broadcast: true }); } if (tab.$TST.parentId) await Tree.detachTab(tab, { broadcast: true }); // Pinned tab is moved at first, so Tab.onPinned handler cannot know tree information // before the pinned tab was moved. Thus we cache tree information for the handler. wait(100).then(() => { tab.$TST.temporaryMetadata.delete('childIdsBeforeMoved'); tab.$TST.temporaryMetadata.delete('parentIdBeforeMoved'); }); } log('The tab can be placed inside existing tab unexpectedly, so now we are trying to fixup tree.'); const action = Tree.detectTabActionFromNewPosition(tab, { isMovingByShortcut: mMaybeTabMovingByShortcut, ...moveInfo, }); log(' => action: ', action); if (!action.action) { log('no action'); return; } // When multiple tabs are moved at once by outside of TST (e.g. moving of multiselected tabs) // Tree.detectTabActionFromNewPosition() may be called for other tabs asynchronously // before this operation finishes. Thus we need to memorize the calculated "parent" // and Tree.detectTabActionFromNewPosition() will use it. if (action.parent) tab.$TST.temporaryMetadata.set('goingToBeAttachedTo', action.parent); // notify event to helper addons with action and allow or deny const cache = {}; const allowed = await TSTAPI.tryOperationAllowed( TSTAPI.kNOTIFY_TRY_FIXUP_TREE_ON_TAB_MOVED, { tab, fromIndex: moveInfo.fromIndex, toIndex: moveInfo.toIndex, action: action.action, parent: action.parent, insertBefore: action.insertBefore, insertAfter: action.insertAfter, }, { tabProperties: ['tab', 'parent', 'insertBefore', 'insertAfter'], cache } ); TSTAPI.clearCache(cache); if (!allowed) { log('no action - canceled by a helper addon'); } else { log('action: ', action); switch (action.action) { case 'invalid': moveBack(tab, moveInfo); break; default: log('tryFixupTreeForInsertedTab: apply action for unattached tab: ', tab, action); await action.apply(); break; } } if (tab.$TST.temporaryMetadata.get('goingToBeAttachedTo') == action.parent) tab.$TST.temporaryMetadata.delete('goingToBeAttachedTo'); } function reserveToEnsureRootTabVisible(tab) { reserveToEnsureRootTabVisible.tabIds.add(tab.id); if (reserveToEnsureRootTabVisible.reserved) clearTimeout(reserveToEnsureRootTabVisible.reserved); reserveToEnsureRootTabVisible.reserved = setTimeout(() => { delete reserveToEnsureRootTabVisible.reserved; const tabs = Array.from(reserveToEnsureRootTabVisible.tabIds, Tab.get); reserveToEnsureRootTabVisible.tabIds.clear(); for (const tab of tabs) { if (!tab.$TST || tab.$TST.parent || !tab.$TST.collapsed) continue; Tree.collapseExpandTabAndSubtree(tab, { collapsed: false, broadcast: true }); } }, 150); } reserveToEnsureRootTabVisible.tabIds = new Set(); Tab.onMoved.addListener((tab, moveInfo = {}) => { if (moveInfo.byInternalOperation || !moveInfo.movedInBulk || tab.$TST.duplicating) { log('internal move'); tab.$TST.nativeTabGroup?.$TST.reindex(); } else { log('process moved tab'); tryFixupTreeForInsertedTab(tab, moveInfo).then(() => { tab.$TST.nativeTabGroup?.$TST.reindex(); }); } reserveToEnsureRootTabVisible(tab); }); function onMessage(message, _sender) { if (!message || typeof message.type != 'string') return; //log('onMessage: ', message, sender); switch (message.type) { case Constants.kNOTIFY_TAB_MOUSEDOWN: mMaybeTabMovingByShortcut = false; break; case Constants.kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH: if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) return; mMaybeTabMovingByShortcut = true; break; case Constants.kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH: if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) return; mMaybeTabMovingByShortcut = false; break; } } browser.runtime.onMessage.addListener(onMessage); Commands.onMoveUp.addListener(async tab => { await tryFixupTreeForInsertedTab(tab, { toIndex: tab.index, fromIndex: tab.index + 1, }); }); Commands.onMoveDown.addListener(async tab => { await tryFixupTreeForInsertedTab(tab, { toIndex: tab.index, fromIndex: tab.index - 1, }); }); TreeStructure.onTabAttachedFromRestoredInfo.addListener((tab, moveInfo) => { tryFixupTreeForInsertedTab(tab, moveInfo); }); function moveBack(tab, moveInfo) { log('Move back tab from unexpected move: ', dumpTab(tab), moveInfo); const id = tab.id; const win = TabsStore.windows.get(tab.windowId); const index = moveInfo.fromIndex; win.internalMovingTabs.set(id, index); logApiTabs(`handle-moved-tabs:moveBack: browser.tabs.move() `, tab.id, { windowId: moveInfo.windowId, index: moveInfo.fromIndex }); // Because we need to use the raw "fromIndex" directly, // we cannot use TabsMove.moveTabInternallyBefore/After here. return browser.tabs.move(tab.id, { windowId: moveInfo.windowId, index: moveInfo.fromIndex }).catch(ApiTabs.createErrorHandler(e => { if (win.internalMovingTabs.get(id) == index) win.internalMovingTabs.delete(id); ApiTabs.handleMissingTabError(e); })); }