/* # 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; })(); } }