Update On Sun Apr 13 20:22:56 CEST 2025

This commit is contained in:
github-action[bot] 2025-04-13 20:22:57 +02:00
parent 745952e3b8
commit 9c69112ad3
2090 changed files with 102264 additions and 32761 deletions

33
Cargo.lock generated
View file

@ -2524,6 +2524,7 @@ dependencies = [
name = "gkrust-uniffi-components"
version = "0.1.0"
dependencies = [
"hashbrown 0.15.2",
"relevancy",
"search",
"suggest",
@ -2794,7 +2795,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7db2ff139bba50379da6aa0766b52fdcb62cb5b263009b09ed58ba604e14bbd1"
dependencies = [
"arbitrary",
"cfg-if",
"crunchy",
"num-traits",
@ -4498,7 +4498,7 @@ checksum = "a2983372caf4480544083767bf2d27defafe32af49ab4df3a0b7fc90793a3664"
[[package]]
name = "naga"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"arrayvec",
"bit-set",
@ -4506,7 +4506,7 @@ dependencies = [
"cfg_aliases",
"codespan-reporting",
"half 2.5.0",
"hashbrown 0.14.999",
"hashbrown 0.15.2",
"hexf-parse",
"indexmap",
"log",
@ -5931,19 +5931,20 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.0.0"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
dependencies = [
"serde",
"serde_derive",
"serde_with_macros",
]
[[package]]
name = "serde_with_macros"
version = "3.0.0"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc7d5d3932fb12ce722ee5e64dd38c504efba37567f0c402f6ca728c3b8b070"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
"darling",
"proc-macro2",
@ -7266,7 +7267,7 @@ version = "0.3.100"
[[package]]
name = "webdriver"
version = "0.52.1"
version = "0.53.0"
dependencies = [
"base64 0.22.1",
"bytes",
@ -7427,7 +7428,7 @@ dependencies = [
[[package]]
name = "wgpu-core"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"arrayvec",
"bit-set",
@ -7436,7 +7437,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"document-features",
"hashbrown 0.14.999",
"hashbrown 0.15.2",
"indexmap",
"log",
"naga",
@ -7457,7 +7458,7 @@ dependencies = [
[[package]]
name = "wgpu-core-deps-apple"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"wgpu-hal",
]
@ -7465,7 +7466,7 @@ dependencies = [
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"wgpu-hal",
]
@ -7473,7 +7474,7 @@ dependencies = [
[[package]]
name = "wgpu-hal"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"android_system_properties",
"arrayvec",
@ -7487,7 +7488,7 @@ dependencies = [
"gpu-alloc",
"gpu-allocator",
"gpu-descriptor",
"hashbrown 0.14.999",
"hashbrown 0.15.2",
"libc",
"libloading",
"log",
@ -7509,7 +7510,7 @@ dependencies = [
[[package]]
name = "wgpu-types"
version = "24.0.0"
source = "git+https://github.com/gfx-rs/wgpu?rev=c7c79a0dc9356081a884b5518d1c08ce7a09c7c5#c7c79a0dc9356081a884b5518d1c08ce7a09c7c5"
source = "git+https://github.com/gfx-rs/wgpu?rev=a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109#a0dbe5ebc6fa24422fb84b2e0fea1cc94dee5109"
dependencies = [
"bitflags 2.9.0",
"bytemuck",
@ -7723,7 +7724,7 @@ dependencies = [
[[package]]
name = "wr_malloc_size_of"
version = "0.0.2"
version = "0.2.0"
dependencies = [
"app_units",
"euclid",

View file

@ -554,6 +554,7 @@ void DocManager::ClearDocCache() {
}
void DocManager::RemoteDocAdded(DocAccessibleParent* aDoc) {
MOZ_ASSERT(aDoc->IsTopLevel());
if (!sRemoteDocuments) {
sRemoteDocuments = new nsTArray<DocAccessibleParent*>;
ClearOnShutdown(&sRemoteDocuments);
@ -563,6 +564,12 @@ void DocManager::RemoteDocAdded(DocAccessibleParent* aDoc) {
"How did we already have the doc!");
sRemoteDocuments->AppendElement(aDoc);
ProxyCreated(aDoc);
// Fire a reorder event on the OuterDocAccessible.
if (LocalAccessible* outerDoc = aDoc->OuterDocOfRemoteBrowser()) {
MOZ_ASSERT(outerDoc->Document());
RefPtr<AccReorderEvent> reorder = new AccReorderEvent(outerDoc);
outerDoc->Document()->FireDelayedEvent(reorder);
}
}
DocAccessible* mozilla::a11y::GetExistingDocAccessible(

View file

@ -15,7 +15,6 @@ XULMAP_TYPE(menu, XULMenuitemAccessible)
XULMAP_TYPE(menubar, XULMenubarAccessible)
XULMAP_TYPE(menucaption, XULMenuitemAccessible)
XULMAP_TYPE(menuitem, XULMenuitemAccessible)
XULMAP_TYPE(menulist, XULComboboxAccessible)
XULMAP_TYPE(menuseparator, XULMenuSeparatorAccessible)
XULMAP_TYPE(notification, XULAlertAccessible)
XULMAP_TYPE(radio, XULRadioButtonAccessible)
@ -67,6 +66,19 @@ XULMAP(image,
return new ImageAccessible(aElement, aContext->Document());
})
XULMAP(menulist,
[](Element* aElement, LocalAccessible* aContext) -> LocalAccessible* {
nsAutoString domID;
if (nsCoreUtils::GetID(aElement, domID)) {
if (domID.Equals(u"ContentSelectDropdown"_ns)) {
return new XULContentSelectDropdownAccessible(
aElement, aContext->Document());
}
}
return new XULComboboxAccessible(aElement, aContext->Document());
})
XULMAP(menupopup, [](Element* aElement, LocalAccessible* aContext) {
return CreateMenupopupAccessible(aElement, aContext);
})

View file

@ -1806,11 +1806,8 @@ void DocAccessible::ProcessLoad() {
#endif
// Do not fire document complete/stop events for root chrome document
// accessibles and for frame/iframe documents because
// a) screen readers start working on focus event in the case of root chrome
// documents
// b) document load event on sub documents causes screen readers to act is if
// entire page is reloaded.
// accessibles because screen readers start working on focus event in the case
// of root chrome documents.
if (!IsLoadEventTarget()) return;
// Fire complete/load stopped if the load event type is given.
@ -2984,31 +2981,7 @@ void DocAccessible::ShutdownChildrenInSubtree(LocalAccessible* aAccessible) {
}
bool DocAccessible::IsLoadEventTarget() const {
nsCOMPtr<nsIDocShellTreeItem> treeItem = mDocumentNode->GetDocShell();
if (!treeItem) {
return false;
}
nsCOMPtr<nsIDocShellTreeItem> parentTreeItem;
treeItem->GetInProcessParent(getter_AddRefs(parentTreeItem));
// Not a root document.
if (parentTreeItem) {
// Return true if it's either:
// a) tab document;
nsCOMPtr<nsIDocShellTreeItem> rootTreeItem;
treeItem->GetInProcessRootTreeItem(getter_AddRefs(rootTreeItem));
if (parentTreeItem == rootTreeItem) return true;
// b) frame/iframe document and its parent document is not in loading state
// Note: we can get notifications while document is loading (and thus
// while there's no parent document yet).
DocAccessible* parentDoc = ParentDocument();
return parentDoc && parentDoc->HasLoadState(eCompletelyLoaded);
}
// It's content (not chrome) root document.
return (treeItem->ItemType() == nsIDocShellTreeItem::typeContent);
return mDocumentNode->GetBrowsingContext()->IsContent();
}
void DocAccessible::SetIPCDoc(DocAccessibleChild* aIPCDoc) {

View file

@ -624,10 +624,8 @@ class DocAccessible : public HyperTextAccessible,
* Return true if the document is a target of document loading events
* (for example, state busy change or document reload events).
*
* Rules: The root chrome document accessible is never an event target
* (for example, Firefox UI window). If the sub document is loaded within its
* parent document then the parent document is a target only (aka events
* coalescence).
* Rule: The root chrome document accessible is never an event target
* (for example, Firefox UI window).
*/
bool IsLoadEventTarget() const;

View file

@ -126,36 +126,18 @@ export const CommonUtils = {
},
/**
* Extract DOMNode id from an accessible. If the accessible is in the remote
* process, DOMNode is not present in parent process. However, if specified by
* the author, DOMNode id will be attached to an accessible object.
*
* Obtain DOMNode id from an accessible. This simply queries the .id property
* on the accessible, but it catches exceptions which might occur if the
* accessible has died.
* @param {nsIAccessible} accessible accessible
* @return {String?} DOMNode id if available
*/
getAccessibleDOMNodeID(accessible) {
if (accessible instanceof Ci.nsIAccessibleDocument) {
// If accessible is a document, trying to find its document body id.
try {
return accessible.DOMNode.body.id;
} catch (e) {
/* This only works if accessible is not a proxy. */
}
}
try {
return accessible.DOMNode.id;
} catch (e) {
/* This will fail if DOMNode is in different process. */
}
try {
// When e10s is enabled, accessible will have an "id" property if its
// corresponding DOMNode has an id. If accessible is a document, its "id"
// property corresponds to the "id" of its body element.
return accessible.id;
} catch (e) {
/* This will fail if accessible is not a proxy. */
// This will fail if the accessible has died.
}
return null;
},

View file

@ -19,7 +19,6 @@ support-files = [
["browser_test_caret_move_granularity.js"]
["browser_test_docload.js"]
skip-if = ["true"]
["browser_test_focus_browserui.js"]

View file

@ -31,17 +31,13 @@ function urlChecker(url) {
}
async function runTests(browser) {
let onLoadEvents = waitForEvents({
expected: [
[EVENT_REORDER, getAccessible(browser)],
[EVENT_DOCUMENT_LOAD_COMPLETE, "body2"],
[EVENT_STATE_CHANGE, busyChecker(false)],
],
unexpected: [
[EVENT_DOCUMENT_LOAD_COMPLETE, inIframeChecker("iframe1")],
[EVENT_STATE_CHANGE, inIframeChecker("iframe1")],
],
});
let onLoadEvents = waitForEvents([
[EVENT_REORDER, getAccessible(browser)],
[EVENT_DOCUMENT_LOAD_COMPLETE, "body2"],
[EVENT_STATE_CHANGE, busyChecker(false)],
[EVENT_DOCUMENT_LOAD_COMPLETE, inIframeChecker("iframe1")],
[EVENT_STATE_CHANGE, inIframeChecker("iframe1")],
]);
BrowserTestUtils.startLoadingURIString(
browser,
@ -123,6 +119,6 @@ async function runTests(browser) {
}
/**
* Test caching of accessible object states
* Test events when a document loads.
*/
addAccessibleTask("", runTests);
addAccessibleTask("", runTests, { chrome: true, topLevel: true });

View file

@ -595,6 +595,9 @@ function accessibleTask(doc, task, options = {}) {
} else {
({ accessible: docAccessible } = await onContentDocLoad);
}
// The test may want to access document methods/attributes such as URL
// and browsingContext.
docAccessible.QueryInterface(nsIAccessibleDocument);
let iframeDocAccessible;
if (gIsIframe) {
if (!options.skipFissionDocLoad) {
@ -603,6 +606,7 @@ function accessibleTask(doc, task, options = {}) {
? (await onIframeDocLoad).accessible
: findAccessibleChildByID(docAccessible, DEFAULT_IFRAME_ID)
.firstChild;
iframeDocAccessible.QueryInterface(nsIAccessibleDocument);
}
}

View file

@ -30,6 +30,8 @@ skip-if = [
["browser_searchbar.js"]
["browser_select.js"]
["browser_shadowdom.js"]
["browser_test_nsIAccessibleDocument_URL.js"]

View file

@ -0,0 +1,168 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/* import-globals-from ../../mochitest/states.js */
loadScripts({ name: "states.js", dir: MOCHITESTS_DIR });
/* import-globals-from ../../mochitest/role.js */
loadScripts({ name: "role.js", dir: MOCHITESTS_DIR });
/**
* Verify that the local accessible created in the parent process for the
* id=ContentSelectDropdown node has its parent spoofed to the relevant
* expanded select.
* Note: id=select2 is unused and serves only to ensure we don't return
* _just any_ select inside the document :)
*/
addAccessibleTask(
`
<select id="select">
<option id="a">optiona</option>
</select>
<select id="select2">
<option id="b">optionb</option>
</select>
`,
async function testSelectAncestorChain(browser, accDoc) {
const LOCAL_DROPDOWN_ID = "ContentSelectDropdown";
const rootAcc = getRootAccessible(document);
ok(rootAcc, "Root Accessible exists");
const optA = findAccessibleChildByID(accDoc, "a");
const select = findAccessibleChildByID(accDoc, "select");
let remoteAccDropdown = select.firstChild;
ok(remoteAccDropdown, "Remote acc dropdown exists");
let isRemote = true;
try {
remoteAccDropdown.id;
} catch (e) {
isRemote = false;
}
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Attempt to fetch the local dropdown
let localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
is(
localAccDropdown,
null,
"Local dropdown cannot be reached while select is collapsed"
);
// Focus the combo box.
await invokeFocus(browser, "select");
// Expand the combobox dropdown.
let p = waitForEvent(EVENT_STATE_CHANGE, LOCAL_DROPDOWN_ID);
EventUtils.synthesizeKey("VK_SPACE");
await p;
// Attempt to fetch the local dropdown
localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
ok(localAccDropdown, "Local dropdown exists when select is expanded.");
// Verify the dropdown rendered in the parent
// process is a child of the select
is(localAccDropdown.parent, select, "Dropdown is a child of the select");
// Verify walking from the select produces the
// appropriate option
remoteAccDropdown = select.firstChild;
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Close the dropdown.
p = waitForEvents({
expected: [[EVENT_HIDE, LOCAL_DROPDOWN_ID]],
});
EventUtils.synthesizeKey("VK_ESCAPE");
await p;
// Verify walking down from the select produces the
// appropriate option
remoteAccDropdown = select.firstChild;
// Verify the remote dropdown:
// - is the role we expect
// - is not identical to the local dropdown via ID
// - is the parent of the remote accessible for the
// option the select contains
if (isRemote) {
is(
remoteAccDropdown.role,
ROLE_COMBOBOX_LIST,
"Select's first child is the combobox list"
);
isnot(
remoteAccDropdown.id,
LOCAL_DROPDOWN_ID,
"Remote dropdown does not match local dropdown's ID."
);
is(
remoteAccDropdown.firstChild,
optA,
"Remote dropdown contains remote acc of option A."
);
}
// Attempt to fetch the local dropdown
localAccDropdown = findAccessibleChildByID(rootAcc, LOCAL_DROPDOWN_ID);
is(
localAccDropdown,
null,
"Local dropdown cannot be reached while select is collapsed"
);
},
{
chrome: true,
topLevel: true,
iframe: true,
remoteIframe: true,
}
);

View file

@ -868,33 +868,17 @@ function getTextFromClipboard() {
}
/**
* Extract DOMNode id from an accessible. If e10s is enabled, DOMNode is not
* present in parent process but, if available, DOMNode id is attached to an
* accessible object.
* Obtain DOMNode id from an accessible. This simply queries the .id property
* on the accessible, but it catches exceptions which might occur if the
* accessible has died.
* @param {nsIAccessible} accessible accessible
* @return {String?} DOMNode id if available
*/
function getAccessibleDOMNodeID(accessible) {
if (accessible instanceof nsIAccessibleDocument) {
// If accessible is a document, trying to find its document body id.
try {
return accessible.DOMNode.body.id;
} catch (e) {
/* This only works if accessible is not a proxy. */
}
}
try {
return accessible.DOMNode.id;
} catch (e) {
/* This will fail if DOMNode is in different process. */
}
try {
// When e10s is enabled, accessible will have an "id" property if its
// corresponding DOMNode has an id. If accessible is a document, its "id"
// property corresponds to the "id" of its body element.
return accessible.id;
} catch (e) {
/* This will fail if accessible is not a proxy. */
// This will fail if the accessible has died.
}
return null;
}

View file

@ -173,13 +173,8 @@ xpcAccessible::GetId(nsAString& aID) {
return NS_ERROR_FAILURE;
}
RemoteAccessible* proxy = IntlGeneric()->AsRemote();
if (!proxy) {
return NS_ERROR_FAILURE;
}
nsString id;
proxy->DOMNodeID(id);
IntlGeneric()->DOMNodeID(id);
aID.Assign(id);
return NS_OK;

View file

@ -9,6 +9,9 @@
#include "nsAccessibilityService.h"
#include "DocAccessible.h"
#include "nsCoreUtils.h"
#include "nsFocusManager.h"
#include "mozilla/a11y/DocAccessibleParent.h"
#include "mozilla/a11y/Role.h"
#include "States.h"
@ -140,3 +143,46 @@ bool XULComboboxAccessible::AreItemsOperable() const {
return false;
}
////////////////////////////////////////////////////////////////////////////////
// XULContentSelectDropdownAccessible
////////////////////////////////////////////////////////////////////////////////
Accessible* XULContentSelectDropdownAccessible::Parent() const {
// We render the expanded dropdown for <select>s in the parent process
// as a child of the application accessible. This confuses some
// ATs which expect the select to _always_ parent the dropdown (in
// both expanded and collapsed states).
// To rectify this, we spoof the <select> as the parent of the
// expanded dropdown here. Note that we do not spoof the child relationship.
// First, try to find the select that spawned this dropdown.
// The select that was activated does not get states::EXPANDED, but
// it should still have focus.
Accessible* focusedAcc = nullptr;
if (auto* focusedNode = FocusMgr()->FocusedDOMNode()) {
// If we get a node here, we're in a non-remote browser.
DocAccessible* doc =
GetAccService()->GetDocAccessible(focusedNode->OwnerDoc());
focusedAcc = doc->GetAccessible(focusedNode);
} else {
nsFocusManager* focusManagerDOM = nsFocusManager::GetFocusManager();
dom::BrowsingContext* focusedContext =
focusManagerDOM->GetFocusedBrowsingContextInChrome();
DocAccessibleParent* focusedDoc =
DocAccessibleParent::GetFrom(focusedContext);
MOZ_ASSERT(focusedDoc && focusedDoc->IsDoc(), "No focused document found");
focusedAcc = focusedDoc->AsDoc()->GetFocusedAcc();
}
if (!NS_WARN_IF(focusedAcc && focusedAcc->IsHTMLCombobox())) {
// We can sometimes get a document here if the select that
// this dropdown should anchor to loses focus. This can happen when
// calling AXPressed on macOS. Call into the regular parent
// function instead.
return LocalParent();
}
return focusedAcc;
}

View file

@ -37,6 +37,21 @@ class XULComboboxAccessible : public AccessibleWrap {
MOZ_CAN_RUN_SCRIPT_BOUNDARY bool AreItemsOperable() const override;
};
/**
* Used for the singular, global instance of a XULCombobox which is rendered
* in the parent process and contains the options of the focused and expanded
* HTML select in a content document. This combobox should have
* id=ContentSelectDropdown
*/
class XULContentSelectDropdownAccessible : public XULComboboxAccessible {
public:
XULContentSelectDropdownAccessible(nsIContent* aContent, DocAccessible* aDoc)
: XULComboboxAccessible(aContent, aDoc) {}
// Accessible
virtual Accessible* Parent() const override;
};
} // namespace a11y
} // namespace mozilla

View file

@ -212,7 +212,7 @@ pref("app.update.langpack.enabled", true);
// This feature is also affected by
// `app.update.multiSessionInstallLockout.timeoutMs`, which is in the branding
// section.
pref("app.update.multiSessionInstallLockout.enabled", true);
pref("app.update.multiSessionInstallLockout.enabled", false);
#if defined(MOZ_BACKGROUNDTASKS)
// The amount of time, in seconds, before background tasks time out and exit.
@ -1811,8 +1811,9 @@ pref("browser.partnerlink.campaign.topsites", "amzn_2020_a1");
pref("browser.newtab.preload", true);
// If an on-train limited rollout of the preonboarding modal is enabled, the
// percentage of the Mac, Linux, and MSIX population to enroll
pref("browser.preonboarding.onTrainRolloutPopulation", 0);
// percentage of the Mac, Linux, and MSIX population to enroll. Default to 25% of
// population (2500 / 10000).
pref("browser.preonboarding.onTrainRolloutPopulation", 2500);
// Mozilla Ad Routing Service (MARS) unified ads service
pref("browser.newtabpage.activity-stream.unifiedAds.tiles.enabled", true);
@ -3055,9 +3056,6 @@ pref("devtools.webconsole.filter.netxhr", false);
// Webconsole autocomplete preference
pref("devtools.webconsole.input.autocomplete",true);
// Show context selector in console input
pref("devtools.webconsole.input.context", true);
// Set to true to eagerly show the results of webconsole terminal evaluations
// when they don't have side effects.
pref("devtools.webconsole.input.eagerEvaluation", true);

View file

@ -146,9 +146,18 @@ customElements.define(
}
get hasNoPermissions() {
const { strings, showIncognitoCheckbox } =
this.notification.options.customElementOptions;
return !(showIncognitoCheckbox || strings.msgs.length);
const {
strings,
showIncognitoCheckbox,
showTechnicalAndInteractionCheckbox,
} = this.notification.options.customElementOptions;
return !(
strings.msgs.length ||
this.#dataCollectionPermissions?.msg ||
showIncognitoCheckbox ||
showTechnicalAndInteractionCheckbox
);
}
get domainsSet() {
@ -171,9 +180,25 @@ customElements.define(
return strings.fullDomainsList.msgIdIndex === idx;
}
/**
* @returns {{idx: number, collectsTechnicalAndInteractionData: boolean}}
* An object with information about data collection permissions for the UI.
*/
get #dataCollectionPermissions() {
if (!this.notification?.options?.customElementOptions) {
return undefined;
}
const { strings } = this.notification.options.customElementOptions;
return strings.dataCollectionPermissions;
}
render() {
const { strings, showIncognitoCheckbox, isUserScriptsRequest } =
this.notification.options.customElementOptions;
const {
strings,
showIncognitoCheckbox,
showTechnicalAndInteractionCheckbox,
isUserScriptsRequest,
} = this.notification.options.customElementOptions;
const { textEl, introEl, permsListEl } = this;
@ -240,6 +265,28 @@ customElements.define(
permsListEl.appendChild(item);
}
if (this.#dataCollectionPermissions?.msg) {
let item = doc.createElementNS(HTML_NS, "li");
item.classList.add(
"webext-perm-granted",
"webext-data-collection-perm-granted"
);
item.textContent = this.#dataCollectionPermissions.msg;
permsListEl.appendChild(item);
}
// Add a checkbox for the "technicalAndInteraction" optional data
// collection permission.
if (showTechnicalAndInteractionCheckbox) {
let item = doc.createElementNS(HTML_NS, "li");
item.classList.add(
"webext-perm-optional",
"webext-data-collection-perm-optional"
);
item.appendChild(this.#createTechnicalAndInteractionDataCheckbox());
permsListEl.appendChild(item);
}
if (showIncognitoCheckbox) {
let item = doc.createElementNS(HTML_NS, "li");
item.classList.add(
@ -367,6 +414,28 @@ customElements.define(
);
return checkboxEl;
}
#createTechnicalAndInteractionDataCheckbox() {
const { grantTechnicalAndInteractionDataCollection } =
this.notification.options.customElementOptions;
const checkboxEl = this.ownerDocument.createXULElement("checkbox");
checkboxEl.label = lazy.PERMISSION_L10N.formatValueSync(
"webext-perms-description-data-long-technicalAndInteraction"
);
checkboxEl.checked = grantTechnicalAndInteractionDataCollection;
checkboxEl.addEventListener("CheckboxStateChange", () => {
// NOTE: the popupnotification instances will be reused
// and so the callback function is destructured here to
// avoid this custom element to prevent it from being
// garbage collected.
const { onTechnicalAndInteractionDataChanged } =
this.notification.options.customElementOptions;
onTechnicalAndInteractionDataChanged?.(checkboxEl.checked);
});
return checkboxEl;
}
}
);

View file

@ -1185,8 +1185,12 @@ var gIdentityHandler = {
},
setURI(uri) {
if (uri instanceof Ci.nsINestedURI) {
uri = uri.QueryInterface(Ci.nsINestedURI).innermostURI;
// Unnest the URI, turning "view-source:https://example.com" into
// "https://example.com" for example. "about:" URIs are a special exception
// here, as some of them have a hidden moz-safe-about inner URI we do not
// want to unnest.
while (uri instanceof Ci.nsINestedURI && !uri.schemeIs("about")) {
uri = uri.QueryInterface(Ci.nsINestedURI).innerURI;
}
this._uri = uri;
@ -1197,7 +1201,7 @@ var gIdentityHandler = {
this._uriHasHost = false;
}
if (uri.schemeIs("about") || uri.schemeIs("moz-safe-about")) {
if (uri.schemeIs("about")) {
let module = E10SUtils.getAboutModule(uri);
if (module) {
let flags = module.getURIFlags(uri);

View file

@ -4541,7 +4541,10 @@ function undoCloseTab(aIndex, sourceWindowSSId) {
if (SessionStore.getSavedTabGroup(lastClosedTabGroupId)) {
group = SessionStore.openSavedTabGroup(
lastClosedTabGroupId,
targetWindow
targetWindow,
{
source: "recent",
}
);
} else {
group = SessionStore.undoCloseTabGroup(
@ -4917,17 +4920,26 @@ var RestoreLastSessionObserver = {
!PrivateBrowsingUtils.isWindowPrivate(window)
) {
Services.obs.addObserver(this, "sessionstore-last-session-cleared", true);
Services.obs.addObserver(
this,
"sessionstore-last-session-re-enable",
true
);
goSetCommandEnabled("Browser:RestoreLastSession", true);
} else if (SessionStore.willAutoRestore) {
document.getElementById("Browser:RestoreLastSession").hidden = true;
}
},
observe() {
// The last session can only be restored once so there's
// no way we need to re-enable our menu item.
Services.obs.removeObserver(this, "sessionstore-last-session-cleared");
goSetCommandEnabled("Browser:RestoreLastSession", false);
observe(aSubject, aTopic) {
switch (aTopic) {
case "sessionstore-last-session-cleared":
goSetCommandEnabled("Browser:RestoreLastSession", false);
break;
case "sessionstore-last-session-re-enable":
goSetCommandEnabled("Browser:RestoreLastSession", true);
break;
}
},
QueryInterface: ChromeUtils.generateQI([

View file

@ -16,6 +16,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';">
<!-- These localization links are not automatically applied to any XHR
response body and must be applied manually as well. They are included
so that viewing the file directly shows the results. -->

View file

@ -319,12 +319,20 @@
</menupopup>
<menupopup id="sidebar-history-menu">
<menuitem data-l10n-id="sidebar-history-sort-by-date"
<html:h1 data-l10n-id="sidebar-history-sort-by-heading"
id="sidebar-history-sort-by-heading"/>
<menuitem data-l10n-id="sidebar-history-sort-option-date"
id="sidebar-history-sort-by-date"
type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-by-site"
<menuitem data-l10n-id="sidebar-history-sort-option-site"
id="sidebar-history-sort-by-site"
type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-option-date-and-site"
id="sidebar-history-sort-by-date-and-site"
type="checkbox"/>
<menuitem data-l10n-id="sidebar-history-sort-option-last-visited"
id="sidebar-history-sort-by-last-visited"
type="checkbox"/>
<menuseparator/>
<menuitem data-l10n-id="sidebar-history-clear"
id="sidebar-history-clear"/>

View file

@ -10,8 +10,7 @@ document.addEventListener(
() => {
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
TabGroupMetrics:
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
});
let mainPopupSet = document.getElementById("mainPopupSet");
// eslint-disable-next-line complexity
@ -135,11 +134,12 @@ document.addEventListener(
let tabGroup = gBrowser.getTabGroupById(tabGroupId);
// Tabs need to be removed by their owning `Tabbrowser` or else
// there are errors.
tabGroup.ownerGlobal.gBrowser.removeTabGroup(tabGroup, {
isUserTriggered: true,
telemetrySource:
lazy.TabGroupMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU,
});
tabGroup.ownerGlobal.gBrowser.removeTabGroup(
tabGroup,
lazy.TabMetrics.userTriggeredContext(
lazy.TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU
)
);
}
break;
@ -147,14 +147,18 @@ document.addEventListener(
case "saved-tab-group-context-menu_openInThisWindow":
{
let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
SessionStore.openSavedTabGroup(tabGroupId, window);
SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
});
}
break;
case "saved-tab-group-context-menu_openInNewWindow":
{
// TODO Bug 1940112: "Open Group in New Window" should directly restore saved tab groups into a new window
let { tabGroupId } = event.target.parentElement.triggerNode.dataset;
let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window);
let tabGroup = SessionStore.openSavedTabGroup(tabGroupId, window, {
source: lazy.TabMetrics.METRIC_SOURCE.RECENT_TABS,
});
gBrowser.replaceGroupWithWindow(tabGroup);
}
break;

View file

@ -151,13 +151,14 @@ security.ui.protectionspopup:
type: boolean
bugs:
- https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews:
- https://bugzil.la/1920735
notification_emails:
- wwen@mozilla.com
- emz@mozilla.com
expires:
140
146
open_protectionspopup_cfr:
type: event
@ -389,13 +390,14 @@ security.ui.protectionspopup:
type: string
bugs:
- https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews:
- https://bugzil.la/1920735
notification_emails:
- wwen@mozilla.com
- emz@mozilla.com
expires:
140
146
smartblockembeds_shown:
type: counter
@ -403,13 +405,14 @@ security.ui.protectionspopup:
How many times the SmartBlock placeholders are shown on the page
bugs:
- https://bugzil.la/1920735
- https://bugzil.la/1958162
data_reviews:
- https://bugzil.la/1920735
notification_emails:
- wwen@mozilla.com
- emz@mozilla.com
expires:
140
146
browser.engagement:
bookmarks_toolbar_bookmark_added:

View file

@ -164,7 +164,6 @@
<toolbartabstop/>
<html:div id="urlbar"
popover="manual"
context=""
focused="true"
pageproxystate="invalid"
unifiedsearchbutton-available=""

View file

@ -60,6 +60,14 @@ const knownUnshownImages = [
file: "chrome://global/skin/icons/highlights.svg",
platforms: ["win", "linux", "macosx"],
intermittentShown: ["win", "linux"],
// this file is not loaded in beta since the pref is only
// turned on in nightly
intermittentNotLoaded: Services.prefs.getBoolPref(
"browser.tabs.groups.smart.enabled",
true
)
? []
: ["win", "linux", "macosx"],
},
];

View file

@ -79,6 +79,12 @@ var tests = [
hostForDisplay: "chrome://global/content/mozilla.html",
hasSubview: false,
},
{
name: "about:logo with nested moz-safe-about:logo",
location: "about:logo",
hostForDisplay: "about:logo",
hasSubview: false,
},
];
add_task(async function test() {

View file

@ -28,6 +28,7 @@ export const AdditionalCTA = ({ content, handleAction }) => {
<div className={className}>
<Localized text={content.additional_button?.label}>
<button
id="additional_button"
className={`${buttonStyle} additional-cta`}
onClick={handleAction}
value="additional_button"

View file

@ -401,6 +401,7 @@ export const SecondaryCTA = props => {
</Localized>
<Localized text={props.content[targetElement].label}>
<button
id="secondary_button"
className={buttonStyling}
value={targetElement}
disabled={isDisabled(props.content.secondary_button?.disabled)}

View file

@ -142,12 +142,14 @@ const SubmenuButtonInner = ({ content, handleAction }) => {
return (
<Localized text={content.submenu_button.label ?? {}}>
<button
id="submenu_button"
className={`submenu-button ${isPrimary ? "primary" : "secondary"}`}
value="submenu_button"
onClick={onClick}
ref={ref}
aria-haspopup="menu"
aria-expanded={isSubmenuExpanded}
aria-labelledby={`${content.submenu_button.attached_to} submenu_button`}
/>
</Localized>
);

View file

@ -468,6 +468,7 @@ const SecondaryCTA = props => {
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("span", null)), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: props.content[targetElement].label
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "secondary_button",
className: buttonStyling,
value: targetElement,
disabled: isDisabled(props.content.secondary_button?.disabled),
@ -1828,6 +1829,7 @@ const AdditionalCTA = ({
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: content.additional_button?.label
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "additional_button",
className: `${buttonStyle} additional-cta`,
onClick: handleAction,
value: "additional_button",
@ -1991,12 +1993,14 @@ const SubmenuButtonInner = ({
return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
text: content.submenu_button.label ?? {}
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
id: "submenu_button",
className: `submenu-button ${isPrimary ? "primary" : "secondary"}`,
value: "submenu_button",
onClick: onClick,
ref: ref,
"aria-haspopup": "menu",
"aria-expanded": isSubmenuExpanded
"aria-expanded": isSubmenuExpanded,
"aria-labelledby": `${content.submenu_button.attached_to} submenu_button`
}));
};

View file

@ -564,6 +564,10 @@
"minumum": 0,
"exclusiveMaximum": 10
},
"dismissable": {
"description": "Should the infobar include an X dismiss button, defaults to true",
"type": "boolean"
},
"buttons": {
"type": "array",
"items": {

View file

@ -24,6 +24,10 @@
"minumum": 0,
"exclusiveMaximum": 10
},
"dismissable": {
"description": "Should the infobar include an X dismiss button, defaults to true",
"type": "boolean"
},
"buttons": {
"type": "array",
"items": {

View file

@ -1692,6 +1692,7 @@ const MESSAGES = () => {
style: "secondary",
label: {
marginBlock: "0 -8px",
string_id: "split-dismiss-button-default-label",
},
},
tiles: {
@ -1960,6 +1961,7 @@ const MESSAGES = () => {
style: "secondary",
label: {
marginBlock: "0 -8px",
string_id: "split-dismiss-button-default-label",
},
},
tiles: {

View file

@ -45,7 +45,9 @@ class InfoBarNotification {
priority,
eventCallback: this.infobarCallback,
},
content.buttons.map(b => this.formatButtonConfig(b))
content.buttons.map(b => this.formatButtonConfig(b)),
false,
content.dismissable
);
this.addImpression();

View file

@ -177,3 +177,78 @@ add_task(async function prevent_multiple_messages() {
infobar.notification.closeButton.click();
Assert.equal(InfoBar._activeInfobar, null, "Cleared the active notification");
});
add_task(async function default_dismissable_button_shows() {
let message = (await CFRMessageProvider.getMessages()).find(
m => m.id === "INFOBAR_ACTION_86"
);
Assert.ok(message, "Found the message");
// Use the base message which has no dismissable property by default.
let dispatchStub = sinon.stub();
let infobar = await InfoBar.showInfoBarMessage(
BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
message,
dispatchStub
);
Assert.ok(
infobar.notification.closeButton,
"Default message should display a close button"
);
infobar.notification.closeButton.click();
await BrowserTestUtils.waitForCondition(
() => infobar.notification === null,
"Wait for default message notification to be dismissed."
);
});
add_task(
async function non_dismissable_notification_does_not_show_close_button() {
let baseMessage = (await CFRMessageProvider.getMessages()).find(
m => m.id === "INFOBAR_ACTION_86"
);
Assert.ok(baseMessage, "Found the base message");
let message = {
...baseMessage,
content: {
...baseMessage.content,
dismissable: false,
},
};
// Add a footer button we can close the infobar with
message.content.buttons.push({
label: "Cancel",
action: {
type: "CANCEL",
},
});
let dispatchStub = sinon.stub();
let infobar = await InfoBar.showInfoBarMessage(
BrowserWindowTracker.getTopWindow().gBrowser.selectedBrowser,
message,
dispatchStub
);
Assert.ok(
!infobar.notification.closeButton,
"Non-dismissable message should not display a close button"
);
let cancelButton = infobar.notification.querySelector(
".footer-button:not(.primary)"
);
Assert.ok(cancelButton, "Non-primary footer button exists");
cancelButton.click();
await BrowserTestUtils.waitForCondition(
() => infobar.notification === null,
"Wait for default message notification to close."
);
}
);

View file

@ -67,18 +67,57 @@ export const ContentAnalysis = {
isInitialized: false,
// Maps string UserActionId to { userActionId, requestTokenSet, timer } or
// { userActionId, requestTokenSet, notification }
/**
* @typedef {object} NotificationInfo - information about the busy dialog itself that is showing
* @property {*} [close] - Method to close the native notification
* @property {BrowsingContext} [dialogBrowsingContext] - browsing context where the
* confirm() dialog is shown
*/
/**
* @typedef {object} BusyDialogInfo - information about a busy dialog that is either showing or will
* will be shown after a delay.
* @property {string} userActionId - The userActionId of the request
* @property {Set<string>} requestTokenSet - The set of requestTokens associated with the userActionId
* @property {*} [timer] - Result of a setTimeout() call that can be used to cancel the showing of the busy
* dialog if it has not been displayed yet.
* @property {NotificationInfo} [notification] - Information about the busy dialog that is being shown.
*/
/**
* @type {Map<string, BusyDialogInfo>}
*
* Maps string UserActionId to info about the busy dialog.
*/
userActionToBusyDialogMap: new Map(),
/**
* @type {Map<string, {browsingContext: BrowsingContext, resourceNameOrOperationType: object}>}
* @typedef {object} ResourceNameOrOperationType
* @property {string} [name] - the name of the resource
* @property {number} [operationType] - the type of operation
*/
/**
* @typedef {object} RequestInfo
* @property {BrowsingContext} browsingContext - browsing context where the request was sent from
* @property {ResourceNameOrOperationType} resourceNameOrOperationType - name of the operation
*/
/**
* @type {Map<string, RequestInfo>}
*/
requestTokenToRequestInfo: new Map(),
/**
* @type {Set<string>}
*/
warnDialogRequestTokens: new Set(),
/**
* Registers for various messages/events that will indicate the
* need for communicating something to the user.
*
* @param {Window} window - The window to monitor
*/
initialize(window) {
if (!lazy.gContentAnalysis.isActive) {
@ -180,6 +219,17 @@ export const ContentAnalysis = {
break;
}
case "quit-application": {
// We're quitting, so respond false to all WARN dialogs.
let requestTokensToCancel = this.warnDialogRequestTokens;
// Clear this first so the handler showing the dialog will know not
// to call respondToWarnDialog() again.
this.warnDialogRequestTokens = new Set();
for (let warnDialogRequestToken of requestTokensToCancel) {
lazy.gContentAnalysis.respondToWarnDialog(
warnDialogRequestToken,
false
);
}
this.uninitialize();
break;
}
@ -256,6 +306,12 @@ export const ContentAnalysis = {
}
},
/**
* Shows the panel that indicates that DLP is active.
*
* @param {Element} element The toolbarbutton the user has clicked on
* @param {panelUI} panelUI Maintains state for the main menu panel
*/
async showPanel(element, panelUI) {
element.ownerDocument.l10n.setAttributes(
lazy.PanelMultiView.getViewNode(
@ -268,6 +324,11 @@ export const ContentAnalysis = {
panelUI.showSubView("content-analysis-panel", element);
},
/**
* Closes a busy dialog
*
* @param {BusyDialogInfo?} caView - the busy dialog to close
*/
_disconnectFromView(caView) {
if (!caView) {
return;
@ -301,6 +362,16 @@ export const ContentAnalysis = {
}
},
/**
* Shows either a dialog or native notification or both, depending on the values of
* _SHOW_DIALOGS and _SHOW_NOTIFICATIONS.
*
* @param {string} aMessage - Message to show
* @param {BrowsingContext} aBrowsingContext - BrowsingContext to show the dialog in.
* @param {number} aTimeout - timeout for closing the native notification. 0 indicates it is
* not automatically closed.
* @returns {NotificationInfo?} - information about the native notification, if it has been shown.
*/
_showMessage(aMessage, aBrowsingContext, aTimeout = 0) {
if (this._SHOW_DIALOGS) {
Services.prompt.asyncAlert(
@ -331,6 +402,12 @@ export const ContentAnalysis = {
return null;
},
/**
* Whether the notification should block browser interaction.
*
* @param {number} aAnalysisType The type of DLP analysis being done.
* @returns {boolean}
*/
_shouldShowBlockingNotification(aAnalysisType) {
return !(
aAnalysisType == Ci.nsIContentAnalysisRequest.eFileDownloaded ||
@ -338,8 +415,13 @@ export const ContentAnalysis = {
);
},
// This function also transforms the nameOrOperationType so we won't have to
// look it up again.
/**
* This function also transforms the nameOrOperationType so we won't have to
* look it up again.
*
* @param {ResourceNameOrOperationType} nameOrOperationType
* @returns {string}
*/
_getResourceNameFromNameOrOperationType(nameOrOperationType) {
if (!nameOrOperationType.name) {
let l10nId = undefined;
@ -374,8 +456,7 @@ export const ContentAnalysis = {
* line. This is used to add more context to the message
* if a file is being uploaded rather than just the name
* of the file.
* @returns {object} An object with either a name property that can be used as-is, or
* an operationType property.
* @returns {ResourceNameOrOperationType}
*/
_getResourceNameOrOperationTypeFromRequest(aRequest, aStandalone) {
if (
@ -395,6 +476,14 @@ export const ContentAnalysis = {
return { operationType: aRequest.operationTypeForDisplay };
},
/**
* Sets up an "operation is in progress" dialog to be shown after a delay,
* unless one is already showing for this userActionId.
*
* @param {nsIContentAnalysisRequest} aRequest
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @param {BrowsingContext} aBrowsingContext
*/
_queueSlowCAMessage(
aRequest,
aResourceNameOrOperationType,
@ -433,6 +522,12 @@ export const ContentAnalysis = {
}, slowTimeoutMs);
},
/**
* Removes the Slow CA message, if it is showing
*
* @param {string} aUserActionId The user action ID to remove
* @param {string} aRequestToken The request token to remove
*/
_removeSlowCAMessage(aUserActionId, aRequestToken) {
let entry = this.userActionToBusyDialogMap.get(aUserActionId);
if (!entry) {
@ -456,8 +551,9 @@ export const ContentAnalysis = {
},
/**
* Gets all the requests that are still in progress.
*
* @returns {Iterable<{browsingContext: BrowsingContext, resourceNameOrOperationType: object}>} Information about the requests that are still in progress
* @returns {Iterable<RequestInfo>} Information about the requests that are still in progress
*/
_getAllSlowCARequestInfos() {
return this.userActionToBusyDialogMap
@ -469,6 +565,11 @@ export const ContentAnalysis = {
/**
* Show a message to the user to indicate that a CA request is taking
* a long time.
*
* @param {string} aOperation Name of the operation
* @param {nsIContentAnalysisRequest} aRequest The request that is taking a long time
* @param {string} aBodyMessage Message to show in the body of the alert
* @param {BrowsingContext} aBrowsingContext BrowsingContext to show the alert in
*/
_showSlowCAMessage(aOperation, aRequest, aBodyMessage, aBrowsingContext) {
if (!this._shouldShowBlockingNotification(aOperation)) {
@ -489,6 +590,13 @@ export const ContentAnalysis = {
);
},
/**
* Gets the dialog message to show for the Slow CA dialog.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @param {number} aNumRequests
* @returns {string}
*/
_getSlowDialogMessage(aResourceNameOrOperationType, aNumRequests) {
if (aResourceNameOrOperationType.name) {
let label =
@ -524,6 +632,12 @@ export const ContentAnalysis = {
return this.l10n.formatValueSync(l10nId, { agent: lazy.agentName });
},
/**
* Gets the dialog message to show when the request has an error.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
* @returns {string}
*/
_getErrorDialogMessage(aResourceNameOrOperationType) {
if (aResourceNameOrOperationType.name) {
return this.l10n.formatValueSync(
@ -552,6 +666,16 @@ export const ContentAnalysis = {
}
return this.l10n.formatValueSync(l10nId);
},
/**
* Show the Slow CA blocking dialog.
*
* @param {BrowsingContext} aBrowsingContext
* @param {string} aUserActionId
* @param {string} aRequestToken
* @param {string} aBodyMessage
* @returns {NotificationInfo}
*/
_showSlowCABlockingMessage(
aBrowsingContext,
aUserActionId,
@ -589,12 +713,12 @@ export const ContentAnalysis = {
// in which case we need to cancel the request.
if (this.requestTokenToRequestInfo.delete(aRequestToken)) {
// TODO: Is this useful? I think no.
this._removeSlowCAMessage({}, aRequestToken);
this._removeSlowCAMessage(aUserActionId, aRequestToken);
lazy.gContentAnalysis.cancelRequestsByRequestToken(aRequestToken);
}
});
return {
requestToken: aRequestToken,
dialogBrowsingContext: aBrowsingContext,
};
},
@ -602,7 +726,14 @@ export const ContentAnalysis = {
/**
* Show a message to the user to indicate the result of a CA request.
*
* @returns {object} a notification object (if shown)
* @param {object} aResourceNameOrOperationType
* @param {BrowsingContext} aBrowsingContext
* @param {string} aRequestToken
* @param {string} aUserActionId
* @param {number} aCAResult
* @param {bool} aIsAgentResponse
* @param {number} aRequestCancelError
* @returns {NotificationInfo?} a notification object (if shown)
*/
async _showCAResult(
aResourceNameOrOperationType,
@ -635,6 +766,7 @@ export const ContentAnalysis = {
case Ci.nsIContentAnalysisResponse.eWarn: {
let allow = false;
try {
this.warnDialogRequestTokens.add(aRequestToken);
const result = await Services.prompt.asyncConfirmEx(
aBrowsingContext,
Ci.nsIPromptService.MODAL_TYPE_TAB,
@ -664,7 +796,13 @@ export const ContentAnalysis = {
// the request is still active.
allow = false;
}
lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow);
// Note that the shutdown code in the "quit-application" handler
// may have cleared out warnDialogRequestTokens and responded
// to the request already, so don't call respondToWarnDialog()
// if aRequestToken is not in warnDialogRequestTokens.
if (this.warnDialogRequestTokens.delete(aRequestToken)) {
lazy.gContentAnalysis.respondToWarnDialog(aRequestToken, allow);
}
return null;
}
case Ci.nsIContentAnalysisResponse.eBlock: {
@ -832,6 +970,8 @@ export const ContentAnalysis = {
/**
* Returns the correct text for warn dialog contents.
*
* @param {ResourceNameOrOperationType} aResourceNameOrOperationType
*/
async _warnDialogText(aResourceNameOrOperationType) {
const caInfo = await lazy.gContentAnalysis.getDiagnosticInfo();

View file

@ -5,7 +5,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
with Files("**"):
BUG_COMPONENT = ("Toolkit", "General")
BUG_COMPONENT = ("Firefox", "Data Loss Prevention")
EXTRA_JS_MODULES += [
"content/ContentAnalysis.sys.mjs",

View file

@ -10,6 +10,8 @@ class TestNoToolbarChanges(MarionetteTestCase):
Test that toolbar widgets remain in the same order over several restarts of the browser
"""
have_seen_import_button = False
def setUp(self):
super().setUp()
self.marionette.set_context("chrome")
@ -37,13 +39,30 @@ class TestNoToolbarChanges(MarionetteTestCase):
navbarPlacements,
msg="AREA_NAVBAR placements are as expected",
)
actualBookmarkPlacements = self.get_area_widgets("AREA_BOOKMARKS")
bookmarkPlacements = self.get_area_default_placements("AREA_BOOKMARKS")
bookmarkPlacements.insert(0, "import-button")
self.assertEqual(
self.get_area_widgets("AREA_BOOKMARKS"),
bookmarkPlacements,
msg="AREA_BOOKMARKS placements are as expected",
# The import button is added lazily on startup, so we can't predict
# whether it'll be here. Turning it off via prefs=[] annotations on the
# test also doesn't work
# (https://bugzilla.mozilla.org/show_bug.cgi?id=1959688).
# So we simply accept placements either with or without the button - but
# if we ever see the button we should keep seeing it.
self.have_seen_import_button = (
self.have_seen_import_button or "import-button" in actualBookmarkPlacements
)
if self.have_seen_import_button:
self.assertEqual(
actualBookmarkPlacements,
["import-button"] + bookmarkPlacements,
msg="AREA_BOOKMARKS placements are as expected",
)
else:
self.assertEqual(
actualBookmarkPlacements,
bookmarkPlacements,
msg="AREA_BOOKMARKS placements are as expected",
)
self.assertEqual(
self.get_area_widgets("AREA_ADDONS"),
self.get_area_default_placements("AREA_ADDONS"),

View file

@ -13,6 +13,8 @@ module.exports = {
TabContext: true,
Window: true,
clickModifiersFromEvent: true,
getExtTabGroupIdForInternalTabGroupId: true,
getInternalTabGroupIdForExtTabGroupId: true,
makeWidgetId: true,
openOptionsPage: true,
replaceUrlInTab: true,

View file

@ -135,6 +135,49 @@ global.replaceUrlInTab = (gBrowser, tab, uri) => {
return loaded;
};
// The tabs.Tab.groupId type in the public extension API is an integer,
// but tabbrowser's tab group ID are strings. This handles the conversion.
//
// tabbrowser.addTabGroup() generates the internal tab group ID as follows:
// internal group id = `${Date.now()}-${Math.round(Math.random() * 100)}`;
// After dropping the hyphen ("-"), the result can be coerced into a safe
// integer.
//
// As a safeguard, in case the format changes, we fall back to maintaining
// an internal mapping (that never gets cleaned up).
// This may change in https://bugzilla.mozilla.org/show_bug.cgi?id=1960104
const fallbackTabGroupIdMap = new Map();
let nextFallbackTabGroupId = 1;
global.getExtTabGroupIdForInternalTabGroupId = groupIdStr => {
const parsedTabId = /^(\d{13})-(\d{1,3})$/.exec(groupIdStr);
if (parsedTabId) {
const groupId = parsedTabId[1] * 1000 + parseInt(parsedTabId[2], 10);
if (Number.isSafeInteger(groupId)) {
return groupId;
}
}
// Fall back.
let fallbackGroupId = fallbackTabGroupIdMap.get(groupIdStr);
if (!fallbackGroupId) {
fallbackGroupId = nextFallbackTabGroupId++;
fallbackTabGroupIdMap.set(groupIdStr, fallbackGroupId);
}
return fallbackGroupId;
};
global.getInternalTabGroupIdForExtTabGroupId = groupId => {
if (Number.isSafeInteger(groupId) && groupId >= 1e15) {
// 16 digits - this inverts getExtTabGroupIdForInternalTabGroupId.
const groupIdStr = `${Math.floor(groupId / 1000)}-${groupId % 1000}`;
return groupIdStr;
}
for (let [groupIdStr, fallbackGroupId] of fallbackTabGroupIdMap) {
if (fallbackGroupId === groupId) {
return groupIdStr;
}
}
return null;
};
/**
* Manages tab-specific and window-specific context data, and dispatches
* tab select events across all windows.
@ -878,6 +921,11 @@ class Tab extends TabBase {
return successor ? tabTracker.getId(successor) : -1;
}
get groupId() {
const { group } = this.nativeTab;
return group ? getExtTabGroupIdForInternalTabGroupId(group.id) : -1;
}
/**
* Converts session store data to an object compatible with the return value
* of the convert() method, representing that data.

View file

@ -160,6 +160,7 @@ const allProperties = new Set([
"autoDiscardable",
"discarded",
"favIconUrl",
"groupId",
"hidden",
"isArticle",
"mutedInfo",
@ -260,13 +261,17 @@ this.tabs = class extends ExtensionAPIPersistent {
}),
onMoved({ fire }) {
let { tabManager } = this.extension;
/**
* @param {CustomEvent} event
*/
let moveListener = event => {
let nativeTab = event.originalTarget;
let { previousTabState, currentTabState } = event.detail;
if (tabManager.canAccessTab(nativeTab)) {
fire.async(tabTracker.getId(nativeTab), {
windowId: windowTracker.getId(nativeTab.ownerGlobal),
fromIndex: event.detail,
toIndex: nativeTab._tPos,
fromIndex: previousTabState.tabIndex,
toIndex: currentTabState.tabIndex,
});
}
};
@ -461,6 +466,15 @@ this.tabs = class extends ExtensionAPIPersistent {
needed.push("discarded");
} else if (event.type == "TabBrowserDiscarded") {
needed.push("discarded");
} else if (event.type === "TabGrouped") {
needed.push("groupId");
} else if (event.type === "TabUngrouped") {
if (event.originalTarget.group) {
// If there is still a group, that means that the group changed,
// so TabGrouped will also fire. Ignore to avoid duplicate events.
return;
}
needed.push("groupId");
} else if (event.type == "TabShow") {
needed.push("hidden");
} else if (event.type == "TabHide") {
@ -527,6 +541,10 @@ this.tabs = class extends ExtensionAPIPersistent {
listeners.set("TabBrowserInserted", listener);
listeners.set("TabBrowserDiscarded", listener);
}
if (filter.properties.has("groupId")) {
listeners.set("TabGrouped", listener);
listeners.set("TabUngrouped", listener);
}
if (filter.properties.has("hidden")) {
listeners.set("TabShow", listener);
listeners.set("TabHide", listener);
@ -1661,6 +1679,113 @@ this.tabs = class extends ExtensionAPIPersistent {
let nativeTab = getTabOrActive(tabId);
nativeTab.linkedBrowser.goBack(false);
},
group(options) {
let nativeTabs = getNativeTabsFromIDArray(options.tabIds);
let window = windowTracker.getWindow(
options.createProperties?.windowId ?? Window.WINDOW_ID_CURRENT,
context
);
const windowIsPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
for (const nativeTab of nativeTabs) {
if (
PrivateBrowsingUtils.isWindowPrivate(nativeTab.ownerGlobal) !==
windowIsPrivate
) {
if (windowIsPrivate) {
throw new ExtensionError(
"Cannot move non-private tabs to private window"
);
}
throw new ExtensionError(
"Cannot move private tabs to non-private window"
);
}
}
function unpinTabsBeforeGrouping() {
for (const nativeTab of nativeTabs) {
nativeTab.ownerGlobal.gBrowser.unpinTab(nativeTab);
}
}
let group;
if (options.groupId == null) {
// By default, tabs are appended after all other tabs in the
// window. But if we are grouping tabs within a window, ideally the
// tabs should just be grouped without moving positions.
// TODO bug 1939214: when addTabGroup inserts tabs at the front as
// needed (instead of always appending), simplify this logic.
const tabInWin = nativeTabs.find(t => t.ownerGlobal === window);
let insertBefore = tabInWin;
if (tabInWin?.group) {
if (tabInWin.group.tabs[0] === tabInWin) {
// When tabInWin is at the front of a tab group, insert before
// the tab group (instead of after it).
insertBefore = tabInWin.group;
} else {
insertBefore = insertBefore.group.nextElementSibling;
}
}
unpinTabsBeforeGrouping();
group = window.gBrowser.addTabGroup(nativeTabs, { insertBefore });
// Note: group is never null, because the only condition for which
// it could be null is when all tabs are pinned, and we are already
// explicitly unpinning them before moving.
} else {
group = window.gBrowser.getTabGroupById(
getInternalTabGroupIdForExtTabGroupId(options.groupId)
);
if (!group) {
throw new ExtensionError(`No group with id: ${options.groupId}`);
}
unpinTabsBeforeGrouping();
// When moving tabs within the same window, try to maintain their
// relative positions.
const tabsBefore = [];
const tabsAfter = [];
const firstTabInGroup = group.tabs[0];
for (const nativeTab of nativeTabs) {
if (
nativeTab.ownerGlobal === window &&
nativeTab._tPos < firstTabInGroup._tPos
) {
tabsBefore.push(nativeTab);
} else {
tabsAfter.push(nativeTab);
}
}
if (tabsBefore.length) {
window.gBrowser.moveTabsBefore(tabsBefore, firstTabInGroup);
}
if (tabsAfter.length) {
group.addTabs(tabsAfter);
}
}
return getExtTabGroupIdForInternalTabGroupId(group.id);
},
ungroup(tabIds) {
const nativeTabs = getNativeTabsFromIDArray(tabIds);
// Ungroup tabs while trying to preserve the relative order of tabs
// within the tab strip as much as possible. This is not always
// possible, e.g. when a tab group is only partially ungrouped.
const ungroupOrder = new DefaultMap(() => []);
for (const nativeTab of nativeTabs) {
if (nativeTab.group) {
ungroupOrder.get(nativeTab.group).push(nativeTab);
}
}
for (const [group, tabs] of ungroupOrder) {
// Preserve original order of ungrouped tabs.
tabs.sort((a, b) => a._tPos - b._tPos);
if (tabs[0] === tabs[0].group.tabs[0]) {
// The tab is the front of the tab group, so insert before
// current tab group to preserve order.
tabs[0].ownerGlobal.gBrowser.moveTabsBefore(tabs, group);
} else {
tabs[0].ownerGlobal.gBrowser.moveTabsAfter(tabs, group);
}
}
},
},
};
return tabsApi;

View file

@ -227,6 +227,12 @@
"optional": true,
"minimum": -1,
"description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."
},
"groupId": {
"type": "integer",
"optional": true,
"minimum": -1,
"description": "The ID of the group that the tab belongs to. $(ref:tabGroups.TAB_GROUP_ID_NONE) (-1) if the tab does not belong to a tab group."
}
}
},
@ -429,6 +435,7 @@
"autoDiscardable",
"discarded",
"favIconUrl",
"groupId",
"hidden",
"isArticle",
"mutedInfo",
@ -832,6 +839,12 @@
"optional": true,
"description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab."
},
"groupId": {
"type": "integer",
"minimum": -1,
"optional": true,
"description": "The ID of the group that the tabs are in, or $(ref:tabGroups.TAB_GROUP_ID_NONE) (-1) for ungrouped tabs."
},
"screen": {
"choices": [
{
@ -1591,6 +1604,83 @@
"parameters": []
}
]
},
{
"name": "group",
"type": "function",
"description": "Adds one or more tabs to a specified group, or if no group is specified, adds the given tabs to a newly created group.",
"async": "callback",
"parameters": [
{
"name": "options",
"type": "object",
"properties": {
"tabIds": {
"description": "The tab ID or list of tab IDs to add to the specified group.",
"choices": [
{ "type": "integer", "minimum": 0 },
{
"type": "array",
"items": { "type": "integer", "minimum": 0 },
"minItems": 1
}
]
},
"groupId": {
"type": "integer",
"description": "The ID of the group to add the tabs to. If not specified, a new group will be created.",
"minimum": 0,
"optional": true
},
"createProperties": {
"type": "object",
"optional": true,
"description": "Configurations for creating a group. Cannot be used if groupId is already specified.",
"properties": {
"windowId": {
"type": "integer",
"minimum": -2,
"optional": true,
"description": "The window of the new group. Defaults to the current window."
}
}
}
}
},
{
"type": "function",
"name": "callback",
"optional": true,
"parameters": [
{
"name": "groupId",
"type": "integer",
"minimum": 0,
"description": "The ID of the group that the tabs were added to."
}
]
}
]
},
{
"name": "ungroup",
"type": "function",
"description": "Removes one or more tabs from their respective groups. If any groups become empty, they are deleted.",
"async": true,
"parameters": [
{
"name": "tabIds",
"description": "The tab ID or list of tab IDs to remove from their respective groups.",
"choices": [
{ "type": "integer", "minimum": 0 },
{
"type": "array",
"items": { "type": "integer", "minimum": 0 },
"minItems": 1
}
]
}
]
}
],
"events": [

View file

@ -538,6 +538,10 @@ https_first_disabled = true
["browser_ext_tabs_goBack_goForward.js"]
["browser_ext_tabs_groupId.js"]
["browser_ext_tabs_group_ungroup.js"]
["browser_ext_tabs_hide.js"]
https_first_disabled = true
@ -579,6 +583,8 @@ skip-if = [
["browser_ext_tabs_onUpdated_filter.js"]
["browser_ext_tabs_onUpdated_groupId.js"]
["browser_ext_tabs_opener.js"]
["browser_ext_tabs_printPreview.js"]

View file

@ -0,0 +1,144 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
function resetInternalExtensionFallbackTabGroupIdMap() {
const { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs"
);
// ExtensionParent.apiManager.global may be unset if there has not been any
// attempt to load any extension yet.
// We need to use eval() because the variables are declared with const/let,
// which cannot simply be read as properties from the global.
ExtensionParent.apiManager.global?.eval(`
"use strict";
// Reset to initial values as set by ext-browser.js
fallbackTabGroupIdMap.clear();
nextFallbackTabGroupId = 1;
`);
}
function getInternalExtensionFallbackTabGroupIdMapEntries() {
const { ExtensionParent } = ChromeUtils.importESModule(
"resource://gre/modules/ExtensionParent.sys.mjs"
);
return Array.from(
ExtensionParent.apiManager.global.eval("fallbackTabGroupIdMap").entries()
);
}
async function getCurrentTabGroupId() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
browser.test.assertTrue(
Number.isSafeInteger(tab.groupId),
`groupId is safe integer: ${tab.groupId}`
);
browser.test.assertTrue(
tab.groupId >= 0,
`groupId is non-negative integer: ${tab.groupId}`
);
const tabRepeated = await browser.tabs.get(tab.id);
browser.test.assertEq(
tab.groupId,
tabRepeated.groupId,
"groupId should consistently return the same value"
);
const tabs = await browser.tabs.query({ groupId: tab.groupId });
browser.test.assertEq(
tabs.length,
1,
"tabs.query({ groupId }) found the one and only tab in that group"
);
browser.test.assertEq(tab.id, tabs[0].id, "Got expected tab");
browser.test.sendMessage("ext_groupId", tab.groupId);
},
});
await extension.startup();
const groupId = await extension.awaitMessage("ext_groupId");
await extension.unload();
return groupId;
}
// This test checks whether the tabs.Tab.groupId field has a meaningful value
// derived from a tabbrowser tab group.
add_task(async function tabs_Tab_groupId() {
resetInternalExtensionFallbackTabGroupIdMap();
const tab1 = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"https://example.com/?1",
true
);
const tab2 = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
"https://example.com/?2",
true
);
gBrowser.selectedTab = tab2;
// Without a given ID, tabbrowser generates a new ID.
const group1 = gBrowser.addTabGroup([tab1]);
// The following ID differs from the tabbrowser format:
const group2 = gBrowser.addTabGroup([tab2], { id: "non-numericid" });
is(group2.id, "non-numericid", "Created group with non-numeric ID");
info("Testing tab group with internally generated ID");
gBrowser.selectedTab = tab1;
const generatedId1 = await getCurrentTabGroupId();
const generatedId2 = await getCurrentTabGroupId();
is(generatedId1, generatedId2, "Same groupId across extensions (generated)");
info("Testing tab group with ID, not matching internally generated IDs");
gBrowser.selectedTab = tab2;
const strangeId1 = await getCurrentTabGroupId();
const strangeId2 = await getCurrentTabGroupId();
is(strangeId1, strangeId2, "Same groupId across extensions (non-internal)");
isnot(generatedId1, strangeId1, "Independent tab groups have different ID");
// Move tab to another tab group.
const group3 = gBrowser.addTabGroup([tab2], { id: "another-non-numericid" });
is(group3.id, "another-non-numericid", "Updated group with non-numeric ID");
const strangeId3 = await getCurrentTabGroupId();
isnot(strangeId2, strangeId3, "New tab group has different tab group ID");
// Verify assumptions behind the above tests, to make sure that the test
// passes for the expected reasons.
is(
`${Math.floor(generatedId1 / 1000)}-${generatedId1 % 1000}`,
group1.id,
"groupId in extension API derived from internally-generated group ID"
);
is(
strangeId1,
1,
"groupId in extension API is next available ID (non-numericid)"
);
is(
strangeId3,
2,
"groupId in extension API is next available ID (another-non-numericid)"
);
BrowserTestUtils.removeTab(tab1);
BrowserTestUtils.removeTab(tab2);
// The main purpose of this check is to verify that we do not store mappings
// for internal groupIds that are in the expected format. Additionally, this
// also shows that the map is not cleaned up even after the group disappears.
Assert.deepEqual(
getInternalExtensionFallbackTabGroupIdMapEntries(),
[
["non-numericid", strangeId1],
["another-non-numericid", strangeId3],
],
"The internal fallback map contains only non-numeric IDs"
);
resetInternalExtensionFallbackTabGroupIdMap();
});

View file

@ -0,0 +1,438 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
// TODO bug 1938594: group_across_private_browsing_windows sometimes triggers
// this error. See https://bugzilla.mozilla.org/show_bug.cgi?id=1938594#c1
PromiseTestUtils.allowMatchingRejectionsGlobally(
/Unexpected undefined tabState for onMoveToNewWindow/
);
add_task(async function group_ungroup_and_index() {
const extension = ExtensionTestUtils.loadExtension({
files: {
"tab1.htm": "<title>tab1.html</title>",
"tab2.htm": "<title>tab2.html</title>",
"tab3.htm": "<title>tab3.html</title>",
},
async background() {
const { id: tabId1 } = await browser.tabs.create({ url: "tab1.htm" });
const { id: tabId2 } = await browser.tabs.create({ url: "tab2.htm " });
const { id: tabId3 } = await browser.tabs.create({ url: "tab3.htm" });
async function assertAllTabExpectations(expectations, desc) {
const tabs = await Promise.all([
browser.tabs.get(tabId1),
browser.tabs.get(tabId2),
browser.tabs.get(tabId3),
]);
const { indexes, groupIds, ...rest } = expectations;
if (Object.keys(rest).length) {
// Sanity check, so we don't miss expectations due to typos.
throw new Error(
`Unexpected keys in expectations: ${Object.keys(rest)} for ${desc}`
);
}
browser.test.assertDeepEq(
indexes,
tabs.map(t => t.index),
`${desc} : Tabs should be at expected indexes`
);
browser.test.assertDeepEq(
groupIds,
tabs.toSorted((a, b) => a.index - b.index).map(t => t.groupId),
`${desc} : Tab groupIds in order of tabs`
);
}
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [-1, -1, -1] },
"Initial tab indexes and group IDs before group()"
);
const groupId1 = await browser.tabs.group({ tabIds: tabId1 });
const groupId2 = await browser.tabs.group({ tabIds: [tabId2, tabId3] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId1, groupId2, groupId2] },
"Got two groups after group(tab1) + group([tab2, tab3])"
);
// It should be possible to ungroup tabs from different groups.
// And their position should not move, since these tabs are not in the
// middle of a tab group.
await browser.tabs.ungroup([tabId3, tabId1]);
await browser.tabs.ungroup(tabId2);
await browser.test.assertRejects(
browser.tabs.group({ tabIds: tabId3, groupId: groupId1 }),
`No group with id: ${groupId1}`,
"After ungrouping, the groupId should no longer be valid"
);
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [-1, -1, -1] },
"Tab groups should still be at their original position in the tab"
);
// Now grouping two tabs that are apart - they should be together.
const groupId3 = await browser.tabs.group({ tabIds: [tabId1, tabId3] });
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId3, groupId3, -1] },
"Tabs in same tab group must be next to each other"
);
// Join existing tab group - now we should have three in the tab group.
const groupId4 = await browser.tabs.group({
tabIds: [tabId2],
groupId: groupId3,
});
browser.test.assertEq(
groupId3,
groupId4,
"group() with a groupId parameter returns given groupId"
);
// Joining an existing group should not have changed positions.
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId3, groupId3, groupId3] },
"Tab order did not change when joining adjacent group"
);
await browser.tabs.ungroup([tabId1, tabId2, tabId3]);
// Ungrouping of the group should not have changed positions either,
// despite the list of tabIds passed to ungroup() being out of order.
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [-1, -1, -1] },
"Tab order did not change when ungrouping with out-of-order tabIds"
);
// Group tabs together. Tab positions should match given order.
const groupId5 = await browser.tabs.group({
tabIds: [tabId1, tabId2, tabId3],
});
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId5, groupId5, groupId5] },
"Tab order matches order of tabIds passed to tabs.group()"
);
// Move the leftmost tab to a new group. That tab should still be
// positioned at the left of the original tab group.
const groupId6 = await browser.tabs.group({ tabIds: [tabId1] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId6, groupId5, groupId5] },
"Leftmost tab should still be ordered before the original tab group"
);
// Join an existing group (from the left). Position should not change.
await browser.tabs.group({ tabIds: [tabId1], groupId: groupId5 });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId5, groupId5, groupId5] },
"Tab order did not change when joining a tab group from the left"
);
await browser.test.assertRejects(
browser.tabs.group({ tabIds: tabId3, groupId: groupId6 }),
`No group with id: ${groupId6}`,
"Old groupId should be invalid after last tab was moved from group"
);
// Move the middle tab to a new group. That tab should be at the right.
const groupId7 = await browser.tabs.group({ tabIds: [tabId2] });
await assertAllTabExpectations(
{ indexes: [1, 3, 2], groupIds: [groupId5, groupId5, groupId7] },
"group() on middle tab in existing group appears on the right"
);
// Prepare: tabId1 and tabId2 together at the left, followed by tabId3.
const groupId8 = await browser.tabs.group({ tabIds: [tabId1, tabId2] });
await browser.tabs.ungroup(tabId3);
// When tabId2 is moved to a new group, it should stay in the middle,
// meaning that the tab was inserted after its original tab group.
// In particular, it should not move to the end of the tab strip.
const groupId9 = await browser.tabs.group({ tabIds: [tabId2] });
await assertAllTabExpectations(
{ indexes: [1, 2, 3], groupIds: [groupId8, groupId9, -1] },
"group() on rightmost tab should appear after original tab group"
);
await browser.tabs.remove(tabId1);
await browser.tabs.remove(tabId2);
await browser.tabs.remove(tabId3);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_with_windowId() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: windowId, tabs: initialTabs } = await browser.windows.create(
{}
);
browser.test.assertEq(1, initialTabs.length, "Got window with 1 tab");
const { id: tabId1 } = await browser.tabs.create({});
const { id: tabId2 } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({
tabIds: [tabId2, tabId1],
createProperties: { windowId },
});
browser.test.assertDeepEq(
Array.from(await browser.tabs.query({ groupId: groupId1 }), t => t.id),
[tabId2, tabId1],
"Moved tabs to group"
);
browser.test.assertDeepEq(
Array.from(await browser.tabs.query({ windowId }), t => t.id),
[initialTabs[0].id, tabId2, tabId1],
"Moved tabs to group in new window (next to initial tab)"
);
await browser.windows.remove(windowId);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_adopt_from_multiple_windows() {
const extension = ExtensionTestUtils.loadExtension({
incognitoOverride: "spanning",
async background() {
const {
id: windowId1,
tabs: [{ id: tabId1 }],
} = await browser.windows.create({});
const {
id: windowId2,
tabs: [{ id: tabId2 }],
} = await browser.windows.create({});
const {
id: windowId3,
tabs: [{ id: tabId3 }],
} = await browser.windows.create({});
// This confirms that group() can adapt tabs from different windows.
const groupId = await browser.tabs.group({
tabIds: [tabId2, tabId3],
createProperties: { windowId: windowId1 },
});
await browser.test.assertRejects(
browser.windows.get(windowId2),
`Invalid window ID: ${windowId2}`,
"Window closes when group() adopts the last tab of the window"
);
// We just confirmed that window2 is closed, do the same for window3.
await browser.test.assertRejects(
browser.tabs.group({
tabIds: tabId1,
createProperties: { windowId: windowId3 },
}),
`Invalid window ID: ${windowId3}`,
"group() cannot adapt groups from a closed window"
);
browser.test.assertDeepEq(
// Note: tabId1 is missing because the above group() rejected.
[tabId2, tabId3],
Array.from(await browser.tabs.query({ groupId }), tab => tab.id),
"All specified tabIds should now belong to the given group"
);
await browser.windows.remove(windowId1);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_across_private_browsing_windows() {
const extension = ExtensionTestUtils.loadExtension({
incognitoOverride: "spanning",
async background() {
const privateWin = await browser.windows.create({ incognito: true });
const normalWin = await browser.windows.create({ incognito: false });
const otherPrivateWin = await browser.windows.create({ incognito: true });
const otherNormalWin = await browser.windows.create({ incognito: false });
// Mixture of private and non-private tabIDs, so we can easily verify
// whether we inadvertently move some of the tabs.
const privateTab = await browser.tabs.create({ windowId: privateWin.id });
const privateAndNonPrivateTabs = [
privateTab,
await browser.tabs.create({ windowId: normalWin.id }),
await browser.tabs.create({ windowId: privateWin.id }),
await browser.tabs.create({ windowId: normalWin.id }),
];
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
}),
"Cannot move private tabs to non-private window",
"Should not be able to move private+non-private tabs to current window"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
createProperties: { windowId: otherPrivateWin.id },
}),
"Cannot move non-private tabs to private window",
"Should not be able to move non-private tab to private window"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: privateAndNonPrivateTabs.map(t => t.id),
createProperties: { windowId: otherNormalWin.id },
}),
"Cannot move private tabs to non-private window",
"Should not be able to move private tab to non-private window"
);
const reply = await browser.runtime.sendMessage("@no_private", {
privateWindowId: privateWin.id,
privateTabId: privateTab.id,
});
browser.test.assertEq("no_private:done", reply, "Reply from other ext");
for (const tab of privateAndNonPrivateTabs) {
const actualTab = await browser.tabs.get(tab.id);
browser.test.assertEq(
tab.windowId,
actualTab.windowId,
"Tab should not have moved to a different window"
);
browser.test.assertEq(
tab.windowId,
actualTab.windowId,
"Tab should not have moved within its window"
);
browser.test.assertEq(
tab.groupId,
-1,
"Tab should not have joined a group"
);
}
// Now check that we can actually group tabs in private windows.
const groupId = await browser.tabs.group({
tabIds: privateTab.id,
createProperties: { windowId: otherPrivateWin.id },
});
const updatedPrivateTab = await browser.tabs.get(privateTab.id);
browser.test.assertEq(
groupId,
updatedPrivateTab.groupId,
"group() succeeded with private tab"
);
browser.test.assertEq(
otherPrivateWin.id,
updatedPrivateTab.windowId,
"Private tab is now part of the destination private window"
);
await browser.windows.remove(privateWin.id);
await browser.windows.remove(normalWin.id);
await browser.windows.remove(otherPrivateWin.id);
await browser.windows.remove(otherNormalWin.id);
browser.test.sendMessage("done");
},
});
const extensionWithoutPrivateAccess = ExtensionTestUtils.loadExtension({
manifest: { browser_specific_settings: { gecko: { id: "@no_private" } } },
background() {
browser.runtime.onMessageExternal.addListener(async data => {
const { privateWindowId, privateTabId } = data;
const { id: normalTabId } = await browser.tabs.create({});
await browser.test.assertRejects(
browser.tabs.group({ tabIds: [privateTabId] }),
`Invalid tab ID: ${privateTabId}`,
"@no_private should not be able to group private tabs"
);
await browser.test.assertRejects(
browser.tabs.group({
tabIds: [normalTabId],
createProperties: { windowId: privateWindowId },
}),
`Invalid window ID: ${privateWindowId}`,
"@no_private should not see private windows"
);
await browser.tabs.remove(normalTabId);
return "no_private:done"; // Checked by sender.
});
},
});
await extensionWithoutPrivateAccess.startup();
await extension.startup();
await extension.awaitMessage("done");
await extensionWithoutPrivateAccess.unload();
await extension.unload();
});
add_task(async function group_pinned_tab() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: tabId1 } = await browser.tabs.create({ pinned: true });
const { id: tabId2 } = await browser.tabs.create({ pinned: true });
const groupId = await browser.tabs.group({ tabIds: [tabId1] });
browser.test.assertTrue(groupId > 0, `group() created group: ${groupId}`);
const tab1 = await browser.tabs.get(tabId1);
browser.test.assertFalse(tab1.pinned, "group() unpins tab 1");
browser.test.assertEq(groupId, tab1.groupId, "group() grouped tab 1");
const groupId2 = await browser.tabs.group({ tabIds: [tabId2], groupId });
browser.test.assertEq(groupId, groupId2, "group() existing group");
const tab2 = await browser.tabs.get(tabId2);
browser.test.assertFalse(tab2.pinned, "group() unpins tab 2");
browser.test.assertEq(groupId, tab2.groupId, "Tab joined existing group");
await browser.tabs.remove(tabId1);
await browser.tabs.remove(tabId2);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function group_pinned_tab_from_different_window() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const { id: tabId } = await browser.tabs.create({ pinned: true });
const { id: windowId } = await browser.windows.create({});
const groupId = await browser.tabs.group({
tabIds: [tabId],
createProperties: { windowId },
});
browser.test.assertTrue(groupId > 0, `group() created group: ${groupId}`);
const tab = await browser.tabs.get(tabId);
browser.test.assertFalse(tab.pinned, "group() unpins tab");
browser.test.assertEq(groupId, tab.groupId, "group() grouped tab");
browser.test.assertEq(windowId, tab.windowId, "Moved tab to new window");
await browser.windows.remove(windowId);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});

View file

@ -0,0 +1,109 @@
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
add_task(async function onUpdated_when_grouping_and_ungrouping() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener(
(tabId, changeInfo, tab) => {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
},
{ properties: ["groupId"] }
);
const { id: tabId } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.ungroup(tabId);
const groupId2 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ groupId: groupId1 }, { groupId: -1 }, { groupId: groupId2 }],
changes,
"Observed tabs.onUpdated events after group(), ungroup() and group()"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function onUpdated_when_grouping_and_regrouping() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener(
(tabId, changeInfo, tab) => {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
},
{ properties: ["groupId"] }
);
const { id: tabId } = await browser.tabs.create({});
const groupId1 = await browser.tabs.group({ tabIds: [tabId] });
const groupId2 = await browser.tabs.group({ tabIds: [tabId] });
const groupId3 = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ groupId: groupId1 }, { groupId: groupId2 }, { groupId: groupId3 }],
changes,
"Observed tabs.onUpdated events after group() and regrouping repeatedly"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});
add_task(async function onUpdated_when_grouping_pinned_tab() {
const extension = ExtensionTestUtils.loadExtension({
async background() {
const changes = [];
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.groupId) {
browser.test.assertEq(
changeInfo.groupId,
tab.groupId,
"changeInfo.groupId matches tab.groupId"
);
changes.push(changeInfo);
} else if (changeInfo.pinned != null) {
changes.push(changeInfo);
}
});
const { id: tabId } = await browser.tabs.create({ pinned: true });
const groupId = await browser.tabs.group({ tabIds: [tabId] });
await browser.tabs.remove(tabId);
browser.test.assertDeepEq(
[{ pinned: true }, { pinned: false }, { groupId: groupId }],
changes,
"Observed tabs.onUpdated events after group() of pinned tab"
);
browser.test.sendMessage("done");
},
});
await extension.startup();
await extension.awaitMessage("done");
await extension.unload();
});

View file

@ -33,6 +33,7 @@ let expectedBackgroundApisTargetSpecific = [
"tabs.getZoomSettings",
"tabs.goBack",
"tabs.goForward",
"tabs.group",
"tabs.highlight",
"tabs.insertCSS",
"tabs.move",
@ -58,6 +59,7 @@ let expectedBackgroundApisTargetSpecific = [
"tabs.setZoom",
"tabs.setZoomSettings",
"tabs.toggleReaderMode",
"tabs.ungroup",
"tabs.update",
"tabs.warmup",
"windows.CreateType",

View file

@ -37,13 +37,22 @@ const HISTORY_MAP_L10N_IDS = {
},
};
/**
* When sorting by date or site, each card "item" is a single visit.
*
* When sorting by date *and* site, each card "item" is a mapping of site
* domains to their respective list of visits.
*
* @typedef {HistoryVisit | [string, HistoryVisit[]]} CardItem
*/
/**
* A list of visits displayed on a card.
*
* @typedef {object} CardEntry
*
* @property {string} domain
* @property {HistoryVisit[]} items
* @property {CardItem[]} items
* @property {string} l10nId
*/
@ -127,7 +136,7 @@ export class HistoryController {
/**
* Update cached history.
*
* @param {Map<CacheKey, HistoryVisit[]>} [historyMap]
* @param {CachedHistory} [historyMap]
* If provided, performs an update using the given data (instead of fetching
* it from the db).
*/
@ -146,7 +155,19 @@ export class HistoryController {
}
for (const { items } of entries) {
for (const item of items) {
this.#normalizeVisit(item);
switch (sortOption) {
case "datesite": {
// item is a [ domain, visit[] ] entry.
const [, visits] = item;
for (const visit of visits) {
this.#normalizeVisit(visit);
}
break;
}
default:
// item is a single visit.
this.#normalizeVisit(item);
}
}
}
this.historyCache = { entries, searchQuery, sortOption };
@ -203,6 +224,11 @@ export class HistoryController {
return this.#getVisitsForDate(historyMap);
case "site":
return this.#getVisitsForSite(historyMap);
case "datesite":
this.#setTodaysDate();
return this.#getVisitsForDateSite(historyMap);
case "lastvisited":
return this.#getVisitsForLastVisited(historyMap);
default:
return [];
}
@ -285,9 +311,9 @@ export class HistoryController {
* Get a list of visits per day for each day on this month, excluding today
* and yesterday.
*
* @param {Map<number, HistoryVisit[]>} cachedHistory
* @param {CachedHistory} cachedHistory
* The history cache to process.
* @returns {HistoryVisit[][]}
* @returns {CardItem[]}
* A list of visits for each day.
*/
#getVisitsByDay(cachedHistory) {
@ -313,9 +339,9 @@ export class HistoryController {
* excluding yesterday's visits if yesterday happens to fall on the previous
* month.
*
* @param {Map<number, HistoryVisit[]>} cachedHistory
* @param {CachedHistory} cachedHistory
* The history cache to process.
* @returns {HistoryVisit[][]}
* @returns {CardItem[]}
* A list of visits for each month.
*/
#getVisitsByMonth(cachedHistory) {
@ -332,6 +358,12 @@ export class HistoryController {
const month = this.placesQuery.getStartOfMonthTimestamp(date);
if (month !== previousMonth) {
visitsPerMonth.push(visits);
} else if (this.sortOption === "datesite") {
// CardItem type is currently Map<string, HistoryVisit[]>.
visitsPerMonth[visitsPerMonth.length - 1] = this.#mergeMaps(
visitsPerMonth.at(-1),
visits
);
} else {
visitsPerMonth[visitsPerMonth.length - 1] = visitsPerMonth
.at(-1)
@ -372,6 +404,22 @@ export class HistoryController {
);
}
/**
* Merge two maps of (domain: string) => HistoryVisit[] into a single map.
*
* @param {Map<string, HistoryVisit[]>} oldMap
* @param {Map<string, HistoryVisit[]>} newMap
* @returns {Map<string, HistoryVisit[]>}
*/
#mergeMaps(oldMap, newMap) {
const map = new Map(oldMap);
for (const [domain, newVisits] of newMap) {
const oldVisits = map.get(domain);
map.set(domain, oldVisits?.concat(newVisits) ?? newVisits);
}
return map;
}
/**
* Get a list of visits, sorted by site, in alphabetical order.
*
@ -386,6 +434,75 @@ export class HistoryController {
})).sort((a, b) => a.domain.localeCompare(b.domain));
}
/**
* Get a list of visits, sorted by date and site, in reverse chronological
* order.
*
* @param {Map<number, Map<string, HistoryVisit[]>>} historyMap
* @returns {CardEntry[]}
*/
#getVisitsForDateSite(historyMap) {
const entries = [];
const visitsFromToday = this.#getVisitsFromToday(historyMap);
const visitsFromYesterday = this.#getVisitsFromYesterday(historyMap);
const visitsByDay = this.#getVisitsByDay(historyMap);
const visitsByMonth = this.#getVisitsByMonth(historyMap);
/**
* Sorts items alphabetically by domain name.
*
* @param {[string, HistoryVisit[]][]} items
* @returns {[string, HistoryVisit[]][]} The items in sorted order.
*/
function sortItems(items) {
return items.sort(([aDomain], [bDomain]) =>
aDomain.localeCompare(bDomain)
);
}
// Add visits from today and yesterday.
if (visitsFromToday.length) {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-today"],
items: sortItems(visitsFromToday),
});
}
if (visitsFromYesterday.length) {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-yesterday"],
items: sortItems(visitsFromYesterday),
});
}
// Add visits from this month, grouped by day.
visitsByDay.forEach(visits => {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-this-month"],
items: sortItems([...visits]),
});
});
// Add visits from previous months, grouped by month.
visitsByMonth.forEach(visits => {
entries.push({
l10nId: HISTORY_MAP_L10N_IDS[this.component]["history-date-prev-month"],
items: sortItems([...visits]),
});
});
return entries;
}
/**
* Get a list of visits sorted by recency.
*
* @param {HistoryVisit[]} items
* @returns {CardEntry[]}
*/
#getVisitsForLastVisited(items) {
return [{ items }];
}
async #fetchHistory() {
return this.placesQuery.getHistory({
daysOld: 60,

View file

@ -10,6 +10,7 @@ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
ContentAnalysisUtils: "resource://gre/modules/ContentAnalysisUtils.sys.mjs",
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
@ -552,6 +553,16 @@ export const GenAI = {
}
});
// For Content Analysis, we need to specify the URL that the data is being sent to.
// In this case it's not the URL in the browsingContext (like it is in other cases),
// but the URL of the chatProvider is close enough to where the content will eventually
// be sent.
lazy.ContentAnalysisUtils.setupContentAnalysisEventsForTextElement(
textAreaEl,
browser.browsingContext,
Services.io.newURI(lazy.chatProvider)
);
const resetHeight = () => {
textAreaEl.style.height = "auto";
textAreaEl.style.height = textAreaEl.scrollHeight + "px";

View file

@ -57,20 +57,81 @@
margin: 0;
}
> ul {
font-size: var(--og-main-font-size);
padding-inline-start: var(--space-large);
}
> ul {
font-size: var(--og-main-font-size);
line-height: 1.15; /* Design requires 18px line-height */
list-style-type: square;
padding-inline-start: var(--space-large);
}
li {
margin-block: var(--space-medium);
li {
margin-block: var(--space-medium);
padding-inline-start: 5px;
&::marker {
color: var(--border-color-deemphasized);
}
}
> hr {
border-color: var(--border-color-card);
}
> hr {
border-color: var(--border-color-card);
}
> p {
margin-block: var(--space-medium) 0;
}
}
/**
* Defines the animation for the loading state of link preview keypoints
* Creates a smooth gradient animation that moves from right to left
* to indicate content is being loaded
*/
@keyframes link-preview-keypoints-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.keypoints-list {
.content-item {
margin-bottom: var(--space-xlarge);
width: 100%;
&.loading {
div {
--skeleton-loader-background-color: var(--tab-group-suggestions-loading-animation-color-1);
--skeleton-loader-motion-element-color: var(--tab-group-suggestions-loading-animation-color-2);
animation: link-preview-keypoints-loading 3s infinite;
background: linear-gradient(
100deg,
var(--skeleton-loader-background-color) 30%,
var(--skeleton-loader-motion-element-color) 50%,
var(--skeleton-loader-background-color) 70%
);
background-size: 200% 100%;
border-radius: 5px;
height: var(--og-main-font-size);
margin-bottom: 4px;
width: 100%;
/* Add non-impactful references to the CSS variables to satisfy the test browser_parsable_css */
outline-color: var(--skeleton-loader-background-color);
border-color: var(--skeleton-loader-motion-element-color);
}
div:nth-of-type(1) {
max-width: 95%;
}
div:nth-of-type(2) {
max-width: 98%;
}
div:nth-of-type(3) {
max-width: 90%;
}
}
}
}

View file

@ -26,6 +26,9 @@ class LinkPreviewCard extends MozLitElement {
// Text for the link to visit the original URL when in error state
static VISIT_LINK_TEXT = "Visit link";
// Number of placeholder rows to show when loading
static PLACEHOLDER_COUNT = 3;
static properties = {
generating: { type: Number }, // 0 = off, 1-4 = generating & dots state
keyPoints: { type: Array },
@ -160,13 +163,35 @@ class LinkPreviewCard extends MozLitElement {
${this.generating || this.keyPoints.length
? html`
<div class="ai-content">
<h3>
${this.generating
? "Generating key points" + ".".repeat(this.generating - 1)
: "Key points"}
</h3>
<ul>
${this.keyPoints.map(item => html`<li>${item}</li>`)}
<h3>Key points</h3>
<ul class="keypoints-list">
${
/* All populated content items */
this.keyPoints.map(
item => html`<li class="content-item">${item}</li>`
)
}
${
/* Loading placeholders with three divs each */
this.generating
? Array(
Math.max(
0,
LinkPreviewCard.PLACEHOLDER_COUNT -
this.keyPoints.length
)
)
.fill()
.map(
() =>
html` <li class="content-item loading">
<div></div>
<div></div>
<div></div>
</li>`
)
: []
}
</ul>
${this.progress >= 0
? html`

View file

@ -112,6 +112,7 @@ if CONFIG["MOZ_DEBUG"] or CONFIG["MOZ_DEV_EDITION"] or CONFIG["NIGHTLY_BUILD"]:
BROWSER_CHROME_MANIFESTS += [
"safebrowsing/content/test/browser.toml",
"tests/browser/browser.toml",
"tests/browser/eval/browser.toml",
]
if CONFIG["MOZ_UPDATER"]:

View file

@ -3,12 +3,14 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<?csp default-src chrome:; img-src chrome: moz-icon:; object-src 'none'; ?>
<!doctype html>
<html>
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src chrome:; img-src chrome: moz-icon:; style-src chrome: 'unsafe-inline'; object-src 'none';"
/>
<title>Interactions Debug Viewer</title>
<script
type="module"

View file

@ -1314,6 +1314,12 @@ var gMainPane = {
"command",
this.handleDeleteAll
);
Services.obs.addObserver(this, "intl:app-locales-changed");
}
destroy() {
Services.obs.removeObserver(this, "intl:app-locales-changed");
}
handleInstallAll = async () => {
@ -1397,6 +1403,7 @@ var gMainPane = {
for (const { langTag, displayName } of this.state.languageList) {
const hboxRow = document.createXULElement("hbox");
hboxRow.classList.add("translations-manage-language");
hboxRow.setAttribute("data-lang-tag", langTag);
const languageLabel = document.createXULElement("label");
languageLabel.textContent = displayName; // The display name is already localized.
@ -1558,11 +1565,41 @@ var gMainPane = {
hideError() {
this.elements.error.hidden = true;
}
observe(_subject, topic, _data) {
if (topic === "intl:app-locales-changed") {
this.refreshLanguageListDisplay();
}
}
refreshLanguageListDisplay() {
try {
const languageDisplayNames =
TranslationsParent.createLanguageDisplayNames();
for (const row of this.elements.installList.children) {
const rowLangTag = row.getAttribute("data-lang-tag");
if (!rowLangTag) {
continue;
}
const label = row.querySelector("label");
if (label) {
const newDisplayName = languageDisplayNames.of(rowLangTag);
if (label.textContent !== newDisplayName) {
label.textContent = newDisplayName;
}
}
}
} catch (error) {
console.error(error);
}
}
}
TranslationsState.create().then(
state => {
new TranslationsView(state);
this._translationsView = new TranslationsView(state);
},
error => {
// This error can happen when a user is not connected to the internet, or
@ -2736,6 +2773,13 @@ var gMainPane = {
Services.prefs.removeObserver(PREF_CONTAINERS_EXTENSION, this);
Services.obs.removeObserver(this, AUTO_UPDATE_CHANGED_TOPIC);
Services.obs.removeObserver(this, BACKGROUND_UPDATE_CHANGED_TOPIC);
// Clean up the TranslationsView instance if it exists
if (this._translationsView) {
this._translationsView.destroy();
this._translationsView = null;
}
AppearanceChooser.destroy();
},

View file

@ -54,11 +54,12 @@ ChromeUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => {
return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]);
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"gParentalControlsService",
"@mozilla.org/parental-controls-service;1",
"nsIParentalControlsService"
ChromeUtils.defineLazyGetter(lazy, "gParentalControlsService", () =>
"@mozilla.org/parental-controls-service;1" in Cc
? Cc["@mozilla.org/parental-controls-service;1"].getService(
Ci.nsIParentalControlsService
)
: null
);
XPCOMUtils.defineLazyPreferenceGetter(
@ -154,8 +155,6 @@ Preferences.addAll([
{ id: "browser.urlbar.suggest.quicksuggest.nonsponsored", type: "bool" },
{ id: "browser.urlbar.suggest.quicksuggest.sponsored", type: "bool" },
{ id: "browser.urlbar.quicksuggest.dataCollection.enabled", type: "bool" },
{ id: PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, type: "string" },
{ id: PREF_URLBAR_WEATHER_USER_ENABLED, type: "bool" },
// History
{ id: "places.history.enabled", type: "bool" },
@ -709,7 +708,7 @@ var gPrivacyPane = {
mode == Ci.nsIDNSService.MODE_TRRFIRST ||
mode == Ci.nsIDNSService.MODE_TRRONLY
) {
if (lazy.gParentalControlsService.parentalControlsEnabled) {
if (lazy.gParentalControlsService?.parentalControlsEnabled) {
return "preferences-doh-status-not-active";
}
let confirmationState = Services.dns.currentTrrConfirmationState;
@ -732,7 +731,7 @@ var gPrivacyPane = {
if (
(mode == Ci.nsIDNSService.MODE_TRRFIRST ||
mode == Ci.nsIDNSService.MODE_TRRONLY) &&
lazy.gParentalControlsService.parentalControlsEnabled
lazy.gParentalControlsService?.parentalControlsEnabled
) {
errReason = Services.dns.getTRRSkipReasonName(
Ci.nsITRRSkipReason.TRR_PARENTAL_CONTROL

View file

@ -14,10 +14,6 @@ ChromeUtils.defineESModuleGetters(lazy, {
UserSearchEngine: "resource://gre/modules/UserSearchEngine.sys.mjs",
});
const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
"browser.urlbar.quicksuggest.blockedDigests";
const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";
Preferences.addAll([
{ id: "browser.search.suggest.enabled", type: "bool" },
{ id: "browser.urlbar.suggest.searches", type: "bool" },
@ -74,9 +70,11 @@ var gSearchPane = {
Services.obs.addObserver(this, "browser-search-engine-modified");
Services.obs.addObserver(this, "intl:app-locales-changed");
Services.obs.addObserver(this, "quicksuggest-dismissals-changed");
window.addEventListener("unload", () => {
Services.obs.removeObserver(this, "browser-search-engine-modified");
Services.obs.removeObserver(this, "intl:app-locales-changed");
Services.obs.removeObserver(this, "quicksuggest-dismissals-changed");
});
let suggestsPref = Preferences.get("browser.search.suggest.enabled");
@ -365,14 +363,8 @@ var gSearchPane = {
QuickSuggest.SETTINGS_UI.FULL;
this._updateDismissedSuggestionsStatus();
Preferences.get(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST).on("change", () =>
this._updateDismissedSuggestionsStatus()
);
Preferences.get(PREF_URLBAR_WEATHER_USER_ENABLED).on("change", () =>
this._updateDismissedSuggestionsStatus()
);
setEventListener("restoreDismissedSuggestions", "command", () =>
this.restoreDismissedSuggestions()
QuickSuggest.clearDismissedSuggestions()
);
container.hidden = false;
@ -414,21 +406,9 @@ var gSearchPane = {
* Enables/disables the "Restore" button for dismissed Firefox Suggest
* suggestions.
*/
_updateDismissedSuggestionsStatus() {
async _updateDismissedSuggestionsStatus() {
document.getElementById("restoreDismissedSuggestions").disabled =
!Services.prefs.prefHasUserValue(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST) &&
!(
Services.prefs.prefHasUserValue(PREF_URLBAR_WEATHER_USER_ENABLED) &&
!Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED)
);
},
/**
* Restores Firefox Suggest suggestions dismissed by the user.
*/
restoreDismissedSuggestions() {
Services.prefs.clearUserPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST);
Services.prefs.clearUserPref(PREF_URLBAR_WEATHER_USER_ENABLED);
!(await QuickSuggest.canClearDismissedSuggestions());
},
handleEvent(aEvent) {
@ -481,7 +461,11 @@ var gSearchPane = {
default:
this._engineStore.browserSearchEngineModified(engine, data);
}
break;
}
case "quicksuggest-dismissals-changed":
this._updateDismissedSuggestionsStatus();
break;
}
},

View file

@ -13,9 +13,6 @@ const CONTAINER_ID = "firefoxSuggestContainer";
const DATA_COLLECTION_TOGGLE_ID = "firefoxSuggestDataCollectionSearchToggle";
const LEARN_MORE_ID = "firefoxSuggestLearnMore";
const BUTTON_RESTORE_DISMISSED_ID = "restoreDismissedSuggestions";
const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
"browser.urlbar.quicksuggest.blockedDigests";
const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";
// Maps `SETTINGS_UI` values to expected visibility state objects. See
// `assertSuggestVisibility()` in `head.js` for info on the state objects.
@ -202,6 +199,11 @@ add_task(async function initiallyEnabled_settingsUiOfflineOnly() {
// Tests the "Restore" button for dismissed suggestions.
add_task(async function restoreDismissedSuggestions() {
Assert.ok(
!(await QuickSuggest.canClearDismissedSuggestions()),
"Sanity check: This test expects canClearDismissedSuggestions to return false initially"
);
await openPreferencesViaOpenPreferencesAPI("search", { leaveOpen: true });
let doc = gBrowser.selectedBrowser.contentDocument;
@ -209,47 +211,29 @@ add_task(async function restoreDismissedSuggestions() {
addressBarSection.scrollIntoView();
let button = doc.getElementById(BUTTON_RESTORE_DISMISSED_ID);
Assert.equal(
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""),
"",
"Block list is empty initially"
);
Assert.ok(
Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED),
"Weather suggestions are enabled initially"
);
Assert.ok(button.disabled, "Restore button is disabled initially.");
await QuickSuggest.blockedSuggestions.add("https://example.com/");
Assert.notEqual(
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""),
"",
"Block list is non-empty after adding URL"
Assert.ok(
await QuickSuggest.canClearDismissedSuggestions(),
"canClearDismissedSuggestions should return true after dismissing a suggestion"
);
Assert.ok(!button.disabled, "Restore button is enabled after blocking URL.");
let clearPromise = TestUtils.topicObserved("quicksuggest-dismissals-cleared");
button.click();
Assert.equal(
Services.prefs.getStringPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST, ""),
"",
"Block list is empty clicking Restore button"
await clearPromise;
Assert.ok(
await QuickSuggest.blockedSuggestions.isEmpty(),
"blockedSuggestions.isEmpty() should return true after restoring dismissals"
);
Assert.ok(
!(await QuickSuggest.canClearDismissedSuggestions()),
"canClearDismissedSuggestions should return false after restoring dismissals"
);
Assert.ok(button.disabled, "Restore button is disabled after clicking it.");
Services.prefs.setBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED, false);
Assert.ok(
!button.disabled,
"Restore button is enabled after disabling weather suggestions."
);
button.click();
Assert.ok(
Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED),
"Weather suggestions are enabled after clicking Restore button"
);
Assert.ok(
button.disabled,
"Restore button is disabled after clicking it again."
);
gBrowser.removeCurrentTab();
await SpecialPowers.popPrefEnv();
});

View file

@ -6,7 +6,6 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ClientID: "resource://gre/modules/ClientID.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
});
@ -231,12 +230,10 @@ add_task(async function testReactivateProfileGroupID() {
"upload should be disabled after unchecking checkbox"
);
// TODO: what could we explicitly await, rather than resorting to a timeout?
await new Promise(resolve => lazy.setTimeout(resolve, 1000));
Assert.equal(
Services.prefs.getStringPref("toolkit.telemetry.cachedProfileGroupID"),
lazy.TelemetryUtils.knownProfileGroupID,
await TestUtils.waitForCondition(
() =>
Services.prefs.getStringPref("toolkit.telemetry.cachedProfileGroupID") ===
lazy.TelemetryUtils.knownProfileGroupID,
"after disabling data collection, the profile group ID pref should have the canary value"
);

View file

@ -62,6 +62,9 @@ export var SearchUIUtils = {
case "search-engine-removal":
this.removalOfSearchEngineNotificationBox(...args);
break;
case "search-settings-reset":
this.searchSettingsResetNotificationBox(...args);
break;
}
},
@ -123,6 +126,45 @@ export var SearchUIUtils = {
}
},
/**
* Infobar informing the user that the search settings had to be reset
* and what their new default engine is.
*
* @param {string} newEngine
* Name of the new default engine.
*/
async searchSettingsResetNotificationBox(newEngine) {
let win = lazy.BrowserWindowTracker.getTopWindow();
let buttons = [
{
"l10n-id": "reset-search-settings-button",
primary: true,
callback() {
const notificationBox = win.gNotificationBox.getNotificationWithValue(
"search-settings-reset"
);
win.gNotificationBox.removeNotification(notificationBox);
},
},
{
supportPage: "prefs-search",
},
];
await win.gNotificationBox.appendNotification(
"search-settings-reset",
{
label: {
"l10n-id": "reset-search-settings-message",
"l10n-args": { newEngine },
},
priority: win.gNotificationBox.PRIORITY_SYSTEM,
},
buttons
);
},
/**
* Adds an open search engine and handles error UI.
*

View file

@ -36,7 +36,8 @@ serp-categorization:
send_if_empty: false
metadata:
include_info_sections: false
use_ohttp: true
uploader_capabilities:
- ohttp
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1868476
data_reviews:

View file

@ -27,3 +27,27 @@ add_task(async function test_removalMessage() {
notificationBox.close();
});
add_task(async function test_resetMessage() {
Assert.ok(
!gNotificationBox.getNotificationWithValue("search-settings-reset"),
"Message is not displayed initially."
);
BrowserUtils.callModulesFromCategory(
{ categoryName: "search-service-notification" },
"search-settings-reset",
"Engine 1"
);
await TestUtils.waitForCondition(
() => gNotificationBox.getNotificationWithValue("search-settings-reset"),
"Waiting for message to be displayed"
);
let notificationBox = gNotificationBox.getNotificationWithValue(
"search-settings-reset"
);
Assert.ok(notificationBox, "Message is displayed.");
notificationBox.close();
});

View file

@ -24,6 +24,7 @@ const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored";
const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored";
const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored";
const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared";
const NOTIFY_LAST_SESSION_RE_ENABLED = "sessionstore-last-session-re-enable";
const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup";
const NOTIFY_INITIATING_MANUAL_RESTORE =
"sessionstore-initiating-manual-restore";
@ -157,6 +158,7 @@ const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
@ -174,6 +176,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
HomePage: "resource:///modules/HomePage.sys.mjs",
PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs",
sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs",
RunState: "resource:///modules/sessionstore/RunState.sys.mjs",
SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs",
@ -249,6 +252,10 @@ export var SessionStore = {
return SessionStoreInternal.willAutoRestore;
},
get shouldRestoreLastSession() {
return SessionStoreInternal._shouldRestoreLastSession;
},
init: function ss_init() {
SessionStoreInternal.init();
},
@ -889,7 +896,21 @@ export var SessionStore = {
* @returns {MozTabbrowserTabGroup}
* a reference to the restored tab group in a browser window.
*/
openSavedTabGroup(tabGroupId, targetWindow) {
openSavedTabGroup(
tabGroupId,
targetWindow,
{ source = TabMetrics.METRIC_SOURCE.UNKNOWN } = {}
) {
let isVerticalMode = targetWindow.gBrowser.tabContainer.verticalMode;
Glean.tabgroup.reopen.record({
id: tabGroupId,
source,
layout: isVerticalMode
? TabMetrics.METRIC_TABS_LAYOUT.VERTICAL
: TabMetrics.METRIC_TABS_LAYOUT.HORIZONTAL,
type: TabMetrics.METRIC_REOPEN_TYPE.SAVED,
});
return SessionStoreInternal.openSavedTabGroup(tabGroupId, targetWindow);
},
@ -1008,6 +1029,15 @@ var SessionStoreInternal = {
// whether the last window was closed and should be restored
_restoreLastWindow: false,
// whether we should restore last session on the next launch
// of a regular Firefox window. This scenario is triggered
// when a user closes all regular Firefox windows but the session is not over
_shouldRestoreLastSession: false,
// whether we will potentially be restoring the session
// more than once without Firefox restarting in between
_restoreWithoutRestart: false,
// number of tabs currently restoring
_tabsRestoringCount: 0,
@ -1937,6 +1967,10 @@ var SessionStoreInternal = {
this._windows[aWindow.__SSi].isPopup = true;
}
if (aWindow.document.documentElement.hasAttribute("taskbartab")) {
this._windows[aWindow.__SSi].isTaskbarTab = true;
}
let tabbrowser = aWindow.gBrowser;
// add tab change listeners to all already existing tabs
@ -1966,6 +2000,10 @@ var SessionStoreInternal = {
*/
initializeWindow(aWindow, aInitialState = null) {
let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow);
let isTaskbarTab = this._windows[aWindow.__SSi].isTaskbarTab;
// A regular window is not a private window, taskbar tab window, or popup window
let isRegularWindow =
!isPrivateWindow && !isTaskbarTab && aWindow.toolbar.visible;
// perform additional initialization when the first window is loading
if (lazy.RunState.isStopped) {
@ -2111,6 +2149,24 @@ var SessionStoreInternal = {
// we actually restored the session just now.
this._prefBranch.setBoolPref("sessionstore.resume_session_once", false);
}
// This is a taskbar-tab specific scenario. If an user closes
// all regular Firefox windows except for taskbar tabs and has
// auto restore on startup enabled, _shouldRestoreLastSession
// will be set to true. We should then restore when a
// regular Firefox window is opened.
else if (
Services.prefs.getBoolPref("browser.taskbarTabs.enabled", false) &&
this._shouldRestoreLastSession &&
isRegularWindow
) {
let lastSessionState = LastSession.getState();
this._globalState.setFromState(lastSessionState);
lazy.SessionCookies.restore(lastSessionState.cookies || []);
this.restoreWindows(aWindow, lastSessionState, {
firstWindow: true,
});
this._shouldRestoreLastSession = false;
}
if (this._restoreLastWindow && aWindow.toolbar.visible) {
// always reset (if not a popup window)
@ -2302,6 +2358,43 @@ var SessionStoreInternal = {
// we explicitly allow saving an "empty" window state.
let isLastWindow = this.isLastRestorableWindow();
let isLastRegularWindow =
Object.values(this._windows).filter(
wData => !wData.isPrivate && !wData.isTaskbarTab
).length == 1;
let taskbarTabsRemains = Object.values(this._windows).some(
wData => wData.isTaskbarTab
);
// Closing the last regular Firefox window with
// at least one taskbar tab window still active.
// The session is considered over and we need to restore
// the next time a non-private, non-taskbar-tab window
// is opened.
if (
Services.prefs.getBoolPref("browser.taskbarTabs.enabled", false) &&
isLastRegularWindow &&
!winData.isTaskbarTab &&
!winData.isPrivate &&
taskbarTabsRemains
) {
// If the setting is enabled, Firefox should auto-restore
// the next time a regular window is opened
if (this.willAutoRestore) {
this._shouldRestoreLastSession = true;
// Otherwise, we want "restore last session" button
// to be avaliable in the hamburger menu
} else {
Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_RE_ENABLED);
}
let savedState = this.getCurrentState(true);
lazy.PrivacyFilter.filterPrivateWindowsAndTabs(savedState);
LastSession.setState(savedState);
this._restoreWithoutRestart = true;
}
// clear this window from the list, since it has definitely been closed.
delete this._windows[aWindow.__SSi];
@ -2321,7 +2414,7 @@ var SessionStoreInternal = {
// 2) Flush the window.
// 3) When the flush is complete, revisit our decision to store the window
// in _closedWindows, and add/remove as necessary.
if (!winData.isPrivate) {
if (!winData.isPrivate && !winData.isTaskbarTab) {
this.maybeSaveClosedWindow(winData, isLastWindow);
}
@ -2342,7 +2435,7 @@ var SessionStoreInternal = {
// Save non-private windows if they have at
// least one saveable tab or are the last window.
if (!winData.isPrivate) {
if (!winData.isPrivate && !winData.isTaskbarTab) {
this.maybeSaveClosedWindow(winData, isLastWindow);
if (!isLastWindow && winData.closedId > -1) {
@ -4941,6 +5034,19 @@ var SessionStoreInternal = {
// Restore into windows or open new ones as needed.
for (let i = 0; i < lastSessionState.windows.length; i++) {
let winState = lastSessionState.windows[i];
// If we're restoring multiple times without
// Firefox restarting, we need to remove
// the window being restored from "previously closed windows"
if (this._restoreWithoutRestart) {
let restoreIndex = this._closedWindows.findIndex(win => {
return win.closedId == winState.closedId;
});
if (restoreIndex > -1) {
this._closedWindows.splice(restoreIndex, 1);
}
}
let lastSessionWindowID = winState.__lastSessionWindowID;
// delete lastSessionWindowID so we don't add that to the window again
delete winState.__lastSessionWindowID;
@ -4990,6 +5096,10 @@ var SessionStoreInternal = {
this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows))
);
if (this._restoreWithoutRestart) {
this.removeDuplicateClosedWindows(lastSessionState);
}
// Merge closed windows from this session with ones from last session
if (lastSessionState._closedWindows) {
// reset window closedIds and any references to them from closed tabs
@ -5033,6 +5143,26 @@ var SessionStoreInternal = {
this._notifyOfClosedObjectsChange();
},
/**
* There might be duplicates in these two arrays if we
* restore multiple times without restarting in between.
* We will keep the contents of the more recent _closedWindows array
*
* @param lastSessionState
* An object containing information about the previous browsing session
*/
removeDuplicateClosedWindows(lastSessionState) {
// A set of closedIDs for the most recent list of closed windows
let currentClosedIds = new Set(
this._closedWindows.map(window => window.closedId)
);
// Remove closed windows that are present in both current and last session
lastSessionState._closedWindows = lastSessionState._closedWindows.filter(
win => !currentClosedIds.has(win.closedId)
);
},
/**
* Revive a crashed tab and restore its state from before it crashed.
*
@ -5264,7 +5394,7 @@ var SessionStoreInternal = {
// collect the data for all windows
for (ix in this._windows) {
if (this._windows[ix]._restoring) {
if (this._windows[ix]._restoring || this._windows[ix].isTaskbarTab) {
// window data is still in _statesToRestore
continue;
}
@ -6946,6 +7076,9 @@ var SessionStoreInternal = {
* @returns {boolean} true if the group is saveable.
*/
shouldSaveTabGroup: function ssi_shouldSaveTabGroup(group) {
if (!group) {
return false;
}
for (let tab of group.tabs) {
let tabState = lazy.TabState.collect(tab);
if (this._shouldSaveTabState(tabState)) {

View file

@ -109,11 +109,9 @@ export var StartupPerformance = {
delta
);
} else {
Services.telemetry
.getHistogramById(
"FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS"
)
.add(delta);
Glean.sessionRestore.manualRestoreDurationUntilEagerTabsRestored.accumulateSingleSample(
delta
);
}
Glean.sessionRestore.numberOfEagerTabsRestored.accumulateSingleSample(
this._totalNumberOfEagerTabs

View file

@ -27,3 +27,9 @@ skip-if = [
"os == 'win' && os_version == '11.26100' && processor == 'x86'", # Bug 1727691
"os == 'win' && os_version == '11.26100' && processor == 'x86_64'", # Bug 1727691
]
["test_taskbartab_restore.py"]
run-if = ["os == 'win'"]
["test_taskbartab_sessionstate.py"]
run-if = ["os == 'win'"]

View file

@ -43,6 +43,7 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
no_auto_updates=True,
win_register_restart=False,
test_windows=DEFAULT_WINDOWS,
taskbartabs_enable=False,
):
super(SessionStoreTestCase, self).setUp()
self.marionette.set_context("chrome")
@ -79,6 +80,8 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
"browser.sessionstore.debug.no_auto_updates": no_auto_updates,
# Whether to enable the register application restart mechanism.
"toolkit.winRegisterApplicationRestart": win_register_restart,
# Whether to enable taskbar tabs for this test
"browser.taskbarTabs.enabled": taskbartabs_enable,
}
)
@ -138,6 +141,47 @@ class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase):
self.marionette.switch_to_window(win)
self.open_tabs(win, urls)
# Open a Firefox web app (taskbar tab) window
def open_taskbartab_window(self):
self.marionette.execute_async_script(
"""
let [resolve] = arguments;
(async () => {
let extraOptions = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
Ci.nsIWritablePropertyBag2
);
extraOptions.setPropertyAsBool("taskbartab", true);
let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
args.appendElement(null);
args.appendElement(extraOptions);
args.appendElement(null);
// Simulate opening a taskbar tab window
let win = Services.ww.openWindow(
null,
AppConstants.BROWSER_CHROME_URL,
"_blank",
"chrome,dialog=no,titlebar,close,toolbar,location,personalbar=no,status,menubar=no,resizable,minimizable,scrollbars",
args
);
await new Promise(resolve => {
win.addEventListener("load", resolve, { once: true });
});
await win.delayedStartupPromise;
})().then(resolve);
"""
)
# Helper function for taskbar tabs tests, opens a taskbar tab window,
# closes the regular window, and reopens another regular window.
# Firefox will then be in a "ready to restore" state
def setup_taskbartab_restore_scenario(self):
self.open_taskbartab_window()
taskbar_tab_window_handle = self.marionette.close_chrome_window()[0]
self.marionette.switch_to_window(taskbar_tab_window_handle)
self.marionette.open(type="window")
def open_tabs(self, win, urls):
"""Open a set of URLs inside a window in new tabs.

View file

@ -0,0 +1,134 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import sys
from marionette_driver import Wait
# add this directory to the path
sys.path.append(os.path.dirname(__file__))
from session_store_test_case import SessionStoreTestCase
def inline(title):
return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format(
title
)
class TestManualRestoreWithTaskbarTabs(SessionStoreTestCase):
def setUp(self):
super(TestManualRestoreWithTaskbarTabs, self).setUp(
startup_page=1,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all regular windows except for a taskbar tab window. The
session should be over at this point. Opening another regular Firefox
window will open "restore previous session" in the hamburger menu.
And clicking it will restore the correct session.
"""
def test_restore_without_closing_taskbartab(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
# See session_store_test_case.py
self.setup_taskbartab_restore_scenario()
# Verify that "restore previous session" button
# is visible in the hamburger menu
self.assertEqual(
self.marionette.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return PanelMultiView.getViewNode(
newWindow.document,
"appMenu-restoreSession"
).hasAttribute("disabled");
"""
),
False,
"The restore last session button should be visible",
)
# Simulate clicking "restore previous session"
self.marionette.execute_script(
"""
SessionStore.restoreLastSession();
"""
)
# Wait for the restore to be completed,
# meaning the window we opened should have
# two tabs again.
Wait(self.marionette).until(
lambda mn: mn.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return newWindow.gBrowser.tabs.length;
"""
)
== 2
)
class TestAutoRestoreWithTaskbarTabs(SessionStoreTestCase):
def setUp(self):
super(TestAutoRestoreWithTaskbarTabs, self).setUp(
startup_page=3,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all regular windows except for a taskbar tab window. The
session should be over at this point. Opening another regular Firefox
window will open automatically restore the correct session
"""
def test_restore_without_closing_taskbartab(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
self.setup_taskbartab_restore_scenario()
# Wait for the auto restore to be completed,
# meaning the window we opened should have
# the original two tabs plus the home page tab.
Wait(self.marionette).until(
lambda mn: mn.execute_script(
"""
let newWindow = BrowserWindowTracker.getTopWindow({ allowTaskbarTabs: false });
return newWindow.gBrowser.tabs.length;
"""
)
== 3
)

View file

@ -0,0 +1,91 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import sys
# add this directory to the path
sys.path.append(os.path.dirname(__file__))
from session_store_test_case import SessionStoreTestCase
def inline(title):
return "data:text/html;charset=utf-8,<html><head><title>{}</title></head><body></body></html>".format(
title
)
class TestTaskbarTabSessionState(SessionStoreTestCase):
def setUp(self):
super(TestTaskbarTabSessionState, self).setUp(
startup_page=1,
include_private=False,
restore_on_demand=False,
taskbartabs_enable=True,
test_windows=set(
[
# Window 1
(
inline("lorem ipsom"),
inline("dolor"),
),
]
),
)
"""
Close all Firefox windows with the web app being closed last,
the session store state should include the last regular window
that's closed, but not the web app
"""
def test_taskbartab_session_state(self):
self.wait_for_windows(
self.all_windows, "Not all requested windows have been opened"
)
self.marionette.enforce_gecko_prefs({"browser.taskbarTabs.enabled": True})
self.open_taskbartab_window()
# Close the original regular Firefox window
taskbar_tab_window_handle = self.marionette.close_chrome_window()[0]
self.marionette.switch_to_window(taskbar_tab_window_handle)
self.marionette.set_context("content")
dummy_html = self.marionette.absolute_url("empty.html")
self.marionette.navigate(dummy_html)
self.marionette.set_context("chrome")
self.marionette.quit()
self.marionette.start_session()
self.marionette.set_context("chrome")
# check for the session store state, it should have only two tabs and one window
self.assertEqual(
self.marionette.execute_script(
"""
const { _LastSession } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/SessionStore.sys.mjs"
);
return _LastSession.getState().windows.length
"""
),
1,
"One window should be in the session state",
)
self.assertEqual(
self.marionette.execute_script(
"""
const { _LastSession } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/SessionStore.sys.mjs"
);
return _LastSession.getState().windows[0].tabs.length
"""
),
2,
"Two tabs should be in the session state",
)

View file

@ -1753,7 +1753,7 @@ var SidebarController = {
* hiding of the sidebar.
* @param {boolean} options.dismissPanel -Only close the panel or close the whole sidebar (the default.)
*/
hide({ triggerNode, dismissPanel = true } = {}) {
hide({ triggerNode, dismissPanel = this.sidebarRevampEnabled } = {}) {
if (!this.isOpen) {
return;
}
@ -1910,6 +1910,14 @@ var SidebarController = {
// Re-render sidebar-main so that templating is updated
// for proper keyboard navigation for Tools
this.sidebarMain.requestUpdate();
if (
!this.verticalTabsEnabled &&
this.sidebarRevampVisibility == "hide-sidebar"
) {
// the sidebar.visibility pref didn't change so updateVisbility hasn't
// been called; we need to call it here to un-expand the launcher
this._state.updateVisibility(undefined, false);
}
},
debouncedMouseEnter() {

View file

@ -15,3 +15,8 @@ fxview-search-textbox {
.menu-button::part(button) {
margin-inline-start: var(--space-small);
}
.nested-card {
--card-accordion-closed-icon: url("chrome://global/skin/icons/arrow-right.svg");
--card-accordion-open-icon: url("chrome://global/skin/icons/arrow-down.svg");
}

View file

@ -45,6 +45,12 @@ export class SidebarHistory extends SidebarPage {
this._menu = doc.getElementById("sidebar-history-menu");
this._menuSortByDate = doc.getElementById("sidebar-history-sort-by-date");
this._menuSortBySite = doc.getElementById("sidebar-history-sort-by-site");
this._menuSortByDateSite = doc.getElementById(
"sidebar-history-sort-by-date-and-site"
);
this._menuSortByLastVisited = doc.getElementById(
"sidebar-history-sort-by-last-visited"
);
this._menu.addEventListener("command", this);
this._menu.addEventListener("popuphidden", this.handlePopupEvent);
this.addContextMenuListeners();
@ -75,6 +81,12 @@ export class SidebarHistory extends SidebarPage {
case "sidebar-history-sort-by-site":
this.controller.onChangeSortOption(e, "site");
break;
case "sidebar-history-sort-by-date-and-site":
this.controller.onChangeSortOption(e, "datesite");
break;
case "sidebar-history-sort-by-last-visited":
this.controller.onChangeSortOption(e, "lastvisited");
break;
case "sidebar-history-clear":
lazy.Sanitizer.showUI(this.topWindow);
break;
@ -161,39 +173,63 @@ export class SidebarHistory extends SidebarPage {
const { historyVisits } = this.controller;
switch (this.controller.sortOption) {
case "date":
return historyVisits.map(({ l10nId, items }, i) => {
let tabIndex = i > 0 ? "-1" : undefined;
return html` <moz-card
type="accordion"
?expanded=${i < DAYS_EXPANDED_INITIALLY}
data-l10n-id=${l10nId}
data-l10n-args=${JSON.stringify({
date: items[0].time,
})}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
});
return historyVisits.map(({ l10nId, items }, i) =>
this.#dateCardTemplate(l10nId, i, items)
);
case "site":
return historyVisits.map(({ domain, items }, i) => {
let tabIndex = i > 0 ? "-1" : undefined;
return html` <moz-card
type="accordion"
expanded
heading=${domain}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
});
return historyVisits.map(({ domain, items }, i) =>
this.#siteCardTemplate(domain, i, items)
);
case "datesite":
return historyVisits.map(({ l10nId, items }, i) =>
this.#dateCardTemplate(l10nId, i, items, true)
);
case "lastvisited":
return historyVisits.map(
({ items }) =>
html`<moz-card>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`
);
default:
return [];
}
}
#dateCardTemplate(l10nId, index, items, isDateSite = false) {
const tabIndex = index > 0 ? "-1" : undefined;
return html` <moz-card
type="accordion"
?expanded=${index < DAYS_EXPANDED_INITIALLY}
data-l10n-id=${l10nId}
data-l10n-args=${JSON.stringify({
date: isDateSite ? items[0][1][0].time : items[0].time,
})}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${isDateSite
? items.map(([domain, visits], i) =>
this.#siteCardTemplate(domain, i, visits, true)
)
: this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
}
#siteCardTemplate(domain, index, items, isDateSite = false) {
let tabIndex = index > 0 ? "-1" : undefined;
return html` <moz-card
class=${isDateSite ? "nested-card" : ""}
type="accordion"
?expanded=${!isDateSite}
heading=${domain}
@keydown=${this.handleCardKeydown}
tabindex=${ifDefined(tabIndex)}
>
${this.#tabListTemplate(this.getTabItems(items))}
</moz-card>`;
}
#emptyMessageTemplate() {
let descriptionHeader;
let descriptionLabels;
@ -302,6 +338,14 @@ export class SidebarHistory extends SidebarPage {
"checked",
this.controller.sortOption == "site"
);
this._menuSortByDateSite.setAttribute(
"checked",
this.controller.sortOption == "datesite"
);
this._menuSortByLastVisited.setAttribute(
"checked",
this.controller.sortOption == "lastvisited"
);
}
render() {

View file

@ -36,7 +36,7 @@ export class SidebarPanelHeader extends MozLitElement {
view=${this.view}
size="default"
type="icon ghost"
tabindex="-1"
tabindex="1"
>
</moz-button>
</div>

View file

@ -30,7 +30,7 @@ function isActiveElement(el) {
}
add_task(async function test_keyboard_navigation() {
const { document } = win;
const { document, SidebarController } = win;
const sidebar = document.querySelector("sidebar-main");
info("Waiting for tool buttons to be present");
await BrowserTestUtils.waitForMutationCondition(
@ -87,7 +87,38 @@ add_task(async function test_keyboard_navigation() {
info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(isActiveElement(customizeButton), "Customize button is focused.");
}).skip(); // Bug 1950504
info("Press Enter key again.");
const promiseFocused = BrowserTestUtils.waitForEvent(win, "SidebarFocused");
EventUtils.synthesizeKey("KEY_Enter", {}, win);
await promiseFocused;
await sidebar.updateComplete;
ok(sidebar.open, "Sidebar is open.");
let customizeDocument = SidebarController.browser.contentDocument;
const customizeComponent =
customizeDocument.querySelector("sidebar-customize");
const sidebarPanelHeader = customizeComponent.shadowRoot.querySelector(
"sidebar-panel-header"
);
let closeButton = sidebarPanelHeader.closeButton;
info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(isActiveElement(closeButton), "Close button is focused.");
info("Press Tab key.");
EventUtils.synthesizeKey("KEY_Tab", {}, win);
ok(
isActiveElement(customizeComponent.verticalTabsInput),
"First customize component is focused"
);
info("Press Tab and Shift key.");
EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, win);
ok(isActiveElement(closeButton), "Close button is focused.");
EventUtils.synthesizeKey("KEY_Enter", {}, win);
await sidebar.updateComplete;
ok(!sidebar.open, "Sidebar is closed.");
});
add_task(async function test_menu_items_labeled() {
const { document, SidebarController } = win;

View file

@ -188,6 +188,8 @@ add_task(async function test_history_sort() {
const menu = component._menu;
const sortByDateButton = component._menuSortByDate;
const sortBySiteButton = component._menuSortBySite;
const sortByDateSiteButton = component._menuSortByDateSite;
const sortByLastVisitedButton = component._menuSortByLastVisited;
info("Sort history by site.");
let promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
@ -233,6 +235,58 @@ add_task(async function test_history_sort() {
"The cards for Today and Yesterday are expanded."
);
}
info("Sort history by date and site.");
promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
EventUtils.synthesizeMouseAtCenter(menuButton, {}, contentWindow);
await promiseMenuShown;
menu.activateItem(sortByDateSiteButton);
await BrowserTestUtils.waitForMutationCondition(
component.shadowRoot,
{ childList: true, subtree: true },
() => component.lists.length === dates.length * URLs.length
);
Assert.ok(
true,
"There is a card for each date, and a nested card for each site."
);
Assert.equal(
sortByDateSiteButton.getAttribute("checked"),
"true",
"Sort by date and site is checked."
);
const outerCards = [...component.cards].filter(
el => !el.classList.contains("nested-card")
);
for (const [i, card] of outerCards.entries()) {
Assert.equal(
card.expanded,
i === 0 || i === 1,
"The cards for Today and Yesterday are expanded."
);
}
info("Sort history by last visited.");
promiseMenuShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
EventUtils.synthesizeMouseAtCenter(menuButton, {}, contentWindow);
await promiseMenuShown;
menu.activateItem(sortByLastVisitedButton);
await BrowserTestUtils.waitForMutationCondition(
component.shadowRoot,
{ childList: true, subtree: true },
() => component.lists.length === 1
);
Assert.equal(
component.lists[0].tabItems.length,
URLs.length,
"There is a single card with a row for each site."
);
Assert.equal(
sortByLastVisitedButton.getAttribute("checked"),
"true",
"Sort by last visited is checked."
);
win.SidebarController.hide();
});

View file

@ -585,3 +585,57 @@ add_task(async function test_vertical_tabs_min_width() {
}
await SpecialPowers.popPrefEnv();
});
add_task(
async function test_launcher_collapsed_entering_horiz_tabs_with_hide_sidebar() {
const { sidebarMain } = SidebarController;
await SpecialPowers.pushPrefEnv({ set: [["sidebar.verticalTabs", true]] });
await waitForTabstripOrientation("vertical");
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main is shown initially."
);
ok(
sidebarMain.expanded,
"Launcher is expanded with vertical tabs and always-show"
);
await SpecialPowers.pushPrefEnv({
set: [["sidebar.visibility", "hide-sidebar"]],
});
await sidebarMain.updateComplete;
ok(
BrowserTestUtils.isHidden(sidebarMain),
"Revamped sidebar main hidden when we switch to hide-sidebar."
);
// toggle the launcher back open.
document.getElementById("sidebar-button").doCommand();
await sidebarMain.updateComplete;
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main visible again."
);
ok(
sidebarMain.expanded,
"Launcher is still expanded as vertical tabs are still enabled"
);
// switch back to horizontal tabs and confirm the launcher get un-expanded
await SpecialPowers.pushPrefEnv({ set: [["sidebar.verticalTabs", false]] });
await waitForTabstripOrientation("horizontal");
ok(
BrowserTestUtils.isVisible(sidebarMain),
"Revamped sidebar main is still visible when we switch to horizontal tabs."
);
ok(
!sidebarMain.expanded,
"Launcher is collapsed when we switch to horizontal tabs with hide-sidebar"
);
await SpecialPowers.popPrefEnv();
await SpecialPowers.popPrefEnv();
await SpecialPowers.popPrefEnv();
}
);

View file

@ -17,16 +17,16 @@
}
},
"duty-start-dates": {
"2025-24-03": "Nikki Sharpley",
"2025-01-06": "Kelly Cochrane",
"2025-01-08": "Jonathan Sudiaman",
"2025-01-10": "Sam Foster",
"2025-01-12": "Sarah Clements",
"2026-01-02": "Nikki Sharpley",
"2026-01-04": "Kelly Cochrane",
"2026-01-06": "Jonathan Sudiaman",
"2026-01-08": "Sam Foster",
"2026-01-10": "Sarah Clements",
"2026-01-12": "Nikki Sharpley"
"2025-04-10": "Nikki Sharpley",
"2025-06-01": "Kelly Cochrane",
"2025-08-01": "Jonathan Sudiaman",
"2025-10-01": "Sam Foster",
"2025-12-01": "Sarah Clements",
"2026-02-01": "Nikki Sharpley",
"2026-04-01": "Kelly Cochrane",
"2026-06-01": "Jonathan Sudiaman",
"2026-08-01": "Sam Foster",
"2026-10-01": "Sarah Clements",
"2026-12-01": "Nikki Sharpley"
}
}

View file

@ -2,9 +2,11 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import subprocess
import threading
import time
from pathlib import Path
import mozpack.path as mozpath
from mach.decorators import Command, CommandArgument, SubCommand
@ -40,6 +42,19 @@ def storybook_build(command_context):
return run_npm(command_context, args=["run", "build-storybook"])
@SubCommand(
"storybook",
"upgrade",
description="Upgrade all storybook dependencies to latest of ranges in package.json",
)
def storybook_upgrade(command_context):
delete_storybook_node_modules()
package_lock_path = "browser/components/storybook/package-lock.json"
if os.path.exists(package_lock_path):
os.unlink(package_lock_path)
return run_npm(command_context, args=["install"])
@SubCommand(
"storybook", "launch", description="Launch the Storybook site in your local build."
)
@ -55,6 +70,21 @@ def storybook_launch(command_context):
)
def delete_path(path):
if path.is_file() or path.is_symlink():
path.unlink()
return
for p in path.iterdir():
delete_path(p)
path.rmdir()
def delete_storybook_node_modules():
node_modules_path = Path("browser/components/storybook/node_modules")
if node_modules_path.exists():
delete_path(node_modules_path)
def start_browser(command_context):
# This delay is used to avoid launching the browser before the Storybook server has started.
time.sleep(5)

View file

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs";
const MAX_INITIAL_ITEMS = 5;
@ -77,7 +78,9 @@ export class GroupsPanel {
}
case "allTabsGroupView_restoreGroup":
this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win);
this.win.SessionStore.openSavedTabGroup(tabGroupId, this.win, {
source: TabMetrics.METRIC_SOURCE.TAB_OVERFLOW_MENU,
});
break;
}
}

View file

@ -143,23 +143,15 @@ export class SmartTabGroupingManager {
async smartTabGroupingForGroup(group, tabs) {
// Add tabs to suggested group
const groupTabs = group.tabs;
const uniqueSpecs = new Set();
const allTabs = tabs.filter(tab => {
// Don't include tabs already pinned
if (tab.pinned) {
return false;
}
const spec = tab?.linkedBrowser?.currentURI?.spec;
if (!spec) {
if (!tab?.linkedBrowser?.currentURI?.spec) {
return false;
}
if (!uniqueSpecs.has(spec)) {
uniqueSpecs.add(spec);
return true;
}
return false;
return true;
});
// find tabs that are part of the group

View file

@ -1,18 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tab groups could originate. These "source" values
* should be sent as extra data with tab group-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
UNKNOWN: "unknown",
});
export const TabGroupMetrics = {
METRIC_SOURCE,
};

View file

@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* A common list of systems, surfaces, controls, etc. from which user
* interactions with tabs could originate. These "source" values
* should be sent as extra data with tab-related metrics events.
*/
const METRIC_SOURCE = Object.freeze({
TAB_OVERFLOW_MENU: "tab_overflow",
TAB_GROUP_MENU: "tab_group",
TAB_MENU: "tab_menu",
DRAG_AND_DROP: "drag",
SUGGEST: "suggest",
RECENT_TABS: "recent",
UNKNOWN: "unknown",
});
const METRIC_TABS_LAYOUT = Object.freeze({
HORIZONTAL: "horizontal",
VERTICAL: "vertical",
});
const METRIC_REOPEN_TYPE = Object.freeze({
SAVED: "saved",
DELETED: "deleted",
});
/**
* @typedef {object} TabMetricsContext
* @property {boolean} [isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone). This causes telemetry events to fire.
* @property {string} [telemetrySource="unknown"]
* The system, surface, or control the user used to take this action.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
/**
* Creates a `TabMetricsContext` object for a user event originating from
* the specified source.
*
* @param {string} telemetrySource
* @see TabMetrics.METRIC_SOURCE
* @returns {TabMetricsContext}
*/
function userTriggeredContext(telemetrySource) {
return {
isUserTriggered: true,
telemetrySource,
};
}
export const TabMetrics = {
METRIC_SOURCE,
METRIC_TABS_LAYOUT,
METRIC_REOPEN_TYPE,
userTriggeredContext,
};

View file

@ -235,6 +235,9 @@ class TabsListBase {
}
}
/**
* @param {MozTabbrowserTab} tab
*/
_moveTab(tab) {
let item = this.tabToElement.get(tab);
if (item) {

View file

@ -110,8 +110,8 @@
PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
SmartTabGroupingManager:
"moz-src:///browser/components/tabbrowser/SmartTabGrouping.sys.mjs",
TabGroupMetrics:
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs",
TabMetrics:
"moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
TabStateFlusher:
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
UrlbarProviderOpenTabs:
@ -2958,7 +2958,7 @@
* Causes the group create UI to be displayed and telemetry events to be fired.
* @param {string} [options.telemetryUserCreateSource]
* The means by which the tab group was created.
* @see TabGroupMetrics.METRIC_SOURCE for possible values.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
addTabGroup(
@ -2981,6 +2981,10 @@
}
if (!id) {
// Note: If this changes, make sure to also update the
// getExtTabGroupIdForInternalTabGroupId implementation in
// browser/components/extensions/parent/ext-browser.js.
// See: Bug 1960104 - Improve tab group ID generation in addTabGroup
id = `${Date.now()}-${Math.round(Math.random() * 100)}`;
}
let group = this._createTabGroup(id, color, false, label);
@ -3035,14 +3039,14 @@
* switches windows). This causes telemetry events to fire.
* @param {string} [options.telemetrySource="unknown"]
* The means by which the tab group was removed.
* @see TabGroupMetrics.METRIC_SOURCE for possible values.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
async removeTabGroup(
group,
options = {
isUserTriggered: false,
telemetrySource: this.TabGroupMetrics.METRIC_SOURCE.UNKNOWN,
telemetrySource: this.TabMetrics.METRIC_SOURCE.UNKNOWN,
}
) {
if (this.tabGroupMenu.panel.state != "closed") {
@ -3943,11 +3947,11 @@
// Place tab at the end of the contextual tab group because one of:
// 1) no `itemAfter` so `tab` should be the last tab in the tab strip
// 2) `itemAfter` is in a different tab group
this.moveTabToGroup(tab, tabGroup);
tabGroup.appendChild(tab);
}
} else if (
this.isTab(itemAfter) &&
itemAfter?.group?.tabs[0] == itemAfter
(this.isTab(itemAfter) && itemAfter.group?.tabs[0] == itemAfter) ||
this.isTabGroupLabel(itemAfter)
) {
// If there is ambiguity around whether or not a tab should be inserted
// into a group (i.e. because the new tab is being inserted on the
@ -5869,7 +5873,7 @@
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {Element} element
* @returns {boolean}
* `true` if element is a `<tab>`
*/
@ -5878,7 +5882,16 @@
}
/**
* @param {MozTabbrowserTab|MozTextLabel} element
* @param {Element} element
* @returns {boolean}
* `true` if element is a `<tab-group>`
*/
isTabGroup(element) {
return !!(element?.tagName == "tab-group");
}
/**
* @param {Element} element
* @returns {boolean}
* `true` if element is the `<label>` in a `<tab-group>`
*/
@ -5911,7 +5924,7 @@
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} aTab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* The tab or tab group to move. Also accepts a tab group label as a
* stand-in for its group.
* @param {object} [options]
@ -5921,18 +5934,36 @@
* The desired position, expressed as the index within the
* `MozTabbrowserTabs::ariaFocusableItems` array.
* @param {boolean} [options.forceUngrouped=false]
* Force `aTab` to move into position as a standalone tab, overriding
* Force `element` to move into position as a standalone tab, overriding
* any possibility of entering a tab group. For example, setting `true`
* ensures that a pinned tab will not accidentally be placed inside of
* a tab group, since pinned tabs are presently not allowed in tab groups.
* @property {boolean} [options.isUserTriggered=false]
* Should be true if there was an explicit action/request from the user
* (as opposed to some action being taken internally or for technical
* bookkeeping reasons alone) to move the tab. This causes telemetry
* events to fire.
* @property {string} [options.telemetrySource="unknown"]
* The system, surface, or control the user used to move the tab.
* @see TabMetrics.METRIC_SOURCE for possible values.
* Defaults to "unknown".
*/
moveTabTo(aTab, { elementIndex, tabIndex, forceUngrouped = false } = {}) {
moveTabTo(
element,
{
elementIndex,
tabIndex,
forceUngrouped = false,
isUserTriggered = false,
telemetrySource = this.TabMetrics.METRIC_SOURCE.UNKNOWN,
} = {}
) {
if (typeof elementIndex == "number") {
tabIndex = this.#elementIndexToTabIndex(elementIndex);
}
// Don't allow mixing pinned and unpinned tabs.
if (this.isTab(aTab) && aTab.pinned) {
if (this.isTab(element) && element.pinned) {
tabIndex = Math.min(tabIndex, this.pinnedTabCount - 1);
} else {
tabIndex = Math.max(tabIndex, this.pinnedTabCount);
@ -5940,73 +5971,84 @@
// Return early if the tab is already in the right spot.
if (
this.isTab(aTab) &&
aTab._tPos == tabIndex &&
!(aTab.group && forceUngrouped)
this.isTab(element) &&
element._tPos == tabIndex &&
!(element.group && forceUngrouped)
) {
return;
}
// When asked to move a tab group label, we need to move the whole group
// instead.
if (this.isTabGroupLabel(aTab)) {
if (this.isTabGroupLabel(element)) {
element = element.group;
}
if (this.isTabGroup(element)) {
forceUngrouped = true;
aTab = aTab.group;
}
this.#handleTabMove(aTab, () => {
let neighbor = this.tabs[tabIndex];
if (forceUngrouped && neighbor.group) {
neighbor = neighbor.group;
}
if (neighbor && tabIndex > aTab._tPos) {
neighbor.after(aTab);
} else {
this.tabContainer.insertBefore(aTab, neighbor);
}
});
this.#handleTabMove(
element,
() => {
let neighbor = this.tabs[tabIndex];
if (forceUngrouped && neighbor.group) {
neighbor = neighbor.group;
}
if (neighbor && this.isTab(element) && tabIndex > element._tPos) {
neighbor.after(element);
} else {
this.tabContainer.insertBefore(element, neighbor);
}
},
{ isUserTriggered, telemetrySource }
);
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/
moveTabBefore(tab, targetElement) {
this.#moveTabNextTo(tab, targetElement, true);
moveTabBefore(element, targetElement, metricsContext) {
this.#moveTabNextTo(element, targetElement, true, metricsContext);
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/
moveTabsBefore(tabs, targetElement) {
this.#moveTabsNextTo(tabs, targetElement, true);
moveTabsBefore(elements, targetElement, metricsContext) {
this.#moveTabsNextTo(elements, targetElement, true, metricsContext);
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/
moveTabAfter(tab, targetElement) {
this.#moveTabNextTo(tab, targetElement, false);
moveTabAfter(element, targetElement, metricsContext) {
this.#moveTabNextTo(element, targetElement, false, metricsContext);
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} tabs
* @param {MozTabbrowserTab|MozTabbrowserTabGroup[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {TabMetricsContext} [metricsContext]
*/
moveTabsAfter(tabs, targetElement) {
this.#moveTabsNextTo(tabs, targetElement, false);
moveTabsAfter(elements, targetElement, metricsContext) {
this.#moveTabsNextTo(elements, targetElement, false, metricsContext);
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} tab
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* The tab or tab group to move. Also accepts a tab group label as a
* stand-in for its group.
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore
* @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/
#moveTabNextTo(tab, targetElement, moveBefore = false) {
#moveTabNextTo(element, targetElement, moveBefore = false, metricsContext) {
if (this.isTabGroupLabel(targetElement)) {
targetElement = targetElement.group;
if (!moveBefore) {
@ -6014,53 +6056,82 @@
moveBefore = true;
}
}
if (this.isTabGroupLabel(tab)) {
tab = tab.group;
if (this.isTabGroupLabel(element)) {
element = element.group;
if (targetElement?.group) {
targetElement = targetElement.group;
}
}
// Don't allow mixing pinned and unpinned tabs.
if (tab.pinned && !targetElement?.pinned) {
if (element.pinned && !targetElement?.pinned) {
targetElement = this.tabs[this.pinnedTabCount - 1];
moveBefore = false;
} else if (!tab.pinned && targetElement && targetElement.pinned) {
} else if (!element.pinned && targetElement && targetElement.pinned) {
targetElement = this.tabs[this.pinnedTabCount];
moveBefore = true;
}
let getContainer = () => {
if (tab.pinned && this.tabContainer.verticalMode) {
if (element.pinned && this.tabContainer.verticalMode) {
return this.tabContainer.verticalPinnedTabsContainer;
}
return this.tabContainer;
};
this.#handleTabMove(tab, () => {
if (moveBefore) {
getContainer().insertBefore(tab, targetElement);
} else if (targetElement) {
targetElement.after(tab);
} else {
getContainer().appendChild(tab);
}
});
this.#handleTabMove(
element,
() => {
if (moveBefore) {
getContainer().insertBefore(element, targetElement);
} else if (targetElement) {
targetElement.after(element);
} else {
getContainer().appendChild(element);
}
},
metricsContext
);
}
/**
* @param {MozTabbrowserTab[]} tabs
* @param {MozTabbrowserTab[]} elements
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} targetElement
* @param {boolean} moveBefore
* @param {boolean} [moveBefore=false]
* @param {TabMetricsContext} [metricsContext]
*/
#moveTabsNextTo(tabs, targetElement, moveBefore = false) {
this.#moveTabNextTo(tabs[0], targetElement, moveBefore);
for (let i = 1; i < tabs.length; i++) {
this.#moveTabNextTo(tabs[i], tabs[i - 1]);
#moveTabsNextTo(
elements,
targetElement,
moveBefore = false,
metricsContext
) {
this.#moveTabNextTo(
elements[0],
targetElement,
moveBefore,
metricsContext
);
for (let i = 1; i < elements.length; i++) {
this.#moveTabNextTo(
elements[i],
elements[i - 1],
false,
metricsContext
);
}
}
moveTabToGroup(aTab, aGroup) {
/**
*
* @param {MozTabbrowserTab} aTab
* @param {MozTabbrowserTabGroup} aGroup
* @param {TabMetricsContext} [metricsContext]
*/
moveTabToGroup(aTab, aGroup, metricsContext) {
if (!this.isTab(aTab)) {
throw new Error("Can only move a tab into a tab group");
}
if (aTab.pinned) {
return;
}
@ -6069,47 +6140,118 @@
}
aGroup.collapsed = false;
this.#handleTabMove(aTab, () => aGroup.appendChild(aTab));
this.#handleTabMove(aTab, () => aGroup.appendChild(aTab), metricsContext);
this.removeFromMultiSelectedTabs(aTab);
this.tabContainer._notifyBackgroundTab(aTab);
}
/**
* @param {MozTabbrowserTab} aTab
* @param {function():void} moveActionCallback
* @returns
* @typedef {object} TabMoveState
* @property {number} tabIndex
* @property {number} [elementIndex]
* @property {string} [tabGroupId]
*/
#handleTabMove(aTab, moveActionCallback) {
/**
* @param {MozTabbrowserTab} tab
* @returns {TabMoveState|undefined}
*/
#getTabMoveState(tab) {
if (!this.isTab(tab)) {
return undefined;
}
let state = {
tabIndex: tab._tPos,
};
if (tab.visible) {
state.elementIndex = tab.elementIndex;
}
if (tab.group) {
state.tabGroupId = tab.group.id;
}
return state;
}
/**
* @param {MozTabbrowserTab} tab
* @param {TabMoveState} [previousTabState]
* @param {TabMoveState} [currentTabState]
* @param {TabMetricsContext} [metricsContext]
*/
#notifyOnTabMove(tab, previousTabState, currentTabState, metricsContext) {
if (!this.isTab(tab) || !previousTabState || !currentTabState) {
return;
}
let changedPosition =
previousTabState.tabIndex != currentTabState.tabIndex;
let changedTabGroup =
previousTabState.tabGroupId != currentTabState.tabGroupId;
if (changedPosition || changedTabGroup) {
tab.dispatchEvent(
new CustomEvent("TabMove", {
bubbles: true,
detail: {
previousTabState,
currentTabState,
isUserTriggered: metricsContext?.isUserTriggered ?? false,
telemetrySource:
metricsContext?.telemetrySource ??
this.TabMetrics.METRIC_SOURCE.UNKNOWN,
},
})
);
}
}
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @param {function():void} moveActionCallback
* @param {TabMetricsContext} [metricsContext]
*/
#handleTabMove(element, moveActionCallback, metricsContext) {
let tabs;
if (this.isTab(element)) {
tabs = [element];
} else if (this.isTabGroup(element)) {
tabs = element.tabs;
} else {
throw new Error("Can only move a tab or tab group within the tab bar");
}
let wasFocused = document.activeElement == this.selectedTab;
let oldPosition = this.isTab(aTab) && aTab._tPos;
let previousTabStates = tabs.map(tab => this.#getTabMoveState(tab));
moveActionCallback();
// Clear tabs cache after moving nodes because the order of tabs may have
// changed.
this.tabContainer._invalidateCachedTabs();
this._lastRelatedTabMap = new WeakMap();
this._updateTabsAfterInsert();
if (wasFocused) {
this.selectedTab.focus();
}
if (aTab.selected) {
this.tabContainer._handleTabSelect(true);
}
for (let i = 0; i < tabs.length; i++) {
let tab = tabs[i];
if (tab.selected) {
this.tabContainer._handleTabSelect(true);
}
if (tab.pinned) {
this.tabContainer._positionPinnedTabs();
}
if (aTab.pinned) {
this.tabContainer._positionPinnedTabs();
}
// Pinning/unpinning vertical tabs, and moving tabs into tab groups, both bypass moveTabTo.
// We still want to check whether its worth dispatching an event.
if (this.isTab(aTab) && oldPosition != aTab._tPos) {
let evt = document.createEvent("UIEvents");
evt.initUIEvent("TabMove", true, false, window, oldPosition);
aTab.dispatchEvent(evt);
let currentTabState = this.#getTabMoveState(tab);
this.#notifyOnTabMove(
tab,
previousTabStates[i],
currentTabState,
metricsContext
);
}
}
@ -8696,11 +8838,7 @@ var TabContextMenu = {
}
item.classList.add("menuitem-iconic");
if (group.collapsed) {
item.classList.add("tab-group-icon-collapsed");
} else {
item.classList.add("tab-group-icon");
}
item.classList.add("tab-group-icon");
item.style.setProperty(
"--tab-group-color",
group.style.getPropertyValue("--tab-group-color")
@ -9056,8 +9194,16 @@ var TabContextMenu = {
gTabsPanel.hideAllTabsPanel();
},
/**
* @param {MozTabbrowserTabGroup} group
*/
moveTabsToGroup(group) {
group.addTabs(this.contextTabs);
group.addTabs(
this.contextTabs,
gBrowser.TabMetrics.userTriggeredContext(
gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU
)
);
group.ownerGlobal.focus();
},

View file

@ -7,8 +7,8 @@
// This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`.
{
const { TabGroupMetrics } = ChromeUtils.importESModule(
"moz-src:///browser/components/tabbrowser/TabGroupMetrics.sys.mjs"
const { TabMetrics } = ChromeUtils.importESModule(
"moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs"
);
const { TabStateFlusher } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
@ -448,10 +448,12 @@
document
.getElementById("tabGroupEditor_deleteGroup")
.addEventListener("command", () => {
gBrowser.removeTabGroup(this.activeGroup, {
isUserTriggered: true,
telemetrySource: TabGroupMetrics.METRIC_SOURCE.TAB_GROUP_MENU,
});
gBrowser.removeTabGroup(
this.activeGroup,
TabMetrics.userTriggeredContext(
TabMetrics.METRIC_SOURCE.TAB_GROUP_MENU
)
);
});
this.panel.addEventListener("popupshown", this);

View file

@ -234,9 +234,11 @@
/**
* add tabs to the group
*
* @param tabs array of tabs to add
* @param {MozTabbrowserTab[]} tabs
* @param {TabMetricsContext} [metricsContext]
* Optional context to record for metrics purposes.
*/
addTabs(tabs) {
addTabs(tabs, metricsContext) {
for (let tab of tabs) {
let tabToMove =
this.ownerGlobal === tab.ownerGlobal
@ -245,7 +247,7 @@
tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
selectTab: tab.selected,
});
gBrowser.moveTabToGroup(tabToMove, this);
gBrowser.moveTabToGroup(tabToMove, this, metricsContext);
}
this.#lastAddedTo = Date.now();
}

View file

@ -9,21 +9,20 @@
// This is loaded into all browser windows. Wrap in a block to prevent
// leaking to window scope.
{
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
});
const TAB_PREVIEW_PREF = "browser.tabs.hoverPreview.enabled";
const DIRECTION_BACKWARD = -1;
const DIRECTION_FORWARD = 1;
const isTab = element => gBrowser.isTab(element);
const isTabGroup = element => gBrowser.isTabGroup(element);
const isTabGroupLabel = element => gBrowser.isTabGroupLabel(element);
/**
* @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
* @returns {boolean}
* `true` if element is a `<tab-group>`
*/
const isTabGroup = element => !!(element?.tagName == "tab-group");
class MozTabbrowserTabs extends MozElements.TabsBase {
static observedAttributes = ["orient"];
@ -1060,6 +1059,10 @@
var dropEffect = dt.dropEffect;
var draggedTab;
let movingTabs;
/** @type {TabMetricsContext} */
const dropMetricsContext = lazy.TabMetrics.userTriggeredContext(
lazy.TabMetrics.METRIC_SOURCE.DRAG_AND_DROP
);
if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) {
// tab copy or move
draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
@ -1084,7 +1087,7 @@
duplicatedDraggedTab = duplicatedTab;
}
}
gBrowser.moveTabsBefore(duplicatedTabs, dropTarget);
gBrowser.moveTabsBefore(duplicatedTabs, dropTarget, dropMetricsContext);
if (draggedTab.container != this || event.shiftKey) {
this.selectedItem = duplicatedDraggedTab;
}
@ -1172,15 +1175,23 @@
let moveTabs = () => {
if (dropIndex !== undefined) {
for (let tab of movingTabs) {
gBrowser.moveTabTo(tab, { elementIndex: dropIndex });
gBrowser.moveTabTo(
tab,
{ elementIndex: dropIndex },
dropMetricsContext
);
if (!directionForward) {
dropIndex++;
}
}
} else if (dropBefore) {
gBrowser.moveTabsBefore(movingTabs, dropElement);
gBrowser.moveTabsBefore(
movingTabs,
dropElement,
dropMetricsContext
);
} else {
gBrowser.moveTabsAfter(movingTabs, dropElement);
gBrowser.moveTabsAfter(movingTabs, dropElement, dropMetricsContext);
}
this.#expandGroupOnDrop(draggedTab);
};

View file

@ -168,6 +168,61 @@ tabgroup:
type: string
expires: never
reopen:
type: event
description: >
Recorded when a user reopens a saved tab group
notification_emails:
- dao@mozilla.com
- dwalker@mozilla.com
- jswinarton@mozilla.com
- dwalker@mozilla.com
bugs:
- https://bugzil.la/1938425
data_reviews:
- https://bugzil.la/1938425
extra_keys:
source:
description: The surface used to find and recall the saved group
type: string
layout:
description: The tabs layout (horizontal or vertical)
type: string
id:
description: The ID of the tab group. Tab group IDs are derived from their creation timestamps and have no other relationship to any tab group metadata.
type: string
type:
description: Whether the user reopened a saved group or a deleted group.
type: string
expires: never
add_tab:
type: event
disabled: true # To be controlled by server knobs during Firefox 138 launch due to expected high volume
description: >
Recorded when the user adds one or more ungrouped tabs to an existing tab group
notification_emails:
- dao@mozilla.com
- jswinarton@mozilla.com
- sthompson@mozilla.com
bugs:
- https://bugzil.la/1938424
data_reviews:
- https://bugzil.la/1938424
data_sensitivity:
- interaction
extra_keys:
source:
description: The system, surface, or control the user used to add the tab(s) to the tab group
type: string
tabs:
description: The number of tabs added to the tab group
type: quantity
layout:
description: The layout of the tab strip when the tabs were added (either "horizontal" or "vertical")
type: string
expires: never
active_groups:
type: labeled_quantity
description: >

View file

@ -13,7 +13,7 @@ MOZ_SRC_FILES += [
"NewTabPagePreloading.sys.mjs",
"OpenInTabsUtils.sys.mjs",
"SmartTabGrouping.sys.mjs",
"TabGroupMetrics.sys.mjs",
"TabMetrics.sys.mjs",
"TabsList.sys.mjs",
"TabUnloader.sys.mjs",
]

View file

@ -580,6 +580,22 @@ add_task(async function test_TabGroupEvents() {
tabGroupCollapsedTrigger.uninit();
});
add_task(async function test_moveTabGroup() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let group = gBrowser.addTabGroup([tab1, tab2]);
let tabMoveEvents = Promise.all([
BrowserTestUtils.waitForEvent(tab1, "TabMove"),
BrowserTestUtils.waitForEvent(tab2, "TabMove"),
]);
info("moving tab group and awaiting TabMove events");
gBrowser.moveTabToStart(group);
await tabMoveEvents;
await removeTabGroup(group);
});
add_task(async function test_moveTabBetweenGroups() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
@ -1686,19 +1702,19 @@ add_task(async function test_tabGroupCreatePanel() {
let tabgroupPanel = tabgroupEditor.panel;
let nameField = tabgroupPanel.querySelector("#tab-group-name");
let tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
let group;
let openCreatePanel = async () => {
let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown");
group = gBrowser.addTabGroup([tab], {
let group = gBrowser.addTabGroup([tab], {
color: "cyan",
label: "Food",
isUserTriggered: true,
});
await panelShown;
return group;
};
await openCreatePanel();
let group = await openCreatePanel();
Assert.equal(tabgroupPanel.state, "open", "Create panel is visible");
Assert.ok(tabgroupEditor.createMode, "Group editor is in create mode");
// Edit panel should be populated with correct group details
@ -1725,14 +1741,14 @@ add_task(async function test_tabGroupCreatePanel() {
Assert.ok(!tab.group, "Tab is ungrouped after hitting Cancel");
info("New group should be removed after hitting Esc");
await openCreatePanel();
group = await openCreatePanel();
panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden");
EventUtils.synthesizeKey("KEY_Escape");
await panelHidden;
Assert.ok(!tab.group, "Tab is ungrouped after hitting Esc");
info("New group should remain when dismissing panel");
await openCreatePanel();
group = await openCreatePanel();
panelHidden = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "hidden");
tabgroupPanel.hidePopup();
await panelHidden;
@ -1742,7 +1758,7 @@ add_task(async function test_tabGroupCreatePanel() {
group.ungroupTabs();
info("Panel inputs should work correctly");
await openCreatePanel();
group = await openCreatePanel();
nameField.focus();
nameField.value = "";
EventUtils.sendString("Shopping");
@ -1819,7 +1835,7 @@ add_task(async function test_tabGroupCreatePanel() {
tabGroupCreatedTrigger.uninit();
});
async function createTabGroupAndOpenEditPanel(tabs = []) {
async function createTabGroupAndOpenEditPanel(tabs = [], label = "") {
let tabgroupEditor = document.getElementById("tab-group-editor");
let tabgroupPanel = tabgroupEditor.panel;
if (!tabs.length) {
@ -1828,7 +1844,7 @@ async function createTabGroupAndOpenEditPanel(tabs = []) {
});
tabs = [tab];
}
let group = gBrowser.addTabGroup(tabs, { color: "cyan", label: "Food" });
let group = gBrowser.addTabGroup(tabs, { color: "cyan", label });
let panelShown = BrowserTestUtils.waitForPopupEvent(tabgroupPanel, "shown");
EventUtils.synthesizeMouseAtCenter(
@ -1844,7 +1860,10 @@ async function createTabGroupAndOpenEditPanel(tabs = []) {
}
add_task(async function test_tabGroupPanelAddTab() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel();
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_tabGroupPanelAddTab"
);
let tabgroupPanel = tabgroupEditor.panel;
let addNewTabButton = tabgroupPanel.querySelector(
@ -1858,13 +1877,14 @@ add_task(async function test_tabGroupPanelAddTab() {
Assert.ok(tabgroupPanel.state === "closed", "Group editor is closed");
Assert.equal(group.tabs.length, 2, "Group has 2 tabs");
for (let tab of group.tabs) {
BrowserTestUtils.removeTab(tab);
}
await removeTabGroup(group);
});
add_task(async function test_tabGroupPanelUngroupTabs() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel();
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_tabGroupPanelAddTab"
);
let tabgroupPanel = tabgroupEditor.panel;
let tab = group.tabs[0];
let ungroupTabsButton = tabgroupPanel.querySelector(
@ -1914,7 +1934,10 @@ add_task(async function test_moveGroupToNewWindow() {
"about:mozilla is third"
);
};
let { group } = await createTabGroupAndOpenEditPanel(tabs);
let { group } = await createTabGroupAndOpenEditPanel(
tabs,
"test_moveGroupToNewWindow"
);
let newWindowOpened = BrowserTestUtils.waitForNewWindow();
document.getElementById("tabGroupEditor_moveGroupToNewWindow").click();
@ -1964,7 +1987,7 @@ add_task(async function test_moveGroupToNewWindow() {
!moveGroupButton.disabled,
"Button is enabled again when additional tab present"
);
await removeTabGroup(movedGroup);
await BrowserTestUtils.closeWindow(newWin, { animate: false });
});
@ -1973,7 +1996,10 @@ add_task(async function test_moveGroupToNewWindow() {
* group is not saveable.
*/
add_task(async function test_saveDisabledForUnimportantGroup() {
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel();
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[],
"test_saveDisabledForUnimportantGroups"
);
let saveAndCloseGroupButton = tabgroupEditor.panel.querySelector(
"#tabGroupEditor_saveAndCloseGroup"
);
@ -1987,7 +2013,7 @@ add_task(async function test_saveDisabledForUnimportantGroup() {
);
tabgroupEditor.panel.hidePopup();
await panelHidden;
await gBrowser.removeTabGroup(group);
await removeTabGroup(group);
});
add_task(async function test_saveAndCloseGroup() {
@ -1997,7 +2023,10 @@ add_task(async function test_saveAndCloseGroup() {
tabGroupSavedTrigger.init(triggerHandler);
let tab = await addTab("about:mozilla");
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel([tab]);
let { tabgroupEditor, group } = await createTabGroupAndOpenEditPanel(
[tab],
"test_saveAndCloseGroup"
);
let tabgroupPanel = tabgroupEditor.panel;
await TabStateFlusher.flush(tab.linkedBrowser);
let saveAndCloseGroupButton = tabgroupPanel.querySelector(
@ -2093,6 +2122,7 @@ add_task(async function test_pinningInteractionsWithTabGroups() {
);
moreTabs.concat(tabs).forEach(tab => BrowserTestUtils.removeTab(tab));
await removeTabGroup(group);
});
add_task(async function test_pinFirstGroupedTab() {
@ -2120,6 +2150,7 @@ add_task(async function test_adoptTab() {
Assert.equal(adoptedTab._tPos, 1, "tab adopted into expected position");
Assert.equal(adoptedTab.group, group, "tab adopted into tab group");
await removeTabGroup(group);
await BrowserTestUtils.closeWindow(newWin, { animate: false });
});
@ -2229,3 +2260,33 @@ add_task(async function test_bug1957723_addTabsByIndex() {
gBrowser.removeAllTabsBut(initialTab);
});
add_task(async function test_bug1959438_duplicateTabJustBeforeGroup() {
let initialTab = gBrowser.tabs[0];
let triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
const tabs = createManyTabs(3);
gBrowser.addTabGroup(tabs);
Assert.equal(gBrowser.tabs.length, 4, "Tab strip starts with four tabs");
gBrowser.selectTabAtIndex(0);
// Simulate an addTab call similar to what would be called when a tab is
// duplicated. This produces a situation where addTab has no index, but knows
// it needs to create one next to the currently selected tab, and guesses for
// itself.
// If this happens next to a tab group, the resulting element index will
// point to the tab group label.
gBrowser.addTab("https://example.com", {
index: undefined,
relatedToCurrent: true,
ownerTab: gBrowser.selectedTab,
triggeringPrincipal,
});
// This will fail if the tab ends up merged with the tab label.
Assert.equal(gBrowser.tabs.length, 5, "A new tab was added to the tab strip");
gBrowser.removeAllTabsBut(initialTab);
});

View file

@ -6,6 +6,10 @@ const { TabStateFlusher } = ChromeUtils.importESModule(
"resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
);
const { UrlbarTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/UrlbarTestUtils.sys.mjs"
);
let resetTelemetry = async () => {
await Services.fog.testFlushAllChildren();
Services.fog.testResetFOG();
@ -15,10 +19,17 @@ let resetTelemetry = async () => {
let win;
add_setup(async () => {
await SpecialPowers.pushPrefEnv({
set: [
["browser.tabs.groups.enabled", true],
["browser.urlbar.scotchBonnet.enableOverride", true],
],
});
win = await BrowserTestUtils.openNewBrowserWindow();
win.gTabsPanel.init();
registerCleanupFunction(async () => {
await BrowserTestUtils.closeWindow(win);
await SpecialPowers.popPrefEnv();
});
});
@ -349,56 +360,38 @@ async function closeTabsMenu() {
await hidden;
}
/**
* @param {XULToolbarButton} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let nodeWindow = triggerNode.ownerGlobal;
triggerNode.scrollIntoView();
const contextMenu = nodeWindow.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
nodeWindow
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}
/**
* Returns a new basic, unnamed tab group that is fully loaded in the browser
* and in session state.
*
* @returns {Promise<MozTabbrowserTabGroup>}
*/
async function makeTabGroup() {
async function makeTabGroup(name = "") {
let tab = BrowserTestUtils.addTab(win.gBrowser, "https://example.com");
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
await TabStateFlusher.flush(tab.linkedBrowser);
let group = win.gBrowser.addTabGroup([tab]);
let group = win.gBrowser.addTabGroup([tab], { label: name });
// Close the automatically-opened "create tab group" menu.
win.gBrowser.tabGroupMenu.close();
return group;
}
/**
* Returns a basic tab group from makeTabGroup and saves it.
*
* @returns {string} the ID of the saved group
*/
async function saveAndCloseGroup(group) {
let closedObjectsChanged = TestUtils.topicObserved(
"sessionstore-closed-objects-changed"
);
group.ownerGlobal.SessionStore.addSavedTabGroup(group);
await removeTabGroup(group);
await closedObjectsChanged;
return group.id;
}
add_task(async function test_tabOverflowContextMenu_deleteOpenTabGroup() {
await resetTelemetry();
@ -446,3 +439,194 @@ add_task(async function test_tabOverflowContextMenu_deleteOpenTabGroup() {
await resetTelemetry();
});
async function waitForReopenRecord() {
return BrowserTestUtils.waitForCondition(() => {
let tabGroupReopenTelemetry = Glean.tabgroup.reopen.testGetValue();
return tabGroupReopenTelemetry?.length > 0;
}, "Waiting for reopen telemetry to populate");
}
function assertReopenEvent({ id, source, layout, type }) {
let tabGroupReopenEvents = Glean.tabgroup.reopen.testGetValue();
Assert.equal(
tabGroupReopenEvents.length,
1,
"should have recorded one tabgroup.reopen event"
);
let [reopenEvent] = tabGroupReopenEvents;
Assert.deepEqual(
reopenEvent.extra,
{
id,
source,
layout,
type,
},
"should have recorded correct id, source, and layout for reopen event"
);
}
async function waitForNoActiveGroups() {
return BrowserTestUtils.waitForCondition(
() => !win.gBrowser.getAllTabGroups().length,
"waiting for an empty group list"
);
}
async function doReopenTests(useVerticalTabs) {
await waitForNoActiveGroups();
Assert.ok(!win.gBrowser.getAllTabGroups().length, "there are no tab groups");
Assert.ok(!win.SessionStore.savedGroups.length, "no saved groups");
let expectedLayout = useVerticalTabs ? "vertical" : "horizontal";
await SpecialPowers.pushPrefEnv({
set: [
["sidebar.revamp", true],
["sidebar.verticalTabs", useVerticalTabs],
],
});
let group = await makeTabGroup("reopen-test");
let groupId = await saveAndCloseGroup(group);
info("Restoring from overflow menu");
await waitForNoActiveGroups();
let menu = await openTabsMenu();
let groupItems = menu.querySelectorAll(
"#allTabsMenu-groupsView .all-tabs-group-action-button"
);
Assert.equal(groupItems.length, 1, "1 group in menu");
let groupButton = groupItems[0];
Assert.equal(
groupButton.getAttribute("data-tab-group-id"),
groupId,
"Correct group appears in menu"
);
groupButton.click();
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "tab_overflow",
layout: expectedLayout,
type: "saved",
});
await resetTelemetry();
await saveAndCloseGroup(win.gBrowser.getTabGroupById(groupId));
info("restoring saved group via undoClosetab");
await waitForNoActiveGroups();
undoCloseTab(undefined, win.__SSi);
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "recent",
layout: expectedLayout,
type: "saved",
});
await addTab("about:blank"); // removed by undoCloseTab
await saveAndCloseGroup(win.gBrowser.getTabGroupById(groupId));
await resetTelemetry();
info("restoring saved group from URLbar suggestion");
await waitForNoActiveGroups();
await UrlbarTestUtils.promiseAutocompleteResultPopup({
window: win,
waitForFocus,
value: "reopen-test",
fireInputEvent: true,
reopenOnBlur: true,
});
let reopenGroupButton = win.gURLBar.panel.querySelector(
`[data-action^="tabgroup"]`
);
Assert.ok(!!reopenGroupButton, "Reopen group action is present in results");
let closedObjectsChanged = TestUtils.topicObserved(
"sessionstore-closed-objects-changed"
);
await UrlbarTestUtils.promisePopupClose(win, () => {
EventUtils.synthesizeKey("KEY_Tab", {}, win);
EventUtils.synthesizeKey("KEY_Enter", {}, win);
});
await closedObjectsChanged;
await waitForReopenRecord();
assertReopenEvent({
id: groupId,
source: "suggest",
layout: expectedLayout,
type: "saved",
});
await win.gBrowser.removeTabGroup(win.gBrowser.getTabGroupById(groupId));
await resetTelemetry();
await SpecialPowers.popPrefEnv();
}
add_task(async function test_reopenSavedGroupTelemetry() {
info("Perform reopen tests in horizontal tabs mode");
await doReopenTests(false);
info("Perform reopen tests in vertical tabs mode");
await doReopenTests(true);
});
add_task(async function test_tabContextMenu_addTabsToGroup() {
await resetTelemetry();
// `tabgroup.add_tab` is disabled by default and enabled by server knobs,
// so this test needs to enable it manually in order to test it.
Services.fog.applyServerKnobsConfig(
JSON.stringify({
metrics_enabled: {
"tabgroup.add_tab": true,
},
})
);
info("set up a tab group to test with");
let group = await makeTabGroup();
let groupId = group.id;
info("create 8 ungrouped tabs to test with");
let moreTabs = Array.from({ length: 8 }).map(() =>
BrowserTestUtils.addTab(win.gBrowser, "https://example.com")
);
info("select first ungrouped tab and multi-select three more tabs");
win.gBrowser.selectedTab = moreTabs[0];
moreTabs.slice(1, 4).forEach(tab => win.gBrowser.addToMultiSelectedTabs(tab));
await BrowserTestUtils.waitForCondition(() => {
return win.gBrowser.multiSelectedTabsCount == 4;
}, "Wait for Tabbrowser to update the multiselected tab state");
let menu = await getContextMenu(win.gBrowser.selectedTab, "tabContextMenu");
let moveTabToGroupItem = win.document.getElementById(
"context_moveTabToGroup"
);
let tabGroupButton = moveTabToGroupItem.querySelector(
`[tab-group-id="${groupId}"]`
);
tabGroupButton.click();
await closeContextMenu(menu);
await BrowserTestUtils.waitForCondition(() => {
return Glean.tabgroup.addTab.testGetValue() !== null;
}, "Wait for a Glean event to be recorded");
let [addTabEvent] = Glean.tabgroup.addTab.testGetValue();
Assert.deepEqual(
addTabEvent.extra,
{
source: "tab_menu",
tabs: "4",
layout: "horizontal",
},
"should have recorded the correct event metadata"
);
for (let tab of moreTabs) {
BrowserTestUtils.removeTab(tab);
}
await removeTabGroup(group);
await resetTelemetry();
});

View file

@ -601,3 +601,36 @@ async function removeTabGroup(group) {
await group.ownerGlobal.gBrowser.removeTabGroup(group, { animate: false });
await removePromise;
}
/**
* @param {Node} triggerNode
* @param {string} contextMenuId
* @returns {Promise<XULMenuElement|XULPopupElement>}
*/
async function getContextMenu(triggerNode, contextMenuId) {
let win = triggerNode.ownerGlobal;
triggerNode.scrollIntoView({ behavior: "instant" });
const contextMenu = win.document.getElementById(contextMenuId);
const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
contextMenu,
"shown"
);
EventUtils.synthesizeMouseAtCenter(
triggerNode,
{ type: "contextmenu", button: 2 },
win
);
await contextMenuShown;
return contextMenu;
}
/**
* @param {XULMenuElement|XULPopupElement} contextMenu
* @returns {Promise<void>}
*/
async function closeContextMenu(contextMenu) {
let menuHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
contextMenu.hidePopup();
await menuHidden;
}

View file

@ -11,12 +11,19 @@ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const kWebAppWindowFeatures =
"chrome,dialog=no,titlebar,close,toolbar,location,personalbar=no,status,menubar=no,resizable,minimizable,scrollbars";
let lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});
export let TaskbarTabs = {
async init(window) {
if (
AppConstants.platform != "win" ||
!Services.prefs.getBoolPref(kEnabledPref, false) ||
window.document.documentElement.hasAttribute("taskbartab")
window.document.documentElement.hasAttribute("taskbartab") ||
!window.toolbar.visible ||
lazy.PrivateBrowsingUtils.isWindowPrivate(window)
) {
return;
}

View file

@ -0,0 +1,5 @@
[DEFAULT]
# Startup pref, needs to be set in [DEFAULT]
prefs = ["security.allow_unsafe_dangerous_privileged_evil_eval=true"]
["browser_csp_eval.js"]

View file

@ -0,0 +1,18 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
add_task(function eval_allowed_with_special_pref() {
is(
window.location.pathname,
"/content/browser.xhtml",
"Running in browser.xhtml"
);
// eslint-disable-next-line no-eval
eval("window.test_code_ran = 1;");
is(window.test_code_ran, 1, "eval() executed successfully");
delete window.test_code_ran;
});

View file

@ -655,19 +655,10 @@ var FullPageTranslationsPanel = new (class {
"full-page-translations-panel-view-default"
);
if (!this._hasShownPanel) {
actor.firstShowUriSpec = gBrowser.currentURI.spec;
}
if (
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
document.l10n.setAttributes(header, "translations-panel-header");
actor.firstShowUriSpec = null;
if (TranslationsParent.hasUserEverTranslated()) {
intro.hidden = true;
document.l10n.setAttributes(header, "translations-panel-header");
} else {
Services.prefs.setBoolPref("browser.translations.panelShown", true);
intro.hidden = false;
document.l10n.setAttributes(header, "translations-panel-intro-header");
}
@ -1450,11 +1441,9 @@ var FullPageTranslationsPanel = new (class {
async #showEngineError(actor) {
const { button } = this.buttonElements;
await this.#ensureLangListsBuilt();
if (!this.#isShowingDefaultView()) {
await this.#showDefaultView(actor).catch(e => {
this.console?.error(e);
});
}
await this.#showDefaultView(actor).catch(e => {
this.console?.error(e);
});
this.elements.error.hidden = false;
this.#showError({
message: "translations-panel-error-translating",
@ -1647,10 +1636,7 @@ var FullPageTranslationsPanel = new (class {
// Follow the same rules for displaying the first-run intro text for the
// button's accessible tooltip label.
if (
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
if (TranslationsParent.hasUserEverTranslated()) {
document.l10n.setAttributes(
button,
"urlbar-translations-button2"
@ -1688,10 +1674,3 @@ var FullPageTranslationsPanel = new (class {
}
};
})();
XPCOMUtils.defineLazyPreferenceGetter(
FullPageTranslationsPanel,
"_hasShownPanel",
"browser.translations.panelShown",
false
);

View file

@ -27,7 +27,7 @@ add_task(
await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es",
expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
await FullPageTranslationsTestUtils.clickTranslateButton();

View file

@ -27,7 +27,7 @@ add_task(
await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es",
expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
await FullPageTranslationsTestUtils.clickTranslateButton();

View file

@ -26,7 +26,7 @@ add_task(async function test_browser_translations_full_page_multiple_windows() {
await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es",
expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
await FullPageTranslationsTestUtils.clickCancelButton();

View file

@ -36,14 +36,14 @@ add_task(async function test_translations_moz_extension() {
"The button is available."
);
is(button.getAttribute("data-l10n-id"), "urlbar-translations-button2");
is(button.getAttribute("data-l10n-id"), "urlbar-translations-button-intro");
await FullPageTranslationsTestUtils.assertPageIsUntranslated(runInPage);
await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es",
expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
await FullPageTranslationsTestUtils.clickTranslateButton({

View file

@ -29,7 +29,7 @@ add_task(async function test_browser_translations_full_page_multiple_windows() {
await FullPageTranslationsTestUtils.openPanel({
expectedFromLanguage: "es",
expectedToLanguage: "en",
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
await FullPageTranslationsTestUtils.clickTranslateButton({
downloadHandler: testPage1.resolveDownloads,

View file

@ -21,7 +21,7 @@ add_task(async function test_translations_panel_a11y_focus() {
expectedFromLanguage: "es",
expectedToLanguage: "en",
openWithKeyboard: true,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewDefault,
onOpenPanel: FullPageTranslationsTestUtils.assertPanelViewIntro,
});
is(

Some files were not shown because too many files have changed in this diff Show more