Skip to content

Instantly share code, notes, and snippets.

@ReKylee
Last active July 2, 2025 23:33
Show Gist options
  • Select an option

  • Save ReKylee/5243c18ea08e855c6c3051518cd435c3 to your computer and use it in GitHub Desktop.

Select an option

Save ReKylee/5243c18ea08e855c6c3051518cd435c3 to your computer and use it in GitHub Desktop.
Auto Tab Groups AKA Tab Matching
// ==UserScript==
// @name Tab Sorter (Refactored)
// @version 5.3.0
// @description Intelligently sorts and groups tabs, with external configuration and improved logic.
// @author
// @include main
/* NOTE: Have tab-sort-normalization-map.json in your chrome folder!
* Tab Sorter Script Configuration:
*
* All preferences can be set in about:config.
*
* --- AVAILABLE PREFERENCES ---
*
* 1. Toggle the 'Clear' Button
* - Preference Name: extensions.tab_sort.showClearButton
* - Type: Boolean
* - Default Value: true
* - Description: Set to 'false' to hide the "Clear Ungrouped Tabs" (the 'X') button.
* Set to 'true' to show it.
*
* 2. Set Minimum Grouping Size
* - Preference Name: extensions.tab_sort.min_grouping
* - Type: Integer
* - Default Value: 3
* - Description: The minimum number of ungrouped tabs required before the "Sort"
* button appears and the sorting function will run.
*
* 3. Enable Debug Logging
* - Preference Name: extensions.tab_sort.debug
* - Type: Boolean
* - Default Value: false
* - Description: Set to 'true' to see detailed logs from the script in the
* Browser Console (Ctrl+Shift+J). Useful for troubleshooting.
*
*/
// ==/UserScript==
(() => {
// Stop if already running
if (window.TabSorter) {
window.TabSorter.destroy();
}
class TabSorter {
constructor() {
this.ID = `tab-sort-${Date.now()}`;
this.LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
this.config = {
// Preferences loaded from about:config
showClearButton: true,
minGroupingSize: 3,
debug: false,
// Loaded from external JSON
normalizationMap: {},
// Static config
consolidationDistanceThreshold: 2,
aiClusteringThreshold: 0.5, // Cosine similarity threshold for grouping
};
this.state = {
isSorting: false,
isClearing: false,
isInitialized: false,
};
this.dom = {
style: null,
commandSet: null,
};
this.observe = this.observe.bind(this);
// Explicitly grab utilities from the main window scope
try {
const { ChromeUtils, Services, NetUtil, Ci } = window;
this.utils = { ChromeUtils, Services, NetUtil, Ci };
} catch (e) {
this.log(
this.LOG_LEVELS.ERROR,
"Failed to get window utilities. The script will not run.",
e,
);
return;
}
if (document.readyState === "complete") {
this.init();
} else {
window.addEventListener("load", () => this.init(), { once: true });
}
}
// --- INITIALIZATION & DESTRUCTION ---
async init() {
if (this.state.isInitialized) return;
this.log(this.LOG_LEVELS.INFO, "Initializing...");
// Wait for the browser UI to be ready
const ready = await this.waitForBrowserUI();
if (!ready) {
this.log(
this.LOG_LEVELS.ERROR,
"Initialization failed: Browser UI not found.",
);
return;
}
await this.loadConfig();
this.injectStyles();
this.setupCommands();
this.attachEventListeners();
this.updateAllToolbarButtons();
this.state.isInitialized = true;
this.log(this.LOG_LEVELS.INFO, "Initialization complete.");
}
destroy() {
this.log(this.LOG_LEVELS.INFO, "Destroying instance...");
// Disconnect observers and remove event listeners
this.detachEventListeners();
// Remove injected styles
this.dom.style?.remove();
// Remove buttons and separator lines from all separators
document
.querySelectorAll(".tab-sort-button-container")
.forEach((c) => c.remove());
document
.querySelectorAll(".tab-sort-separator-line")
.forEach((l) => l.remove());
// Mark as destroyed
this.state.isInitialized = false;
// Remove from window
if (window.TabSorter) {
delete window.TabSorter;
}
}
async waitForBrowserUI() {
let attempts = 0;
const maxAttempts = 50;
const interval = 200;
while (attempts < maxAttempts) {
const commandSet = document.querySelector("commandset#zenCommandSet");
const separator = document.querySelector(
".vertical-pinned-tabs-container-separator",
);
const gBrowserReady =
typeof gBrowser !== "undefined" && gBrowser.tabContainer;
const gZenWorkspacesReady =
typeof window.gZenWorkspaces !== "undefined" &&
window.gZenWorkspaces.activeWorkspace != null;
if (commandSet && separator && gBrowserReady && gZenWorkspacesReady) {
this.dom.commandSet = commandSet;
return true;
}
await new Promise((resolve) => setTimeout(resolve, interval));
attempts++;
}
return false;
}
// --- CONFIGURATION & STYLES ---
async loadConfig() {
this.log(this.LOG_LEVELS.DEBUG, "Loading configuration...");
const prefService = this.utils.Services.prefs.getBranch(
"extensions.tab_sort.",
);
// Load from about:config
try {
this.config.showClearButton = prefService.getBoolPref(
"showClearButton",
true,
);
this.config.minGroupingSize = prefService.getIntPref("min_grouping", 3);
this.config.debug = prefService.getBoolPref("debug", false);
} catch (e) {
this.log(
this.LOG_LEVELS.WARN,
"Could not read about:config preferences. Using defaults.",
e,
);
}
// Load normalization map from external JSON using NetUtil
try {
// Get chrome folder inside the Firefox profile
const file = Services.dirsvc.get("UChrm", Ci.nsIFile);
file.append("tab-sort-normalization-map.json");
if (file.exists() && file.isFile()) {
const uri = Services.io.newFileURI(file);
const jsonString = await new Promise((resolve, reject) => {
NetUtil.asyncFetch(
{
uri,
loadUsingSystemPrincipal: true,
},
(inputStream, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Error(`Failed to fetch file: ${status}`));
return;
}
const scriptableInputStream = Cc[
"@mozilla.org/scriptableinputstream;1"
].createInstance(Ci.nsIScriptableInputStream);
scriptableInputStream.init(inputStream);
const data = scriptableInputStream.read(
scriptableInputStream.available(),
);
resolve(data);
},
);
});
this.config.normalizationMap = JSON.parse(jsonString);
this.log(
this.LOG_LEVELS.INFO,
"Successfully loaded normalization map from JSON file.",
);
} else {
this.log(
this.LOG_LEVELS.WARN,
`tab-sort-normalization-map.json not found at path: ${file.path}. Using empty map.`,
);
this.config.normalizationMap = {};
}
} catch (e) {
this.log(
this.LOG_LEVELS.ERROR,
"Failed to load or parse normalization map JSON. Using empty map.",
e,
);
this.config.normalizationMap = {};
}
}
injectStyles() {
const css = `
.vertical-pinned-tabs-container-separator {
background: none !important;
height: 16px !important;
max-height: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
overflow: visible !important;
border: none !important;
width: 100% !important;
box-sizing: border-box !important;
padding: 0 8px !important;
margin: 1px 0 !important;
}
.tab-sort-separator-line {
flex-grow: 1; /* Takes up available space */
height: 10px; /* Space for wave animation */
min-width: 0;
}
.tab-sort-separator-line .tab-sort-line-path {
stroke: var(--lwt-sidebar-text-color, #fbfbfe);
stroke-opacity: 0.3;
stroke-width: 1;
fill: none;
}
.tab-sort-button-container {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0; /* Prevent buttons from shrinking */
margin-left: auto; /* Pushes buttons to the right */
padding-left: 8px; /* Adds space between the line and buttons */
}
#tab-sort-sort-button, #tab-sort-clear-button {
-moz-appearance: none;
border: none;
background: transparent;
padding: 4px; /* Increased padding for bigger buttons */
border-radius: 4px;
cursor: pointer;
fill: currentColor;
color: var(--lwt-sidebar-text-color, #fbfbfe);
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
#tab-sort-sort-button:hover, #tab-sort-clear-button:hover {
background-color: var(--tab-selected-bgcolor, rgba(255, 255, 255, 0.1));
opacity: 1;
}
#tab-sort-sort-button.hidden-button, #tab-sort-clear-button.hidden-button {
display: none !important;
}
#tab-sort-sort-button svg, #tab-sort-clear-button svg {
width: 16px; /* Increased icon size */
height: 16px; /* Increased icon size */
display: block;
}
/* Animations */
@keyframes tab-sort-brush-sweep {
0% { transform: rotate(0deg); }
20% { transform: rotate(-15deg); }
40% { transform: rotate(15deg); }
60% { transform: rotate(-15deg); }
80% { transform: rotate(15deg); }
100% { transform: rotate(0deg); }
}
#tab-sort-sort-button.brushing {
animation: tab-sort-brush-sweep 0.8s ease-in-out;
}
.tab-is-sorting .tab-icon-image, .tab-is-sorting .tab-label {
animation: loading-pulse-tab 1.5s ease-in-out infinite;
}
@keyframes loading-pulse-tab {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}`;
this.dom.style = document.createElement("style");
this.dom.style.id = "tab-sort-refactored-styles";
this.dom.style.textContent = css;
document.head.appendChild(this.dom.style);
}
// --- EVENT HANDLING & COMMANDS ---
setupCommands() {
const createCommand = (id) => {
if (!this.dom.commandSet.querySelector(`#${id}`)) {
const command = window.MozXULElement.parseXULToFragment(
`<command id="${id}"/>`,
).firstChild;
this.dom.commandSet.appendChild(command);
}
};
createCommand("cmd_tab_sort_sort");
createCommand("cmd_tab_sort_clear");
this._onCommand = this._onCommand.bind(this);
this.dom.commandSet.addEventListener("command", this._onCommand);
}
attachEventListeners() {
this._updateButtonsDebounced = this.debounce(
this.updateAllToolbarButtons,
250,
);
const tabContainer = gBrowser.tabContainer;
tabContainer.addEventListener("TabOpen", this._updateButtonsDebounced);
tabContainer.addEventListener("TabClose", this._updateButtonsDebounced);
tabContainer.addEventListener("TabSelect", this._updateButtonsDebounced);
tabContainer.addEventListener("TabGrouped", this._updateButtonsDebounced);
tabContainer.addEventListener(
"TabUngrouped",
this._updateButtonsDebounced,
);
this._onWorkspaceSwitched = this._onWorkspaceSwitched.bind(this);
window.addEventListener(
"zen-workspace-switched",
this._onWorkspaceSwitched,
);
// Add preference observer for real-time config changes
this.utils.Services.prefs
.getBranch("extensions.tab_sort.")
.addObserver("", this);
}
detachEventListeners() {
if (this._updateButtonsDebounced) {
const tabContainer = gBrowser?.tabContainer;
if (tabContainer) {
tabContainer.removeEventListener(
"TabOpen",
this._updateButtonsDebounced,
);
tabContainer.removeEventListener(
"TabClose",
this._updateButtonsDebounced,
);
tabContainer.removeEventListener(
"TabSelect",
this._updateButtonsDebounced,
);
tabContainer.removeEventListener(
"TabGrouped",
this._updateButtonsDebounced,
);
tabContainer.removeEventListener(
"TabUngrouped",
this._updateButtonsDebounced,
);
}
}
window.removeEventListener(
"zen-workspace-switched",
this._onWorkspaceSwitched,
);
this.dom.commandSet?.removeEventListener("command", this._onCommand);
// Remove preference observer
this.utils.Services.prefs
.getBranch("extensions.tab_sort.")
.removeObserver("", this);
}
_onCommand(event) {
switch (event.target.id) {
case "cmd_tab_sort_sort":
this.log(this.LOG_LEVELS.INFO, "Sort command triggered.");
const sortButton = document.querySelector(
"#tab-sort-sort-button:not(.hidden-button)",
);
if (sortButton) {
sortButton.classList.add("brushing");
setTimeout(() => sortButton.classList.remove("brushing"), 800);
}
this.sortTabs();
break;
case "cmd_tab_sort_clear":
this.log(this.LOG_LEVELS.INFO, "Clear command triggered.");
this.clearUngroupedTabs();
break;
}
}
// nsIObserver implementation for preference changes
observe(aSubject, aTopic, aData) {
if (aTopic !== "nsPref:changed") {
return;
}
switch (aData) {
case "showClearButton":
this.log(
this.LOG_LEVELS.INFO,
"Preference 'showClearButton' changed. Updating UI.",
);
this.config.showClearButton = aSubject.getBoolPref(aData);
this.updateAllToolbarButtons();
break;
case "min_grouping":
this.log(this.LOG_LEVELS.INFO, "Preference 'min_grouping' changed.");
this.config.minGroupingSize = aSubject.getIntPref(aData);
this.updateAllToolbarButtons();
break;
case "debug":
this.log(this.LOG_LEVELS.INFO, "Preference 'debug' changed.");
this.config.debug = aSubject.getBoolPref(aData);
break;
}
}
_onWorkspaceSwitched() {
this.log(
this.LOG_LEVELS.INFO,
`Workspace switched to ${window.gZenWorkspaces?.activeWorkspace}. Updating UI.`,
);
this.updateAllToolbarButtons();
}
// --- UI & BUTTONS ---
updateAllToolbarButtons() {
const separators = document.querySelectorAll(
".vertical-pinned-tabs-container-separator",
);
separators.forEach((separator) => {
let line = separator.querySelector(".tab-sort-separator-line");
if (!line) {
const svgNS = "http://www.w3.org/2000/svg";
line = document.createElementNS(svgNS, "svg");
line.setAttribute("class", "tab-sort-separator-line");
line.setAttribute("viewBox", "0 0 100 1");
line.setAttribute("preserveAspectRatio", "none");
const path = document.createElementNS(svgNS, "path");
path.setAttribute("d", "M 0 0.5 L 100 0.5");
line.appendChild(path);
separator.prepend(line);
}
let container = separator.querySelector(".tab-sort-button-container");
if (!container) {
container = document.createElement("div");
container.className = "tab-sort-button-container";
separator.appendChild(container);
}
this.createOrUpdateToolbarButtons(container);
});
}
createOrUpdateToolbarButtons(container) {
const { ungroupedTotal } = this.getTabCounts();
// Sort Button
let sortButton = container.querySelector("#tab-sort-sort-button");
if (!sortButton) {
const buttonFrag = window.MozXULElement.parseXULToFragment(`
<toolbarbutton id="tab-sort-sort-button" command="cmd_tab_sort_sort" tooltiptext="Sort Ungrouped Tabs">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<path d="M19.9132 21.3765C19.8875 21.0162 19.6455 20.7069 19.3007 20.5993L7.21755 16.8291C6.87269 16.7215 6.49768 16.8384 6.27165 17.1202C5.73893 17.7845 4.72031 19.025 3.78544 19.9965C2.4425 21.392 3.01177 22.4772 4.66526 22.9931C4.82548 23.0431 5.78822 21.7398 6.20045 21.7398C6.51906 21.8392 6.8758 23.6828 7.26122 23.8031C7.87402 23.9943 8.55929 24.2081 9.27891 24.4326C9.59033 24.5298 10.2101 23.0557 10.5313 23.1559C10.7774 23.2327 10.7236 24.8834 10.9723 24.961C11.8322 25.2293 12.699 25.4997 13.5152 25.7544C13.868 25.8645 14.8344 24.3299 15.1637 24.4326C15.496 24.5363 15.191 26.2773 15.4898 26.3705C16.7587 26.7664 17.6824 27.0546 17.895 27.1209C19.5487 27.6369 20.6333 27.068 20.3226 25.1563C20.1063 23.8255 19.9737 22.2258 19.9132 21.3765Z"/>
<path d="M16.719 1.7134C17.4929-0.767192 20.7999 0.264626 20.026 2.74523C19.2521 5.22583 18.1514 8.75696 17.9629 9.36C17.7045 10.1867 16.1569 15.1482 15.899 15.9749L19.2063 17.0068C20.8597 17.5227 20.205 19.974 18.4514 19.4268L8.52918 16.331C6.87208 15.8139 7.62682 13.3938 9.28426 13.911L12.5916 14.9429C12.8495 14.1163 14.3976 9.15491 14.6555 8.32807C14.9135 7.50122 15.9451 4.19399 16.719 1.7134Z"/>
</svg>
</toolbarbutton>`);
sortButton = buttonFrag.firstChild;
container.appendChild(sortButton);
}
sortButton.classList.toggle(
"hidden-button",
ungroupedTotal < this.config.minGroupingSize,
);
// Clear Button - NEW, MORE ROBUST LOGIC
let clearButton = container.querySelector("#tab-sort-clear-button");
if (!clearButton) {
const buttonFrag = window.MozXULElement.parseXULToFragment(`
<toolbarbutton id="tab-sort-clear-button" command="cmd_tab_sort_clear" tooltiptext="Close Ungrouped Tabs">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M2.5 2a.5.5 0 0 0-.353.146.5.5 0 0 0 0 .708L7.293 8l-5.146 5.145a.5.5 0 0 0 0 .707.5.5 0 0 0 .707 0L8 8.706l5.146 5.146a.5.5 0 0 0 .706 0 .5.5 0 0 0 0-.707L8.708 8l5.146-5.146a.5.5 0 0 0 0-.707.5.5 0 0 0-.707 0L8 7.292 2.854 2.146A.5.5 0 0 0 2.5 2z"/>
</svg>
</toolbarbutton>`);
clearButton = buttonFrag.firstChild;
container.appendChild(clearButton);
}
clearButton.classList.toggle(
"hidden-button",
!this.config.showClearButton || ungroupedTotal === 0,
);
}
// --- CORE FUNCTIONALITY ---
async sortTabs() {
if (this.state.isSorting) {
this.log(this.LOG_LEVELS.WARN, "Sorting already in progress.");
return;
}
this.state.isSorting = true;
this.log(this.LOG_LEVELS.INFO, "--- Starting tab sort process ---");
const separator = document.querySelector(
".vertical-pinned-tabs-container-separator",
);
if (separator) {
this.startWaveAnimation(separator);
}
const { ungroupedTabs, existingGroups } = this.getTabsAndGroups();
ungroupedTabs.forEach((t) => t.classList.add("tab-is-sorting"));
try {
// Phase 1: Move tabs to existing groups
const { movedTabs, remainingTabs } = this.assignTabsToExistingGroups(
ungroupedTabs,
existingGroups,
);
this.log(
this.LOG_LEVELS.INFO,
`Phase 1: Moved ${movedTabs.size} tabs into existing groups.`,
);
// Phase 2: Group remaining tabs with AI
if (remainingTabs.length >= this.config.minGroupingSize) {
this.log(
this.LOG_LEVELS.INFO,
`Phase 2: Grouping ${remainingTabs.length} remaining tabs with AI.`,
);
const aiGroups = await this.groupTabsWithAI(remainingTabs);
await this.createGroups(aiGroups);
} else {
this.log(
this.LOG_LEVELS.INFO,
`Phase 2: Skipped, only ${remainingTabs.length} tabs remain.`,
);
}
} catch (error) {
this.log(
this.LOG_LEVELS.ERROR,
"An error occurred during the sorting process:",
error,
);
} finally {
this.state.isSorting = false;
document
.querySelectorAll(".tab-is-sorting")
.forEach((t) => t.classList.remove("tab-is-sorting"));
this.updateAllToolbarButtons();
this.log(this.LOG_LEVELS.INFO, "--- Tab sort process finished ---");
}
}
assignTabsToExistingGroups(ungroupedTabs, existingGroups) {
const movedTabs = new Set();
const groupContentCache = new Map(); // Cache: Map<groupId, Set<hostname>>
// Pre-populate cache with hostnames from existing groups
for (const group of existingGroups.values()) {
const hostnames = new Set();
group.tabs.forEach((tab) => {
const hostname = this.getTabData(tab).hostname;
if (hostname) hostnames.add(hostname);
});
// It's safer to key the cache by the group's label, as that's what's used for matching.
groupContentCache.set(group.label, hostnames);
}
for (const tab of ungroupedTabs) {
const tabData = this.getTabData(tab);
let targetGroup = null;
// --- START: CORRECTED NORMALIZATION LOGIC ---
// This block replaces the incorrect 'getNormalizedName' logic.
let bestMatch = "";
const map = this.config.normalizationMap || {}; // Ensure map exists
const searchString =
`${tabData.hostname} ${tabData.title}`.toLowerCase();
// Find the longest matching alias from the map within the tab's details.
for (const alias in map) {
if (
searchString.includes(alias.toLowerCase()) &&
alias.length > bestMatch.length
) {
bestMatch = alias;
}
}
// If a matching alias was found, use its value (the group name) to find the target group.
if (bestMatch) {
const normalizedGroupName = map[bestMatch];
if (existingGroups.has(normalizedGroupName)) {
targetGroup = existingGroups.get(normalizedGroupName);
}
}
// --- END: CORRECTED NORMALIZATION LOGIC ---
// If no normalization match was found, fall back to matching by hostname.
if (!targetGroup) {
for (const group of existingGroups.values()) {
const hostnames = groupContentCache.get(group.label);
if (
hostnames &&
tabData.hostname &&
hostnames.has(tabData.hostname)
) {
targetGroup = group;
break;
}
}
}
if (targetGroup) {
this.log(
this.LOG_LEVELS.DEBUG,
`Moving tab "${tabData.title}" to existing group "${targetGroup.label}".`,
);
gBrowser.moveTabToGroup(tab, targetGroup.element);
movedTabs.add(tab);
}
}
const remainingTabs = ungroupedTabs.filter((tab) => !movedTabs.has(tab));
return { movedTabs, remainingTabs };
}
async groupTabsWithAI(tabs) {
this.log(
this.LOG_LEVELS.INFO,
`AI Grouping: Processing ${tabs.length} tabs.`,
);
const validTabs = tabs.filter((tab) => tab && tab.isConnected);
if (validTabs.length === 0) return [];
try {
const { createEngine } = ChromeUtils.importESModule(
"chrome://global/content/ml/EngineProcess.sys.mjs",
);
// --- 1. Embedding and Clustering (Proven to work) ---
const embeddingEngine = await createEngine({
taskName: "feature-extraction",
modelId: "Mozilla/smart-tab-embedding",
modelHub: "huggingface",
engineId: "embedding-engine",
});
const tabDataList = validTabs.map((tab) => this.getTabData(tab));
const embeddings = [];
for (const tabData of tabDataList) {
const result = await embeddingEngine.run({ args: [tabData.title] });
let rawEmbedding;
if (result && result[0] && Array.isArray(result[0].embedding)) {
rawEmbedding = result[0].embedding;
} else if (result && Array.isArray(result[0])) {
rawEmbedding = result[0];
} else if (Array.isArray(result)) {
rawEmbedding = result;
}
const averageEmbedding = (arrays) => {
if (!Array.isArray(arrays) || arrays.length === 0) return [];
if (typeof arrays[0] === "number") return arrays;
const len = arrays[0].length;
const avg = new Array(len).fill(0);
for (const arr of arrays) {
for (let i = 0; i < len; i++) {
avg[i] += arr[i];
}
}
for (let i = 0; i < len; i++) {
avg[i] /= arrays.length;
}
return avg;
};
const pooled = averageEmbedding(rawEmbedding);
if (pooled.length > 0) {
embeddings.push(pooled);
} else {
embeddings.push([]);
}
}
const normalizedEmbeddings = embeddings.map((vector) =>
this.normalize(vector),
);
const SIMILARITY_THRESHOLD = 0.4;
const groupIndices = this.clusterEmbeddings(
normalizedEmbeddings,
SIMILARITY_THRESHOLD,
);
// --- 2. Simplified Naming Phase ---
// We are no longer using the failing namingEngine.
this.log(
this.LOG_LEVELS.INFO,
"AI clustering complete. Generating group names from tab titles.",
);
const finalGroups = [];
for (const indexArray of groupIndices) {
const tabsInGroup = indexArray.map((i) => validTabs[i]);
if (tabsInGroup.length === 0) continue;
// Name the group based on the title of the first tab in the cluster.
const representativeTitle = tabDataList[indexArray[0]].title;
const groupName = this.processTopic(representativeTitle);
finalGroups.push({ label: groupName, tabs: tabsInGroup });
}
this.log(
this.LOG_LEVELS.INFO,
`AI successfully prepared ${finalGroups.length} groups.`,
);
return finalGroups;
} catch (e) {
this.log(
this.LOG_LEVELS.ERROR,
"A critical error occurred during AI processing. Switching to non-AI grouping.",
e,
);
// If anything fails, the robust hostname grouping will take over.
return this.groupTabsByHostname(validTabs);
}
}
async createGroups(groupsToCreate) {
let colorIndex = 0;
const groupColors = [
"blue",
"red",
"yellow",
"green",
"pink",
"purple",
"orange",
"cyan",
];
for (const group of groupsToCreate) {
const connectedTabs = group.tabs.filter(
(tab) => tab && tab.isConnected,
);
if (connectedTabs.length < this.config.minGroupingSize) {
this.log(
this.LOG_LEVELS.DEBUG,
`Skipping group "${group.label}" after filtering; not enough connected tabs remaining.`,
);
continue;
}
const finalLabel = this.consolidateGroupName(group.label);
const existingGroup = this.findGroupElement(finalLabel);
if (existingGroup) {
this.log(
this.LOG_LEVELS.DEBUG,
`Adding ${connectedTabs.length} tabs to existing group "${finalLabel}".`,
);
connectedTabs.forEach((tab) =>
gBrowser.moveTabToGroup(tab, existingGroup),
);
} else {
this.log(
this.LOG_LEVELS.DEBUG,
`Creating new group "${finalLabel}" with ${connectedTabs.length} tabs.`,
);
// Add the 'insertBefore' property to give Firefox's internal function
// a crucial hint about where to place the new group in the UI.
const groupOptions = {
label: finalLabel,
color: groupColors[colorIndex % groupColors.length],
insertBefore: connectedTabs[0], // Use the first tab in the group as the anchor point.
};
gBrowser.addTabGroup(connectedTabs, groupOptions);
colorIndex++;
}
}
}
consolidateGroupName(label) {
const existingGroups = this.getTabsAndGroups().existingGroups;
for (const existingLabel of existingGroups.keys()) {
if (
this.levenshteinDistance(
label.toLowerCase(),
existingLabel.toLowerCase(),
) <= this.config.consolidationDistanceThreshold
) {
this.log(
this.LOG_LEVELS.DEBUG,
`Consolidating new group "${label}" into existing "${existingLabel}".`,
);
return existingLabel;
}
}
return label;
}
clearUngroupedTabs() {
if (this.state.isClearing) return;
this.state.isClearing = true;
const { ungroupedTabs } = this.getTabsAndGroups();
const tabsToClose = ungroupedTabs.filter((tab) => !tab.selected);
this.log(
this.LOG_LEVELS.INFO,
`Clearing ${tabsToClose.length} ungrouped tabs.`,
);
if (tabsToClose.length > 0) {
gBrowser.removeTabs(tabsToClose);
}
this.state.isClearing = false;
this.updateAllToolbarButtons();
}
// --- HELPERS & UTILITIES ---
startWaveAnimation(separator) {
const pathElement = separator?.querySelector(".tab-sort-line-path");
if (!pathElement) return;
// Cancel any previous animation on this element
if (separator.animationFrameId) {
cancelAnimationFrame(separator.animationFrameId);
}
const duration = 800; // Total animation time in ms
const maxAmplitude = 4; // Max height of the wave
const frequency = 8; // How many waves
let startTime = performance.now();
let t = 0; // Time variable for wave movement
const animate = (currentTime) => {
const elapsedTime = currentTime - startTime;
if (elapsedTime >= duration) {
pathElement.setAttribute("d", "M 0 5 L 100 5"); // Reset to straight line
separator.animationFrameId = null;
return;
}
t += 0.5;
// Use a sine wave for progress to make the amplitude grow and shrink smoothly
const growth = Math.sin((elapsedTime / duration) * Math.PI);
const currentAmplitude = maxAmplitude * growth;
let pathData = "";
const segments = 50;
for (let i = 0; i <= segments; i++) {
const x = (i / segments) * 100;
const y =
5 +
currentAmplitude *
Math.sin((x / (100 / frequency)) * 2 * Math.PI + t * 0.1);
pathData +=
(i === 0 ? "M" : "L") + ` ${x.toFixed(2)} ${y.toFixed(2)}`;
}
pathElement.setAttribute("d", pathData);
separator.animationFrameId = requestAnimationFrame(animate);
};
separator.animationFrameId = requestAnimationFrame(animate);
}
getTabsAndGroups() {
const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace;
const allTabs = Array.from(gBrowser.tabs);
const ungroupedTabs = [];
const existingGroups = new Map(); // Map<label, {id, element, tabs}>
for (const tab of allTabs) {
if (
tab.getAttribute("zen-workspace-id") !== currentWorkspaceId ||
tab.pinned ||
tab.hasAttribute("zen-empty-tab")
) {
continue;
}
const groupEl = tab.closest("tab-group");
if (groupEl) {
const label = groupEl.getAttribute("label");
const id = groupEl.getAttribute("zen-group-id");
if (label && !existingGroups.has(label)) {
existingGroups.set(label, {
id,
label,
element: groupEl,
tabs: [tab],
});
} else if (label) {
existingGroups.get(label).tabs.push(tab);
}
} else {
ungroupedTabs.push(tab);
}
}
return { ungroupedTabs, existingGroups };
}
getTabCounts() {
const { ungroupedTabs } = this.getTabsAndGroups();
return { ungroupedTotal: ungroupedTabs.length };
}
getTabData(tab) {
if (!tab || !tab.isConnected) return { title: "", url: "", hostname: "" };
try {
const browser = tab.linkedBrowser || gBrowser.getBrowserForTab(tab);
const title = tab.label || "Untitled";
const url = browser.currentURI.spec;
const hostname = url.startsWith("http")
? new URL(url).hostname.replace(/^www\./, "")
: "";
return { title, url, hostname };
} catch (e) {
return { title: tab.label, url: "", hostname: "" };
}
}
getNormalizedName({ hostname, title }) {
if (!hostname && !title) return null;
const map = this.config.normalizationMap;
for (const groupName in map) {
if (hostname && map[groupName].hostnames?.includes(hostname)) {
return groupName;
}
if (
title &&
map[groupName].aliases?.some((alias) =>
title.toLowerCase().includes(alias),
)
) {
return groupName;
}
}
// Fallback to the first part of the hostname (e.g., "news" from "news.google.com")
if (hostname) {
return hostname.split(".")[0];
}
return null;
}
findGroupElement(label) {
const safeSelector = label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return document.querySelector(`tab-group[label="${safeSelector}"]`);
}
processTopic(text) {
if (!text) return "Uncategorized";
const textTrimmedLower = text.trim().toLowerCase();
// --- Step 1: Use the normalizationMap for a direct match ---
// This is the critical part that was missing from our script's version.
if (
this.config.normalizationMap &&
this.config.normalizationMap[textTrimmedLower]
) {
this.log(
this.LOG_LEVELS.DEBUG,
`Normalizing "${textTrimmedLower}" to "${this.config.normalizationMap[textTrimmedLower]}"`,
);
return this.config.normalizationMap[textTrimmedLower];
}
// --- Step 2: Clean the raw name from the AI or tab title ---
// This logic is adapted from the working script.
let cleanedText = text.replace(
/^(Category is|The category is|Topic:)\s*"?/i,
"",
); // Remove AI prefixes
cleanedText = cleanedText.replace(/^\\s*[\\d.*-]+\\s*/, ""); // Remove leading numbers/bullets
cleanedText = cleanedText.replace(/["'*().:;,?]/g, ""); // Remove punctuation
// --- Step 3: Format into a clean Title Case name ---
const finalName = cleanedText
.trim()
.toLowerCase()
.split(" ")
.slice(0, 3) // Take up to 3 words for a decent group name
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
// Ensure we don't return an empty string
return finalName.substring(0, 40) || "Uncategorized";
}
normalize(vector) {
if (!Array.isArray(vector) || vector.length === 0) return [];
const magnitude = Math.sqrt(
vector.reduce((sum, value) => sum + value * value, 0),
);
if (magnitude === 0) return vector; // Avoid division by zero
return vector.map((value) => value / magnitude);
}
cosineSimilarity(vecA, vecB) {
if (!vecA || !vecB || vecA.length !== vecB.length) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
clusterEmbeddings(vectors, threshold) {
this.log(
this.LOG_LEVELS.DEBUG,
`--- [DEBUG] Clustering ${vectors.length} vectors with threshold ${threshold} ---`,
);
const groups = [];
const used = new Array(vectors.length).fill(false);
for (let i = 0; i < vectors.length; i++) {
if (used[i]) continue;
const group = [i];
used[i] = true;
for (let j = 0; j < vectors.length; j++) {
if (i !== j && !used[j]) {
const similarity = this.cosineSimilarity(vectors[i], vectors[j]);
// This is the most important new log:
this.log(
this.LOG_LEVELS.DEBUG,
` [DEBUG] Comparing vector[${i}] vs vector[${j}]: Similarity = ${similarity.toFixed(4)}`,
);
if (similarity > threshold) {
this.log(
this.LOG_LEVELS.INFO,
` [DEBUG] >>> Match found! Grouping vector[${j}] with vector[${i}].`,
);
group.push(j);
used[j] = true;
}
}
}
groups.push(group);
}
this.log(
this.LOG_LEVELS.DEBUG,
`--- [DEBUG] Clustering finished. Found ${groups.length} potential groups. ---`,
);
return groups;
}
levenshteinDistance(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = Array(b.length + 1)
.fill(null)
.map(() => Array(a.length + 1).fill(null));
for (let i = 0; i <= b.length; i++) matrix[i][0] = i;
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
);
}
}
return matrix[b.length][a.length];
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
log(level, ...args) {
const levelName = Object.keys(this.LOG_LEVELS).find(
(key) => this.LOG_LEVELS[key] === level,
);
const configLogLevel = this.config.debug
? this.LOG_LEVELS.DEBUG
: this.LOG_LEVELS.INFO;
if (level >= configLogLevel) {
const style = `color: ${level === this.LOG_LEVELS.ERROR ? "red" : level === this.LOG_LEVELS.WARN ? "orange" : "inherit"}`;
console.log(`%c[TabSorter] [${levelName}]`, style, ...args);
}
}
}
// --- Start the script ---
window.TabSorter = new TabSorter();
})();
{
"GitHub": {
"hostnames": ["github.com"],
"aliases": ["gh"]
},
"Stack Overflow": {
"hostnames": ["stackoverflow.com"],
"aliases": ["so"]
},
"YouTube": {
"hostnames": ["youtube.com", "youtube.com"],
"aliases": ["yt"]
},
"Twitch": {
"hostnames": ["twitch.tv"],
"aliases": []
},
"Reddit": {
"hostnames": ["reddit.com", "old.reddit.com"],
"aliases": []
},
"Twitter": {
"hostnames": ["twitter.com", "x.com"],
"aliases": []
},
"LinkedIn": {
"hostnames": ["linkedin.com"],
"aliases": []
},
"Google Docs": {
"hostnames": ["docs.google.com"],
"aliases": []
},
"Google Drive": {
"hostnames": ["drive.google.com"],
"aliases": []
},
"Gmail": {
"hostnames": ["mail.google.com"],
"aliases": []
},
"OpenAI": {
"hostnames": ["openai.com"],
"aliases": ["chatgpt"]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment