520 lines
17 KiB
JavaScript
520 lines
17 KiB
JavaScript
'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);
|