/* # 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, sanitizeForHTMLText, compareAsNumber, isFirefoxViewTab, } from '/common/common.js'; import * as ApiTabs from '/common/api-tabs.js'; import * as Bookmark from '/common/bookmark.js'; import * as Constants from '/common/constants.js'; import * as Dialog from '/common/dialog.js'; import * as Permissions from '/common/permissions.js'; import * as TabsStore from '/common/tabs-store.js'; import * as TSTAPI from '/common/tst-api.js'; import { Tab, TreeItem } from '/common/TreeItem.js'; import * as TabsGroup from './tabs-group.js'; import * as TabsOpen from './tabs-open.js'; import * as Tree from './tree.js'; function log(...args) { internalLogger('background/handle-tab-bunches', ...args); } // ==================================================================== // Detection of a bunch of tabs opened at same time. // Firefox's WebExtensions API doesn't provide ability to know which tabs // are opened together by a single trigger. Thus TST tries to detect such // "tab bunches" based on their opened timing. // ==================================================================== Tab.onBeforeCreate.addListener(async (tab, info) => { const win = TabsStore.windows.get(tab.windowId); if (!win) return; const openerId = tab.openerTabId; const openerTab = openerId && (await browser.tabs.get(openerId).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError))); if ((openerTab && (openerTab.pinned || isFirefoxViewTab(openerTab)) && openerTab.windowId == tab.windowId) || (!openerTab && !info.maybeOrphan)) { if (win.preventToDetectTabBunchesUntil > Date.now()) { win.preventToDetectTabBunchesUntil += configs.tabBunchesDetectionTimeout; } else { win.openedNewTabs.set(tab.id, { id: tab.id, windowId: tab.windowId, indexOnCreated: tab.$indexOnCreated, openerId: openerTab?.id, openerIsPinned: openerTab?.pinned, openerIsFirefoxView: isFirefoxViewTab(openerTab), maybeFromBookmark: tab.$TST.maybeFromBookmark, shouldNotGrouped: TSTAPI.isGroupingBlocked(), }); } } if (win.delayedTabBunchesDetection) clearTimeout(win.delayedTabBunchesDetection); win.delayedTabBunchesDetection = setTimeout( tryDetectTabBunches, configs.tabBunchesDetectionTimeout, win ); }); const mPossibleTabBunchesToBeGrouped = []; const mPossibleTabBunchesFromBookmarks = []; async function tryDetectTabBunches(win) { if (Tab.needToWaitTracked(win.id)) await Tab.waitUntilTrackedAll(win.id); if (Tab.needToWaitMoved(win.id)) await Tab.waitUntilMovedAll(win.id); let tabReferences = Array.from(win.openedNewTabs.values()); log('tryDetectTabBunches for ', tabReferences); win.openedNewTabs.clear(); tabReferences = tabReferences.filter(tabReference => { if (!tabReference.id) return false; const tab = Tab.get(tabReference.id); if (!tab) return false; const uniqueId = tab?.$TST?.uniqueId; return !uniqueId || (!uniqueId.duplicated && !uniqueId.restored); }); if (tabReferences.length == 0) { log(' => there is no possible bunches of tabs.'); return; } if (tabReferences.length > 1) { await Promise.all(tabReferences.map(tabReference => { const tab = Tab.get(tabReference.id); tab.$TST.temporaryMetadata.set('openedWithOthers', true); // We need to wait until all tabs are handlede completely. // Otherwise `tab.$TST.needToBeGroupedSiblings` may contain unrelated tabs // (tabs opened from any other parent tab) unexpectedly. return tab.$TST.opened; })); } if (areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) && configs.fixupOrderOfTabsFromOtherDevice) { const ids = tabReferences.map(tabReference => tabReference.id); const index = tabReferences.map(tabReference => Tab.get(tabReference.id).index).sort(compareAsNumber)[0]; log(' => gather tabs from other device at ', ids, index); await browser.tabs.move(ids, { index }); } if (configs.autoGroupNewTabsFromPinned || configs.autoGroupNewTabsFromFirefoxView || configs.autoGroupNewTabsFromOthers) { mPossibleTabBunchesToBeGrouped.push(tabReferences); tryGroupTabBunches(); } if (configs.autoGroupNewTabsFromBookmarks || configs.restoreTreeForTabsFromBookmarks) { mPossibleTabBunchesFromBookmarks.push(tabReferences); tryHandlTabBunchesFromBookmarks(); } } async function tryGroupTabBunches() { if (tryGroupTabBunches.running) return; const tabReferences = mPossibleTabBunchesToBeGrouped.shift(); if (!tabReferences) return; log('tryGroupTabBunches for ', tabReferences); tryGroupTabBunches.running = true; try { const fromPinned = []; const fromOthers = []; // extract only pure new tabs for (const tabReference of tabReferences) { if (tabReference.shouldNotGrouped) continue; const tab = Tab.get(tabReference.id); if (!tab) continue; if (tabReference.openerTabId) tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information const uniqueId = tab.$TST.uniqueId; if (tab.$TST.isGroupTab || uniqueId.duplicated || uniqueId.restored) continue; if (tabReference.openerIsPinned || tabReference.openerIsFirefoxView) { // We should check the "autoGroupNewTabsFromPinned" config here, // because to-be-grouped tabs should be ignored by the handler for // "autoAttachSameSiteOrphan" behavior. if ((tab.$TST.hasPinnedOpener && configs.autoGroupNewTabsFromPinned) || (tab.$TST.hasFirefoxViewOpener && configs.autoGroupNewTabsFromFirefoxView)) fromPinned.push(tab); } else { fromOthers.push(tab); } } log(' => ', { fromPinned, fromOthers }); if (fromPinned.length > 0 && (configs.autoGroupNewTabsFromPinned || configs.autoGroupNewTabsFromFirefoxView)) { const newRootTabs = Tab.collectRootTabs(TreeItem.sort(fromPinned)); if (newRootTabs.length > 0) { await tryGroupTabBunchesFromPinnedOpener(newRootTabs); } } // We can assume that new tabs from a bookmark folder and from other // sources won't be mixed. const openedFromBookmarkFolder = fromOthers.length > 0 && await detectBookmarkFolderFromTabs(fromOthers, tabReferences.length); log(' => tryGroupTabBunches:openedFromBookmarkFolder: ', !!openedFromBookmarkFolder); const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder ? openedFromBookmarkFolder.tabs : fromOthers)); log(' newRootTabs: ', newRootTabs); if (newRootTabs.length > 1 && !openedFromBookmarkFolder && // we should ignore tabs from bookmark folder: they should be handled by tryHandlTabBunchesFromBookmarks configs.autoGroupNewTabsFromOthers) { const granted = await confirmToAutoGroupNewTabsFromOthers(fromOthers); if (granted) await TabsGroup.groupTabs(newRootTabs, { ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromOthers), broadcast: true }); } } catch(error) { log('Error on tryGroupTabBunches: ', String(error), error.stack); } finally { tryGroupTabBunches.running = false; if (mPossibleTabBunchesToBeGrouped.length > 0) tryGroupTabBunches(); } } async function confirmToAutoGroupNewTabsFromOthers(tabs) { if (tabs.length <= 1 || !configs.warnOnAutoGroupNewTabs) return true; const windowId = tabs[0].windowId; const win = await browser.windows.get(windowId); const listing = configs.warnOnAutoGroupNewTabsWithListing ? Dialog.tabsToHTMLList(tabs, { maxRows: configs.warnOnAutoGroupNewTabsWithListingMaxRows, maxHeight: Math.round(win.height * 0.8), maxWidth: Math.round(win.width * 0.75) }) : ''; const result = await Dialog.show(win, { content: `
${sanitizeForHTMLText(browser.i18n.getMessage('warnOnAutoGroupNewTabs_message', [tabs.length]))}
${listing} `.trim(), buttons: [ browser.i18n.getMessage('warnOnAutoGroupNewTabs_close'), browser.i18n.getMessage('warnOnAutoGroupNewTabs_cancel') ], checkMessage: browser.i18n.getMessage('warnOnAutoGroupNewTabs_warnAgain'), checked: true, modal: true, // for popup type: 'common-dialog', // for popup url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup title: browser.i18n.getMessage('warnOnAutoGroupNewTabs_title'), // for popup onShownInPopup(container) { setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. // this need to be done on the next tick, to use the height of the box for calculation of dialog size const style = container.querySelector('ul').style; style.height = '0px'; // this makes the box shrinkable style.maxHeight = 'none'; style.minHeight = '0px'; }, 0); } }); switch (result.buttonIndex) { case 0: if (!result.checked) configs.warnOnAutoGroupNewTabs = false; return true; case 1: if (!result.checked) { configs.warnOnAutoGroupNewTabs = false; configs.autoGroupNewTabsFromOthers = false; } default: return false; } } async function tryGroupTabBunchesFromPinnedOpener(rootTabs) { log(`tryGroupTabBunchesFromPinnedOpener: ${rootTabs.length} root tabs are opened from pinned tabs`); // First, collect pinned opener tabs. let pinnedOpeners = []; const childrenOfPinnedTabs = {}; for (const tab of rootTabs) { const opener = tab.$TST.openerTab; if (!pinnedOpeners.includes(opener)) pinnedOpeners.push(opener); } log('pinnedOpeners ', () => pinnedOpeners.map(dumpTab)); // Second, collect tabs opened from pinned openers including existing tabs // (which were left ungrouped in previous process). const openerOf = {}; const allRootTabs = await Tab.getRootTabs(rootTabs[0].windowId) for (const tab of allRootTabs) { if (tab.$TST.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER)) continue; if (rootTabs.includes(tab)) { // newly opened tab const opener = tab.$TST.openerTab; if (!opener) continue; openerOf[tab.id] = opener; const tabs = childrenOfPinnedTabs[opener.id] || []; childrenOfPinnedTabs[opener.id] = tabs.concat([tab]); continue; } const opener = Tab.getByUniqueId(tab.$TST.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID)); if (!opener || !(opener.pinned || isFirefoxViewTab(opener)) || opener.windowId != tab.windowId) continue; // existing and not yet grouped tab if (!pinnedOpeners.includes(opener)) pinnedOpeners.push(opener); openerOf[tab.id] = opener; const tabs = childrenOfPinnedTabs[opener.id] || []; childrenOfPinnedTabs[opener.id] = tabs.concat([tab]); } // Ignore pinned openeres which has no child tab to be grouped. pinnedOpeners = pinnedOpeners.filter(opener => { return childrenOfPinnedTabs[opener.id].length > 1 || Tab.getGroupTabForOpener(opener); }); log(' => ', () => pinnedOpeners.map(dumpTab)); // Move newly opened tabs to expected position before grouping! // Note that we should refer "insertNewChildAt" instead of "insertNewTabFromPinnedTabAt" / "insertNewTabFromFirefoxViewAt" // because these children are going to be controlled in a sub tree. for (const tab of rootTabs.slice(0).sort((a, b) => a.id - b.id)/* process them in the order they were opened */) { const opener = openerOf[tab.id]; const siblings = tab.$TST.needToBeGroupedSiblings; if (!pinnedOpeners.includes(opener) || Tab.getGroupTabForOpener(opener) || siblings.length == 0 || tab.$TST.temporaryMetadata.has('alreadyMovedAsOpenedFromPinnedOpener')) continue; let refTabs = {}; try { refTabs = Tree.getReferenceTabsForNewChild(tab, null, { lastRelatedTab: opener.$TST.previousLastRelatedTab, parent: siblings[0], children: siblings, descendants: siblings.map(sibling => [sibling, ...sibling.$TST.descendants]).flat() }); } catch(_error) { // insertChildAt == "no control" case } if (refTabs.insertAfter) { await Tree.moveTabSubtreeAfter( tab, refTabs.insertAfter, { broadcast: true } ); log(`newly opened child ${tab.id} has been moved after ${refTabs.insertAfter?.id}`); } else if (refTabs.insertBefore) { await Tree.moveTabSubtreeBefore( tab, refTabs.insertBefore, { broadcast: true } ); log(`newly opened child ${tab.id} has been moved before ${refTabs.insertBefore?.id}`); } else { continue; } tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); } // Finally, try to group opened tabs. const newGroupTabs = new Map(); for (const opener of pinnedOpeners) { const children = childrenOfPinnedTabs[opener.id].sort((a, b) => a.index - b.index); let parent = Tab.getGroupTabForOpener(opener); if (parent) { for (const child of children) { TabsStore.removeToBeGroupedTab(child); } continue; } log(`trying to group children of ${dumpTab(opener)}: `, () => children.map(dumpTab)); const uri = TabsGroup.makeGroupTabURI({ title: browser.i18n.getMessage('groupTab_fromPinnedTab_label', opener.title), openerTabId: opener.$TST.uniqueId.id, ...TabsGroup.temporaryStateParams(isFirefoxViewTab(opener) ? configs.groupTabTemporaryStateForChildrenOfFirefoxView : configs.groupTabTemporaryStateForChildrenOfPinned) }); parent = await TabsOpen.openURIInTab(uri, { windowId: opener.windowId, insertBefore: children[0], cookieStoreId: opener.cookieStoreId, inBackground: true }); log('opened group tab: ', dumpTab(parent)); newGroupTabs.set(opener, true); for (const child of children) { // Prevent the tab to be grouped again after it is ungrouped manually. child.$TST.setAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, true); TabsStore.removeToBeGroupedTab(child); await Tree.attachTabTo(child, parent, { forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree dontMove: true, broadcast: true }); } if (opener.active) parent.$TST.addState(Constants.kTAB_STATE_BUNDLED_ACTIVE); } return true; } async function tryHandlTabBunchesFromBookmarks() { if (tryHandlTabBunchesFromBookmarks.running) return; const tabReferences = mPossibleTabBunchesFromBookmarks.shift(); if (!tabReferences) return; log('tryHandlTabBunchesFromBookmarks for ', tabReferences); tryHandlTabBunchesFromBookmarks.running = true; try { const tabs = []; for (const tabReference of tabReferences) { const tab = Tab.get(tabReference.id); if (!tab) continue; if (tabReference.openerTabId) tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information const uniqueId = tab.$TST.uniqueId; if (uniqueId.duplicated || uniqueId.restored) continue; tabs.push(tab); } log(' => ', { tabs }); // We can assume that new tabs from a bookmark folder and from other // sources won't be mixed. const openedFromBookmarkFolder = tabs.length > 0 && await detectBookmarkFolderFromTabs(tabs, tabReferences.length); log(' => tryHandlTabBunchesFromBookmarks:openedFromBookmarkFolder: ', openedFromBookmarkFolder); if (openedFromBookmarkFolder) { if (configs.restoreTreeForTabsFromBookmarks) { log(' ==> trying to restore tree structure from bookmark information'); const structure = await Bookmark.getTreeStructureFromBookmarkFolder(openedFromBookmarkFolder.folder); log(' ==> structure:', structure); if (structure.length == openedFromBookmarkFolder.tabs.length) { log(' ===> apply'); await Tree.applyTreeStructureToTabs(openedFromBookmarkFolder.tabs, structure); } } const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder.tabs)); if (newRootTabs.length > 1 && configs.autoGroupNewTabsFromBookmarks && tabReferences.every(tabReference => !tabReference.shouldNotGrouped)) { log(' => tryHandlTabBunchesFromBookmarks:group'); await TabsGroup.groupTabs(openedFromBookmarkFolder.tabs, { ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromBookmarks), broadcast: true }); } } } catch(error) { log('Error on tryHandlTabBunchesFromBookmarks: ', String(error), error.stack); } finally { tryHandlTabBunchesFromBookmarks.running = false; if (mPossibleTabBunchesFromBookmarks.length > 0) tryHandlTabBunchesFromBookmarks(); } } async function detectBookmarkFolderFromTabs(tabs, allNewTabsCount = tabs.length) { log('detectBookmarkFolderFromTabs: ', tabs, allNewTabsCount); return new Promise((resolve, _reject) => { const maybeFromBookmarks = []; let restCount = tabs.length; for (const tab of tabs) { tab.$TST.promisedPossibleOpenerBookmarks.then(async bookmarks => { log(` bookmarks from tab ${tab.id}: `, bookmarks); restCount--; if (bookmarks.length > 0) maybeFromBookmarks.push(tab); const folder = await tryDetectMostTabsContainedBookmarkFolder(maybeFromBookmarks, allNewTabsCount); if (folder) { log('detectBookmarkFolderFromTabs: found folder => ', { folder, tabs }); resolve({ folder, tabs: maybeFromBookmarks, }); } else if (restCount == 0) { resolve(null); } }); } }); } async function tryDetectMostTabsContainedBookmarkFolder(bookmarkedTabs, allNewTabsCount = bookmarkedTabs.length) { log('tryDetectMostTabsContainedBookmarkFolder ', { bookmarkedTabs, allNewTabsCount }); const parentIds = bookmarkedTabs.map(tab => tab.$TST.possibleOpenerBookmarks.map(bookmark => bookmark.parentId)).flat(); const counts = []; const countById = {}; for (const id of parentIds) { if (!(id in countById)) counts.push(countById[id] = { id, count: 0 }); countById[id].count++; } log(' counts: ', counts); if (counts.length == 0) return null; const greatestCountParent = counts.sort((a, b) => b.count - a.count)[0]; const minCount = allNewTabsCount * configs.tabsFromSameFolderMinThresholdPercentage / 100; log(' => ', { greatestCountParent, minCount }); if (greatestCountParent.count <= minCount) return null; const items = await browser.bookmarks.get(greatestCountParent.id); return Array.isArray(items) ? items[0] : items; } // Detect tabs sent from other device with `browser.tabs.insertAfterCurrent`=true based on their index // (Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1596787 ) // See also: https://github.com/piroor/treestyletab/issues/2419 function areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) { if (tabReferences.length == 0) return false; const activeTab = Tab.getActiveTab(tabReferences[0].windowId); if (!activeTab) return false; const activeIndex = activeTab.index; const followingTabsCount = Tab.getTabs(activeTab.windowId).filter(tab => tab.index > activeIndex).length; const createdCount = tabReferences.length; const expectedIndices = [activeIndex + 1]; const actualIndices = tabReferences.map(tabReference => tabReference.indexOnCreated); const overTabsCount = Math.max(0, createdCount - followingTabsCount); const shouldCountDown = Math.min(createdCount - 1, createdCount - Math.floor(overTabsCount / 2)); const shouldCountUp = createdCount - shouldCountDown - 1; for (let i = 0; i < shouldCountUp; i++) { expectedIndices.push(activeIndex + 2 + i + (createdCount - overTabsCount)); } for (let i = shouldCountDown - 1; i > -1; i--) { expectedIndices.push(activeIndex + 1 + i); } const received = actualIndices.join(',') == expectedIndices.join(','); log('areTabsFromOtherDeviceWithInsertAfterCurrent:', received, { overTabsCount, shouldCountUp, shouldCountDown, size: expectedIndices.length, actualIndices, expectedIndices }); return received; }