Files
2026-07-13 12:34:04 +08:00

4794 lines
178 KiB
JavaScript

/* ================================================================
Tab Harbor — Dashboard App (Pure Extension Edition)
This file is the brain of the dashboard. Now that the dashboard
IS the extension page (not inside an iframe), it can call
chrome.tabs and chrome.storage directly — no postMessage bridge needed.
What this file does:
1. Reads open browser tabs directly via chrome.tabs.query()
2. Groups tabs by domain with a landing pages category
3. Renders domain cards, banners, and stats
4. Handles all user actions (close tabs, saved sessions, focus tab)
5. Stores dashboard preferences and workspace state in chrome.storage.local
================================================================ */
'use strict';
const {
getLanguagePreference: runtimeGetLanguagePreference,
setLanguagePreference: runtimeSetLanguagePreference,
t: runtimeT,
} = globalThis.TabHarborI18n || {};
const {
escapeHtml: runtimeEscapeHtml,
escapeHtmlAttribute: runtimeEscapeHtmlAttribute,
getFallbackLabel: runtimeGetFallbackLabel,
getGroupIcon: runtimeGetGroupIcon,
getIconSources: runtimeGetIconSources,
getPrimaryDomain: runtimeGetPrimaryDomain,
} = globalThis.TabOutIconUtils || {};
const {
addSessionGroup,
assignTabToSessionGroup,
clearTabSessionGroup,
normalizeSessionGroups,
pruneSessionGroups,
renameSessionGroup,
} = globalThis.TabOutSessionGroups || {};
const {
applyGroupOrder,
createReorderedKeys,
normalizeGroupOrderState,
} = globalThis.TabOutGroupOrder || {};
const {
clampTriggerTop: runtimeClampTriggerTop,
} = globalThis.TabOutDeferredTriggerPosition || {};
const {
loadChromeTabGroupsSetting,
saveChromeTabGroupsSetting,
syncChromeTabGroups,
collapseChromeTabGroupsInWindow,
syncChromeTabGroupExpansionForTab,
isChromeTabGroupsEnabled,
populateChromeGroupMap,
queryExistingChromeGroups,
setImportMode,
subscribeToChromeTabGroupChanges,
} = globalThis.TabOutChromeTabGroups || {};
const {
setImageFallbackAttributes: runtimeSetImageFallbackAttributes,
} = globalThis;
const {
EMPTY_META: runtimeEmptyChromeImportedMeta = { entries: [] },
normalizeChromeImportedGroupMeta,
reconcileChromeTabGroupImports,
} = globalThis.TabOutChromeTabGroupImport || {};
const {
reorderSubsetByIds,
} = globalThis.TabOutListOrder || {};
const {
compressImageFileForStorage,
} = globalThis.TabOutBackgroundImage || {};
const {
getSavedSessionRestoreMode: runtimeGetSavedSessionRestoreMode,
} = globalThis.TabOutThemeControls || {};
const {
getCanonicalTabUrl: runtimeGetCanonicalTabUrl,
isRestorableTabUrl: runtimeIsRestorableTabUrl,
parseSuspendedTabUrl: runtimeParseSuspendedTabUrl,
} = globalThis.TabHarborTabUrlUtils || {};
const {
addSavedTabSession: runtimeAddSavedTabSession,
appendSavedTabSessionTabs: runtimeAppendSavedTabSessionTabs,
buildSessionSnapshot: runtimeBuildSessionSnapshot,
createRestoredSessionGroups: runtimeCreateRestoredSessionGroups,
getSavedTabSessions: runtimeGetSavedTabSessions,
} = globalThis.TabHarborTabSessions || {};
/* ----------------------------------------------------------------
CHROME TABS — Direct API Access
Since this page IS the extension's new tab page, it has full
access to chrome.tabs and chrome.storage. No middleman needed.
---------------------------------------------------------------- */
// Visible open tabs for this dashboard window — populated by fetchOpenTabs()
let openTabs = [];
let allOpenTabIds = [];
let sessionGroupsState = normalizeSessionGroups ? normalizeSessionGroups() : { groups: [], assignments: {} };
const MANUAL_GROUP_PREFIX = '__session_group__:';
const PAGE_CHIP_DRAG_DEBUG = false;
const SESSION_GROUPS_KEY = 'sessionGroups';
const IMPORTED_CHROME_GROUPS_KEY = 'importedChromeSessionGroups';
let groupOrderState = normalizeGroupOrderState ? normalizeGroupOrderState() : { sessionOrder: [], pinnedOrder: [], pinEnabled: false };
const GROUP_ORDER_KEY = 'groupOrder';
let groupTabOrderState = {};
const GROUP_TAB_ORDER_KEY = 'groupTabOrder';
let groupLabelOverrides = {};
const GROUP_LABEL_OVERRIDES_KEY = 'groupLabelOverrides';
let groupRenameEditorState = null;
let draggedGroupId = '';
let dragStartPoint = null;
let suppressJumpUntil = 0;
let suppressPageChipClickUntil = 0;
let draggedGroupButtonEl = null;
let dragPlaceholderEl = null;
let draggedDrawerItemId = '';
let draggedDrawerItemEl = null;
let drawerItemDragState = null;
let drawerItemPlaceholderEl = null;
let draggedPageChipId = '';
let draggedPageChipEl = null;
let pageChipDragState = null;
let pageChipPlaceholderEl = null;
let pageChipNewGroupSlotEl = null;
let tabSessionPickerState = {
open: false,
mode: 'new',
source: 'current-window',
selectedTabIds: [],
newSessionName: '',
targetSessionId: '',
windowId: null,
groups: [],
savedSessions: [],
};
let chromeTabGroupsEnabled = false;
let sleepControlEnabled = false;
let importedChromeGroupMeta = normalizeChromeImportedGroupMeta
? normalizeChromeImportedGroupMeta(runtimeEmptyChromeImportedMeta)
: { entries: [] };
let chromeTabGroupsImportTimer = null;
let chromeTabGroupsUnsubscribe = null;
let chromeTabGroupsImportInFlight = false;
let suppressChromeTabGroupsImportUntil = 0;
let currentDashboardTabId = null;
let currentDashboardWindowId = null;
let dashboardStartupTabChangeIgnoreUntil = 0;
const ENTRY_ANIMATIONS_CLASS = 'entry-animations-enabled';
let entryAnimationsTimer = null;
const CHROME_TAB_GROUPS_DEBUG_KEY = 'chromeTabGroupsDebug';
const HITOKOTO_CACHE_KEY = 'hitokotoCache';
const HITOKOTO_CACHE_LIMIT = 5;
function reorderVisibleItemsByIds(items, orderIds, includeItem) {
if (reorderSubsetByIds) {
return reorderSubsetByIds(items, orderIds, includeItem);
}
if (!Array.isArray(items)) return [];
const list = items.slice();
const shouldInclude = typeof includeItem === 'function' ? includeItem : () => true;
const subset = list.filter(shouldInclude);
const normalizedOrder = Array.isArray(orderIds) ? orderIds.map(id => String(id)).filter(Boolean) : [];
if (!subset.length || subset.length !== normalizedOrder.length) return list;
const subsetMap = new Map(subset.map(item => [String(item.id), item]));
if (normalizedOrder.some(id => !subsetMap.has(id))) return list;
let nextIndex = 0;
return list.map(item => {
if (!shouldInclude(item)) return item;
const nextItem = subsetMap.get(normalizedOrder[nextIndex]);
nextIndex += 1;
return nextItem || item;
});
}
function normalizeGroupTabOrderState(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
return Object.fromEntries(
Object.entries(input)
.map(([groupKey, orderIds]) => [
String(groupKey),
Array.isArray(orderIds)
? [...new Set(orderIds.map(id => String(id)).filter(Boolean))]
: [],
])
.filter(([, orderIds]) => orderIds.length > 0)
);
}
function normalizeGroupLabelOverrides(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
return Object.fromEntries(
Object.entries(input)
.map(([groupKey, label]) => [String(groupKey), String(label || '').trim()])
.filter(([, label]) => Boolean(label))
);
}
function getTabOrderTokens(tab) {
const tokens = [];
if (tab?.id != null) tokens.push(String(tab.id));
if (tab?.url) tokens.push(String(tab.url));
return [...new Set(tokens.filter(Boolean))];
}
function getPrimaryTabOrderToken(tab) {
return getTabOrderTokens(tab)[0] || '';
}
function pruneGroupTabOrderState(state, groups = []) {
const normalized = normalizeGroupTabOrderState(state);
const groupMap = new Map(
groups.map(group => [
String(group.domain),
new Set(
(group.tabs || [])
.flatMap(tab => getTabOrderTokens(tab))
.filter(Boolean)
),
])
);
return Object.fromEntries(
Object.entries(normalized)
.map(([groupKey, orderIds]) => {
const validTokens = groupMap.get(String(groupKey));
if (!validTokens) return null;
const filtered = orderIds.filter(token => validTokens.has(token));
return filtered.length > 0 ? [String(groupKey), filtered] : null;
})
.filter(Boolean)
);
}
async function loadGroupTabOrder(groups = []) {
const stored = await chrome.storage.local.get(GROUP_TAB_ORDER_KEY);
const nextState = normalizeGroupTabOrderState(stored[GROUP_TAB_ORDER_KEY]);
const prunedState = pruneGroupTabOrderState(nextState, groups);
groupTabOrderState = prunedState;
await chrome.storage.local.set({ [GROUP_TAB_ORDER_KEY]: prunedState });
return prunedState;
}
async function saveGroupTabOrder(nextState) {
groupTabOrderState = normalizeGroupTabOrderState(nextState);
await chrome.storage.local.set({ [GROUP_TAB_ORDER_KEY]: groupTabOrderState });
return groupTabOrderState;
}
async function loadGroupLabelOverrides() {
const stored = await chrome.storage.local.get(GROUP_LABEL_OVERRIDES_KEY);
groupLabelOverrides = normalizeGroupLabelOverrides(stored[GROUP_LABEL_OVERRIDES_KEY]);
return groupLabelOverrides;
}
async function saveGroupLabelOverrides(nextState) {
groupLabelOverrides = normalizeGroupLabelOverrides(nextState);
await chrome.storage.local.set({ [GROUP_LABEL_OVERRIDES_KEY]: groupLabelOverrides });
return groupLabelOverrides;
}
function reorderGroupTabsByStoredUrls(tabs, groupKey) {
const orderIds = groupTabOrderState[String(groupKey)] || [];
if (!Array.isArray(tabs) || !tabs.length || !orderIds.length) return Array.isArray(tabs) ? tabs.slice() : [];
const orderIndex = new Map(orderIds.map((id, index) => [String(id), index]));
return tabs
.map((tab, originalIndex) => {
const match = getTabOrderTokens(tab)
.map(token => orderIndex.get(token))
.find(index => Number.isInteger(index));
return {
tab,
originalIndex,
order: Number.isInteger(match) ? match : Number.MAX_SAFE_INTEGER,
};
})
.sort((a, b) => a.order - b.order || a.originalIndex - b.originalIndex)
.map(entry => entry.tab);
}
function getOrderedUniqueTabsForGroup(group) {
const tabs = Array.isArray(group?.tabs) ? group.tabs : [];
return reorderGroupTabsByStoredUrls(tabs, group?.domain);
}
function getTabsOrderedForChromeSync(group) {
const tabs = Array.isArray(group?.tabs) ? group.tabs : [];
const orderIds = groupTabOrderState[String(group?.domain)] || [];
if (!orderIds.length) return tabs.slice();
const orderIndex = new Map(orderIds.map((id, index) => [String(id), index]));
return tabs
.map((tab, originalIndex) => ({
tab,
originalIndex,
order: (() => {
const match = getTabOrderTokens(tab)
.map(token => orderIndex.get(token))
.find(index => Number.isInteger(index));
return Number.isInteger(match) ? match : Number.MAX_SAFE_INTEGER;
})(),
}))
.sort((a, b) => a.order - b.order || a.originalIndex - b.originalIndex)
.map(entry => entry.tab);
}
function getChromeSyncGroups(groups = domainGroups) {
return (Array.isArray(groups) ? groups : []).map(group => ({
...group,
tabs: getTabsOrderedForChromeSync(group),
}));
}
function isManualGroupKey(groupKey = '') {
return String(groupKey || '').startsWith(MANUAL_GROUP_PREFIX);
}
function getManualGroupIdFromGroupKey(groupKey = '') {
return isManualGroupKey(groupKey) ? String(groupKey).slice(MANUAL_GROUP_PREFIX.length) : '';
}
function getDomainGroupByKey(groupKey = '') {
return domainGroups.find(group => String(group?.domain) === String(groupKey)) || null;
}
function getGroupDisplayLabel(group) {
if (!group) return 'Group';
if (group.domain === '__landing-pages__') {
return groupLabelOverrides[group.domain] || (runtimeT ? runtimeT('homepagesLabel') : 'Homepages');
}
return String(groupLabelOverrides[group.domain] || group.label || friendlyDomain(group.domain) || 'Group').trim() || 'Group';
}
function createUniqueSessionGroupName(baseName, groups = sessionGroupsState.groups, excludeGroupId = '') {
const fallbackName = String(baseName || 'Group').trim() || 'Group';
const lowerFallback = fallbackName.toLowerCase();
const takenNames = new Set(
(groups || [])
.filter(group => group && String(group.id || '') !== String(excludeGroupId || ''))
.map(group => String(group.name || '').trim().toLowerCase())
.filter(Boolean)
);
if (!takenNames.has(lowerFallback)) return fallbackName;
let suffix = 2;
while (takenNames.has(`${lowerFallback} ${suffix}`)) suffix += 1;
return `${fallbackName} ${suffix}`;
}
function openGroupRenameEditor(groupKey, manualGroupId = '') {
const group = getDomainGroupByKey(groupKey);
if (!group) return;
groupRenameEditorState = {
groupKey: String(groupKey || ''),
manualGroupId: String(manualGroupId || ''),
value: getGroupDisplayLabel(group),
shouldFocus: true,
};
void renderDashboard();
}
function closeGroupRenameEditor() {
groupRenameEditorState = null;
}
async function submitGroupRenameEditor() {
if (!groupRenameEditorState) return;
const { groupKey, manualGroupId } = groupRenameEditorState;
const group = getDomainGroupByKey(groupKey);
if (!group) {
closeGroupRenameEditor();
return;
}
const currentLabel = getGroupDisplayLabel(group);
const cleanName = String(groupRenameEditorState.value || '').trim();
if (!cleanName || cleanName === currentLabel) {
closeGroupRenameEditor();
await renderDashboard();
return;
}
try {
if (manualGroupId) {
const nextState = renameSessionGroup(sessionGroupsState, manualGroupId, cleanName);
await saveSessionGroups(nextState);
} else {
await saveGroupLabelOverrides({
...groupLabelOverrides,
[groupKey]: cleanName,
});
}
closeGroupRenameEditor();
await renderDashboard();
showToast(runtimeT ? runtimeT('toastRenamedGroup', { name: cleanName }) : `Renamed to ${cleanName}`);
} catch (err) {
showToast(err.message || (runtimeT ? runtimeT('toastCouldNotCreateGroup') : 'Could not create group'));
}
}
function deriveDraggedTabGroupName(tab) {
const rawTitle = cleanTitle(
smartTitle(stripTitleNoise(tab?.title || ''), tab?.url || ''),
tab?.url || ''
);
const title = String(rawTitle || '').trim();
if (title) return title;
try {
const parsed = new URL(String(tab?.url || ''));
return friendlyDomain(parsed.hostname || parsed.host || parsed.href || 'Group');
} catch {
return 'Group';
}
}
function getTabIdsForGroupChip(groupKey, chipSortId) {
const group = getDomainGroupByKey(groupKey);
if (!group) return [];
return (group.tabs || [])
.filter(tab => getTabOrderTokens(tab).includes(String(chipSortId || '')))
.map(tab => Number(tab?.id))
.filter(Number.isFinite);
}
function getOrderedIdsForGroup(groupKey) {
const group = getDomainGroupByKey(groupKey);
return getOrderedUniqueTabsForGroup(group).map(tab => getPrimaryTabOrderToken(tab)).filter(Boolean);
}
function buildOrderedIdsFromList(listEl, draggedId) {
if (!listEl) return [];
return [...listEl.children]
.map(node => {
if (node === pageChipPlaceholderEl) return String(draggedId || '');
return node.dataset?.chipSortId || '';
})
.filter(Boolean);
}
function logPageChipDragDebug(stage, details = {}) {
if (!PAGE_CHIP_DRAG_DEBUG) return;
const payload = Object.entries(details)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
void (payload ? `${stage} ${payload}` : stage);
}
function clearPageChipDropPreview() {
document.querySelectorAll('.mission-card.is-drop-target').forEach(card => {
card.classList.remove('is-drop-target');
});
document.body.classList.remove('page-chip-drop-new-group');
pageChipNewGroupSlotEl?.remove();
pageChipNewGroupSlotEl = null;
}
function setPageChipDropPreview(cardEl, {
createNewGroup = false,
newGroupPlacement = 'after',
insertBeforeCardEl = null,
} = {}) {
clearPageChipDropPreview();
if (cardEl) cardEl.classList.add('is-drop-target');
if (createNewGroup) {
document.body.classList.add('page-chip-drop-new-group');
const missionsEl = document.getElementById('openTabsMissions');
if (missionsEl) {
pageChipNewGroupSlotEl = document.createElement('div');
pageChipNewGroupSlotEl.className = 'mission-drop-new-group-slot';
pageChipNewGroupSlotEl.innerHTML = '<span class="mission-drop-new-group-line"></span>';
const firstCard = missionsEl.querySelector('.mission-card');
if (insertBeforeCardEl) {
missionsEl.insertBefore(pageChipNewGroupSlotEl, insertBeforeCardEl);
} else if (newGroupPlacement === 'before' && firstCard) {
missionsEl.insertBefore(pageChipNewGroupSlotEl, firstCard);
} else {
missionsEl.appendChild(pageChipNewGroupSlotEl);
}
}
}
}
function getPageChipDropTarget(clientX, clientY) {
const missionsEl = document.getElementById('openTabsMissions');
const missionCards = missionsEl ? [...missionsEl.querySelectorAll('.mission-card')] : [];
if (!missionsEl || !missionCards.length) return null;
const firstCardRect = missionCards[0].getBoundingClientRect();
const lastCardRect = missionCards[missionCards.length - 1].getBoundingClientRect();
const edgeThreshold = 18;
const gapThreshold = 24;
if (clientY <= firstCardRect.top + edgeThreshold) {
return { kind: 'new-group', placement: 'before', reason: 'top-edge' };
}
if (clientY >= lastCardRect.bottom - edgeThreshold) {
return { kind: 'new-group', placement: 'after', reason: 'bottom-edge' };
}
for (let index = 0; index < missionCards.length - 1; index += 1) {
const currentRect = missionCards[index].getBoundingClientRect();
const nextCardEl = missionCards[index + 1];
const nextRect = nextCardEl.getBoundingClientRect();
const gapTop = currentRect.bottom - gapThreshold;
const gapBottom = nextRect.top + gapThreshold;
if (clientY >= gapTop && clientY <= gapBottom) {
return {
kind: 'new-group',
placement: 'before',
insertBeforeCardEl: nextCardEl,
reason: 'card-gap',
};
}
}
for (const cardEl of missionCards) {
const rect = cardEl.getBoundingClientRect();
const withinCardY = clientY >= rect.top && clientY <= rect.bottom;
if (!withinCardY) continue;
const listEl = cardEl.querySelector('.mission-pages');
const groupKey = cardEl.dataset?.groupId || '';
const listRect = listEl?.getBoundingClientRect?.();
const sourceGroupKey = pageChipDragState?.sourceGroupKey || '';
const isSourceGroup = groupKey === sourceGroupKey;
const cardEdgeThreshold = 16;
if (listEl && groupKey && listRect && isSourceGroup) {
if (clientY <= listRect.top - cardEdgeThreshold) {
return { kind: 'new-group', placement: 'before', insertBeforeCardEl: cardEl, reason: 'source-card-top-gap' };
}
if (clientY >= listRect.bottom + cardEdgeThreshold) {
return { kind: 'new-group', placement: 'after', reason: 'source-card-bottom-gap' };
}
}
if (listEl && groupKey) {
return { kind: 'group', cardEl, listEl, groupKey };
}
}
return null;
}
function reassignTabsToSessionGroup(state, tabIds, groupId) {
let nextState = state;
for (const tabId of tabIds) {
nextState = assignTabToSessionGroup(nextState, tabId, groupId);
}
return nextState;
}
function clearTabsFromSessionGroups(state, tabIds) {
let nextState = state;
for (const tabId of tabIds) {
nextState = clearTabSessionGroup(nextState, tabId);
}
return nextState;
}
function ensureManualDropGroup(state, targetGroupKey) {
const targetManualGroupId = getManualGroupIdFromGroupKey(targetGroupKey);
if (targetManualGroupId) {
return {
nextState: state,
groupId: targetManualGroupId,
groupName: state.groups.find(group => group.id === targetManualGroupId)?.name || '',
};
}
const targetGroup = getDomainGroupByKey(targetGroupKey);
if (!targetGroup) throw new Error('Group not found');
const baseName = getGroupDisplayLabel(targetGroup);
const nextName = createUniqueSessionGroupName(baseName, state.groups);
const created = addSessionGroup(state, nextName);
const targetTabIds = (targetGroup.tabs || [])
.map(tab => Number(tab?.id))
.filter(Number.isFinite);
return {
nextState: reassignTabsToSessionGroup(created.state, targetTabIds, created.group.id),
groupId: created.group.id,
groupName: created.group.name,
};
}
/**
* fetchOpenTabs()
*
* Reads all currently open browser tabs directly from Chrome.
* Sets the extensionId flag so we can identify Tab Harbor's own pages.
*/
/* ----------------------------------------------------------------
Hitokoto helper
---------------------------------------------------------------- */
async function fetchHitokoto(timeoutMs = 3000) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch('https://v1.hitokoto.cn/', { signal: controller.signal });
clearTimeout(timeoutId);
if (!response || !response.ok) return null;
const data = await response.json();
return data || null;
} catch (err) {
return null;
}
}
function normalizeHitokotoEntry(data) {
if (!data || typeof data !== 'object') return null;
const hitokoto = String(data.hitokoto || '').trim();
if (!hitokoto) return null;
return {
hitokoto,
from_who: String(data.from_who || '').trim(),
from: String(data.from || '').trim(),
};
}
function getHitokotoCache() {
try {
const parsed = JSON.parse(localStorage.getItem(HITOKOTO_CACHE_KEY) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map(normalizeHitokotoEntry).filter(Boolean).slice(0, HITOKOTO_CACHE_LIMIT);
} catch (_err) {
return [];
}
}
function saveHitokotoCache(nextEntries) {
try {
const entries = Array.isArray(nextEntries) ? nextEntries : [];
localStorage.setItem(HITOKOTO_CACHE_KEY, JSON.stringify(entries.slice(0, HITOKOTO_CACHE_LIMIT)));
} catch (_err) {
// Cache writes are best-effort; the dashboard should stay usable without them.
}
}
function addHitokotoToCache(data) {
const entry = normalizeHitokotoEntry(data);
if (!entry) return null;
const existing = getHitokotoCache().filter(item => item.hitokoto !== entry.hitokoto);
saveHitokotoCache([entry, ...existing]);
return entry;
}
function setHitokotoContent(textEl, fromEl, data) {
const entry = normalizeHitokotoEntry(data);
if (!entry || !textEl || !fromEl) return false;
textEl.textContent = entry.hitokoto;
const from = [entry.from_who, entry.from].filter(Boolean).join(' · ');
fromEl.textContent = from ? ` — ${from}` : '';
return true;
}
function renderCachedHitokoto(textEl, fromEl) {
return setHitokotoContent(textEl, fromEl, getHitokotoCache()[0]);
}
function refreshHitokotoInBackground(textEl, fromEl) {
fetchHitokoto().then(data => {
if (typeof themePreferences !== 'undefined' && themePreferences.hitokotoEnabled === false) return;
const entry = addHitokotoToCache(data);
if (!entry) return;
if (setHitokotoContent(textEl, fromEl, entry)) {
const hitokotoEl = document.getElementById('hitokoto');
if (hitokotoEl) hitokotoEl.style.display = '';
}
}).catch(() => { /* silently fail */ });
}
function warmHitokotoCacheInBackground() {
fetchHitokoto().then(data => {
if (typeof themePreferences !== 'undefined' && themePreferences.hitokotoEnabled === false) return;
addHitokotoToCache(data);
}).catch(() => { /* silently fail */ });
}
async function fetchOpenTabs() {
try {
const newtabUrl = window.location.href;
const currentWindowId = await getDashboardWindowIdForOpenTabs();
const tabs = await chrome.tabs.query({});
allOpenTabIds = tabs.map(tab => tab?.id).filter(tabId => tabId != null);
const visibleTabs = currentWindowId == null
? tabs
: tabs.filter(t => t.windowId === currentWindowId);
openTabs = visibleTabs.map(t => {
const rawUrl = t.url || '';
const suspended = runtimeParseSuspendedTabUrl
? runtimeParseSuspendedTabUrl(rawUrl)
: { isSuspended: false, originalUrl: '', title: '' };
const canonicalUrl = runtimeGetCanonicalTabUrl
? runtimeGetCanonicalTabUrl(rawUrl)
: rawUrl;
return {
id: t.id,
rawUrl,
url: canonicalUrl,
title: suspended.title || t.title,
windowId: t.windowId,
active: t.active,
favIconUrl: t.favIconUrl || '',
isSuspended: Boolean(suspended.isSuspended),
discarded: t.discarded || false,
// Flag Tab Harbor's own pages so we can detect duplicate new tabs
isTabOut: rawUrl === newtabUrl || rawUrl === 'chrome://newtab/',
};
});
} catch {
// chrome.tabs API unavailable (shouldn't happen in an extension page)
openTabs = [];
allOpenTabIds = [];
}
}
function getOpenTabIdsForSessionPruning() {
return allOpenTabIds.length > 0 ? allOpenTabIds : openTabs.map(tab => tab.id);
}
async function queryTabsForDashboardWindow() {
const currentWindowId = await getDashboardWindowIdForOpenTabs();
if (currentWindowId == null) return chrome.tabs.query({});
try {
return await chrome.tabs.query({ windowId: currentWindowId });
} catch {
return chrome.tabs.query({});
}
}
async function loadSessionGroups(openTabIds = []) {
const stored = await chrome.storage.local.get(SESSION_GROUPS_KEY);
const nextState = normalizeSessionGroups(stored[SESSION_GROUPS_KEY]);
const prunedState = pruneSessionGroups(nextState, openTabIds);
sessionGroupsState = prunedState;
await chrome.storage.local.set({ [SESSION_GROUPS_KEY]: prunedState });
return prunedState;
}
async function saveSessionGroups(nextState) {
sessionGroupsState = normalizeSessionGroups(nextState);
await chrome.storage.local.set({ [SESSION_GROUPS_KEY]: sessionGroupsState });
return sessionGroupsState;
}
async function loadImportedChromeGroupMeta() {
if (typeof normalizeChromeImportedGroupMeta !== 'function') {
importedChromeGroupMeta = { entries: [] };
return importedChromeGroupMeta;
}
const stored = await chrome.storage.local.get(IMPORTED_CHROME_GROUPS_KEY);
importedChromeGroupMeta = normalizeChromeImportedGroupMeta(stored[IMPORTED_CHROME_GROUPS_KEY]);
return importedChromeGroupMeta;
}
async function saveImportedChromeGroupMeta(nextMeta) {
if (typeof normalizeChromeImportedGroupMeta !== 'function') {
importedChromeGroupMeta = { entries: [] };
return importedChromeGroupMeta;
}
importedChromeGroupMeta = normalizeChromeImportedGroupMeta(nextMeta);
await chrome.storage.local.set({ [IMPORTED_CHROME_GROUPS_KEY]: importedChromeGroupMeta });
return importedChromeGroupMeta;
}
async function saveChromeTabGroupsDebug(snapshot) {
await chrome.storage.local.set({
[CHROME_TAB_GROUPS_DEBUG_KEY]: {
...snapshot,
updatedAt: new Date().toISOString(),
},
});
}
function suppressChromeTabGroupsImport(durationMs = 1200) {
suppressChromeTabGroupsImportUntil = Math.max(
suppressChromeTabGroupsImportUntil,
Date.now() + Math.max(0, Number(durationMs) || 0)
);
if (chromeTabGroupsImportTimer) {
clearTimeout(chromeTabGroupsImportTimer);
chromeTabGroupsImportTimer = null;
}
}
function isChromeTabGroupsImportSuppressed() {
return Date.now() < suppressChromeTabGroupsImportUntil;
}
function disableEntryAnimations() {
if (entryAnimationsTimer) {
clearTimeout(entryAnimationsTimer);
entryAnimationsTimer = null;
}
document.body.classList.remove(ENTRY_ANIMATIONS_CLASS);
}
function primeEntryAnimations(durationMs = 1200) {
if (!document.body) return;
document.body.classList.add(ENTRY_ANIMATIONS_CLASS);
if (entryAnimationsTimer) clearTimeout(entryAnimationsTimer);
entryAnimationsTimer = setTimeout(() => {
document.body.classList.remove(ENTRY_ANIMATIONS_CLASS);
entryAnimationsTimer = null;
}, Math.max(0, Number(durationMs) || 0));
}
async function syncChromeTabGroupsWithoutImportEcho() {
if (typeof syncChromeTabGroups !== 'function') return;
suppressChromeTabGroupsImport();
await syncChromeTabGroups(getChromeSyncGroups(domainGroups));
}
function disableChromeTabGroupsImportModeForLocalEdits() {
if (!chromeTabGroupsEnabled) return;
if (typeof setImportMode === 'function') setImportMode(false);
}
function shouldImportChromeGroupsIntoSessionState() {
if (!chromeTabGroupsEnabled) return false;
const groupCount = Array.isArray(sessionGroupsState?.groups) ? sessionGroupsState.groups.length : 0;
const assignmentCount = sessionGroupsState?.assignments ? Object.keys(sessionGroupsState.assignments).length : 0;
return groupCount === 0 && assignmentCount === 0;
}
function ensureChromeTabGroupsSubscription() {
if (!chromeTabGroupsEnabled || typeof subscribeToChromeTabGroupChanges !== 'function') {
if (chromeTabGroupsImportTimer) {
clearTimeout(chromeTabGroupsImportTimer);
chromeTabGroupsImportTimer = null;
}
if (chromeTabGroupsUnsubscribe) {
chromeTabGroupsUnsubscribe();
chromeTabGroupsUnsubscribe = null;
}
return;
}
if (chromeTabGroupsUnsubscribe) return;
chromeTabGroupsUnsubscribe = subscribeToChromeTabGroupChanges(() => {
if (isChromeTabGroupsImportSuppressed()) return;
scheduleChromeTabGroupsImport();
});
}
async function importChromeNativeGroupsIntoSessionGroups() {
if (!chromeTabGroupsEnabled ||
typeof reconcileChromeTabGroupImports !== 'function' ||
typeof queryExistingChromeGroups !== 'function') {
await saveChromeTabGroupsDebug({
stage: 'import-skipped',
enabled: chromeTabGroupsEnabled,
hasReconcile: typeof reconcileChromeTabGroupImports === 'function',
hasQuery: typeof queryExistingChromeGroups === 'function',
});
return 0;
}
const chromeGroups = await queryExistingChromeGroups();
const nativeGroups = [];
for (const chromeGroup of chromeGroups) {
const groupedTabs = await chrome.tabs.query({ groupId: chromeGroup.id }).catch(() => []);
const tabIds = groupedTabs.map(tab => tab.id).filter(tabId => tabId != null);
if (!tabIds.length) continue;
nativeGroups.push({
chromeGroupId: chromeGroup.id,
windowId: chromeGroup.windowId != null ? chromeGroup.windowId : (groupedTabs[0]?.windowId ?? 0),
title: chromeGroup.title || 'Group',
color: chromeGroup.color || 'grey',
tabIds,
});
}
const result = reconcileChromeTabGroupImports({
currentState: sessionGroupsState,
importedMeta: importedChromeGroupMeta,
nativeGroups,
});
await saveSessionGroups(result.state);
await saveImportedChromeGroupMeta(result.importedMeta);
if (typeof populateChromeGroupMap === 'function') {
await populateChromeGroupMap(result.mappings);
}
await saveChromeTabGroupsDebug({
stage: 'import-finished',
chromeGroupCount: chromeGroups.length,
importedNativeGroupCount: nativeGroups.length,
sessionGroupCount: result.state.groups.length,
assignmentCount: Object.keys(result.state.assignments).length,
importedMetaCount: result.importedMeta.entries.length,
chromeGroupTitles: chromeGroups.map(group => group?.title || '(untitled)'),
nativeGroups,
});
return nativeGroups.length;
}
function scheduleChromeTabGroupsImport() {
if (!chromeTabGroupsEnabled) return;
if (chromeTabGroupsImportTimer) clearTimeout(chromeTabGroupsImportTimer);
chromeTabGroupsImportTimer = setTimeout(async () => {
chromeTabGroupsImportTimer = null;
if (chromeTabGroupsImportInFlight) {
scheduleChromeTabGroupsImport();
return;
}
chromeTabGroupsImportInFlight = true;
try {
await fetchOpenTabs();
const realTabs = getRealTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await loadImportedChromeGroupMeta();
if (!shouldImportChromeGroupsIntoSessionState()) {
disableChromeTabGroupsImportModeForLocalEdits();
return;
}
const importedCount = await importChromeNativeGroupsIntoSessionGroups();
if (typeof setImportMode === 'function') setImportMode(importedCount > 0);
disableChromeTabGroupsImportModeForLocalEdits();
window.__suppressAutoRefreshUntil = Date.now() + 2000;
await renderDashboard();
if (window.__tabRefreshTimeout) {
clearTimeout(window.__tabRefreshTimeout);
window.__tabRefreshTimeout = null;
}
window.__suppressAutoRefreshUntil = 0;
} finally {
chromeTabGroupsImportInFlight = false;
}
}, 120);
}
async function applyChromeTabGroupsToggle(nextEnabled) {
const enable = Boolean(nextEnabled);
await saveChromeTabGroupsSetting(enable);
chromeTabGroupsEnabled = enable;
await fetchOpenTabs();
const realTabs = getRealTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await loadImportedChromeGroupMeta();
let importedCount = 0;
if (enable && shouldImportChromeGroupsIntoSessionState()) {
importedCount = await importChromeNativeGroupsIntoSessionGroups();
} else if (!enable && typeof reconcileChromeTabGroupImports === 'function') {
const cleared = reconcileChromeTabGroupImports({
currentState: sessionGroupsState,
importedMeta: importedChromeGroupMeta,
nativeGroups: [],
});
await saveSessionGroups(cleared.state);
await saveImportedChromeGroupMeta(cleared.importedMeta);
}
ensureChromeTabGroupsSubscription();
if (typeof setImportMode === 'function') setImportMode(importedCount > 0);
disableChromeTabGroupsImportModeForLocalEdits();
window.__suppressAutoRefreshUntil = Date.now() + 2000;
await renderDashboard();
if (window.__tabRefreshTimeout) {
clearTimeout(window.__tabRefreshTimeout);
window.__tabRefreshTimeout = null;
}
window.__suppressAutoRefreshUntil = 0;
showToast(enable
? (runtimeT ? runtimeT('toastChromeTabGroupsOn') : 'Chrome tab groups on')
: (runtimeT ? runtimeT('toastChromeTabGroupsOff') : 'Chrome tab groups off'));
}
async function loadGroupOrder() {
const stored = await chrome.storage.local.get(GROUP_ORDER_KEY);
groupOrderState = normalizeGroupOrderState(stored[GROUP_ORDER_KEY]);
return groupOrderState;
}
async function saveGroupOrder(nextState) {
groupOrderState = normalizeGroupOrderState(nextState);
await chrome.storage.local.set({ [GROUP_ORDER_KEY]: groupOrderState });
return groupOrderState;
}
function updateGroupNavButtonIcon(groupKey) {
const group = domainGroups.find(item => String(item.domain) === String(groupKey));
const button = document.querySelector(`.group-nav-button[data-group-id="${CSS.escape(String(groupKey))}"]`);
if (!group || !button || !runtimeGetGroupIcon) return;
const label = group.domain === '__landing-pages__' ? (runtimeT ? runtimeT('homepagesLabel') : 'Homepages') : (group.label || friendlyDomain(group.domain));
const orderedGroup = {
...group,
tabs: getOrderedUniqueTabsForGroup(group),
};
const iconData = runtimeGetGroupIcon(orderedGroup, label, 32);
const img = button.querySelector('.group-nav-icon');
const fallback = button.querySelector('.group-nav-fallback');
if (img && iconData.src) {
img.src = iconData.src;
if (typeof runtimeSetImageFallbackAttributes === 'function') {
runtimeSetImageFallbackAttributes(img, iconData.fallbackSources || iconData.fallbackSrc);
}
img.style.display = '';
if (fallback) {
fallback.textContent = iconData.fallbackLabel;
fallback.style.display = 'none';
}
return;
}
if (img) img.style.display = 'none';
if (fallback) {
fallback.textContent = iconData.fallbackLabel;
fallback.style.display = '';
}
}
function animatePageChipItems(listEl, previousRects) {
listEl?.querySelectorAll('[data-chip-sort-id]').forEach(item => {
if (item.classList.contains('is-dragging')) return;
const key = item.dataset.chipSortId || '';
const previousRect = previousRects.get(key);
if (!previousRect) return;
const nextRect = item.getBoundingClientRect();
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
if (!deltaX && !deltaY) return;
item.style.transition = 'none';
item.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
requestAnimationFrame(() => {
item.style.transition = 'transform 0.16s ease';
item.style.transform = '';
});
});
}
function syncGroupOrderState(orderKeys) {
groupOrderState = normalizeGroupOrderState({
...groupOrderState,
sessionOrder: orderKeys,
pinnedOrder: orderKeys,
pinEnabled: false,
});
return groupOrderState;
}
function getStableGroupId(groupKey) {
return 'domain-' + String(groupKey).replace(/[^a-z0-9]/g, '-');
}
function getTabIdValue(tabId) {
const id = Number(tabId);
return Number.isFinite(id) ? id : null;
}
async function getCurrentWindowId() {
const currentWindow = await chrome.windows.getCurrent();
return currentWindow?.id;
}
async function getDashboardWindowIdForOpenTabs() {
const currentTab = await resolveCurrentDashboardTab();
if (currentTab?.windowId != null) {
currentDashboardWindowId = currentTab.windowId;
return currentDashboardWindowId;
}
try {
currentDashboardWindowId = await getCurrentWindowId();
} catch {
currentDashboardWindowId = null;
}
return currentDashboardWindowId;
}
function getTabCanonicalUrl(tab) {
return runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(tab?.url || '') : String(tab?.url || '');
}
function getTabsByIds(tabIds = [], tabs = openTabs) {
const selectedIds = new Set((tabIds || []).map(String).filter(Boolean));
return (Array.isArray(tabs) ? tabs : []).filter(tab => selectedIds.has(String(tab?.id)));
}
function getTabGroupLookup(groups = domainGroups) {
const lookup = new Map();
for (const group of Array.isArray(groups) ? groups : []) {
const groupEntry = {
key: String(group?.domain || ''),
label: getGroupDisplayLabel(group),
manualGroupId: String(group?.manualGroupId || ''),
};
for (const tab of Array.isArray(group?.tabs) ? group.tabs : []) {
if (tab?.id != null) lookup.set(String(tab.id), groupEntry);
if (tab?.url) lookup.set(String(tab.url), groupEntry);
}
}
return lookup;
}
async function refreshTabSessionModel() {
await fetchOpenTabs();
const realTabs = getRealTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await loadGroupOrder();
await loadGroupLabelOverrides();
await buildDomainGroups(realTabs);
return realTabs;
}
async function saveTabsAsSession(tabs, selectedTabIds, source = 'manual', name = '') {
if (!runtimeBuildSessionSnapshot || !runtimeAddSavedTabSession) {
throw new Error('Session storage is unavailable');
}
const selectedIds = new Set((selectedTabIds || []).map(String).filter(Boolean));
const snapshot = runtimeBuildSessionSnapshot({
tabs,
groupLookup: getTabGroupLookup(),
selectedTabIds: [...selectedIds],
source,
name,
now: new Date().toISOString(),
});
const savedSessions = await runtimeAddSavedTabSession(snapshot);
const tabIdsToClose = (Array.isArray(tabs) ? tabs : [])
.filter(tab => selectedIds.has(String(tab?.id)) && (runtimeIsRestorableTabUrl ? runtimeIsRestorableTabUrl(tab?.url || '') : true))
.map(tab => getTabIdValue(tab?.id))
.filter(Number.isFinite);
if (tabIdsToClose.length > 0) {
await chrome.tabs.remove(tabIdsToClose);
}
await renderDashboard();
return {
session: snapshot,
sessions: savedSessions,
closedTabIds: tabIdsToClose,
};
}
async function saveCurrentWindowTabSession() {
const realTabs = await refreshTabSessionModel();
const currentWindowId = await getCurrentWindowId();
const windowTabs = realTabs.filter(tab => tab.windowId === currentWindowId);
const result = await saveTabsAsSession(
windowTabs,
windowTabs.map(tab => String(tab.id)),
'current-window'
);
return result;
}
async function saveSelectedTabSession(tabIds = [], source = 'selected', name = '') {
const realTabs = await refreshTabSessionModel();
const selectedTabs = getTabsByIds(tabIds, realTabs);
const result = await saveTabsAsSession(selectedTabs, tabIds, source, name);
return result;
}
async function appendTabsToExistingSavedSession(sessionId, tabIds = []) {
if (!runtimeBuildSessionSnapshot || !runtimeAppendSavedTabSessionTabs) {
throw new Error('Session storage is unavailable');
}
const realTabs = await refreshTabSessionModel();
const selectedTabs = getTabsByIds(tabIds, realTabs);
const selectedIds = new Set((tabIds || []).map(String).filter(Boolean));
const snapshot = runtimeBuildSessionSnapshot({
tabs: selectedTabs,
groupLookup: getTabGroupLookup(),
selectedTabIds: [...selectedIds],
source: 'append-existing',
now: new Date().toISOString(),
});
const result = await runtimeAppendSavedTabSessionTabs(sessionId, snapshot.tabs, {
skipDuplicateUrls: true,
});
const tabIdsToClose = selectedTabs
.filter(tab => selectedIds.has(String(tab?.id)) && (runtimeIsRestorableTabUrl ? runtimeIsRestorableTabUrl(tab?.url || '') : true))
.map(tab => getTabIdValue(tab?.id))
.filter(Number.isFinite);
if (tabIdsToClose.length > 0) {
await chrome.tabs.remove(tabIdsToClose);
}
return {
...result,
selectedCount: snapshot.tabs.length,
closedTabIds: tabIdsToClose,
};
}
async function submitTodoEditor() {
const editorState = getTodoEditorState ? getTodoEditorState() : {};
const title = String(editorState.title || '').trim();
const description = String(editorState.description || '').trim();
if (!title) {
setTodoEditorError(runtimeT ? runtimeT('todoTitleRequired') : 'Add a title before saving.');
await renderDeferredColumn();
focusTodoEditorTitle();
return;
}
if (editorState.mode === 'edit') {
if (!editorState.todoId) return;
await updateTodoItem(editorState.todoId, { title, description });
closeTodoEditor();
showToast(runtimeT ? runtimeT('toastTodoUpdated') : 'Todo updated');
await renderDeferredColumn();
return;
}
await createTodoItem({ title, description });
closeTodoEditor();
drawerView = 'todos';
todoDetailId = '';
await renderDeferredColumn();
}
async function getTabSessionPickerContext() {
await refreshTabSessionModel();
const currentWindowId = await getCurrentWindowId();
const groups = domainGroups
.map(group => ({
key: String(group.domain),
label: getGroupDisplayLabel(group),
manualGroupId: String(group.manualGroupId || ''),
tabs: getOrderedUniqueTabsForGroup(group)
.filter(tab => tab.windowId === currentWindowId)
.filter(tab => runtimeIsRestorableTabUrl ? runtimeIsRestorableTabUrl(tab.url || '') : true)
.map(tab => ({
id: tab.id,
url: tab.url,
rawUrl: tab.rawUrl || tab.url,
title: tab.title || tab.url,
favIconUrl: tab.favIconUrl || '',
windowId: tab.windowId,
})),
}))
.filter(group => group.tabs.length > 0);
return { groups };
}
function getTabSessionPickerSelectedIds() {
return [...new Set((tabSessionPickerState.selectedTabIds || []).map(String).filter(Boolean))];
}
function getTabSessionPickerAllIds(groups = []) {
return [...new Set((Array.isArray(groups) ? groups : [])
.flatMap(group => Array.isArray(group?.tabs) ? group.tabs : [])
.map(tab => String(tab?.id || ''))
.filter(Boolean))];
}
function getScopedTabSessionPickerGroups(groups = [], scopeTabIds = null) {
if (!Array.isArray(scopeTabIds)) return Array.isArray(groups) ? groups : [];
const scopeIds = new Set(scopeTabIds.map(id => String(id)).filter(Boolean));
if (!scopeIds.size) return [];
return (Array.isArray(groups) ? groups : [])
.map(group => ({
...group,
tabs: (Array.isArray(group?.tabs) ? group.tabs : [])
.filter(tab => scopeIds.has(String(tab?.id || ''))),
}))
.filter(group => group.tabs.length > 0);
}
function getTabSessionPickerGroupIds(groupKey = '', groups = []) {
const group = (Array.isArray(groups) ? groups : [])
.find(item => String(item?.key || item?.domain || '') === String(groupKey || ''));
return (group?.tabs || [])
.map(tab => String(tab?.id || ''))
.filter(Boolean);
}
function getSavedSessionOptionLabel(session = {}) {
const tabCount = Array.isArray(session?.tabs) ? session.tabs.length : 0;
const tabWord = runtimeT
? (tabCount === 1 ? runtimeT('tabsWordSingular') : runtimeT('tabsWordPlural'))
: `tab${tabCount === 1 ? '' : 's'}`;
return `${session.name || 'Saved tabs'} (${tabCount} ${tabWord})`;
}
async function openTabSessionPicker({
source = 'current-window',
initialTabIds = null,
scopeTabIds = null,
} = {}) {
const context = await getTabSessionPickerContext();
const savedSessions = runtimeGetSavedTabSessions ? await runtimeGetSavedTabSessions() : [];
const scopedGroups = getScopedTabSessionPickerGroups(context.groups, scopeTabIds);
const allIds = getTabSessionPickerAllIds(scopedGroups);
const allIdSet = new Set(allIds);
const initialSelectedIds = Array.isArray(initialTabIds)
? [...new Set(initialTabIds.map(id => String(id)).filter(id => allIdSet.has(id)))]
: allIds;
tabSessionPickerState = {
open: true,
mode: tabSessionPickerState.mode === 'existing' && savedSessions.length ? 'existing' : 'new',
source,
selectedTabIds: initialSelectedIds,
newSessionName: tabSessionPickerState.newSessionName || '',
targetSessionId: tabSessionPickerState.targetSessionId || String(savedSessions[0]?.id || ''),
windowId: await getCurrentWindowId(),
groups: scopedGroups,
savedSessions,
};
renderOpenTabsArea();
}
function closeTabSessionPicker() {
tabSessionPickerState = {
open: false,
mode: 'new',
source: 'current-window',
selectedTabIds: [],
newSessionName: '',
targetSessionId: '',
windowId: null,
groups: [],
savedSessions: [],
};
renderOpenTabsArea();
}
function renderTabSessionPicker(groups = tabSessionPickerState.groups) {
if (!tabSessionPickerState.open) return '';
const pickerGroups = Array.isArray(groups) ? groups : [];
const savedSessions = Array.isArray(tabSessionPickerState.savedSessions)
? tabSessionPickerState.savedSessions
: [];
const selectedIds = new Set(getTabSessionPickerSelectedIds());
const selectedCount = selectedIds.size;
const existingDisabled = savedSessions.length === 0;
const mode = tabSessionPickerState.mode === 'existing' && !existingDisabled ? 'existing' : 'new';
const targetSessionId = tabSessionPickerState.targetSessionId || String(savedSessions[0]?.id || '');
const safeTargetSessionId = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(targetSessionId) : targetSessionId.replace(/"/g, '&quot;');
const safeSelectLabel = runtimeEscapeHtmlAttribute
? runtimeEscapeHtmlAttribute(runtimeT ? runtimeT('sessionPickerTargetSessionLabel') : 'Target session')
: 'Target session';
const newSessionName = String(tabSessionPickerState.newSessionName || '');
const safeNewSessionName = runtimeEscapeHtmlAttribute
? runtimeEscapeHtmlAttribute(newSessionName)
: newSessionName.replace(/"/g, '&quot;');
const namePlaceholder = runtimeT ? runtimeT('sessionPickerNewSessionNamePlaceholder') : 'Group title (optional)';
const safeNamePlaceholder = runtimeEscapeHtmlAttribute
? runtimeEscapeHtmlAttribute(namePlaceholder)
: namePlaceholder.replace(/"/g, '&quot;');
const listHtml = pickerGroups.length
? pickerGroups.map(group => {
const groupKey = String(group.key || group.domain || '');
const safeGroupKey = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(groupKey) : groupKey.replace(/"/g, '&quot;');
const tabs = Array.isArray(group.tabs)
? (group.key ? group.tabs : getOrderedUniqueTabsForGroup(group))
.filter(tab => runtimeIsRestorableTabUrl ? runtimeIsRestorableTabUrl(tab.url || '') : true)
: [];
const groupIds = tabs.map(tab => String(tab?.id || '')).filter(Boolean);
const checked = groupIds.length > 0 && groupIds.every(id => selectedIds.has(id));
const rawGroupLabel = group.label || (group.domain ? getGroupDisplayLabel(group) : 'Group');
const groupLabel = runtimeEscapeHtml ? runtimeEscapeHtml(rawGroupLabel) : rawGroupLabel;
return `
<section class="session-picker-group">
<label class="session-picker-group-header">
<input type="checkbox" data-action="toggle-session-picker-group" data-group-key="${safeGroupKey}"${checked ? ' checked' : ''}>
<span class="session-picker-group-title">${groupLabel}</span>
<span class="session-picker-group-count">${tabs.length}</span>
</label>
<div class="session-picker-tabs">
${tabs.map(tab => {
const tabId = String(tab?.id || '');
const safeTabId = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(tabId) : tabId.replace(/"/g, '&quot;');
const safeTitle = runtimeEscapeHtml ? runtimeEscapeHtml(tab.title || tab.url || 'Tab') : tab.title || tab.url || 'Tab';
const safeUrlText = runtimeEscapeHtml ? runtimeEscapeHtml(getTabCanonicalUrl(tab)) : getTabCanonicalUrl(tab);
const iconData = runtimeGetIconSources ? runtimeGetIconSources(tab, 16) : { sources: [], hostname: '' };
const faviconUrl = iconData.sources?.[0] || '';
const fallbackUrl = iconData.sources?.[1] || '';
const fallbackSrcset = iconData.sources?.length > 2
? (runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(JSON.stringify(iconData.sources.slice(2))) : JSON.stringify(iconData.sources.slice(2)).replace(/"/g, '&quot;'))
: '';
const fallbackLabel = runtimeGetFallbackLabel ? runtimeGetFallbackLabel(tab.title || tab.url || '', iconData.hostname || '') : '?';
return `
<label class="session-picker-tab-row">
<input type="checkbox" data-action="toggle-session-picker-tab" data-tab-id="${safeTabId}"${selectedIds.has(tabId) ? ' checked' : ''}>
<span class="session-picker-tab-title">
${faviconUrl ? `<img src="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(faviconUrl) : faviconUrl}" alt="" data-fallback-src="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(fallbackUrl) : fallbackUrl}"${fallbackSrcset ? ` data-fallback-srcset="${fallbackSrcset}"` : ''}>` : ''}
<span class="inline-favicon-fallback"${faviconUrl ? ' style="display:none"' : ''}>${runtimeEscapeHtml ? runtimeEscapeHtml(fallbackLabel) : fallbackLabel}</span>
<span>${safeTitle}</span>
</span>
<span class="session-picker-tab-url">${safeUrlText}</span>
</label>`;
}).join('')}
</div>
</section>`;
}).join('')
: `<div class="session-picker-empty">${runtimeT ? runtimeT('sessionPickerEmpty') : 'No restorable tabs in this window.'}</div>`;
return `
<section class="session-picker" id="tabSessionPicker" aria-label="${runtimeT ? runtimeT('sessionPickerTitle') : 'Choose what to save'}">
<div class="session-picker-header">
<div>
<div class="session-picker-title">${runtimeT ? runtimeT('sessionPickerTitle') : 'Choose what to save'}</div>
</div>
<button class="group-action-icon group-action-close" type="button" data-action="close-session-picker" aria-label="${runtimeT ? runtimeT('sessionPickerClose') : 'Close tab picker'}">
${ICONS.close}
</button>
</div>
<div class="session-picker-list">${listHtml}</div>
<div class="session-picker-footer">
<span class="session-picker-count">${runtimeT ? runtimeT('sessionPickerSelectedCount', { count: selectedCount }) : `${selectedCount} selected`}</span>
<div class="session-picker-save-mode" role="group" aria-label="${runtimeT ? runtimeT('sessionPickerModeLabel') : 'Save mode'}">
<button class="session-picker-mode-button${mode === 'new' ? ' is-active' : ''}" type="button" data-action="select-session-picker-mode" data-session-picker-mode="new" aria-pressed="${mode === 'new'}">${runtimeT ? runtimeT('sessionPickerNewSession') : 'New group'}</button>
<button class="session-picker-mode-button${mode === 'existing' ? ' is-active' : ''}" type="button" data-action="select-session-picker-mode" data-session-picker-mode="existing" aria-pressed="${mode === 'existing'}"${existingDisabled ? ' disabled' : ''}>${runtimeT ? runtimeT('sessionPickerExistingSession') : 'Existing group'}</button>
</div>
<input class="session-picker-name-input" type="text" data-action="change-session-picker-new-name" value="${safeNewSessionName}" placeholder="${safeNamePlaceholder}" aria-label="${safeNamePlaceholder}"${mode === 'new' ? '' : ' hidden disabled'}>
<select class="session-picker-target-select" data-action="change-session-picker-target" aria-label="${safeSelectLabel}"${mode === 'existing' && !existingDisabled ? '' : ' hidden disabled'}>
${savedSessions.length
? savedSessions.map(session => {
const id = String(session?.id || '');
const safeId = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(id) : id.replace(/"/g, '&quot;');
const label = getSavedSessionOptionLabel(session);
return `<option value="${safeId}"${id === targetSessionId ? ' selected' : ''}>${runtimeEscapeHtml ? runtimeEscapeHtml(label) : label}</option>`;
}).join('')
: `<option value="">${runtimeT ? runtimeT('sessionPickerNoSavedSessions') : 'No saved sessions yet'}</option>`}
</select>
<button class="action-btn save-tabs" type="button" data-action="save-selected-session-tabs"${selectedCount ? '' : ' disabled'}>
${mode === 'existing'
? (runtimeT ? runtimeT('sessionPickerAddToExisting') : 'Add to session')
: (runtimeT ? runtimeT('sessionPickerSaveAndClose') : 'Save and close')}
</button>
</div>
</section>`;
}
async function submitTabSessionPicker() {
const selectedIds = getTabSessionPickerSelectedIds();
if (!selectedIds.length) {
showToast(runtimeT ? runtimeT('toastNoSessionTabsSelected') : 'Select at least one tab');
return;
}
try {
if (tabSessionPickerState.mode === 'existing') {
const targetSessionId = tabSessionPickerState.targetSessionId || String(tabSessionPickerState.savedSessions?.[0]?.id || '');
if (!targetSessionId) throw new Error('Session not found');
const result = await appendTabsToExistingSavedSession(targetSessionId, selectedIds);
tabSessionPickerState = {
...tabSessionPickerState,
open: false,
newSessionName: '',
};
await renderDashboard();
const skipped = result?.skippedDuplicateCount || 0;
showToast(runtimeT
? runtimeT('toastSessionTabsAdded', { count: result?.appendedCount || 0, skipped })
: `Added ${result?.appendedCount || 0} tabs${skipped ? `, skipped ${skipped} duplicates` : ''}`);
return;
}
const newSessionName = String(tabSessionPickerState.newSessionName || '').trim();
tabSessionPickerState = {
...tabSessionPickerState,
open: false,
newSessionName: '',
};
const result = await saveSelectedTabSession(
selectedIds,
tabSessionPickerState.source || 'selected',
newSessionName
);
showToast(runtimeT
? runtimeT('toastSessionSaved', { count: result?.session?.tabs?.length || 0 })
: 'Session saved');
} catch (err) {
console.error('[tab-harbor] Failed to save selected tabs:', err);
showToast(runtimeT ? runtimeT('toastSessionActionFailed') : 'Could not update saved tabs');
}
}
async function openSavedTabsInCurrentWindow(tabs = []) {
const [firstTab, ...restTabs] = Array.isArray(tabs) ? tabs : [];
if (!firstTab?.url) return { restoredTabs: [], windowId: null };
const currentWindowId = await getCurrentWindowId();
const firstCreatedTab = currentWindowId != null
? await chrome.tabs.create({
windowId: currentWindowId,
url: firstTab.url,
active: true,
})
: await chrome.tabs.create({
url: firstTab.url,
active: true,
});
const targetWindowId = firstCreatedTab?.windowId ?? currentWindowId ?? null;
const restoredTabs = firstCreatedTab?.id != null
? [{ id: firstCreatedTab.id, url: firstTab.url }]
: [];
for (const tab of restTabs) {
const createdTab = await chrome.tabs.create({
windowId: targetWindowId,
url: tab.url,
active: false,
});
restoredTabs.push({
id: createdTab.id,
url: tab.url,
});
}
return { restoredTabs, windowId: targetWindowId };
}
async function openSavedTabsInNewWindow(tabs = []) {
const [firstTab, ...restTabs] = Array.isArray(tabs) ? tabs : [];
if (!firstTab?.url) return { restoredTabs: [], windowId: null };
const createdWindow = await chrome.windows.create({
url: firstTab.url,
focused: true,
});
const restoredTabs = [];
const firstCreatedTab = Array.isArray(createdWindow?.tabs) ? createdWindow.tabs[0] : null;
if (firstCreatedTab?.id != null) {
restoredTabs.push({
id: firstCreatedTab.id,
url: firstTab.url,
});
} else if (createdWindow?.id != null) {
const createdWindowTabs = await chrome.tabs.query({ windowId: createdWindow.id });
const queriedFirstTab = createdWindowTabs?.[0];
if (queriedFirstTab?.id != null) {
restoredTabs.push({
id: queriedFirstTab.id,
url: firstTab.url,
});
}
}
for (const tab of restTabs) {
const createdTab = await chrome.tabs.create({
windowId: createdWindow.id,
url: tab.url,
active: false,
});
restoredTabs.push({
id: createdTab.id,
url: tab.url,
});
}
return { restoredTabs, windowId: createdWindow?.id ?? null };
}
async function restoreSavedTabToBrowser(tabUrl) {
if (!tabUrl) return null;
const restoreMode = runtimeGetSavedSessionRestoreMode ? runtimeGetSavedSessionRestoreMode() : 'new-window';
if (restoreMode === 'current-window') {
const { windowId } = await openSavedTabsInCurrentWindow([{ url: tabUrl }]);
return { windowId, restoreMode };
}
const { windowId } = await openSavedTabsInNewWindow([{ url: tabUrl }]);
return { windowId, restoreMode };
}
async function restoreSavedTabSession(sessionId) {
if (!runtimeGetSavedTabSessions || !runtimeCreateRestoredSessionGroups) {
throw new Error('Session restore is unavailable');
}
const sessions = await runtimeGetSavedTabSessions();
const session = sessions.find(item => item.id === String(sessionId));
if (!session || !Array.isArray(session.tabs) || !session.tabs.length) {
throw new Error('Session not found');
}
const restoreMode = runtimeGetSavedSessionRestoreMode ? runtimeGetSavedSessionRestoreMode() : 'new-window';
const { restoredTabs, windowId } = restoreMode === 'current-window'
? await openSavedTabsInCurrentWindow(session.tabs)
: await openSavedTabsInNewWindow(session.tabs);
const nextSessionGroups = runtimeCreateRestoredSessionGroups({
existingState: sessionGroupsState,
session,
restoredTabs,
now: new Date().toISOString(),
});
await saveSessionGroups(nextSessionGroups);
await renderDashboard();
return {
restoredCount: restoredTabs.length,
windowId,
};
}
async function removeOpenTabByIdOrUrl(tabId, tabUrl) {
const numericTabId = getTabIdValue(tabId);
if (numericTabId != null) {
await chrome.tabs.remove(numericTabId);
return numericTabId;
}
const allTabs = await queryTabsForDashboardWindow();
const targetUrl = runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(tabUrl || '') : tabUrl;
const match = allTabs.find(tab => {
const canonicalUrl = getTabCanonicalUrl(tab);
return tab.url === tabUrl || canonicalUrl === targetUrl;
});
if (match?.id != null) {
await chrome.tabs.remove(match.id);
return match.id;
}
return null;
}
function animateNavButtonNode(button, previousRect) {
if (!button || !previousRect || button.classList.contains('is-dragging')) return;
const nextRect = button.getBoundingClientRect();
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
if (!deltaX && !deltaY) return;
const travel = Math.hypot(deltaX, deltaY);
const duration = prefersReducedMotion()
? 0
: Math.min(320, Math.max(190, Math.round(172 + travel * 0.28)));
button.style.transition = 'none';
button.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0)`;
requestAnimationFrame(() => {
button.style.transition = duration
? `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`
: 'none';
button.style.transform = '';
});
}
function animateNavButtons(navListEl, previousRects) {
navListEl?.querySelectorAll('.group-nav-button').forEach(button => {
const key = button.dataset.groupId || '';
animateNavButtonNode(button, previousRects.get(key));
});
}
function animateMissionCards(missionsEl, previousRects) {
missionsEl?.querySelectorAll('.mission-card').forEach(card => {
const key = card.dataset.groupId || '';
const previousRect = previousRects.get(key);
if (!previousRect) return;
const nextRect = card.getBoundingClientRect();
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
if (!deltaX && !deltaY) return;
const travel = Math.hypot(deltaX, deltaY);
const duration = prefersReducedMotion()
? 0
: Math.min(220, Math.max(140, Math.round(150 + travel * 0.18)));
card.style.transition = 'none';
card.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0)`;
requestAnimationFrame(() => {
card.style.transition = duration
? `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`
: 'none';
card.style.transform = '';
});
});
}
function buildPreviewGroupOrderFromDom(insertedGroupKey = '') {
const missionsEl = document.getElementById('openTabsMissions');
if (!missionsEl) return [];
return [...missionsEl.children]
.map(node => {
if (node === pageChipNewGroupSlotEl) return insertedGroupKey;
if (node.classList?.contains('mission-card')) return node.dataset?.groupId || '';
return '';
})
.filter(Boolean);
}
function buildPersistentGroupOrderWithInsertedGroup(insertedGroupKey, {
insertBeforeGroupKey = '',
placement = 'after',
} = {}) {
const normalizedInsertedKey = String(insertedGroupKey || '');
if (!normalizedInsertedKey) return [];
const currentKeys = domainGroups
.map(group => String(group?.domain || ''))
.filter(Boolean)
.filter(key => key !== normalizedInsertedKey);
const normalizedBeforeKey = String(insertBeforeGroupKey || '');
if (normalizedBeforeKey) {
const insertIndex = currentKeys.indexOf(normalizedBeforeKey);
if (insertIndex >= 0) {
currentKeys.splice(insertIndex, 0, normalizedInsertedKey);
return currentKeys;
}
}
if (placement === 'before') {
currentKeys.unshift(normalizedInsertedKey);
return currentKeys;
}
currentKeys.push(normalizedInsertedKey);
return currentKeys;
}
function buildPersistentGroupOrderReplacingKey(replacementGroupKey, replacedGroupKey) {
const normalizedReplacementKey = String(replacementGroupKey || '');
const normalizedReplacedKey = String(replacedGroupKey || '');
if (!normalizedReplacementKey) return [];
const currentKeys = domainGroups
.map(group => String(group?.domain || ''))
.filter(Boolean)
.filter(key => key !== normalizedReplacementKey);
if (!normalizedReplacedKey) {
currentKeys.push(normalizedReplacementKey);
return currentKeys;
}
const replaceIndex = currentKeys.indexOf(normalizedReplacedKey);
if (replaceIndex >= 0) {
currentKeys.splice(replaceIndex, 1, normalizedReplacementKey);
return currentKeys;
}
currentKeys.push(normalizedReplacementKey);
return currentKeys;
}
async function persistGroupOrder(orderKeys = []) {
const normalizedKeys = [...new Set((orderKeys || []).map(key => String(key)).filter(Boolean))];
if (!normalizedKeys.length) return groupOrderState;
return saveGroupOrder({
...groupOrderState,
sessionOrder: normalizedKeys,
pinnedOrder: normalizedKeys,
pinEnabled: false,
});
}
function applyLiveGroupOrder(orderKeys, options = {}) {
const keyOrder = orderKeys.map(String);
const groupMap = new Map(domainGroups.map(group => [String(group.domain), group]));
domainGroups = keyOrder.map(key => groupMap.get(key)).filter(Boolean);
syncGroupOrderState(domainGroups.map(group => group.domain));
const missionsEl = document.getElementById('openTabsMissions');
const navListEl = document.querySelector('#workspaceTopNav .group-nav-list[data-nav-kind="open-tabs"]');
if (options.reorderCards !== false) {
const previousMissionRects = new Map();
missionsEl?.querySelectorAll('.mission-card').forEach(card => {
previousMissionRects.set(card.dataset.groupId || '', card.getBoundingClientRect());
});
keyOrder.forEach(key => {
const card = missionsEl?.querySelector(`.mission-card[data-group-id="${CSS.escape(String(key))}"]`);
if (card) missionsEl.appendChild(card);
});
animateMissionCards(missionsEl, previousMissionRects);
}
if (options.reorderNav === false || !navListEl) return;
const previousNavRects = new Map();
navListEl.querySelectorAll('.group-nav-button').forEach(button => {
previousNavRects.set(button.dataset.groupId || '', button.getBoundingClientRect());
});
keyOrder.forEach(key => {
const button = navListEl.querySelector(`.group-nav-button[data-group-id="${CSS.escape(String(key))}"]`);
if (button) navListEl.appendChild(button);
});
animateNavButtons(navListEl, previousNavRects);
}
function clearGroupDragState() {
draggedGroupId = '';
dragStartPoint = null;
draggedGroupButtonEl = null;
dragPlaceholderEl?.remove();
dragPlaceholderEl = null;
document.body.classList.remove('group-dragging');
document.querySelectorAll('.group-nav-button.is-dragging').forEach(button => {
button.classList.remove('is-dragging');
button.style.removeProperty('--drag-left');
button.style.removeProperty('--drag-top');
});
}
function animateDrawerListItems(listEl, previousRects) {
listEl?.querySelectorAll('[data-drawer-sort-id]').forEach(item => {
if (item.classList.contains('is-dragging')) return;
const key = item.dataset.drawerSortId || '';
const previousRect = previousRects.get(key);
if (!previousRect) return;
const nextRect = item.getBoundingClientRect();
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
if (!deltaX && !deltaY) return;
item.style.transition = 'none';
item.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
requestAnimationFrame(() => {
item.style.transition = 'transform 0.16s ease';
item.style.transform = '';
});
});
}
function ensureDrawerItemPlaceholder() {
if (drawerItemPlaceholderEl || !draggedDrawerItemEl) return drawerItemPlaceholderEl;
drawerItemPlaceholderEl = document.createElement('div');
drawerItemPlaceholderEl.className = 'drawer-reorder-placeholder';
drawerItemPlaceholderEl.style.height = `${draggedDrawerItemEl.getBoundingClientRect().height}px`;
draggedDrawerItemEl.insertAdjacentElement('afterend', drawerItemPlaceholderEl);
return drawerItemPlaceholderEl;
}
function clearDrawerItemDragState() {
draggedDrawerItemId = '';
drawerItemDragState = null;
drawerItemPlaceholderEl?.remove();
drawerItemPlaceholderEl = null;
document.body.classList.remove('drawer-list-dragging');
if (draggedDrawerItemEl) {
draggedDrawerItemEl.classList.remove('is-dragging');
draggedDrawerItemEl.style.removeProperty('--drag-left');
draggedDrawerItemEl.style.removeProperty('--drag-top');
draggedDrawerItemEl.style.removeProperty('--drag-width');
}
draggedDrawerItemEl = null;
}
function ensurePageChipPlaceholder(listEl = pageChipDragState?.dropListEl || pageChipDragState?.sourceListEl) {
if (!draggedPageChipEl) return null;
if (!pageChipPlaceholderEl) {
pageChipPlaceholderEl = document.createElement('div');
pageChipPlaceholderEl.className = 'chip-reorder-placeholder';
pageChipPlaceholderEl.style.height = `${draggedPageChipEl.getBoundingClientRect().height}px`;
}
if (listEl && pageChipPlaceholderEl.parentElement !== listEl) {
listEl.appendChild(pageChipPlaceholderEl);
}
return pageChipPlaceholderEl;
}
function clampPageChipClientPoint(clientX, clientY) {
const maxX = Math.max(0, window.innerWidth - 1);
const maxY = Math.max(0, window.innerHeight - 1);
return {
clientX: Math.min(Math.max(Number(clientX) || 0, 0), maxX),
clientY: Math.min(Math.max(Number(clientY) || 0, 0), maxY),
};
}
function clearPageChipDragState({ removeNode = false } = {}) {
const handleEl = pageChipDragState?.handleEl || null;
const pointerId = pageChipDragState?.pointerId;
draggedPageChipId = '';
clearPageChipDropPreview();
pageChipPlaceholderEl?.remove();
pageChipPlaceholderEl = null;
document.body.classList.remove('page-chip-list-dragging');
document.body.classList.remove('page-chip-drag-armed');
if (draggedPageChipEl) {
if (removeNode) draggedPageChipEl.remove();
draggedPageChipEl.classList.remove('is-dragging');
draggedPageChipEl.style.removeProperty('--drag-left');
draggedPageChipEl.style.removeProperty('--drag-top');
draggedPageChipEl.style.removeProperty('--drag-width');
}
if (handleEl && pointerId != null && typeof handleEl.releasePointerCapture === 'function') {
try {
if (handleEl.hasPointerCapture?.(pointerId)) handleEl.releasePointerCapture(pointerId);
} catch {}
}
pageChipDragState = null;
draggedPageChipEl = null;
}
function updateDraggedPageChipPosition(clientX, clientY) {
if (!draggedPageChipEl || !pageChipDragState) return;
const clampedPoint = clampPageChipClientPoint(clientX, clientY);
draggedPageChipEl.style.setProperty('--drag-left', `${clampedPoint.clientX - pageChipDragState.offsetX}px`);
draggedPageChipEl.style.setProperty('--drag-top', `${clampedPoint.clientY - pageChipDragState.offsetY}px`);
}
function startPageChipDragVisuals() {
if (!draggedPageChipEl || !pageChipDragState || pageChipDragState.moved) return;
disableEntryAnimations();
pageChipDragState.moved = true;
document.body.classList.add('page-chip-list-dragging');
draggedPageChipEl.classList.add('is-dragging');
draggedPageChipEl.style.setProperty('--drag-width', `${draggedPageChipEl.getBoundingClientRect().width}px`);
ensurePageChipPlaceholder();
logPageChipDragDebug('drag-start', {
groupKey: pageChipDragState.sourceGroupKey,
chip: draggedPageChipId,
});
}
function syncPageChipDropTarget(clientX, clientY) {
const clampedPoint = clampPageChipClientPoint(clientX, clientY);
const dropTarget = getPageChipDropTarget(clampedPoint.clientX, clampedPoint.clientY);
if (!pageChipDragState || !draggedPageChipId) return;
if (!dropTarget) {
const previous = pageChipDragState.debugTargetSignature || '';
pageChipDragState.dropGroupKey = '';
pageChipDragState.dropListEl = null;
pageChipDragState.dropCardEl = null;
pageChipDragState.createNewGroup = false;
pageChipDragState.newGroupPlacement = '';
pageChipDragState.insertBeforeCardEl = null;
pageChipDragState.lastResolvedDropTarget = null;
pageChipPlaceholderEl?.remove();
clearPageChipDropPreview();
pageChipDragState.debugTargetSignature = 'none';
if (previous !== 'none') {
logPageChipDragDebug('target', { kind: 'none', x: Math.round(clampedPoint.clientX), y: Math.round(clampedPoint.clientY) });
}
return;
}
if (dropTarget.kind === 'new-group') {
pageChipDragState.dropGroupKey = '';
pageChipDragState.dropListEl = null;
pageChipDragState.dropCardEl = null;
pageChipDragState.createNewGroup = true;
pageChipDragState.newGroupPlacement = dropTarget.placement || 'after';
pageChipDragState.insertBeforeCardEl = dropTarget.insertBeforeCardEl || null;
pageChipDragState.lastResolvedDropTarget = {
kind: 'new-group',
placement: pageChipDragState.newGroupPlacement,
insertBeforeCardEl: pageChipDragState.insertBeforeCardEl,
reason: dropTarget.reason || '',
};
pageChipPlaceholderEl?.remove();
setPageChipDropPreview(null, {
createNewGroup: true,
newGroupPlacement: pageChipDragState.newGroupPlacement,
insertBeforeCardEl: pageChipDragState.insertBeforeCardEl,
});
const signature = `new:${pageChipDragState.newGroupPlacement}:${dropTarget.reason || ''}`;
if (pageChipDragState.debugTargetSignature !== signature) {
pageChipDragState.debugTargetSignature = signature;
logPageChipDragDebug('target', {
kind: 'new-group',
placement: pageChipDragState.newGroupPlacement,
reason: dropTarget.reason || '',
x: Math.round(clampedPoint.clientX),
y: Math.round(clampedPoint.clientY),
});
}
return;
}
const { cardEl, listEl, groupKey } = dropTarget;
pageChipDragState.dropGroupKey = groupKey;
pageChipDragState.dropListEl = listEl;
pageChipDragState.dropCardEl = cardEl;
pageChipDragState.createNewGroup = false;
pageChipDragState.newGroupPlacement = '';
pageChipDragState.insertBeforeCardEl = null;
pageChipDragState.lastResolvedDropTarget = {
kind: 'group',
groupKey,
cardEl,
listEl,
};
setPageChipDropPreview(cardEl);
const signature = `group:${groupKey}`;
if (pageChipDragState.debugTargetSignature !== signature) {
pageChipDragState.debugTargetSignature = signature;
logPageChipDragDebug('target', {
kind: 'group',
groupKey,
x: Math.round(clampedPoint.clientX),
y: Math.round(clampedPoint.clientY),
});
}
return dropTarget;
}
function previewPageChipOrder(clientX, clientY) {
const dropTarget = syncPageChipDropTarget(clientX, clientY);
if (!dropTarget || dropTarget.kind !== 'group') return;
const { listEl } = dropTarget;
const placeholder = ensurePageChipPlaceholder(listEl);
const previousRects = new Map();
const items = [...listEl.querySelectorAll('[data-chip-sort-id]:not(.is-dragging)')];
items.forEach(item => {
previousRects.set(item.dataset.chipSortId || '', item.getBoundingClientRect());
});
let insertBeforeItem = null;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
insertBeforeItem = item;
break;
}
}
if (insertBeforeItem) {
listEl.insertBefore(placeholder, insertBeforeItem);
} else {
listEl.appendChild(placeholder);
}
animatePageChipItems(listEl, previousRects);
}
async function saveGroupTabRowOrder(groupKey, orderIds) {
if (!groupKey || !Array.isArray(orderIds) || !orderIds.length) return;
await saveGroupTabOrder({
...groupTabOrderState,
[String(groupKey)]: orderIds.map(id => String(id)).filter(Boolean),
});
}
function createSessionGroupFromDraggedTab(state, tab) {
const nextName = createUniqueSessionGroupName(deriveDraggedTabGroupName(tab), state.groups);
return addSessionGroup(state, nextName);
}
async function saveCrossGroupTabRowOrder(sourceGroupKey, targetGroupKey, targetListEl, draggedId) {
const nextState = {
...groupTabOrderState,
[String(sourceGroupKey)]: getOrderedIdsForGroup(sourceGroupKey).filter(id => id !== String(draggedId || '')),
[String(targetGroupKey)]: targetListEl
? buildOrderedIdsFromList(targetListEl, draggedId)
: [String(draggedId || '')].filter(Boolean),
};
await saveGroupTabOrder(nextState);
}
async function moveDraggedPageChipToGroup(targetGroupKey) {
const sourceGroupKey = pageChipDragState?.sourceGroupKey || '';
const draggedChipId = draggedPageChipId;
const draggedTabIds = getTabIdsForGroupChip(sourceGroupKey, draggedChipId);
if (!sourceGroupKey || !draggedChipId || !draggedTabIds.length) return null;
const targetWasManualGroup = isManualGroupKey(targetGroupKey);
logPageChipDragDebug('move-group-begin', {
sourceGroupKey,
targetGroupKey,
draggedChipId,
tabIds: draggedTabIds.join(','),
});
let nextState = clearTabsFromSessionGroups(sessionGroupsState, draggedTabIds);
const targetGroup = ensureManualDropGroup(nextState, targetGroupKey);
nextState = reassignTabsToSessionGroup(targetGroup.nextState, draggedTabIds, targetGroup.groupId);
nextState = pruneSessionGroups(nextState, getOpenTabIdsForSessionPruning());
logPageChipDragDebug('move-group-save-session', {
groupId: targetGroup.groupId,
groupName: targetGroup.groupName,
});
await saveSessionGroups(nextState);
logPageChipDragDebug('move-group-end', {
groupId: targetGroup.groupId,
groupName: targetGroup.groupName,
});
return {
groupKey: `${MANUAL_GROUP_PREFIX}${targetGroup.groupId}`,
groupName: targetGroup.groupName,
targetWasManualGroup,
};
}
async function createSessionGroupFromDraggedPageChip() {
const sourceGroupKey = pageChipDragState?.sourceGroupKey || '';
const draggedChipId = draggedPageChipId;
const draggedTabs = getDomainGroupByKey(sourceGroupKey)?.tabs?.filter(tab =>
getTabOrderTokens(tab).includes(String(draggedChipId || ''))
) || [];
const draggedTabIds = draggedTabs.map(tab => Number(tab?.id)).filter(Number.isFinite);
if (!sourceGroupKey || !draggedChipId || !draggedTabs.length || !draggedTabIds.length) return null;
logPageChipDragDebug('create-group-begin', {
sourceGroupKey,
draggedChipId,
tabIds: draggedTabIds.join(','),
});
let nextState = clearTabsFromSessionGroups(sessionGroupsState, draggedTabIds);
const created = createSessionGroupFromDraggedTab(nextState, draggedTabs[0]);
nextState = reassignTabsToSessionGroup(created.state, draggedTabIds, created.group.id);
nextState = pruneSessionGroups(nextState, getOpenTabIdsForSessionPruning());
logPageChipDragDebug('create-group-save-session', {
groupId: created.group.id,
groupName: created.group.name,
});
await saveSessionGroups(nextState);
logPageChipDragDebug('create-group-save-order', {
groupId: created.group.id,
groupName: created.group.name,
});
await saveCrossGroupTabRowOrder(sourceGroupKey, `${MANUAL_GROUP_PREFIX}${created.group.id}`, null, draggedChipId);
logPageChipDragDebug('create-group-end', {
groupId: created.group.id,
groupName: created.group.name,
});
return created.group;
}
async function finishPageChipDrag() {
if (!draggedPageChipId || !pageChipDragState) return false;
const moved = pageChipDragState.moved;
const sourceGroupKey = pageChipDragState.sourceGroupKey;
const targetGroupKey = pageChipDragState.dropGroupKey || '';
const targetListEl = pageChipDragState.dropListEl;
const createNewGroup = pageChipDragState.createNewGroup;
logPageChipDragDebug('finish-begin', {
moved,
sourceGroupKey,
targetGroupKey,
createNewGroup,
placement: pageChipDragState.newGroupPlacement || '',
});
try {
const changedGroupKeys = new Set();
let requiresOpenTabsRebuild = true;
if (moved) {
if (targetGroupKey && targetGroupKey === sourceGroupKey && targetListEl) {
const orderIds = buildOrderedIdsFromList(targetListEl, draggedPageChipId);
logPageChipDragDebug('finish-reorder-save', { orderCount: orderIds.length });
await saveGroupTabRowOrder(sourceGroupKey, orderIds);
if (draggedPageChipEl && pageChipPlaceholderEl) {
targetListEl.insertBefore(draggedPageChipEl, pageChipPlaceholderEl);
}
requiresOpenTabsRebuild = false;
} else if (targetGroupKey) {
const movedGroup = await moveDraggedPageChipToGroup(targetGroupKey);
if (movedGroup) {
disableChromeTabGroupsImportModeForLocalEdits();
changedGroupKeys.add(sourceGroupKey);
changedGroupKeys.add(movedGroup.groupKey);
if (!movedGroup.targetWasManualGroup) {
const nextGroupOrder = buildPersistentGroupOrderReplacingKey(movedGroup.groupKey, targetGroupKey);
await persistGroupOrder(nextGroupOrder);
}
logPageChipDragDebug('finish-save-cross-order', { groupKey: movedGroup.groupKey });
await saveCrossGroupTabRowOrder(sourceGroupKey, movedGroup.groupKey, targetListEl, draggedPageChipId);
logPageChipDragDebug('finish-group-move', { groupKey: movedGroup.groupKey, groupName: movedGroup.groupName });
}
} else if (createNewGroup) {
const createdGroup = await createSessionGroupFromDraggedPageChip();
if (createdGroup) {
disableChromeTabGroupsImportModeForLocalEdits();
changedGroupKeys.add(sourceGroupKey);
const createdGroupKey = `${MANUAL_GROUP_PREFIX}${createdGroup.id}`;
changedGroupKeys.add(createdGroupKey);
const nextGroupOrder = buildPersistentGroupOrderWithInsertedGroup(createdGroupKey, {
insertBeforeGroupKey: pageChipDragState.insertBeforeCardEl?.dataset?.groupId || '',
placement: pageChipDragState.newGroupPlacement || 'after',
});
await persistGroupOrder(nextGroupOrder);
logPageChipDragDebug('create-group-save-group-order', {
orderCount: nextGroupOrder.length,
});
logPageChipDragDebug('finish-new-group', { groupName: createdGroup.name });
}
}
clearPageChipDragState({ removeNode: requiresOpenTabsRebuild });
suppressPageChipClickUntil = Date.now() + 250;
if (!requiresOpenTabsRebuild) {
logPageChipDragDebug('finish-local-reorder-commit', { groupKey: sourceGroupKey });
await syncChromeTabGroupsWithoutImportEcho();
} else {
logPageChipDragDebug('finish-open-tabs-patch-begin', { moved, changedGroups: [...changedGroupKeys].join(',') });
await renderOpenTabsLayout({
rebuildGroups: true,
syncChrome: true,
patchDom: true,
changedGroupKeys: [...changedGroupKeys],
});
logPageChipDragDebug('finish-open-tabs-patch-end', { moved });
}
} else {
clearPageChipDragState({ removeNode: moved });
}
logPageChipDragDebug('finish-end', { moved });
return true;
} catch (error) {
logPageChipDragDebug('finish-error', {
message: error?.message || String(error),
});
throw error;
}
}
function updateDraggedDrawerItemPosition(clientX, clientY) {
if (!draggedDrawerItemEl || !drawerItemDragState) return;
draggedDrawerItemEl.style.setProperty('--drag-left', `${clientX - drawerItemDragState.offsetX}px`);
draggedDrawerItemEl.style.setProperty('--drag-top', `${clientY - drawerItemDragState.offsetY}px`);
}
function previewDrawerItemOrder(clientY) {
const listEl = drawerItemDragState?.listEl;
if (!listEl || !draggedDrawerItemId) return;
const placeholder = ensureDrawerItemPlaceholder();
const previousRects = new Map();
const items = [...listEl.querySelectorAll('[data-drawer-sort-id]:not(.is-dragging)')];
items.forEach(item => {
previousRects.set(item.dataset.drawerSortId || '', item.getBoundingClientRect());
});
let insertBeforeItem = null;
for (const item of items) {
const rect = item.getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
insertBeforeItem = item;
break;
}
}
if (insertBeforeItem) {
listEl.insertBefore(placeholder, insertBeforeItem);
} else {
listEl.appendChild(placeholder);
}
animateDrawerListItems(listEl, previousRects);
}
async function reorderTodoItems(orderIds) {
const todos = await getTodos();
const nextTodos = reorderVisibleItemsByIds(
todos,
orderIds,
todo => todo && todo.id && !todo.completed && !todo.dismissed
);
return saveTodos(nextTodos);
}
async function saveDrawerItemOrder(kind, orderIds) {
if (!Array.isArray(orderIds) || !orderIds.length) return;
if (kind === 'todo') {
await reorderTodoItems(orderIds);
}
}
function updateDraggedButtonPosition(clientX, clientY) {
if (!draggedGroupButtonEl || !dragStartPoint) return;
draggedGroupButtonEl.style.setProperty('--drag-left', `${clientX - dragStartPoint.offsetX}px`);
draggedGroupButtonEl.style.setProperty('--drag-top', `${clientY - dragStartPoint.offsetY}px`);
}
function ensureDragPlaceholder() {
if (dragPlaceholderEl || !draggedGroupButtonEl) return dragPlaceholderEl;
dragPlaceholderEl = document.createElement('div');
dragPlaceholderEl.className = 'group-nav-placeholder';
draggedGroupButtonEl.insertAdjacentElement('afterend', dragPlaceholderEl);
return dragPlaceholderEl;
}
function previewDraggedOrder(clientX) {
const navListEl = document.querySelector('#workspaceTopNav .group-nav-list[data-nav-kind="open-tabs"]');
if (!navListEl || !draggedGroupId) return;
const placeholder = ensureDragPlaceholder();
const buttons = [...navListEl.querySelectorAll('.group-nav-button:not(.is-dragging)')];
let insertBeforeButton = null;
for (const button of buttons) {
const rect = button.getBoundingClientRect();
if (clientX < rect.left + rect.width / 2) {
insertBeforeButton = button;
break;
}
}
if (insertBeforeButton) {
navListEl.insertBefore(placeholder, insertBeforeButton);
} else {
navListEl.appendChild(placeholder);
}
const previewOrderKeys = [...navListEl.children]
.map(node => {
if (node === placeholder) return draggedGroupId;
if (node.classList?.contains('group-nav-button') && !node.classList.contains('is-dragging')) {
return node.dataset.groupId || '';
}
return '';
})
.filter(Boolean);
if (previewOrderKeys.length > 0) {
applyLiveGroupOrder(previewOrderKeys, { reorderCards: false, reorderNav: false });
}
}
async function removeTabAssignments(tabIds = []) {
if (!tabIds.length) return sessionGroupsState;
let nextState = sessionGroupsState;
for (const tabId of tabIds) {
nextState = clearTabSessionGroup(nextState, tabId);
}
nextState = pruneSessionGroups(nextState, getOpenTabIdsForSessionPruning());
return saveSessionGroups(nextState);
}
/**
* closeTabsByUrls(urls)
*
* Closes all open tabs whose hostname matches any of the given URLs.
* After closing, re-fetches the tab list to keep our state accurate.
*
* Special case: file:// URLs are matched exactly (they have no hostname).
*/
async function closeTabsByUrls(urls) {
if (!urls || urls.length === 0) return;
// Separate file:// URLs (exact match) from regular URLs (hostname match)
const targetHostnames = [];
const exactUrls = new Set();
for (const u of urls) {
const canonicalUrl = runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(u) : u;
if (canonicalUrl.startsWith('file://')) {
exactUrls.add(canonicalUrl);
} else {
try { targetHostnames.push(new URL(canonicalUrl).hostname); }
catch { /* skip unparseable */ }
}
}
const allTabs = await queryTabsForDashboardWindow();
const toClose = allTabs
.filter(tab => {
const tabUrl = getTabCanonicalUrl(tab);
if (tabUrl.startsWith('file://') && exactUrls.has(tabUrl)) return true;
try {
const tabHostname = new URL(tabUrl).hostname;
return tabHostname && targetHostnames.includes(tabHostname);
} catch { return false; }
})
.map(tab => tab.id);
if (toClose.length > 0) await chrome.tabs.remove(toClose);
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
}
/**
* closeTabsExact(urls)
*
* Closes tabs by exact URL match (not hostname). Used for landing pages
* so closing "Gmail inbox" doesn't also close individual email threads.
*/
async function closeTabsExact(urls) {
if (!urls || urls.length === 0) return;
const urlSet = new Set(urls.map(url => runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(url) : url));
const allTabs = await queryTabsForDashboardWindow();
const toClose = allTabs.filter(t => urlSet.has(getTabCanonicalUrl(t))).map(t => t.id);
if (toClose.length > 0) await chrome.tabs.remove(toClose);
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
}
/**
* focusTab(url)
*
* Switches Chrome to the tab with the given URL (exact match first,
* then hostname fallback). Also brings the window to the front.
*/
async function focusTab(url, tabId = null) {
if (!url && !tabId) return;
const numericTabId = getTabIdValue(tabId);
if (numericTabId != null) {
try {
const targetTab = await chrome.tabs.get(numericTabId);
if (targetTab?.id != null) {
await chrome.tabs.update(targetTab.id, { active: true });
await chrome.windows.update(targetTab.windowId, { focused: true });
if (typeof syncChromeTabGroupExpansionForTab === 'function') {
await syncChromeTabGroupExpansionForTab(targetTab);
}
return true;
}
} catch { /* fall back to URL matching */ }
}
const allTabs = await queryTabsForDashboardWindow();
const targetUrl = runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(url || '') : url;
// Try exact URL match first
let matches = allTabs.filter(t => getTabCanonicalUrl(t) === targetUrl || t.url === url);
// Fall back to hostname match
if (matches.length === 0) {
try {
const targetHost = new URL(targetUrl).hostname;
matches = allTabs.filter(t => {
try { return new URL(getTabCanonicalUrl(t)).hostname === targetHost; }
catch { return false; }
});
} catch {}
}
if (matches.length === 0) return false;
const match = matches.find(t => t.active) || matches[0];
await chrome.tabs.update(match.id, { active: true });
await chrome.windows.update(match.windowId, { focused: true });
if (typeof syncChromeTabGroupExpansionForTab === 'function') {
await syncChromeTabGroupExpansionForTab(match);
}
return true;
}
async function navigateCurrentTabToUrl(url) {
if (!url) return false;
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return false;
await chrome.tabs.update(activeTab.id, { url });
return true;
}
async function openOrFocusUrl(url) {
if (!url) return false;
await navigateCurrentTabToUrl(url);
return true;
}
async function runDefaultSearch(query) {
const text = String(query || '').trim();
if (!text) return;
if (chrome.search?.query) {
await chrome.search.query({
text,
disposition: 'CURRENT_TAB',
});
return;
}
const fallbackUrl = `https://www.google.com/search?q=${encodeURIComponent(text)}`;
await navigateCurrentTabToUrl(fallbackUrl);
}
/**
* closeDuplicateTabs(urls, keepOne)
*
* Closes duplicate tabs for the given list of URLs.
* keepOne=true → keep one copy of each, close the rest.
* keepOne=false → close all copies.
*/
async function closeDuplicateTabs(urls, keepOne = true) {
const allTabs = await queryTabsForDashboardWindow();
const toClose = [];
for (const url of urls) {
const targetUrl = runtimeGetCanonicalTabUrl ? runtimeGetCanonicalTabUrl(url) : url;
const matching = allTabs.filter(t => getTabCanonicalUrl(t) === targetUrl);
if (keepOne) {
const keep = matching.find(t => t.active) || matching[0];
for (const tab of matching) {
if (tab.id !== keep.id) toClose.push(tab.id);
}
} else {
for (const tab of matching) toClose.push(tab.id);
}
}
if (toClose.length > 0) await chrome.tabs.remove(toClose);
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
}
/**
* closeTabOutDupes()
*
* Closes all duplicate Tab Harbor new-tab pages except the current one.
*/
async function closeTabOutDupes() {
const newtabUrl = window.location.href;
const allTabs = await queryTabsForDashboardWindow();
const currentWindow = await chrome.windows.getCurrent();
const tabOutTabs = allTabs.filter(t =>
t.url === newtabUrl || t.url === 'chrome://newtab/'
);
if (tabOutTabs.length <= 1) return;
// Keep the active Tab Harbor tab in the CURRENT window — that's the one the
// user is looking at right now. Falls back to any active one, then the first.
const keep =
tabOutTabs.find(t => t.active && t.windowId === currentWindow.id) ||
tabOutTabs.find(t => t.active) ||
tabOutTabs[0];
const toClose = tabOutTabs.filter(t => t.id !== keep.id).map(t => t.id);
if (toClose.length > 0) await chrome.tabs.remove(toClose);
await fetchOpenTabs();
}
/**
* discardTab(tabId)
*
* Discards (unloads) a tab to free memory while keeping it in the tab bar.
* Chrome's discard API puts the tab to sleep; clicking it later will reload.
*/
async function discardTab(tabId) {
if (!tabId) return false;
try {
await chrome.tabs.discard(Number(tabId));
return true;
} catch (err) {
console.error('Failed to discard tab:', err);
return false;
}
}
/* ----------------------------------------------------------------
IN-MEMORY STORE FOR OPEN-TAB GROUPS
---------------------------------------------------------------- */
let domainGroups = [];
/* ----------------------------------------------------------------
HELPER: filter out browser-internal pages
---------------------------------------------------------------- */
/**
* getRealTabs()
*
* Returns tabs that are real web pages — no chrome://, extension
* pages, about:blank, etc.
*/
function getRealTabs() {
return openTabs.filter(t => {
const url = t.url || '';
if (runtimeIsRestorableTabUrl) return runtimeIsRestorableTabUrl(url);
return (
!url.startsWith('chrome://') &&
!url.startsWith('chrome-extension://') &&
!url.startsWith('about:') &&
!url.startsWith('edge://') &&
!url.startsWith('brave://')
);
});
}
/**
* checkTabOutDupes()
*
* Counts how many Tab Harbor pages are open. If more than 1,
* shows a banner offering to close the extras.
*/
function checkTabOutDupes() {
const tabOutTabs = openTabs.filter(t => t.isTabOut);
const banner = document.getElementById('tabOutDupeBanner');
const countEl = document.getElementById('tabOutDupeCount');
if (!banner) return;
if (tabOutTabs.length > 1) {
if (countEl) countEl.textContent = tabOutTabs.length;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
}
/* ----------------------------------------------------------------
OVERFLOW CHIPS ("+N more" expand button in domain cards)
---------------------------------------------------------------- */
function buildOverflowChips(hiddenTabs, urlCounts = {}) {
const hiddenChips = hiddenTabs.map(tab => {
const label = cleanTitle(smartTitle(stripTitleNoise(tab.title || ''), tab.url), '');
const count = urlCounts[tab.url] || 1;
const dupeTag = count > 1 ? ` <span class="chip-dupe-badge">(${count}x)</span>` : '';
const chipClass = count > 1 ? ' chip-has-dupes' : '';
const safeUrl = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(tab.url || '') : (tab.url || '').replace(/"/g, '&quot;');
const safeTitle = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(label) : label.replace(/"/g, '&quot;');
const safeLabel = runtimeEscapeHtml ? runtimeEscapeHtml(label) : label;
const iconData = runtimeGetIconSources(tab, 16);
const faviconUrl = iconData.sources[0] || '';
const fallbackUrl = iconData.sources[1] || '';
const fallbackLabel = runtimeGetFallbackLabel(label, iconData.hostname);
const safeFallbackUrl = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(fallbackUrl) : fallbackUrl.replace(/"/g, '&quot;');
const safeTabId = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(String(tab.id || '')) : String(tab.id || '').replace(/"/g, '&quot;');
return `<div class="page-chip clickable${chipClass}" data-action="focus-tab" data-tab-id="${safeTabId}" data-tab-url="${safeUrl}" aria-label="${safeTitle}">
${faviconUrl ? `<img class="chip-favicon" src="${faviconUrl}" alt="" data-fallback-src="${safeFallbackUrl}" data-fallback-srcset="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(JSON.stringify(iconData.sources.slice(2))) : JSON.stringify(iconData.sources.slice(2)).replace(/"/g, '&quot;')}">` : ''}
<span class="chip-favicon chip-favicon-fallback"${faviconUrl ? ' style="display:none"' : ''}>${fallbackLabel}</span>
<span class="chip-text">${safeLabel}</span>${dupeTag}
<div class="chip-actions">
${sleepControlEnabled && !tab.active ? `
<button class="chip-action chip-discard" type="button" data-action="discard-tab" data-tab-id="${tab.id}" aria-label="${runtimeT ? runtimeT('discardTab') : 'Sleep tab'}" data-tooltip="${runtimeT ? runtimeT('discardTab') : 'Sleep tab'}">
${ICONS.moon}
</button>
` : ''}
<button class="chip-action chip-session-save" type="button" data-action="save-single-tab-session" data-tab-id="${safeTabId}" data-tab-url="${safeUrl}" data-tab-title="${safeTitle}" aria-label="${runtimeT ? runtimeT('saveTabSession') : 'Save tab session'}" data-tooltip="${runtimeT ? runtimeT('saveTabSession') : 'Save tab session'}">
${ICONS.archive}
</button>
<button class="chip-action chip-close" type="button" data-action="close-single-tab" data-tab-id="${safeTabId}" data-tab-url="${safeUrl}" aria-label="${runtimeT ? runtimeT('closeThisTab') : 'Close this tab'}" data-tooltip="${runtimeT ? runtimeT('closeThisTab') : 'Close this tab'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
</div>`;
}).join('');
return `
<div class="page-chips-overflow" style="display:none">${hiddenChips}</div>
<div class="page-chip page-chip-overflow clickable" data-action="expand-chips">
<span class="chip-text">${runtimeT ? runtimeT('moreCount', { count: hiddenTabs.length }) : `+${hiddenTabs.length} more`}</span>
</div>`;
}
/* ----------------------------------------------------------------
DOMAIN CARD RENDERER
---------------------------------------------------------------- */
/**
* renderDomainCard(group, groupIndex)
*
* Builds the HTML for one domain group card.
* group = { domain: string, tabs: [{ url, title, id, windowId, active }] }
*/
function renderDomainCard(group) {
const tabs = group.tabs || [];
const tabCount = tabs.length;
const stableId = getStableGroupId(group.domain);
const groupTitle = getGroupDisplayLabel(group);
const renameEditorOpen = groupRenameEditorState?.groupKey === String(group.domain);
const renameEditorValue = renameEditorOpen ? (groupRenameEditorState?.value || groupTitle) : groupTitle;
const safeRenameValue = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(renameEditorValue) : String(renameEditorValue).replace(/"/g, '&quot;');
// Count duplicates (exact URL match)
const urlCounts = {};
for (const tab of tabs) urlCounts[tab.url] = (urlCounts[tab.url] || 0) + 1;
const dupeUrls = Object.entries(urlCounts).filter(([, c]) => c > 1);
const hasDupes = dupeUrls.length > 0;
const totalExtras = dupeUrls.reduce((s, [, c]) => s + c - 1, 0);
const dupeBadge = hasDupes
? `<span class="duplicate-count-badge">
${runtimeT
? runtimeT('duplicatesCount', {
count: totalExtras,
suffix: totalExtras !== 1 ? 's' : '',
})
: `${totalExtras} duplicate${totalExtras !== 1 ? 's' : ''}`}
</span>`
: '';
const orderedTabs = getOrderedUniqueTabsForGroup(group);
const visibleTabs = orderedTabs.slice(0, 8);
const extraCount = orderedTabs.length - visibleTabs.length;
const pageChips = visibleTabs.map(tab => {
let label = cleanTitle(smartTitle(stripTitleNoise(tab.title || ''), tab.url), group.domain);
// For localhost tabs, prepend port number so you can tell projects apart
try {
const parsed = new URL(tab.url);
if (parsed.hostname === 'localhost' && parsed.port) label = `${parsed.port} ${label}`;
} catch {}
const count = urlCounts[tab.url];
const dupeTag = count > 1 ? ` <span class="chip-dupe-badge">(${count}x)</span>` : '';
const chipClass = `${count > 1 ? ' chip-has-dupes' : ''}${tab.discarded ? ' page-chip--discarded' : ''}`;
const safeUrl = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(tab.url || '') : (tab.url || '').replace(/"/g, '&quot;');
const safeTitle = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(label) : label.replace(/"/g, '&quot;');
const safeLabel = runtimeEscapeHtml ? runtimeEscapeHtml(label) : label;
const sortId = getPrimaryTabOrderToken(tab);
const safeSortId = (runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(sortId) : String(sortId).replace(/"/g, '&quot;'));
const safeGroupId = (runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '&quot;'));
const iconData = runtimeGetIconSources(tab, 16);
const faviconUrl = iconData.sources[0] || '';
const fallbackUrl = iconData.sources[1] || '';
const fallbackLabel = runtimeGetFallbackLabel(label, iconData.hostname);
const safeFallbackUrl = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(fallbackUrl) : fallbackUrl.replace(/"/g, '&quot;');
return `<div class="page-chip clickable${chipClass}" data-action="focus-tab" data-tab-id="${tab.id}" data-tab-url="${safeUrl}" data-chip-sort-id="${safeSortId}" data-chip-group-id="${safeGroupId}" aria-label="${safeTitle}">
<button class="drawer-reorder-handle chip-reorder-handle" type="button" data-chip-drag-handle="tab" aria-label="${runtimeT ? runtimeT('dragReorderTab') : 'Drag to reorder tab'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01" /></svg>
</button>
${faviconUrl ? `<img class="chip-favicon" src="${faviconUrl}" alt="" data-fallback-src="${safeFallbackUrl}" data-fallback-srcset="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(JSON.stringify(iconData.sources.slice(2))) : JSON.stringify(iconData.sources.slice(2)).replace(/"/g, '&quot;')}">` : ''}
<span class="chip-favicon chip-favicon-fallback"${faviconUrl ? ' style="display:none"' : ''}>${fallbackLabel}</span>
<span class="chip-text">${safeLabel}</span>${dupeTag}
<div class="chip-actions">
${sleepControlEnabled && !tab.active ? `<button class="chip-action chip-discard" data-action="discard-tab" data-tab-id="${tab.id}" aria-label="${runtimeT ? runtimeT('discardTab') : 'Sleep tab'}" data-tooltip="${runtimeT ? runtimeT('discardTab') : 'Sleep tab'}">
${ICONS.moon}
</button>` : ''}
<button class="chip-action chip-session-save" data-action="save-single-tab-session" data-tab-id="${tab.id}" data-tab-url="${safeUrl}" data-tab-title="${safeTitle}" aria-label="${runtimeT ? runtimeT('saveTabSession') : 'Save tab session'}" data-tooltip="${runtimeT ? runtimeT('saveTabSession') : 'Save tab session'}">
${ICONS.archive}
</button>
<button class="chip-action chip-close" data-action="close-single-tab" data-tab-id="${tab.id}" data-tab-url="${safeUrl}" aria-label="${runtimeT ? runtimeT('closeThisTab') : 'Close this tab'}" data-tooltip="${runtimeT ? runtimeT('closeThisTab') : 'Close this tab'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
</div>`;
}).join('') + (extraCount > 0 ? buildOverflowChips(orderedTabs.slice(8), urlCounts) : '');
const saveGroupButton = `
<button class="group-action-icon" type="button" data-action="save-domain-session" data-domain-id="${stableId}" aria-label="${runtimeT ? runtimeT('saveGroupSession') : 'Save group session'}" data-tooltip="${runtimeT ? runtimeT('saveGroupSession') : 'Save group session'}">
${ICONS.archive}
</button>`;
const closeAllButton = `
<button class="group-action-icon group-action-close" type="button" data-action="close-domain-tabs" data-domain-id="${stableId}" aria-label="${runtimeT ? runtimeT('closeGroup') : 'Close group'}" data-tooltip="${runtimeT ? runtimeT('closeGroup') : 'Close group'}">
${ICONS.close}
</button>`;
let actionsHtml = '';
if (hasDupes) {
const dupeUrlsEncoded = dupeUrls.map(([url]) => encodeURIComponent(url)).join(',');
actionsHtml += `
<button class="action-btn" type="button" data-action="dedup-keep-one" data-dupe-urls="${dupeUrlsEncoded}">
${runtimeT
? runtimeT('closedDuplicatesCount', { count: totalExtras, suffix: totalExtras !== 1 ? 's' : '' })
: `Close ${totalExtras} duplicate${totalExtras !== 1 ? 's' : ''}`}
</button>`;
}
return `
<div class="mission-card domain-card ${hasDupes ? 'has-amber-bar' : 'has-neutral-bar'}" data-domain-id="${stableId}" data-group-id="${group.domain}">
<div class="status-bar"></div>
<div class="mission-content">
<div class="mission-top">
<div class="mission-heading">
<span class="mission-title-wrap">
${renameEditorOpen ? `
<form class="mission-rename-form" data-action="submit-group-rename" data-group-key="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '&quot;')}"${group.manualGroupId ? ` data-manual-group-id="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.manualGroupId) : String(group.manualGroupId).replace(/"/g, '&quot;')}"` : ''}>
<input class="mission-rename-input" type="text" value="${safeRenameValue}" data-group-rename-input="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '&quot;')}" aria-label="${runtimeT ? runtimeT('renameGroup') : 'Rename group'}" autocomplete="off">
</form>
` : `
<button class="mission-rename-trigger" type="button" data-action="rename-session-group" data-group-key="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '&quot;')}"${group.manualGroupId ? ` data-manual-group-id="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.manualGroupId) : String(group.manualGroupId).replace(/"/g, '&quot;')}"` : ''} aria-label="${runtimeT ? runtimeT('renameGroup') : 'Rename group'}" title="${runtimeT ? runtimeT('renameGroup') : 'Rename group'}">
<span class="mission-name">${runtimeEscapeHtml ? runtimeEscapeHtml(groupTitle) : groupTitle}</span>
</button>
`}
</span>
${dupeBadge}
</div>
<div class="mission-actions">
${sleepControlEnabled ? `
<button class="group-action-icon" type="button" data-action="sleep-domain-tabs" data-domain-id="${stableId}" aria-label="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs in group'}" data-tooltip="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs in group'}">
${ICONS.moon}
</button>` : ''}
${saveGroupButton}
${closeAllButton}
</div>
</div>
<div class="mission-pages">${pageChips}</div>
${actionsHtml ? `<div class="actions">${actionsHtml}</div>` : ''}
</div>
<div class="mission-meta">
<div class="mission-page-count">${tabCount}</div>
<div class="mission-page-label">${runtimeT ? runtimeT('tabsLabel') : 'tabs'}</div>
</div>
</div>`;
}
function renderGroupNav(group) {
const stableId = getStableGroupId(group.domain);
const label = getGroupDisplayLabel(group);
const orderedGroup = {
...group,
tabs: getOrderedUniqueTabsForGroup(group),
};
const iconData = runtimeGetGroupIcon(orderedGroup, label, 32);
const safeTooltip = runtimeEscapeHtmlAttribute(label);
return `
<button
class="group-nav-button"
data-action="jump-to-domain"
data-nav-kind="open-tabs"
data-group-id="${group.domain}"
data-domain-id="${stableId}"
data-tooltip="${safeTooltip}"
aria-label="${runtimeT ? runtimeT('jumpToLabel', { label: safeTooltip }) : `Jump to ${safeTooltip}` }"
draggable="false"
>
${iconData.src
? `<img class="group-nav-icon" src="${iconData.src}" alt="" draggable="false" data-fallback-src="${runtimeEscapeHtmlAttribute(iconData.fallbackSrc)}" data-fallback-srcset="${runtimeEscapeHtmlAttribute(JSON.stringify(iconData.fallbackSources?.slice(1) || []))}">`
: ''}
<span class="group-nav-fallback"${iconData.src ? ' style="display:none"' : ''}>${iconData.fallbackLabel}</span>
</button>`;
}
function renderWorkspacePageSwitch(currentPage = 'home') {
const homeActive = currentPage !== 'saved-tabs';
const savedActive = currentPage === 'saved-tabs';
const homeLabel = runtimeT ? runtimeT('workspacePageHome') : 'Home';
const savedLabel = runtimeT ? runtimeT('workspacePageSavedTabs') : 'Saved tabs';
return `
<nav class="workspace-page-switch" id="workspacePageSwitch" aria-label="Workspace pages">
<button class="workspace-page-switch-btn${homeActive ? ' is-active' : ''}" type="button" data-action="switch-workspace-page" data-page="home" aria-pressed="${homeActive ? 'true' : 'false'}" aria-controls="homePage">${homeLabel}</button>
<button class="workspace-page-switch-btn${savedActive ? ' is-active' : ''}" type="button" data-action="switch-workspace-page" data-page="saved-tabs" aria-pressed="${savedActive ? 'true' : 'false'}" aria-controls="savedTabsPage">${savedLabel}</button>
</nav>`;
}
function renderWorkspaceThemeTools() {
const languagePreference = runtimeGetLanguagePreference ? runtimeGetLanguagePreference() : 'auto';
return `
<div class="group-nav-tools">
<button class="header-theme-trigger" id="themeMenuTrigger" type="button" data-action="toggle-theme-menu" data-tooltip="${runtimeT ? runtimeT('deskSettings') : 'Desk settings'}" aria-label="${runtimeT ? runtimeT('deskSettings') : 'Desk settings'}" aria-expanded="false" aria-controls="themeMenuPanel">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 7.5h15m-12 4.5h9m-6 4.5h3" />
<circle cx="7.5" cy="7.5" r="1.5" fill="currentColor" stroke="none" />
<circle cx="16.5" cy="12" r="1.5" fill="currentColor" stroke="none" />
<circle cx="10.5" cy="16.5" r="1.5" fill="currentColor" stroke="none" />
</svg>
</button>
<div class="theme-menu" id="themeMenuPanel" hidden role="dialog" aria-label="${runtimeT ? runtimeT('deskSettingsPanel') : 'Desk settings panel'}">
<div class="theme-menu-tabs" role="tablist" aria-label="${runtimeT ? runtimeT('deskSettingsPanel') : 'Desk settings panel'}">
<button class="theme-menu-tab" id="themeMenuAppearanceTab" type="button" role="tab" data-action="select-theme-menu-tab" data-theme-menu-tab="appearance" aria-controls="themeMenuAppearancePanel" aria-selected="true">${runtimeT ? runtimeT('settingsTabAppearance') : 'Appearance'}</button>
<button class="theme-menu-tab" id="themeMenuFeaturesTab" type="button" role="tab" data-action="select-theme-menu-tab" data-theme-menu-tab="features" aria-controls="themeMenuFeaturesPanel" aria-selected="false">${runtimeT ? runtimeT('settingsTabFeatures') : 'Features'}</button>
</div>
<div class="theme-menu-panel" id="themeMenuAppearancePanel" role="tabpanel" aria-labelledby="themeMenuAppearanceTab" data-theme-menu-panel="appearance">
<div class="theme-menu-section">
<div class="theme-menu-row theme-menu-row-inline-choices">
<div class="theme-menu-label">${runtimeT ? runtimeT('appearanceMode') : 'Appearance mode'}</div>
<div class="theme-mode-options" id="themeModeOptions" role="group" aria-label="${runtimeT ? runtimeT('appearanceMode') : 'Appearance mode'}"></div>
</div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-label">${runtimeT ? runtimeT('deskPalette') : 'Desk palette'}</div>
<div class="theme-options" id="themeOptions"></div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-label">${runtimeT ? runtimeT('deskBackdrop') : 'Desk backdrop'}</div>
<div class="theme-menu-actions">
<button class="theme-menu-action" type="button" data-action="open-background-picker">${runtimeT ? runtimeT('uploadImage') : 'Upload image'}</button>
<button class="theme-menu-action is-secondary" type="button" data-action="clear-custom-background">${runtimeT ? runtimeT('clearText') : 'Clear'}</button>
</div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-row theme-menu-row-inline-choices">
<div class="theme-menu-label">${runtimeT ? runtimeT('languageLabel') : 'Language'}</div>
<div class="theme-language-options" role="group" aria-label="${runtimeT ? runtimeT('languageLabel') : 'Language'}">
<button class="theme-language-option ${languagePreference === 'auto' ? 'is-active' : ''}" type="button" data-action="select-language" data-language="auto" aria-pressed="${languagePreference === 'auto'}">${runtimeT ? runtimeT('languageAuto') : 'Auto'}</button>
<button class="theme-language-option ${languagePreference === 'en' ? 'is-active' : ''}" type="button" data-action="select-language" data-language="en" aria-pressed="${languagePreference === 'en'}">${runtimeT ? runtimeT('languageEnglish') : 'English'}</button>
<button class="theme-language-option ${languagePreference === 'zh-CN' ? 'is-active' : ''}" type="button" data-action="select-language" data-language="zh-CN" aria-pressed="${languagePreference === 'zh-CN'}">${runtimeT ? runtimeT('languageChinese') : 'Chinese'}</button>
</div>
</div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-row theme-menu-row-inline-range">
<div class="theme-menu-label">${runtimeT ? runtimeT('surfaceDepth') : 'Surface depth'}</div>
<input
class="theme-range"
id="themeTransparencyRange"
type="range"
aria-label="${runtimeT ? runtimeT('surfaceDepth') : 'Surface depth'}"
min="2"
max="60"
step="1"
value="14"
>
<div class="theme-range-value" id="themeTransparencyValue">14%</div>
</div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-row theme-menu-row-inline-range">
<div class="theme-menu-label">${runtimeT ? runtimeT('uiScaleLabel') : 'Text size'}</div>
<input
class="theme-range"
id="themeUiScaleRange"
type="range"
aria-label="${runtimeT ? runtimeT('uiScaleLabel') : 'Text size'}"
min="100"
max="120"
step="1"
value="100"
>
<div class="theme-range-value" id="themeUiScaleValue">100%</div>
</div>
</div>
<div class="theme-menu-section">
<div class="theme-menu-row theme-menu-row-inline-range">
<div class="theme-menu-label">${runtimeT ? runtimeT('shortcutScaleLabel') : 'Shortcut size'}</div>
<input
class="theme-range"
id="themeShortcutScaleRange"
type="range"
aria-label="${runtimeT ? runtimeT('shortcutScaleLabel') : 'Shortcut size'}"
min="100"
max="130"
step="1"
value="100"
>
<div class="theme-range-value" id="themeShortcutScaleValue">100%</div>
</div>
</div>
</div>
<div class="theme-menu-panel" id="themeMenuFeaturesPanel" role="tabpanel" aria-labelledby="themeMenuFeaturesTab" data-theme-menu-panel="features" hidden>
<div class="theme-menu-section">
<label class="theme-menu-toggle-label theme-menu-toggle-button-row">
<button class="theme-toggle-switch ${chromeTabGroupsEnabled ? 'is-active' : ''}" type="button" data-action="toggle-chrome-tab-groups" aria-pressed="${chromeTabGroupsEnabled ? 'true' : 'false'}" aria-label="${runtimeT ? runtimeT('chromeTabGroupsLabel') : 'Chrome tab groups'}"></button>
<span class="theme-menu-label theme-menu-toggle-text">${runtimeT ? runtimeT('chromeTabGroupsLabel') : 'Chrome tab groups'}</span>
</label>
</div>
<div class="theme-menu-section">
<label class="theme-menu-toggle-label theme-menu-toggle-button-row">
<button class="theme-toggle-switch ${(typeof themePreferences !== 'undefined' && themePreferences.hitokotoEnabled !== false) ? 'is-active' : ''}" type="button" data-action="toggle-hitokoto" aria-pressed="${(typeof themePreferences !== 'undefined' && themePreferences.hitokotoEnabled !== false) ? 'true' : 'false'}" aria-label="${runtimeT ? runtimeT('hitokotoLabel') : '一言'}"></button>
<span class="theme-menu-label theme-menu-toggle-text">${runtimeT ? runtimeT('hitokotoLabel') : '一言'}</span>
</label>
</div>
<div class="theme-menu-section">
<label class="theme-menu-toggle-label theme-menu-toggle-button-row">
<button class="theme-toggle-switch ${sleepControlEnabled ? 'is-active' : ''}" type="button" data-action="toggle-sleep-control" aria-pressed="${sleepControlEnabled ? 'true' : 'false'}" aria-label="${runtimeT ? runtimeT('sleepControlLabel') : 'Manual sleep control'}"></button>
<span class="theme-menu-label theme-menu-toggle-text">${runtimeT ? runtimeT('sleepControlLabel') : 'Manual sleep control'}</span>
</label>
</div>
<div class="theme-menu-section">
<label class="theme-menu-toggle-label theme-menu-toggle-button-row">
<button class="theme-toggle-switch ${(typeof themePreferences !== 'undefined' && themePreferences.closeDuplicateNewTabsEnabled) ? 'is-active' : ''}" type="button" data-action="toggle-close-duplicate-new-tabs" aria-pressed="${(typeof themePreferences !== 'undefined' && themePreferences.closeDuplicateNewTabsEnabled) ? 'true' : 'false'}" aria-label="${runtimeT ? runtimeT('closeDuplicateNewTabsLabel') : 'Auto-close duplicate new tabs'}"></button>
<span class="theme-menu-label theme-menu-toggle-text">${runtimeT ? runtimeT('closeDuplicateNewTabsLabel') : 'Auto-close duplicate new tabs'}</span>
</label>
</div>
</div>
<input type="file" id="themeBackgroundInput" accept="image/*" hidden>
</div>
</div>`;
}
function renderGroupNavArea(groups) {
return `
<div class="group-nav-list" data-nav-kind="open-tabs">
${groups.map(group => renderGroupNav(group)).join('')}
</div>
${renderWorkspacePageSwitch('home')}
${renderWorkspaceThemeTools()}`;
}
function buildElementFromHtml(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstElementChild || null;
}
function getWorkspaceTopNavHost() {
return document.getElementById('workspaceTopNav');
}
function syncWorkspaceTopNavMarkup(markup = '', visible = true) {
const navHost = getWorkspaceTopNavHost();
if (!navHost) return;
navHost.innerHTML = markup;
navHost.style.display = visible ? 'flex' : 'none';
if (typeof renderThemeMenu === 'function') renderThemeMenu();
}
function renderOpenTabsSummary(realTabs = getRealTabs()) {
const openTabsSection = document.getElementById('openTabsSection');
const openTabsSectionCount = document.getElementById('openTabsSectionCount');
const openTabsSectionTitle = document.getElementById('openTabsSectionTitle');
const openTabsMissionsEl = document.getElementById('openTabsMissions');
if (!openTabsSection) return;
if (domainGroups.length > 0) {
if (openTabsSectionTitle) openTabsSectionTitle.textContent = runtimeT ? runtimeT('openTabsSectionTitle') : 'Open tabs';
if (openTabsSectionCount) {
openTabsSectionCount.innerHTML = `
${sleepControlEnabled ? `<button class="section-icon-action" type="button" data-action="sleep-all-open-tabs" aria-label="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs'}" data-tooltip="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs'}">${ICONS.moon}</button>` : ''}
<button class="section-icon-action" type="button" data-action="save-current-window-session" aria-label="${runtimeT ? runtimeT('saveSessionButton') : 'Save session'}" data-tooltip="${runtimeT ? runtimeT('saveSessionButton') : 'Save session'}">${ICONS.archive}</button>
<button class="section-icon-action section-icon-action-close" type="button" data-action="close-all-open-tabs" aria-label="${runtimeT ? runtimeT('closeAllTabsButton') : 'Close all tabs'}" data-tooltip="${runtimeT ? runtimeT('closeAllTabsButton') : 'Close all tabs'}">${ICONS.close}</button>`;
}
syncWorkspaceTopNavMarkup(renderGroupNavArea(domainGroups), true);
if (openTabsMissionsEl) {
openTabsMissionsEl.querySelector('#tabSessionPicker')?.remove();
if (tabSessionPickerState.open) {
openTabsMissionsEl.insertAdjacentHTML('afterbegin', renderTabSessionPicker());
}
}
openTabsSection.style.display = 'block';
return;
}
syncWorkspaceTopNavMarkup(renderGroupNavArea([]), true);
openTabsSection.style.display = 'block';
if (openTabsMissionsEl) openTabsMissionsEl.innerHTML = renderMissionsEmptyState();
if (openTabsSectionCount) openTabsSectionCount.textContent = runtimeT ? runtimeT('emptyTabsCount') : '0 domains';
}
function patchOpenTabsDomFromGroups(realTabs = getRealTabs(), changedGroupKeys = []) {
const missionsEl = document.getElementById('openTabsMissions');
const navHost = getWorkspaceTopNavHost();
const navListEl = navHost?.querySelector('.group-nav-list[data-nav-kind="open-tabs"]');
if (!missionsEl || !navHost || !navListEl) {
renderOpenTabsArea(realTabs);
return;
}
const changedKeys = new Set((changedGroupKeys || []).map(String).filter(Boolean));
const existingCards = new Map(
[...missionsEl.querySelectorAll('.mission-card')].map(card => [card.dataset.groupId || '', card])
);
const existingButtons = new Map(
[...navListEl.querySelectorAll('.group-nav-button')].map(button => [button.dataset.groupId || '', button])
);
const desiredKeys = domainGroups.map(group => String(group.domain));
existingCards.forEach((card, key) => {
if (!desiredKeys.includes(key)) card.remove();
});
existingButtons.forEach((button, key) => {
if (!desiredKeys.includes(key)) button.remove();
});
const previousNavRects = new Map();
navListEl.querySelectorAll('.group-nav-button').forEach(button => {
previousNavRects.set(button.dataset.groupId || '', button.getBoundingClientRect());
});
for (const group of domainGroups) {
const key = String(group.domain);
const currentCard = existingCards.get(key) || null;
let nextCardNode = currentCard;
if (!currentCard || changedKeys.has(key)) {
nextCardNode = buildElementFromHtml(renderDomainCard(group));
if (currentCard && nextCardNode) {
currentCard.replaceWith(nextCardNode);
}
}
if (nextCardNode) missionsEl.appendChild(nextCardNode);
const currentButton = existingButtons.get(key) || null;
let nextButtonNode = currentButton;
if (!currentButton || changedKeys.has(key)) {
nextButtonNode = buildElementFromHtml(renderGroupNav(group));
if (currentButton && nextButtonNode) {
currentButton.replaceWith(nextButtonNode);
}
}
if (nextButtonNode) navListEl.appendChild(nextButtonNode);
}
animateNavButtons(navListEl, previousNavRects);
renderOpenTabsSummary(realTabs);
}
async function buildDomainGroups(realTabs = getRealTabs()) {
// Landing pages (Gmail inbox, Twitter home, etc.) get their own special group
// so they can be closed together without affecting content tabs on the same domain.
const LANDING_PAGE_PATTERNS = [
{ hostname: 'mail.google.com', test: (p, h) =>
!h.includes('#inbox/') && !h.includes('#sent/') && !h.includes('#search/') },
{ hostname: 'x.com', pathExact: ['/home'] },
{ hostname: 'www.linkedin.com', pathExact: ['/'] },
{ hostname: 'github.com', pathExact: ['/'] },
{ hostname: 'www.youtube.com', pathExact: ['/'] },
...(typeof LOCAL_LANDING_PAGE_PATTERNS !== 'undefined' ? LOCAL_LANDING_PAGE_PATTERNS : []),
];
function isLandingPage(url) {
try {
const parsed = new URL(url);
return LANDING_PAGE_PATTERNS.some(p => {
const hostnameMatch = p.hostname
? parsed.hostname === p.hostname
: p.hostnameEndsWith
? parsed.hostname.endsWith(p.hostnameEndsWith)
: false;
if (!hostnameMatch) return false;
if (p.test) return p.test(parsed.pathname, url);
if (p.pathPrefix) return parsed.pathname.startsWith(p.pathPrefix);
if (p.pathExact) return p.pathExact.includes(parsed.pathname);
return parsed.pathname === '/';
});
} catch { return false; }
}
domainGroups = [];
const manualGroupMap = Object.fromEntries(
sessionGroupsState.groups.map(group => [
group.id,
{
domain: `${MANUAL_GROUP_PREFIX}${group.id}`,
label: group.name,
tabs: [],
isManual: true,
manualGroupId: group.id,
createdAt: group.createdAt,
},
])
);
const groupMap = {};
const landingTabs = [];
const customGroups = typeof LOCAL_CUSTOM_GROUPS !== 'undefined' ? LOCAL_CUSTOM_GROUPS : [];
function matchCustomGroup(url) {
try {
const parsed = new URL(url);
return customGroups.find(r => {
const hostMatch = r.hostname
? parsed.hostname === r.hostname
: r.hostnameEndsWith
? parsed.hostname.endsWith(r.hostnameEndsWith)
: false;
if (!hostMatch) return false;
if (r.pathPrefix) return parsed.pathname.startsWith(r.pathPrefix);
return true;
}) || null;
} catch { return null; }
}
for (const tab of realTabs) {
try {
const assignedGroupId = sessionGroupsState.assignments[String(tab.id)];
if (assignedGroupId && manualGroupMap[assignedGroupId]) {
manualGroupMap[assignedGroupId].tabs.push({
...tab,
manualGroupId: assignedGroupId,
});
continue;
}
if (isLandingPage(tab.url)) {
landingTabs.push(tab);
continue;
}
const customRule = matchCustomGroup(tab.url);
if (customRule) {
const key = customRule.groupKey;
if (!groupMap[key]) groupMap[key] = { domain: key, label: customRule.groupLabel, tabs: [] };
groupMap[key].tabs.push(tab);
continue;
}
let hostname;
if (tab.url && tab.url.startsWith('file://')) {
hostname = 'local-files';
} else {
const rawHostname = new URL(tab.url).hostname;
hostname = runtimeGetPrimaryDomain ? runtimeGetPrimaryDomain(rawHostname) : rawHostname;
}
if (!hostname) continue;
if (!groupMap[hostname]) groupMap[hostname] = { domain: hostname, tabs: [] };
groupMap[hostname].tabs.push(tab);
} catch {
// Skip malformed URLs
}
}
if (landingTabs.length > 0) {
groupMap.__landing_pages__ = undefined;
groupMap['__landing-pages__'] = { domain: '__landing-pages__', tabs: landingTabs };
}
const landingHostnames = new Set(LANDING_PAGE_PATTERNS.map(p => p.hostname).filter(Boolean));
const landingSuffixes = LANDING_PAGE_PATTERNS.map(p => p.hostnameEndsWith).filter(Boolean);
function isLandingDomain(domain) {
if (landingHostnames.has(domain)) return true;
return landingSuffixes.some(s => domain.endsWith(s));
}
const manualGroups = Object.values(manualGroupMap)
.filter(group => group.tabs.length > 0)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const automaticGroups = Object.values(groupMap).sort((a, b) => {
const aIsLanding = a.domain === '__landing-pages__';
const bIsLanding = b.domain === '__landing-pages__';
if (aIsLanding !== bIsLanding) return aIsLanding ? -1 : 1;
const aIsPriority = isLandingDomain(a.domain);
const bIsPriority = isLandingDomain(b.domain);
if (aIsPriority !== bIsPriority) return aIsPriority ? -1 : 1;
return b.tabs.length - a.tabs.length;
});
for (const group of automaticGroups) {
if (groupLabelOverrides[group.domain]) {
group.label = groupLabelOverrides[group.domain];
}
}
domainGroups = applyGroupOrder([...manualGroups, ...automaticGroups], groupOrderState);
await loadGroupTabOrder(domainGroups);
return domainGroups;
}
function renderOpenTabsArea(realTabs = getRealTabs()) {
const openTabsSection = document.getElementById('openTabsSection');
const openTabsMissionsEl = document.getElementById('openTabsMissions');
const openTabsSectionCount = document.getElementById('openTabsSectionCount');
const openTabsSectionTitle = document.getElementById('openTabsSectionTitle');
if (domainGroups.length > 0 && openTabsSection) {
if (openTabsSectionTitle) openTabsSectionTitle.textContent = runtimeT ? runtimeT('openTabsSectionTitle') : 'Open tabs';
if (openTabsSectionCount) {
openTabsSectionCount.innerHTML = `
${sleepControlEnabled ? `<button class="section-icon-action" type="button" data-action="sleep-all-open-tabs" aria-label="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs'}" data-tooltip="${runtimeT ? runtimeT('sleepAllTabsButton') : 'Sleep all tabs'}">${ICONS.moon}</button>` : ''}
<button class="section-icon-action" type="button" data-action="save-current-window-session" aria-label="${runtimeT ? runtimeT('saveSessionButton') : 'Save session'}" data-tooltip="${runtimeT ? runtimeT('saveSessionButton') : 'Save session'}">${ICONS.archive}</button>
<button class="section-icon-action section-icon-action-close" type="button" data-action="close-all-open-tabs" aria-label="${runtimeT ? runtimeT('closeAllTabsButton') : 'Close all tabs'}" data-tooltip="${runtimeT ? runtimeT('closeAllTabsButton') : 'Close all tabs'}">${ICONS.close}</button>`;
}
if (openTabsMissionsEl) openTabsMissionsEl.innerHTML = `${renderTabSessionPicker()}${domainGroups.map(g => renderDomainCard(g)).join('')}`;
syncWorkspaceTopNavMarkup(renderGroupNavArea(domainGroups), true);
openTabsSection.style.display = 'block';
} else if (openTabsSection) {
syncWorkspaceTopNavMarkup(renderGroupNavArea([]), true);
openTabsSection.style.display = 'block';
if (openTabsMissionsEl) openTabsMissionsEl.innerHTML = renderMissionsEmptyState();
if (openTabsSectionCount) openTabsSectionCount.textContent = runtimeT ? runtimeT('emptyTabsCount') : '0 domains';
}
const statTabs = document.getElementById('statTabs');
if (statTabs) statTabs.textContent = openTabs.length;
if (groupRenameEditorState?.shouldFocus) {
const focusKey = String(groupRenameEditorState.groupKey || '');
requestAnimationFrame(() => {
const renameInput = document.querySelector(`[data-group-rename-input="${CSS.escape(focusKey)}"]`);
if (!renameInput) return;
renameInput.focus();
renameInput.select?.();
if (groupRenameEditorState && groupRenameEditorState.groupKey === focusKey) {
groupRenameEditorState.shouldFocus = false;
}
});
}
}
async function renderOpenTabsLayout({ rebuildGroups = true, syncChrome = false, patchDom = false, changedGroupKeys = [] } = {}) {
const realTabs = getRealTabs();
if (rebuildGroups) {
await buildDomainGroups(realTabs);
}
if (patchDom) {
patchOpenTabsDomFromGroups(realTabs, changedGroupKeys);
} else {
renderOpenTabsArea(realTabs);
}
setupImageErrorHandlers();
if (syncChrome) {
await syncChromeTabGroupsWithoutImportEcho();
}
}
/* ----------------------------------------------------------------
MAIN DASHBOARD RENDERER
---------------------------------------------------------------- */
/**
* renderStaticDashboard()
*
* The main render function:
* 1. Paints greeting + date
* 2. Fetches open tabs via chrome.tabs.query()
* 3. Groups tabs by domain (with landing pages pulled out to their own group)
* 4. Renders domain cards
* 5. Updates footer stats
* 6. Renders the "Saved for Later" checklist
*/
async function renderStaticDashboard() {
// --- Header ---
const greetingEl = document.getElementById('greeting');
const dateEl = document.getElementById('dateDisplay');
if (greetingEl) greetingEl.textContent = getGreeting();
if (dateEl) dateEl.textContent = getDateDisplay();
// --- Hitokoto (一言) ---
const hitokotoEnabled = typeof themePreferences !== 'undefined' ? themePreferences.hitokotoEnabled : true;
const hitokotoEl = document.getElementById('hitokoto');
const hitokotoTextEl = document.getElementById('hitokotoText');
const hitokotoFromEl = document.getElementById('hitokotoFrom');
if (hitokotoEnabled && hitokotoEl && hitokotoTextEl && hitokotoFromEl) {
const hasCachedHitokoto = renderCachedHitokoto(hitokotoTextEl, hitokotoFromEl);
hitokotoEl.style.display = hasCachedHitokoto ? '' : 'none';
void warmHitokotoCacheInBackground();
} else if (hitokotoEl) {
hitokotoEl.style.display = 'none';
}
renderThemeMenu();
await renderQuickShortcuts();
// --- Fetch tabs ---
await fetchOpenTabs();
const realTabs = getRealTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await loadGroupOrder();
await loadGroupLabelOverrides();
await buildDomainGroups(realTabs);
renderOpenTabsArea(realTabs);
// --- Check for duplicate Tab Harbor tabs ---
checkTabOutDupes();
// --- Render "Saved for Later" column ---
await renderDeferredColumn();
// Setup image error handlers for CSP compliance
setupImageErrorHandlers();
if (document.body.classList.contains('showing-saved-tabs-page')) {
await globalThis.TabHarborSessionManager?.renderSavedTabsPage?.();
}
}
async function renderDashboard() {
await renderStaticDashboard();
if (chromeTabGroupsEnabled) {
await syncChromeTabGroupsWithoutImportEcho();
}
}
async function resolveCurrentDashboardTab() {
let currentTab = null;
try {
currentTab = await chrome.tabs.getCurrent();
} catch {
currentTab = null;
}
if (!currentTab?.id || !currentTab?.windowId) {
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentTab = activeTab || currentTab || null;
} catch {
currentTab = currentTab || null;
}
}
return currentTab;
}
function shouldSkipStartupTabChange(message = {}) {
if (Date.now() > dashboardStartupTabChangeIgnoreUntil) return false;
if (message.source !== 'tabs.onCreated' && message.source !== 'tabs.onUpdated') return false;
if (currentDashboardTabId == null || message.triggerTabId == null) return false;
return Number(message.triggerTabId) === Number(currentDashboardTabId);
}
async function collapseChromeGroupsForCurrentTabHarborTab() {
if (typeof collapseChromeTabGroupsInWindow !== 'function') return;
const currentTab = await resolveCurrentDashboardTab();
const newtabUrl = window.location.href;
const isTabHarborTab = currentTab?.url === newtabUrl || currentTab?.url === 'chrome://newtab/';
if (!currentTab?.windowId || !isTabHarborTab) return;
await collapseChromeTabGroupsInWindow(currentTab.windowId);
}
function updateBackToTopVisibility() {
const button = document.getElementById('backToTopBtn');
if (!button) return;
const shouldShow = window.scrollY > 320;
button.classList.toggle('visible', shouldShow);
}
/* ----------------------------------------------------------------
EVENT HANDLERS — using event delegation
One listener on document handles ALL button clicks.
Think of it as one security guard watching the whole building
instead of one per door.
---------------------------------------------------------------- */
document.addEventListener('click', async (e) => {
if (e.target.closest('.chip-reorder-handle')) return;
// Walk up the DOM to find the nearest element with data-action
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'toggle-theme-menu') {
setThemeMenuOpen(!themeMenuOpen);
return;
}
if (action === 'select-theme-menu-tab') {
themeMenuActiveTab = actionEl.dataset.themeMenuTab === 'features' ? 'features' : 'appearance';
renderThemeMenu();
return;
}
if (action === 'select-theme') {
const paletteId = actionEl.dataset.paletteId || 'paper';
await saveThemePreferences({ paletteId });
setThemeMenuOpen(false, { restoreFocus: true });
showToast(runtimeT ? runtimeT('toastThemeUpdated') : 'Theme updated');
return;
}
if (action === 'select-theme-mode') {
const mode = actionEl.dataset.themeMode || 'system';
await saveThemePreferences({ mode });
setThemeMenuOpen(false, { restoreFocus: true });
const modeLabelKey = {
system: 'themeModeSystem',
light: 'themeModeLight',
dark: 'themeModeDark',
}[mode] || 'themeModeSystem';
const modeLabel = runtimeT
? runtimeT(modeLabelKey)
: mode;
showToast(runtimeT ? runtimeT('toastThemeModeUpdated', { mode: modeLabel }) : `Appearance mode: ${modeLabel}`);
return;
}
if (action === 'open-background-picker') {
document.getElementById('themeBackgroundInput')?.click();
return;
}
if (action === 'clear-custom-background') {
await saveThemePreferences({ customBackground: '' });
setThemeMenuOpen(false, { restoreFocus: true });
showToast(runtimeT ? runtimeT('toastBackgroundCleared') : 'Background cleared');
return;
}
if (action === 'select-language') {
const language = actionEl.dataset.language || 'auto';
if (runtimeSetLanguagePreference) {
await runtimeSetLanguagePreference(language, { reload: true });
return;
}
}
// ---- Close duplicate Tab Harbor tabs ----
if (action === 'close-tabout-dupes') {
// Suppress auto-refresh to prevent animation spam
window.__suppressAutoRefreshUntil = Date.now() + 2000;
await closeTabOutDupes();
await renderDashboard();
if (window.__tabRefreshTimeout) {
clearTimeout(window.__tabRefreshTimeout);
window.__tabRefreshTimeout = null;
}
window.__suppressAutoRefreshUntil = 0;
updateBackToTopVisibility();
playCloseSound();
const banner = document.getElementById('tabOutDupeBanner');
if (banner) {
banner.style.transition = 'opacity 0.4s';
banner.style.opacity = '0';
setTimeout(() => { banner.style.display = 'none'; banner.style.opacity = '1'; }, 400);
}
showToast(runtimeT ? runtimeT('toastClosedExtraTabHarborTabs') : 'Closed extra Tab Harbor tabs');
return;
}
if (action === 'toggle-chrome-tab-groups') {
const nextEnabled = !chromeTabGroupsEnabled;
if (typeof setThemeMenuOpen === 'function') setThemeMenuOpen(false);
await applyChromeTabGroupsToggle(nextEnabled);
return;
}
if (action === 'toggle-hitokoto') {
const nextEnabled = !(typeof themePreferences !== 'undefined' && themePreferences.hitokotoEnabled);
await saveThemePreferences({ hitokotoEnabled: nextEnabled });
const hitokotoEl = document.getElementById('hitokoto');
const hitokotoTextEl = document.getElementById('hitokotoText');
const hitokotoFromEl = document.getElementById('hitokotoFrom');
if (hitokotoEl) hitokotoEl.style.display = nextEnabled ? '' : 'none';
if (nextEnabled && hitokotoTextEl && hitokotoFromEl) {
const hasCachedHitokoto = renderCachedHitokoto(hitokotoTextEl, hitokotoFromEl);
if (hitokotoEl) hitokotoEl.style.display = hasCachedHitokoto ? '' : 'none';
void refreshHitokotoInBackground(hitokotoTextEl, hitokotoFromEl);
} else if (!nextEnabled && hitokotoTextEl && hitokotoFromEl) {
hitokotoTextEl.textContent = '';
hitokotoFromEl.textContent = '';
}
// Sync toggle switch visual state (renderThemeMenu does not know about this switch)
const toggleSwitch = document.querySelector('[data-action="toggle-hitokoto"]');
if (toggleSwitch) {
toggleSwitch.classList.toggle('is-active', nextEnabled);
toggleSwitch.setAttribute('aria-pressed', String(nextEnabled));
}
return;
}
if (action === 'toggle-sleep-control') {
const nextEnabled = sleepControlEnabled !== true;
await saveThemePreferences({ sleepControlEnabled: nextEnabled });
sleepControlEnabled = nextEnabled;
await renderDashboard();
return;
}
if (action === 'toggle-close-duplicate-new-tabs') {
const nextEnabled = !(typeof themePreferences !== 'undefined' && themePreferences.closeDuplicateNewTabsEnabled);
await saveThemePreferences({ closeDuplicateNewTabsEnabled: nextEnabled });
const toggleSwitch = document.querySelector('[data-action="toggle-close-duplicate-new-tabs"]');
if (toggleSwitch) {
toggleSwitch.classList.toggle('is-active', nextEnabled);
toggleSwitch.setAttribute('aria-pressed', String(nextEnabled));
}
return;
}
const card = actionEl.closest('.mission-card');
if (action === 'save-current-window-session') {
if (!actionEl.closest('#homePage')) return;
e.preventDefault();
await openTabSessionPicker({ source: 'current-window' });
return;
}
if (action === 'close-session-picker') {
e.preventDefault();
closeTabSessionPicker();
return;
}
if (action === 'select-session-picker-mode') {
e.preventDefault();
const nextMode = actionEl.dataset.sessionPickerMode === 'existing' ? 'existing' : 'new';
if (nextMode === 'existing' && !tabSessionPickerState.savedSessions?.length) return;
tabSessionPickerState = {
...tabSessionPickerState,
mode: nextMode,
targetSessionId: tabSessionPickerState.targetSessionId || String(tabSessionPickerState.savedSessions?.[0]?.id || ''),
};
renderOpenTabsArea();
return;
}
if (action === 'toggle-session-picker-group') {
const groupKey = actionEl.dataset.groupKey || '';
const groupIds = getTabSessionPickerGroupIds(groupKey, tabSessionPickerState.groups);
const selected = new Set(getTabSessionPickerSelectedIds());
const checked = groupIds.length > 0 && groupIds.every(id => selected.has(id));
groupIds.forEach(id => {
if (checked) selected.delete(id);
else selected.add(id);
});
tabSessionPickerState = {
...tabSessionPickerState,
selectedTabIds: [...selected],
};
renderOpenTabsArea();
return;
}
if (action === 'toggle-session-picker-tab') {
const tabId = String(actionEl.dataset.tabId || '');
if (!tabId) return;
const selected = new Set(getTabSessionPickerSelectedIds());
if (selected.has(tabId)) selected.delete(tabId);
else selected.add(tabId);
tabSessionPickerState = {
...tabSessionPickerState,
selectedTabIds: [...selected],
};
renderOpenTabsArea();
return;
}
if (action === 'save-selected-session-tabs') {
e.preventDefault();
await submitTabSessionPicker();
return;
}
if (action === 'rename-session-group') {
e.stopPropagation();
const groupKey = actionEl.dataset.groupKey || '';
const manualGroupId = actionEl.dataset.manualGroupId || '';
openGroupRenameEditor(groupKey, manualGroupId);
return;
}
if (action === 'cancel-group-rename') {
e.stopPropagation();
closeGroupRenameEditor();
await renderDashboard();
return;
}
// ---- Expand overflow chips ("+N more") ----
if (action === 'expand-chips') {
const overflowContainer = actionEl.parentElement.querySelector('.page-chips-overflow');
if (overflowContainer) {
overflowContainer.style.display = 'contents';
actionEl.remove();
}
return;
}
// ---- Focus a specific tab ----
if (action === 'focus-tab') {
if (Date.now() < suppressPageChipClickUntil) return;
const tabUrl = actionEl.dataset.tabUrl;
const tabId = actionEl.dataset.tabId || '';
if (tabUrl || tabId) await focusTab(tabUrl, tabId);
return;
}
if (action === 'toggle-chrome-tab-groups') {
return;
}
if (action === 'jump-to-domain') {
if (Date.now() < suppressJumpUntil) return;
const domainId = actionEl.dataset.domainId;
if (!domainId) return;
const target = document.querySelector(`.mission-card[data-domain-id="${domainId}"]`);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.add('group-nav-target');
setTimeout(() => target.classList.remove('group-nav-target'), 1200);
return;
}
// ---- Close a single tab ----
if (action === 'close-single-tab') {
e.stopPropagation(); // don't trigger parent chip's focus-tab
const tabUrl = actionEl.dataset.tabUrl;
const tabId = actionEl.dataset.tabId || '';
if (!tabUrl && !tabId) return;
// Suppress auto-refresh to prevent animation spam
window.__suppressAutoRefreshUntil = Date.now() + 2000;
// Close the tab in Chrome directly. Prefer id so suspended/canonical URLs
// and duplicate pages cannot close the wrong tab.
await removeOpenTabByIdOrUrl(tabId, tabUrl);
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
playCloseSound();
// Animate the chip row out
const chip = actionEl.closest('.page-chip');
const parentCard = chip?.closest('.mission-card');
if (chip) {
const rect = chip.getBoundingClientRect();
shootConfetti(rect.left + rect.width / 2, rect.top + rect.height / 2);
// First phase: fade and scale down
chip.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
chip.style.opacity = '0';
chip.style.transform = 'scale(0.95)';
setTimeout(() => {
// Second phase: collapse height to 0 for smooth upward slide
chip.style.transition = 'height 0.2s ease-out, margin 0.2s ease-out, padding 0.2s ease-out, opacity 0.1s';
chip.style.height = '0';
chip.style.marginTop = '0';
chip.style.marginBottom = '0';
chip.style.paddingTop = '0';
chip.style.paddingBottom = '0';
chip.style.overflow = 'hidden';
setTimeout(() => {
chip.remove();
// Check if card is now empty and animate it out
if (parentCard) {
const remainingChips = parentCard.querySelectorAll('.page-chip[data-action="focus-tab"]');
if (remainingChips.length === 0) {
// Card is empty - wait a brief moment for layout to settle, then animate card out
setTimeout(() => {
animateCardOut(parentCard);
}, 50);
}
}
}, 200);
}, 150);
}
// Update footer
const statTabs = document.getElementById('statTabs');
if (statTabs) statTabs.textContent = openTabs.length;
showToast(runtimeT ? runtimeT('toastTabClosed') : 'Tab closed');
return;
}
// ---- Discard (sleep) a single tab ----
if (action === 'discard-tab') {
e.stopPropagation();
const tabId = actionEl.dataset.tabId;
if (!tabId) return;
window.__suppressAutoRefreshUntil = Date.now() + 2000;
const discarded = await discardTab(Number(tabId));
if (!discarded) {
showToast(runtimeT ? runtimeT('toastTabDiscardFailed') || 'Failed to sleep tab' : 'Failed to sleep tab');
return;
}
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await renderDashboard();
showToast(runtimeT ? runtimeT('toastTabDiscarded') : 'Tab sleeping');
return;
}
// ---- Save a single tab as its own session snapshot (then close it) ----
if (action === 'save-single-tab-session') {
e.stopPropagation();
const tabId = actionEl.dataset.tabId || '';
if (!tabId) return;
await openTabSessionPicker({
source: 'single-tab',
initialTabIds: [tabId],
scopeTabIds: [tabId],
});
return;
}
// ---- Close all tabs in a domain group ----
if (action === 'close-domain-tabs') {
const domainId = actionEl.dataset.domainId;
const group = domainGroups.find(g => {
return 'domain-' + g.domain.replace(/[^a-z0-9]/g, '-') === domainId;
});
if (!group) return;
// Suppress auto-refresh to prevent animation spam
window.__suppressAutoRefreshUntil = Date.now() + 2000;
const urls = group.tabs.map(t => t.url);
// Landing pages and custom groups (whose domain key isn't a real hostname)
// must use exact URL matching to avoid closing unrelated tabs
const useExact = group.domain === '__landing-pages__' || !!group.label;
if (useExact) {
await closeTabsExact(urls);
} else {
await closeTabsByUrls(urls);
}
if (card) {
playCloseSound();
animateCardOut(card);
}
// Remove from in-memory groups
const idx = domainGroups.indexOf(group);
if (idx !== -1) domainGroups.splice(idx, 1);
const groupLabel = group.domain === '__landing-pages__'
? (runtimeT ? runtimeT('homepagesLabel') : 'Homepages')
: (group.label || friendlyDomain(group.domain));
const tabsWord = runtimeT
? (urls.length === 1 ? runtimeT('tabsWordSingular') : runtimeT('tabsWordPlural'))
: `tab${urls.length !== 1 ? 's' : ''}`;
showToast(runtimeT
? runtimeT('closedTabsFromGroup', { count: urls.length, tabsWord, groupLabel })
: `Closed ${urls.length} ${tabsWord} from ${groupLabel}`);
const statTabs = document.getElementById('statTabs');
if (statTabs) statTabs.textContent = openTabs.length;
return;
}
// ---- Save a whole domain group as one session snapshot (then close it) ----
if (action === 'save-domain-session') {
e.preventDefault();
const domainId = actionEl.dataset.domainId || '';
const group = domainGroups.find(g => getStableGroupId(g.domain) === domainId);
if (!group) return;
const tabIds = getOrderedUniqueTabsForGroup(group)
.map(tab => String(tab?.id || ''))
.filter(Boolean);
if (!tabIds.length) return;
await openTabSessionPicker({
source: 'group',
initialTabIds: tabIds,
scopeTabIds: tabIds,
});
return;
}
// ---- Sleep all tabs in a domain group ----
if (action === 'sleep-domain-tabs') {
const domainId = actionEl.dataset.domainId || '';
const group = domainGroups.find(g => getStableGroupId(g.domain) === domainId);
if (!group) return;
const tabs = getOrderedUniqueTabsForGroup(group).filter(t => !t.active);
if (!tabs.length) return;
window.__suppressAutoRefreshUntil = Date.now() + 2000;
const results = await Promise.all(tabs.map(t => discardTab(t.id)));
const succeeded = results.filter(Boolean).length;
if (!succeeded) {
showToast(runtimeT ? runtimeT('toastTabDiscardFailed') || 'Failed to sleep tabs' : 'Failed to sleep tabs');
return;
}
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await renderDashboard();
showToast(runtimeT
? runtimeT('toastTabsDiscarded', { count: succeeded })
: `${succeeded} tabs sleeping`);
return;
}
// ---- Sleep all open tabs ----
if (action === 'sleep-all-open-tabs') {
const allTabs = getRealTabs().filter(t => !t.active);
if (!allTabs.length) return;
window.__suppressAutoRefreshUntil = Date.now() + 2000;
const results = await Promise.all(allTabs.map(t => discardTab(t.id)));
const succeeded = results.filter(Boolean).length;
if (!succeeded) {
showToast(runtimeT ? runtimeT('toastTabDiscardFailed') || 'Failed to sleep tabs' : 'Failed to sleep tabs');
return;
}
await fetchOpenTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
await renderDashboard();
showToast(runtimeT
? runtimeT('toastTabsDiscarded', { count: succeeded })
: `${succeeded} tabs sleeping`);
return;
}
// ---- Close duplicates, keep one copy ----
if (action === 'dedup-keep-one') {
const urlsEncoded = actionEl.dataset.dupeUrls || '';
const urls = urlsEncoded.split(',').map(u => decodeURIComponent(u)).filter(Boolean);
if (urls.length === 0) return;
// Suppress auto-refresh to prevent animation spam
window.__suppressAutoRefreshUntil = Date.now() + 2000;
await closeDuplicateTabs(urls, true);
playCloseSound();
// Hide the dedup button
actionEl.style.transition = 'opacity 0.2s';
actionEl.style.opacity = '0';
setTimeout(() => actionEl.remove(), 200);
// Remove dupe badges from the card
if (card) {
card.querySelectorAll('.chip-dupe-badge').forEach(b => {
b.style.transition = 'opacity 0.2s';
b.style.opacity = '0';
setTimeout(() => b.remove(), 200);
});
card.querySelectorAll('.duplicate-count-badge').forEach(badge => {
badge.style.transition = 'opacity 0.2s';
badge.style.opacity = '0';
setTimeout(() => badge.remove(), 200);
});
card.classList.remove('has-amber-bar');
card.classList.add('has-neutral-bar');
}
showToast(runtimeT ? runtimeT('toastClosedDuplicatesKeptOne') : 'Closed duplicates, kept one copy each');
return;
}
// ---- Close ALL open tabs ----
if (action === 'close-all-open-tabs') {
// Suppress auto-refresh to prevent animation spam
window.__suppressAutoRefreshUntil = Date.now() + 2000;
const allUrls = openTabs
.filter(t => t.url && !t.url.startsWith('chrome') && !t.url.startsWith('about:'))
.map(t => t.url);
await closeTabsByUrls(allUrls);
playCloseSound();
document.querySelectorAll('#openTabsMissions .mission-card').forEach(c => {
shootConfetti(
c.getBoundingClientRect().left + c.offsetWidth / 2,
c.getBoundingClientRect().top + c.offsetHeight / 2
);
animateCardOut(c);
});
showToast(runtimeT ? runtimeT('toastAllTabsClosed') : 'All tabs closed. Fresh start.');
return;
}
});
document.addEventListener('click', (e) => {
const themeTrigger = document.getElementById('themeMenuTrigger');
const themePanel = document.getElementById('themeMenuPanel');
if (
themeMenuOpen &&
themePanel &&
!themePanel.contains(e.target) &&
!themeTrigger?.contains(e.target)
) {
setThemeMenuOpen(false);
}
});
document.addEventListener('click', (e) => {
const todoTrigger = e.target.closest('#todoTrigger');
if (todoTrigger) {
if (Date.now() < deferredTriggerSuppressClickUntil) return;
const nextOpen = !deferredPanelOpen;
drawerView = 'todos';
setDeferredPanelOpen(nextOpen);
return;
}
if (e.target.closest('#deferredOverlay')) {
setDeferredPanelOpen(false);
}
});
document.addEventListener('pointerdown', (e) => {
if (!groupRenameEditorState) return;
if (e.target.closest('.mission-rename-form') || e.target.closest('[data-action="rename-session-group"]')) return;
void submitGroupRenameEditor();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && themeMenuOpen) {
setThemeMenuOpen(false, { restoreFocus: true });
return;
}
if (e.key === 'Escape' && groupRenameEditorState) {
closeGroupRenameEditor();
void renderDashboard();
return;
}
if (e.key === 'Escape' && deferredPanelOpen && todoEditorState?.open) {
closeTodoEditor();
void renderDeferredColumn();
return;
}
if (e.key === 'Escape' && deferredPanelOpen) {
setDeferredPanelOpen(false);
}
});
document.addEventListener('pointerdown', (e) => {
const trigger = e.target.closest('.deferred-trigger');
const triggerStack = document.getElementById('drawerTriggerStack');
if (!trigger || !triggerStack || isMobileDeferredLayout() || e.button !== 0) return;
const rect = triggerStack.getBoundingClientRect();
deferredTriggerDragState = {
startY: e.clientY,
offsetY: e.clientY - rect.top,
moved: false,
};
});
document.addEventListener('pointermove', (e) => {
if (!deferredTriggerDragState || isMobileDeferredLayout()) return;
const triggerStack = document.getElementById('drawerTriggerStack');
if (!triggerStack) return;
const distance = Math.abs(e.clientY - deferredTriggerDragState.startY);
if (!deferredTriggerDragState.moved && distance < 6) return;
deferredTriggerDragState.moved = true;
const nextTop = runtimeClampTriggerTop(
e.clientY - deferredTriggerDragState.offsetY,
window.innerHeight,
triggerStack.offsetHeight || 96,
24
);
if (nextTop == null) return;
deferredTriggerPosition = { top: nextTop };
triggerStack.style.top = `${nextTop}px`;
});
document.addEventListener('pointerup', async () => {
if (!deferredTriggerDragState) return;
if (deferredTriggerDragState.moved) {
await saveDeferredTriggerPosition(deferredTriggerPosition);
deferredTriggerSuppressClickUntil = Date.now() + 250;
}
deferredTriggerDragState = null;
});
document.addEventListener('click', (e) => {
const backToTopBtn = e.target.closest('#backToTopBtn');
if (!backToTopBtn) return;
window.scrollTo({
top: 0,
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
});
document.addEventListener('click', async (e) => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'toggle-todo-search') {
todoSearchOpen = !todoSearchOpen;
if (!todoSearchOpen) todoSearchQuery = '';
await renderDeferredColumn();
return;
}
if (action === 'create-todo') {
drawerView = 'todos';
todoDetailId = '';
openTodoEditor({ mode: 'create' });
await renderDeferredColumn();
focusTodoEditorTitle();
return;
}
if (action === 'edit-todo') {
const id = actionEl.dataset.todoId;
if (!id) return;
const todos = await getTodos();
const todo = todos.find(item => item.id === id && !item.dismissed);
if (!todo) return;
drawerView = 'todos';
openTodoEditor({
mode: 'edit',
todo,
});
await renderDeferredColumn();
focusTodoEditorTitle();
return;
}
if (action === 'update-todo-editor-field') {
setTodoEditorField(actionEl.dataset.field || '', actionEl.value || '');
return;
}
if (action === 'submit-todo-editor') {
e.preventDefault();
await submitTodoEditor();
return;
}
if (action === 'cancel-todo-editor') {
closeTodoEditor();
await renderDeferredColumn();
return;
}
if (action === 'open-todo-detail') {
todoDetailId = actionEl.dataset.todoId || '';
await renderDeferredColumn();
return;
}
if (action === 'close-todo-detail') {
todoDetailId = '';
await renderDeferredColumn();
return;
}
if (action === 'complete-todo') {
const id = actionEl.dataset.todoId;
if (!id) return;
await completeTodoItem(id);
if (todoDetailId === id) todoDetailId = '';
await renderDeferredColumn();
return;
}
if (action === 'delete-todo') {
const id = actionEl.dataset.todoId;
if (!id) return;
await deleteTodoItem(id);
if (todoDetailId === id) todoDetailId = '';
if (todoEditorState?.todoId === id) closeTodoEditor();
showToast(runtimeT ? runtimeT('toastTodoDeleted') : 'Todo deleted');
await renderDeferredColumn();
return;
}
if (action === 'delete-todo-archive') {
const id = actionEl.dataset.todoId;
if (!id) return;
await deleteTodoItem(id);
await renderDeferredColumn();
return;
}
if (action === 'clear-todo-archive') {
await clearTodoArchiveItems();
await renderDeferredColumn();
return;
}
if (action === 'close-drawer') {
setDeferredPanelOpen(false);
return;
}
});
document.addEventListener('pointerdown', (e) => {
const chipHandle = e.target.closest('[data-chip-drag-handle="tab"]');
const chipItem = e.target.closest('[data-chip-sort-id]');
const chipAction = e.target.closest('.chip-actions');
if (chipItem && !chipAction && e.button === 0) {
const item = chipItem;
const listEl = item?.parentElement;
const groupKey = item?.dataset.chipGroupId || '';
if (!item || !listEl || !groupKey) return;
e.preventDefault();
e.stopPropagation();
draggedPageChipId = item.dataset.chipSortId || '';
draggedPageChipEl = item;
const dragHandleEl = chipHandle || item;
document.body.classList.add('page-chip-drag-armed');
const rect = item.getBoundingClientRect();
pageChipDragState = {
sourceGroupKey: groupKey,
sourceListEl: listEl,
dropGroupKey: groupKey,
dropListEl: listEl,
dropCardEl: item.closest('.mission-card'),
createNewGroup: false,
newGroupPlacement: '',
handleEl: dragHandleEl,
pointerId: e.pointerId,
x: e.clientX,
y: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
moved: false,
};
logPageChipDragDebug('pointerdown', {
groupKey,
chip: draggedPageChipId,
x: Math.round(e.clientX),
y: Math.round(e.clientY),
pointerId: e.pointerId,
});
if (typeof dragHandleEl.setPointerCapture === 'function' && e.pointerId != null) {
try {
dragHandleEl.setPointerCapture(e.pointerId);
logPageChipDragDebug('capture', { pointerId: e.pointerId });
} catch {}
}
return;
}
const drawerHandle = e.target.closest('.drawer-reorder-handle');
if (!drawerHandle || e.button !== 0) return;
const item = drawerHandle.closest('[data-drawer-sort-id]');
const listEl = item?.parentElement;
const kind = item?.dataset.drawerSortKind || '';
if (!item || !listEl || !kind) return;
e.preventDefault();
draggedDrawerItemId = item.dataset.drawerSortId || '';
draggedDrawerItemEl = item;
const rect = item.getBoundingClientRect();
drawerItemDragState = {
kind,
listEl,
x: e.clientX,
y: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
moved: false,
};
});
document.addEventListener('pointermove', (e) => {
if (draggedPageChipId && pageChipDragState) {
const distance = Math.hypot(e.clientX - pageChipDragState.x, e.clientY - pageChipDragState.y);
if (!pageChipDragState.moved && distance >= 4) {
startPageChipDragVisuals();
}
if (pageChipDragState.moved) {
updateDraggedPageChipPosition(e.clientX, e.clientY);
previewPageChipOrder(e.clientX, e.clientY);
}
return;
}
if (!draggedDrawerItemId || !drawerItemDragState) return;
const distance = Math.hypot(e.clientX - drawerItemDragState.x, e.clientY - drawerItemDragState.y);
if (!drawerItemDragState.moved && distance < 4) return;
if (!drawerItemDragState.moved) {
drawerItemDragState.moved = true;
document.body.classList.add('drawer-list-dragging');
draggedDrawerItemEl?.classList.add('is-dragging');
draggedDrawerItemEl?.style.setProperty('--drag-width', `${draggedDrawerItemEl.getBoundingClientRect().width}px`);
ensureDrawerItemPlaceholder();
}
updateDraggedDrawerItemPosition(e.clientX, e.clientY);
previewDrawerItemOrder(e.clientY);
});
document.addEventListener('pointerup', async (e) => {
if (draggedPageChipId && pageChipDragState) {
if (pageChipDragState.pointerId != null && e.pointerId != null && pageChipDragState.pointerId !== e.pointerId) return;
logPageChipDragDebug('pointerup', {
x: Math.round(e.clientX),
y: Math.round(e.clientY),
pointerId: e.pointerId,
moved: pageChipDragState.moved,
});
if (!pageChipDragState.moved) {
const distance = Math.hypot(e.clientX - pageChipDragState.x, e.clientY - pageChipDragState.y);
if (distance >= 4) {
startPageChipDragVisuals();
}
}
updateDraggedPageChipPosition(e.clientX, e.clientY);
const stickyTarget = pageChipDragState.lastResolvedDropTarget;
const stickyIsNonSource = stickyTarget && (
stickyTarget.kind === 'new-group'
|| (stickyTarget.kind === 'group' && stickyTarget.groupKey && stickyTarget.groupKey !== pageChipDragState.sourceGroupKey)
);
if (!stickyIsNonSource) {
syncPageChipDropTarget(e.clientX, e.clientY);
} else {
logPageChipDragDebug('pointerup-keep-target', {
kind: stickyTarget.kind,
groupKey: stickyTarget.groupKey || '',
placement: stickyTarget.placement || '',
});
}
await finishPageChipDrag();
return;
}
if (!draggedDrawerItemId || !drawerItemDragState) return;
const moved = drawerItemDragState.moved;
if (moved) {
const orderIds = [...drawerItemDragState.listEl.children]
.map(node => {
if (node === drawerItemPlaceholderEl) return draggedDrawerItemId;
return node.dataset?.drawerSortId || '';
})
.filter(Boolean);
await saveDrawerItemOrder(drawerItemDragState.kind, orderIds);
}
const draggedKind = drawerItemDragState.kind;
clearDrawerItemDragState();
if (moved) {
if (draggedKind === 'todo') {
await renderTodoPanel();
}
}
});
document.addEventListener('pointercancel', async (e) => {
if (!draggedPageChipId || !pageChipDragState) return;
if (pageChipDragState.pointerId != null && e.pointerId != null && pageChipDragState.pointerId !== e.pointerId) return;
logPageChipDragDebug('pointercancel', { pointerId: e.pointerId });
await finishPageChipDrag();
});
document.addEventListener('pointerdown', (e) => {
const button = e.target.closest('.group-nav-button[data-nav-kind="open-tabs"]');
if (!button) return;
draggedGroupId = button.dataset.groupId || '';
draggedGroupButtonEl = button;
const rect = button.getBoundingClientRect();
dragStartPoint = {
x: e.clientX,
y: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
moved: false,
lastTargetId: draggedGroupId,
};
});
document.addEventListener('pointermove', (e) => {
if (!draggedGroupId || !dragStartPoint) return;
const distance = Math.hypot(e.clientX - dragStartPoint.x, e.clientY - dragStartPoint.y);
if (!dragStartPoint.moved && distance < 2) return;
if (!dragStartPoint.moved) {
dragStartPoint.moved = true;
document.body.classList.add('group-dragging');
draggedGroupButtonEl?.classList.add('is-dragging');
ensureDragPlaceholder();
}
updateDraggedButtonPosition(e.clientX, e.clientY);
previewDraggedOrder(e.clientX);
});
document.addEventListener('pointerup', async () => {
if (!draggedGroupId) return;
const moved = dragStartPoint?.moved;
const nextGroupOrder = groupOrderState.sessionOrder?.slice() || domainGroups.map(group => String(group.domain));
if (dragStartPoint?.moved) {
await saveGroupOrder(groupOrderState);
suppressJumpUntil = Date.now() + 250;
}
clearGroupDragState();
if (moved) {
applyLiveGroupOrder(nextGroupOrder, { reorderCards: true, reorderNav: true });
await syncChromeTabGroupsWithoutImportEcho();
}
});
document.addEventListener('click', (e) => {
const toggle = e.target.closest('#todoArchiveToggle');
if (!toggle) return;
const nextOpen = !toggle.classList.contains('open');
toggle.classList.toggle('open', nextOpen);
toggle.setAttribute('aria-expanded', String(nextOpen));
const body = document.getElementById('todoArchiveBody');
if (body) {
body.hidden = !nextOpen;
body.style.display = nextOpen ? 'block' : 'none';
}
});
// ---- Archive search — filter archived items as user types ----
document.addEventListener('input', async (e) => {
if (e.target.id === 'themeTransparencyRange') {
themePreferences = normalizeThemePreferences({
...themePreferences,
surfaceOpacity: Number(e.target.value),
});
applyThemePreferences();
const valueEl = document.getElementById('themeTransparencyValue');
if (valueEl) valueEl.textContent = `${themePreferences.surfaceOpacity}%`;
await chrome.storage.local.set({ [THEME_PREFERENCES_KEY]: themePreferences });
return;
}
if (e.target.id === 'themeUiScaleRange') {
themePreferences = normalizeThemePreferences({
...themePreferences,
uiScale: Number(e.target.value),
});
applyThemePreferences();
const valueEl = document.getElementById('themeUiScaleValue');
if (valueEl) valueEl.textContent = `${themePreferences.uiScale}%`;
await chrome.storage.local.set({ [THEME_PREFERENCES_KEY]: themePreferences });
return;
}
if (e.target.id === 'themeShortcutScaleRange') {
themePreferences = normalizeThemePreferences({
...themePreferences,
shortcutScale: Number(e.target.value),
});
applyThemePreferences();
const valueEl = document.getElementById('themeShortcutScaleValue');
if (valueEl) valueEl.textContent = `${themePreferences.shortcutScale}%`;
await chrome.storage.local.set({ [THEME_PREFERENCES_KEY]: themePreferences });
return;
}
const sessionPickerNameInput = e.target.closest('[data-action="change-session-picker-new-name"]');
if (sessionPickerNameInput) {
tabSessionPickerState = {
...tabSessionPickerState,
newSessionName: sessionPickerNameInput.value || '',
};
return;
}
return;
});
document.addEventListener('input', (e) => {
const renameInput = e.target.closest('.mission-rename-input');
if (!renameInput || !groupRenameEditorState) return;
groupRenameEditorState = {
...groupRenameEditorState,
value: renameInput.value,
shouldFocus: false,
};
});
document.addEventListener('focusout', (e) => {
const renameInput = e.target.closest('.mission-rename-input');
if (!renameInput || !groupRenameEditorState) return;
const nextFocused = e.relatedTarget;
if (nextFocused && nextFocused.closest?.('.mission-rename-form')) return;
void submitGroupRenameEditor();
});
document.addEventListener('input', async (e) => {
const todoEditorInput = e.target.closest('[data-action="update-todo-editor-field"]');
if (todoEditorInput) {
setTodoEditorField(todoEditorInput.dataset.field || '', todoEditorInput.value || '');
return;
}
if (e.target.id !== 'todoSearchInput') return;
todoSearchQuery = e.target.value.trim();
await renderDeferredColumn();
});
document.addEventListener('change', async (e) => {
const sessionPickerTarget = e.target.closest('[data-action="change-session-picker-target"]');
if (sessionPickerTarget) {
tabSessionPickerState = {
...tabSessionPickerState,
targetSessionId: sessionPickerTarget.value || '',
};
return;
}
if (e.target.id !== 'themeBackgroundInput') return;
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
if (!compressImageFileForStorage) {
throw new Error('Background compression is unavailable');
}
const customBackground = await compressImageFileForStorage(file);
await saveThemePreferences({ customBackground });
setThemeMenuOpen(false, { restoreFocus: true });
showToast('Background updated');
} catch (err) {
showToast(err?.message || 'Could not load background');
}
});
document.addEventListener('submit', async (e) => {
if (e.target.matches('.mission-rename-form')) {
e.preventDefault();
await submitGroupRenameEditor();
return;
}
if (e.target.matches('.todo-editor-form')) {
e.preventDefault();
await submitTodoEditor();
return;
}
if (e.target.id !== 'headerSearchForm') return;
e.preventDefault();
const input = document.getElementById('headerSearchInput');
const query = input?.value || '';
await runDefaultSearch(query);
});
/* ----------------------------------------------------------------
INITIALIZE
---------------------------------------------------------------- */
/**
* injectDynamicAnimationStyles()
*
* Dynamically generates CSS animation rules for staggered entry animations.
* This avoids hardcoding dozens of nth-child selectors in the CSS file.
*
* Strategy: Stagger first 10 elements, then cap delay to avoid excessive wait times.
*/
function injectDynamicAnimationStyles() {
// Check if styles already injected to avoid duplicates
if (document.getElementById('dynamic-animation-styles')) return;
const styleEl = document.createElement('style');
styleEl.id = 'dynamic-animation-styles';
const rules = [];
const MAX_STAGGER_COUNT = 10; // Only stagger first 10 elements
const STAGGER_INCREMENT = 0.05; // 50ms between each element
// Active section mission cards - start at 0.25s, stagger first 10, then cap
for (let i = 1; i <= 50; i++) {
const delay = i <= MAX_STAGGER_COUNT
? 0.25 + (i - 1) * STAGGER_INCREMENT
: 0.25 + (MAX_STAGGER_COUNT - 1) * STAGGER_INCREMENT;
rules.push(
`body.${ENTRY_ANIMATIONS_CLASS} .active-section .missions .mission-card:nth-child(${i}) { animation: fadeUp 0.4s ease ${delay.toFixed(2)}s both; }`
);
}
// Abandoned section mission cards - start at 0.5s, stagger first 10, then cap
for (let i = 1; i <= 50; i++) {
const delay = i <= MAX_STAGGER_COUNT
? 0.5 + (i - 1) * STAGGER_INCREMENT
: 0.5 + (MAX_STAGGER_COUNT - 1) * STAGGER_INCREMENT;
rules.push(
`body.${ENTRY_ANIMATIONS_CLASS} .abandoned-section .missions .mission-card:nth-child(${i}) { animation: fadeUp 0.4s ease ${delay.toFixed(2)}s both; }`
);
}
// Deferred list items - stagger first 10, then cap at 0.5s
for (let i = 1; i <= 50; i++) {
const delay = i <= MAX_STAGGER_COUNT
? i * STAGGER_INCREMENT
: MAX_STAGGER_COUNT * STAGGER_INCREMENT;
rules.push(
`.deferred-list .deferred-item:nth-child(${i}) { animation-delay: ${delay.toFixed(2)}s; }`
);
}
styleEl.textContent = rules.join('\n');
document.head.appendChild(styleEl);
}
/**
* setupImageErrorHandlers()
*
* Attaches error handlers to all favicon images after DOM update.
* This replaces inline onerror attributes to comply with CSP.
*/
function setupImageErrorHandlers() {
// Handle chip favicons
document.querySelectorAll('.chip-favicon[data-fallback-url]').forEach(img => {
if (!img.dataset.errorHandlerAttached) {
img.addEventListener('error', function() {
const fallbackUrl = this.dataset.fallbackUrl;
if (fallbackUrl && this.dataset.fallbackApplied !== 'true') {
this.dataset.fallbackApplied = 'true';
this.src = fallbackUrl;
return;
}
this.style.display = 'none';
const sibling = this.nextElementSibling;
if (sibling && sibling.classList.contains('chip-favicon-fallback')) {
sibling.style.display = '';
}
});
img.dataset.errorHandlerAttached = 'true';
}
});
// Handle group nav icons
document.querySelectorAll('.group-nav-icon[data-fallback-src]').forEach(img => {
if (!img.dataset.errorHandlerAttached) {
img.addEventListener('error', function() {
const fallbackSrc = this.dataset.fallbackSrc;
if (fallbackSrc && this.dataset.fallbackApplied !== 'true') {
this.dataset.fallbackApplied = 'true';
this.src = fallbackSrc;
return;
}
this.style.display = 'none';
const sibling = this.nextElementSibling;
if (sibling && sibling.classList.contains('group-nav-fallback')) {
sibling.style.display = '';
}
});
img.dataset.errorHandlerAttached = 'true';
}
});
}
async function initializeDashboardRuntime() {
injectDynamicAnimationStyles();
primeEntryAnimations();
const currentTab = await resolveCurrentDashboardTab();
currentDashboardTabId = currentTab?.id ?? null;
currentDashboardWindowId = currentTab?.windowId ?? null;
dashboardStartupTabChangeIgnoreUntil = Date.now() + 2000;
window.__suppressAutoRefreshUntil = Math.max(
window.__suppressAutoRefreshUntil || 0,
Date.now() + 2000
);
await loadThemePreferences();
sleepControlEnabled = (typeof themePreferences !== 'undefined' && themePreferences.sleepControlEnabled === true);
if (typeof loadChromeTabGroupsSetting === 'function') {
chromeTabGroupsEnabled = await loadChromeTabGroupsSetting();
}
await loadImportedChromeGroupMeta();
if (chromeTabGroupsEnabled) {
await fetchOpenTabs();
const realTabs = getRealTabs();
await loadSessionGroups(getOpenTabIdsForSessionPruning());
if (shouldImportChromeGroupsIntoSessionState()) {
const importedCount = await importChromeNativeGroupsIntoSessionGroups();
if (typeof setImportMode === 'function') setImportMode(importedCount > 0);
}
}
ensureChromeTabGroupsSubscription();
disableChromeTabGroupsImportModeForLocalEdits();
await renderDashboard();
await collapseChromeGroupsForCurrentTabHarborTab();
updateBackToTopVisibility();
// Listen for tab change notifications from background script
setupTabChangeListener();
}
/**
* setupTabChangeListener()
*
* Listens for messages from background.js when tabs change,
* and refreshes the dashboard to show updated tab list.
*/
function setupTabChangeListener() {
const DEBUG = false;
if (DEBUG) console.log('[tab-harbor] Setting up tab change listener');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (DEBUG) console.log('[tab-harbor] Received message:', message);
if (message.action === 'tabs-changed') {
if (shouldSkipStartupTabChange(message)) {
return;
}
// Skip refresh if we just performed a tab action ourselves
// This prevents animation spam when closing tabs from the dashboard
if (Date.now() < (window.__suppressAutoRefreshUntil || 0)) {
return;
}
if (DEBUG) console.log('[tab-harbor] Tab changed, scheduling refresh...');
// Debounce rapid changes (e.g., closing multiple tabs)
if (window.__tabRefreshTimeout) {
clearTimeout(window.__tabRefreshTimeout);
}
window.__tabRefreshTimeout = setTimeout(async () => {
try {
if (DEBUG) console.log('[tab-harbor] Refreshing dashboard...');
await renderDashboard();
updateBackToTopVisibility();
if (DEBUG) console.log('[tab-harbor] Dashboard refreshed successfully');
} catch (err) {
console.warn('[tab-harbor] Failed to refresh dashboard:', err);
}
}, 300); // Wait 300ms after last tab change
}
});
}
function mountDashboardRuntime() {
if (!window.__tabHarborRuntimeMounted) {
document.addEventListener('pointerdown', disableEntryAnimations, { capture: true, passive: true });
window.addEventListener('scroll', updateBackToTopVisibility, { passive: true });
window.addEventListener('focus', () => {
void collapseChromeGroupsForCurrentTabHarborTab();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
void collapseChromeGroupsForCurrentTabHarborTab();
}
});
window.__tabHarborRuntimeMounted = true;
}
return initializeDashboardRuntime();
}
globalThis.TabHarborDashboardRuntime = {
initializeDashboardRuntime,
mountDashboardRuntime,
fetchOpenTabs,
getTabSessionPickerContext,
getOpenTabs: () => openTabs,
renderDashboard,
renderWorkspacePageSwitch,
renderWorkspaceThemeTools,
syncWorkspaceTopNavMarkup,
restoreSavedTabToBrowser,
restoreSavedTabSession,
saveCurrentWindowTabSession,
saveSelectedTabSession,
};