397 lines
14 KiB
JavaScript
397 lines
14 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';
|
|
|
|
// 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;
|
|
}
|
|
});
|
|
}
|