2252 lines
74 KiB
JavaScript
2252 lines
74 KiB
JavaScript
/* ***** BEGIN LICENSE BLOCK *****
|
|
* Version: MPL 1.1
|
|
*
|
|
* The contents of this file are subject to the Mozilla Public License Version
|
|
* 1.1 (the "License"); you may not use this file except in compliance with
|
|
* the License. You may obtain a copy of the License at
|
|
* http://www.mozilla.org/MPL/
|
|
*
|
|
* Software distributed under the License is distributed on an "AS IS" basis,
|
|
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
|
* for the specific language governing rights and limitations under the
|
|
* License.
|
|
*
|
|
* The Original Code is the Tree Style Tab.
|
|
*
|
|
* The Initial Developer of the Original Code is YUKI "Piro" Hiroshi.
|
|
* Portions created by the Initial Developer are Copyright (C) 2011-2025
|
|
* the Initial Developer. All Rights Reserved.
|
|
*
|
|
* Contributor(s): YUKI "Piro" Hiroshi <piro.outsider.reflex@gmail.com>
|
|
* wanabe <https://github.com/wanabe>
|
|
* Tetsuharu OHZEKI <https://github.com/saneyuki>
|
|
* Xidorn Quan <https://github.com/upsuper> (Firefox 40+ support)
|
|
* lv7777 (https://github.com/lv7777)
|
|
*
|
|
* ***** END LICENSE BLOCK ******/
|
|
'use strict';
|
|
|
|
import EventListenerManager from '/extlib/EventListenerManager.js';
|
|
|
|
import {
|
|
log as internalLogger,
|
|
wait,
|
|
dumpTab,
|
|
mapAndFilter,
|
|
configs,
|
|
shouldApplyAnimation,
|
|
getWindowParamsFromSource,
|
|
isFirefoxViewTab,
|
|
} from '/common/common.js';
|
|
import * as ApiTabs from '/common/api-tabs.js';
|
|
import * as Constants from '/common/constants.js';
|
|
import * as SidebarConnection from '/common/sidebar-connection.js';
|
|
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
|
|
import * as TabsStore from '/common/tabs-store.js';
|
|
import * as TreeBehavior from '/common/tree-behavior.js';
|
|
import * as TSTAPI from '/common/tst-api.js';
|
|
import * as UserOperationBlocker from '/common/user-operation-blocker.js';
|
|
|
|
import MetricsData from '/common/MetricsData.js';
|
|
import { Tab, TreeItem } from '/common/TreeItem.js';
|
|
import Window from '/common/Window.js';
|
|
|
|
import * as TabsMove from './tabs-move.js';
|
|
|
|
function log(...args) {
|
|
internalLogger('background/tree', ...args);
|
|
}
|
|
function logCollapseExpand(...args) {
|
|
internalLogger('sidebar/collapse-expand', ...args);
|
|
}
|
|
|
|
|
|
export const onAttached = new EventListenerManager();
|
|
export const onDetached = new EventListenerManager();
|
|
export const onSubtreeCollapsedStateChanging = new EventListenerManager();
|
|
export const onSubtreeCollapsedStateChanged = new EventListenerManager();
|
|
|
|
|
|
const mUnattachableTabIds = new Set();
|
|
|
|
export function markTabIdAsUnattachable(id) {
|
|
mUnattachableTabIds.add(id);
|
|
}
|
|
|
|
export function clearUnattachableTabId(id) {
|
|
mUnattachableTabIds.delete(id);
|
|
}
|
|
|
|
function isTabIdUnattachable(id) {
|
|
return mUnattachableTabIds.has(id);
|
|
}
|
|
|
|
|
|
// return moved (or not)
|
|
export async function attachTabTo(child, parent, options = {}) {
|
|
parent = TabsStore.ensureLivingItem(parent);
|
|
child = TabsStore.ensureLivingItem(child);
|
|
if (!parent || !child) {
|
|
log('missing information: ', { parent, child });
|
|
return false;
|
|
}
|
|
|
|
if (isFirefoxViewTab(parent)) {
|
|
log('Firefox View tab could not be a parent of other tabs');
|
|
return false;
|
|
}
|
|
|
|
log('attachTabTo: ', {
|
|
child: child.id,
|
|
parent: parent.id,
|
|
children: parent.$TST.getAttribute(Constants.kCHILDREN),
|
|
insertAt: options.insertAt,
|
|
insertBefore: options.insertBefore?.id,
|
|
insertAfter: options.insertAfter?.id,
|
|
lastRelatedTab: options.lastRelatedTab?.id,
|
|
dontMove: options.dontMove,
|
|
dontUpdateIndent: options.dontUpdateIndent,
|
|
forceExpand: options.forceExpand,
|
|
dontExpand: options.dontExpand,
|
|
delayedMove: options.delayedMove,
|
|
dontSyncParentToOpenerTab: options.dontSyncParentToOpenerTab,
|
|
broadcast: options.broadcast,
|
|
broadcasted: options.broadcasted,
|
|
stack: `${configs.debug && new Error().stack}\n${options.stack || ''}`
|
|
});
|
|
|
|
if (isTabIdUnattachable(child.id)) {
|
|
log('=> do not attach an unattachable tab to another (maybe already removed)');
|
|
return false;
|
|
}
|
|
if (isTabIdUnattachable(parent.id)) {
|
|
log('=> do not attach to an unattachable tab (maybe already removed)');
|
|
return false;
|
|
}
|
|
|
|
if (parent.pinned || child.pinned) {
|
|
log('=> pinned tabs cannot be attached');
|
|
return false;
|
|
}
|
|
if (parent.windowId != child.windowId) {
|
|
log('=> could not attach tab to a parent in different window');
|
|
return false;
|
|
}
|
|
const ancestors = [parent].concat(parent.$TST.ancestors);
|
|
if (ancestors.includes(child)) {
|
|
log('=> canceled for recursive request');
|
|
return false;
|
|
}
|
|
|
|
if (options.dontMove) {
|
|
log('=> do not move');
|
|
options.insertBefore = child.$TST.nextTab;
|
|
if (!options.insertBefore)
|
|
options.insertAfter = child.$TST.previousTab;
|
|
}
|
|
|
|
if (!options.insertBefore && !options.insertAfter) {
|
|
const refTabs = getReferenceTabsForNewChild(child, parent, options);
|
|
options.insertBefore = refTabs.insertBefore;
|
|
options.insertAfter = refTabs.insertAfter;
|
|
log('=> calculate reference tabs ', refTabs);
|
|
}
|
|
options.insertAfter = options.insertAfter || parent;
|
|
log(`reference tabs for ${child.id}: `, {
|
|
insertBefore: options.insertBefore,
|
|
insertAfter: options.insertAfter
|
|
});
|
|
|
|
if (!options.synchronously)
|
|
await Tab.waitUntilTrackedAll(child.windowId);
|
|
|
|
parent = TabsStore.ensureLivingItem(parent);
|
|
child = TabsStore.ensureLivingItem(child);
|
|
if (!parent || !child) {
|
|
log('attachTabTo: parent or child is closed before attaching.');
|
|
return false;
|
|
}
|
|
if (isTabIdUnattachable(child.id) || isTabIdUnattachable(parent.id)) {
|
|
log('attachTabTo: parent or child is marked as unattachable (maybe already removed)');
|
|
return false;
|
|
}
|
|
|
|
parent.$TST.invalidateCache();
|
|
child.$TST.invalidateCache();
|
|
|
|
const newIndex = Tab.calculateNewTabIndex({
|
|
insertBefore: options.insertBefore,
|
|
insertAfter: options.insertAfter,
|
|
ignoreTabs: [child]
|
|
});
|
|
const moved = newIndex != child.index;
|
|
log(`newIndex for ${child.id}: `, newIndex);
|
|
|
|
const newlyAttached = (
|
|
!parent.$TST.childIds.includes(child.id) ||
|
|
child.$TST.parentId != parent.id
|
|
);
|
|
if (!newlyAttached)
|
|
log('=> already attached');
|
|
|
|
if (newlyAttached) {
|
|
detachTab(child, {
|
|
...options,
|
|
// Don't broadcast this detach operation, because this "attachTabTo" can be
|
|
// broadcasted. If we broadcast this detach operation, the tab is detached
|
|
// twice in the sidebar!
|
|
broadcast: false
|
|
});
|
|
|
|
log('attachTabTo: setting child information to ', parent.id);
|
|
// we need to set its children via the "children" setter, to invalidate cached information.
|
|
parent.$TST.children = parent.$TST.childIds.concat([child.id]);
|
|
|
|
// We don't need to update its parent information, because the parent's
|
|
// "children" setter updates the child itself automatically.
|
|
|
|
const parentLevel = parseInt(parent.$TST.getAttribute(Constants.kLEVEL) || 0);
|
|
if (!options.dontUpdateIndent)
|
|
updateTabsIndent(child, parentLevel + 1, { justNow: options.synchronously });
|
|
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED,
|
|
windowId: parent.windowId,
|
|
tabId: parent.id,
|
|
childIds: parent.$TST.childIds,
|
|
addedChildIds: [child.id],
|
|
removedChildIds: [],
|
|
newlyAttached
|
|
});
|
|
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_ATTACHED)) {
|
|
const cache = {};
|
|
TSTAPI.broadcastMessage({
|
|
type: TSTAPI.kNOTIFY_TREE_ATTACHED,
|
|
tab: child,
|
|
parent,
|
|
}, { tabProperties: ['tab', 'parent'], cache }).catch(_error => {});
|
|
TSTAPI.clearCache(cache);
|
|
}
|
|
}
|
|
|
|
if (child.openerTabId != parent.id &&
|
|
!options.dontSyncParentToOpenerTab &&
|
|
configs.syncParentTabAndOpenerTab) {
|
|
log(`openerTabId of ${child.id} is changed by TST!: ${child.openerTabId} (original) => ${parent.id} (changed by TST)`, new Error().stack);
|
|
child.openerTabId = parent.id;
|
|
child.$TST.updatingOpenerTabIds.push(parent.id);
|
|
browser.tabs.update(child.id, { openerTabId: parent.id })
|
|
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
|
|
wait(200).then(() => {
|
|
const index = child.$TST.updatingOpenerTabIds.findIndex(id => id == parent.id);
|
|
child.$TST.updatingOpenerTabIds.splice(index, 1);
|
|
});
|
|
}
|
|
|
|
if (newlyAttached)
|
|
await collapseExpandForAttachedTab(child, parent, options);
|
|
|
|
if (!options.dontMove) {
|
|
let nextTab = options.insertBefore;
|
|
let prevTab = options.insertAfter;
|
|
if (!nextTab && !prevTab) {
|
|
nextTab = Tab.getTabAt(child.windowId, newIndex);
|
|
if (!nextTab)
|
|
prevTab = Tab.getTabAt(child.windowId, newIndex - 1);
|
|
}
|
|
log('move newly attached child: ', dumpTab(child), {
|
|
next: dumpTab(nextTab),
|
|
prev: dumpTab(prevTab)
|
|
});
|
|
if (!nextTab ||
|
|
// We should not use a descendant of the "child" tab as the reference tab
|
|
// when we are going to attach the "child" and its descendants to the new
|
|
// parent.
|
|
// See also: https://github.com/piroor/treestyletab/issues/2892#issuecomment-862424942
|
|
nextTab.$TST.parent == child) {
|
|
await moveTabSubtreeAfter(child, prevTab, {
|
|
...options,
|
|
broadcast: true
|
|
});
|
|
}
|
|
else {
|
|
await moveTabSubtreeBefore(child, nextTab, {
|
|
...options,
|
|
broadcast: true
|
|
});
|
|
}
|
|
}
|
|
|
|
child.$TST.opened.then(() => {
|
|
if (!TabsStore.ensureLivingItem(child) || // not removed while waiting
|
|
child.$TST.parent != parent) // not detached while waiting
|
|
return;
|
|
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_COMPLETELY,
|
|
windowId: child.windowId,
|
|
childId: child.id,
|
|
parentId: parent.id,
|
|
newlyAttached
|
|
});
|
|
});
|
|
|
|
onAttached.dispatch(child, {
|
|
...options,
|
|
parent,
|
|
insertBefore: options.insertBefore,
|
|
insertAfter: options.insertAfter,
|
|
newIndex, newlyAttached
|
|
});
|
|
|
|
return !options.dontMove && moved;
|
|
}
|
|
|
|
async function collapseExpandForAttachedTab(tab, parent, options = {}) {
|
|
// Because the tab is possibly closing for "reopen" operation,
|
|
// we need to apply "forceExpand" immediately. Otherwise, when
|
|
// the tab is closed with "subtree collapsed" state, descendant
|
|
// tabs are also closed even if "forceExpand" is "true".
|
|
log('collapseExpandForAttachedTab: newly attached tab ', { tab, parent, options });
|
|
if (parent.$TST.subtreeCollapsed &&
|
|
!options.forceExpand) {
|
|
log(' the tree is collapsed, but keep collapsed by forceExpand option');
|
|
collapseExpandTabAndSubtree(tab, {
|
|
collapsed: true,
|
|
justNow: true,
|
|
broadcast: true
|
|
});
|
|
}
|
|
|
|
const isNewTreeCreatedManually = !options.justNow && parent.$TST.childIds.length == 1;
|
|
let parentTreeCollasped = parent.$TST.subtreeCollapsed;
|
|
let parentCollasped = parent.$TST.collapsed;
|
|
|
|
const cache = {};
|
|
const allowed = (options.forceExpand || !options.dontExpand) && await TSTAPI.tryOperationAllowed(
|
|
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD,
|
|
{ tab: parent,
|
|
child: tab },
|
|
{ tabProperties: ['tab', 'child'], cache }
|
|
);
|
|
TSTAPI.clearCache(cache);
|
|
if (!TabsStore.ensureLivingItem(tab)) {
|
|
log(' not living tab, do nothing');
|
|
return;
|
|
}
|
|
|
|
if (options.forceExpand && allowed) {
|
|
log(` expand tab ${tab.id} by forceExpand option`);
|
|
if (parentTreeCollasped)
|
|
collapseExpandSubtree(parent, {
|
|
...options,
|
|
collapsed: false,
|
|
broadcast: true
|
|
});
|
|
else
|
|
collapseExpandTabAndSubtree(tab, {
|
|
...options,
|
|
collapsed: false,
|
|
broadcast: true
|
|
});
|
|
parentTreeCollasped = false;
|
|
}
|
|
else {
|
|
log(' not forceExpanded');
|
|
}
|
|
|
|
if (!options.dontExpand) {
|
|
if (allowed) {
|
|
if (configs.autoCollapseExpandSubtreeOnAttach &&
|
|
(isNewTreeCreatedManually ||
|
|
parent.$TST.isAutoExpandable)) {
|
|
log(' collapse others by collapseExpandTreesIntelligentlyFor');
|
|
await collapseExpandTreesIntelligentlyFor(parent, {
|
|
broadcast: true
|
|
});
|
|
}
|
|
if (configs.autoCollapseExpandSubtreeOnSelect ||
|
|
isNewTreeCreatedManually ||
|
|
parent.$TST.isAutoExpandable ||
|
|
options.forceExpand) {
|
|
log(' expand ancestor tabs');
|
|
parentTreeCollasped = false;
|
|
parentCollasped = false;
|
|
await Promise.all([parent].concat(parent.$TST.ancestors).map(async ancestor => {
|
|
if (!ancestor.$TST.subtreeCollapsed)
|
|
return;
|
|
const allowed = await TSTAPI.tryOperationAllowed(
|
|
TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD,
|
|
{ tab: ancestor,
|
|
child: tab },
|
|
{ tabProperties: ['tab', 'child'], cache }
|
|
);
|
|
TSTAPI.clearCache(cache);
|
|
if (!allowed) {
|
|
parentTreeCollasped = true;
|
|
parentCollasped = true;
|
|
return;
|
|
}
|
|
if (!TabsStore.ensureLivingItem(tab))
|
|
return;
|
|
collapseExpandSubtree(ancestor, {
|
|
...options,
|
|
collapsed: false,
|
|
broadcast: true
|
|
});
|
|
parentTreeCollasped = false;
|
|
}));
|
|
if (!TabsStore.ensureLivingItem(tab))
|
|
return;
|
|
}
|
|
if (!parent.$TST.subtreeCollapsed &&
|
|
tab.$TST.collapsed) {
|
|
log(' moved from collapsed tree to expanded tree');
|
|
collapseExpandTabAndSubtree(tab, {
|
|
...options,
|
|
collapsed: false,
|
|
broadcast: true,
|
|
});
|
|
}
|
|
}
|
|
else {
|
|
log(' not allowed to expand');
|
|
}
|
|
}
|
|
else if (parent.$TST.isAutoExpandable ||
|
|
parent.$TST.collapsed) {
|
|
log(' collapse auto expanded tree');
|
|
collapseExpandTabAndSubtree(tab, {
|
|
...options,
|
|
collapsed: true,
|
|
broadcast: true
|
|
});
|
|
}
|
|
else {
|
|
log(' nothing to do');
|
|
}
|
|
if (parentTreeCollasped || parentCollasped) {
|
|
log(' collapse tab because the parent is collapsed');
|
|
collapseExpandTabAndSubtree(tab, {
|
|
...options,
|
|
collapsed: true,
|
|
forceExpand: false,
|
|
broadcast: true
|
|
});
|
|
}
|
|
}
|
|
|
|
export function getReferenceTabsForNewChild(child, parent, { insertAt, ignoreTabs, lastRelatedTab, children, descendants } = {}) {
|
|
log('getReferenceTabsForNewChild ', { child, parent, insertAt, ignoreTabs, lastRelatedTab, children, descendants });
|
|
if (typeof insertAt !== 'number')
|
|
insertAt = configs.insertNewChildAt;
|
|
log(' insertAt = ', insertAt);
|
|
if (parent && !descendants)
|
|
descendants = parent.$TST.descendants;
|
|
if (ignoreTabs)
|
|
descendants = descendants.filter(tab => !ignoreTabs.includes(tab));
|
|
log(' descendants = ', descendants);
|
|
let insertBefore, insertAfter;
|
|
if (descendants.length > 0) {
|
|
const firstChild = descendants[0];
|
|
const lastDescendant = descendants[descendants.length - 1];
|
|
switch (insertAt) {
|
|
case Constants.kINSERT_END:
|
|
default:
|
|
insertAfter = lastDescendant;
|
|
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_END)`);
|
|
break;
|
|
case Constants.kINSERT_TOP:
|
|
insertBefore = firstChild;
|
|
log(` insert ${child?.id} before firstChild ${insertBefore?.id} (insertAt=kINSERT_TOP)`);
|
|
break;
|
|
case Constants.kINSERT_NEAREST: {
|
|
const allTabs = Tab.getOtherTabs((child || parent).windowId, ignoreTabs);
|
|
const index = child ? allTabs.indexOf(child) : -1;
|
|
log(' insertAt=kINSERT_NEAREST ', { allTabs, index });
|
|
if (index < allTabs.indexOf(firstChild)) {
|
|
insertBefore = firstChild;
|
|
insertAfter = parent;
|
|
log(` insert ${child?.id} between parent ${insertAfter?.id} and firstChild ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`);
|
|
}
|
|
else if (index > allTabs.indexOf(lastDescendant)) {
|
|
insertAfter = lastDescendant;
|
|
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`);
|
|
}
|
|
else { // inside the tree
|
|
if (parent && !children)
|
|
children = parent.$TST.children;
|
|
if (ignoreTabs)
|
|
children = children.filter(tab => !ignoreTabs.includes(tab));
|
|
for (const child of children) {
|
|
if (index > allTabs.indexOf(child))
|
|
continue;
|
|
insertBefore = child;
|
|
log(` insert ${child?.id} before nearest following child ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`);
|
|
break;
|
|
}
|
|
if (!insertBefore) {
|
|
insertAfter = lastDescendant;
|
|
log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`);
|
|
}
|
|
}
|
|
}; break;
|
|
case Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB: {
|
|
// Simulates Firefox's default behavior with `browser.tabs.insertRelatedAfterCurrent`=`true`.
|
|
// The result will become same to kINSERT_NO_CONTROL case,
|
|
// but this is necessary for environments with disabled the preference.
|
|
if ((lastRelatedTab === undefined) && parent)
|
|
lastRelatedTab = child && parent.$TST.lastRelatedTabId == child.id ? parent.$TST.previousLastRelatedTab : parent.$TST.lastRelatedTab; // it could be updated already...
|
|
if (lastRelatedTab) {
|
|
insertAfter = lastRelatedTab.$TST.lastDescendant || lastRelatedTab;
|
|
log(` insert ${child?.id} after lastRelatedTab ${lastRelatedTab.id} (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`);
|
|
}
|
|
else {
|
|
insertBefore = firstChild;
|
|
log(` insert ${child?.id} before firstChild (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`);
|
|
}
|
|
}; break;
|
|
case Constants.kINSERT_NO_CONTROL:
|
|
break;
|
|
}
|
|
}
|
|
else {
|
|
insertAfter = parent;
|
|
log(` insert ${child?.id} after parent`);
|
|
}
|
|
if (insertBefore == child) {
|
|
// Return unsafe tab, to avoid placing the child after hidden tabs
|
|
// (too far from the place it should be.)
|
|
insertBefore = insertBefore?.$TST.unsafeNextTab;
|
|
log(` => insert ${child?.id} before next tab ${insertBefore?.id} of the child tab itelf`);
|
|
}
|
|
if (insertAfter == child) {
|
|
insertAfter = insertAfter?.$TST.previousTab;
|
|
log(` => insert ${child?.id} after previous tab ${insertAfter?.id} of the child tab itelf`);
|
|
}
|
|
// disallow to place tab in invalid position
|
|
if (insertBefore) {
|
|
if (parent && insertBefore.index <= parent.index) {
|
|
insertBefore = null;
|
|
log(` => do not put ${child?.id} before a tab preceding to the parent`);
|
|
}
|
|
//TODO: we need to reject more cases...
|
|
}
|
|
if (insertAfter) {
|
|
const allTabsInTree = [...descendants];
|
|
if (parent)
|
|
allTabsInTree.unshift(parent);
|
|
const lastMember = allTabsInTree[allTabsInTree.length - 1];
|
|
if (lastMember != insertAfter &&
|
|
insertAfter.index >= lastMember.index) {
|
|
insertAfter = lastMember;
|
|
log(` => do not put ${child?.id} after the last tab ${insertAfter?.id} in the tree`);
|
|
}
|
|
//TODO: we need to reject more cases...
|
|
}
|
|
return { insertBefore, insertAfter };
|
|
}
|
|
|
|
export function getReferenceTabsForNewNextSibling(base, options = {}) {
|
|
log('getReferenceTabsForNewNextSibling ', base);
|
|
let insertBefore = base.$TST.nextSiblingTab;
|
|
if (insertBefore?.pinned &&
|
|
!options.pinned) {
|
|
insertBefore = Tab.getFirstNormalTab(base.windowId);
|
|
}
|
|
let insertAfter = base.$TST.lastDescendant || base;
|
|
if (insertAfter &&
|
|
!insertAfter.pinned &&
|
|
options.pinned) {
|
|
insertAfter = Tab.getLastPinnedTab(base.windowId);
|
|
}
|
|
return { insertBefore, insertAfter };
|
|
}
|
|
|
|
export function detachTab(child, options = {}) {
|
|
log('detachTab: ', child.id, options,
|
|
{ stack: `${configs.debug && new Error().stack}\n${options.stack || ''}` });
|
|
// the "parent" option is used for removing child.
|
|
const parent = TabsStore.ensureLivingItem(options.parent) || child.$TST.parent;
|
|
|
|
if (parent) {
|
|
// we need to set children and parent via setters, to invalidate cached information.
|
|
parent.$TST.children = parent.$TST.childIds.filter(id => id != child.id);
|
|
parent.$TST.invalidateCache();
|
|
log('detachTab: children information is updated ', parent.id, parent.$TST.childIds);
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED,
|
|
windowId: parent.windowId,
|
|
tabId: parent.id,
|
|
childIds: parent.$TST.childIds,
|
|
addedChildIds: [],
|
|
removedChildIds: [child.id],
|
|
detached: true
|
|
});
|
|
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_DETACHED)) {
|
|
const cache = {};
|
|
TSTAPI.broadcastMessage({
|
|
type: TSTAPI.kNOTIFY_TREE_DETACHED,
|
|
tab: child,
|
|
oldParent: parent,
|
|
}, { tabProperties: ['tab', 'oldParent'], cache }).catch(_error => {});
|
|
TSTAPI.clearCache(cache);
|
|
}
|
|
// We don't need to clear its parent information, because the old parent's
|
|
// "children" setter removes the parent ifself from the detached child
|
|
// automatically.
|
|
}
|
|
else {
|
|
log(` => parent(${child.$TST.parentId}) is already removed, or orphan tab`);
|
|
// This can happen when the parent tab was detached via the native tab bar
|
|
// or Firefox's built-in command to detach tab from window.
|
|
}
|
|
|
|
if (!options.toBeRemoved && !options.toBeDetached)
|
|
updateTabsIndent(child);
|
|
|
|
if (child.openerTabId &&
|
|
!options.dontSyncParentToOpenerTab &&
|
|
configs.syncParentTabAndOpenerTab) {
|
|
log(`openerTabId of ${child.id} is cleared by TST!: ${child.openerTabId} (original)`, configs.debug && new Error().stack);
|
|
child.openerTabId = child.id;
|
|
browser.tabs.update(child.id, { openerTabId: child.id }) // set self id instead of null, because it requires any valid tab id...
|
|
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
|
|
}
|
|
child.$TST.invalidateCache();
|
|
|
|
onDetached.dispatch(child, {
|
|
oldParentTab: parent,
|
|
toBeRemoved: !!options.toBeRemoved,
|
|
toBeDetached: !!options.toBeDetached
|
|
});
|
|
}
|
|
|
|
export function getWholeTree(rootTabs) {
|
|
if (!Array.isArray(rootTabs))
|
|
rootTabs = [rootTabs];
|
|
const wholeTree = [...rootTabs];
|
|
for (const rootTab of rootTabs) {
|
|
wholeTree.push(...rootTab.$TST.descendants);
|
|
}
|
|
return TreeItem.sort([...new Set(wholeTree)]);
|
|
}
|
|
|
|
export async function detachTabsFromTree(tabs, options = {}) {
|
|
if (!Array.isArray(tabs))
|
|
tabs = [tabs];
|
|
tabs = Array.from(tabs).reverse();
|
|
// you should specify this option if you already call "Tree.getWholeTree()" for the tabs.
|
|
const partial = 'partial' in options ?
|
|
options.partial :
|
|
getWholeTree(tabs).length != tabs.length;
|
|
const promisedAttach = [];
|
|
const tabsSet = new Set(tabs);
|
|
for (const tab of tabs) {
|
|
let behavior = partial ?
|
|
TreeBehavior.getParentTabOperationBehavior(tab, {
|
|
context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE,
|
|
}) :
|
|
Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE)
|
|
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
|
|
promisedAttach.push(detachAllChildren(tab, {
|
|
...options,
|
|
behavior,
|
|
ignoreTabs: tabs,
|
|
}));
|
|
if (options.fromParent &&
|
|
!tabsSet.has(tab.$TST.parent)) {
|
|
promisedAttach.push(detachTab(tab, options));
|
|
}
|
|
}
|
|
if (promisedAttach.length > 0)
|
|
await Promise.all(promisedAttach);
|
|
}
|
|
|
|
export async function detachAllChildren(
|
|
tab = null,
|
|
{ windowId, children, descendants, parent, nearestFollowingRootTab, newParent, ignoreTabs, behavior, dontExpand, dontSyncParentToOpenerTab,
|
|
...options } = {}
|
|
) {
|
|
if (tab) {
|
|
windowId = tab.$TST.windowId;
|
|
parent = tab.$TST.parent;
|
|
children = tab.$TST.children;
|
|
descendants = tab.$TST.descendants;
|
|
}
|
|
log('detachAllChildren: ',
|
|
tab?.id,
|
|
{ children, parent, nearestFollowingRootTab, newParent, behavior, dontExpand, dontSyncParentToOpenerTab },
|
|
options);
|
|
// the "children" option is used for removing tab.
|
|
children = children ? children.map(TabsStore.ensureLivingItem) : tab.$TST.children;
|
|
|
|
const ignoreTabsSet = new Set(ignoreTabs || []);
|
|
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD &&
|
|
newParent &&
|
|
!children.includes(newParent))
|
|
children.unshift(newParent);
|
|
|
|
if (!children.length)
|
|
return;
|
|
log(' => children to be detached: ', () => children.map(dumpTab));
|
|
|
|
if (behavior === undefined)
|
|
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN;
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE)
|
|
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD;
|
|
|
|
options.dontUpdateInsertionPositionInfo = true;
|
|
|
|
// the "parent" option is used for removing tab.
|
|
parent = TabsStore.ensureLivingItem(parent) || tab?.$TST.parent;
|
|
while (ignoreTabsSet.has(parent)) {
|
|
parent = parent.$TST.parent;
|
|
}
|
|
if (tab?.$TST.isGroupTab &&
|
|
Tab.getRemovingTabs(tab.windowId).length == children.length) {
|
|
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN;
|
|
options.dontUpdateIndent = false;
|
|
}
|
|
|
|
let previousTab = null;
|
|
let nextTab = null;
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN &&
|
|
!configs.moveTabsToBottomWhenDetachedFromClosedParent) {
|
|
nextTab = nearestFollowingRootTab !== undefined ?
|
|
nearestFollowingRootTab :
|
|
tab?.$TST.nearestFollowingRootTab;
|
|
previousTab = nextTab ?
|
|
nextTab.$TST.previousTab :
|
|
Tab.getLastTab(windowId || tab.windowId);
|
|
const descendantsSet = new Set(descendants || tab.$TST.descendants);
|
|
while (previousTab && (!tab || descendantsSet.has(previousTab))) {
|
|
previousTab = previousTab.$TST.previousTab;
|
|
}
|
|
}
|
|
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) {
|
|
// open new group tab and replace the detaching tab with it.
|
|
behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN;
|
|
}
|
|
|
|
if (!dontExpand &&
|
|
((tab && !tab.$TST.collapsed) ||
|
|
(behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE &&
|
|
behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB))) {
|
|
if (tab) {
|
|
await collapseExpandSubtree(tab, {
|
|
...options,
|
|
collapsed: false
|
|
});
|
|
}
|
|
else {
|
|
for (const child of children) {
|
|
await collapseExpandTabAndSubtree(child, {
|
|
...options,
|
|
collapsed: false,
|
|
forceExpand: behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let count = 0;
|
|
for (const child of children) {
|
|
if (!child)
|
|
continue;
|
|
const promises = [];
|
|
if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN) {
|
|
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
|
|
|
|
// reference tabs can be closed while waiting...
|
|
if (nextTab?.$TST.removing)
|
|
nextTab = null;
|
|
if (previousTab?.$TST.removing)
|
|
previousTab = null;
|
|
|
|
if (nextTab) {
|
|
promises.push(moveTabSubtreeBefore(child, nextTab, options));
|
|
}
|
|
else {
|
|
promises.push(moveTabSubtreeAfter(child, previousTab, options));
|
|
previousTab = child.$TST.lastDescendant || child;
|
|
}
|
|
}
|
|
else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD) {
|
|
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
|
|
if (count == 0) {
|
|
if (parent) {
|
|
promises.push(attachTabTo(child, parent, {
|
|
...options,
|
|
dontSyncParentToOpenerTab,
|
|
dontExpand: true,
|
|
dontMove: true
|
|
}));
|
|
}
|
|
promises.push(collapseExpandSubtree(child, {
|
|
...options,
|
|
collapsed: false
|
|
}));
|
|
//deleteTabValue(child, Constants.kTAB_STATE_SUBTREE_COLLAPSED);
|
|
}
|
|
else {
|
|
promises.push(attachTabTo(child, children[0], {
|
|
...options,
|
|
dontSyncParentToOpenerTab,
|
|
dontExpand: true,
|
|
dontMove: true
|
|
}));
|
|
}
|
|
}
|
|
else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN &&
|
|
parent) {
|
|
promises.push(attachTabTo(child, parent, {
|
|
...options,
|
|
dontSyncParentToOpenerTab,
|
|
dontExpand: true,
|
|
dontMove: true
|
|
}));
|
|
}
|
|
else { // behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN
|
|
promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab }));
|
|
}
|
|
count++;
|
|
await Promise.all(promises);
|
|
}
|
|
}
|
|
|
|
// returns moved (or not)
|
|
export async function behaveAutoAttachedTab(
|
|
tab,
|
|
{ baseTab, behavior, broadcast, dontMove } = {}
|
|
) {
|
|
if (!configs.autoAttach)
|
|
return false;
|
|
|
|
baseTab = baseTab || Tab.getActiveTab(TabsStore.getCurrentWindowId() || tab.windowId);
|
|
log('behaveAutoAttachedTab ', tab.id, baseTab.id, { baseTab, behavior });
|
|
|
|
if (baseTab?.$TST.ancestors.includes(tab)) {
|
|
log(' => ignore possibly restored ancestor tab to avoid cyclic references');
|
|
return false;
|
|
}
|
|
|
|
if (baseTab.pinned) {
|
|
if (!tab.pinned)
|
|
return false;
|
|
behavior = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING;
|
|
log(' => override behavior for pinned tabs');
|
|
}
|
|
switch (behavior) {
|
|
default:
|
|
return false;
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_ORPHAN:
|
|
log(' => kNEWTAB_OPEN_AS_ORPHAN');
|
|
detachTab(tab, {
|
|
broadcast
|
|
});
|
|
if (tab.$TST.nextTab)
|
|
return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), {
|
|
delayedMove: true
|
|
});
|
|
return false;
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB:
|
|
log(' => kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB');
|
|
const lastRelatedTab = baseTab.$TST.lastRelatedTab;
|
|
if (lastRelatedTab) {
|
|
log(` place after last related tab ${dumpTab(lastRelatedTab)}`);
|
|
await TabsMove.moveTabAfter(tab, lastRelatedTab.$TST.lastDescendant || lastRelatedTab, {
|
|
delayedMove: true,
|
|
broadcast: true
|
|
});
|
|
return attachTabTo(tab, baseTab, {
|
|
insertAfter: lastRelatedTab,
|
|
lastRelatedTab,
|
|
forceExpand: true,
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
}
|
|
log(` no lastRelatedTab: fallback to kNEWTAB_OPEN_AS_CHILD`);
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD:
|
|
log(' => kNEWTAB_OPEN_AS_CHILD');
|
|
return attachTabTo(tab, baseTab, {
|
|
dontMove: dontMove || configs.insertNewChildAt == Constants.kINSERT_NO_CONTROL,
|
|
forceExpand: true,
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD_TOP:
|
|
log(' => kNEWTAB_OPEN_AS_CHILD_TOP');
|
|
return attachTabTo(tab, baseTab, {
|
|
dontMove,
|
|
forceExpand: true,
|
|
delayedMove: true,
|
|
insertAt: Constants.kINSERT_TOP,
|
|
broadcast
|
|
});
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD_END:
|
|
log(' => kNEWTAB_OPEN_AS_CHILD_END');
|
|
return attachTabTo(tab, baseTab, {
|
|
dontMove,
|
|
forceExpand: true,
|
|
delayedMove: true,
|
|
insertAt: Constants.kINSERT_END,
|
|
broadcast
|
|
});
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_SIBLING: {
|
|
log(' => kNEWTAB_OPEN_AS_SIBLING');
|
|
const parent = baseTab.$TST.parent;
|
|
if (parent) {
|
|
await attachTabTo(tab, parent, {
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
return true;
|
|
}
|
|
else {
|
|
detachTab(tab, {
|
|
broadcast
|
|
});
|
|
return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), {
|
|
delayedMove: true
|
|
});
|
|
}
|
|
};
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING:
|
|
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER: {
|
|
log(' => kNEWTAB_OPEN_AS_NEXT_SIBLING(_WITH_INHERITED_CONTAINER)');
|
|
let nextSibling = baseTab.$TST.nextSiblingTab;
|
|
if (nextSibling == tab)
|
|
nextSibling = null;
|
|
const parent = baseTab.$TST.parent;
|
|
if (parent) {
|
|
return attachTabTo(tab, parent, {
|
|
insertBefore: nextSibling,
|
|
insertAfter: baseTab.$TST.lastDescendant || baseTab,
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
}
|
|
else {
|
|
detachTab(tab, {
|
|
broadcast
|
|
});
|
|
if (nextSibling)
|
|
return TabsMove.moveTabBefore(tab, nextSibling, {
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
else
|
|
return TabsMove.moveTabAfter(tab, baseTab.$TST.lastDescendant, {
|
|
delayedMove: true,
|
|
broadcast
|
|
});
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function behaveAutoAttachedTabs(tabs, options = {}) {
|
|
switch (options.behavior) {
|
|
default:
|
|
return false;
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_ORPHAN:
|
|
if (options.baseTabs && !options.baseTab)
|
|
options.baseTab = options.baseTabs[options.baseTabs.length - 1];
|
|
for (const tab of tabs) {
|
|
await behaveAutoAttachedTab(tab, options);
|
|
}
|
|
return false;
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD:
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD_TOP:
|
|
case Constants.kNEWTAB_OPEN_AS_CHILD_END: {
|
|
if (options.baseTabs && !options.baseTab)
|
|
options.baseTab = options.baseTabs[0];
|
|
let moved = false;
|
|
for (const tab of tabs) {
|
|
moved = (await behaveAutoAttachedTab(tab, options)) || moved;
|
|
}
|
|
return moved;
|
|
};
|
|
|
|
case Constants.kNEWTAB_OPEN_AS_SIBLING:
|
|
case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: {
|
|
if (options.baseTabs && !options.baseTab)
|
|
options.baseTab = options.baseTabs[options.baseTabs.length - 1];
|
|
let moved = false;
|
|
for (const tab of tabs.reverse()) {
|
|
moved = (await behaveAutoAttachedTab(tab, options)) || moved;
|
|
}
|
|
return moved;
|
|
};
|
|
}
|
|
}
|
|
|
|
function updateTabsIndent(tabs, level = undefined, options = {}) {
|
|
if (!tabs)
|
|
return;
|
|
|
|
if (!Array.isArray(tabs))
|
|
tabs = [tabs];
|
|
|
|
if (!tabs.length)
|
|
return;
|
|
|
|
if (level === undefined)
|
|
level = tabs[0].$TST.ancestors.length;
|
|
|
|
for (let i = 0, maxi = tabs.length; i < maxi; i++) {
|
|
const item = tabs[i];
|
|
if (!item || item.pinned)
|
|
continue;
|
|
|
|
updateTabIndent(item, level, options);
|
|
}
|
|
}
|
|
|
|
// this is called multiple times on a session restoration, so this should be throttled for better performance
|
|
function updateTabIndent(tab, level = undefined, options = {}) {
|
|
let timer = updateTabIndent.delayed.get(tab.id);
|
|
if (timer)
|
|
clearTimeout(timer);
|
|
if (options.justNow || !shouldApplyAnimation()) {
|
|
return updateTabIndentNow(tab, level, options);
|
|
}
|
|
timer = setTimeout(() => {
|
|
updateTabIndent.delayed.delete(tab.id);
|
|
updateTabIndentNow(tab, level);
|
|
}, 100);
|
|
updateTabIndent.delayed.set(tab.id, timer);
|
|
}
|
|
updateTabIndent.delayed = new Map();
|
|
|
|
function updateTabIndentNow(tab, level = undefined, options = {}) {
|
|
if (!TabsStore.ensureLivingItem(tab))
|
|
return;
|
|
tab.$TST.setAttribute(Constants.kLEVEL, level);
|
|
updateTabsIndent(tab.$TST.children, level + 1, options);
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_LEVEL_CHANGED,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
level
|
|
});
|
|
}
|
|
|
|
|
|
// collapse/expand tabs
|
|
|
|
// returns an array of tab ids which are changed their visibility
|
|
export async function collapseExpandSubtree(tab, params = {}) {
|
|
params.collapsed = !!params.collapsed;
|
|
if (!tab || !TabsStore.ensureLivingItem(tab))
|
|
return [];
|
|
if (!TabsStore.ensureLivingItem(tab)) // it was removed while waiting
|
|
return [];
|
|
params.stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`;
|
|
logCollapseExpand('collapseExpandSubtree: ', dumpTab(tab), tab.$TST.subtreeCollapsed, params);
|
|
const visibilityChangedTabIds = await collapseExpandSubtreeInternal(tab, params);
|
|
onSubtreeCollapsedStateChanged.dispatch(tab, { collapsed: !!params.collapsed });
|
|
if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED)) {
|
|
TSTAPI.broadcastMessage({
|
|
type: TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED,
|
|
tab,
|
|
collapsed: !!params.collapsed
|
|
}, { tabProperties: ['tab'] }).catch(_error => {});
|
|
}
|
|
return visibilityChangedTabIds;
|
|
}
|
|
async function collapseExpandSubtreeInternal(tab, params = {}) {
|
|
if (!params.force &&
|
|
tab.$TST.subtreeCollapsed == params.collapsed)
|
|
return [];
|
|
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGING,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
collapsed: !!params.collapsed,
|
|
});
|
|
|
|
if (params.collapsed) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED);
|
|
tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
|
|
}
|
|
else {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED);
|
|
}
|
|
//setTabValue(tab, Constants.kTAB_STATE_SUBTREE_COLLAPSED, params.collapsed);
|
|
|
|
const isInViewport = await browser.runtime.sendMessage({
|
|
type: Constants.kCOMMAND_ASK_TAB_IS_IN_VIEWPORT,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
allowPartial: true,
|
|
}).catch(_error => false);
|
|
const anchor = isInViewport ? tab : null;
|
|
|
|
const childTabs = tab.$TST.children;
|
|
const lastExpandedTabIndex = childTabs.length - 1;
|
|
const allVisibilityChangedTabIds = [];
|
|
for (let i = 0, maxi = childTabs.length; i < maxi; i++) {
|
|
const childTab = childTabs[i];
|
|
if (i == lastExpandedTabIndex &&
|
|
!params.collapsed) {
|
|
allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, {
|
|
collapsed: params.collapsed,
|
|
justNow: params.justNow,
|
|
anchor,
|
|
last: true,
|
|
broadcast: false
|
|
})));
|
|
}
|
|
else {
|
|
allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, {
|
|
collapsed: params.collapsed,
|
|
justNow: params.justNow,
|
|
broadcast: false
|
|
})));
|
|
}
|
|
}
|
|
const visibilityChangedTabIds = [...new Set(allVisibilityChangedTabIds)];
|
|
|
|
onSubtreeCollapsedStateChanging.dispatch(tab, { collapsed: params.collapsed });
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
collapsed: !!params.collapsed,
|
|
justNow: params.justNow,
|
|
anchorId: anchor?.id,
|
|
visibilityChangedTabIds,
|
|
last: true
|
|
});
|
|
|
|
return visibilityChangedTabIds;
|
|
}
|
|
|
|
// returns an array of tab ids which are changed their visibility
|
|
export function manualCollapseExpandSubtree(tab, params = {}) {
|
|
params.manualOperation = true;
|
|
const visibilityChangedTabIds = collapseExpandSubtree(tab, params);
|
|
if (!params.collapsed) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
|
|
//setTabValue(tab, Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY, true);
|
|
}
|
|
return visibilityChangedTabIds;
|
|
}
|
|
|
|
// returns an array of tab ids which are changed their visibility
|
|
export async function collapseExpandTabAndSubtree(tab, params = {}) {
|
|
log('collapseExpandTabAndSubtree ', tab, params);
|
|
const visibilityChangedTabIds = [];
|
|
|
|
if (!tab) {
|
|
log(' no target');
|
|
return visibilityChangedTabIds;
|
|
}
|
|
|
|
// allow to expand root collapsed tab
|
|
if (!tab.$TST.collapsed &&
|
|
!tab.$TST.parent) {
|
|
log(' no parent');
|
|
return visibilityChangedTabIds;
|
|
}
|
|
|
|
if (collapseExpandTab(tab, params))
|
|
visibilityChangedTabIds.push(tab.id);
|
|
|
|
if (params.collapsed &&
|
|
tab.active &&
|
|
configs.unfocusableCollapsedTab) {
|
|
logCollapseExpand('current tree is going to be collapsed');
|
|
const allowed = await TSTAPI.tryOperationAllowed(
|
|
TSTAPI.kNOTIFY_TRY_MOVE_FOCUS_FROM_COLLAPSING_TREE,
|
|
{ tab },
|
|
{ tabProperties: ['tab'] }
|
|
);
|
|
if (allowed) {
|
|
let newSelection = tab.$TST.nearestVisibleAncestorOrSelf;
|
|
if (configs.avoidDiscardedTabToBeActivatedIfPossible && newSelection.discarded)
|
|
newSelection = newSelection.$TST.nearestLoadedTabInTree ||
|
|
newSelection.$TST.nearestLoadedTab ||
|
|
newSelection;
|
|
logCollapseExpand('=> switch to ', newSelection.id);
|
|
TabsInternalOperation.activateTab(newSelection, { silently: true });
|
|
}
|
|
}
|
|
|
|
if (!tab.$TST.subtreeCollapsed) {
|
|
const children = tab.$TST.children;
|
|
const allVisibilityChangedTabs = await Promise.all(children.map((child, index) => {
|
|
const last = params.last &&
|
|
(index == children.length - 1);
|
|
return collapseExpandTabAndSubtree(child, {
|
|
...params,
|
|
collapsed: params.collapsed,
|
|
justNow: params.justNow,
|
|
anchor: last && params.anchor,
|
|
last: last,
|
|
broadcast: params.broadcast
|
|
});
|
|
}));
|
|
visibilityChangedTabIds.push(...allVisibilityChangedTabs.flat());
|
|
}
|
|
|
|
return [...new Set(visibilityChangedTabIds)];
|
|
}
|
|
|
|
// returns true if the tab's visibility is changed
|
|
export async function collapseExpandTab(tab, params = {}) {
|
|
if (tab.pinned && params.collapsed) {
|
|
log('CAUTION: a pinned tab is going to be collapsed, but canceled.',
|
|
dumpTab(tab), { stack: configs.debug && new Error().stack });
|
|
params.collapsed = false;
|
|
}
|
|
|
|
// When an asynchronous "expand" operation is processed after a
|
|
// synchronous "collapse" operation, it can produce an expanded
|
|
// child tab under "subtree-collapsed" parent. So this is a failsafe.
|
|
if (!params.forceExpand &&
|
|
!params.collapsed &&
|
|
tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed)) {
|
|
log('collapseExpandTab: canceled to avoid expansion under collapsed tree ',
|
|
tab.$TST.ancestors.find(ancestor => ancestor.$TST.subtreeCollapsed));
|
|
return false;
|
|
}
|
|
|
|
const visibilityChanged = tab.$TST.collapsed != params.collapsed;
|
|
|
|
const stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`;
|
|
logCollapseExpand(`collapseExpandTab ${tab.id} `, params, { stack })
|
|
const last = params.last &&
|
|
(!tab.$TST.hasChild || tab.$TST.subtreeCollapsed);
|
|
const byAncestor = tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed) == params.collapsed;
|
|
const collapseExpandInfo = {
|
|
...params,
|
|
anchor: last && params.anchor,
|
|
last
|
|
};
|
|
|
|
if (params.collapsed) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED);
|
|
TabsStore.removeVisibleTab(tab);
|
|
TabsStore.removeExpandedTab(tab);
|
|
}
|
|
else {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED);
|
|
TabsStore.addVisibleTab(tab);
|
|
TabsStore.addExpandedTab(tab);
|
|
}
|
|
|
|
Tab.onCollapsedStateChanged.dispatch(tab, collapseExpandInfo);
|
|
|
|
// the message is called multiple times on a session restoration, so it should be throttled for better performance
|
|
let timer = collapseExpandTab.delayedNotify.get(tab.id);
|
|
if (timer)
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
collapseExpandTab.delayedNotify.delete(tab.id);
|
|
if (!TabsStore.ensureLivingItem(tab))
|
|
return;
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
anchorId: collapseExpandInfo.anchor?.id,
|
|
justNow: params.justNow,
|
|
collapsed: params.collapsed,
|
|
last,
|
|
stack,
|
|
byAncestor
|
|
});
|
|
}, shouldApplyAnimation() ? 100 : 0);
|
|
collapseExpandTab.delayedNotify.set(tab.id, timer);
|
|
|
|
return visibilityChanged;
|
|
}
|
|
collapseExpandTab.delayedNotify = new Map();
|
|
|
|
export async function collapseExpandTreesIntelligentlyFor(tab, options = {}) {
|
|
if (!tab)
|
|
return;
|
|
|
|
logCollapseExpand('collapseExpandTreesIntelligentlyFor ', tab);
|
|
const win = TabsStore.windows.get(tab.windowId);
|
|
if (win.doingIntelligentlyCollapseExpandCount > 0) {
|
|
logCollapseExpand('=> done by others');
|
|
return;
|
|
}
|
|
win.doingIntelligentlyCollapseExpandCount++;
|
|
|
|
try {
|
|
const expandedAncestors = [tab.id]
|
|
.concat(tab.$TST.ancestors.map(ancestor => ancestor.id))
|
|
.concat(tab.$TST.descendants.map(descendant => descendant.id));
|
|
const collapseTabs = Tab.getSubtreeCollapsedTabs(tab.windowId, {
|
|
'!id': expandedAncestors
|
|
});
|
|
logCollapseExpand(`${collapseTabs.length} tabs can be collapsed, ancestors: `, expandedAncestors);
|
|
const allowedToCollapse = new Set();
|
|
await Promise.all(collapseTabs.map(async tab => {
|
|
const allowed = await TSTAPI.tryOperationAllowed(
|
|
TSTAPI.kNOTIFY_TRY_COLLAPSE_TREE_FROM_OTHER_EXPANSION,
|
|
{ tab },
|
|
{ tabProperties: ['tab'] }
|
|
);
|
|
if (allowed)
|
|
allowedToCollapse.add(tab);
|
|
}));
|
|
for (const collapseTab of collapseTabs) {
|
|
if (!allowedToCollapse.has(collapseTab))
|
|
continue;
|
|
let dontCollapse = false;
|
|
const parentTab = collapseTab.$TST.parent;
|
|
if (parentTab) {
|
|
dontCollapse = true;
|
|
if (!parentTab.$TST.subtreeCollapsed) {
|
|
for (const ancestor of collapseTab.$TST.ancestors) {
|
|
if (!expandedAncestors.includes(ancestor.id))
|
|
continue;
|
|
dontCollapse = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
logCollapseExpand(`${collapseTab.id}: dontCollapse = ${dontCollapse}`);
|
|
|
|
const manuallyExpanded = collapseTab.$TST.states.has(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY);
|
|
if (!dontCollapse &&
|
|
!manuallyExpanded &&
|
|
collapseTab.$TST.descendants.every(tab => !tab.$TST.canBecomeSticky))
|
|
collapseExpandSubtree(collapseTab, {
|
|
...options,
|
|
collapsed: true
|
|
});
|
|
}
|
|
|
|
collapseExpandSubtree(tab, {
|
|
...options,
|
|
collapsed: false
|
|
});
|
|
}
|
|
catch(error) {
|
|
log(`failed to collapse/expand tree under ${tab.id}: ${String(error)}`, error);
|
|
}
|
|
win.doingIntelligentlyCollapseExpandCount--;
|
|
}
|
|
|
|
export async function fixupSubtreeCollapsedState(tab, options = {}) {
|
|
let fixed = false;
|
|
if (!tab.$TST.hasChild)
|
|
return fixed;
|
|
const firstChild = tab.$TST.firstChild;
|
|
const childrenCollapsed = firstChild.$TST.collapsed;
|
|
const collapsedStateMismatched = tab.$TST.subtreeCollapsed != childrenCollapsed;
|
|
const nextIsFirstChild = tab.$TST.nextTab == firstChild;
|
|
log('fixupSubtreeCollapsedState ', {
|
|
tab: tab.id,
|
|
childrenCollapsed,
|
|
collapsedStateMismatched,
|
|
nextIsFirstChild
|
|
});
|
|
if (collapsedStateMismatched) {
|
|
log(' => set collapsed state');
|
|
await collapseExpandSubtree(tab, {
|
|
...options,
|
|
collapsed: childrenCollapsed
|
|
});
|
|
fixed = true;
|
|
}
|
|
if (!nextIsFirstChild) {
|
|
log(' => move child tabs');
|
|
await followDescendantsToMovedRoot(tab, options);
|
|
fixed = true;
|
|
}
|
|
return fixed;
|
|
}
|
|
|
|
|
|
// operate tabs based on tree information
|
|
|
|
export async function moveTabSubtreeBefore(tab, nextTab, options = {}) {
|
|
if (!tab)
|
|
return;
|
|
if (nextTab?.$TST.isAllPlacedBeforeSelf([tab].concat(tab.$TST.descendants))) {
|
|
log('moveTabSubtreeBefore:no need to move');
|
|
return;
|
|
}
|
|
|
|
log('moveTabSubtreeBefore: ', tab.id, nextTab?.id);
|
|
const win = TabsStore.windows.get(tab.windowId);
|
|
win.subTreeMovingCount++;
|
|
try {
|
|
await TabsMove.moveTabInternallyBefore(tab, nextTab, options);
|
|
if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting
|
|
throw new Error('the tab was removed before moving of descendants');
|
|
await followDescendantsToMovedRoot(tab, options);
|
|
}
|
|
catch(error) {
|
|
log(`failed to move subtree: ${String(error)}`, error);
|
|
}
|
|
await wait(0);
|
|
win.subTreeMovingCount--;
|
|
}
|
|
|
|
export async function moveTabSubtreeAfter(tab, previousTab, options = {}) {
|
|
if (!tab)
|
|
return;
|
|
|
|
log('moveTabSubtreeAfter: ', tab.id, previousTab?.id);
|
|
if (previousTab?.$TST.isAllPlacedAfterSelf([tab].concat(tab.$TST.descendants))) {
|
|
log(' => no need to move');
|
|
return;
|
|
}
|
|
|
|
const win = TabsStore.windows.get(tab.windowId);
|
|
win.subTreeMovingCount++;
|
|
try {
|
|
await TabsMove.moveTabInternallyAfter(tab, previousTab, options);
|
|
if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting
|
|
throw new Error('the tab was removed before moving of descendants');
|
|
await followDescendantsToMovedRoot(tab, options);
|
|
}
|
|
catch(error) {
|
|
log(`failed to move subtree: ${String(error)}`, error);
|
|
}
|
|
await wait(0);
|
|
win.subTreeMovingCount--;
|
|
}
|
|
|
|
async function followDescendantsToMovedRoot(tab, options = {}) {
|
|
if (!tab.$TST.hasChild)
|
|
return;
|
|
|
|
log('followDescendantsToMovedRoot: ', tab);
|
|
const win = TabsStore.windows.get(tab.windowId);
|
|
win.subTreeChildrenMovingCount++;
|
|
win.subTreeMovingCount++;
|
|
try {
|
|
await TabsMove.moveTabsAfter(tab.$TST.descendants, tab, options);
|
|
}
|
|
catch(error) {
|
|
log(`failed to move descendants of ${tab.id}: ${String(error)}`, error);
|
|
}
|
|
win.subTreeChildrenMovingCount--;
|
|
win.subTreeMovingCount--;
|
|
}
|
|
|
|
// before https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 is fixed (Firefox 67 or older)
|
|
let mSlowDuplication = false;
|
|
browser.runtime.getBrowserInfo().then(browserInfo => {
|
|
if (parseInt(browserInfo.version.split('.')[0]) < 68)
|
|
mSlowDuplication = true;
|
|
});
|
|
|
|
export async function moveTabs(tabs, { duplicate, ...options } = {}) {
|
|
tabs = tabs.filter(TabsStore.ensureLivingItem);
|
|
if (tabs.length == 0)
|
|
return [];
|
|
|
|
log('moveTabs: ', () => ({ tabs: tabs.map(dumpTab), duplicate, options }));
|
|
|
|
const windowId = parseInt(tabs[0].windowId || TabsStore.getCurrentWindowId());
|
|
|
|
let newWindow = options.destinationPromisedNewWindow;
|
|
|
|
let destinationWindowId = options.destinationWindowId;
|
|
if (!destinationWindowId && !newWindow) {
|
|
destinationWindowId = TabsStore.getCurrentWindowId() || windowId;
|
|
}
|
|
|
|
const isAcrossWindows = windowId != destinationWindowId || !!newWindow;
|
|
log('moveTabs: isAcrossWindows = ', isAcrossWindows, `${windowId} => ${destinationWindowId}`);
|
|
|
|
options.insertAfter = options.insertAfter || Tab.getLastTab(destinationWindowId);
|
|
|
|
let movedTabs = tabs;
|
|
const structure = TreeBehavior.getTreeStructureFromTabs(tabs);
|
|
log('original tree structure: ', structure);
|
|
|
|
let hasActive = false;
|
|
for (const tab of movedTabs) {
|
|
if (tab.active)
|
|
hasActive = true;
|
|
if (isAcrossWindows &&
|
|
!duplicate)
|
|
tab.$TST.temporaryMetadata.set('movingAcrossWindows', true);
|
|
}
|
|
|
|
if (!duplicate)
|
|
await detachTabsFromTree(tabs, options);
|
|
|
|
if (isAcrossWindows || duplicate) {
|
|
if (mSlowDuplication)
|
|
UserOperationBlocker.blockIn(windowId, { throbber: true });
|
|
try {
|
|
let win;
|
|
const prepareWindow = () => {
|
|
win = Window.init(destinationWindowId);
|
|
if (isAcrossWindows) {
|
|
win.toBeOpenedTabsWithPositions += tabs.length;
|
|
win.toBeOpenedOrphanTabs += tabs.length;
|
|
for (const tab of tabs) {
|
|
win.toBeAttachedTabs.add(tab.id);
|
|
}
|
|
}
|
|
};
|
|
if (newWindow) {
|
|
newWindow = newWindow.then(win => {
|
|
log('moveTabs: destination window is ready, ', win);
|
|
destinationWindowId = win.id;
|
|
prepareWindow();
|
|
return win;
|
|
});
|
|
}
|
|
else {
|
|
prepareWindow();
|
|
}
|
|
|
|
let movedTabIds = tabs.map(tab => tab.id);
|
|
await Promise.all([
|
|
newWindow,
|
|
(async () => {
|
|
const sourceWindow = TabsStore.windows.get(tabs[0].windowId);
|
|
if (duplicate) {
|
|
sourceWindow.toBeOpenedTabsWithPositions += tabs.length;
|
|
sourceWindow.toBeOpenedOrphanTabs += tabs.length;
|
|
sourceWindow.duplicatingTabsCount += tabs.length;
|
|
}
|
|
if (isAcrossWindows) {
|
|
for (const tab of tabs) {
|
|
sourceWindow.toBeDetachedTabs.add(tab.id);
|
|
}
|
|
}
|
|
|
|
log('preparing tabs');
|
|
if (duplicate) {
|
|
const startTime = Date.now();
|
|
// This promise will be resolved with very large delay.
|
|
// (See also https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 )
|
|
const promisedDuplicatedTabs = Promise.all(movedTabIds.map(async (id, _index) => {
|
|
try {
|
|
return await browser.tabs.duplicate(id).catch(ApiTabs.createErrorHandler());
|
|
}
|
|
catch(e) {
|
|
ApiTabs.handleMissingTabError(e);
|
|
return null;
|
|
}
|
|
})).then(tabs => {
|
|
log(`ids from API responses are resolved in ${Date.now() - startTime}msec: `, () => tabs.map(dumpTab));
|
|
return tabs;
|
|
});
|
|
movedTabs = await promisedDuplicatedTabs;
|
|
if (mSlowDuplication)
|
|
UserOperationBlocker.setProgress(50, windowId);
|
|
movedTabs = movedTabs.map(tab => Tab.get(tab.id));
|
|
movedTabIds = movedTabs.map(tab => tab.id);
|
|
}
|
|
else {
|
|
const movedTabIdsSet = new Set(movedTabIds);
|
|
for (const tab of movedTabs) {
|
|
tab.$TST.temporaryMetadata.set('movingAcrossWindows', true);
|
|
if (tab.$TST.parentId &&
|
|
!movedTabIdsSet.has(tab.$TST.parentId))
|
|
detachTab(tab, {
|
|
broadcast: true,
|
|
toBeDetached: true
|
|
});
|
|
}
|
|
}
|
|
})()
|
|
]);
|
|
log('moveTabs: all windows and tabs are ready, ', movedTabIds, destinationWindowId);
|
|
let toIndex = (tabs.some(tab => tab.pinned) ? Tab.getPinnedTabs(destinationWindowId) : Tab.getAllTabs(destinationWindowId)).length;
|
|
log('toIndex = ', toIndex);
|
|
if (options.insertBefore?.windowId == destinationWindowId) {
|
|
try {
|
|
toIndex = Tab.get(options.insertBefore.id).index;
|
|
}
|
|
catch(e) {
|
|
ApiTabs.handleMissingTabError(e);
|
|
log('options.insertBefore is unavailable');
|
|
}
|
|
}
|
|
else if (options.insertAfter?.windowId == destinationWindowId) {
|
|
try {
|
|
toIndex = Tab.get(options.insertAfter.id).index + 1;
|
|
}
|
|
catch(e) {
|
|
ApiTabs.handleMissingTabError(e);
|
|
log('options.insertAfter is unavailable');
|
|
}
|
|
}
|
|
if (!isAcrossWindows &&
|
|
movedTabs[0].index < toIndex)
|
|
toIndex--;
|
|
log(' => ', toIndex);
|
|
if (isAcrossWindows) {
|
|
let temporaryFocusHolderTab = null;
|
|
if (hasActive) {
|
|
// Blur to-be-moved tab, otherwise tabs.move() will activate them for each
|
|
// while the moving process and all dicarded tabs are unexpectedly restored.
|
|
const nextActiveTab = await TabsInternalOperation.blurTab(movedTabs, {
|
|
silently: true,
|
|
});
|
|
if (!nextActiveTab) {
|
|
// There is no focusible left tab, so we move focus to a tmeporary tab.
|
|
// It will be removed automatically after tabs are moved.
|
|
temporaryFocusHolderTab = await browser.tabs.create({
|
|
url: 'about:blank',
|
|
active: true,
|
|
windowId
|
|
});
|
|
}
|
|
}
|
|
movedTabs = await browser.tabs.move(movedTabIds, {
|
|
windowId: destinationWindowId,
|
|
index: toIndex
|
|
});
|
|
if (temporaryFocusHolderTab) {
|
|
const leftTabsInSourceWindow = await browser.tabs.query({ windowId });
|
|
if (leftTabsInSourceWindow.length == 1)
|
|
browser.windows.remove(windowId);
|
|
else
|
|
browser.tabs.remove(temporaryFocusHolderTab.id);
|
|
}
|
|
movedTabs = movedTabs.map(tab => Tab.get(tab.id));
|
|
movedTabIds = movedTabs.map(tab => tab.id);
|
|
for (const tab of movedTabs) {
|
|
tab.$TST.temporaryMetadata.delete('movingAcrossWindows');
|
|
tab.windowId = destinationWindowId;
|
|
}
|
|
log('moved across windows: ', movedTabIds);
|
|
}
|
|
|
|
log('applying tree structure', structure);
|
|
// wait until tabs.onCreated are processed (for safety)
|
|
let newTabs;
|
|
const startTime = Date.now();
|
|
const maxDelay = configs.maximumAcceptableDelayForTabDuplication;
|
|
while (Date.now() - startTime < maxDelay) {
|
|
newTabs = mapAndFilter(movedTabs,
|
|
tab => Tab.get(tab.id) || undefined);
|
|
if (mSlowDuplication)
|
|
UserOperationBlocker.setProgress(Math.round(newTabs.length / tabs.length * 50) + 50, windowId);
|
|
if (newTabs.length < tabs.length) {
|
|
log('retrying: ', movedTabIds, newTabs.length, tabs.length);
|
|
await wait(100);
|
|
continue;
|
|
}
|
|
await Promise.all(newTabs.map(tab => tab.$TST.opened));
|
|
await applyTreeStructureToTabs(newTabs, structure, {
|
|
broadcast: true
|
|
});
|
|
if (duplicate) {
|
|
for (const tab of newTabs) {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_DUPLICATING, { broadcast: true });
|
|
TabsStore.removeDuplicatingTab(tab);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (!newTabs) {
|
|
log('failed to move tabs (timeout)');
|
|
newTabs = [];
|
|
}
|
|
movedTabs = newTabs;
|
|
}
|
|
catch(e) {
|
|
if (configs.debug)
|
|
console.log('failed to move/duplicate tabs ', e, new Error().stack);
|
|
throw e;
|
|
}
|
|
finally {
|
|
if (mSlowDuplication)
|
|
UserOperationBlocker.unblockIn(windowId, { throbber: true });
|
|
}
|
|
}
|
|
|
|
|
|
movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined);
|
|
if (options.insertBefore) {
|
|
await TabsMove.moveTabsBefore(
|
|
movedTabs,
|
|
options.insertBefore,
|
|
options
|
|
);
|
|
}
|
|
else if (options.insertAfter) {
|
|
await TabsMove.moveTabsAfter(
|
|
movedTabs,
|
|
options.insertAfter,
|
|
options
|
|
);
|
|
}
|
|
else {
|
|
log('no move: just duplicate or import');
|
|
}
|
|
// Tabs can be removed while waiting, so we need to
|
|
// refresh the array of tabs.
|
|
movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined);
|
|
|
|
if (isAcrossWindows) {
|
|
for (const tab of movedTabs) {
|
|
if (tab.$TST.parent ||
|
|
parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0) == 0)
|
|
continue;
|
|
updateTabIndent(tab, 0);
|
|
}
|
|
}
|
|
|
|
return movedTabs;
|
|
}
|
|
|
|
export async function openNewWindowFromTabs(tabs, options = {}) {
|
|
if (tabs.length == 0)
|
|
return [];
|
|
|
|
log('openNewWindowFromTabs: ', tabs, options);
|
|
const sourceWindow = await browser.windows.get(tabs[0].windowId);
|
|
const sourceParams = getWindowParamsFromSource(sourceWindow, options);
|
|
const windowParams = {
|
|
//active: true, // not supported in Firefox...
|
|
url: 'about:blank',
|
|
...sourceParams,
|
|
};
|
|
// positions are not provided for a maximized or fullscren window!
|
|
if (typeof sourceParams.left == 'number')
|
|
sourceParams.left += 20;
|
|
if (typeof sourceParams.top == 'number')
|
|
sourceParams.top += 20;
|
|
let newWindow;
|
|
const promsiedNewWindow = browser.windows.create(windowParams)
|
|
.then(createdWindow => {
|
|
newWindow = createdWindow;
|
|
log('openNewWindowFromTabs: new window is ready, ', newWindow, windowParams);
|
|
UserOperationBlocker.blockIn(newWindow.id);
|
|
return newWindow;
|
|
})
|
|
.catch(ApiTabs.createErrorHandler());
|
|
tabs = tabs.filter(TabsStore.ensureLivingItem);
|
|
const movedTabs = await moveTabs(tabs, {
|
|
...options,
|
|
destinationPromisedNewWindow: promsiedNewWindow
|
|
});
|
|
|
|
log('closing needless tabs');
|
|
browser.windows.get(newWindow.id, { populate: true })
|
|
.then(win => {
|
|
const movedTabIds = new Set(movedTabs.map(tab => tab.id));
|
|
log('moved tabs: ', movedTabIds);
|
|
const removeTabs = mapAndFilter(win.tabs, tab =>
|
|
!movedTabIds.has(tab.id) && Tab.get(tab.id) || undefined
|
|
);
|
|
log('removing tabs: ', removeTabs);
|
|
TabsInternalOperation.removeTabs(removeTabs);
|
|
UserOperationBlocker.unblockIn(newWindow.id);
|
|
})
|
|
.catch(ApiTabs.createErrorSuppressor());
|
|
|
|
return movedTabs;
|
|
}
|
|
|
|
|
|
/* "treeStructure" is an array of integers, meaning:
|
|
[A] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree)
|
|
[B] => 0 (parent is 1st item in this tree)
|
|
[C] => 0 (parent is 1st item in this tree)
|
|
[D] => 2 (parent is 2nd in this tree)
|
|
[E] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree, and this creates another tree)
|
|
[F] => 0 (parent is 1st item in this another tree)
|
|
See also getTreeStructureFromTabs() in tree-behavior.js
|
|
*/
|
|
export async function applyTreeStructureToTabs(tabs, treeStructure, options = {}) {
|
|
if (!tabs || !treeStructure)
|
|
return;
|
|
|
|
MetricsData.add('applyTreeStructureToTabs: start');
|
|
|
|
log('applyTreeStructureToTabs: ', () => ({ tabs: tabs.map(dumpTab), treeStructure, options }));
|
|
tabs = tabs.slice(0, treeStructure.length);
|
|
treeStructure = treeStructure.slice(0, tabs.length);
|
|
|
|
let expandStates = tabs.map(tab => !!tab);
|
|
expandStates = expandStates.slice(0, tabs.length);
|
|
while (expandStates.length < tabs.length)
|
|
expandStates.push(TreeBehavior.STRUCTURE_NO_PARENT);
|
|
|
|
MetricsData.add('applyTreeStructureToTabs: preparation');
|
|
|
|
let parent = null;
|
|
let tabsInTree = [];
|
|
const promises = [];
|
|
for (let i = 0, maxi = tabs.length; i < maxi; i++) {
|
|
const tab = tabs[i];
|
|
/*
|
|
if (tab.$TST.collapsed)
|
|
collapseExpandTabAndSubtree(tab, {
|
|
...options,
|
|
collapsed: false,
|
|
justNow: true
|
|
});
|
|
*/
|
|
const structureInfo = treeStructure[i];
|
|
let parentIndexInTree = TreeBehavior.STRUCTURE_NO_PARENT;
|
|
if (typeof structureInfo == 'number') { // legacy format
|
|
parentIndexInTree = structureInfo;
|
|
}
|
|
else {
|
|
parentIndexInTree = structureInfo.parent;
|
|
expandStates[i] = !structureInfo.collapsed;
|
|
}
|
|
log(` applyTreeStructureToTabs: parent for ${tab.id} => ${parentIndexInTree}`);
|
|
if (parentIndexInTree == TreeBehavior.STRUCTURE_NO_PARENT ||
|
|
parentIndexInTree == TreeBehavior.STRUCTURE_KEEP_PARENT) {
|
|
// there is no parent, so this is a new parent!
|
|
parent = null;
|
|
tabsInTree = [tab];
|
|
}
|
|
else {
|
|
tabsInTree.push(tab);
|
|
parent = parentIndexInTree < tabsInTree.length ? tabsInTree[parentIndexInTree] : null;
|
|
}
|
|
log(' => parent = ', parent);
|
|
if (parentIndexInTree != TreeBehavior.STRUCTURE_KEEP_PARENT)
|
|
detachTab(tab, { justNow: true });
|
|
if (parent && tab != parent) {
|
|
parent.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); // prevent focus changing by "current tab attached to collapsed tree"
|
|
promises.push(attachTabTo(tab, parent, {
|
|
...options,
|
|
dontExpand: true,
|
|
dontMove: true,
|
|
justNow: true
|
|
}));
|
|
}
|
|
}
|
|
if (promises.length > 0)
|
|
await Promise.all(promises);
|
|
MetricsData.add('applyTreeStructureToTabs: attach/detach');
|
|
|
|
log('expandStates: ', expandStates);
|
|
for (let i = tabs.length - 1; i > -1; i--) {
|
|
const tab = tabs[i];
|
|
const expanded = expandStates[i];
|
|
collapseExpandSubtree(tab, {
|
|
...options,
|
|
collapsed: expanded === undefined ? !tab.$TST.hasChild : !expanded ,
|
|
justNow: true,
|
|
force: true
|
|
});
|
|
}
|
|
MetricsData.add('applyTreeStructureToTabs: collapse/expand');
|
|
}
|
|
|
|
|
|
|
|
//===================================================================
|
|
// Fixup tree structure for unexpectedly inserted tabs
|
|
//===================================================================
|
|
|
|
class TabActionForNewPosition {
|
|
constructor(action, { tab, parent, insertBefore, insertAfter, isTabCreating, isMovingByShortcut, mustToApply } = {}) {
|
|
this.action = action || null;
|
|
this.tab = tab;
|
|
this.parent = parent;
|
|
this.insertBefore = insertBefore;
|
|
this.insertAfter = insertAfter;
|
|
this.isTabCreating = isTabCreating;
|
|
this.isMovingByShortcut = isMovingByShortcut;
|
|
this.mustToApply = mustToApply;
|
|
}
|
|
|
|
async applyIfNeeded() {
|
|
if (!this.mustToApply)
|
|
return;
|
|
return this.apply();
|
|
}
|
|
|
|
async apply() {
|
|
log('TabActionForNewPosition: applying ', this);
|
|
switch (this.action) {
|
|
case 'invalid':
|
|
throw new Error('invalid action: this must not happen!');
|
|
|
|
case 'attach': {
|
|
const attached = attachTabTo(this.tab, Tab.get(this.parent), {
|
|
insertBefore: Tab.get(this.insertBefore),
|
|
insertAfter: Tab.get(this.insertAfter),
|
|
forceExpand: this.isTabCreating || this.isMovingByShortcut,
|
|
broadcast: true,
|
|
synchronously: this.isTabCreating,
|
|
});
|
|
if (!this.isTabCreating)
|
|
await attached;
|
|
followDescendantsToMovedRoot(this.tab);
|
|
}; break;
|
|
|
|
case 'detach':
|
|
detachTab(this.tab, { broadcast: true });
|
|
followDescendantsToMovedRoot(this.tab);
|
|
if (!this.insertBefore && !this.insertAfter)
|
|
break;
|
|
|
|
case 'move':
|
|
if (this.insertBefore) {
|
|
moveTabSubtreeBefore(
|
|
this.tab,
|
|
Tab.get(this.insertBefore),
|
|
{ broadcast: true }
|
|
);
|
|
return;
|
|
}
|
|
else if (this.insertAfter) {
|
|
moveTabSubtreeAfter(
|
|
this.tab,
|
|
Tab.get(this.insertAfter),
|
|
{ broadcast: true }
|
|
);
|
|
return;
|
|
}
|
|
|
|
default:
|
|
followDescendantsToMovedRoot(this.tab);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function detectTabActionFromNewPosition(tab, moveInfo = {}) {
|
|
const isTabCreating = !!moveInfo?.isTabCreating;
|
|
const isMovingByShortcut = !!moveInfo?.isMovingByShortcut;
|
|
|
|
if (tab.pinned)
|
|
return new TabActionForNewPosition(tab.$TST.parentId ? 'detach' : 'move', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
});
|
|
|
|
log('detectTabActionFromNewPosition: ', dumpTab(tab), moveInfo);
|
|
const tree = moveInfo.treeForActionDetection || snapshotForActionDetection(tab);
|
|
const target = tree.target;
|
|
log(' calculate new position: ', tab, tree);
|
|
|
|
const toIndex = moveInfo.toIndex;
|
|
const fromIndex = moveInfo.fromIndex;
|
|
if (toIndex == fromIndex) { // no move?
|
|
log('=> no move');
|
|
return new TabActionForNewPosition();
|
|
}
|
|
|
|
const prevTab = tree.tabsById[target.previous];
|
|
const nextTab = tree.tabsById[target.next];
|
|
|
|
// When multiple tabs are moved at once by outside of TST (e.g. moving of multiselected tabs)
|
|
// this method may be called multiple times asynchronously before previous operation finishes.
|
|
// Thus we need to refer the calculated "parent" if it is given.
|
|
const futurePrevParent = Tab.get(Tab.get(prevTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo'));
|
|
const futureNextParent = Tab.get(Tab.get(nextTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo'));
|
|
|
|
const prevParent = prevTab && tree.tabsById[prevTab.parent] ||
|
|
snapshotTab(Tab.get(prevTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe
|
|
snapshotTab(futurePrevParent);
|
|
const nextParent = nextTab && tree.tabsById[nextTab.parent] ||
|
|
snapshotTab(Tab.get(nextTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe
|
|
snapshotTab(futureNextParent);
|
|
if (prevParent)
|
|
tree.tabsById[prevParent.id] = prevParent;
|
|
if (nextParent)
|
|
tree.tabsById[nextParent.id] = nextParent;
|
|
|
|
// Given treeForActionDetection may not contain the parent tab, so we fixup the information.
|
|
if (prevTab &&
|
|
!prevTab.parent &&
|
|
prevParent) {
|
|
prevTab.parent = prevParent.id;
|
|
prevTab.level = prevParent.level + 1;
|
|
}
|
|
if (nextTab &&
|
|
!nextTab.parent &&
|
|
nextParent) {
|
|
nextTab.parent = nextParent.id;
|
|
nextTab.level = nextParent.level + 1;
|
|
}
|
|
log('prevTab: ', dumpTab(prevTab), `parent: ${prevTab?.parent}`);
|
|
log('nextTab: ', dumpTab(nextTab), `parent: ${nextTab?.parent}`);
|
|
|
|
const prevLevel = prevTab ? prevTab.level : -1 ;
|
|
const nextLevel = nextTab ? nextTab.level : -1 ;
|
|
log('prevLevel: '+prevLevel);
|
|
log('nextLevel: '+nextLevel);
|
|
|
|
const oldParent = tree.tabsById[target.parent] || snapshotTab(Tab.get(target.parent));
|
|
if (oldParent)
|
|
tree.tabsById[oldParent.id] = oldParent;
|
|
let newParent = null;
|
|
let mustToApply = false;
|
|
|
|
if (!oldParent &&
|
|
(!nextTab ||
|
|
!nextParent)) {
|
|
if (!nextTab)
|
|
log('=> A root level tab, placed at the end of tabs. We should keep it in the root level.');
|
|
else
|
|
log(' => A root level tab, placed before another root level tab. We should keep it in the root level.');
|
|
return new TabActionForNewPosition('move', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
insertAfter: prevTab?.id,
|
|
mustToApply,
|
|
});
|
|
}
|
|
|
|
if (target.mayBeReplacedWithContainer) {
|
|
log('=> replaced by Firefox Multi-Acount Containers or Temporary Containers');
|
|
newParent = prevLevel < nextLevel ? prevTab : prevParent;
|
|
mustToApply = true;
|
|
}
|
|
else if (oldParent &&
|
|
prevTab &&
|
|
oldParent?.id == prevTab?.id) {
|
|
log('=> no need to fix case');
|
|
newParent = oldParent;
|
|
}
|
|
else if (!prevTab) {
|
|
log('=> moved to topmost position');
|
|
newParent = null;
|
|
mustToApply = !!oldParent;
|
|
}
|
|
else if (!nextTab) {
|
|
log('=> moved to last position');
|
|
let ancestor = oldParent;
|
|
while (ancestor) {
|
|
if (ancestor.id == prevParent?.id) {
|
|
log(' => moving in related tree: keep it attached in existing tree');
|
|
newParent = prevParent;
|
|
break;
|
|
}
|
|
ancestor = tree.tabsById[ancestor.parent];
|
|
}
|
|
if (!newParent) {
|
|
log(' => moving from other tree: keep it orphaned');
|
|
}
|
|
mustToApply = !!oldParent && newParent?.id != oldParent.id;
|
|
}
|
|
else if (prevParent?.id == nextParent?.id) {
|
|
log('=> moved into existing tree');
|
|
newParent = prevParent;
|
|
mustToApply = !oldParent || newParent?.id != oldParent.id;
|
|
}
|
|
else if (prevLevel > nextLevel &&
|
|
nextTab?.parent != tab.id) {
|
|
log('=> moved to end of existing tree');
|
|
if (!target.active &&
|
|
target.children.length == 0 &&
|
|
(Date.now() - target.trackedAt) < 500) {
|
|
log('=> maybe newly opened tab');
|
|
newParent = prevParent;
|
|
}
|
|
else {
|
|
log('=> maybe drag and drop (or opened with active state and position)');
|
|
const realDelta = Math.abs(toIndex - fromIndex);
|
|
newParent = realDelta < 2 ? prevParent : (oldParent || nextParent) ;
|
|
}
|
|
while (newParent?.collapsed) {
|
|
log('=> the tree is collapsed, up to parent tree')
|
|
newParent = tree.tabsById[newParent.parent];
|
|
}
|
|
mustToApply = !!oldParent && newParent?.id != oldParent.id;
|
|
}
|
|
else if (prevLevel < nextLevel &&
|
|
nextTab?.parent == prevTab?.id) {
|
|
log('=> moved to first child position of existing tree');
|
|
newParent = prevTab || oldParent || nextParent;
|
|
mustToApply = !!oldParent && newParent?.id != oldParent.id;
|
|
}
|
|
|
|
log('calculated parent: ', {
|
|
old: oldParent?.id,
|
|
new: newParent?.id
|
|
});
|
|
|
|
if (newParent) {
|
|
let ancestor = newParent;
|
|
while (ancestor) {
|
|
if (ancestor.id == target.id) {
|
|
if (moveInfo.toIndex - moveInfo.fromIndex == 1) {
|
|
log('=> maybe move-down by keyboard shortcut or something.');
|
|
let nearestForeigner = tab.$TST.nearestFollowingForeignerTab;
|
|
if (nearestForeigner &&
|
|
nearestForeigner == tab)
|
|
nearestForeigner = nearestForeigner.$TST.nextTab;
|
|
log('nearest foreigner tab: ', nearestForeigner?.id);
|
|
if (nearestForeigner) {
|
|
if (nearestForeigner.$TST.hasChild)
|
|
return new TabActionForNewPosition('attach', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
parent: nearestForeigner.id,
|
|
insertAfter: nearestForeigner.id,
|
|
mustToApply,
|
|
});
|
|
return new TabActionForNewPosition(tab.$TST.parent ? 'detach' : 'move', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
insertAfter: nearestForeigner.id,
|
|
mustToApply,
|
|
});
|
|
}
|
|
}
|
|
log('=> invalid move: a parent is moved inside its own tree!');
|
|
return new TabActionForNewPosition('invalid');
|
|
}
|
|
ancestor = tree.tabsById[ancestor.parent];
|
|
}
|
|
}
|
|
|
|
if (newParent != oldParent) {
|
|
if (newParent) {
|
|
return new TabActionForNewPosition('attach', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
parent: newParent.id,
|
|
insertBefore: nextTab?.id,
|
|
insertAfter: prevTab?.id,
|
|
mustToApply,
|
|
});
|
|
}
|
|
else {
|
|
return new TabActionForNewPosition('detach', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
mustToApply,
|
|
});
|
|
}
|
|
}
|
|
return new TabActionForNewPosition('move', {
|
|
tab,
|
|
isTabCreating,
|
|
isMovingByShortcut,
|
|
mustToApply,
|
|
});
|
|
}
|
|
|
|
|
|
//===================================================================
|
|
// Take snapshot
|
|
//===================================================================
|
|
|
|
export function snapshotForActionDetection(targetTab) {
|
|
const prevTab = targetTab.$TST.nearestCompletelyOpenedNormalPrecedingTab;
|
|
const nextTab = targetTab.$TST.nearestCompletelyOpenedNormalFollowingTab;
|
|
const tabs = Array.from(new Set([
|
|
...(prevTab?.$TST?.ancestors || []),
|
|
prevTab,
|
|
targetTab,
|
|
nextTab,
|
|
targetTab.$TST.parent,
|
|
]))
|
|
.filter(TabsStore.ensureLivingItem)
|
|
.sort((a, b) => a.index - b.index);
|
|
return snapshotTree(targetTab, tabs);
|
|
}
|
|
|
|
function snapshotTree(targetTab, tabs) {
|
|
const allTabs = tabs || Tab.getTabs(targetTab.windowId);
|
|
|
|
const snapshotById = {};
|
|
function snapshotChild(tab) {
|
|
if (!TabsStore.ensureLivingItem(tab) || tab.pinned)
|
|
return null;
|
|
return snapshotById[tab.id] = snapshotTab(tab);
|
|
}
|
|
const snapshotArray = allTabs.map(tab => snapshotChild(tab));
|
|
for (const tab of allTabs) {
|
|
const item = snapshotById[tab.id];
|
|
if (!item)
|
|
continue;
|
|
item.parent = tab.$TST.parent?.id;
|
|
item.next = tab.$TST.nearestCompletelyOpenedNormalFollowingTab?.id;
|
|
item.previous = tab.$TST.nearestCompletelyOpenedNormalPrecedingTab?.id;
|
|
}
|
|
const activeTab = Tab.getActiveTab(targetTab.windowId);
|
|
return {
|
|
target: snapshotById[targetTab.id],
|
|
active: activeTab && snapshotById[activeTab.id],
|
|
tabs: snapshotArray,
|
|
tabsById: snapshotById,
|
|
};
|
|
}
|
|
|
|
function snapshotTab(tab) {
|
|
if (!tab)
|
|
return null;
|
|
return {
|
|
id: tab.id,
|
|
url: tab.url,
|
|
cookieStoreId: tab.cookieStoreId,
|
|
active: tab.active,
|
|
children: tab.$TST.children.map(child => child.id),
|
|
collapsed: tab.$TST.subtreeCollapsed,
|
|
pinned: tab.pinned,
|
|
level: tab.$TST.level, // parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0), // we need to use the number of real ancestors instead of a cached "level", because it will be updated with delay
|
|
trackedAt: tab.$TST.trackedAt,
|
|
mayBeReplacedWithContainer: tab.$TST.mayBeReplacedWithContainer,
|
|
};
|
|
}
|
|
|
|
|
|
SidebarConnection.onMessage.addListener(async (windowId, message) => {
|
|
switch (message.type) {
|
|
case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE: {
|
|
await Tab.waitUntilTracked(message.tabId);
|
|
const tab = Tab.get(message.tabId);
|
|
if (!tab)
|
|
return;
|
|
const params = {
|
|
collapsed: message.collapsed,
|
|
justNow: message.justNow,
|
|
broadcast: true,
|
|
stack: message.stack
|
|
};
|
|
if (message.manualOperation)
|
|
manualCollapseExpandSubtree(tab, params);
|
|
else
|
|
collapseExpandSubtree(tab, params);
|
|
}; break;
|
|
|
|
case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE_INTELLIGENTLY_FOR: {
|
|
await Tab.waitUntilTracked(message.tabId);
|
|
const tab = Tab.get(message.tabId);
|
|
if (tab)
|
|
await collapseExpandTreesIntelligentlyFor(tab);
|
|
}; break;
|
|
|
|
case Constants.kCOMMAND_NEW_WINDOW_FROM_TABS: {
|
|
log('new window requested: ', message);
|
|
await Tab.waitUntilTracked(message.tabIds);
|
|
const tabs = message.tabIds.map(id => TabsStore.tabs.get(id));
|
|
openNewWindowFromTabs(tabs, message);
|
|
}; break;
|
|
}
|
|
});
|