/* # 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'; // internal operations means operations bypassing WebExtensions' tabs APIs. import EventListenerManager from '/extlib/EventListenerManager.js'; import { log as internalLogger, dumpTab, mapAndFilter, configs, wait, } from './common.js'; import * as ApiTabs from './api-tabs.js'; import * as Constants from './constants.js'; import * as SidebarConnection from './sidebar-connection.js'; import * as TabsStore from './tabs-store.js'; import * as TabsUpdate from './tabs-update.js'; import { Tab, TreeItem } from '/common/TreeItem.js'; function log(...args) { internalLogger('common/tabs-internal-operation', ...args); } export const onBeforeTabsRemove = new EventListenerManager(); export async function activateTab(tab, { byMouseOperation, keepMultiselection, silently } = {}) { if (!Constants.IS_BACKGROUND) throw new Error('Error: TabsInternalOperation.activateTab is available only on the background page, use a `kCOMMAND_ACTIVATE_TAB` message instead.'); tab = TabsStore.ensureLivingItem(tab); if (!tab) return; log('activateTab: ', dumpTab(tab)); const win = TabsStore.windows.get(tab.windowId); win.internallyFocusingTabs.add(tab.id); if (byMouseOperation) win.internallyFocusingByMouseTabs.add(tab.id); if (silently) win.internallyFocusingSilentlyTabs.add(tab.id); let tabs = [tab]; if (tab.$TST.hasOtherHighlighted && keepMultiselection) { const highlightedTabs = Tab.getHighlightedTabs(tab.windowId); if (highlightedTabs.some(highlightedTab => highlightedTab.id == tab.id)) { // switch active tab with highlighted state tabs = tabs.concat(mapAndFilter(highlightedTabs, highlightedTab => highlightedTab.id != tab.id && highlightedTab || undefined)); } } if (tabs.length == 1) win.tabsToBeHighlightedAlone.add(tab.id); highlightTabs(tabs); } export async function blurTab(bluredTabs, { windowId, silently, keepDiscarded } = {}) { if (bluredTabs && !Array.isArray(bluredTabs)) bluredTabs = [bluredTabs]; const bluredTabIds = new Set(Array.from(bluredTabs || [], tab => tab.id || tab)); // First, try to find successor based on successorTabId from left tabs. let successorTab = Tab.get(bluredTabs.find(tab => tab.active)?.successorTabId); const scannedTabIds = new Set(); while (bluredTabIds.has(successorTab?.id) || (keepDiscarded && successorTab?.discarded)) { if (scannedTabIds.has(successorTab.id)) break; // prevent infinite loop! scannedTabIds.add(successorTab.id); const nextSuccessorTab = (successorTab.successorTabId > 0 && successorTab.successorTabId != successorTab.id) ? Tab.get(successorTab.successorTabId) : null; if (!nextSuccessorTab) break; successorTab = nextSuccessorTab; } log('blurTab/step 1: found successor = ', successorTab?.id); // Second, try to detect successor based on their order. if (!successorTab || bluredTabIds.has(successorTab.id) || (keepDiscarded && successorTab.discarded)) { if (successorTab) log(' => it cannot become the successor, find again'); let bluredTabsFound = false; for (const tab of Tab.getVisibleTabs(windowId || bluredTabs[0].windowId)) { if (keepDiscarded && tab.discarded) { continue; } const blured = bluredTabIds.has(tab.id); if (blured) bluredTabsFound = true; if (!bluredTabsFound) successorTab = tab; if (bluredTabsFound && !blured) { successorTab = tab; break; } } log('blurTab/step 2: found successor = ', successorTab?.id); } if (successorTab) await activateTab(successorTab, { silently }); return successorTab; } export function removeTab(tab) { return removeTabs([tab]); } export async function removeTabs(tabs, { keepDescendants, byMouseOperation, originalStructure, triggerTab } = {}) { if (!Constants.IS_BACKGROUND) throw new Error('TabsInternalOperation.removeTabs is available only on the background page, use a `kCOMMAND_REMOVE_TABS_INTERNALLY` message instead.'); log('TabsInternalOperation.removeTabs: ', () => tabs.map(dumpTab)); if (tabs.length == 0) return; await onBeforeTabsRemove.dispatch(tabs); const win = TabsStore.windows.get(tabs[0].windowId); const tabIds = []; let willChangeFocus = false; tabs = tabs.filter(tab => { if ((!win || !win.internalClosingTabs.has(tab.id)) && TabsStore.ensureLivingItem(tab)) { tabIds.push(tab.id); if (tab.active) willChangeFocus = true; return true; } return false; }); log(' => ', () => tabs.map(dumpTab)); if (!tabs.length) return; if (win) { // Flag tabs to be closed at a time. With this flag TST skips some // operations on tab close (for example, opening a group tab to replace // a closed parent tab to keep the tree structure). for (const tab of tabs) { win.internalClosingTabs.add(tab.id); tab.$TST.addState(Constants.kTAB_STATE_TO_BE_REMOVED); clearCache(tab); if (keepDescendants) win.keepDescendantsTabs.add(tab.id); if (willChangeFocus && byMouseOperation) { win.internallyFocusingByMouseTabs.add(tab.id); setTimeout(() => { // the operation can be canceled win.internallyFocusingByMouseTabs.delete(tab.id); }, 250); } } } const sortedTabs = TreeItem.sort(Array.from(tabs)); Tab.onMultipleTabsRemoving.dispatch(sortedTabs, { triggerTab, originalStructure }); const promisedRemoved = browser.tabs.remove(tabIds).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); if (win) { promisedRemoved.then(() => { // "beforeunload" listeners in tabs blocks the operation and the // returned promise is resolved after all "beforeunload" listeners // are processed and "browser.tabs.onRemoved()" listeners are // processed for really closed tabs. // In other words, there may be some "canceled tab close"s and // we need to clear "to-be-closed" flags for such tabs. // See also: https://github.com/piroor/treestyletab/issues/2384 const canceledTabs = new Set(tabs.filter(tab => tab.$TST && !tab.$TST.destroyed)); log(`${canceledTabs.size} tabs may be canceled to close.`); if (canceledTabs.size == 0) { Tab.onMultipleTabsRemoved.dispatch(sortedTabs, { triggerTab, originalStructure }); return; } log(`Clearing "to-be-removed" flag for requested ${tabs.length} tabs...`); for (const tab of canceledTabs) { tab.$TST.removeState(Constants.kTAB_STATE_TO_BE_REMOVED); win.internalClosingTabs.delete(tab.id); if (keepDescendants) win.keepDescendantsTabs.delete(tab.id); } Tab.onMultipleTabsRemoved.dispatch(sortedTabs.filter(tab => !canceledTabs.has(tab)), { triggerTab, originalStructure }); }); } return promisedRemoved; } export function setTabActive(tab) { const oldActiveTabs = clearOldActiveStateInWindow(tab.windowId, tab); tab.active = true; tab.$TST.addState(Constants.kTAB_STATE_ACTIVE); tab.$TST.removeState(Constants.kTAB_STATE_NOT_ACTIVATED_SINCE_LOAD); tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true }); tab.$TST.removeState(Constants.kTAB_STATE_PENDING, { broadcast: Constants.IS_BACKGROUND }); TabsStore.activeTabsInWindow.get(tab.windowId).add(tab); TabsStore.activeTabInWindow.set(tab.windowId, tab); Tab.onActivated.dispatch(tab); return oldActiveTabs; } export function clearOldActiveStateInWindow(windowId, exception) { const oldTabs = TabsStore.activeTabsInWindow.get(windowId); for (const oldTab of oldTabs) { if (oldTab.id == exception?.id) continue; oldTab.$TST.removeState(Constants.kTAB_STATE_ACTIVE); oldTab.active = false; oldTabs.delete(oldTab); Tab.onUnactivated.dispatch(oldTab); } return Array.from(oldTabs); } export function clearCache(tab) { if (!tab) return; const errorHandler = ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError); for (const key of Constants.kCACHE_KEYS) { browser.sessions.removeTabValue(tab.id, key).catch(errorHandler); } } // Note: this treats the first specified tab as active. export async function highlightTabs(tabs, { inheritToCollapsedDescendants } = {}) { if (!Constants.IS_BACKGROUND) throw new Error('TabsInternalOperation.highlightTabs is available only on the background page, use a `kCOMMAND_HIGHLIGHT_TABS` message instead.'); if (!tabs || tabs.length == 0) throw new Error('TabsInternalOperation.highlightTabs requires one or more tabs.'); const highlightedTabs = Tab.getHighlightedTabs(tabs[0].windowId); if (tabs.map(tab => tab.id).join('\n') == highlightedTabs.map(tab => tab.id).join('\n')) { log('highlightTabs: already highlighted'); return; } log('setting tabs highlighted ', tabs, { inheritToCollapsedDescendants }); const startAtTimestamp = Date.now(); const startAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; highlightTabs.lastStartedAt = startAt; const windowId = tabs[0].windowId; const win = TabsStore.windows.get(windowId); win.highlightingTabs.clear(); win.tabsMovedWhileHighlighting = false; const tabIds = tabs.map(tab => { win.highlightingTabs.add(tab.id); return tab.id; }); const toBeHighlightedTabIds = new Set([...win.highlightingTabs]); TabsUpdate.updateTabsHighlighted({ windowId, tabIds, inheritToCollapsedDescendants, }); SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED, windowId, tabIds, }); // for better performance, we should not call browser.tabs.update() for each tab. const highlightedTabIds = new Set(tabIds); const activeTab = Tab.getActiveTab(windowId); const indices = mapAndFilter(highlightedTabIds, id => id == activeTab.id ? undefined : Tab.get(id).index); if (highlightedTabIds.has(activeTab.id)) indices.unshift(activeTab.index); // highlight tabs progressively, because massinve change at once may block updating of highlighted appearance of tabs. let count = 1; // 1 is for setActive() while (highlightTabs.lastStartedAt == startAt) { count += (configs.provressiveHighlightingStep <= 0 ? Number.MAX_SAFE_INTEGER : configs.provressiveHighlightingStep); await browser.tabs.highlight({ windowId, populate: false, tabs: indices.slice(0, count), }).catch(ApiTabs.createErrorSuppressor()); const progress = Math.ceil(Math.min(indices.length, count) / indices.length * 100); log(`highlightTabs: ${progress} %`); await wait(configs.progressievHighlightingInterval); if (win.tabsMovedWhileHighlighting) { log('highlightTabs: tabs are moved while highlighting, retry'); await wait(250); return highlightTabs(tabs, { inheritToCollapsedDescendants }); } if (win.highlightingTabs.size < toBeHighlightedTabIds.size) { log('highlightTabs: someone cleared multiselection while in-progress ', toBeHighlightedTabIds.size, win.highlightingTabs.size); break; } const unifiedHighlightTabIds = new Set([...toBeHighlightedTabIds, ...win.highlightingTabs]); if (unifiedHighlightTabIds.size != toBeHighlightedTabIds.size) { log('highlightTabs: someone tried multiselection again while in-progress ', toBeHighlightedTabIds.size, win.highlightingTabs.size); break; } if (count >= indices.length) break; SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_IN_PROGRESS, windowId, progress, }); } SidebarConnection.sendMessage({ type: Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_COMPLETE, windowId, }); log('highlightTabs done. ', Date.now() - startAtTimestamp, ' msec'); } SidebarConnection.onMessage.addListener(async (windowId, message) => { switch (message.type) { case Constants.kCOMMAND_ACTIVATE_TAB: { await Tab.waitUntilTracked(message.tabId); const tab = Tab.get(message.tabId); if (!tab) return; activateTab(tab, { byMouseOperation: message.byMouseOperation, keepMultiselection: message.keepMultiselection, silently: message.silently }); }; break; case Constants.kCOMMAND_HIGHLIGHT_TABS: { await Tab.waitUntilTracked(message.tabIds); highlightTabs(message.tabIds.map(id => Tab.get(id)), { inheritToCollapsedDescendants: message.inheritToCollapsedDescendants, }); }; break; case Constants.kCOMMAND_REMOVE_TABS_INTERNALLY: await Tab.waitUntilTracked(message.tabIds); removeTabs(message.tabIds.map(id => Tab.get(id)), { byMouseOperation: message.byMouseOperation, keepDescendants: message.keepDescendants }); break; case Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION: await Tab.waitUntilTracked(message.tabIds); removeTabs(message.tabIds.map(id => Tab.get(id)), { byMouseOperation: true, keepDescendants: message.keepDescendants }); break; } }); if (Constants.IS_BACKGROUND) { browser.runtime.onMessage.addListener((message, _sender) => { switch (message.type) { // for operations from group-tab.html case Constants.kCOMMAND_REMOVE_TABS_INTERNALLY: Tab.waitUntilTracked(message.tabIds).then(() => { removeTabs(message.tabIds.map(id => Tab.get(id)), { byMouseOperation: message.byMouseOperation, keepDescendants: message.keepDescendants, }); }); break; // for automated tests case Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION: Tab.waitUntilTracked(message.tabIds).then(() => { removeTabs(message.tabIds.map(id => Tab.get(id)), { byMouseOperation: true }); }); break; } }); }