├── .gitignore ├── LICENSE ├── README.md ├── clear.uc.js ├── image.jpg ├── preferences.json ├── tab_sort_clear.uc.js └── theme.json /.gitignore: -------------------------------------------------------------------------------- 1 | api.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Darsh A 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ✨ AI Tab Groups for Zen Browser ✨ 2 | ‼️ Breaks for Zen version <1.12.6b, Please use the latest script. 3 | 4 | https://github.com/user-attachments/assets/fc792843-b1da-448e-ba00-63322a3d9c99 5 | 6 | ## Pre-requisites 7 | - Enable userChrome Customizations: 8 | In `about:config` go to `toolkit.legacyUserProfileCustomizations.stylesheets` and set it to `true`. 9 | - Install and Setup the userChrome.js Loader from [Autoconfig](https://github.com/MrOtherGuy/fx-autoconfig/tree/master) 10 | - Install the Tab Groups config from [Advanced Tab Groups](https://github.com/Anoms12/Advanced-Tab-Groups) 11 | If you already have a Tab Groups config you can skip this 12 | 13 | ## Setup and Install 14 | - Copy and paste the `tab_sort_clear.uc.js` file to your `chrome/JS` folder. 15 | ### AI Setup 16 | 1. For Gemini (RECOMMENDED) 17 | - Set `gemini { enabled:true }` in `apiConfig` and `ollama { enabled:false }` in `apiConfig` 18 | - Get an API Key from [AI Studios](https://aistudio.google.com) 19 | - Replace `YOUR_GEMINI-API-KEY` with the copied API key 20 | - Don't change the Gemini model since 2.0 has very low rate limits (unless you are rich ig) 21 | 2. For Ollama 22 | - Download and install [Ollama](https://ollama.com/) 23 | - Install your preferred model. The script uses `llama3.1` by default 24 | - Set `ollama { enabled:true }` in `apiConfig` and `gemini { enabled:false }` in `apiConfig` 25 | - Set the model you downloaded in ollama.model: in the config (you can see the models by doing `ollama list` in terminal) 26 | - Make sure `browser.tabs.groups.smart.enabled` is set to `false` in `about:config` 27 | - Open Zen browser, go to `about:support` and clear the startup cache. 28 | - Done. Enjoy ^^ 29 | 30 | ## How it works? 31 | - The script has two phases, first it manually sorts tabs that have common words in their title and URL, second it uses the AI to sort rest of the tabs and checks if they fit in the existing groups (that were manually created) or if it should create a new group. 32 | - The script only fetches the tabs full URL and title, thus it prioritizes the title first for main context and URL for sub context. 33 | - The sort function only works when there is two or more tabs to sort into a group. 34 | - You can also have a selected group of tabs sorted as well, this allows you to have fine-grained control over the sorting (works for tabs that are already grouped as well, they may be re-sorted). 35 | - You are free to change the AI prompt to your suitable workflow. The prompt is at the top in `apiConfig`. 36 | - The `Clear` button only clears un-grouped non-pinned tabs. 37 | 38 | **Peace <3** 39 | 40 | -------------------------------------------------------------------------------- /clear.uc.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const CONFIG = { 3 | styles: ` 4 | .vertical-pinned-tabs-container-separator, 5 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator { 6 | display: flex !important; 7 | flex-direction: column; 8 | margin-left: 0 !important; 9 | min-height: 1px !important; 10 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1)); 11 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out; 12 | margin-top: 5px !important; 13 | margin-bottom: 8px !important; 14 | } 15 | #clear-button { 16 | opacity: 0; 17 | transition: opacity 0.1s ease-in-out; 18 | position: absolute; 19 | right: 0; 20 | font-size: 12px; 21 | width: 60px; 22 | pointer-events: auto; 23 | align-self: end; 24 | appearance: none; 25 | margin-top: -8px; 26 | padding: 1px; 27 | color: grey; 28 | label { display: block; } 29 | } 30 | #clear-button:hover { 31 | opacity: 1; 32 | color: white; 33 | border-radius: 4px; 34 | } 35 | .vertical-pinned-tabs-container-separator:has(#clear-button):hover, 36 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#clear-button):hover { 37 | width: calc(100% - 60px); 38 | margin-right: auto; 39 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 40 | } 41 | .vertical-pinned-tabs-container-separator:hover #clear-button, 42 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #clear-button { 43 | opacity: 1; 44 | } 45 | .tab-closing { 46 | animation: fadeUp 0.5s forwards; 47 | } 48 | @keyframes fadeUp { 49 | 0% { opacity: 1; transform: translateY(0); } 50 | 100% { opacity: 0; transform: translateY(-20px); max-height: 0px; padding: 0; margin: 0; border: 0; } 51 | } 52 | .tabbrowser-tab { 53 | transition: transform 0.3s ease-out, opacity 0.3s ease-out, max-height 0.5s ease-out, margin 0.5s ease-out, padding 0.5s ease-out; 54 | } 55 | ` 56 | }; 57 | 58 | let commandListenerAdded = false; 59 | 60 | const injectStyles = () => { 61 | let styleElement = document.getElementById('tab-clear-styles'); 62 | if (!styleElement) { 63 | styleElement = Object.assign(document.createElement('style'), { 64 | id: 'tab-clear-styles', 65 | textContent: CONFIG.styles 66 | }); 67 | document.head.appendChild(styleElement); 68 | } else if (styleElement.textContent !== CONFIG.styles) { 69 | styleElement.textContent = CONFIG.styles; 70 | } 71 | }; 72 | 73 | const clearTabs = () => { 74 | try { 75 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace; 76 | if (!currentWorkspaceId) return; 77 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`; 78 | const tabsToClose = []; 79 | for (const tab of gBrowser.tabs) { 80 | const isSameWorkSpace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId; 81 | const groupParent = tab.closest('tab-group'); 82 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false; 83 | const isEmptyZenTab = tab.hasAttribute("zen-empty-tab"); 84 | if (isSameWorkSpace && !tab.selected && !tab.pinned && !isInGroupInCorrectWorkspace && !isEmptyZenTab && tab.isConnected) { 85 | tabsToClose.push(tab); 86 | } 87 | } 88 | if (tabsToClose.length === 0) return; 89 | 90 | tabsToClose.forEach(tab => { 91 | tab.classList.add('tab-closing'); 92 | setTimeout(() => { 93 | if (tab && tab.isConnected) { 94 | try { 95 | gBrowser.removeTab(tab, { animate: false, skipSessionStore: false, closeWindowWithLastTab: false }); 96 | } catch (removeError) { 97 | if (tab && tab.isConnected) { 98 | tab.classList.remove('tab-closing'); 99 | } 100 | } 101 | } 102 | }, 500); 103 | }); 104 | } catch (error) { 105 | console.log(error); 106 | } 107 | }; 108 | 109 | function ensureButtonsExist(container) { 110 | if (!container || !container.isConnected) return; 111 | if (!container.querySelector('#clear-button')) { 112 | try { 113 | const buttonFragment = window.MozXULElement.parseXULToFragment( 114 | `` 115 | ); 116 | if (container.isConnected) { 117 | container.appendChild(buttonFragment.firstChild.cloneNode(true)); 118 | } 119 | } catch (e) { console.log(e); } 120 | } 121 | } 122 | 123 | function addButtonsToAllSeparators() { 124 | const separators = document.querySelectorAll(".vertical-pinned-tabs-container-separator"); 125 | if (separators.length > 0) { 126 | separators.forEach(ensureButtonsExist); 127 | } else { 128 | const periphery = document.querySelector('#tabbrowser-arrowscrollbox-periphery'); 129 | if (periphery && !periphery.querySelector('#clear-button')) { 130 | ensureButtonsExist(periphery); 131 | } 132 | } 133 | } 134 | 135 | function setupCommandsAndListener() { 136 | const zenCommands = document.querySelector("commandset#zenCommandSet"); 137 | if (!zenCommands) return; 138 | if (!zenCommands.querySelector("#cmd_zenClearTabs")) { 139 | try { 140 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild; 141 | zenCommands.appendChild(cmd); 142 | } catch (e) { console.log(e); } 143 | } 144 | if (!commandListenerAdded) { 145 | try { 146 | zenCommands.addEventListener('command', (event) => { 147 | if (event.target.id === "cmd_zenClearTabs") { 148 | clearTabs(); 149 | } 150 | }); 151 | commandListenerAdded = true; 152 | } catch (e) { console.log(e); } 153 | } 154 | } 155 | 156 | function setupZenWorkspaceHooks() { 157 | if (typeof gZenWorkspaces === 'undefined' || typeof gZenWorkspaces.clearButtonHooksApplied !== 'undefined') { 158 | return; 159 | } 160 | gZenWorkspaces.originalClearButtonHooks = { 161 | onTabBrowserInserted: gZenWorkspaces.onTabBrowserInserted, 162 | updateTabsContainers: gZenWorkspaces.updateTabsContainers, 163 | }; 164 | gZenWorkspaces.clearButtonHooksApplied = true; 165 | 166 | gZenWorkspaces.onTabBrowserInserted = function(event) { 167 | if (typeof gZenWorkspaces.originalClearButtonHooks.onTabBrowserInserted === 'function') { 168 | try { gZenWorkspaces.originalClearButtonHooks.onTabBrowserInserted.call(gZenWorkspaces, event); } catch (e) { /* Error handling removed */ } 169 | } 170 | setTimeout(addButtonsToAllSeparators, 150); 171 | }; 172 | 173 | gZenWorkspaces.updateTabsContainers = function(...args) { 174 | if (typeof gZenWorkspaces.originalClearButtonHooks.updateTabsContainers === 'function') { 175 | try { gZenWorkspaces.originalClearButtonHooks.updateTabsContainers.apply(gZenWorkspaces, args); } catch (e) { /* Error handling removed */ } 176 | } 177 | setTimeout(addButtonsToAllSeparators, 150); 178 | }; 179 | } 180 | 181 | function initializeScript() { 182 | let checkCount = 0; 183 | const maxChecks = 30; 184 | const checkInterval = 1000; 185 | 186 | const initCheckInterval = setInterval(() => { 187 | checkCount++; 188 | const separatorExists = !!document.querySelector(".vertical-pinned-tabs-container-separator"); 189 | const peripheryExists = !!document.querySelector('#tabbrowser-arrowscrollbox-periphery'); 190 | const commandSetExists = !!document.querySelector("commandset#zenCommandSet"); 191 | const gBrowserReady = typeof gBrowser !== 'undefined' && gBrowser.tabContainer; 192 | const gZenWorkspacesReady = typeof gZenWorkspaces !== 'undefined' && typeof gZenWorkspaces.activeWorkspace !== 'undefined'; 193 | const ready = gBrowserReady && commandSetExists && (separatorExists || peripheryExists) && gZenWorkspacesReady; 194 | 195 | if (ready) { 196 | clearInterval(initCheckInterval); 197 | const finalSetup = () => { 198 | try { 199 | injectStyles(); 200 | setupCommandsAndListener(); 201 | addButtonsToAllSeparators(); 202 | setupZenWorkspaceHooks(); 203 | } catch (e) { console.log(e); } 204 | }; 205 | if ('requestIdleCallback' in window) { 206 | requestIdleCallback(finalSetup, { timeout: 2000 }); 207 | } else { 208 | setTimeout(finalSetup, 500); 209 | } 210 | } else if (checkCount > maxChecks) { 211 | clearInterval(initCheckInterval); 212 | } 213 | }, checkInterval); 214 | } 215 | 216 | if (document.readyState === "complete" || document.readyState === "interactive") { 217 | initializeScript(); 218 | } else { 219 | window.addEventListener("load", initializeScript, { once: true }); 220 | } 221 | 222 | })(); 223 | -------------------------------------------------------------------------------- /image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/488dc5c202745447fa08836e0719e4b04063efc7/image.jpg -------------------------------------------------------------------------------- /preferences.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "property": "extensions.tabgroups.ai_model", 4 | "label": "Choose AI - Gemini or Ollama", 5 | "type": "dropdown", 6 | "placeholder": false, 7 | "value": "number", 8 | "default": 1, 9 | "options": [ 10 | { 11 | "value": 1, 12 | "label": "Gemini" 13 | }, 14 | { 15 | "value": 2, 16 | "label": "Ollama" 17 | } 18 | ] 19 | }, 20 | { 21 | "type": "separator", 22 | "label": "Gemini", 23 | "id": "gemini-separator", 24 | "conditions": [ 25 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } } 26 | ] 27 | }, 28 | { 29 | "property": "extensions.tabgroups.gemini_api_key", 30 | "label": "Gemini API Key", 31 | "type": "string", 32 | "placeholder": "Enter your Gemini API key here", 33 | "conditions": [ 34 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } } 35 | ] 36 | }, 37 | { 38 | "property": "extensions.tabgroups.gemini_model", 39 | "label": "Gemini Model to Use For Renaming", 40 | "default": "gemini-1.5-flash-latest", 41 | "type": "string", 42 | "placeholder": "Enter your Gemini Model here", 43 | "conditions": [ 44 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } } 45 | ] 46 | }, 47 | { 48 | "type": "separator", 49 | "label": "Ollama", 50 | "id": "ollama-separator", 51 | "conditions": [ 52 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } } 53 | ] 54 | }, 55 | { 56 | "property": "extensions.tabgroups.ollama_endpoint", 57 | "label": "Ollama Endpoint URL", 58 | "default": "http://localhost:11434/api/generate", 59 | "type": "string", 60 | "placeholder": "Enter your Ollama Endpoint URL here", 61 | "conditions": [ 62 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } } 63 | ] 64 | }, 65 | { 66 | "property": "extensions.tabgroups.ollama_model", 67 | "label": "Ollama Model to Use For Renaming", 68 | "default": "llama3.1:latest", 69 | "type": "string", 70 | "placeholder": "Enter your Ollama Model here", 71 | "conditions": [ 72 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } } 73 | ] 74 | } 75 | ] 76 | -------------------------------------------------------------------------------- /tab_sort_clear.uc.js: -------------------------------------------------------------------------------- 1 | // VERSION 4.10.0 (Added tab multi-selection sort capability) 2 | (() => { 3 | // --- Configuration --- 4 | 5 | // Preference Key for AI Model Selection 6 | const AI_MODEL_PREF = "extensions.tabgroups.ai_model"; // '1' for Gemini, '2' for Ollama 7 | // Preference Keys for AI Config 8 | const OLLAMA_ENDPOINT_PREF = "extensions.tabgroups.ollama_endpoint"; 9 | const OLLAMA_MODEL_PREF = "extensions.tabgroups.ollama_model"; 10 | const GEMINI_API_KEY_PREF = "extensions.tabgroups.gemini_api_key"; 11 | const GEMINI_MODEL_PREF = "extensions.tabgroups.gemini_model"; 12 | 13 | // Helper function to read preferences with fallbacks 14 | const getPref = (prefName, defaultValue = "") => { 15 | try { 16 | const prefService = Services.prefs; 17 | if (prefService.prefHasUserValue(prefName)) { 18 | switch (prefService.getPrefType(prefName)) { 19 | case prefService.PREF_STRING: 20 | return prefService.getStringPref(prefName); 21 | case prefService.PREF_INT: 22 | return prefService.getIntPref(prefName); 23 | case prefService.PREF_BOOL: 24 | return prefService.getBoolPref(prefName); 25 | } 26 | } 27 | } catch (e) { 28 | console.warn(`Failed to read preference ${prefName}:`, e); 29 | } 30 | return defaultValue; 31 | }; 32 | 33 | // Read preference values 34 | const AI_MODEL_VALUE = getPref(AI_MODEL_PREF, "1"); // Default to Gemini 35 | const OLLAMA_ENDPOINT_VALUE = getPref(OLLAMA_ENDPOINT_PREF, "http://localhost:11434/api/generate"); 36 | const OLLAMA_MODEL_VALUE = getPref(OLLAMA_MODEL_PREF, "llama3.2"); 37 | const GEMINI_API_KEY_VALUE = getPref(GEMINI_API_KEY_PREF, ""); 38 | const GEMINI_MODEL_VALUE = getPref(GEMINI_MODEL_PREF, "gemini-1.5-flash"); 39 | 40 | const CONFIG = { 41 | apiConfig: { 42 | ollama: { 43 | endpoint: OLLAMA_ENDPOINT_VALUE, 44 | enabled: AI_MODEL_VALUE == "2", 45 | model: OLLAMA_MODEL_VALUE, 46 | promptTemplateBatch: `Analyze the following numbered list of tab data (Title, URL, Description) and assign a concise category (1-2 words, Title Case) for EACH tab. 47 | 48 | Existing Categories (Use these EXACT names if a tab fits): 49 | {EXISTING_CATEGORIES_LIST} 50 | 51 | --- 52 | Instructions for Assignment: 53 | 1. **Prioritize Existing:** For each tab below, determine if it clearly belongs to one of the 'Existing Categories'. Base this primarily on the URL/Domain, then Title/Description. If it fits, you MUST use the EXACT category name provided in the 'Existing Categories' list. DO NOT create a minor variation (e.g., if 'Project Docs' exists, use that, don't create 'Project Documentation'). 54 | 2. **Assign New Category (If Necessary):** Only if a tab DOES NOT fit an existing category, assign the best NEW concise category (1-2 words, Title Case). 55 | * PRIORITIZE the URL/Domain (e.g., 'GitHub', 'YouTube', 'StackOverflow'). 56 | * Use Title/Description for specifics or generic domains. 57 | 3. **Consistency is CRITICAL:** Use the EXACT SAME category name for all tabs belonging to the same logical group (whether assigned an existing or a new category). If multiple tabs point to 'google.com/search?q=recipes', categorize them consistently (e.g., 'Google Search' or 'Recipes', but use the same one for all). 58 | 4. **Format:** 1-2 words, Title Case. 59 | 60 | --- 61 | Input Tab Data: 62 | {TAB_DATA_LIST} 63 | 64 | --- 65 | Instructions for Output: 66 | 1. Output ONLY the category names. 67 | 2. Provide EXACTLY ONE category name per line. 68 | 3. The number of lines in your output MUST EXACTLY MATCH the number of tabs in the Input Tab Data list above. 69 | 4. DO NOT include numbering, explanations, apologies, markdown formatting, or any surrounding text like "Output:" or backticks. 70 | 5. Just the list of categories, separated by newlines. 71 | --- 72 | 73 | Output:` 74 | }, 75 | gemini: { 76 | enabled: AI_MODEL_VALUE == "1", 77 | apiKey: GEMINI_API_KEY_VALUE, 78 | model: GEMINI_MODEL_VALUE, 79 | // Endpoint structure: https://generativelanguage.googleapis.com/v1beta/models/{model}:{method} 80 | apiBaseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/', 81 | promptTemplateBatch: `Analyze the following numbered list of tab data (Title, URL, Description) and assign a concise category (1-2 words, Title Case) for EACH tab. 82 | 83 | Existing Categories (Use these EXACT names if a tab fits): 84 | {EXISTING_CATEGORIES_LIST} 85 | 86 | --- 87 | Instructions for Assignment: 88 | 1. **Prioritize Existing:** For each tab below, determine if it clearly belongs to one of the 'Existing Categories'. Base this primarily on the URL/Domain, then Title/Description. If it fits, you MUST use the EXACT category name provided in the 'Existing Categories' list. DO NOT create a minor variation (e.g., if 'Project Docs' exists, use that, don't create 'Project Documentation'). 89 | 2. **Assign New Category (If Necessary):** Only if a tab DOES NOT fit an existing category, assign the best NEW concise category (1-2 words, Title Case). 90 | * PRIORITIZE the URL/Domain (e.g., 'GitHub', 'YouTube', 'StackOverflow'). 91 | * Use Title/Description for specifics or generic domains. 92 | 3. **Consistency is CRITICAL:** Use the EXACT SAME category name for all tabs belonging to the same logical group (whether assigned an existing or a new category). If multiple tabs point to 'google.com/search?q=recipes', categorize them consistently (e.g., 'Google Search' or 'Recipes', but use the same one for all). 93 | 4. **Format:** 1-2 words, Title Case. 94 | 95 | --- 96 | Input Tab Data: 97 | {TAB_DATA_LIST} 98 | 99 | --- 100 | Instructions for Output: 101 | 1. Output ONLY the category names. 102 | 2. Provide EXACTLY ONE category name per line. 103 | 3. The number of lines in your output MUST EXACTLY MATCH the number of tabs in the Input Tab Data list above. 104 | 4. DO NOT include numbering, explanations, apologies, markdown formatting, or any surrounding text like "Output:" or backticks. 105 | 5. Just the list of categories, separated by newlines. 106 | --- 107 | 108 | Output:`, 109 | generationConfig: { 110 | temperature: 0.1, // Low temp for consistency 111 | // maxOutputTokens: calculated dynamically based on tab count 112 | candidateCount: 1, // Only need one best answer 113 | // stopSequences: ["---"] // Optional: define sequences to stop generation 114 | } 115 | }, 116 | customApi: { 117 | enabled: false, 118 | // ... (custom API config if needed) 119 | } 120 | }, 121 | groupColors: [ 122 | "var(--tab-group-color-blue)", "var(--tab-group-color-red)", "var(--tab-group-color-yellow)", 123 | "var(--tab-group-color-green)", "var(--tab-group-color-pink)", "var(--tab-group-color-purple)", 124 | "var(--tab-group-color-orange)", "var(--tab-group-color-cyan)", "var(--tab-group-color-gray)" 125 | ], 126 | groupColorNames: [ 127 | "blue", "red", "yellow", "green", "pink", "purple", "orange", "cyan", "gray" 128 | ], 129 | preGroupingThreshold: 2, // Min tabs for keyword/hostname pre-grouping 130 | titleKeywordStopWords: new Set([ 131 | 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'of', 132 | 'is', 'am', 'are', 'was', 'were', 'be', 'being', 'been', 'has', 'have', 'had', 'do', 'does', 'did', 133 | 'how', 'what', 'when', 'where', 'why', 'which', 'who', 'whom', 'whose', 134 | 'new', 'tab', 'untitled', 'page', 'home', 'com', 'org', 'net', 'io', 'dev', 'app', 135 | 'get', 'set', 'list', 'view', 'edit', 'create', 'update', 'delete', 136 | 'my', 'your', 'his', 'her', 'its', 'our', 'their', 'me', 'you', 'him', 'her', 'it', 'us', 'them', 137 | 'about', 'search', 'results', 'posts', 'index', 'dashboard', 'profile', 'settings', 138 | 'official', 'documentation', 'docs', 'wiki', 'help', 'support', 'faq', 'guide', 139 | 'error', 'login', 'signin', 'sign', 'up', 'out', 'welcome', 'loading', 'vs', 'using', 'code', 140 | 'microsoft', 'google', 'apple', 'amazon', 'facebook', 'twitter' 141 | ]), 142 | minKeywordLength: 3, 143 | consolidationDistanceThreshold: 2, // Max Levenshtein distance to merge similar group names 144 | styles: ` 145 | #sort-button { 146 | opacity: 0; 147 | transition: opacity 0.1s ease-in-out; 148 | position: absolute; 149 | right: 55px; /* Positioned to the left of the clear button */ 150 | font-size: 12px; 151 | width: 60px; 152 | pointer-events: auto; 153 | align-self: end; 154 | appearance: none; 155 | margin-top: -8px; 156 | padding: 1px; 157 | color: gray; 158 | label { display: block; } 159 | } 160 | #sort-button:hover { 161 | opacity: 1; 162 | color: white; 163 | border-radius: 4px; 164 | } 165 | 166 | #clear-button { 167 | opacity: 0; 168 | transition: opacity 0.1s ease-in-out; 169 | position: absolute; 170 | right: 0; 171 | font-size: 12px; 172 | width: 60px; 173 | pointer-events: auto; 174 | align-self: end; 175 | appearance: none; 176 | margin-top: -8px; 177 | padding: 1px; 178 | color: grey; 179 | label { display: block; } 180 | } 181 | #clear-button:hover { 182 | opacity: 1; 183 | color: white; 184 | border-radius: 4px; 185 | } 186 | /* Separator Base Style (Ensures background is animatable) */ 187 | .vertical-pinned-tabs-container-separator { 188 | display: flex !important; 189 | flex-direction: column; 190 | margin-left: 0; 191 | min-height: 1px; 192 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1)); /* Subtle base color */ 193 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out; /* Add background transition */ 194 | } 195 | /* Separator Hover Logic */ 196 | .vertical-pinned-tabs-container-separator:has(#sort-button):has(#clear-button):hover { 197 | width: calc(100% - 115px); /* 60px (clear) + 55px (sort) */ 198 | margin-right: auto; 199 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); /* Slightly lighter on hover */ 200 | } 201 | /* Hover when ONLY SORT is present */ 202 | .vertical-pinned-tabs-container-separator:has(#sort-button):not(:has(#clear-button)):hover { 203 | width: calc(100% - 65px); /* Only space for sort + margin */ 204 | margin-right: auto; 205 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 206 | } 207 | /* Hover when ONLY CLEAR is present */ 208 | .vertical-pinned-tabs-container-separator:not(:has(#sort-button)):has(#clear-button):hover { 209 | width: calc(100% - 60px); /* Only space for clear */ 210 | margin-right: auto; 211 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 212 | } 213 | /* Show BOTH buttons on separator hover */ 214 | .vertical-pinned-tabs-container-separator:hover #sort-button, 215 | .vertical-pinned-tabs-container-separator:hover #clear-button { 216 | opacity: 1; 217 | } 218 | 219 | /* When theres no Pinned Tabs */ 220 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator { 221 | display: flex !important; 222 | flex-direction: column !important; 223 | margin-left: 0 !important; 224 | margin-top: 5px !important; 225 | margin-bottom: 8px !important; 226 | min-height: 1px !important; 227 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1)); /* Subtle base color */ 228 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out; /* Add background transition */ 229 | } 230 | /* Hover when BOTH buttons are potentially visible (No Pinned) */ 231 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#sort-button):has(#clear-button):hover { 232 | width: calc(100% - 115px); /* 60px (clear) + 55px (sort) */ 233 | margin-right: auto; 234 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 235 | } 236 | /* Hover when ONLY SORT is present (No Pinned) */ 237 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#sort-button):not(:has(#clear-button)):hover { 238 | width: calc(100% - 65px); /* Only space for sort + margin */ 239 | margin-right: auto; 240 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 241 | } 242 | /* Hover when ONLY CLEAR is present (No Pinned) */ 243 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:not(:has(#sort-button)):has(#clear-button):hover { 244 | width: calc(100% - 60px); /* Only space for clear */ 245 | margin-right: auto; 246 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); 247 | } 248 | /* Show BOTH buttons on separator hover (No Pinned) */ 249 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #sort-button, 250 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #clear-button { 251 | opacity: 1; 252 | } 253 | 254 | /* Separator Pulsing Animation */ 255 | @keyframes pulse-separator-bg { 256 | 0% { background-color: var(--lwt-toolbarbutton-border-color, rgb(255, 141, 141)); } 257 | 50% { background-color: var(--lwt-toolbarbutton-hover-background, rgba(137, 178, 255, 0.91)); } /* Brighter pulse color */ 258 | 100% { background-color: var(--lwt-toolbarbutton-border-color, rgb(142, 253, 238)); } 259 | } 260 | 261 | .separator-is-sorting { 262 | animation: pulse-separator-bg 1.5s ease-in-out infinite; 263 | will-change: background-color; 264 | } 265 | 266 | /* Tab Animations */ 267 | .tab-closing { 268 | animation: fadeUp 0.5s forwards; 269 | } 270 | @keyframes fadeUp { 271 | 0% { opacity: 1; transform: translateY(0); } 272 | 100% { opacity: 0; transform: translateY(-20px); max-height: 0px; padding: 0; margin: 0; border: 0; } /* Add max-height */ 273 | } 274 | @keyframes loading-pulse-tab { 275 | 0%, 100% { opacity: 0.6; } 276 | 50% { opacity: 1; } 277 | } 278 | .tab-is-sorting .tab-icon-image, 279 | .tab-is-sorting .tab-label { 280 | animation: loading-pulse-tab 1.5s ease-in-out infinite; 281 | will-change: opacity; 282 | } 283 | .tabbrowser-tab { 284 | transition: transform 0.3s ease-out, opacity 0.3s ease-out, max-height 0.5s ease-out, margin 0.5s ease-out, padding 0.5s ease-out; /* Add transition for closing */ 285 | } 286 | ` 287 | }; 288 | 289 | // --- Globals & State --- 290 | let groupColorIndex = 0; 291 | let isSorting = false; 292 | let commandListenerAdded = false; 293 | 294 | // --- Helper Functions --- 295 | 296 | const injectStyles = () => { 297 | let styleElement = document.getElementById('tab-sort-clear-styles'); 298 | if (styleElement) { 299 | if (styleElement.textContent !== CONFIG.styles) { 300 | styleElement.textContent = CONFIG.styles; 301 | console.log("BUTTONS: Styles updated."); 302 | } 303 | return; 304 | } 305 | styleElement = Object.assign(document.createElement('style'), { 306 | id: 'tab-sort-clear-styles', 307 | textContent: CONFIG.styles 308 | }); 309 | document.head.appendChild(styleElement); 310 | console.log("BUTTONS: Styles injected."); 311 | }; 312 | 313 | const getTabData = (tab) => { 314 | if (!tab || !tab.isConnected) { 315 | return { title: 'Invalid Tab', url: '', hostname: '', description: '' }; 316 | } 317 | let title = 'Untitled Page'; 318 | let fullUrl = ''; 319 | let hostname = ''; 320 | let description = ''; 321 | 322 | try { 323 | const originalTitle = tab.getAttribute('label') || tab.querySelector('.tab-label, .tab-text')?.textContent || ''; 324 | const browser = tab.linkedBrowser || tab._linkedBrowser || gBrowser?.getBrowserForTab?.(tab); 325 | 326 | if (browser?.currentURI?.spec && !browser.currentURI.spec.startsWith('about:')) { 327 | try { 328 | const currentURL = new URL(browser.currentURI.spec); 329 | fullUrl = currentURL.href; 330 | hostname = currentURL.hostname.replace(/^www\./, ''); 331 | } catch (e) { 332 | hostname = 'Invalid URL'; 333 | fullUrl = browser?.currentURI?.spec || 'Invalid URL'; 334 | } 335 | } else if (browser?.currentURI?.spec) { 336 | fullUrl = browser.currentURI.spec; 337 | hostname = 'Internal Page'; 338 | } 339 | 340 | if (!originalTitle || originalTitle === 'New Tab' || originalTitle === 'about:blank' || originalTitle === 'Loading...' || originalTitle.startsWith('http:') || originalTitle.startsWith('https:')) { 341 | if (hostname && hostname !== 'Invalid URL' && hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== 'Internal Page') { 342 | title = hostname; 343 | } else { 344 | try { 345 | const pathSegment = new URL(fullUrl).pathname.split('/')[1]; 346 | if (pathSegment) { 347 | title = pathSegment; 348 | } 349 | } catch { /* ignore */ } 350 | } 351 | } else { 352 | title = originalTitle.trim(); 353 | } 354 | title = title || 'Untitled Page'; 355 | 356 | try { 357 | if (browser && browser.contentDocument) { 358 | const metaDescElement = browser.contentDocument.querySelector('meta[name="description"]'); 359 | if (metaDescElement) { 360 | description = metaDescElement.getAttribute('content')?.trim() || ''; 361 | description = description.substring(0, 200); 362 | } 363 | } 364 | } catch (contentError) { 365 | /* ignore permission errors */ 366 | } 367 | } catch (e) { 368 | console.error('Error getting tab data for tab:', tab, e); 369 | title = 'Error Processing Tab'; 370 | } 371 | return { title: title, url: fullUrl, hostname: hostname || 'N/A', description: description || 'N/A' }; 372 | }; 373 | 374 | const toTitleCase = (str) => { 375 | if (!str) return ""; // Added guard for null/undefined input 376 | return str.toLowerCase() 377 | .split(' ') 378 | .map(word => word.charAt(0).toUpperCase() + word.slice(1)) 379 | .join(' '); 380 | }; 381 | 382 | const processTopic = (text) => { 383 | if (!text) return "Uncategorized"; 384 | 385 | const originalTextTrimmedLower = text.trim().toLowerCase(); 386 | const normalizationMap = { 387 | 'github.com': 'GitHub', 'github': 'GitHub', 388 | 'stackoverflow.com': 'Stack Overflow', 'stack overflow': 'Stack Overflow', 'stackoverflow': 'Stack Overflow', 389 | 'google docs': 'Google Docs', 'docs.google.com': 'Google Docs', 390 | 'google drive': 'Google Drive', 'drive.google.com': 'Google Drive', 391 | 'youtube.com': 'YouTube', 'youtube': 'YouTube', 392 | 'reddit.com': 'Reddit', 'reddit': 'Reddit', 393 | 'chatgpt': 'ChatGPT', 'openai.com': 'OpenAI', 394 | 'gmail': 'Gmail', 'mail.google.com': 'Gmail', 395 | 'aws': 'AWS', 'amazon web services': 'AWS', 396 | 'pinterest.com': 'Pinterest', 'pinterest': 'Pinterest', 397 | 'developer.mozilla.org': 'MDN Web Docs', 'mdn': 'MDN Web Docs', 'mozilla': 'Mozilla' 398 | }; 399 | 400 | if (normalizationMap[originalTextTrimmedLower]) { 401 | return normalizationMap[originalTextTrimmedLower]; 402 | } 403 | 404 | let processedText = text.replace(/^(Category is|The category is|Topic:)\s*"?/i, ''); 405 | processedText = processedText.replace(/^\s*[\d.\-*]+\s*/, ''); 406 | let words = processedText.trim().split(/\s+/); 407 | let category = words.slice(0, 2).join(' '); 408 | category = category.replace(/["'*().:;,]/g, ''); 409 | 410 | return toTitleCase(category).substring(0, 40) || "Uncategorized"; 411 | }; 412 | 413 | const extractTitleKeywords = (title) => { 414 | if (!title || typeof title !== 'string') { 415 | return new Set(); 416 | } 417 | const cleanedTitle = title.toLowerCase() 418 | .replace(/[-_]/g, ' ') 419 | .replace(/[^\w\s]/g, '') 420 | .replace(/\s+/g, ' ') 421 | .trim(); 422 | const words = cleanedTitle.split(' '); 423 | const keywords = new Set(); 424 | 425 | for (const word of words) { 426 | if (word.length >= CONFIG.minKeywordLength && !CONFIG.titleKeywordStopWords.has(word) && !/^\d+$/.test(word)) { 427 | keywords.add(word); 428 | } 429 | } 430 | return keywords; 431 | }; 432 | 433 | const getNextGroupColorName = () => { 434 | const colorName = CONFIG.groupColorNames[groupColorIndex % CONFIG.groupColorNames.length]; 435 | groupColorIndex++; 436 | return colorName; 437 | }; 438 | 439 | const findGroupElement = (topicName, workspaceId) => { 440 | const sanitizedTopicName = topicName.trim(); 441 | if (!sanitizedTopicName) return null; 442 | 443 | // Escape special characters for CSS selector 444 | const safeSelectorTopicName = sanitizedTopicName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 445 | 446 | // Use :has() to find the group by label that CONTAINS a tab with the correct workspace ID 447 | const selector = `tab-group[label="${safeSelectorTopicName}"]:has(tab[zen-workspace-id="${workspaceId}"])`; 448 | 449 | try { 450 | // console.log(`findGroupElement: Searching with selector: ${selector}`); // Optional debug log 451 | return document.querySelector(selector); 452 | } catch (e) { 453 | console.error(`Error finding group with selector: ${selector}`, e); 454 | return null; 455 | } 456 | }; 457 | 458 | const levenshteinDistance = (a, b) => { 459 | if (!a || !b) return Math.max(a?.length ?? 0, b?.length ?? 0); 460 | a = a.toLowerCase(); 461 | b = b.toLowerCase(); 462 | if (a.length === 0) return b.length; 463 | if (b.length === 0) return a.length; 464 | 465 | const matrix = []; 466 | for (let i = 0; i <= b.length; i++) { 467 | matrix[i] = [i]; 468 | } 469 | for (let j = 0; j <= a.length; j++) { 470 | matrix[0][j] = j; 471 | } 472 | 473 | for (let i = 1; i <= b.length; i++) { 474 | for (let j = 1; j <= a.length; j++) { 475 | const cost = a[i - 1] === b[j - 1] ? 0 : 1; 476 | matrix[i][j] = Math.min( 477 | matrix[i - 1][j] + 1, // Deletion 478 | matrix[i][j - 1] + 1, // Insertion 479 | matrix[i - 1][j - 1] + cost // Substitution 480 | ); 481 | } 482 | } 483 | return matrix[b.length][a.length]; 484 | }; 485 | // --- End Helper Functions --- 486 | 487 | 488 | // --- AI Interaction --- 489 | const askAIForMultipleTopics = async (tabs, existingCategoryNames = []) => { 490 | const validTabs = tabs.filter(tab => tab && tab.isConnected); 491 | if (!validTabs || validTabs.length === 0) { 492 | return []; 493 | } 494 | 495 | const { gemini, ollama } = CONFIG.apiConfig; 496 | let result = []; 497 | let apiChoice = "None"; 498 | 499 | validTabs.forEach(tab => tab.classList.add('tab-is-sorting')); 500 | 501 | try { 502 | if (gemini.enabled) { 503 | apiChoice = "Gemini"; 504 | if (!gemini.apiKey) { 505 | throw new Error("Gemini API key is missing or not set. Please paste your key in the CONFIG section."); 506 | } 507 | console.log(`Batch AI (Gemini): Requesting categories for ${validTabs.length} tabs, considering ${existingCategoryNames.length} existing categories...`); 508 | 509 | const tabDataArray = validTabs.map(getTabData); 510 | const formattedTabDataList = tabDataArray.map((data, index) => 511 | `${index + 1}.\nTitle: "${data.title}"\nURL: "${data.url}"\nDescription: "${data.description}"` 512 | ).join('\n\n'); 513 | const formattedExistingCategories = existingCategoryNames.length > 0 514 | ? existingCategoryNames.map(name => `- ${name}`).join('\n') 515 | : "None"; 516 | 517 | const prompt = gemini.promptTemplateBatch 518 | .replace("{EXISTING_CATEGORIES_LIST}", formattedExistingCategories) 519 | .replace("{TAB_DATA_LIST}", formattedTabDataList); 520 | 521 | const apiUrl = `${gemini.apiBaseUrl}${gemini.model}:generateContent?key=${gemini.apiKey}`; 522 | const headers = { 'Content-Type': 'application/json' }; 523 | const estimatedOutputTokens = Math.max(256, validTabs.length * 16); // Dynamic estimation 524 | 525 | const requestBody = { 526 | contents: [{ parts: [{ text: prompt }] }], 527 | generationConfig: { 528 | ...gemini.generationConfig, 529 | maxOutputTokens: estimatedOutputTokens 530 | } 531 | }; 532 | 533 | const response = await fetch(apiUrl, { 534 | method: 'POST', 535 | headers: headers, 536 | body: JSON.stringify(requestBody) 537 | }); 538 | 539 | if (!response.ok) { 540 | let errorText = `API Error ${response.status}`; 541 | try { 542 | const errorData = await response.json(); 543 | errorText += `: ${errorData?.error?.message || response.statusText}`; 544 | console.error("Gemini API Error Response:", errorData); 545 | } catch (parseError) { 546 | errorText += `: ${response.statusText}`; 547 | const rawText = await response.text().catch(() => ''); 548 | console.error("Gemini API Error Raw Response:", rawText); 549 | } 550 | if (response.status === 400 && errorText.includes("API key not valid")) { 551 | throw new Error(`Gemini API Error: API key is not valid. Please check the key in the script configuration. (${errorText})`); 552 | } 553 | if (response.status === 403) { 554 | throw new Error(`Gemini API Error: Permission denied. Ensure the API key has the 'generativelanguage.models.generateContent' permission enabled. (${errorText})`); 555 | } 556 | throw new Error(errorText); 557 | } 558 | 559 | const data = await response.json(); 560 | const aiText = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); 561 | 562 | if (!aiText) { 563 | console.error("Gemini API: Empty or unexpected response structure.", data); 564 | if (data?.promptFeedback?.blockReason) { 565 | throw new Error(`Gemini API Error: Request blocked due to ${data.promptFeedback.blockReason}. Check safety ratings: ${JSON.stringify(data.promptFeedback.safetyRatings)}`); 566 | } 567 | if (data?.candidates?.[0]?.finishReason && data.candidates[0].finishReason !== "STOP") { 568 | throw new Error(`Gemini API Error: Generation finished unexpectedly due to ${data.candidates[0].finishReason}.`); 569 | } 570 | throw new Error("Gemini API response content is missing or empty."); 571 | } 572 | 573 | console.log("Gemini Raw Response Text:\n---\n", aiText, "\n---"); 574 | const lines = aiText.split('\n').map(line => line.trim()).filter(Boolean); 575 | 576 | if (lines.length !== validTabs.length) { 577 | console.warn(`Batch AI (Gemini): Mismatch! Expected ${validTabs.length} topics, received ${lines.length}.`); 578 | if (validTabs.length === 1 && lines.length > 0) { 579 | const firstLineTopic = processTopic(lines[0]); 580 | console.warn(` -> Mismatch Correction (Single Tab): Using first line "${lines[0]}" -> Topic: "${firstLineTopic}"`); 581 | result = [{ tab: validTabs[0], topic: firstLineTopic }]; 582 | } else if (lines.length > validTabs.length) { 583 | console.warn(` -> Mismatch Correction (Too Many Lines): Truncating response to ${validTabs.length} lines.`); 584 | const processedTopics = lines.slice(0, validTabs.length).map(processTopic); 585 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] })); 586 | } else { 587 | console.warn(` -> Fallback (Too Few Lines): Assigning remaining tabs "Uncategorized".`); 588 | const processedTopics = lines.map(processTopic); 589 | result = validTabs.map((tab, index) => ({ 590 | tab: tab, 591 | topic: index < processedTopics.length ? processedTopics[index] : "Uncategorized" 592 | })); 593 | } 594 | } else { 595 | const processedTopics = lines.map(processTopic); 596 | console.log("Batch AI (Gemini): Processed Topics:", processedTopics); 597 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] })); 598 | } 599 | 600 | } else if (ollama.enabled) { 601 | // --- OLLAMA LOGIC --- 602 | apiChoice = "Ollama"; 603 | console.log(`Batch AI (Ollama): Requesting categories for ${validTabs.length} tabs, considering ${existingCategoryNames.length} existing categories...`); 604 | let apiUrl = ollama.endpoint; 605 | let headers = { 'Content-Type': 'application/json' }; 606 | 607 | const tabDataArray = validTabs.map(getTabData); 608 | const formattedTabDataList = tabDataArray.map((data, index) => 609 | `${index + 1}.\nTitle: "${data.title}"\nURL: "${data.url}"\nDescription: "${data.description}"` 610 | ).join('\n\n'); 611 | const formattedExistingCategories = existingCategoryNames.length > 0 612 | ? existingCategoryNames.map(name => `- ${name}`).join('\n') 613 | : "None"; 614 | 615 | const prompt = ollama.promptTemplateBatch 616 | .replace("{EXISTING_CATEGORIES_LIST}", formattedExistingCategories) 617 | .replace("{TAB_DATA_LIST}", formattedTabDataList); 618 | 619 | const requestBody = { 620 | model: ollama.model, 621 | prompt: prompt, 622 | stream: false, 623 | options: { temperature: 0.1, num_predict: validTabs.length * 15 } // Dynamic estimation 624 | }; 625 | 626 | const response = await fetch(apiUrl, { 627 | method: 'POST', 628 | headers: headers, 629 | body: JSON.stringify(requestBody) 630 | }); 631 | 632 | if (!response.ok) { 633 | const errorText = await response.text().catch(() => 'Unknown API error reason'); 634 | throw new Error(`Ollama API Error ${response.status}: ${errorText}`); 635 | } 636 | 637 | const data = await response.json(); 638 | let aiText = data.response?.trim(); 639 | 640 | if (!aiText) { 641 | throw new Error("Ollama: Empty API response"); 642 | } 643 | 644 | const lines = aiText.split('\n').map(line => line.trim()).filter(Boolean); 645 | 646 | if (lines.length !== validTabs.length) { 647 | console.warn(`Batch AI (Ollama): Mismatch! Expected ${validTabs.length} topics, received ${lines.length}. AI Response:\n${aiText}`); 648 | if (validTabs.length === 1 && lines.length > 0) { 649 | const firstLineTopic = processTopic(lines[0]); 650 | console.warn(` -> Mismatch Correction (Single Tab): Using first line "${lines[0]}" -> Topic: "${firstLineTopic}"`); 651 | result = [{ tab: validTabs[0], topic: firstLineTopic }]; 652 | } else if (lines.length > validTabs.length) { 653 | console.warn(` -> Mismatch Correction (Too Many Lines): Truncating response to ${validTabs.length} lines.`); 654 | const processedTopics = lines.slice(0, validTabs.length).map(processTopic); 655 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] })); 656 | } else { 657 | console.warn(` -> Fallback (Too Few Lines): Assigning remaining tabs "Uncategorized".`); 658 | const processedTopics = lines.map(processTopic); 659 | result = validTabs.map((tab, index) => ({ 660 | tab: tab, 661 | topic: index < processedTopics.length ? processedTopics[index] : "Uncategorized" 662 | })); 663 | } 664 | } else { 665 | const processedTopics = lines.map(processTopic); 666 | console.log("Batch AI (Ollama): Processed Topics:", processedTopics); 667 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] })); 668 | } 669 | } else { 670 | throw new Error("No AI API is enabled in the configuration (Gemini or Ollama)."); 671 | } 672 | return result; 673 | } catch (error) { 674 | console.error(`Batch AI (${apiChoice}): Error getting topics:`, error); 675 | // Return "Uncategorized" for all tabs on error 676 | return validTabs.map(tab => ({ tab, topic: "Uncategorized" })); 677 | } finally { 678 | // Remove sorting indicator after a short delay 679 | setTimeout(() => { 680 | validTabs.forEach(tab => { 681 | if (tab && tab.isConnected) { 682 | tab.classList.remove('tab-is-sorting'); 683 | } 684 | }); 685 | }, 200); 686 | } 687 | }; 688 | // --- End AI Interaction --- 689 | 690 | // --- Main Sorting Function --- 691 | const sortTabsByTopic = async () => { 692 | if (isSorting) { 693 | console.log("Sorting already in progress."); 694 | return; 695 | } 696 | isSorting = true; 697 | 698 | // Check for multiple tab selection 699 | const selectedTabs = gBrowser.selectedTabs; 700 | const isSortingSelectedTabs = selectedTabs.length > 1; 701 | const actionType = isSortingSelectedTabs ? "selected tabs" : "all ungrouped tabs"; 702 | 703 | console.log(`Starting tab sort (${actionType} mode) - (v4.10.0 - Flexible Group Selector)...`); 704 | 705 | let separatorsToSort = []; // Keep track of separators to remove class later 706 | try { 707 | separatorsToSort = document.querySelectorAll('.vertical-pinned-tabs-container-separator'); 708 | if(separatorsToSort.length > 0) { 709 | console.log("Applying sorting indicator to separator(s)..."); 710 | separatorsToSort.forEach(sep => sep.classList.add('separator-is-sorting')); 711 | } else { 712 | console.warn("Could not find separator element to apply sorting indicator."); 713 | } 714 | 715 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace; 716 | if (!currentWorkspaceId) { 717 | console.error("Cannot get current workspace ID."); 718 | // No need to set isSorting = false here, finally block handles it 719 | return; // Exit early 720 | } 721 | 722 | // --- Step 1: Get ALL Existing Group Names for Context --- 723 | const allExistingGroupNames = new Set(); 724 | // CORRECTED SELECTOR using :has() 725 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`; 726 | console.log("Querying for groups using selector:", groupSelector); 727 | 728 | document.querySelectorAll(groupSelector).forEach(groupEl => { 729 | const label = groupEl.getAttribute('label'); 730 | if (label) { 731 | allExistingGroupNames.add(label); 732 | } else { 733 | console.log("Group element found, but missing label attribute:", groupEl); 734 | } 735 | }); 736 | console.log(`Found ${allExistingGroupNames.size} existing group names for context:`, Array.from(allExistingGroupNames)); 737 | 738 | // Determine if we are sorting multiple selected tabs 739 | let initialTabsToSort = []; 740 | if (isSortingSelectedTabs) { 741 | console.log(` -> Sorting ${selectedTabs.length} selected tabs.`); 742 | initialTabsToSort = selectedTabs.filter(tab => { 743 | const isInCorrectWorkspace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId; 744 | // For multi-selected tabs, we allow sorting even if they are already in a group. 745 | return ( 746 | isInCorrectWorkspace && 747 | !tab.pinned && 748 | !tab.hasAttribute('zen-empty-tab') && 749 | tab.isConnected 750 | ); 751 | }); 752 | } else { 753 | // Sort all ungrouped tabs - ensure they aren't already in a group matched by the NEW selector 754 | console.log(" -> Sorting all ungrouped tabs in the current workspace."); 755 | initialTabsToSort = Array.from(gBrowser.tabs).filter(tab => { 756 | const isInCorrectWorkspace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId; 757 | const groupParent = tab.closest('tab-group'); 758 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false; 759 | return ( 760 | isInCorrectWorkspace && // Must be in the target workspace 761 | !tab.pinned && // Not pinned 762 | !tab.hasAttribute('zen-empty-tab') && // Not an empty zen tab 763 | !isInGroupInCorrectWorkspace && // Not already in a group belonging to this workspace 764 | tab.isConnected // Tab is connected 765 | ); 766 | }); 767 | } 768 | 769 | if (initialTabsToSort.length === 0) { 770 | console.log(`No tabs to sort in this workspace (${actionType} mode).`); 771 | // No need to set isSorting = false here, finally block handles it 772 | return; // Exit early 773 | } 774 | console.log(`Found ${initialTabsToSort.length} tabs to process for sorting.`); 775 | 776 | // --- Pre-Grouping Logic (Keywords & Hostnames) --- 777 | const preGroups = {}; 778 | const handledTabs = new Set(); 779 | const tabDataCache = new Map(); 780 | const tabKeywordsCache = new Map(); 781 | 782 | initialTabsToSort.forEach(tab => { 783 | const data = getTabData(tab); 784 | tabDataCache.set(tab, data); 785 | tabKeywordsCache.set(tab, data.title ? extractTitleKeywords(data.title) : new Set()); 786 | }); 787 | 788 | // Keyword pre-grouping 789 | const keywordToTabsMap = new Map(); 790 | initialTabsToSort.forEach(tab => { 791 | const keywords = tabKeywordsCache.get(tab); 792 | if (keywords) { 793 | keywords.forEach(keyword => { 794 | if (!keywordToTabsMap.has(keyword)) { 795 | keywordToTabsMap.set(keyword, new Set()); 796 | } 797 | keywordToTabsMap.get(keyword).add(tab); 798 | }); 799 | } 800 | }); 801 | 802 | const potentialKeywordGroups = []; 803 | keywordToTabsMap.forEach((tabsSet, keyword) => { 804 | if (tabsSet.size >= CONFIG.preGroupingThreshold) { 805 | potentialKeywordGroups.push({ keyword: keyword, tabs: tabsSet, size: tabsSet.size }); 806 | } 807 | }); 808 | potentialKeywordGroups.sort((a, b) => b.size - a.size); // Process larger groups first 809 | 810 | potentialKeywordGroups.forEach(({ keyword, tabs }) => { 811 | const finalTabsForGroup = new Set(); 812 | tabs.forEach(tab => { 813 | if (!handledTabs.has(tab)) { 814 | finalTabsForGroup.add(tab); 815 | } 816 | }); 817 | if (finalTabsForGroup.size >= CONFIG.preGroupingThreshold) { 818 | const categoryName = processTopic(keyword); 819 | console.log(` - Pre-Grouping by Title Keyword: "${keyword}" (Count: ${finalTabsForGroup.size}) -> Category: "${categoryName}"`); 820 | preGroups[categoryName] = Array.from(finalTabsForGroup); 821 | finalTabsForGroup.forEach(tab => handledTabs.add(tab)); 822 | } 823 | }); 824 | 825 | // Hostname pre-grouping (for remaining tabs) 826 | const hostnameCounts = {}; 827 | initialTabsToSort.forEach(tab => { 828 | if (!handledTabs.has(tab)) { 829 | const data = tabDataCache.get(tab); 830 | if (data?.hostname && data.hostname !== 'N/A' && data.hostname !== 'Invalid URL' && data.hostname !== 'Internal Page') { 831 | hostnameCounts[data.hostname] = (hostnameCounts[data.hostname] || 0) + 1; 832 | } 833 | } 834 | }); 835 | 836 | const sortedHostnames = Object.keys(hostnameCounts).sort((a, b) => hostnameCounts[b] - hostnameCounts[a]); 837 | 838 | for (const hostname of sortedHostnames) { 839 | if (hostnameCounts[hostname] >= CONFIG.preGroupingThreshold) { 840 | const categoryName = processTopic(hostname); 841 | // Avoid creating a hostname group if a keyword group with the same processed name already exists 842 | if (preGroups[categoryName]) { 843 | console.log(` - Skipping Hostname Group for "${hostname}" -> Category "${categoryName}" (already exists from keywords).`); 844 | continue; 845 | } 846 | 847 | const tabsForHostnameGroup = []; 848 | initialTabsToSort.forEach(tab => { 849 | if (!handledTabs.has(tab)) { 850 | const data = tabDataCache.get(tab); 851 | if (data?.hostname === hostname) { 852 | tabsForHostnameGroup.push(tab); 853 | } 854 | } 855 | }); 856 | 857 | if (tabsForHostnameGroup.length >= CONFIG.preGroupingThreshold) { 858 | console.log(` - Pre-Grouping by Hostname: "${hostname}" (Count: ${tabsForHostnameGroup.length}) -> Category: "${categoryName}"`); 859 | preGroups[categoryName] = tabsForHostnameGroup; 860 | tabsForHostnameGroup.forEach(tab => handledTabs.add(tab)); 861 | } 862 | } 863 | } 864 | // --- End Pre-Grouping Logic --- 865 | 866 | // --- AI Grouping for Remaining Tabs --- 867 | const tabsForAI = initialTabsToSort.filter(tab => !handledTabs.has(tab) && tab.isConnected); 868 | let aiTabTopics = []; 869 | const comprehensiveExistingNames = new Set([...allExistingGroupNames, ...Object.keys(preGroups)]); 870 | const existingNamesForAIContext = Array.from(comprehensiveExistingNames); 871 | 872 | if (tabsForAI.length > 0) { 873 | console.log(` -> ${tabsForAI.length} tabs remaining for AI analysis. Providing ${existingNamesForAIContext.length} existing categories as context.`); 874 | aiTabTopics = await askAIForMultipleTopics(tabsForAI, existingNamesForAIContext); // Pass comprehensive names 875 | } else { 876 | console.log(" -> No tabs remaining for AI analysis."); 877 | } 878 | // --- End AI Grouping --- 879 | 880 | // --- Combine Groups --- 881 | const finalGroups = { ...preGroups }; 882 | aiTabTopics.forEach(({ tab, topic }) => { 883 | if (!topic || topic === "Uncategorized" || !tab || !tab.isConnected) { 884 | if (topic && topic !== "Uncategorized") { 885 | console.warn(` -> AI suggested category "${topic}" but associated tab is invalid/disconnected.`); 886 | } 887 | return; // Skip invalid/uncategorized/disconnected 888 | } 889 | if (!finalGroups[topic]) { 890 | finalGroups[topic] = []; 891 | } 892 | // Double-check if tab was somehow handled between AI request and processing 893 | if (!handledTabs.has(tab)) { 894 | finalGroups[topic].push(tab); 895 | handledTabs.add(tab); // Mark as handled now 896 | } else { 897 | const originalGroup = Object.keys(preGroups).find(key => preGroups[key].includes(tab)); 898 | console.warn(` -> AI suggested category "${topic}" for tab "${getTabData(tab).title}", but it was already pre-grouped under "${originalGroup || 'Unknown Pre-Group'}". Keeping pre-grouped assignment.`); 899 | } 900 | }); 901 | // --- End Combine Groups --- 902 | 903 | // --- Consolidate Similar Category Names (Levenshtein distance) --- 904 | console.log(" -> Consolidating potential duplicate categories..."); 905 | const originalKeys = Object.keys(finalGroups); 906 | const mergedKeys = new Set(); 907 | const consolidationMap = {}; // To track merges: mergedKey -> canonicalKey 908 | 909 | for (let i = 0; i < originalKeys.length; i++) { 910 | let keyA = originalKeys[i]; 911 | if (mergedKeys.has(keyA)) continue; // Already merged into another key 912 | 913 | // Resolve transitive merges for keyA if it was already targeted 914 | while (consolidationMap[keyA]) { 915 | keyA = consolidationMap[keyA]; 916 | } 917 | if (mergedKeys.has(keyA)) continue; // Check again after resolving transitive merges 918 | 919 | for (let j = i + 1; j < originalKeys.length; j++) { 920 | let keyB = originalKeys[j]; 921 | if (mergedKeys.has(keyB)) continue; 922 | 923 | // Resolve transitive merges for keyB 924 | while (consolidationMap[keyB]) { 925 | keyB = consolidationMap[keyB]; 926 | } 927 | if (mergedKeys.has(keyB) || keyA === keyB) continue; // Already merged or identical after resolving 928 | 929 | const distance = levenshteinDistance(keyA, keyB); 930 | const threshold = CONFIG.consolidationDistanceThreshold; 931 | 932 | if (distance <= threshold && distance > 0) { // Only merge if similar but not identical 933 | // Determine which key to keep (prioritize existing, then pre-grouped, then shorter) 934 | let canonicalKey = keyA; 935 | let mergedKey = keyB; 936 | 937 | const keyAIsActuallyExisting = allExistingGroupNames.has(keyA); 938 | const keyBIsActuallyExisting = allExistingGroupNames.has(keyB); 939 | const keyAIsPreGroup = keyA in preGroups; 940 | const keyBIsPreGroup = keyB in preGroups; 941 | 942 | // Priority: Existing > Pre-Group > Shorter Length 943 | if (keyBIsActuallyExisting && !keyAIsActuallyExisting) { 944 | [canonicalKey, mergedKey] = [keyB, keyA]; // B is existing, A is not 945 | } else if (keyAIsActuallyExisting && keyBIsActuallyExisting) { 946 | // Both exist, prefer pre-group, then shorter 947 | if (keyBIsPreGroup && !keyAIsPreGroup) [canonicalKey, mergedKey] = [keyB, keyA]; 948 | else if (keyA.length > keyB.length) [canonicalKey, mergedKey] = [keyB, keyA]; 949 | } else if (!keyAIsActuallyExisting && !keyBIsActuallyExisting) { 950 | // Neither exist, prefer pre-group, then shorter 951 | if (keyBIsPreGroup && !keyAIsPreGroup) [canonicalKey, mergedKey] = [keyB, keyA]; 952 | else if (keyA.length > keyB.length) [canonicalKey, mergedKey] = [keyB, keyA]; 953 | } 954 | // Handle the case where keyA exists, keyB doesn't (already default) 955 | 956 | console.log(` - Consolidating: Merging "${mergedKey}" into "${canonicalKey}" (Distance: ${distance})`); 957 | 958 | // Merge tabs from mergedKey into canonicalKey 959 | if (finalGroups[mergedKey]) { 960 | if (!finalGroups[canonicalKey]) finalGroups[canonicalKey] = []; 961 | const uniqueTabsToAdd = finalGroups[mergedKey].filter(tab => 962 | tab && tab.isConnected && !finalGroups[canonicalKey].some(existingTab => existingTab === tab) 963 | ); 964 | finalGroups[canonicalKey].push(...uniqueTabsToAdd); 965 | } 966 | 967 | mergedKeys.add(mergedKey); // Mark B as merged 968 | consolidationMap[mergedKey] = canonicalKey; // Track the merge target 969 | delete finalGroups[mergedKey]; // Remove the merged group 970 | 971 | // If keyA was the one being merged, update keyA to the canonical key for subsequent checks in the inner loop 972 | if (mergedKey === keyA) { 973 | keyA = canonicalKey; 974 | break; // Break inner loop as keyA has changed, restart comparison from outer loop perspective 975 | } 976 | } 977 | } 978 | } 979 | console.log(" -> Consolidation complete."); 980 | // --- End Consolidation --- 981 | 982 | console.log(" -> Final Consolidated groups:", Object.keys(finalGroups).map(k => `${k} (${finalGroups[k]?.length ?? 0})`).join(', ')); 983 | if (Object.keys(finalGroups).length === 0) { 984 | console.log("No valid groups identified after consolidation. Sorting finished."); 985 | // No need to set isSorting = false here, finally block handles it 986 | return; // Exit early 987 | } 988 | 989 | // --- Step 2: Get existing group ELEMENTS once before the loop --- 990 | const existingGroupElementsMap = new Map(); 991 | document.querySelectorAll(groupSelector).forEach(groupEl => { // Use the same corrected selector 992 | const label = groupEl.getAttribute('label'); 993 | if (label) { 994 | existingGroupElementsMap.set(label, groupEl); 995 | } 996 | }); 997 | 998 | // Reset color index AFTER consolidation, before creating new groups 999 | groupColorIndex = 0; 1000 | 1001 | // --- Process each final, consolidated group --- 1002 | for (const topic in finalGroups) { 1003 | // Filter AGAIN for valid, connected tabs right before moving/grouping 1004 | const tabsForThisTopic = finalGroups[topic].filter(t => { 1005 | if (!t || !t.isConnected) return false; 1006 | // Always allow valid tabs through; group membership will be checked at move time 1007 | return true; 1008 | }); 1009 | 1010 | if (tabsForThisTopic.length === 0) { 1011 | console.log(` -> Skipping group "${topic}" as no valid, unsorted tabs remain in this workspace.`); 1012 | continue; // Skip empty or already correctly sorted collections 1013 | } 1014 | 1015 | // --- Step 3: Use the Map for lookup --- 1016 | const existingGroupElement = existingGroupElementsMap.get(topic); 1017 | 1018 | if (existingGroupElement && existingGroupElement.isConnected) { // Check if the element is still in the DOM 1019 | // Move tabs to EXISTING group 1020 | console.log(` -> Moving ${tabsForThisTopic.length} tabs to existing group "${topic}".`); 1021 | try { 1022 | // Ensure group is expanded before moving tabs into it 1023 | if (existingGroupElement.getAttribute("collapsed") === "true") { 1024 | existingGroupElement.setAttribute("collapsed", "false"); 1025 | const groupLabelElement = existingGroupElement.querySelector('.tab-group-label'); 1026 | if (groupLabelElement) { 1027 | groupLabelElement.setAttribute('aria-expanded', 'true'); // Ensure visually expanded too 1028 | } 1029 | } 1030 | // Move tabs one by one 1031 | for (const tab of tabsForThisTopic) { 1032 | if (!tab || !tab.isConnected) continue; 1033 | const groupParent = tab.closest('tab-group'); 1034 | // Only skip if already in the *target* group 1035 | const isAlreadyInTargetGroup = groupParent === existingGroupElement; 1036 | if (!isAlreadyInTargetGroup) { 1037 | gBrowser.moveTabToGroup(tab, existingGroupElement); 1038 | } else { 1039 | console.log(` -> Tab "${getTabData(tab)?.title || 'Unknown'}" is already in the correct group "${topic}", skipping move.`); 1040 | } 1041 | } 1042 | } catch (e) { 1043 | console.error(`Error moving tabs to existing group "${topic}":`, e, existingGroupElement); 1044 | } 1045 | } else { 1046 | // Create NEW group 1047 | if (existingGroupElement && !existingGroupElement.isConnected) { 1048 | console.warn(` -> Existing group element for "${topic}" was found in map but is no longer connected to DOM. Will create a new group.`); 1049 | } 1050 | 1051 | const wasOriginallyPreGroup = topic in preGroups; 1052 | const wasDirectlyFromAI = aiTabTopics.some(ait => ait.topic === topic && tabsForThisTopic.includes(ait.tab)); 1053 | 1054 | // Create group if it meets threshold OR came from pre-grouping OR came directly from AI 1055 | if (tabsForThisTopic.length >= CONFIG.preGroupingThreshold || wasDirectlyFromAI || wasOriginallyPreGroup) { 1056 | console.log(` -> Creating new group "${topic}" with ${tabsForThisTopic.length} tabs.`); 1057 | const firstValidTabForGroup = tabsForThisTopic[0]; // Need a reference tab for insertion point 1058 | const groupOptions = { 1059 | label: topic, 1060 | color: getNextGroupColorName(), 1061 | insertBefore: firstValidTabForGroup // Insert before the first tab being added 1062 | }; 1063 | try { 1064 | // Create the group with all tabs at once 1065 | const newGroup = gBrowser.addTabGroup(tabsForThisTopic, groupOptions); 1066 | 1067 | if (newGroup && newGroup.isConnected) { // Check if group was created and is in DOM 1068 | console.log(` -> Successfully created group element for "${topic}".`); 1069 | existingGroupElementsMap.set(topic, newGroup); // Add to map for potential later reuse in this run 1070 | } else { 1071 | console.warn(` -> addTabGroup didn't return a connected element for "${topic}". Attempting fallback find.`); 1072 | // Use the CORRECTED findGroupElement helper 1073 | const newGroupElFallback = findGroupElement(topic, currentWorkspaceId); 1074 | if (newGroupElFallback && newGroupElFallback.isConnected) { 1075 | console.log(` -> Found new group element for "${topic}" via fallback.`); 1076 | existingGroupElementsMap.set(topic, newGroupElFallback); // Add to map via fallback 1077 | } else { 1078 | console.error(` -> Failed to find the newly created group element for "${topic}" even with fallback.`); 1079 | } 1080 | } 1081 | } catch (e) { 1082 | console.error(`Error calling gBrowser.addTabGroup for topic "${topic}":`, e); 1083 | // Attempt to find the group even after an error, it might have partially succeeded 1084 | const groupAfterError = findGroupElement(topic, currentWorkspaceId); 1085 | if (groupAfterError && groupAfterError.isConnected) { 1086 | console.warn(` -> Group "${topic}" might exist despite error. Found via findGroupElement.`); 1087 | existingGroupElementsMap.set(topic, groupAfterError); // Update map 1088 | } else { 1089 | console.error(` -> Failed to find group "${topic}" after creation error.`); 1090 | } 1091 | } 1092 | } else { 1093 | console.log(` -> Skipping creation of small group "${topic}" (${tabsForThisTopic.length} tabs) - didn't meet threshold and wasn't a pre-group or directly from AI.`); 1094 | } 1095 | } 1096 | } // End loop through final groups 1097 | 1098 | console.log("--- Tab sorting process complete ---"); 1099 | 1100 | } catch (error) { 1101 | console.error("Error during overall sorting process:", error); 1102 | } finally { 1103 | isSorting = false; // Ensure sorting flag is reset 1104 | 1105 | // Remove loading indicator class 1106 | if (separatorsToSort.length > 0) { 1107 | console.log("Removing sorting indicator from separator(s)..."); 1108 | separatorsToSort.forEach(sep => { 1109 | // Check if element still exists before removing class 1110 | if (sep && sep.isConnected) { 1111 | sep.classList.remove('separator-is-sorting'); 1112 | } 1113 | }); 1114 | } 1115 | 1116 | // Remove tab loading indicators after a delay 1117 | setTimeout(() => { 1118 | Array.from(gBrowser.tabs).forEach(tab => { 1119 | if (tab && tab.isConnected) { 1120 | tab.classList.remove('tab-is-sorting'); 1121 | } 1122 | }); 1123 | }, 500); // Keep existing delay for tabs 1124 | } 1125 | }; 1126 | // --- End Sorting Function --- 1127 | 1128 | 1129 | // --- Clear Tabs Functionality --- 1130 | const clearTabs = () => { 1131 | console.log("Clearing tabs..."); 1132 | let closedCount = 0; 1133 | try { 1134 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace; 1135 | if (!currentWorkspaceId) { 1136 | console.error("CLEAR BTN: Cannot get current workspace ID."); 1137 | return; 1138 | } 1139 | // Define the group selector for the current workspace *once* 1140 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`; 1141 | 1142 | const tabsToClose = []; 1143 | for (const tab of gBrowser.tabs) { 1144 | const isSameWorkSpace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId; 1145 | const groupParent = tab.closest('tab-group'); 1146 | // Check if the parent group matches the selector for the *current* workspace 1147 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false; 1148 | const isEmptyZenTab = tab.hasAttribute("zen-empty-tab"); 1149 | 1150 | if (isSameWorkSpace && // In the correct workspace 1151 | !tab.selected && // Not the active tab 1152 | !tab.pinned && // Not pinned 1153 | !isInGroupInCorrectWorkspace && // Not in a group belonging to this workspace 1154 | !isEmptyZenTab && // Not an empty Zen tab 1155 | tab.isConnected) { // Is connected 1156 | tabsToClose.push(tab); 1157 | } 1158 | } 1159 | 1160 | if (tabsToClose.length === 0) { 1161 | console.log("CLEAR BTN: No ungrouped, non-pinned, non-active tabs found to clear in this workspace."); 1162 | return; 1163 | } 1164 | 1165 | console.log(`CLEAR BTN: Closing ${tabsToClose.length} tabs.`); 1166 | tabsToClose.forEach(tab => { 1167 | tab.classList.add('tab-closing'); // Add animation class 1168 | closedCount++; 1169 | // Delay removal to allow animation to play 1170 | setTimeout(() => { 1171 | if (tab && tab.isConnected) { 1172 | try { 1173 | gBrowser.removeTab(tab, { 1174 | animate: false, // Animation handled by CSS 1175 | skipSessionStore: false, 1176 | closeWindowWithLastTab: false, 1177 | }); 1178 | } catch (removeError) { 1179 | console.warn(`CLEAR BTN: Error removing tab: ${removeError}`, tab); 1180 | // Attempt to remove animation class if removal fails 1181 | tab.classList.remove('tab-closing'); 1182 | } 1183 | } 1184 | }, 500); // Match CSS animation duration 1185 | }); 1186 | } catch (error) { 1187 | console.error("CLEAR BTN: Error during tab clearing:", error); 1188 | } finally { 1189 | console.log(`CLEAR BTN: Initiated closing for ${closedCount} tabs.`); 1190 | } 1191 | }; 1192 | 1193 | 1194 | // --- Button Initialization & Workspace Handling --- 1195 | 1196 | function ensureButtonsExist(container) { 1197 | if (!container) return; 1198 | 1199 | // Ensure Sort Button 1200 | if (!container.querySelector('#sort-button')) { 1201 | try { 1202 | const buttonFragment = window.MozXULElement.parseXULToFragment( 1203 | `` 1204 | ); 1205 | container.appendChild(buttonFragment.firstChild.cloneNode(true)); 1206 | console.log("BUTTONS: Sort button added to container:", container.id || container.className); 1207 | } catch (e) { 1208 | console.error("BUTTONS: Error creating/appending sort button:", e); 1209 | } 1210 | } 1211 | 1212 | // Ensure Clear Button 1213 | if (!container.querySelector('#clear-button')) { 1214 | try { 1215 | const buttonFragment = window.MozXULElement.parseXULToFragment( 1216 | `` 1217 | ); 1218 | container.appendChild(buttonFragment.firstChild.cloneNode(true)); 1219 | console.log("BUTTONS: Clear button added to container:", container.id || container.className); 1220 | } catch (e) { 1221 | console.error("BUTTONS: Error creating/appending clear button:", e); 1222 | } 1223 | } 1224 | } 1225 | 1226 | function addButtonsToAllSeparators() { 1227 | const separators = document.querySelectorAll(".vertical-pinned-tabs-container-separator"); 1228 | if (separators.length > 0) { 1229 | separators.forEach(ensureButtonsExist); 1230 | } else { 1231 | // Fallback if no separators are found (e.g., different Zen Tab config or error) 1232 | const periphery = document.querySelector('#tabbrowser-arrowscrollbox-periphery'); 1233 | if (periphery && !periphery.querySelector('#sort-button') && !periphery.querySelector('#clear-button')) { 1234 | console.warn("BUTTONS: No separators found, attempting fallback append to periphery."); 1235 | ensureButtonsExist(periphery); 1236 | } else if (!periphery) { 1237 | console.error("BUTTONS: No separators or fallback periphery container found."); 1238 | } 1239 | } 1240 | } 1241 | 1242 | function setupCommandsAndListener() { 1243 | const zenCommands = document.querySelector("commandset#zenCommandSet"); 1244 | if (!zenCommands) { 1245 | console.error("BUTTONS INIT: Could not find 'commandset#zenCommandSet'. Zen Tab Organizer might not be fully loaded."); 1246 | return; 1247 | } 1248 | 1249 | // Add Sort Command if missing 1250 | if (!zenCommands.querySelector("#cmd_zenSortTabs")) { 1251 | try { 1252 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild; 1253 | zenCommands.appendChild(cmd); 1254 | console.log("BUTTONS INIT: Command 'cmd_zenSortTabs' added."); 1255 | } catch (e) { 1256 | console.error("BUTTONS INIT: Error adding command 'cmd_zenSortTabs':", e); 1257 | } 1258 | } 1259 | 1260 | // Add Clear Command if missing 1261 | if (!zenCommands.querySelector("#cmd_zenClearTabs")) { 1262 | try { 1263 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild; 1264 | zenCommands.appendChild(cmd); 1265 | console.log("BUTTONS INIT: Command 'cmd_zenClearTabs' added."); 1266 | } catch (e) { 1267 | console.error("BUTTONS INIT: Error adding command 'cmd_zenClearTabs':", e); 1268 | } 1269 | } 1270 | 1271 | // Add listener only once 1272 | if (!commandListenerAdded) { 1273 | try { 1274 | zenCommands.addEventListener('command', (event) => { 1275 | if (event.target.id === "cmd_zenSortTabs") { 1276 | sortTabsByTopic(); 1277 | } else if (event.target.id === "cmd_zenClearTabs") { 1278 | clearTabs(); 1279 | } 1280 | }); 1281 | commandListenerAdded = true; 1282 | console.log("BUTTONS INIT: Command listener added for Sort and Clear."); 1283 | } catch (e) { 1284 | console.error("BUTTONS INIT: Error adding command listener:", e); 1285 | } 1286 | } 1287 | } 1288 | 1289 | 1290 | // --- gZenWorkspaces Hooks --- 1291 | 1292 | function setupZenWorkspaceHooks() { 1293 | if (typeof gZenWorkspaces === 'undefined') { 1294 | console.warn("BUTTONS: gZenWorkspaces object not found. Skipping hook setup. Ensure Zen Tab Organizer loads first."); 1295 | return; 1296 | } 1297 | // Avoid applying hooks multiple times 1298 | if (typeof gZenWorkspaces.originalHooks !== 'undefined') { 1299 | console.log("BUTTONS HOOK: Hooks already seem to be applied. Skipping re-application."); 1300 | return; 1301 | } 1302 | 1303 | console.log("BUTTONS HOOK: Applying gZenWorkspaces hooks..."); 1304 | // Store original functions before overwriting 1305 | gZenWorkspaces.originalHooks = { 1306 | onTabBrowserInserted: gZenWorkspaces.onTabBrowserInserted, 1307 | updateTabsContainers: gZenWorkspaces.updateTabsContainers, 1308 | }; 1309 | 1310 | // Hook into onTabBrowserInserted (called when workspace elements are likely created/updated) 1311 | gZenWorkspaces.onTabBrowserInserted = function(event) { 1312 | // Call the original function first 1313 | if (typeof gZenWorkspaces.originalHooks.onTabBrowserInserted === 'function') { 1314 | try { 1315 | gZenWorkspaces.originalHooks.onTabBrowserInserted.call(gZenWorkspaces, event); 1316 | } catch (e) { 1317 | console.error("BUTTONS HOOK: Error in original onTabBrowserInserted:", e); 1318 | } 1319 | } 1320 | // Add buttons after a short delay to ensure elements are ready 1321 | setTimeout(addButtonsToAllSeparators, 150); // Slightly increased delay for safety 1322 | }; 1323 | 1324 | // Hook into updateTabsContainers (called on various workspace/tab changes) 1325 | gZenWorkspaces.updateTabsContainers = function(...args) { 1326 | // Call the original function first 1327 | if (typeof gZenWorkspaces.originalHooks.updateTabsContainers === 'function') { 1328 | try { 1329 | gZenWorkspaces.originalHooks.updateTabsContainers.apply(gZenWorkspaces, args); 1330 | } catch (e) { 1331 | console.error("BUTTONS HOOK: Error in original updateTabsContainers:", e); 1332 | } 1333 | } 1334 | // Add buttons after a short delay 1335 | setTimeout(addButtonsToAllSeparators, 150); // Slightly increased delay for safety 1336 | }; 1337 | console.log("BUTTONS HOOK: gZenWorkspaces hooks applied successfully."); 1338 | } 1339 | 1340 | 1341 | // --- Initial Setup Trigger --- 1342 | 1343 | function initializeScript() { 1344 | console.log("INIT: Sort & Clear Tabs Script (v4.10.0 - Gemini - Structured) loading..."); 1345 | let checkCount = 0; 1346 | const maxChecks = 30; // Check for up to ~30 seconds 1347 | const checkInterval = 1000; // Check every second 1348 | 1349 | const initCheckInterval = setInterval(() => { 1350 | checkCount++; 1351 | 1352 | // Check for necessary conditions 1353 | const separatorExists = !!document.querySelector(".vertical-pinned-tabs-container-separator"); 1354 | const peripheryExists = !!document.querySelector('#tabbrowser-arrowscrollbox-periphery'); 1355 | const commandSetExists = !!document.querySelector("commandset#zenCommandSet"); 1356 | const gBrowserReady = typeof gBrowser !== 'undefined' && gBrowser.tabContainer; 1357 | const gZenWorkspacesReady = typeof gZenWorkspaces !== 'undefined' && typeof gZenWorkspaces.activeWorkspace !== 'undefined'; 1358 | 1359 | const ready = gBrowserReady && commandSetExists && (separatorExists || peripheryExists) && gZenWorkspacesReady; 1360 | 1361 | if (ready) { 1362 | console.log(`INIT: Required elements found after ${checkCount} checks. Initializing...`); 1363 | clearInterval(initCheckInterval); // Stop checking 1364 | 1365 | // Defer final setup slightly to ensure everything is stable 1366 | const finalSetup = () => { 1367 | try { 1368 | injectStyles(); 1369 | setupCommandsAndListener(); 1370 | addButtonsToAllSeparators(); // Initial add 1371 | setupZenWorkspaceHooks(); // Setup hooks for future updates 1372 | console.log("INIT: Sort & Clear Button setup and hooks complete."); 1373 | } catch (e) { 1374 | console.error("INIT: Error during deferred final setup:", e); 1375 | } 1376 | }; 1377 | 1378 | // Use requestIdleCallback if available for less impact, otherwise fallback to setTimeout 1379 | if ('requestIdleCallback' in window) { 1380 | requestIdleCallback(finalSetup, { timeout: 2000 }); 1381 | } else { 1382 | setTimeout(finalSetup, 500); 1383 | } 1384 | 1385 | } else if (checkCount > maxChecks) { 1386 | clearInterval(initCheckInterval); // Stop checking after timeout 1387 | console.error(`INIT: Failed to find required elements after ${maxChecks} checks. Script might not function correctly.`); 1388 | console.error("INIT Status:", { 1389 | gBrowserReady, 1390 | commandSetExists, 1391 | separatorExists, 1392 | peripheryExists, 1393 | gZenWorkspacesReady 1394 | }); 1395 | // Provide specific feedback 1396 | if (!gZenWorkspacesReady) console.error(" -> gZenWorkspaces might not be fully initialized yet (activeWorkspace missing?). Ensure Zen Tab Organizer extension is loaded and enabled BEFORE this script runs."); 1397 | if (!separatorExists && !peripheryExists) console.error(" -> Neither separator element '.vertical-pinned-tabs-container-separator' nor fallback periphery '#tabbrowser-arrowscrollbox-periphery' found in the DOM."); 1398 | if (!commandSetExists) console.error(" -> Command set '#zenCommandSet' not found. Ensure Zen Tab Organizer extension is loaded and enabled."); 1399 | if (!gBrowserReady) console.error(" -> Global 'gBrowser' object not ready."); 1400 | } 1401 | }, checkInterval); 1402 | } 1403 | 1404 | // --- Start Initialization --- 1405 | // Wait for the window to load before trying to access DOM elements 1406 | if (document.readyState === "complete" || document.readyState === "interactive") { 1407 | // If already loaded, initialize directly 1408 | initializeScript(); 1409 | } else { 1410 | // Otherwise, wait for the 'load' event 1411 | window.addEventListener("load", initializeScript, { once: true }); 1412 | } 1413 | 1414 | })(); // End script wrapper 1415 | -------------------------------------------------------------------------------- /theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ai-tabgroups-zenbrowser", 3 | "name": "Tab Sort Clear AI", 4 | "description": "The script sorts the temporary tabs into tabgroups using ai for a more cleaner organized browsing experience. Inspired by Arc Max Tidy feature. ", 5 | "homepage": "https://github.com/Darsh-A/Ai-TabGroups-ZenBrowser", 6 | "preferences": "https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/main/preferences.json", 7 | "image": "https://github.com/Darsh-A/Ai-TabGroups-ZenBrowser/blob/main/image.jpg?raw=true", 8 | "js": true, 9 | "readme": "https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/refs/heads/main/README.md", 10 | "author": "Darsh A", 11 | "version": "1.0.0", 12 | "tags": [ 13 | "sine", 14 | "Tab Sort Clear", 15 | "Tab Groups", 16 | "fx-autoconfig", 17 | "JavaScript", 18 | "Darsh A" 19 | ], 20 | "createdAt": "2025-05-26", 21 | "updatedAt": "2025-05-26" 22 | } 23 | --------------------------------------------------------------------------------