Backed out 3 changesets (bug 1877678, bug 1849175) for causing failures on browser_op...
[gecko.git] / browser / components / firefoxview / OpenTabs.sys.mjs
blobac247f5e8f495e3dcf1eb09d4ae18bed5a741aba
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * This module provides the means to monitor and query for tab collections against open
7  * browser windows and allow listeners to be notified of changes to those collections.
8  */
10 const lazy = {};
12 ChromeUtils.defineESModuleGetters(lazy, {
13   DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
14   EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
15   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
16 });
18 const TAB_ATTRS_TO_WATCH = Object.freeze([
19   "attention",
20   "image",
21   "label",
22   "muted",
23   "soundplaying",
24   "titlechanged",
25 ]);
26 const TAB_CHANGE_EVENTS = Object.freeze([
27   "TabAttrModified",
28   "TabClose",
29   "TabMove",
30   "TabOpen",
31   "TabPinned",
32   "TabUnpinned",
33 ]);
34 const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([
35   "activate",
36   "TabAttrModified",
37   "TabClose",
38   "TabOpen",
39   "TabSelect",
40   "TabAttrModified",
41 ]);
43 // Debounce tab/tab recency changes and dispatch max once per frame at 60fps
44 const CHANGES_DEBOUNCE_MS = 1000 / 60;
46 /**
47  * A sort function used to order tabs by most-recently seen and active.
48  */
49 export function lastSeenActiveSort(a, b) {
50   let dt = b.lastSeenActive - a.lastSeenActive;
51   if (dt) {
52     return dt;
53   }
54   // try to break a deadlock by sorting the selected tab higher
55   if (!(a.selected || b.selected)) {
56     return 0;
57   }
58   return a.selected ? -1 : 1;
61 /**
62  * Provides a object capable of monitoring and accessing tab collections for either
63  * private or non-private browser windows. As the class extends EventTarget, consumers
64  * should add event listeners for the change events.
65  *
66  * @param {boolean} options.usePrivateWindows
67               Constrain to only windows that match this privateness. Defaults to false.
68  * @param {Window | null} options.exclusiveWindow
69  *            Constrain to only a specific window.
70  */
71 class OpenTabsTarget extends EventTarget {
72   #changedWindowsByType = {
73     TabChange: new Set(),
74     TabRecencyChange: new Set(),
75   };
76   #dispatchChangesTask;
77   #started = false;
78   #watchedWindows = new Set();
80   #exclusiveWindowWeakRef = null;
81   usePrivateWindows = false;
83   constructor(options = {}) {
84     super();
85     this.usePrivateWindows = !!options.usePrivateWindows;
87     if (options.exclusiveWindow) {
88       this.exclusiveWindow = options.exclusiveWindow;
89       this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`;
90     } else {
91       this.everyWindowCallbackId = `opentabs-${
92         this.usePrivateWindows ? "private" : "non-private"
93       }`;
94     }
95   }
97   get exclusiveWindow() {
98     return this.#exclusiveWindowWeakRef?.get();
99   }
100   set exclusiveWindow(newValue) {
101     if (newValue) {
102       this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue);
103     } else {
104       this.#exclusiveWindowWeakRef = null;
105     }
106   }
108   includeWindowFilter(win) {
109     if (this.#exclusiveWindowWeakRef) {
110       return win == this.exclusiveWindow;
111     }
112     return (
113       win.gBrowser &&
114       !win.closed &&
115       this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
116     );
117   }
119   get currentWindows() {
120     return lazy.EveryWindow.readyWindows.filter(win =>
121       this.includeWindowFilter(win)
122     );
123   }
125   /**
126    * A promise that resolves to all matched windows once their delayedStartupPromise resolves
127    */
128   get readyWindowsPromise() {
129     let windowList = Array.from(
130       Services.wm.getEnumerator("navigator:browser")
131     ).filter(win => {
132       // avoid waiting for windows we definitely don't care about
133       if (this.#exclusiveWindowWeakRef) {
134         return this.exclusiveWindow == win;
135       }
136       return (
137         this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
138       );
139     });
140     return Promise.allSettled(
141       windowList.map(win => win.delayedStartupPromise)
142     ).then(() => {
143       // re-filter the list as properties might have changed in the interim
144       return windowList.filter(win => this.includeWindowFilter);
145     });
146   }
148   haveListenersForEvent(eventType) {
149     switch (eventType) {
150       case "TabChange":
151         return Services.els.hasListenersFor(this, "TabChange");
152       case "TabRecencyChange":
153         return Services.els.hasListenersFor(this, "TabRecencyChange");
154       default:
155         return false;
156     }
157   }
159   get haveAnyListeners() {
160     return (
161       this.haveListenersForEvent("TabChange") ||
162       this.haveListenersForEvent("TabRecencyChange")
163     );
164   }
166   /*
167    * @param {string} type
168    *        Either "TabChange" or "TabRecencyChange"
169    * @param {Object|Function} listener
170    * @param {Object} [options]
171    */
172   addEventListener(type, listener, options) {
173     let hadListeners = this.haveAnyListeners;
174     super.addEventListener(type, listener, options);
176     // if this is the first listener, start up all the window & tab monitoring
177     if (!hadListeners && this.haveAnyListeners) {
178       this.start();
179     }
180   }
182   /*
183    * @param {string} type
184    *        Either "TabChange" or "TabRecencyChange"
185    * @param {Object|Function} listener
186    */
187   removeEventListener(type, listener) {
188     let hadListeners = this.haveAnyListeners;
189     super.removeEventListener(type, listener);
191     // if this was the last listener, we can stop all the window & tab monitoring
192     if (hadListeners && !this.haveAnyListeners) {
193       this.stop();
194     }
195   }
197   /**
198    * Begin watching for tab-related events from all browser windows matching the instance's private property
199    */
200   start() {
201     if (this.#started) {
202       return;
203     }
204     // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves.
205     lazy.EveryWindow.registerCallback(
206       this.everyWindowCallbackId,
207       win => this.#watchWindow(win),
208       win => this.#unwatchWindow(win)
209     );
210     this.#started = true;
211   }
213   /**
214    * Stop watching for tab-related events from all browser windows and clean up.
215    */
216   stop() {
217     if (this.#started) {
218       lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId);
219       this.#started = false;
220     }
221     for (let changedWindows of Object.values(this.#changedWindowsByType)) {
222       changedWindows.clear();
223     }
224     this.#watchedWindows.clear();
225     this.#dispatchChangesTask?.disarm();
226   }
228   /**
229    * Add listeners for tab-related events from the given window. The consumer's
230    * listeners will always be notified at least once for newly-watched window.
231    */
232   #watchWindow(win) {
233     if (!this.includeWindowFilter(win)) {
234       return;
235     }
236     this.#watchedWindows.add(win);
237     const { tabContainer } = win.gBrowser;
238     tabContainer.addEventListener("TabAttrModified", this);
239     tabContainer.addEventListener("TabClose", this);
240     tabContainer.addEventListener("TabMove", this);
241     tabContainer.addEventListener("TabOpen", this);
242     tabContainer.addEventListener("TabPinned", this);
243     tabContainer.addEventListener("TabUnpinned", this);
244     tabContainer.addEventListener("TabSelect", this);
245     win.addEventListener("activate", this);
247     this.#scheduleEventDispatch("TabChange", {});
248     this.#scheduleEventDispatch("TabRecencyChange", {});
249   }
251   /**
252    * Remove all listeners for tab-related events from the given window.
253    * Consumers will always be notified at least once for unwatched window.
254    */
255   #unwatchWindow(win) {
256     // We check the window is in our watchedWindows collection rather than currentWindows
257     // as the unwatched window may not match the criteria we used to watch it anymore,
258     // and we need to unhook our event listeners regardless.
259     if (this.#watchedWindows.has(win)) {
260       this.#watchedWindows.delete(win);
262       const { tabContainer } = win.gBrowser;
263       tabContainer.removeEventListener("TabAttrModified", this);
264       tabContainer.removeEventListener("TabClose", this);
265       tabContainer.removeEventListener("TabMove", this);
266       tabContainer.removeEventListener("TabOpen", this);
267       tabContainer.removeEventListener("TabPinned", this);
268       tabContainer.removeEventListener("TabSelect", this);
269       tabContainer.removeEventListener("TabUnpinned", this);
270       win.removeEventListener("activate", this);
272       this.#scheduleEventDispatch("TabChange", {});
273       this.#scheduleEventDispatch("TabRecencyChange", {});
274     }
275   }
277   /**
278    * Flag the need to notify all our consumers of a change to open tabs.
279    * Repeated calls within approx 16ms will be consolidated
280    * into one event dispatch.
281    */
282   #scheduleEventDispatch(eventType, { sourceWindowId } = {}) {
283     if (!this.haveListenersForEvent(eventType)) {
284       return;
285     }
287     this.#changedWindowsByType[eventType].add(sourceWindowId);
288     // Queue up an event dispatch - we use a deferred task to make this less noisy by
289     // consolidating multiple change events into one.
290     if (!this.#dispatchChangesTask) {
291       this.#dispatchChangesTask = new lazy.DeferredTask(() => {
292         this.#dispatchChanges();
293       }, CHANGES_DEBOUNCE_MS);
294     }
295     this.#dispatchChangesTask.arm();
296   }
298   #dispatchChanges() {
299     this.#dispatchChangesTask?.disarm();
300     for (let [eventType, changedWindowIds] of Object.entries(
301       this.#changedWindowsByType
302     )) {
303       if (this.haveListenersForEvent(eventType) && changedWindowIds.size) {
304         this.dispatchEvent(
305           new CustomEvent(eventType, {
306             detail: {
307               windowIds: [...changedWindowIds],
308             },
309           })
310         );
311         changedWindowIds.clear();
312       }
313     }
314   }
316   /*
317    * @param {Window} win
318    * @param {boolean} sortByRecency
319    * @returns {Array<Tab>}
320    *    The list of visible tabs for the browser window
321    */
322   getTabsForWindow(win, sortByRecency = false) {
323     if (this.currentWindows.includes(win)) {
324       const { visibleTabs } = win.gBrowser;
325       return sortByRecency
326         ? visibleTabs.toSorted(lastSeenActiveSort)
327         : [...visibleTabs];
328     }
329     return [];
330   }
332   /*
333    * @returns {Array<Tab>}
334    *    A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows.
335    */
336   getRecentTabs() {
337     const tabs = [];
338     for (let win of this.currentWindows) {
339       tabs.push(...this.getTabsForWindow(win));
340     }
341     tabs.sort(lastSeenActiveSort);
342     return tabs;
343   }
345   handleEvent({ detail, target, type }) {
346     const win = target.ownerGlobal;
347     // NOTE: we already filtered on privateness by not listening for those events
348     // from private/not-private windows
349     if (
350       type == "TabAttrModified" &&
351       !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr))
352     ) {
353       return;
354     }
356     if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) {
357       this.#scheduleEventDispatch("TabRecencyChange", {
358         sourceWindowId: win.windowGlobalChild.innerWindowId,
359       });
360     }
361     if (TAB_CHANGE_EVENTS.includes(type)) {
362       this.#scheduleEventDispatch("TabChange", {
363         sourceWindowId: win.windowGlobalChild.innerWindowId,
364       });
365     }
366   }
369 const gExclusiveWindows = new (class {
370   perWindowInstances = new WeakMap();
371   constructor() {
372     Services.obs.addObserver(this, "domwindowclosed");
373   }
374   observe(subject, topic, data) {
375     let win = subject;
376     let winTarget = this.perWindowInstances.get(win);
377     if (winTarget) {
378       winTarget.stop();
379       this.perWindowInstances.delete(win);
380     }
381   }
382 })();
385  * Get an OpenTabsTarget instance constrained to a specific window.
387  * @param {Window} exclusiveWindow
388  * @returns {OpenTabsTarget}
389  */
390 const getTabsTargetForWindow = function (exclusiveWindow) {
391   let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow);
392   if (instance) {
393     return instance;
394   }
395   instance = new OpenTabsTarget({
396     exclusiveWindow,
397   });
398   gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance);
399   return instance;
402 const NonPrivateTabs = new OpenTabsTarget({
403   usePrivateWindows: false,
406 const PrivateTabs = new OpenTabsTarget({
407   usePrivateWindows: true,
410 export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };