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

472 lines
16 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,
wait,
configs,
isMacOS,
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from '/common/constants.js';
import * as Permissions from '/common/permissions.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
import * as TSTAPI from '/common/tst-api.js';
import { Tab } from '/common/TreeItem.js';
import Window from '/common/Window.js';
import * as Background from './background.js';
import * as Tree from './tree.js';
function log(...args) {
internalLogger('background/handle-tab-focus', ...args);
}
const PHASE_LOADING = 0;
const PHASE_BACKGROUND_INITIALIZED = 1;
const PHASE_BACKGROUND_BUILT = 2;
const PHASE_BACKGROUND_READY = 3;
let mInitializationPhase = PHASE_LOADING;
Background.onInit.addListener(() => {
mInitializationPhase = PHASE_BACKGROUND_INITIALIZED;
});
Background.onBuilt.addListener(() => {
mInitializationPhase = PHASE_BACKGROUND_BUILT;
});
Background.onReady.addListener(() => {
mInitializationPhase = PHASE_BACKGROUND_READY;
});
let mTabSwitchedByShortcut = false;
let mMaybeTabSwitchingByShortcut = false;
const mLastTabsCountInWindow = new Map();
Window.onInitialized.addListener(win => {
browser.tabs.query({
windowId: win.id,
active: true
})
.then(activeTabs => {
// There may be no active tab on a startup...
if (activeTabs.length > 0 &&
!win.lastActiveTab)
win.lastActiveTab = activeTabs[0].id;
});
});
browser.windows.onRemoved.addListener(windowId => {
mLastTabsCountInWindow.delete(windowId);
});
Tab.onActivating.addListener(async (tab, info = {}) => { // return false if the activation should be canceled
log('Tabs.onActivating ', { tab: dumpTab(tab), info, mMaybeTabSwitchingByShortcut });
if (mMaybeTabSwitchingByShortcut) {
const lastCount = mLastTabsCountInWindow.get(tab.windowId);
const count = Tab.getAllTabs(tab.windowId).length;
if (lastCount != count) {
log('tabs are created or removed: cancel tab switching');
mMaybeTabSwitchingByShortcut = false;
}
mLastTabsCountInWindow.set(tab.windowId, count);
}
if (tab.$TST.temporaryMetadata.has('shouldReloadOnSelect')) {
browser.tabs.reload(tab.id)
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
tab.$TST.temporaryMetadata.delete('shouldReloadOnSelect');
}
const win = TabsStore.windows.get(tab.windowId);
log(' lastActiveTab: ', win.lastActiveTab); // it may be blank on a startup
const lastActiveTab = Tab.get(win.lastActiveTab || info.previousTabId);
cancelDelayedExpand(lastActiveTab);
const shouldSkipCollapsed = (
!info.byInternalOperation &&
mMaybeTabSwitchingByShortcut &&
configs.skipCollapsedTabsForTabSwitchingShortcuts
);
mTabSwitchedByShortcut = mMaybeTabSwitchingByShortcut;
const focusDirection = !lastActiveTab ?
0 :
(!lastActiveTab.$TST.nearestVisiblePrecedingTab &&
!tab.$TST.nearestVisibleFollowingTab) ?
-1 :
(!lastActiveTab.$TST.nearestVisibleFollowingTab &&
!tab.$TST.nearestVisiblePrecedingTab) ?
1 :
(lastActiveTab.index > tab.index) ?
-1 :
1;
const cache = {};
if (tab.$TST.collapsed) {
if (!tab.$TST.parent) {
// This is invalid case, generally never should happen,
// but actually happen on some environment:
// https://github.com/piroor/treestyletab/issues/1717
// So, always expand orphan collapsed tab as a failsafe.
Tree.collapseExpandTab(tab, {
collapsed: false,
broadcast: true
});
await handleNewActiveTab(tab, info);
}
else if (!shouldSkipCollapsed) {
log('=> reaction for focus given from outside of TST');
let allowed = false;
if (configs.unfocusableCollapsedTab) {
log(' => apply unfocusableCollapsedTab');
allowed = await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_COLLAPSED_TAB,
{ tab,
focusDirection },
{ tabProperties: ['tab'], cache }
);
TSTAPI.clearCache(cache);
if (allowed) {
const toBeExpandedAncestors = [tab].concat(tab.$TST.ancestors) ;
for (const ancestor of toBeExpandedAncestors) {
Tree.collapseExpandSubtree(ancestor, {
collapsed: false,
broadcast: true
});
}
}
else {
log(' => canceled by someone.');
}
}
info.allowed = allowed;
await handleNewActiveTab(tab, info);
}
if (shouldSkipCollapsed) {
log('=> reaction for focusing collapsed descendant while Ctrl-Tab/Ctrl-Shift-Tab');
let successor = tab.$TST.nearestVisibleAncestorOrSelf;
if (!successor) // this seems invalid case...
return false;
log('successor = ', successor.id);
if (shouldSkipCollapsed &&
(win.lastActiveTab == successor.id ||
successor.$TST.descendants.some(tab => tab.id == win.lastActiveTab)) &&
focusDirection > 0) {
log('=> redirect successor (focus moved from the successor itself or its descendants)');
successor = successor.$TST.nearestVisibleFollowingTab;
if (successor &&
successor.discarded &&
configs.avoidDiscardedTabToBeActivatedIfPossible)
successor = successor.$TST.nearestLoadedTabInTree ||
successor.$TST.nearestLoadedTab ||
successor;
if (!successor)
successor = Tab.getFirstVisibleTab(tab.windowId);
log('=> ', successor.id);
}
else if (!mTabSwitchedByShortcut && // intentional focus to a discarded tabs by Ctrl-Tab/Ctrl-Shift-Tab is always allowed!
successor.discarded &&
configs.avoidDiscardedTabToBeActivatedIfPossible) {
log('=> redirect successor (successor is discarded)');
successor = successor.$TST.nearestLoadedTabInTree ||
successor.$TST.nearestLoadedTab ||
successor;
log('=> ', successor.id);
}
const allowed = await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_REDIRECT_FOCUS_FROM_COLLAPSED_TAB,
{ tab,
focusDirection },
{ tabProperties: ['tab'], cache }
);
TSTAPI.clearCache(cache);
if (allowed) {
win.lastActiveTab = successor.id;
if (mMaybeTabSwitchingByShortcut)
setupDelayedExpand(successor);
TabsInternalOperation.activateTab(successor, { silently: true });
log('Tabs.onActivating: discarded? ', dumpTab(tab), tab?.discarded);
if (tab.discarded)
tab.$TST.temporaryMetadata.set('discardURLAfterCompletelyLoaded', tab.url);
return false;
}
else {
log(' => canceled by someone.');
}
}
}
else if (info.byActiveTabRemove &&
(!configs.autoCollapseExpandSubtreeOnSelect ||
configs.autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove)) {
log('=> reaction for removing current tab');
win.lastActiveTab = tab.id;
tryHighlightBundledTab(tab, {
...info,
shouldSkipCollapsed
});
return true;
}
else if (tab.$TST.hasChild &&
tab.$TST.subtreeCollapsed &&
!shouldSkipCollapsed) {
log('=> reaction for newly active parent tab');
await handleNewActiveTab(tab, info);
}
tab.$TST.temporaryMetadata.delete('discardOnCompletelyLoaded');
win.lastActiveTab = tab.id;
if (mMaybeTabSwitchingByShortcut)
setupDelayedExpand(tab);
else
tryHighlightBundledTab(tab, {
...info,
shouldSkipCollapsed
});
return true;
});
async function handleNewActiveTab(tab, { allowed, silently } = {}) {
log('handleNewActiveTab: ', dumpTab(tab), { allowed, silently });
const shouldCollapseExpandNow = configs.autoCollapseExpandSubtreeOnSelect;
const canCollapseTree = shouldCollapseExpandNow;
const canExpandTree = shouldCollapseExpandNow && !silently;
if (canExpandTree &&
allowed !== false) {
const cache = {};
const allowed = await TSTAPI.tryOperationAllowed(
tab.active ?
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_PARENT :
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_BUNDLED_PARENT,
{ tab },
{ tabProperties: ['tab'], cache }
);
if (!allowed)
return;
if (canCollapseTree &&
configs.autoExpandIntelligently)
await Tree.collapseExpandTreesIntelligentlyFor(tab, {
broadcast: true
});
else
Tree.collapseExpandSubtree(tab, {
collapsed: false,
broadcast: true
});
}
}
async function tryHighlightBundledTab(tab, { shouldSkipCollapsed, allowed, silently } = {}) {
const bundledTab = tab.$TST.bundledTab;
const oldBundledTabs = TabsStore.bundledActiveTabsInWindow.get(tab.windowId);
log('tryHighlightBundledTab ', {
tab: tab.id,
bundledTab: bundledTab?.id,
oldBundledTabs,
shouldSkipCollapsed,
allowed,
silently,
});
for (const tab of oldBundledTabs.values()) {
if (tab == bundledTab)
continue;
tab.$TST.removeState(Constants.kTAB_STATE_BUNDLED_ACTIVE);
}
if (!bundledTab)
return;
bundledTab.$TST.addState(Constants.kTAB_STATE_BUNDLED_ACTIVE);
await wait(100);
if (!tab.active || // ignore tab already inactivated while waiting
tab.$TST.hasOtherHighlighted || // ignore manual highlighting
bundledTab.pinned ||
!configs.syncActiveStateToBundledTabs)
return;
if (bundledTab.$TST.hasChild &&
bundledTab.$TST.subtreeCollapsed &&
!shouldSkipCollapsed)
await handleNewActiveTab(bundledTab, { allowed, silently });
}
Tab.onUpdated.addListener((tab, changeInfo = {}) => {
if ('url' in changeInfo) {
if (tab.$TST.temporaryMetadata.has('discardURLAfterCompletelyLoaded') &&
tab.$TST.temporaryMetadata.get('discardURLAfterCompletelyLoaded') != changeInfo.url)
tab.$TST.temporaryMetadata.delete('discardURLAfterCompletelyLoaded');
}
});
Tab.onStateChanged.addListener(tab => {
if (!tab ||
tab.status != 'complete')
return;
if (typeof browser.tabs.discard == 'function') {
if (tab.url == tab.$TST.temporaryMetadata.get('discardURLAfterCompletelyLoaded') &&
configs.autoDiscardTabForUnexpectedFocus) {
log('Try to discard accidentally restored tab (on restored) ', dumpTab(tab));
wait(configs.autoDiscardTabForUnexpectedFocusDelay).then(() => {
if (!TabsStore.ensureLivingItem(tab) ||
tab.active)
return;
if (tab.status == 'complete')
browser.tabs.discard(tab.id)
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
else
tab.$TST.temporaryMetadata.set('discardOnCompletelyLoaded', true);
});
}
else if (tab.$TST.temporaryMetadata.has('discardOnCompletelyLoaded') && !tab.active) {
log('Discard accidentally restored tab (on complete) ', dumpTab(tab));
browser.tabs.discard(tab.id)
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}
tab.$TST.temporaryMetadata.delete('discardURLAfterCompletelyLoaded');
tab.$TST.temporaryMetadata.delete('discardOnCompletelyLoaded');
});
async function setupDelayedExpand(tab) {
if (!tab)
return;
cancelDelayedExpand(tab);
TabsStore.removeToBeExpandedTab(tab);
const cache = {};
const [ctrlTabHandlingEnabled, allowedToExpandViaAPI] = await Promise.all([
Permissions.isGranted(Permissions.ALL_URLS),
TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_LONG_PRESS_CTRL_KEY,
{ tab },
{ tabProperties: ['tab'], cache }
),
]);
if (!configs.autoExpandOnTabSwitchingShortcuts ||
!tab.$TST.hasChild ||
!tab.$TST.subtreeCollapsed ||
!ctrlTabHandlingEnabled ||
!allowedToExpandViaAPI)
return;
TabsStore.addToBeExpandedTab(tab);
tab.$TST.temporaryMetadata.set('delayedExpand', setTimeout(async () => {
if (!tab.$TST.temporaryMetadata.has('delayedExpand')) { // already canceled
log('delayed expand is already canceled ', tab.id);
return;
}
log('delayed expand by long-press of ctrl key on ', tab.id);
TabsStore.removeToBeExpandedTab(tab);
await Tree.collapseExpandTreesIntelligentlyFor(tab, {
broadcast: true
});
}, configs.autoExpandOnTabSwitchingShortcutsDelay));
}
function cancelDelayedExpand(tab) {
if (!tab ||
!tab.$TST.temporaryMetadata.has('delayedExpand'))
return;
clearTimeout(tab.$TST.temporaryMetadata.get('delayedExpand'));
tab.$TST.temporaryMetadata.delete('delayedExpand');
TabsStore.removeToBeExpandedTab(tab);
}
function cancelAllDelayedExpand(windowId) {
for (const tab of TabsStore.toBeExpandedTabsInWindow.get(windowId)) {
cancelDelayedExpand(tab);
}
}
Tab.onCollapsedStateChanged.addListener((tab, info = {}) => {
tab.$TST.toggleState(Constants.kTAB_STATE_COLLAPSED_DONE, info.collapsed, { broadcast: false });
});
Background.onReady.addListener(() => {
for (const tab of Tab.getAllTabs(null, { iterator: true })) {
tab.$TST.removeState(Constants.kTAB_STATE_BUNDLED_ACTIVE);
}
for (const tab of Tab.getActiveTabs({ iterator: true })) {
tryHighlightBundledTab(tab);
}
});
browser.windows.onFocusChanged.addListener(() => {
mMaybeTabSwitchingByShortcut = false;
});
browser.runtime.onMessage.addListener(onMessage);
function onMessage(message, sender) {
if (mInitializationPhase < PHASE_BACKGROUND_BUILT ||
!message ||
typeof message.type != 'string')
return;
//log('onMessage: ', message, sender);
switch (message.type) {
case Constants.kNOTIFY_TAB_MOUSEDOWN:
mMaybeTabSwitchingByShortcut =
mTabSwitchedByShortcut = false;
break;
case Constants.kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH: {
if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control')))
return;
log('kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH ', message.modifier);
mMaybeTabSwitchingByShortcut = true;
if (sender.tab?.active) {
const win = TabsStore.windows.get(sender.tab.windowId);
win.lastActiveTab = sender.tab.id;
}
if (sender.tab)
mLastTabsCountInWindow.set(sender.tab.windowId, Tab.getAllTabs(sender.tab.windowId).length);
}; break;
case Constants.kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH:
if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control')))
return;
log('kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH ', message.modifier);
return (async () => {
if (mTabSwitchedByShortcut &&
configs.skipCollapsedTabsForTabSwitchingShortcuts &&
sender.tab) {
await Tab.waitUntilTracked(sender.tab.id);
let tab = Tab.get(sender.tab.id);
if (!tab) {
let tabs = await browser.tabs.query({ currentWindow: true, active: true }).catch(ApiTabs.createErrorHandler());
if (tabs.length == 0)
tabs = await browser.tabs.query({ currentWindow: true }).catch(ApiTabs.createErrorHandler());
await Tab.waitUntilTracked(tabs[0].id);
tab = Tab.get(tabs[0].id);
}
cancelAllDelayedExpand(tab.windowId);
const cache = {};
if (configs.autoCollapseExpandSubtreeOnSelect &&
tab &&
TabsStore.windows.get(tab.windowId).lastActiveTab == tab.id &&
(await TSTAPI.tryOperationAllowed(
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_END_TAB_SWITCH,
{ tab },
{ tabProperties: ['tab'], cache }
))) {
Tree.collapseExpandSubtree(tab, {
collapsed: false,
broadcast: true
});
}
}
mMaybeTabSwitchingByShortcut =
mTabSwitchedByShortcut = false;
})();
}
}