--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -2479,31 +2479,66 @@
var evt = new CustomEvent("TabBrowserInserted",
{ bubbles: true, detail: { insertedOnTabCreation: aInsertedOnTabCreation } });
aTab.dispatchEvent(evt);
]]>
</body>
</method>
+ <method name="canHideTab">
+ <parameter name="aTab"/>
+ <body>
+ <![CDATA[
+ "use strict";
+ // TODO Bug 1384515 followup. This is very similar to discardBrowser
+ // logic except that if the browser is already discarded
+ // (!browser.connected) we allow hiding the tab.
+ let browser = aTab.linkedBrowser;
+ return aTab && browser && !aTab.selected && !aTab.closing &&
+ !this._windowIsClosing && browser.isRemoteBrowser &&
+ !(browser.frameLoader && browser.frameLoader.tabParent.hasBeforeUnload) &&
+ !this._isOwningTab(aTab);
+ ]]>
+ </body>
+ </method>
+
+ <method name="_isOwningTab">
+ <parameter name="aTab"/>
+ <body>
+ <![CDATA[
+ "use strict";
+ // TODO Bug 1384515 followup. Owner tabs may close other tabs when
+ // discarded, so for now we do not discard owners.
+ for (let otherTab of this.tabs) {
+ if ("owner" in otherTab && otherTab.owner == aTab) {
+ return true;
+ }
+ }
+ return false;
+ ]]>
+ </body>
+ </method>
+
<method name="discardBrowser">
<parameter name="aBrowser"/>
<body>
<![CDATA[
"use strict";
let tab = this.getTabForBrowser(aBrowser);
if (!tab ||
tab.selected ||
tab.closing ||
this._windowIsClosing ||
!aBrowser.isConnected ||
!aBrowser.isRemoteBrowser ||
- aBrowser.frameLoader.tabParent.hasBeforeUnload) {
+ aBrowser.frameLoader.tabParent.hasBeforeUnload ||
+ this._isOwningTab(tab)) {
return;
}
// Set browser parameters for when browser is restored. Also remove
// listeners and set up lazy restore data in SessionStore. This must
// be done before aBrowser is destroyed and removed from the document.
tab._browserParams = { uriIsAboutBlank: aBrowser.currentURI.spec == "about:blank",
remoteType: aBrowser.remoteType,
@@ -3805,16 +3840,47 @@
this.showTab(tab);
}
this.tabContainer._handleTabSelect(true);
]]>
</body>
</method>
+ <method name="showTabs">
+ <parameter name="aTabs"/>
+ <body>
+ <![CDATA[
+ for (let tab of aTabs) {
+ if (tab.ownerGlobal.gBrowser == this) {
+ this.showTab(tab);
+ }
+ }
+
+ this.tabContainer._handleTabSelect(true);
+ ]]>
+ </body>
+ </method>
+
+ <method name="hideTabs">
+ <parameter name="aTabs"/>
+ <parameter name="aDiscard"/>
+ <body>
+ <![CDATA[
+ for (let tab of aTabs) {
+ if (tab.ownerGlobal.gBrowser == this) {
+ this.hideTab(tab, aDiscard);
+ }
+ }
+
+ this.tabContainer._handleTabSelect(true);
+ ]]>
+ </body>
+ </method>
+
<method name="showTab">
<parameter name="aTab"/>
<body>
<![CDATA[
if (aTab.hidden) {
aTab.removeAttribute("hidden");
this._visibleTabs = null; // invalidate cache
@@ -3827,20 +3893,24 @@
aTab.dispatchEvent(event);
}
]]>
</body>
</method>
<method name="hideTab">
<parameter name="aTab"/>
+ <parameter name="aDiscard"/>
<body>
<![CDATA[
if (!aTab.hidden && !aTab.pinned && !aTab.selected &&
- !aTab.closing) {
+ !aTab.closing && this.canHideTab(aTab)) {
+ if (aDiscard) {
+ this.discardBrowser(aTab.linkedBrowser);
+ }
aTab.setAttribute("hidden", "true");
this._visibleTabs = null; // invalidate cache
this.tabContainer.adjustTabstrip();
this.tabContainer._setPositionalAttributes();
let event = document.createEvent("Events");
--- a/browser/components/extensions/ext-browser.js
+++ b/browser/components/extensions/ext-browser.js
@@ -597,16 +597,20 @@ class Tab extends TabBase {
}
get frameLoader() {
// If we don't have a frameLoader yet, just return a dummy with no width and
// height.
return super.frameLoader || {lazyWidth: 0, lazyHeight: 0};
}
+ get hidden() {
+ return this.nativeTab.hidden;
+ }
+
get cookieStoreId() {
return getCookieStoreIdForTab(this, this.nativeTab);
}
get openerTabId() {
let opener = this.nativeTab.openerTab;
if (opener && opener.parentNode && opener.ownerDocument == this.nativeTab.ownerDocument) {
return tabTracker.getId(opener);
@@ -691,30 +695,33 @@ class Tab extends TabBase {
* @param {DOMWindow} [window = null]
* The browser window which the tab belonged to before it was closed.
* May be null if the window the tab belonged to no longer exists.
*
* @returns {Object}
* @static
*/
static convertFromSessionStoreClosedData(extension, tabData, window = null) {
+ let state = tabData.state || tabData;
+
let result = {
sessionId: String(tabData.closedId),
index: tabData.pos ? tabData.pos : 0,
windowId: window && windowTracker.getId(window),
highlighted: false,
active: false,
pinned: false,
- incognito: Boolean(tabData.state && tabData.state.isPrivate),
- lastAccessed: tabData.state ? tabData.state.lastAccessed : tabData.lastAccessed,
+ hidden: state.hidden,
+ incognito: Boolean(state.isPrivate),
+ lastAccessed: state.lastAccessed,
};
if (extension.tabManager.hasTabPermission(tabData)) {
- let entries = tabData.state ? tabData.state.entries : tabData.entries;
- let lastTabIndex = tabData.state ? tabData.state.index : tabData.index;
+ let entries = state.entries;
+ let lastTabIndex = state.index;
// We need to take lastTabIndex - 1 because the index in the tab data is
// 1-based rather than 0-based.
let entry = entries[lastTabIndex - 1];
result.url = entry.url;
result.title = entry.title;
if (tabData.image) {
result.favIconUrl = tabData.image;
}
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -98,16 +98,48 @@ this.tabs = class extends ExtensionAPI {
tab = tabManager.getWrapper(tabTracker.activeTab);
}
await tabListener.awaitTabReady(tab.nativeTab);
return tab;
}
+ function getBrowserTabsMap(tabIds, hidden = true) {
+ let containers = new Map();
+ tabIds.forEach(tabId => {
+ let tab = tabTracker.getTab(tabId);
+ if (tab.hidden == hidden) {
+ if (!containers.has(tab.ownerGlobal.gBrowser)) {
+ containers.set(tab.ownerGlobal.gBrowser, [tab]);
+ } else {
+ containers.get(tab.ownerGlobal.gBrowser).push(tab);
+ }
+ }
+ });
+ return containers;
+ }
+
+ if (extension.hasPermission("tabHide")) {
+ extension.callOnClose({
+ close: () => {
+ if (extension.shutdownReason == "ADDON_DISABLE" ||
+ extension.shutdownReason == "ADDON_UNINSTALL") {
+ // Show all hidden tabs if a tab managing extension is uninstalled or
+ // disabled. If a user has more than one, the extensions will need to
+ // self-manage re-hiding tabs.
+ let tabIds = Array.from(tabTracker._tabIds.keys());
+ for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, true).entries()) {
+ gBrowser.showTabs(tabs);
+ }
+ }
+ },
+ });
+ }
+
let self = {
tabs: {
onActivated: new EventManager(context, "tabs.onActivated", fire => {
let listener = (eventName, event) => {
fire.async(event);
};
tabTracker.on("tab-activated", listener);
@@ -264,16 +296,20 @@ this.tabs = class extends ExtensionAPI {
}
} else if (event.type == "TabPinned") {
needed.push("pinned");
} else if (event.type == "TabUnpinned") {
needed.push("pinned");
} else if (event.type == "TabBrowserInserted" &&
!event.detail.insertedOnTabCreation) {
needed.push("discarded");
+ } else if (event.type == "TabShow") {
+ needed.push("hidden");
+ } else if (event.type == "TabHide") {
+ needed.push("hidden");
}
let tab = tabManager.getWrapper(event.originalTarget);
let changeInfo = {};
for (let prop of needed) {
changeInfo[prop] = tab[prop];
}
@@ -301,25 +337,29 @@ this.tabs = class extends ExtensionAPI {
fireForTab(tab, {isArticle: event.data.isArticle});
};
windowTracker.addListener("status", statusListener);
windowTracker.addListener("TabAttrModified", listener);
windowTracker.addListener("TabPinned", listener);
windowTracker.addListener("TabUnpinned", listener);
windowTracker.addListener("TabBrowserInserted", listener);
+ windowTracker.addListener("TabShow", listener);
+ windowTracker.addListener("TabHide", listener);
tabTracker.on("tab-isarticle", isArticleChangeListener);
return () => {
windowTracker.removeListener("status", statusListener);
windowTracker.removeListener("TabAttrModified", listener);
windowTracker.removeListener("TabPinned", listener);
windowTracker.removeListener("TabUnpinned", listener);
windowTracker.removeListener("TabBrowserInserted", listener);
+ windowTracker.removeListener("TabShow", listener);
+ windowTracker.removeListener("TabHide", listener);
tabTracker.off("tab-isarticle", isArticleChangeListener);
};
}).api(),
create(createProperties) {
return new Promise((resolve, reject) => {
let window = createProperties.windowId !== null ?
windowTracker.getWindow(createProperties.windowId, context) :
@@ -933,13 +973,42 @@ this.tabs = class extends ExtensionAPI {
let tab = await promiseTabWhenReady(tabId);
if (!tab.isInReaderMode && !tab.isArticle) {
throw new ExtensionError("The specified tab cannot be placed into reader mode.");
}
tab = getTabOrActive(tabId);
tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
},
+
+ show(tabIds) {
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+ for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, true).entries()) {
+ gBrowser.showTabs(tabs);
+ }
+ },
+
+ hide(tabIds) {
+ if (!extension.hasPermission("tabHide")) {
+ throw new ExtensionError("tabHide permission required.");
+ }
+ if (!Array.isArray(tabIds)) {
+ tabIds = [tabIds];
+ }
+ for (let [gBrowser, tabs] of getBrowserTabsMap(tabIds, false).entries()) {
+ // TODO Bug 1384515 followup. We additionally discard hidden tabs
+ // which places the tab back into a suspended lazy state. This will
+ // become an option later.
+ gBrowser.hideTabs(tabs, true);
+ }
+ },
+
+ isHideable(tabId) {
+ let nativeTab = tabTracker.getTab(tabId);
+ return nativeTab.ownerGlobal.gBrowser.canHideTab(nativeTab);
+ },
},
};
return self;
}
};
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -7,17 +7,18 @@
"namespace": "manifest",
"types": [
{
"$extend": "OptionalPermission",
"choices": [{
"type": "string",
"enum": [
"activeTab",
- "tabs"
+ "tabs",
+ "tabHide"
]
}]
}
]
},
{
"namespace": "tabs",
"description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.",
@@ -70,16 +71,17 @@
"url": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
"title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
"favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."},
"status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."},
"discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."},
"incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."},
"width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
"height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
+ "hidden": {"type": "boolean", "optional": true, "description": "True if the tab is hidden."},
"sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
"cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
"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."}
}
},
{
"id": "ZoomSettingsMode",
@@ -592,16 +594,21 @@
"optional": true,
"description": "Whether the tabs have completed loading."
},
"discarded": {
"type": "boolean",
"optional": true,
"description": "True while the tabs are not loaded with content."
},
+ "hidden": {
+ "type": "boolean",
+ "optional": true,
+ "description": "True while the tabs are hidden."
+ },
"title": {
"type": "string",
"optional": true,
"description": "Match page titles against a pattern."
},
"url": {
"choices": [
{"type": "string"},
@@ -1187,16 +1194,62 @@
{
"type": "string",
"name": "status",
"description": "Save status: saved, replaced, canceled, not_saved, not_replaced."
}
]
}
]
+ },
+ {
+ "name": "show",
+ "type": "function",
+ "description": "Shows one or more tabs.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The TAB ID or list of TAB IDs to show.",
+ "choices": [
+ {"type": "integer", "minimum": 0},
+ {"type": "array", "items": {"type": "integer", "minimum": 0}}
+ ]
+ }
+ ]
+ },
+ {
+ "name": "hide",
+ "type": "function",
+ "description": "Hides one or more tabs. The <code>\"tabHide\"</code> permission is required to hide tabs.",
+ "async": true,
+ "parameters": [
+ {
+ "name": "tabIds",
+ "description": "The TAB ID or list of TAB IDs to hide.",
+ "choices": [
+ {"type": "integer", "minimum": 0},
+ {"type": "array", "items": {"type": "integer", "minimum": 0}}
+ ]
+ }
+ ]
+ },
+ {
+ "name": "isHideable",
+ "type": "function",
+ "description": "Returns whether the tab can be hidden or not.",
+ "async": true,
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0,
+ "description": "The ID of the tab to check."
+ }
+ ]
}
],
"events": [
{
"name": "onCreated",
"type": "function",
"description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.",
"parameters": [
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -139,16 +139,17 @@ skip-if = os == "linux" && debug && bits
[browser_ext_tabs_events.js]
[browser_ext_tabs_executeScript.js]
[browser_ext_tabs_executeScript_good.js]
[browser_ext_tabs_executeScript_bad.js]
[browser_ext_tabs_executeScript_multiple.js]
[browser_ext_tabs_executeScript_no_create.js]
[browser_ext_tabs_executeScript_runAt.js]
[browser_ext_tabs_getCurrent.js]
+[browser_ext_tabs_hide.js]
[browser_ext_tabs_insertCSS.js]
[browser_ext_tabs_lastAccessed.js]
[browser_ext_tabs_lazy.js]
[browser_ext_tabs_removeCSS.js]
[browser_ext_tabs_move_array.js]
[browser_ext_tabs_move_window.js]
[browser_ext_tabs_move_window_multiple.js]
[browser_ext_tabs_move_window_pinned.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js
@@ -0,0 +1,155 @@
+"use strict";
+
+function* BrowserWindowIterator() {
+ let windowsEnum = Services.wm.getEnumerator("navigator:browser");
+ while (windowsEnum.hasMoreElements()) {
+ let currentWindow = windowsEnum.getNext();
+ if (!currentWindow.closed) {
+ yield currentWindow;
+ }
+ }
+}
+
+// Close all but our primary window.
+function promiseAllButPrimaryWindowClosed() {
+ let windows = [];
+ for (let win of BrowserWindowIterator()) {
+ if (win != window) {
+ windows.push(win);
+ }
+ }
+
+ return Promise.all(windows.map(BrowserTestUtils.closeWindow));
+}
+
+function promiseNotification(topic) {
+ return new Promise((resolve, reject) => {
+ function observe(subject, topic, data) {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }
+ Services.obs.addObserver(observe, topic);
+ });
+}
+
+add_task(async function test_tabs_showhide() {
+ async function background() {
+ browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
+ if ("hidden" in changeInfo) {
+ browser.test.sendMessage("changeInfo", {tabId, changeInfo});
+ }
+ });
+ browser.test.onMessage.addListener(async (msg, data) => {
+ switch (msg) {
+ case "hideall": {
+ let tabs = await browser.tabs.query({hidden: false});
+ let ids = tabs.map(tab => tab.id);
+ for (let id of ids) {
+ let tab = await browser.tabs.get(id);
+ // selected tab cannot be hidden
+ browser.test.assertEq(!tab.highlighted, await browser.tabs.isHideable(tab.id), "tab hideable value ok");
+ browser.test.assertEq(false, tab.hidden, "tab is visible");
+ }
+ browser.tabs.hide(ids);
+ break;
+ }
+ case "showall": {
+ let tabs = await browser.tabs.query({hidden: true});
+ let ids = tabs.map(tab => tab.id);
+ for (let id of ids) {
+ let tab = await browser.tabs.get(id);
+ browser.test.assertEq(true, tab.hidden, "tab is hidden");
+ }
+ browser.tabs.show(ids);
+ break;
+ }
+ }
+ });
+ }
+
+ let extdata = {
+ manifest: {permissions: ["tabs", "tabHide"]},
+ useAddonManager: "temporary",
+ background,
+ };
+ let extension = ExtensionTestUtils.loadExtension(extdata);
+ await extension.startup();
+
+ let sessData = {
+ windows: [{
+ tabs: [
+ {entries: [{url: "about:blank"}]},
+ {entries: [{url: "https://example.com/"}]},
+ {entries: [{url: "https://mochi.test:8888/"}]},
+ ],
+ }, {
+ tabs: [
+ {entries: [{url: "about:blank"}]},
+ {entries: [{url: "http://test1.example.com/"}]},
+ ],
+ }],
+ };
+
+ let restored = promiseNotification("sessionstore-browser-state-restored");
+ SessionStore.setBrowserState(JSON.stringify(sessData));
+ await restored;
+
+ extension.sendMessage("hideall");
+ await extension.awaitMessage("changeInfo");
+ await extension.awaitMessage("changeInfo");
+ await extension.awaitMessage("changeInfo");
+
+ let otherwin;
+ for (let win of BrowserWindowIterator()) {
+ if (win != window) {
+ otherwin = win;
+ }
+ let tabs = Array.from(win.gBrowser.tabs.values());
+ ok(!tabs[0].hidden, "first tab not hidden");
+ for (let i = 1; i < tabs.length; i++) {
+ ok(tabs[i].hidden, "tab hidden value is correct");
+ }
+ }
+
+ // Test closing the last visible tab, the next tab which is hidden should become
+ // visible.
+ ok(!otherwin.gBrowser.selectedTab.hidden, "selected tab is not hidden");
+ await Promise.all([
+ BrowserTestUtils.removeTab(otherwin.gBrowser.selectedTab),
+ extension.awaitMessage("changeInfo"),
+ ]);
+ ok(!otherwin.gBrowser.selectedTab.hidden, "tab was unhidden");
+
+ extension.sendMessage("showall");
+ await extension.awaitMessage("changeInfo");
+ await extension.awaitMessage("changeInfo");
+
+ for (let win of BrowserWindowIterator()) {
+ let tabs = Array.from(win.gBrowser.tabs.values());
+ for (let i = 0; i < tabs.length; i++) {
+ ok(!tabs[i].hidden, "tab hidden value is correct");
+ }
+ }
+
+ // Test that opener tabs cannot be hidden.
+ // TODO Bug 1384515 Followup, when we can optionally not discard we'll need
+ // to update the test to check it is hidden but not discarded.
+ let selectedTab = otherwin.gBrowser.selectedTab;
+ otherwin.gBrowser.addTab("http://test1.example.com/", {ownerTab: selectedTab, openerBrowser: selectedTab.linkedBrowser});
+ extension.sendMessage("hideall");
+ await extension.awaitMessage("changeInfo");
+ await extension.awaitMessage("changeInfo");
+ await extension.awaitMessage("changeInfo");
+ ok(!selectedTab.hidden, "opener tab is not hidden");
+
+ // close tabs in first window
+ let tabs = Array.from(gBrowser.tabs.values());
+ tabs.shift();
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+ // close second window
+ await promiseAllButPrimaryWindowClosed();
+
+ await extension.unload();
+});
--- a/toolkit/components/extensions/ext-tabs-base.js
+++ b/toolkit/components/extensions/ext-tabs-base.js
@@ -448,16 +448,18 @@ class TabBase {
* @param {object} queryInfo
* The query info against which to match.
* @param {boolean} [queryInfo.active]
* Matches against the exact value of the tab's `active` attribute.
* @param {boolean} [queryInfo.audible]
* Matches against the exact value of the tab's `audible` attribute.
* @param {string} [queryInfo.cookieStoreId]
* Matches against the exact value of the tab's `cookieStoreId` attribute.
+ * @param {boolean} [queryInfo.hidden]
+ * Matches against the exact value of the tab's `hidden` attribute.
* @param {boolean} [queryInfo.highlighted]
* Matches against the exact value of the tab's `highlighted` attribute.
* @param {integer} [queryInfo.index]
* Matches against the exact value of the tab's `index` attribute.
* @param {boolean} [queryInfo.muted]
* Matches against the exact value of the tab's `mutedInfo.muted` attribute.
* @param {boolean} [queryInfo.pinned]
* Matches against the exact value of the tab's `pinned` attribute.
@@ -470,17 +472,17 @@ class TabBase {
* than an exact value match, and will do so in the future.
* @param {MatchPattern} [queryInfo.url]
* Requires the tab's URL to match the given MatchPattern object.
*
* @returns {boolean}
* True if the tab matches the query.
*/
matches(queryInfo) {
- const PROPS = ["active", "audible", "cookieStoreId", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
+ const PROPS = ["active", "audible", "cookieStoreId", "hidden", "highlighted", "index", "openerTabId", "pinned", "status", "title"];
if (PROPS.some(prop => queryInfo[prop] != null && queryInfo[prop] !== this[prop])) {
return false;
}
if (queryInfo.muted !== null) {
if (queryInfo.muted !== this.mutedInfo.muted) {
return false;
@@ -508,16 +510,17 @@ class TabBase {
let result = {
id: this.id,
index: this.index,
windowId: this.windowId,
highlighted: this.selected,
active: this.selected,
pinned: this.pinned,
status: this.status,
+ hidden: this.hidden,
discarded: this.discarded,
incognito: this.incognito,
width: this.width,
height: this.height,
lastAccessed: this.lastAccessed,
audible: this.audible,
mutedInfo: this.mutedInfo,
isArticle: this.isArticle,