'use strict'; (function attachChromeTabGroups(globalScope) { const STORAGE_KEY = 'chromeTabGroupsEnabled'; const META_PERSIST_KEY = 'chromeTabGroupsMeta'; let cachedEnabled = false; let chromeGroupMap = {}; let importMode = false; let chromeEventMuteUntil = 0; let chromeListenersAttached = false; const chromeGroupSubscribers = new Set(); const GROUP_COLORS = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange']; function muteChromeGroupEvents(durationMs = 250) { chromeEventMuteUntil = Math.max(chromeEventMuteUntil, Date.now() + durationMs); } function shouldIgnoreChromeEvent() { return !cachedEnabled || Date.now() < chromeEventMuteUntil; } function notifyChromeGroupSubscribers(event) { if (shouldIgnoreChromeEvent()) return; for (const subscriber of chromeGroupSubscribers) { try { subscriber(event); } catch {} } } function shouldNotifyChromeGroupUpdate(changeInfo) { if (!changeInfo || typeof changeInfo !== 'object') return false; const changedKeys = Object.keys(changeInfo); if (changedKeys.length === 0) return false; return changedKeys.some(key => key !== 'collapsed'); } function attachChromeListeners() { if (chromeListenersAttached || typeof chrome === 'undefined') return; const eventBindings = [ [chrome.tabGroups?.onCreated, group => notifyChromeGroupSubscribers({ source: 'tabGroups.onCreated', group })], [chrome.tabGroups?.onUpdated, (groupId, changeInfo) => { // Collapsing or expanding a group should not trigger a full import cycle. if (!shouldNotifyChromeGroupUpdate(changeInfo)) return; notifyChromeGroupSubscribers({ source: 'tabGroups.onUpdated', groupId, changeInfo }); }], [chrome.tabGroups?.onRemoved, group => notifyChromeGroupSubscribers({ source: 'tabGroups.onRemoved', group })], [chrome.tabs?.onAttached, (tabId, attachInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onAttached', tabId, attachInfo })], [chrome.tabs?.onCreated, tab => notifyChromeGroupSubscribers({ source: 'tabs.onCreated', tab })], [chrome.tabs?.onDetached, (tabId, detachInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onDetached', tabId, detachInfo })], [chrome.tabs?.onMoved, async (tabId, moveInfo) => { try { const movedTab = await chrome.tabs.get(tabId); if (movedTab?.groupId == null || Number(movedTab.groupId) < 0) return; notifyChromeGroupSubscribers({ source: 'tabs.onMoved', tabId, moveInfo, tab: movedTab }); } catch {} }], [chrome.tabs?.onRemoved, (tabId, removeInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onRemoved', tabId, removeInfo })], [chrome.tabs?.onUpdated, (tabId, changeInfo, tab) => { if (changeInfo?.groupId == null) return; notifyChromeGroupSubscribers({ source: 'tabs.onUpdated', tabId, changeInfo, tab }); }], ]; for (const [eventSource, listener] of eventBindings) { if (eventSource && typeof eventSource.addListener === 'function') { eventSource.addListener(listener); } } chromeListenersAttached = true; } function getGroupTitle(group) { if (group.domain === '__landing-pages__') return 'Homepages'; if (group.label) return group.label; try { const hostname = group.domain.replace(/^__session_group__:/, ''); return friendlyDomain(hostname); } catch { return group.domain; } } function assignGroupColor(groupKey, index) { if (groupKey.startsWith('__session_group__:')) return 'blue'; if (groupKey === '__landing-pages__') return 'yellow'; return GROUP_COLORS[index % GROUP_COLORS.length]; } async function loadChromeTabGroupsSetting() { try { const stored = await chrome.storage.local.get(STORAGE_KEY); cachedEnabled = Boolean(stored[STORAGE_KEY]); } catch { cachedEnabled = false; } await loadPersistedChromeGroupMap(); return cachedEnabled; } async function saveChromeTabGroupsSetting(enabled) { cachedEnabled = Boolean(enabled); await chrome.storage.local.set({ [STORAGE_KEY]: cachedEnabled }); return cachedEnabled; } async function persistChromeGroupMap() { try { // Save group metadata (title, color) instead of raw groupIds, // since Chrome tab group IDs are only stable within a session. const meta = {}; for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) { for (const chromeGroupId of Object.values(windowMap)) { try { const group = await chrome.tabGroups.get(chromeGroupId); if (group && !meta[groupKey]) { meta[groupKey] = { title: group.title, color: group.color }; } } catch {} } } await chrome.storage.local.set({ [META_PERSIST_KEY]: meta }); } catch {} } async function loadPersistedChromeGroupMap() { try { const result = await chrome.storage.local.get(META_PERSIST_KEY); const meta = result[META_PERSIST_KEY]; if (!meta || Object.keys(meta).length === 0) return; const currentGroups = await chrome.tabGroups.query({}); const reconciled = {}; for (const [groupKey, info] of Object.entries(meta)) { // Match by title + color — the values we control. After restart, // Chrome assigns new groupIds, but our groups retain their title/color. const match = currentGroups.find(g => g.title === info.title && g.color === info.color ); if (match) { if (!reconciled[groupKey]) reconciled[groupKey] = {}; reconciled[groupKey][match.windowId] = match.id; } } chromeGroupMap = reconciled; } catch {} } function isChromeApiAvailable() { return typeof chrome !== 'undefined' && chrome.tabs && typeof chrome.tabs.group === 'function' && chrome.tabGroups && typeof chrome.tabGroups.update === 'function'; } async function ungroupTabs(tabIds) { if (!tabIds || tabIds.length === 0) return; try { await chrome.tabs.ungroup(tabIds); } catch { // Tab may have been closed already } } async function reorderGroupedTabs(chromeGroupId, desiredTabIds, windowId) { if (!chromeGroupId || !Array.isArray(desiredTabIds) || desiredTabIds.length <= 1) return; let groupedTabs = []; try { groupedTabs = await chrome.tabs.query({ groupId: chromeGroupId }); } catch { return; } if (!groupedTabs.length) return; const currentTabs = groupedTabs .filter(tab => desiredTabIds.includes(tab.id)) .sort((a, b) => a.index - b.index); if (!currentTabs.length) return; const currentOrder = currentTabs.map(tab => tab.id); if (currentOrder.length === desiredTabIds.length && currentOrder.every((tabId, index) => tabId === desiredTabIds[index])) { return; } const baseIndex = Math.min(...currentTabs.map(tab => tab.index)); for (const [offset, tabId] of desiredTabIds.entries()) { try { await chrome.tabs.move(tabId, { windowId, index: baseIndex + offset }); } catch {} } } async function reorderWindowTabsByDesiredOrder(windowId, desiredTabIds) { if (!Number.isFinite(windowId) || !Array.isArray(desiredTabIds) || desiredTabIds.length <= 1) return; let windowTabs = []; try { windowTabs = await chrome.tabs.query({ windowId }); } catch { return; } const relevantTabs = windowTabs .filter(tab => desiredTabIds.includes(tab.id)) .sort((a, b) => a.index - b.index); if (relevantTabs.length <= 1) return; const currentOrder = relevantTabs.map(tab => tab.id); if (currentOrder.length === desiredTabIds.length && currentOrder.every((tabId, index) => tabId === desiredTabIds[index])) { return; } const baseIndex = Math.min(...relevantTabs.map(tab => tab.index)); for (const [offset, tabId] of desiredTabIds.entries()) { try { await chrome.tabs.move(tabId, { windowId, index: baseIndex + offset }); } catch {} } } async function removeAllChromeGroups() { muteChromeGroupEvents(); const allTrackedTabIds = []; for (const windowMap of Object.values(chromeGroupMap)) { for (const chromeGroupId of Object.values(windowMap)) { try { const group = await chrome.tabGroups.get(chromeGroupId); if (group) { const tabs = await chrome.tabs.query({ groupId: chromeGroupId }); allTrackedTabIds.push(...tabs.map(t => t.id).filter(Boolean)); } } catch { // Group may have been removed externally } } } if (allTrackedTabIds.length > 0) { await ungroupTabs(allTrackedTabIds); } chromeGroupMap = {}; await persistChromeGroupMap(); } async function syncChromeTabGroups(domainGroups) { muteChromeGroupEvents(); await loadPersistedChromeGroupMap(); if (!cachedEnabled) { await removeAllChromeGroups(); return; } if (!isChromeApiAvailable()) return; // Build desired state: { groupKey: { windowId: [tabIds] } } const desired = {}; const desiredWindowOrders = {}; for (const group of domainGroups) { const groupKey = group.domain; for (const tab of (group.tabs || [])) { if (tab.id == null) continue; const windowId = tab.windowId != null ? tab.windowId : 0; if (!desired[groupKey]) desired[groupKey] = {}; if (!desired[groupKey][windowId]) desired[groupKey][windowId] = []; desired[groupKey][windowId].push(tab.id); if (!desiredWindowOrders[windowId]) desiredWindowOrders[windowId] = []; desiredWindowOrders[windowId].push(tab.id); } } // Collect current Chrome tab groups to check existence let currentGroups = []; try { currentGroups = await chrome.tabGroups.query({}); } catch {} const validGroupIds = new Set(currentGroups.map(g => g.id)); // Remove orphaned Chrome groups only for windows represented in this sync. // Other windows may be managed by their own Tab Harbor new-tab page. const desiredWindowIds = new Set( Object.values(desired) .flatMap(windowMap => Object.keys(windowMap)) .map(windowId => Number(windowId)) .filter(Number.isFinite) ); for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) { for (const [windowIdStr, chromeGroupId] of Object.entries(windowMap)) { const windowId = Number(windowIdStr); if (!validGroupIds.has(chromeGroupId)) { delete windowMap[windowIdStr]; continue; } if (desiredWindowIds.has(windowId) && !desired[groupKey]?.[windowId]) { try { const tabs = await chrome.tabs.query({ groupId: chromeGroupId }); await ungroupTabs(tabs.map(t => t.id).filter(Boolean)); } catch {} delete windowMap[windowIdStr]; } } if (Object.keys(windowMap).length === 0) { delete chromeGroupMap[groupKey]; } } // Process each virtual group let colorIndex = 0; for (const [groupKey, windowMap] of Object.entries(desired)) { const groupColor = assignGroupColor(groupKey, colorIndex); const group = domainGroups.find(g => g.domain === groupKey); const title = group ? getGroupTitle(group) : groupKey; if (!groupKey.startsWith('__session_group__:')) { colorIndex++; } for (const [windowIdStr, tabIds] of Object.entries(windowMap)) { if (tabIds.length === 0) continue; const windowId = Number(windowIdStr); let chromeGroupId = chromeGroupMap[groupKey]?.[windowId]; // Reuse existing Chrome group if still valid if (chromeGroupId != null && !validGroupIds.has(chromeGroupId)) { chromeGroupId = null; } if (chromeGroupId == null) { // In import mode, only reuse existing groups — don't create new ones if (importMode) continue; // Create new group try { chromeGroupId = await chrome.tabs.group({ tabIds }); } catch { // Some tabs may have valid IDs but fail grouping; try one by one for (const tabId of tabIds) { try { if (chromeGroupId == null) { chromeGroupId = await chrome.tabs.group({ tabIds: tabId }); } else { await chrome.tabs.group({ groupId: chromeGroupId, tabIds: tabId }); } } catch {} } } if (chromeGroupId != null) { try { await chrome.tabGroups.update(chromeGroupId, { title, color: groupColor, collapsed: true }); } catch {} } } else { // Move tabs into existing group try { await chrome.tabs.group({ groupId: chromeGroupId, tabIds }); } catch {} } if (chromeGroupId != null) { await reorderGroupedTabs(chromeGroupId, tabIds, windowId); } // Track the mapping if (chromeGroupId != null) { if (!chromeGroupMap[groupKey]) chromeGroupMap[groupKey] = {}; chromeGroupMap[groupKey][windowId] = chromeGroupId; } } } for (const [windowIdStr, orderedTabIds] of Object.entries(desiredWindowOrders)) { await reorderWindowTabsByDesiredOrder(Number(windowIdStr), orderedTabIds); } await persistChromeGroupMap(); } async function resetChromeGroupState() { chromeGroupMap = {}; cachedEnabled = false; importMode = false; chromeEventMuteUntil = 0; try { await chrome.storage.local.remove(META_PERSIST_KEY); } catch {} } function isChromeTabGroupsEnabled() { return cachedEnabled; } function getChromeGroupCount() { return Object.keys(chromeGroupMap).length; } async function populateChromeGroupMap(mappings) { for (const { virtualGroupKey, windowId, chromeGroupId } of mappings) { if (!chromeGroupMap[virtualGroupKey]) chromeGroupMap[virtualGroupKey] = {}; chromeGroupMap[virtualGroupKey][windowId] = chromeGroupId; } await persistChromeGroupMap(); } async function queryExistingChromeGroups() { try { return await chrome.tabGroups.query({}); } catch { return []; } } async function collapseChromeTabGroupsInWindow(windowId) { if (!cachedEnabled || !isChromeApiAvailable()) return; const targetWindowId = Number(windowId); if (!Number.isFinite(targetWindowId)) return; let groups = []; try { groups = await chrome.tabGroups.query({}); } catch { return; } const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId); muteChromeGroupEvents(); for (const group of groupsInWindow) { if (Boolean(group.collapsed)) continue; try { await chrome.tabGroups.update(group.id, { collapsed: true }); } catch {} } } async function syncChromeTabGroupExpansionForTab(tab) { if (!cachedEnabled || !isChromeApiAvailable()) return; const targetGroupId = Number(tab?.groupId); const targetWindowId = Number(tab?.windowId); if (!Number.isFinite(targetGroupId) || targetGroupId < 0) return; if (!Number.isFinite(targetWindowId)) return; let groups = []; try { groups = await chrome.tabGroups.query({}); } catch { return; } const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId); muteChromeGroupEvents(); for (const group of groupsInWindow) { const nextCollapsed = group.id !== targetGroupId; if (Boolean(group.collapsed) === nextCollapsed) continue; try { await chrome.tabGroups.update(group.id, { collapsed: nextCollapsed }); } catch {} } } function setImportMode(enabled) { importMode = Boolean(enabled); } function isImportMode() { return importMode; } function subscribeToChromeTabGroupChanges(listener) { if (typeof listener !== 'function') { return () => {}; } attachChromeListeners(); chromeGroupSubscribers.add(listener); return () => { chromeGroupSubscribers.delete(listener); }; } const api = { loadChromeTabGroupsSetting, saveChromeTabGroupsSetting, syncChromeTabGroups, resetChromeGroupState, isChromeTabGroupsEnabled, getChromeGroupCount, populateChromeGroupMap, queryExistingChromeGroups, collapseChromeTabGroupsInWindow, syncChromeTabGroupExpansionForTab, setImportMode, isImportMode, subscribeToChromeTabGroupChanges, STORAGE_KEY, assignGroupColor, getGroupTitle, }; if (typeof module !== 'undefined' && module.exports) { module.exports = api; } globalScope.TabOutChromeTabGroups = api; })(typeof globalThis !== 'undefined' ? globalThis : window);