diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index 9676548a79c5..d552c1256989 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -780,6 +780,11 @@ class Tab extends TabBase { return this.url && this.url.startsWith(READER_MODE_PREFIX); } + get successorTabId() { + const {successor} = this.nativeTab; + return successor ? tabTracker.getId(successor) : -1; + } + /** * Converts session store data to an object compatible with the return value * of the convert() method, representing that data. diff --git a/browser/components/extensions/parent/ext-tabs.js b/browser/components/extensions/parent/ext-tabs.js index e58092974fba..4ef7113208ad 100644 --- a/browser/components/extensions/parent/ext-tabs.js +++ b/browser/components/extensions/parent/ext-tabs.js @@ -31,6 +31,8 @@ XPCOMUtils.defineLazyPreferenceGetter(this, "gMultiSelectEnabled", MULTISELECT_P const TAB_HIDE_CONFIRMED_TYPE = "tabHideNotification"; +const TAB_ID_NONE = -1; + XPCOMUtils.defineLazyGetter(this, "tabHidePopup", () => { return new ExtensionControlledPopup({ @@ -727,6 +729,19 @@ this.tabs = class extends ExtensionAPI { } tabTracker.setOpener(nativeTab, opener); } + if (updateProperties.successorTabId !== null) { + let successor = null; + if (updateProperties.successorTabId !== TAB_ID_NONE) { + successor = tabTracker.getTab(updateProperties.successorTabId, null); + if (!successor) { + throw new ExtensionError("Invalid successorTabId"); + } + if (successor.ownerDocument !== nativeTab.ownerDocument) { + throw new ExtensionError("Successor tab must be in the same window as the tab being updated"); + } + } + tabbrowser.setSuccessor(nativeTab, successor); + } return tabManager.convert(nativeTab); }, @@ -1240,6 +1255,57 @@ this.tabs = class extends ExtensionAPI { tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode"); }, + moveInSuccession(tabIds, tabId, options) { + const {insert, append} = options || {}; + const tabIdSet = new Set(tabIds); + if (tabIdSet.size !== tabIds.length) { + throw new ExtensionError("IDs must not occur more than once in tabIds"); + } + if ((append || insert) && tabIdSet.has(tabId)) { + throw new ExtensionError("Value of tabId must not occur in tabIds if append or insert is true"); + } + + const referenceTab = tabTracker.getTab(tabId, null); + let referenceWindow = referenceTab && referenceTab.ownerGlobal; + let previousTab, lastSuccessor; + if (append) { + previousTab = referenceTab; + lastSuccessor = (insert && referenceTab && referenceTab.successor) || null; + } else { + lastSuccessor = referenceTab; + } + + let firstTab; + for (const tabId of tabIds) { + const tab = tabTracker.getTab(tabId, null); + if (tab === null) { + continue; + } + if (referenceWindow === null) { + referenceWindow = tab.ownerGlobal; + } else if (tab.ownerGlobal !== referenceWindow) { + continue; + } + referenceWindow.gBrowser.replaceInSuccession(tab, tab.successor); + if (append && tab === lastSuccessor) { + lastSuccessor = tab.successor; + } + if (previousTab) { + referenceWindow.gBrowser.setSuccessor(previousTab, tab); + } else { + firstTab = tab; + } + previousTab = tab; + } + + if (previousTab) { + if (!append && insert && lastSuccessor !== null) { + referenceWindow.gBrowser.replaceInSuccession(lastSuccessor, firstTab); + } + referenceWindow.gBrowser.setSuccessor(previousTab, lastSuccessor); + } + }, + show(tabIds) { if (!Services.prefs.getBoolPref(TABHIDE_PREFNAME, false)) { throw new ExtensionError(`tabs.show is currently experimental and must be enabled with the ${TABHIDE_PREFNAME} preference.`); diff --git a/browser/components/extensions/schemas/tabs.json b/browser/components/extensions/schemas/tabs.json index f8d7b40f4408..30de1bc10dda 100644 --- a/browser/components/extensions/schemas/tabs.json +++ b/browser/components/extensions/schemas/tabs.json @@ -102,7 +102,8 @@ "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."}, "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}, "sharingState": {"$ref": "SharingState", "optional": true, "description": "Current tab sharing state for screen, microphone and camera."}, - "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."} + "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."}, + "successorTabId": {"type": "integer", "optional": true, "minimum": -1, "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."} } }, { @@ -888,6 +889,12 @@ "type": "boolean", "optional": true, "description": "Whether the load should replace the current history entry for the tab." + }, + "successorTabId": { + "type": "integer", + "minimum": -1, + "optional": true, + "description": "The ID of this tab's successor. If specified, the successor tab must be in the same window as this tab." } } }, @@ -1396,6 +1403,48 @@ ] } ] + }, + { + "name": "moveInSuccession", + "type": "function", + "async": true, + "description": "Removes an array of tabs from their lines of succession and prepends or appends them in a chain to another tab.", + "parameters": [ + { + "name": "tabIds", + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 1, + "description": "An array of tab IDs to move in the line of succession. For each tab in the array, the tab's current predecessors will have their successor set to the tab's current successor, and each tab will then be set to be the successor of the previous tab in the array. Any tabs not in the same window as the tab indicated by the second argument (or the first tab in the array, if no second argument) will be skipped." + }, + { + "name": "tabId", + "type": "integer", + "optional": true, + "default": -1, + "minimum": -1, + "description": "The ID of a tab to set as the successor of the last tab in the array, or $(ref:tabs.TAB_ID_NONE) to leave the last tab without a successor. If options.append is true, then this tab is made the predecessor of the first tab in the array instead." + }, + { + "name": "options", + "type": "object", + "optional": true, + "properties": { + "append": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Whether to move the tabs before (false) or after (true) tabId in the succession. Defaults to false." + }, + "insert": { + "type": "boolean", + "optional": true, + "default": false, + "description": "Whether to link up the current predecessors or successor (depending on options.append) of tabId to the other side of the chain after it is prepended or appended. If true, one of the following happens: if options.append is false, the first tab in the array is set as the successor of any current predecessors of tabId; if options.append is true, the current successor of tabId is set as the successor of the last tab in the array. Defaults to false." + } + } + } + ] } ], "events": [ diff --git a/browser/components/extensions/test/browser/browser-common.ini b/browser/components/extensions/test/browser/browser-common.ini index 95fbb8890349..62d5d0703641 100644 --- a/browser/components/extensions/test/browser/browser-common.ini +++ b/browser/components/extensions/test/browser/browser-common.ini @@ -222,6 +222,7 @@ skip-if = (verify && !debug && (os == 'mac')) skip-if = os == 'mac' # Save as PDF not supported on Mac OS X [browser_ext_tabs_sendMessage.js] [browser_ext_tabs_sharingState.js] +[browser_ext_tabs_successors.js] [browser_ext_tabs_cookieStoreId.js] [browser_ext_tabs_update.js] [browser_ext_tabs_update_highlighted.js] diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_successors.js b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js new file mode 100644 index 000000000000..16cfb73105a4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js @@ -0,0 +1,212 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function background(tabCount, testFn) { + try { + const {TAB_ID_NONE} = browser.tabs; + const tabIds = await Promise.all(Array.from({length: tabCount}, () => browser.tabs.create({url: "about:blank"}).then(t => t.id))); + + const toTabIds = i => tabIds[i]; + + const setSuccessors = mapping => Promise.all(mapping.map((succ, i) => + browser.tabs.update(tabIds[i], {successorTabId: tabIds[succ]}))); + + const verifySuccessors = async function(mapping, name) { + const promises = [], expected = []; + for (let i = 0; i < mapping.length; i++) { + if (mapping[i] !== undefined) { + promises.push(browser.tabs.get(tabIds[i]).then(t => t.successorTabId)); + expected.push(mapping[i] === TAB_ID_NONE ? TAB_ID_NONE : tabIds[mapping[i]]); + } + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; i++) { + browser.test.assertEq(expected[i], results[i], `${name}: successorTabId of tab ${i} in mapping should be ${expected[i]}`); + } + }; + + await testFn({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}); + + browser.test.notifyPass("background-script"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("background-script"); + } +} + +async function runTabTest(tabCount, testFn) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background: `(${background})(${tabCount}, ${testFn});`, + }); + + await extension.startup(); + await extension.awaitFinish("background-script"); + await extension.unload(); +} + +add_task(function testTabSuccessors() { + return runTabTest(3, async function({TAB_ID_NONE, tabIds}) { + const anotherWindow = await browser.windows.create({url: "about:blank"}); + + browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "Tabs default to an undefined successor"); + + // Basic getting and setting + + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]}); + browser.test.assertEq(tabIds[1], (await browser.tabs.get(tabIds[0])).successorTabId, "tabs.update assigned the correct successor"); + + await browser.tabs.update(tabIds[0], {successorTabId: browser.tabs.TAB_ID_NONE}); + browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "tabs.update cleared successor"); + + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]}); + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[0]}); + browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "Setting a tab as its own successor clears the successor instead"); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], {successorTabId: 1e8}), + /Invalid successorTabId/, + "tabs.update should throw with an invalid successor tab ID"); + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], {successorTabId: anotherWindow.tabs[0].id}), + /Successor tab must be in the same window as the tab being updated/, + "tabs.update should throw with a successor tab ID from another window"); + + // Make sure the successor is truly being assigned + + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[2], active: true}); + await browser.tabs.remove(tabIds[0]); + browser.test.assertEq(tabIds[2], (await browser.tabs.query({active: true}))[0].id); + + + return browser.tabs.remove([tabIds[1], tabIds[2], anotherWindow.tabs[0].id]); + }); +}); + +add_task(function testMoveInSuccession_appendFalse() { + return runTabTest(8, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) { + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors([TAB_ID_NONE, 0], "scenario 1"); + + await browser.tabs.moveInSuccession([0, 1, 2, 3].map(toTabIds), tabIds[0]); + await verifySuccessors([1, 2, 3, 0], "scenario 2"); + + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors([TAB_ID_NONE, 0], "scenario 1 after tab 0 has a successor"); + + await browser.tabs.update(tabIds[7], {successorTabId: tabIds[0]}); + await browser.tabs.moveInSuccession([4, 5, 6, 7].map(toTabIds)); + await verifySuccessors(new Array(4).concat([5, 6, 7, TAB_ID_NONE]), "scenario 4"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7]); + await verifySuccessors([7, TAB_ID_NONE, 7, 2, 6, 7, 3, 5], "scenario 5"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {insert: true}); + await verifySuccessors([4, TAB_ID_NONE, 7, 2, 6, 4, 3, 5], "insert = true"); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], {insert: true}); + await verifySuccessors([4, 2, 0, 1, 3], "insert = true, part 2"); + + await browser.tabs.moveInSuccession([tabIds[0], tabIds[1], 1e8, tabIds[2]]); + await verifySuccessors([1, 2, TAB_ID_NONE], "unknown tab ID"); + + browser.test.assertTrue(await browser.tabs.moveInSuccession([1e8]).then(() => true, () => false), "When all tab IDs are unknown, tabs.moveInSuccession should not throw"); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1], tabIds[0]]), + /IDs must not occur more than once in tabIds/, + "tabs.moveInSuccession should throw when a tab is referenced more than once in tabIds"); + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], {insert: true}), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true"); + + + return browser.tabs.remove(tabIds); + }); +}); + +add_task(function testMoveInSuccession_appendTrue() { + return runTabTest(8, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) { + await browser.tabs.moveInSuccession([1].map(toTabIds), tabIds[0], {append: true}); + await verifySuccessors([1, TAB_ID_NONE], "scenario 1"); + + await browser.tabs.update(tabIds[3], {successorTabId: tabIds[4]}); + await browser.tabs.moveInSuccession([1, 2, 3].map(toTabIds), tabIds[0], {append: true}); + await verifySuccessors([1, 2, 3, TAB_ID_NONE], "scenario 2"); + + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]}); + await browser.tabs.moveInSuccession([1e8], tabIds[0], {append: true}); + browser.test.assertEq(TAB_ID_NONE, (await browser.tabs.get(tabIds[0])).successorTabId, "If no tabs get appended after the reference tab, it should lose its successor"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {append: true}); + await verifySuccessors([7, TAB_ID_NONE, TAB_ID_NONE, 2, 6, 7, 3, 4], "scenario 3"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession([4, 6, 3, 2].map(toTabIds), tabIds[7], {append: true, insert: true}); + await verifySuccessors([7, TAB_ID_NONE, 5, 2, 6, 7, 3, 4], "insert = true"); + + await browser.tabs.moveInSuccession([0, 4].map(toTabIds), tabIds[7], {append: true, insert: true}); + await verifySuccessors([4, undefined, undefined, undefined, 6, undefined, undefined, 0], "insert = true, part 2"); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], {append: true, insert: true}); + await verifySuccessors([3, 2, 4, 1, 0], "insert = true, part 3"); + + await browser.tabs.update(tabIds[0], {successorTabId: tabIds[1]}); + await browser.tabs.moveInSuccession([1e8], tabIds[0], {append: true, insert: true}); + browser.test.assertEq(tabIds[1], (await browser.tabs.get(tabIds[0])).successorTabId, "If no tabs get inserted after the reference tab, it should keep its successor"); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], {append: true}), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true"); + + + return browser.tabs.remove(tabIds); + }); +}); + +add_task(function testMoveInSuccession_ignoreTabsInOtherWindows() { + return runTabTest(2, async function({TAB_ID_NONE, tabIds, toTabIds, setSuccessors, verifySuccessors}) { + const anotherWindow = await browser.windows.create({url: Array.from({length: 3}, () => "about:blank")}); + tabIds.push(...anotherWindow.tabs.map(t => t.id)); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4]); + await verifySuccessors([1, 0, 4, 2, TAB_ID_NONE], "first tab in another window"); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4]); + await verifySuccessors([1, 0, 4, 2, TAB_ID_NONE], "middle tab in another window"); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds)); + await verifySuccessors([1, 0, TAB_ID_NONE, 2, TAB_ID_NONE], "using the first tab to determine the window"); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4], {append: true}); + await verifySuccessors([1, 0, TAB_ID_NONE, 2, 3], "first tab in another window, appending"); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4], {append: true}); + await verifySuccessors([1, 0, TAB_ID_NONE, 2, 3], "middle tab in another window, appending"); + + return browser.tabs.remove(tabIds); + }); +}); diff --git a/browser/components/extensions/test/mochitest/test_ext_all_apis.html b/browser/components/extensions/test/mochitest/test_ext_all_apis.html index 4fd70c8a85e8..1732f4d3836f 100644 --- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html +++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html @@ -36,6 +36,7 @@ let expectedBackgroundApisTargetSpecific = [ "tabs.highlight", "tabs.insertCSS", "tabs.move", + "tabs.moveInSuccession", "tabs.onActivated", "tabs.onAttached", "tabs.onCreated", diff --git a/mobile/android/components/extensions/ext-tabs.js b/mobile/android/components/extensions/ext-tabs.js index 60007a767004..47cdd85b95c6 100644 --- a/mobile/android/components/extensions/ext-tabs.js +++ b/mobile/android/components/extensions/ext-tabs.js @@ -336,7 +336,7 @@ this.tabs = class extends ExtensionAPI { // Not sure what to do here? Which tab should we select? } } - // FIXME: highlighted/selected, muted, pinned, openerTabId + // FIXME: highlighted/selected, muted, pinned, openerTabId, successorTabId return tabManager.convert(nativeTab); }, diff --git a/mobile/android/components/extensions/ext-utils.js b/mobile/android/components/extensions/ext-utils.js index 2d615bac5101..9cd431ecfe8c 100644 --- a/mobile/android/components/extensions/ext-utils.js +++ b/mobile/android/components/extensions/ext-utils.js @@ -550,6 +550,10 @@ class Tab extends TabBase { return "complete"; } + get successorTabId() { + return -1; + } + get width() { return this.browser.clientWidth; } diff --git a/toolkit/components/extensions/parent/ext-tabs-base.js b/toolkit/components/extensions/parent/ext-tabs-base.js index 3cc229650fa9..683cd2a261bf 100644 --- a/toolkit/components/extensions/parent/ext-tabs-base.js +++ b/toolkit/components/extensions/parent/ext-tabs-base.js @@ -495,6 +495,15 @@ class TabBase { throw new Error("Not implemented"); } + /** + * @property {integer} successorTabId + * @readonly + * @abstract + */ + get successorTabId() { + throw new Error("Not implemented"); + } + /** * Returns true if this tab matches the the given query info object. Omitted * or null have no effect on the match. @@ -609,6 +618,7 @@ class TabBase { isArticle: this.isArticle, isInReaderMode: this.isInReaderMode, sharingState: this.sharingState, + successorTabId: this.successorTabId, }; // If the tab has not been fully layed-out yet, fallback to the geometry