466 lines
16 KiB
JavaScript
466 lines
16 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 {
|
|
log as internalLogger,
|
|
wait,
|
|
toLines,
|
|
configs
|
|
} from '/common/common.js';
|
|
import * as ApiTabs from '/common/api-tabs.js';
|
|
import * as Constants from '/common/constants.js';
|
|
import { SequenceMatcher } from '/extlib/diff.js';
|
|
import * as SidebarConnection from '/common/sidebar-connection.js';
|
|
import * as TabsStore from '/common/tabs-store.js';
|
|
|
|
import { Tab, TreeItem } from '/common/TreeItem.js';
|
|
|
|
function log(...args) {
|
|
internalLogger('background/tabs-move', ...args);
|
|
}
|
|
function logApiTabs(...args) {
|
|
internalLogger('common/api-tabs', ...args);
|
|
}
|
|
|
|
|
|
// ========================================================
|
|
// primitive methods for internal use
|
|
|
|
export async function moveTabsBefore(tabs, referenceTab, options = {}) {
|
|
log('moveTabsBefore: ', tabs, referenceTab, options);
|
|
if (!tabs.length ||
|
|
!TabsStore.ensureLivingItem(referenceTab))
|
|
return [];
|
|
|
|
if (referenceTab.$TST.isAllPlacedBeforeSelf(tabs)) {
|
|
log('moveTabsBefore:no need to move');
|
|
return [];
|
|
}
|
|
return moveTabsInternallyBefore(tabs, referenceTab, options);
|
|
}
|
|
export async function moveTabBefore(tab, referenceTab, options = {}) {
|
|
return moveTabsBefore([tab], referenceTab, options).then(moved => moved.length > 0);
|
|
}
|
|
|
|
async function moveTabsInternallyBefore(tabs, referenceTab, options = {}) {
|
|
if (!tabs.length ||
|
|
!TabsStore.ensureLivingItem(referenceTab))
|
|
return [];
|
|
|
|
const win = TabsStore.windows.get(tabs[0].windowId);
|
|
|
|
log('moveTabsInternallyBefore: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options);
|
|
if (referenceTab.type == TreeItem.TYPE_GROUP) {
|
|
referenceTab = referenceTab.$TST?.firstMember;
|
|
if (!TabsStore.ensureLivingItem(referenceTab)) {
|
|
log('missing reference tab');
|
|
return [];
|
|
}
|
|
log(` => reference tab: ${referenceTab.id}(index=${referenceTab.index})`);
|
|
}
|
|
|
|
const precedingReferenceTab = referenceTab.$TST.previousTab;
|
|
if (referenceTab.pinned) {
|
|
// unpinned tab cannot be moved before any pinned tab
|
|
tabs = tabs.filter(tab => tab.pinned);
|
|
}
|
|
else if (precedingReferenceTab &&
|
|
!precedingReferenceTab.pinned) {
|
|
// pinned tab cannot be moved after any unpinned tab
|
|
tabs = tabs.filter(tab => !tab.pinned);
|
|
}
|
|
if (!tabs.length)
|
|
return [];
|
|
|
|
const movedTabs = [];
|
|
try {
|
|
/*
|
|
Tab elements are moved by tabs.onMoved automatically, but
|
|
the operation is asynchronous. To help synchronous operations
|
|
following to this operation, we need to move tabs immediately.
|
|
*/
|
|
const tabGroups = new Set();
|
|
for (const tab of tabs) {
|
|
const oldPreviousTab = tab.$TST.unsafePreviousTab;
|
|
const oldNextTab = tab.$TST.unsafeNextTab;
|
|
if (oldNextTab?.id == referenceTab.id) // no move case
|
|
continue;
|
|
const fromIndex = tab.index;
|
|
if (referenceTab.index > tab.index)
|
|
tab.index = referenceTab.index - 1;
|
|
else
|
|
tab.index = referenceTab.index;
|
|
tabGroups.add(tab.$TST.nativeTabGroup);
|
|
if (SidebarConnection.isInitialized()) { // only on the background page
|
|
win.internalMovingTabs.set(tab.id, tab.index);
|
|
win.alreadyMovedTabs.set(tab.id, tab.index);
|
|
}
|
|
tab.reindexedBy = `moveTabsInternallyBefore (${tab.index})`;
|
|
Tab.track(tab);
|
|
movedTabs.push(tab);
|
|
Tab.onTabInternallyMoved.dispatch(tab, {
|
|
nextTab: referenceTab,
|
|
oldPreviousTab,
|
|
oldNextTab,
|
|
broadcasted: !!options.broadcasted
|
|
});
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
fromIndex,
|
|
toIndex: tab.index,
|
|
nextTabId: referenceTab?.id,
|
|
broadcasted: !!options.broadcasted
|
|
});
|
|
if (options.doNotOptimize) {
|
|
win.internalMovingTabs.set(tab.id, tab.index);
|
|
win.alreadyMovedTabs.set(tab.id, tab.index);
|
|
await browser.tabs.move(tab.id, { index: tab.index });
|
|
win.internalMovingTabs.delete(tab.id);
|
|
win.alreadyMovedTabs.delete(tab.id);
|
|
}
|
|
}
|
|
for (const group of tabGroups) {
|
|
group?.$TST.reindex();
|
|
}
|
|
if (movedTabs.length == 0) {
|
|
log(' => actually nothing moved');
|
|
}
|
|
else {
|
|
log(
|
|
'Tab nodes rearranged by moveTabsInternallyBefore:\n',
|
|
(!configs.debug ? '' :
|
|
() => toLines(Array.from(win.getOrderedTabs()),
|
|
tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`))
|
|
);
|
|
}
|
|
if (SidebarConnection.isInitialized()) { // only on the background page
|
|
if (options.delayedMove) { // Wait until opening animation is finished.
|
|
await wait(configs.newTabAnimationDuration);
|
|
}
|
|
if (!options.doNotOptimize) {
|
|
syncToNativeTabs(tabs);
|
|
}
|
|
}
|
|
}
|
|
catch(e) {
|
|
ApiTabs.handleMissingTabError(e);
|
|
log('moveTabsInternallyBefore failed: ', String(e));
|
|
}
|
|
return movedTabs;
|
|
}
|
|
export async function moveTabInternallyBefore(tab, referenceTab, options = {}) {
|
|
return moveTabsInternallyBefore([tab], referenceTab, options);
|
|
}
|
|
|
|
export async function moveTabsAfter(tabs, referenceTab, options = {}) {
|
|
log('moveTabsAfter: ', tabs, referenceTab, options);
|
|
if (!tabs.length ||
|
|
!TabsStore.ensureLivingItem(referenceTab))
|
|
return [];
|
|
|
|
if (referenceTab.$TST.isAllPlacedAfterSelf(tabs)) {
|
|
log('moveTabsAfter:no need to move');
|
|
return [];
|
|
}
|
|
return moveTabsInternallyAfter(tabs, referenceTab, options);
|
|
}
|
|
export async function moveTabAfter(tab, referenceTab, options = {}) {
|
|
return moveTabsAfter([tab], referenceTab, options).then(moved => moved.length > 0);
|
|
}
|
|
|
|
async function moveTabsInternallyAfter(tabs, referenceTab, options = {}) {
|
|
if (!tabs.length ||
|
|
!TabsStore.ensureLivingItem(referenceTab))
|
|
return [];
|
|
|
|
const win = TabsStore.windows.get(tabs[0].windowId);
|
|
|
|
log('moveTabsInternallyAfter: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options);
|
|
if (referenceTab.type == TreeItem.TYPE_GROUP) {
|
|
if (!referenceTab.collapsed) {
|
|
log(' => move before the first member tab of the reference group');
|
|
return moveTabsInternallyBefore(tabs, referenceTab.$TST?.firstMember, options = {});
|
|
}
|
|
referenceTab = referenceTab.$TST?.lastMember;
|
|
if (!TabsStore.ensureLivingItem(referenceTab)) {
|
|
log('missing reference tab');
|
|
return [];
|
|
}
|
|
log(` => reference tab: ${referenceTab.id}(index=${referenceTab.index})`);
|
|
}
|
|
|
|
const followingReferenceTab = referenceTab.$TST.nextTab;
|
|
if (followingReferenceTab &&
|
|
followingReferenceTab.pinned) {
|
|
// unpinned tab cannot be moved before any pinned tab
|
|
tabs = tabs.filter(tab => tab.pinned);
|
|
}
|
|
else if (!referenceTab.pinned) {
|
|
// pinned tab cannot be moved after any unpinned tab
|
|
tabs = tabs.filter(tab => !tab.pinned);
|
|
}
|
|
if (!tabs.length)
|
|
return [];
|
|
|
|
const movedTabs = [];
|
|
try {
|
|
/*
|
|
Tab elements are moved by tabs.onMoved automatically, but
|
|
the operation is asynchronous. To help synchronous operations
|
|
following to this operation, we need to move tabs immediately.
|
|
*/
|
|
let nextTab = referenceTab.$TST.unsafeNextTab;
|
|
while (nextTab && tabs.find(tab => tab.id == nextTab.id)) {
|
|
nextTab = nextTab.$TST.unsafeNextTab;
|
|
}
|
|
const tabGroups = new Set();
|
|
for (const tab of tabs) {
|
|
const oldPreviousTab = tab.$TST.unsafePreviousTab;
|
|
const oldNextTab = tab.$TST.unsafeNextTab;
|
|
if ((!oldNextTab && !nextTab) ||
|
|
(oldNextTab && nextTab && oldNextTab.id == nextTab.id)) // no move case
|
|
continue;
|
|
const fromIndex = tab.index;
|
|
if (nextTab) {
|
|
if (nextTab.index > tab.index)
|
|
tab.index = nextTab.index - 1;
|
|
else
|
|
tab.index = nextTab.index;
|
|
}
|
|
else {
|
|
tab.index = win.tabs.size - 1
|
|
}
|
|
tabGroups.add(tab.$TST.nativeTabGroup);
|
|
if (SidebarConnection.isInitialized()) { // only on the background page
|
|
win.internalMovingTabs.set(tab.id, tab.index);
|
|
win.alreadyMovedTabs.set(tab.id, tab.index);
|
|
}
|
|
tab.reindexedBy = `moveTabsInternallyAfter (${tab.index})`;
|
|
Tab.track(tab);
|
|
movedTabs.push(tab);
|
|
Tab.onTabInternallyMoved.dispatch(tab, {
|
|
nextTab,
|
|
oldPreviousTab,
|
|
oldNextTab,
|
|
broadcasted: !!options.broadcasted
|
|
});
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED,
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
fromIndex,
|
|
toIndex: tab.index,
|
|
nextTabId: nextTab?.id,
|
|
broadcasted: !!options.broadcasted
|
|
});
|
|
if (options.doNotOptimize) {
|
|
win.internalMovingTabs.set(tab.id, tab.index);
|
|
win.alreadyMovedTabs.set(tab.id, tab.index);
|
|
await browser.tabs.move(tab.id, { index: tab.index });
|
|
win.internalMovingTabs.delete(tab.id);
|
|
win.alreadyMovedTabs.delete(tab.id);
|
|
}
|
|
}
|
|
for (const group of tabGroups) {
|
|
group?.$TST.reindex();
|
|
}
|
|
if (movedTabs.length == 0) {
|
|
log(' => actually nothing moved');
|
|
}
|
|
else {
|
|
log(
|
|
'Tab nodes rearranged by moveTabsInternallyAfter:\n',
|
|
(!configs.debug ? '' :
|
|
() => toLines(Array.from(win.getOrderedTabs()),
|
|
tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`))
|
|
);
|
|
}
|
|
if (SidebarConnection.isInitialized()) { // only on the background page
|
|
if (options.delayedMove) { // Wait until opening animation is finished.
|
|
await wait(configs.newTabAnimationDuration);
|
|
}
|
|
if (!options.doNotOptimize) {
|
|
syncToNativeTabs(tabs);
|
|
}
|
|
}
|
|
}
|
|
catch(e) {
|
|
ApiTabs.handleMissingTabError(e);
|
|
log('moveTabsInternallyAfter failed: ', String(e));
|
|
}
|
|
return movedTabs;
|
|
}
|
|
export async function moveTabInternallyAfter(tab, referenceTab, options = {}) {
|
|
return moveTabsInternallyAfter([tab], referenceTab, options);
|
|
}
|
|
|
|
|
|
// ========================================================
|
|
// Synchronize order of tab elements to browser's tabs
|
|
|
|
const mPreviousSync = new Map();
|
|
const mDelayedSync = new Map();
|
|
const mDelayedSyncTimer = new Map();
|
|
|
|
export async function waitUntilSynchronized(windowId) {
|
|
const previous = mPreviousSync.get(windowId);
|
|
if (previous)
|
|
return previous.then(() => waitUntilSynchronized(windowId));
|
|
return Promise.resolve(mDelayedSync.get(windowId)).then(() => {
|
|
const previous = mPreviousSync.get(windowId);
|
|
if (previous)
|
|
return waitUntilSynchronized(windowId);
|
|
});
|
|
}
|
|
|
|
function syncToNativeTabs(tabs) {
|
|
const windowId = tabs[0].windowId;
|
|
//log(`syncToNativeTabs(${windowId})`);
|
|
if (mDelayedSyncTimer.has(windowId))
|
|
clearTimeout(mDelayedSyncTimer.get(windowId));
|
|
const delayedSync = new Promise((resolve, _reject) => {
|
|
mDelayedSyncTimer.set(windowId, setTimeout(() => {
|
|
mDelayedSync.delete(windowId);
|
|
let previousSync = mPreviousSync.get(windowId);
|
|
if (previousSync)
|
|
previousSync = previousSync.then(() => syncToNativeTabsInternal(windowId));
|
|
else
|
|
previousSync = syncToNativeTabsInternal(windowId);
|
|
previousSync = previousSync.then(resolve);
|
|
mPreviousSync.set(windowId, previousSync);
|
|
}, 250));
|
|
}).then(() => {
|
|
mPreviousSync.delete(windowId);
|
|
});
|
|
mDelayedSync.set(windowId, delayedSync);
|
|
return delayedSync;
|
|
}
|
|
async function syncToNativeTabsInternal(windowId) {
|
|
mDelayedSyncTimer.delete(windowId);
|
|
|
|
if (Tab.needToWaitTracked(windowId))
|
|
await Tab.waitUntilTrackedAll(windowId);
|
|
if (Tab.needToWaitMoved(windowId))
|
|
await Tab.waitUntilMovedAll(windowId);
|
|
|
|
const win = TabsStore.windows.get(windowId);
|
|
if (!win) // already destroyed
|
|
return;
|
|
|
|
// Tabs may be removed while waiting.
|
|
const internalOrder = TabsStore.windows.get(windowId).order;
|
|
const nativeTabsOrder = (await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler())).map(tab => tab.id);
|
|
log(`syncToNativeTabs(${windowId}): rearrange `, { internalOrder:internalOrder.join(','), nativeTabsOrder:nativeTabsOrder.join(',') });
|
|
|
|
log(`syncToNativeTabs(${windowId}): step1, internalOrder => nativeTabsOrder`);
|
|
let tabIdsForUpdatedIndices = Array.from(nativeTabsOrder);
|
|
|
|
const moveOperations = (new SequenceMatcher(nativeTabsOrder, internalOrder)).operations();
|
|
const movedTabs = new Set();
|
|
for (const operation of moveOperations) {
|
|
const [tag, fromStart, fromEnd, toStart, toEnd] = operation;
|
|
log(`syncToNativeTabs(${windowId}): operation `, { tag, fromStart, fromEnd, toStart, toEnd });
|
|
switch (tag) {
|
|
case 'equal':
|
|
case 'delete':
|
|
break;
|
|
|
|
case 'insert':
|
|
case 'replace':
|
|
let moveTabIds = internalOrder.slice(toStart, toEnd);
|
|
const referenceId = nativeTabsOrder[fromStart] || null;
|
|
let toIndex = -1;
|
|
let fromIndices = moveTabIds.map(id => tabIdsForUpdatedIndices.indexOf(id));
|
|
if (referenceId) {
|
|
toIndex = tabIdsForUpdatedIndices.indexOf(referenceId);
|
|
}
|
|
if (toIndex < 0)
|
|
toIndex = internalOrder.length;
|
|
// ignore already removed tabs!
|
|
moveTabIds = moveTabIds.filter((id, index) => fromIndices[index] > -1);
|
|
if (moveTabIds.length == 0)
|
|
continue;
|
|
fromIndices = fromIndices.filter(index => index > -1);
|
|
const fromIndex = fromIndices[0];
|
|
if (fromIndex < toIndex)
|
|
toIndex--;
|
|
log(`syncToNativeTabs(${windowId}): step1, move ${moveTabIds.join(',')} before ${referenceId} / from = ${fromIndex}, to = ${toIndex}`);
|
|
for (const movedId of moveTabIds) {
|
|
win.internalMovingTabs.set(movedId, -1);
|
|
win.alreadyMovedTabs.set(movedId, -1);
|
|
movedTabs.add(movedId);
|
|
}
|
|
logApiTabs(`tabs-move:syncToNativeTabs(${windowId}): step1, browser.tabs.move() `, moveTabIds, {
|
|
windowId,
|
|
index: toIndex
|
|
});
|
|
let reallyMovedTabIds = new Set();
|
|
try {
|
|
const reallyMovedTabs = await browser.tabs.move(moveTabIds, {
|
|
windowId,
|
|
index: toIndex
|
|
}).catch(ApiTabs.createErrorHandler(e => {
|
|
log(`syncToNativeTabs(${windowId}): step1, failed to move: `, String(e), e.stack);
|
|
throw e;
|
|
}));
|
|
reallyMovedTabIds = new Set(reallyMovedTabs.map(tab => tab.id));
|
|
}
|
|
catch(error) {
|
|
console.error(error);
|
|
}
|
|
for (const id of moveTabIds) {
|
|
if (reallyMovedTabIds.has(id))
|
|
continue;
|
|
log(`syncToNativeTabs(${windowId}): failed to move tab ${id}: maybe unplacable position (regular tabs in pinned tabs/pinned tabs in regular tabs), or any other reason`);
|
|
win.internalMovingTabs.delete(id);
|
|
win.alreadyMovedTabs.delete(id);
|
|
}
|
|
tabIdsForUpdatedIndices = tabIdsForUpdatedIndices.filter(id => !moveTabIds.includes(id));
|
|
tabIdsForUpdatedIndices.splice(toIndex, 0, ...moveTabIds);
|
|
break;
|
|
}
|
|
}
|
|
log(`syncToNativeTabs(${windowId}): step1, rearrange completed.`);
|
|
|
|
if (movedTabs.size > 0) {
|
|
// tabs.onMoved produced by this operation can break the order of tabs
|
|
// in the sidebar, so we need to synchronize complete order of tabs after
|
|
// all.
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_SYNC_TABS_ORDER,
|
|
windowId
|
|
});
|
|
|
|
// Multiple times asynchronous tab move is unstable, so we retry again
|
|
// for safety until all tabs are completely synchronized.
|
|
syncToNativeTabs([{ windowId }]);
|
|
}
|
|
}
|