Bug 1500479 - Part 2: expose tab successors in browser.tabs; r=mixedpuppy,rpl

1. Add successorId to the Tab type, so that it will be returned in, e.g.,
   browser.tabs.get calls

2. Extend or create the following methods on the browser.tabs API:
  - update: add successorTabId as an optional property on the provided
    updateProperties object
  - moveInSuccession: new method that manipulates tab successors in bulk

Depends on D4731

Differential Revision: https://phabricator.services.mozilla.com/D9272

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Ryan Hendrickson 2018-11-14 17:24:36 +00:00
Родитель 2b7367c607
Коммит 180811af32
8 изменённых файлов: 349 добавлений и 2 удалений

Просмотреть файл

@ -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.

Просмотреть файл

@ -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.`);

Просмотреть файл

@ -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": [

Просмотреть файл

@ -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]

Просмотреть файл

@ -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);
});
});

Просмотреть файл

@ -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);
},

Просмотреть файл

@ -550,6 +550,10 @@ class Tab extends TabBase {
return "complete";
}
get successorTabId() {
return -1;
}
get width() {
return this.browser.clientWidth;
}

Просмотреть файл

@ -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