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

550 lines
20 KiB
JavaScript

/*
# 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: `
<div>${sanitizeForHTMLText(browser.i18n.getMessage('warnOnAutoGroupNewTabs_message', [tabs.length]))}</div>${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;
}