/* # 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, configs, wait, countMatched, dumpTab } 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 TabsStore from '/common/tabs-store.js'; import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; import * as TabsUpdate from '/common/tabs-update.js'; import * as TreeBehavior from '/common/tree-behavior.js'; import { Tab } from '/common/TreeItem.js'; import * as TabsMove from './tabs-move.js'; import * as TabsOpen from './tabs-open.js'; import * as Tree from './tree.js'; function log(...args) { internalLogger('background/tabs-group', ...args); } export function makeGroupTabURI({ title, temporary, temporaryAggressive, openerTabId, aliasTabId, replacedParentCount } = {}) { const url = new URL(Constants.kGROUP_TAB_URI); if (title) url.searchParams.set('title', title); if (temporaryAggressive) url.searchParams.set('temporaryAggressive', 'true'); else if (temporary) url.searchParams.set('temporary', 'true'); if (openerTabId) url.searchParams.set('openerTabId', openerTabId); if (aliasTabId) url.searchParams.set('aliasTabId', aliasTabId); if (replacedParentCount) url.searchParams.set('replacedParentCount', replacedParentCount); return url.href; } export function temporaryStateParams(state) { switch (state) { case Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE: return { temporary: true, temporaryAggressive: false, }; case Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE: return { temporary: false, temporaryAggressive: true, }; default: break; } return { temporary: false, temporaryAggressive: false, }; } export async function groupTabs(tabs, { broadcast, parent, withDescendants, ...groupTabOptions } = {}) { const rootTabs = Tab.collectRootTabs(tabs); if (rootTabs.length <= 0) return null; log('groupTabs: ', () => tabs.map(dumpTab), { broadcast, parent, withDescendants }); const uri = makeGroupTabURI({ title: browser.i18n.getMessage('groupTab_label', rootTabs[0].title), temporary: true, ...groupTabOptions }); const groupTab = await TabsOpen.openURIInTab(uri, { windowId: rootTabs[0].windowId, parent: parent || rootTabs[0].$TST.parent, insertBefore: rootTabs[0], inBackground: true }); if (!withDescendants) { const structure = TreeBehavior.getTreeStructureFromTabs(tabs); await Tree.detachTabsFromTree(tabs, { broadcast: !!broadcast, }); log('structure: ', structure); await Tree.applyTreeStructureToTabs(tabs, structure, { broadcast: !!broadcast, }); } await TabsMove.moveTabsAfter(tabs.slice(1), tabs[0], { broadcast: !!broadcast }); for (const tab of rootTabs) { await Tree.attachTabTo(tab, groupTab, { forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree dontMove: true, broadcast: !!broadcast, }); } return groupTab; } function reserveToCleanupNeedlessGroupTab(tabOrTabs) { const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; for (const tab of tabs) { if (!TabsStore.ensureLivingItem(tab)) continue; if (tab.$TST.temporaryMetadata.has('reservedCleanupNeedlessGroupTab')) clearTimeout(tab.$TST.temporaryMetadata.get('reservedCleanupNeedlessGroupTab')); tab.$TST.temporaryMetadata.set('reservedCleanupNeedlessGroupTab', setTimeout(() => { if (!tab.$TST) return; tab.$TST.temporaryMetadata.delete('reservedCleanupNeedlessGroupTab'); cleanupNeedlssGroupTab(tab); }, 100)); } } function cleanupNeedlssGroupTab(tabs) { if (!Array.isArray(tabs)) tabs = [tabs]; log('trying to clanup needless temporary group tabs from ', () => tabs.map(dumpTab)); const tabsToBeRemoved = []; for (const tab of tabs) { if (tab.$TST.temporaryMetadata.has('movingAcrossWindows')) continue; if (tab.$TST.isTemporaryGroupTab) { if (tab.$TST.childIds.length > 1) break; const lastChild = tab.$TST.firstChild; if (lastChild && !lastChild.$TST.isTemporaryGroupTab && !lastChild.$TST.isTemporaryAggressiveGroupTab) break; } else if (tab.$TST.isTemporaryAggressiveGroupTab) { if (tab.$TST.childIds.length > 1) break; } else { break; } tabsToBeRemoved.push(tab); } log('=> to be removed: ', () => tabsToBeRemoved.map(dumpTab)); TabsInternalOperation.removeTabs(tabsToBeRemoved, { keepDescendants: true }); } export async function tryReplaceTabWithGroup(tab, { windowId, parent, children, insertBefore, newParent } = {}) { if (tab) { windowId = tab.windowId; parent = tab.$TST.parent; children = tab.$TST.children; insertBefore = insertBefore || tab.$TST.unsafeNextTab; } if (children.length <= 1 || countMatched(children, tab => !tab.$TST.states.has(Constants.kTAB_STATE_TO_BE_REMOVED)) <= 1) return null; log('trying to replace the closing tab with a new group tab'); const firstChild = children[0]; const uri = makeGroupTabURI({ title: browser.i18n.getMessage('groupTab_label', firstChild.title), ...temporaryStateParams(configs.groupTabTemporaryStateForOrphanedTabs), replacedParentCount: (tab?.$TST?.replacedParentGroupTabCount || 0) + 1, }); const win = TabsStore.windows.get(windowId); win.toBeOpenedTabsWithPositions++; const groupTab = await TabsOpen.openURIInTab(uri, { windowId, insertBefore, inBackground: true }); log('group tab: ', dumpTab(groupTab)); if (!groupTab) // the window is closed! return; if (newParent || parent) await Tree.attachTabTo(groupTab, newParent || parent, { dontMove: true, broadcast: true }); for (const child of children) { await Tree.attachTabTo(child, groupTab, { dontMove: true, broadcast: true }); } // This can be triggered on closing of multiple tabs, // so we should cleanup it on such cases for safety. // https://github.com/piroor/treestyletab/issues/2317 wait(1000).then(() => reserveToCleanupNeedlessGroupTab(groupTab)); return groupTab; } // ==================================================================== // init/update group tabs // ==================================================================== /* To prevent the tab is closed by Firefox, we need to inject scripts dynamically. See also: https://github.com/piroor/treestyletab/issues/1670#issuecomment-350964087 */ async function tryInitGroupTab(tab) { if (!tab.$TST.isGroupTab && !tab.$TST.hasGroupTabURL) return; log('tryInitGroupTab ', tab); const v3Options = { target: { tabId: tab.id }, }; const v2Options = { runAt: 'document_start', matchAboutBlank: true }; try { const getPageState = function getPageState() { return [window.prepared, document.documentElement.matches('.initialized')]; }; const [prepared, initialized, reloaded] = (browser.scripting ? browser.scripting.executeScript({ // Manifest V3 ...v3Options, func: getPageState, }).then(results => results && results[0] && results[0].result || []) : browser.tabs.executeScript(tab.id, { ...v2Options, code: `(${getPageState.toString()})()`, }).then(results => results && results[0] || []) ).catch(error => { if (ApiTabs.isMissingHostPermissionError(error) && tab.$TST.hasGroupTabURL) { log(' tryInitGroupTab: failed to run script for restored/discarded tab, reload the tab for safety ', tab.id); browser.tabs.reload(tab.id); return [[false, false, true]]; } return ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)(error); }); log(' tryInitGroupTab: groupt tab state ', tab.id, { prepared, initialized, reloaded }); if (reloaded) { log(' => reloaded ', tab.id); return; } if (prepared && initialized) { log(' => already initialized ', tab.id); return; } } catch(error) { log(' tryInitGroupTab: error while checking initialized: ', tab.id, error); } try { const getTitleExistence = function getState() { return !!document.querySelector('#title'); }; const titleElementExists = (browser.scripting ? browser.scripting.executeScript({ // Manifest V3 ...v3Options, func: getTitleExistence, }).then(results => results && results[0] && results[0].result) : browser.tabs.executeScript(tab.id, { ...v2Options, code: `(${getTitleExistence.toString()})()`, }).then(results => results && results[0]) ).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); if (!titleElementExists && tab.status == 'complete') { // we need to load resources/group-tab.html at first. log(' => title element exists, load again ', tab.id); return browser.tabs.update(tab.id, { url: tab.url }).catch(ApiTabs.createErrorSuppressor()); } } catch(error) { log(' tryInitGroupTab error while checking title element: ', tab.id, error); } (browser.scripting ? browser.scripting.executeScript({ // Manifest V3 ...v3Options, files: [ '/extlib/l10n-classic.js', // ES module does not supported as a content script... '/resources/group-tab.js', ], }) : Promise.all([ browser.tabs.executeScript(tab.id, { ...v2Options, //file: '/common/l10n.js' file: '/extlib/l10n-classic.js', // ES module does not supported as a content script... }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)), browser.tabs.executeScript(tab.id, { ...v2Options, file: '/resources/group-tab.js', }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)), ]) ).then(() => { log('tryInitGroupTab completely initialized: ', tab.id); }); if (tab.$TST.states.has(Constants.kTAB_STATE_UNREAD)) { tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true }); SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TAB_UPDATED, windowId: tab.windowId, tabId: tab.id, removedStates: [Constants.kTAB_STATE_UNREAD] }); } } function reserveToUpdateRelatedGroupTabs(tab, changedInfo) { const tabMetadata = tab.$TST.temporaryMetadata; const updatingTabs = tabMetadata.get('reserveToUpdateRelatedGroupTabsUpdatingTabs') || new Set(); if (!tabMetadata.has('reserveToUpdateRelatedGroupTabsUpdatingTabs')) tabMetadata.set('reserveToUpdateRelatedGroupTabsUpdatingTabs', updatingTabs); const ancestorGroupTabs = [ tab, tab.$TST.bundledTab, ...tab.$TST.ancestors, ...tab.$TST.ancestors.map(tab => tab.$TST.bundledTab), ].filter(tab => tab?.$TST.isGroupTab); for (const updatingTab of ancestorGroupTabs) { const updatingMetadata = updatingTab.$TST.temporaryMetadata; const reservedChangedInfo = updatingMetadata.get('reservedUpdateRelatedGroupTabChangedInfo') || new Set(); for (const info of changedInfo) { reservedChangedInfo.add(info); } if (updatingTabs.has(updatingTab.id)) continue; updatingTabs.add(updatingTab.id); const triggeredUpdates = updatingMetadata.get('reservedUpdateRelatedGroupTabTriggeredUpdates') || new Set(); triggeredUpdates.add(updatingTabs); updatingMetadata.set('reservedUpdateRelatedGroupTabTriggeredUpdates', triggeredUpdates); if (updatingMetadata.has('reservedUpdateRelatedGroupTab')) clearTimeout(updatingMetadata.get('reservedUpdateRelatedGroupTab')); updatingMetadata.set('reservedUpdateRelatedGroupTabChangedInfo', reservedChangedInfo); updatingMetadata.set('reservedUpdateRelatedGroupTab', setTimeout(() => { updatingMetadata.delete('reservedUpdateRelatedGroupTab'); if (updatingTab.$TST) { try { if (reservedChangedInfo.size > 0) updateRelatedGroupTab(updatingTab, [...reservedChangedInfo]); } catch(_error) { } updatingMetadata.delete('reservedUpdateRelatedGroupTabChangedInfo'); } setTimeout(() => { const triggerUpdates = updatingMetadata.get('reservedUpdateRelatedGroupTabTriggeredUpdates') updatingMetadata.delete('reservedUpdateRelatedGroupTabTriggeredUpdates'); if (!triggerUpdates) return; for (const updatingTabs of triggerUpdates) { updatingTabs.delete(updatingTab.id); } }, 100) }, 100)); } } async function updateRelatedGroupTab(groupTab, changedInfo = []) { if (!TabsStore.ensureLivingItem(groupTab)) return; await tryInitGroupTab(groupTab); if (changedInfo.includes('tree')) { try { await browser.tabs.sendMessage(groupTab.id, { type: 'ws:update-tree', }).catch(error => { if (ApiTabs.isMissingHostPermissionError(error)) throw error; return ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleUnloadedError)(error); }); } catch(error) { if (ApiTabs.isMissingHostPermissionError(error)) { log(' updateRelatedGroupTab: failed to run script for restored/discarded tab, reload the tab for safety ', groupTab.id); browser.tabs.reload(groupTab.id); return; } } } const firstChild = groupTab.$TST.firstChild; if (!firstChild) // the tab can be closed while waiting... return; if (changedInfo.includes('title')) { let newTitle; if (Constants.kGROUP_TAB_DEFAULT_TITLE_MATCHER.test(groupTab.title)) { newTitle = browser.i18n.getMessage('groupTab_label', firstChild.title); } else if (Constants.kGROUP_TAB_FROM_PINNED_DEFAULT_TITLE_MATCHER.test(groupTab.title)) { const opener = groupTab.$TST.openerTab; if (opener) { if (opener && opener.favIconUrl) { SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED, windowId: groupTab.windowId, tabId: groupTab.id, favIconUrl: opener.favIconUrl }); } newTitle = browser.i18n.getMessage('groupTab_fromPinnedTab_label', opener.title); } } if (newTitle && groupTab.title != newTitle) { browser.tabs.sendMessage(groupTab.id, { type: 'ws:update-title', title: newTitle, }).catch(ApiTabs.createErrorHandler( ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError, _error => { // failed to update the title by group tab itself, so we try to update it from outside groupTab.title = newTitle; TabsUpdate.updateTab(groupTab, { title: newTitle }); } )); } } } Tab.onRemoved.addListener((tab, _closeInfo = {}) => { const ancestors = tab.$TST.ancestors; wait(0).then(() => { reserveToCleanupNeedlessGroupTab(ancestors); }); }); Tab.onUpdated.addListener((tab, changeInfo) => { if ('url' in changeInfo || 'previousUrl' in changeInfo || 'state' in changeInfo) { const status = changeInfo.status || tab?.status; const url = changeInfo.url ? changeInfo.url : status == 'complete' && tab ? tab.url : ''; if (tab && status == 'complete') { if (url.indexOf(Constants.kGROUP_TAB_URI) == 0) { tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); } else if (!Constants.kSHORTHAND_ABOUT_URI.test(url)) { tab.$TST.getPermanentStates().then(async (states) => { if (url.indexOf(Constants.kGROUP_TAB_URI) == 0) return; // Detect group tab from different session - which can have different UUID for the URL. const PREFIX_REMOVER = /^moz-extension:\/\/[^\/]+/; const pathPart = url.replace(PREFIX_REMOVER, ''); if (states.includes(Constants.kTAB_STATE_GROUP_TAB) && pathPart.split('?')[0] == Constants.kGROUP_TAB_URI.replace(PREFIX_REMOVER, '')) { const parameters = pathPart.replace(/^[^\?]+\?/, ''); const oldUrl = tab.url; await wait(100); // for safety if (tab.url != oldUrl) return; browser.tabs.update(tab.id, { url: `${Constants.kGROUP_TAB_URI}?${parameters}` }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB); } else { tab.$TST.removeState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); } }); } } // restored tab can be replaced with blank tab. we need to restore it manually. else if (changeInfo.url == 'about:blank' && changeInfo.previousUrl && changeInfo.previousUrl.indexOf(Constants.kGROUP_TAB_URI) == 0) { const oldUrl = tab.url; wait(100).then(() => { // redirect with delay to avoid infinite loop of recursive redirections. if (tab.url != oldUrl) return; browser.tabs.update(tab.id, { url: changeInfo.previousUrl }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); }); } if (changeInfo.status || changeInfo.url || url.indexOf(Constants.kGROUP_TAB_URI) == 0) tryInitGroupTab(tab); } if ('title' in changeInfo) { const group = Tab.getGroupTabForOpener(tab); if (group) reserveToUpdateRelatedGroupTabs(group, ['title', 'tree']); } }); Tab.onGroupTabDetected.addListener(tab => { tryInitGroupTab(tab); }); Tab.onLabelUpdated.addListener(tab => { reserveToUpdateRelatedGroupTabs(tab, ['title', 'tree']); }); Tab.onActivating.addListener((tab, _info = {}) => { tryInitGroupTab(tab); }); // returns a boolean: need to reload or not. export async function clearTemporaryState(tab) { if (!tab.$TST.isTemporaryGroupTab && !tab.$TST.isTemporaryAggressiveGroupTab) return; const url = new URL(tab.url); url.searchParams.delete('temporary'); url.searchParams.delete('temporaryAggressive'); await Promise.all([ browser.tabs.sendMessage(tab.id, { type: 'ws:clear-temporary-state', }).catch(ApiTabs.createErrorHandler()), browser.tabs.executeScript(tab.id, { // failsafe runAt: 'document_start', code: `history.replaceState({}, document.title, ${JSON.stringify(url.href)});`, }).catch(ApiTabs.createErrorHandler()), ]); tab.url = url.href; } Tab.onPinned.addListener(async tab => { log('handlePinnedParentTab ', tab); await Tree.collapseExpandSubtree(tab, { collapsed: false, broadcast: true }); log(' childIdsBeforeMoved: ', tab.$TST.temporaryMetadata.get('childIdsBeforeMoved')); log(' parentIdBeforeMoved: ', tab.$TST.temporaryMetadata.get('parentIdBeforeMoved')); const children = ( tab.$TST.temporaryMetadata.has('childIdsBeforeMoved') ? tab.$TST.temporaryMetadata.get('childIdsBeforeMoved').map(id => Tab.get(id)) : tab.$TST.children ).filter(tab => TabsStore.ensureLivingItem(tab)); const parent = TabsStore.ensureLivingItem( tab.$TST.temporaryMetadata.has('parentIdBeforeMoved') ? Tab.get(tab.$TST.temporaryMetadata.get('parentIdBeforeMoved')) : tab.$TST.parent ); let openedGroupTab; const shouldGroupChildren = configs.autoGroupNewTabsFromPinned || tab.$TST.isGroupTab; if (shouldGroupChildren) { log(' => trying to group left tabs with a group: ', children); openedGroupTab = await groupTabs(children, { // If the tab is a group tab, the opened tab should be treated as an alias of the pinned group tab. // Otherwise it should be treated just as a temporary group tab to group children. title: tab.$TST.isGroupTab ? tab.title : browser.i18n.getMessage('groupTab_fromPinnedTab_label', tab.title), temporary: !tab.$TST.isGroupTab, openerTabId: tab.$TST.uniqueId.id, parent, withDescendants: true, }); log(' openedGroupTab: ', openedGroupTab); // Tree structure of left tabs can be modified by someone like tryFixupTreeForInsertedTab@handle-moved-tabs.js. // On such cases we need to restore the original tree structure. const modifiedChildren = children.filter(child => children.includes(child.$TST.parent)); log(' modifiedChildren: ', modifiedChildren); if (modifiedChildren.length > 0) { for (const child of modifiedChildren) { await Tree.detachTab(child, { broadcast: true, }); await Tree.attachTabTo(child, openedGroupTab, { dontMove: true, broadcast: true, }); } } } else { log(' => no need to group left tabs, just detaching'); await Tree.detachAllChildren(tab, { behavior: TreeBehavior.getParentTabOperationBehavior(tab, { context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, preventEntireTreeBehavior: true, }), broadcast: true }); } await Tree.detachTab(tab, { broadcast: true }); // Such a group tab will be closed automatically when all children are detached. // To prevent the auto close behavior, the tab type need to be turned to permanent. await clearTemporaryState(tab); if (tab.$TST.isGroupTab && openedGroupTab) { const url = new URL(tab.url); url.searchParams.set('aliasTabId', openedGroupTab.$TST.uniqueId.id); await Promise.all([ browser.tabs.sendMessage(tab.id, { type: 'ws:replace-state-url', url: url.href, }).catch(ApiTabs.createErrorHandler()), browser.tabs.executeScript(tab.id, { // failsafe runAt: 'document_start', code: `history.replaceState({}, document.title, ${JSON.stringify(url.href)});`, }).catch(ApiTabs.createErrorHandler()), ]); await browser.tabs.sendMessage(tab.id, { type: 'ws:update-tree', url: url.href, }).catch(ApiTabs.createErrorHandler()); tab.url = url.href; } }); Tree.onAttached.addListener((tab, _info = {}) => { reserveToUpdateRelatedGroupTabs(tab, ['tree']); }); Tree.onDetached.addListener((_tab, detachInfo) => { if (!detachInfo.oldParentTab) return; if (detachInfo.oldParentTab.$TST.isGroupTab) reserveToCleanupNeedlessGroupTab(detachInfo.oldParentTab); reserveToUpdateRelatedGroupTabs(detachInfo.oldParentTab, ['tree']); }); /* Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info) => { reserveToUpdateRelatedGroupTabs(tab); }); */