4794 lines
178 KiB
JavaScript
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, '"');
|
|
const safeSelectLabel = runtimeEscapeHtmlAttribute
|
|
? runtimeEscapeHtmlAttribute(runtimeT ? runtimeT('sessionPickerTargetSessionLabel') : 'Target session')
|
|
: 'Target session';
|
|
const newSessionName = String(tabSessionPickerState.newSessionName || '');
|
|
const safeNewSessionName = runtimeEscapeHtmlAttribute
|
|
? runtimeEscapeHtmlAttribute(newSessionName)
|
|
: newSessionName.replace(/"/g, '"');
|
|
const namePlaceholder = runtimeT ? runtimeT('sessionPickerNewSessionNamePlaceholder') : 'Group title (optional)';
|
|
const safeNamePlaceholder = runtimeEscapeHtmlAttribute
|
|
? runtimeEscapeHtmlAttribute(namePlaceholder)
|
|
: namePlaceholder.replace(/"/g, '"');
|
|
const listHtml = pickerGroups.length
|
|
? pickerGroups.map(group => {
|
|
const groupKey = String(group.key || group.domain || '');
|
|
const safeGroupKey = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(groupKey) : groupKey.replace(/"/g, '"');
|
|
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, '"');
|
|
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, '"'))
|
|
: '';
|
|
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, '"');
|
|
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, '"');
|
|
const safeTitle = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(label) : label.replace(/"/g, '"');
|
|
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, '"');
|
|
const safeTabId = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(String(tab.id || '')) : String(tab.id || '').replace(/"/g, '"');
|
|
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, '"')}">` : ''}
|
|
<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, '"');
|
|
|
|
// 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, '"');
|
|
const safeTitle = runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(label) : label.replace(/"/g, '"');
|
|
const safeLabel = runtimeEscapeHtml ? runtimeEscapeHtml(label) : label;
|
|
const sortId = getPrimaryTabOrderToken(tab);
|
|
const safeSortId = (runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(sortId) : String(sortId).replace(/"/g, '"'));
|
|
const safeGroupId = (runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '"'));
|
|
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, '"');
|
|
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, '"')}">` : ''}
|
|
<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, '"')}"${group.manualGroupId ? ` data-manual-group-id="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.manualGroupId) : String(group.manualGroupId).replace(/"/g, '"')}"` : ''}>
|
|
<input class="mission-rename-input" type="text" value="${safeRenameValue}" data-group-rename-input="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.domain) : String(group.domain).replace(/"/g, '"')}" 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, '"')}"${group.manualGroupId ? ` data-manual-group-id="${runtimeEscapeHtmlAttribute ? runtimeEscapeHtmlAttribute(group.manualGroupId) : String(group.manualGroupId).replace(/"/g, '"')}"` : ''} 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,
|
|
};
|