Files
tubestation/waterfox/browser/components/tabgrouping/TabGrouping.sys.mjs
2025-11-06 14:13:51 +00:00

1046 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PrefUtils: "resource:///modules/PrefUtils.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setInterval: "resource://gre/modules/Timer.sys.mjs",
clearInterval: "resource://gre/modules/Timer.sys.mjs",
});
/**
* Preferences used by the TabGrouping module.
* - ENABLED: Master switch for auto-grouping.
* - PLACEMENT: Positioning of the newly grouped tab within the source group.
* - DELAY_ENABLED: Whether to delay grouping to allow cancellation.
* - DELAY_MS: Delay window duration in milliseconds.
* - CANCEL_SHORTCUT: Temporary shortcut to cancel pending grouping.
* - BYPASS_SHORTCUT: Always-on shortcut to open a standard new tab without grouping.
*/
const PREFS = {
ENABLED: "browser.tabs.autoGroupNewTabs",
PLACEMENT: "browser.tabs.autoGroupNewTabs.placement",
DELAY_ENABLED: "browser.tabs.autoGroupNewTabs.delayEnabled",
DELAY_MS: "browser.tabs.autoGroupNewTabs.delayMs",
CANCEL_SHORTCUT: "browser.tabs.autoGroupNewTabs.cancelShortcut",
BYPASS_SHORTCUT: "browser.tabs.autoGroupNewTabs.bypassShortcut",
DEBUG_LOG: "browser.tabs.autoGroupNewTabs.debugLog",
RESUME_GRACE_MS: "browser.tabs.autoGroupNewTabs.resumeGraceMs",
};
/**
* Enumerates placement modes for positioning a new tab within a group.
* - AFTER: Immediately after the source tab.
* - FIRST: At the beginning of the group.
* - LAST: At the end of the group (no explicit move post-grouping).
*/
const PLACEMENT_MODES = {
AFTER: "after",
FIRST: "first",
LAST: "last",
};
/**
* Core module that implements automatic tab grouping.
*
* Responsibilities:
* - Track active tab snapshot and per-window history to infer source tab.
* - Group newly created tabs into the source tabs group.
* - Optionally delay grouping with a cancel window.
* - Provide an always-on bypass shortcut to open a standard new tab (no grouping).
*
* State fields:
* - _enabled, _placement, _delayEnabled, _delayMs, _cancelShortcut, _bypassShortcut
* - _lastActiveTab, _activeHistory, _snapshotInterval
* - _pendingTimers, _cancelShortcutActive, _suspended
*
* Public API:
* - init(): Initialize listeners and state.
* - shutdown(): Tear down listeners and timers.
* - cancelPendingGrouping(): Cancel all pending delayed groupings.
*/
export const TabGrouping = {
_initialized: false,
_enabled: true,
_placement: PLACEMENT_MODES.AFTER,
_delayEnabled: false,
_delayMs: 1000,
_cancelShortcut: "",
_bypassShortcut: "",
/** Enable verbose debug logging for TabGrouping. */
_debugLog: false,
_lastActiveTab: null,
_activeHistory: new Map(),
_snapshotInterval: null,
_pendingTimers: new Map(),
_cancelShortcutActive: false,
/** True while grouping is suspended (e.g., during session restore/startup). */
_suspended: false,
/** Grace period (ms) after restore before resuming grouping. */
_resumeGraceMs: 1000,
/** Timer handle for delayed resume after restore. */
_resumeTimer: null,
/**
* Initialize the TabGrouping module.
* - Loads preferences and observers
* - Starts active tab tracking (snapshot + history)
* - Registers tab listeners and keyboard shortcuts
*/
init() {
if (this._initialized) {
return;
}
this._initialized = true;
// 1) Preferences
this._loadPreferences();
this._setupPrefObservers();
// 2) Active tab tracking (snapshot + history)
this._startActiveTabTracking();
// 3) Tab lifecycle listeners
this._addEventListeners();
// 4) Cancel shortcut wiring (observer)
this._setupKeyboardShortcuts();
this._updateBypassShortcut();
// Register collapse guards
this._registerCollapseGuards();
// Start in suspended mode during startup/session restore; resume once ready.
this._suspended = true;
Services.obs.addObserver(this, "sessionstore-windows-restored");
Services.obs.addObserver(this, "browser-delayed-startup-finished");
},
/**
* Shutdown the TabGrouping module.
* Cleans up timers, observers, and keyboard shortcuts and clears state.
*/
shutdown() {
if (!this._initialized) {
return;
}
this._initialized = false;
// Stop snapshot
if (this._snapshotInterval) {
lazy.clearInterval(this._snapshotInterval);
this._snapshotInterval = null;
}
// Cancel pending
this._cancelAllPending();
// Remove observers
this._removeEventListeners();
// Shortcut cleanup
this._cleanupKeyboardShortcuts();
this._unregisterBypassShortcut();
this._unregisterCollapseGuards();
if (this._resumeTimer) {
lazy.clearTimeout(this._resumeTimer);
this._resumeTimer = null;
}
try {
Services.obs.removeObserver(this, "sessionstore-windows-restored");
} catch (_e) {}
try {
Services.obs.removeObserver(this, "browser-delayed-startup-finished");
} catch (_e) {}
// Clear state
this._lastActiveTab = null;
this._activeHistory.clear();
},
/**
* Load user preferences for automatic tab grouping.
* Reads current values for feature enablement, placement mode, delay settings,
* cancel shortcut, and bypass shortcut.
*/
_loadPreferences() {
this._enabled = lazy.PrefUtils.get(PREFS.ENABLED, true);
this._placement = lazy.PrefUtils.get(
PREFS.PLACEMENT,
PLACEMENT_MODES.AFTER
);
this._delayEnabled = lazy.PrefUtils.get(PREFS.DELAY_ENABLED, false);
this._delayMs = lazy.PrefUtils.get(PREFS.DELAY_MS, 1000);
this._cancelShortcut = lazy.PrefUtils.get(
PREFS.CANCEL_SHORTCUT,
Services.appinfo.OS === "Darwin" ? "Option+`" : "Ctrl+`"
);
this._bypassShortcut = lazy.PrefUtils.get(
PREFS.BYPASS_SHORTCUT,
Services.appinfo.OS === "Darwin" ? "Option+Shift+T" : "Alt+Shift+T"
);
this._resumeGraceMs = lazy.PrefUtils.get(PREFS.RESUME_GRACE_MS, 1000);
this._debugLog = lazy.PrefUtils.get(PREFS.DEBUG_LOG, false);
},
/**
* Register observers for preference changes that affect tab grouping behavior.
* Updates in-memory state and toggles runtime listeners as needed.
*/
_setupPrefObservers() {
this._prefObservers = [
lazy.PrefUtils.addObserver(PREFS.ENABLED, (v) => {
this._enabled = v;
if (!v) {
this._cancelAllPending();
}
}),
lazy.PrefUtils.addObserver(PREFS.PLACEMENT, (v) => {
this._placement = v;
}),
lazy.PrefUtils.addObserver(PREFS.DELAY_ENABLED, (v) => {
this._delayEnabled = v;
if (!v) {
this._cancelAllPending();
}
}),
lazy.PrefUtils.addObserver(PREFS.BYPASS_SHORTCUT, (v) => {
this._bypassShortcut = v;
this._updateBypassShortcut();
}),
lazy.PrefUtils.addObserver(PREFS.DEBUG_LOG, (v) => {
this._debugLog = v;
}),
];
},
/**
* Start tracking the active tab via periodic snapshot and activation events.
* Maintains a per-window history [current, previous] to support robust
* source tab detection during tab creation.
*/
_startActiveTabTracking() {
this._refreshSnapshot();
this._snapshotInterval = lazy.setInterval(
() => this._refreshSnapshot(),
5000
);
// Observe tab/window activation to maintain per-window history
Services.obs.addObserver(this, "browser-tab-activated");
Services.obs.addObserver(this, "browser-window-focus-changed");
},
_refreshSnapshot() {
const win = Services.wm.getMostRecentWindow("navigator:browser");
if (win?.gBrowser) {
const tab = win.gBrowser.selectedTab;
if (tab) {
this._lastActiveTab = tab;
}
}
},
_handleTabActivated(tab) {
if (!tab || !tab.ownerGlobal) {
return;
}
const window = tab.ownerGlobal;
const windowId = window.docShell.outerWindowID;
const history = this._activeHistory.get(windowId) || [];
// [current, previous]
if (history[0] && history[0] !== tab) {
history.unshift(tab);
} else {
history[0] = tab;
}
this._activeHistory.set(windowId, history.slice(0, 2));
this._lastActiveTab = tab;
},
_addEventListeners() {
Services.obs.addObserver(this, "browser-tab-created");
Services.obs.addObserver(this, "browser-tab-removed");
},
_removeEventListeners() {
try {
Services.obs.removeObserver(this, "browser-tab-created");
} catch (_) {}
try {
Services.obs.removeObserver(this, "browser-tab-removed");
} catch (_) {}
try {
Services.obs.removeObserver(this, "browser-tab-activated");
} catch (_) {}
try {
Services.obs.removeObserver(this, "browser-window-focus-changed");
} catch (_) {}
},
/**
* Global observer entry point for tab and window events.
* Handles: tab created/removed/activated, window focus changes,
* and cancel-auto-grouping notifications.
*
* @param {any} subject - Event subject (often a tab element)
* @param {string} topic - Observer topic identifier
* @param {string} _data - Reserved extra data (unused)
*/
observe(subject, topic, _data) {
switch (topic) {
case "browser-tab-created":
this._handleTabCreated(subject);
break;
case "browser-tab-removed":
this._handleTabRemoved(subject);
break;
case "browser-tab-activated":
this._handleTabActivated(subject);
break;
case "browser-window-focus-changed":
this._refreshSnapshot();
break;
case "browser-cancel-auto-grouping":
this.cancelPendingGrouping();
break;
case "sessionstore-windows-restored":
// Schedule resume after grace period to avoid acting during restore churn
if (this._resumeTimer) {
lazy.clearTimeout(this._resumeTimer);
this._resumeTimer = null;
}
this._resumeTimer = lazy.setTimeout(() => {
this._suspended = false;
this._resumeTimer = null;
this._log(
`Session restore complete: resuming grouping after ${this._resumeGraceMs}ms`
);
}, this._resumeGraceMs);
try {
Services.obs.removeObserver(this, "sessionstore-windows-restored");
} catch (_e) {}
try {
Services.obs.removeObserver(this, "browser-delayed-startup-finished");
} catch (_e) {}
break;
case "browser-delayed-startup-finished":
// Defensive: if sessionstore didn't notify (e.g., no restore), schedule resume now
if (this._suspended) {
if (this._resumeTimer) {
lazy.clearTimeout(this._resumeTimer);
this._resumeTimer = null;
}
this._resumeTimer = lazy.setTimeout(() => {
this._suspended = false;
this._resumeTimer = null;
this._log(
`Delayed startup finished: resuming grouping after ${this._resumeGraceMs}ms`
);
}, this._resumeGraceMs);
}
try {
Services.obs.removeObserver(this, "browser-delayed-startup-finished");
} catch (_e) {}
try {
Services.obs.removeObserver(this, "sessionstore-windows-restored");
} catch (_e) {}
break;
}
},
/**
* Handle new tab creation:
* - Determines the source tab via snapshot + per-window history fallback.
* - Skips grouping during session restore while grouping is suspended (_suspended is true).
* - Skips grouping if a bypass-open was just requested for this window.
* - Skips grouping if the new tab already has a group (e.g., from session restore).
* - Applies optional delay window to allow cancellation via the cancel shortcut.
* @param {XULElement} newTab - The newly created tab element.
* @returns {Promise<void>}
*/
async _handleTabCreated(newTab) {
if (!this._enabled || !newTab || !newTab.ownerGlobal) {
return;
}
// Skip grouping during session restore
if (this._suspended) {
this._log("Suspended (session restore): skipping grouping for new tab");
return;
}
const window = newTab.ownerGlobal;
const gBrowser = window.gBrowser;
// If tab groups are collapsed in this window, skip grouping to avoid reverse order/merging
if (window.__tabGroupingCollapsed) {
this._log("Groups collapsed: skipping grouping for new tab");
return;
}
// Bypass shortcut: skip grouping for this creation
if (window.__tabGroupingBypassNext) {
this._log("Bypass flag detected for new tab: skipping grouping");
window.__tabGroupingBypassNext = false;
return;
}
// If the new tab already has a group (e.g., session restore), do not regroup.
if (newTab.group) {
this._log(
"New tab already has a group (likely restore): skipping regrouping"
);
return;
}
// 1) Snapshot source
const sourceTab = this._findSourceTab(newTab, window);
if (!sourceTab || !sourceTab.group) {
this._log(
"No valid source tab or source has no group: skipping grouping"
);
return;
}
// 2) Delay or immediate
if (this._delayEnabled) {
this._log("Scheduling delayed grouping");
this._scheduleGrouping(newTab, sourceTab, gBrowser);
} else {
this._log("Grouping immediately");
this._groupTab(newTab, sourceTab, gBrowser);
}
},
/**
* Resolve the source tab for grouping a newly created tab.
* Prefers the last active snapshot, with a history fallback:
* - If the new tab is selected and snapshot points to itself, use previous active in history.
* - Otherwise use current active in history for that window.
* @param {XULElement} newTab - The new tab being grouped.
* @param {Window} window - The browser window where the tab was created.
* @returns {XULElement|null} The inferred source tab or null if not found.
*/
_findSourceTab(newTab, window) {
// Start with snapshot
let source = this._lastActiveTab;
// Fallback to history if snapshot is self or missing
if (newTab.selected && source && source === newTab) {
source = null;
}
if (!source || source.ownerGlobal !== window) {
const windowId = window.docShell.outerWindowID;
const history = this._activeHistory.get(windowId) || [];
source = newTab.selected ? history[1] : history[0];
}
return source;
},
/**
* Schedule grouping after a delay to allow cancellation.
* Enables the cancel shortcut only while at least one pending timer exists.
* @param {XULElement} newTab - The newly created tab.
* @param {XULElement} sourceTab - The inferred source tab (must have a group).
* @param {object} gBrowser - The tab browser instance for the window.
*/
_scheduleGrouping(newTab, sourceTab, gBrowser) {
// Cancel existing timer for this tab
this._cancelPendingForTab(newTab);
// Enable cancel shortcut while something is pending
if (!this._cancelShortcutActive) {
this._enableCancelShortcut();
}
const timer = lazy.setTimeout(() => {
this._pendingTimers.delete(newTab);
if (this._pendingTimers.size === 0) {
this._disableCancelShortcut();
}
this._groupTab(newTab, sourceTab, gBrowser);
}, this._delayMs);
this._pendingTimers.set(newTab, timer);
},
/**
* Group the new tab into the source tab's group and apply placement policy.
* Validates both tabs are still in the same window and the source has a group.
* @param {XULElement} newTab - The newly created tab.
* @param {XULElement} sourceTab - The source tab providing the group.
* @param {object} gBrowser - The tab browser instance for the window.
* @returns {Promise<void>}
*/
async _groupTab(newTab, sourceTab, gBrowser) {
try {
// Ensure tabs are still valid and in same window
if (
!sourceTab ||
sourceTab === newTab ||
!sourceTab.group ||
newTab.group ||
newTab.ownerGlobal !== sourceTab.ownerGlobal
) {
return;
}
// Add to the same group
this._log("Grouping new tab into source group");
gBrowser.moveTabToGroup(newTab, sourceTab.group);
// Apply placement
await this._applyPlacement(newTab, sourceTab, gBrowser);
} catch (error) {
Cu.reportError(`TabGrouping: Failed to group tab: ${error}`);
}
},
/**
* Apply placement mode for the newly grouped tab within the source group.
* Modes:
* - "after": Move after the source tab.
* - "first": Move to the beginning of the group (or before source if it is the only other tab).
* - "last": Do not explicitly move; rely on default placement at the end.
* @param {XULElement} newTab - The newly grouped tab.
* @param {XULElement} sourceTab - The source tab within the same group.
* @param {object} gBrowser - The tab browser instance for the window.
* @returns {Promise<void>}
*/
async _applyPlacement(newTab, sourceTab, gBrowser) {
// Defensive: tabs may have moved or been restored; ensure we are still in same window and group
if (!newTab || !sourceTab) {
return;
}
if (newTab.ownerGlobal !== sourceTab.ownerGlobal) {
return;
}
if (!newTab.group || !sourceTab.group) {
return;
}
if (newTab.group !== sourceTab.group) {
return;
}
switch (this._placement) {
case PLACEMENT_MODES.AFTER: {
// Move after the source tab (keeps inside the group)
gBrowser.moveTabAfter(newTab, sourceTab);
break;
}
case PLACEMENT_MODES.FIRST: {
// Move to the first position in the group
const groupTabs = gBrowser.tabs.filter(
(t) => t.group === sourceTab.group && t !== newTab
);
if (groupTabs.length > 0) {
const firstTab = groupTabs.reduce(
(min, t) => (t._tPos < min._tPos ? t : min),
groupTabs[0]
);
gBrowser.moveTabBefore(newTab, firstTab);
} else {
// No other tabs yet: place before source
gBrowser.moveTabBefore(newTab, sourceTab);
}
break;
}
case PLACEMENT_MODES.LAST: {
// Match addon behavior: don't move explicitly after grouping
// (rely on default placement of moveTabToGroup for "end" semantics)
break;
}
}
},
/**
* Handle tab removal by canceling any pending delayed grouping for that tab.
* @param {XULElement} tab - The removed tab element.
*/
_handleTabRemoved(tab) {
this._cancelPendingForTab(tab);
},
/**
* Cancel a pending delayed grouping timer for a specific tab, if present.
* Disables the cancel shortcut when no timers remain.
* @param {XULElement} tab - The tab whose pending timer should be canceled.
*/
_cancelPendingForTab(tab) {
if (this._pendingTimers.has(tab)) {
lazy.clearTimeout(this._pendingTimers.get(tab));
this._pendingTimers.delete(tab);
if (this._pendingTimers.size === 0) {
this._disableCancelShortcut();
}
}
},
/**
* Cancel all pending delayed grouping timers and disable the cancel shortcut.
*/
_cancelAllPending() {
this._log("Cancel all pending grouping operations");
for (const timer of this._pendingTimers.values()) {
lazy.clearTimeout(timer);
}
this._pendingTimers.clear();
this._disableCancelShortcut();
},
/**
* Cancel all pending delayed grouping operations and disable the cancel shortcut.
*/
cancelPendingGrouping() {
this._log("Cancel pending grouping requested");
this._cancelAllPending();
},
/**
* Register the observer used by the temporary cancel shortcut.
* This observer is active only while delayed operations are pending.
*/
_setupKeyboardShortcuts() {
Services.obs.addObserver(this, "browser-cancel-auto-grouping");
},
/**
* Remove the observer for the temporary cancel shortcut and unregister it
* from all browser windows.
*/
_cleanupKeyboardShortcuts() {
try {
Services.obs.removeObserver(this, "browser-cancel-auto-grouping");
} catch (_e) {}
this._unregisterKeyboardShortcut();
},
/**
* Enable the temporary cancel shortcut globally while timers are pending.
*/
_enableCancelShortcut() {
this._cancelShortcutActive = true;
this._log("Cancel shortcut enabled");
this._updateKeyboardShortcut();
},
/**
* Disable the temporary cancel shortcut when no timers remain.
*/
_disableCancelShortcut() {
this._cancelShortcutActive = false;
this._log("Cancel shortcut disabled");
this._unregisterKeyboardShortcut();
},
/**
* Register or unregister the temporary cancel shortcut based on current state.
*/
_updateKeyboardShortcut() {
if (this._cancelShortcutActive && this._cancelShortcut) {
this._registerKeyboardShortcut();
} else {
this._unregisterKeyboardShortcut();
}
},
/**
* Register the temporary cancel shortcut on all open browser windows.
*/
_registerKeyboardShortcut() {
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._addShortcutToWindow(window);
}
},
/**
* Unregister the temporary cancel shortcut from all open browser windows.
*/
_unregisterKeyboardShortcut() {
// Unregister the shortcut from all browser windows
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._removeShortcutFromWindow(window);
}
},
/**
* Register or unregister the always-on bypass shortcut depending on preference.
* When set, the shortcut opens a standard new tab and bypasses grouping.
*/
_updateBypassShortcut() {
// Always-on: register if a non-empty shortcut is configured
if (this._bypassShortcut) {
this._registerBypassShortcut();
} else {
this._unregisterBypassShortcut();
}
},
/**
* Register the bypass shortcut on all open browser windows.
*/
_registerBypassShortcut() {
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._addBypassShortcutToWindow(window);
}
},
/**
* Unregister the bypass shortcut from all open browser windows.
*/
_unregisterBypassShortcut() {
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._removeBypassShortcutFromWindow(window);
}
},
/**
* Attach the bypass shortcut keydown handler to a browser window.
* @param {Window} window - The target browser window.
*/
_addBypassShortcutToWindow(window) {
if (!window?.gBrowser || !this._bypassShortcut) {
return;
}
const [modifiers, key] = this._parseShortcut(this._bypassShortcut);
if (!key) {
return;
}
const handler = (event) => {
if (this._matchesShortcut(event, modifiers, key)) {
event.preventDefault();
event.stopPropagation();
try {
this._log(
"Bypass new tab shortcut triggered: opening standard new tab"
);
// Mark bypass for the next created tab in this window
window.__tabGroupingBypassNext = true;
// Open a new tab "standard way" (no grouping)
if (typeof window.BrowserOpenTab === "function") {
window.BrowserOpenTab();
} else if (window.gBrowser?.addTab) {
window.gBrowser.selectedTab =
window.gBrowser.addTab("about:newtab");
}
} catch (_e) {}
}
};
if (window.__tabGroupingBypassHandler) {
window.removeEventListener(
"keydown",
window.__tabGroupingBypassHandler,
true
);
}
window.__tabGroupingBypassHandler = handler;
window.addEventListener("keydown", handler, true);
},
/**
* Detach the bypass shortcut keydown handler from a browser window.
* @param {Window} window - The target browser window.
*/
_removeBypassShortcutFromWindow(window) {
if (window.__tabGroupingBypassHandler) {
window.removeEventListener(
"keydown",
window.__tabGroupingBypassHandler,
true
);
delete window.__tabGroupingBypassHandler;
}
},
/**
* Attach the temporary cancel shortcut keydown handler to a browser window.
* Active only while delayed grouping operations are pending.
* @param {Window} window - The target browser window.
*/
_addShortcutToWindow(window) {
if (!window?.gBrowser || !this._cancelShortcut) {
return;
}
const [modifiers, key] = this._parseShortcut(this._cancelShortcut);
if (!key) {
return;
}
const handler = (event) => {
if (this._matchesShortcut(event, modifiers, key)) {
event.preventDefault();
event.stopPropagation();
Services.obs.notifyObservers(null, "browser-cancel-auto-grouping");
}
};
if (window.__tabGroupingShortcutHandler) {
window.removeEventListener(
"keydown",
window.__tabGroupingShortcutHandler,
true
);
}
window.__tabGroupingShortcutHandler = handler;
window.addEventListener("keydown", handler, true);
},
/**
* Detach the temporary cancel shortcut keydown handler from a browser window.
* @param {Window} window - The target browser window.
*/
_removeShortcutFromWindow(window) {
if (window.__tabGroupingShortcutHandler) {
window.removeEventListener(
"keydown",
window.__tabGroupingShortcutHandler,
true
);
delete window.__tabGroupingShortcutHandler;
}
},
/**
* Parse a human-readable shortcut string into modifiers and a normalized key.
* Supports synonyms for the backquote key and normalizes to event.code "Backquote".
* @param {string} shortcut - Shortcut string (e.g., "Alt+Shift+T").
* @returns {[Set<string>, string]} Tuple of (modifiers, keyCodeOrKey).
*/
_parseShortcut(shortcut) {
const parts = shortcut.split("+");
const rawKey = (parts.pop() || "").trim();
const normalized = parts.map((m) => {
const s = m.trim().toLowerCase();
return s === "option" || s === "opt" ? "alt" : s;
});
const modifiers = new Set(normalized);
// Normalize backquote variants to event.code 'Backquote' for reliable matching
const lower = rawKey.toLowerCase();
const key =
rawKey === "`" ||
lower === "backquote" ||
lower === "backtick" ||
lower === "grave"
? "Backquote"
: rawKey;
return [modifiers, key];
},
/**
* Check whether a keyboard event matches the given shortcut definition.
* Requires exact modifier match (no extra modifiers).
* Special-cases Backquote to accept either event.key or event.code.
* @param {KeyboardEvent} event - The keydown event to test.
* @param {Set<string>} modifiers - Required modifiers (e.g., "ctrl", "alt", "shift", "cmd"|"meta").
* @param {string} key - Normalized key or code (e.g., "Backquote", "T").
* @returns {boolean} True if the event matches the shortcut.
*/
_matchesShortcut(event, modifiers, key) {
// Key match: accept either event.key or event.code for Backquote
const isBackquote = key === "Backquote" || key === "`";
const keyOk = isBackquote
? event.key === "`" || event.code === "Backquote"
: event.key === key || event.code === key;
if (!keyOk) {
return false;
}
// Required modifiers
const requiresCtrl = modifiers.has("ctrl");
const requiresMeta = modifiers.has("cmd") || modifiers.has("meta");
const requiresAlt = modifiers.has("alt");
const requiresShift = modifiers.has("shift");
// Pressed modifiers
const pressedCtrl = event.ctrlKey;
const pressedMeta = event.metaKey; // Cmd on macOS
const pressedAlt = event.altKey;
const pressedShift = event.shiftKey;
// Exact match: required present, others absent
if (pressedCtrl !== requiresCtrl) return false;
if (pressedMeta !== requiresMeta) return false;
if (pressedAlt !== requiresAlt) return false;
if (pressedShift !== requiresShift) return false;
return true;
},
/**
* Debug log helper. Logs when debug pref is enabled.
* @param {...any} args - Values to log to the console.
*/
_log(...args) {
if (!this._debugLog) {
return;
}
try {
console.log("[TabGrouping]", ...args);
} catch (_e) {}
},
/**
* Register collapse guards across browser windows:
* - TabGroupCollapse: mark window as collapsed and skip the very next reopen
* - TabGroupExpand: clear collapsed state
* - TabOpen: if collapse-skip is set, ensure first reopen is not grouped
*/
_registerCollapseGuards() {
// Attach to existing windows
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._addCollapseGuardsToWindow(window);
}
// Attach to future windows
if (!this._windowOpenObserver) {
this._windowOpenObserver = (subject, topic, _data) => {
if (topic !== "domwindowopened") {
return;
}
subject.addEventListener(
"load",
() => {
try {
if (
subject.document?.documentElement?.getAttribute(
"windowtype"
) === "navigator:browser"
) {
this._addCollapseGuardsToWindow(subject);
}
} catch (_e) {}
},
{ once: true }
);
};
Services.obs.addObserver(this._windowOpenObserver, "domwindowopened");
}
},
/**
* Unregister collapse guards across browser windows.
*/
_unregisterCollapseGuards() {
// Detach from existing windows
for (const window of Services.wm.getEnumerator("navigator:browser")) {
this._removeCollapseGuardsFromWindow(window);
}
// Detach from future windows
if (this._windowOpenObserver) {
try {
Services.obs.removeObserver(
this._windowOpenObserver,
"domwindowopened"
);
} catch (_e) {}
this._windowOpenObserver = null;
}
},
/**
* Add collapse/expand and TabOpen guards to a specific browser window.
* @param {Window} window - The browser window.
*/
_addCollapseGuardsToWindow(window) {
if (!window?.gBrowser) {
return;
}
const collapseHandler = () => {
window.__tabGroupingCollapsed = true;
window.__tabGroupingSkipNextCreated = true; // do not regroup the very next reopen
this._log(
"TabGroupCollapse detected: marking window as collapsed and skipping next reopen"
);
};
const expandHandler = () => {
window.__tabGroupingCollapsed = false;
this._log("TabGroupExpand detected: clearing collapsed state");
};
const tabOpenHandler = (evt) => {
// If we just collapsed, ensure the very next reopen is not grouped
if (window.__tabGroupingSkipNextCreated) {
window.__tabGroupingSkipNextCreated = false;
const tab = evt.target;
try {
if (tab?.group && window.gBrowser?.ungroupTab) {
window.gBrowser.ungroupTab(tab);
}
} catch (_e) {}
this._log("First reopen after collapse: ensured ungrouped");
}
};
// Remove previous handlers if present
if (window.__tabGroupingCollapseGuard) {
window.removeEventListener(
"TabGroupCollapse",
window.__tabGroupingCollapseGuard,
true
);
}
if (window.__tabGroupingExpandGuard) {
window.removeEventListener(
"TabGroupExpand",
window.__tabGroupingExpandGuard,
true
);
}
if (window.__tabGroupingTabOpenGuard) {
window.removeEventListener(
"TabOpen",
window.__tabGroupingTabOpenGuard,
true
);
}
// Store and add
window.__tabGroupingCollapseGuard = collapseHandler;
window.__tabGroupingExpandGuard = expandHandler;
window.__tabGroupingTabOpenGuard = tabOpenHandler;
window.addEventListener("TabGroupCollapse", collapseHandler, true);
window.addEventListener("TabGroupExpand", expandHandler, true);
window.addEventListener("TabOpen", tabOpenHandler, true);
},
/**
* Remove collapse/expand and TabOpen guards from a browser window.
* @param {Window} window - The browser window.
*/
_removeCollapseGuardsFromWindow(window) {
if (window.__tabGroupingCollapseGuard) {
window.removeEventListener(
"TabGroupCollapse",
window.__tabGroupingCollapseGuard,
true
);
delete window.__tabGroupingCollapseGuard;
}
if (window.__tabGroupingExpandGuard) {
window.removeEventListener(
"TabGroupExpand",
window.__tabGroupingExpandGuard,
true
);
delete window.__tabGroupingExpandGuard;
}
if (window.__tabGroupingTabOpenGuard) {
window.removeEventListener(
"TabOpen",
window.__tabGroupingTabOpenGuard,
true
);
delete window.__tabGroupingTabOpenGuard;
}
},
};