/* ***** 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 * wanabe * Tetsuharu OHZEKI * Xidorn Quan (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 }]); } }