From e6a45055effa5fa3ae398a2a276771913c5c93d3 Mon Sep 17 00:00:00 2001 From: Lin Yang Date: Tue, 16 Jun 2026 16:13:11 +0800 Subject: [PATCH] feat: add current-window tab scope toggle (v1.0.1) Default to showing tabs from the current window only, with a header switch to view and manage tabs across all windows. Scope preference persists in chrome.storage.local. Remove inline event handlers to fix MV3 CSP violations. Co-authored-by: Cursor --- extension/app.js | 165 ++++++++++++++++++++++++++++++++++------ extension/index.html | 17 +++-- extension/manifest.json | 2 +- extension/style.css | 74 ++++++++++++++++++ 4 files changed, 229 insertions(+), 29 deletions(-) diff --git a/extension/app.js b/extension/app.js index b2ecb78c6..288d089fa 100644 --- a/extension/app.js +++ b/extension/app.js @@ -26,6 +26,78 @@ // All open tabs — populated by fetchOpenTabs() let openTabs = []; +// Tab scope: 'current-window' (default) or 'all-windows' +let tabScope = 'current-window'; +let currentWindowId = null; + +/** + * loadTabScope() + * + * Loads the saved tab scope preference from chrome.storage.local. + * Defaults to 'current-window' (only show tabs in this window). + */ +async function loadTabScope() { + const { tabScope: saved } = await chrome.storage.local.get('tabScope'); + tabScope = saved === 'all-windows' ? 'all-windows' : 'current-window'; +} + +/** + * saveTabScope(scope) + * + * Persists the tab scope preference and updates the toggle UI. + */ +async function saveTabScope(scope) { + tabScope = scope; + await chrome.storage.local.set({ tabScope: scope }); + updateScopeToggleUI(); +} + +/** + * updateScopeToggleUI() + * + * Syncs the header toggle and label styles with the current tabScope. + */ +function updateScopeToggleUI() { + const toggle = document.getElementById('tabScopeToggle'); + const labelCurrent = document.getElementById('scopeLabelCurrent'); + const labelAll = document.getElementById('scopeLabelAll'); + if (!toggle) return; + + const isAllWindows = tabScope === 'all-windows'; + toggle.checked = isAllWindows; + if (labelCurrent) labelCurrent.classList.toggle('active', !isAllWindows); + if (labelAll) labelAll.classList.toggle('active', isAllWindows); +} + +/** + * isCurrentWindowScope() + * + * Returns true when tab operations should be limited to the current window. + */ +function isCurrentWindowScope() { + return tabScope === 'current-window'; +} + +/** + * scopeTabs(tabs) + * + * Filters a tab list to the current window when in current-window mode. + */ +function scopeTabs(tabs) { + if (!isCurrentWindowScope() || currentWindowId == null) return tabs; + return tabs.filter(t => t.windowId === currentWindowId); +} + +/** + * queryScopedTabs() + * + * Queries open tabs, optionally limited to the current window. + */ +async function queryScopedTabs() { + const tabs = await chrome.tabs.query({}); + return scopeTabs(tabs); +} + /** * fetchOpenTabs() * @@ -38,6 +110,9 @@ async function fetchOpenTabs() { // The new URL for this page is now index.html (not newtab.html) const newtabUrl = `chrome-extension://${extensionId}/index.html`; + const currentWindow = await chrome.windows.getCurrent(); + currentWindowId = currentWindow.id; + const tabs = await chrome.tabs.query({}); openTabs = tabs.map(t => ({ id: t.id, @@ -51,6 +126,7 @@ async function fetchOpenTabs() { } catch { // chrome.tabs API unavailable (shouldn't happen in an extension page) openTabs = []; + currentWindowId = null; } } @@ -78,7 +154,7 @@ async function closeTabsByUrls(urls) { } } - const allTabs = await chrome.tabs.query({}); + const allTabs = await queryScopedTabs(); const toClose = allTabs .filter(tab => { const tabUrl = tab.url || ''; @@ -103,7 +179,7 @@ async function closeTabsByUrls(urls) { async function closeTabsExact(urls) { if (!urls || urls.length === 0) return; const urlSet = new Set(urls); - const allTabs = await chrome.tabs.query({}); + const allTabs = await queryScopedTabs(); const toClose = allTabs.filter(t => urlSet.has(t.url)).map(t => t.id); if (toClose.length > 0) await chrome.tabs.remove(toClose); await fetchOpenTabs(); @@ -150,7 +226,7 @@ async function focusTab(url) { * keepOne=false → close all copies. */ async function closeDuplicateTabs(urls, keepOne = true) { - const allTabs = await chrome.tabs.query({}); + const allTabs = await queryScopedTabs(); const toClose = []; for (const url of urls) { @@ -180,9 +256,9 @@ async function closeTabOutDupes() { const allTabs = await chrome.tabs.query({}); const currentWindow = await chrome.windows.getCurrent(); - const tabOutTabs = allTabs.filter(t => + const tabOutTabs = scopeTabs(allTabs.filter(t => t.url === newtabUrl || t.url === 'chrome://newtab/' - ); + )); if (tabOutTabs.length <= 1) return; @@ -720,7 +796,7 @@ let domainGroups = []; * pages, about:blank, etc. */ function getRealTabs() { - return openTabs.filter(t => { + return scopeTabs(openTabs.filter(t => { const url = t.url || ''; return ( !url.startsWith('chrome://') && @@ -729,7 +805,7 @@ function getRealTabs() { !url.startsWith('edge://') && !url.startsWith('brave://') ); - }); + })); } /** @@ -739,7 +815,7 @@ function getRealTabs() { * shows a banner offering to close the extras. */ function checkTabOutDupes() { - const tabOutTabs = openTabs.filter(t => t.isTabOut); + const tabOutTabs = scopeTabs(openTabs.filter(t => t.isTabOut)); const banner = document.getElementById('tabOutDupeBanner'); const countEl = document.getElementById('tabOutDupeCount'); if (!banner) return; @@ -769,7 +845,7 @@ function buildOverflowChips(hiddenTabs, urlCounts = {}) { try { domain = new URL(tab.url).hostname; } catch {} const faviconUrl = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=16` : ''; return `
- ${faviconUrl ? `` : ''} + ${faviconUrl ? `` : ''} ${label}${dupeTag}
`; openTabsMissionsEl.innerHTML = domainGroups.map(g => renderDomainCard(g)).join(''); openTabsSection.style.display = 'block'; @@ -1159,7 +1236,11 @@ async function renderStaticDashboard() { // --- Footer stats --- const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; + const statTabsLabel = document.getElementById('statTabsLabel'); + if (statTabs) statTabs.textContent = getRealTabs().length; + if (statTabsLabel) { + statTabsLabel.textContent = isCurrentWindowScope() ? 'Tabs in this window' : 'Tabs in all windows'; + } // --- Check for duplicate Tab Out tabs --- checkTabOutDupes(); @@ -1227,8 +1308,8 @@ document.addEventListener('click', async (e) => { const tabUrl = actionEl.dataset.tabUrl; if (!tabUrl) return; - // Close the tab in Chrome directly - const allTabs = await chrome.tabs.query({}); + // Close the tab in Chrome directly (scoped to visible tabs) + const allTabs = await queryScopedTabs(); const match = allTabs.find(t => t.url === tabUrl); if (match) await chrome.tabs.remove(match.id); await fetchOpenTabs(); @@ -1258,7 +1339,7 @@ document.addEventListener('click', async (e) => { // Update footer const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; + if (statTabs) statTabs.textContent = getRealTabs().length; showToast('Tab closed'); return; @@ -1280,8 +1361,8 @@ document.addEventListener('click', async (e) => { return; } - // Close the tab in Chrome - const allTabs = await chrome.tabs.query({}); + // Close the tab in Chrome (scoped to visible tabs) + const allTabs = await queryScopedTabs(); const match = allTabs.find(t => t.url === tabUrl); if (match) await chrome.tabs.remove(match.id); await fetchOpenTabs(); @@ -1372,7 +1453,7 @@ document.addEventListener('click', async (e) => { showToast(`Closed ${urls.length} tab${urls.length !== 1 ? 's' : ''} from ${groupLabel}`); const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; + if (statTabs) statTabs.textContent = getRealTabs().length; return; } @@ -1414,9 +1495,7 @@ document.addEventListener('click', async (e) => { // ---- Close ALL open tabs ---- if (action === 'close-all-open-tabs') { - const allUrls = openTabs - .filter(t => t.url && !t.url.startsWith('chrome') && !t.url.startsWith('about:')) - .map(t => t.url); + const allUrls = getRealTabs().map(t => t.url); await closeTabsByUrls(allUrls); playCloseSound(); @@ -1476,7 +1555,47 @@ document.addEventListener('input', async (e) => { }); +/* ---------------------------------------------------------------- + TAB SCOPE TOGGLE + ---------------------------------------------------------------- */ + +document.getElementById('tabScopeToggle')?.addEventListener('change', async (e) => { + const scope = e.target.checked ? 'all-windows' : 'current-window'; + await saveTabScope(scope); + await renderDashboard(); +}); + + /* ---------------------------------------------------------------- INITIALIZE ---------------------------------------------------------------- */ -renderDashboard(); + +/** + * loadLocalConfig() + * + * Optionally loads config.local.js (gitignored personal overrides). + * Silently skips if the file doesn't exist. + */ +function loadLocalConfig() { + return new Promise(resolve => { + const script = document.createElement('script'); + script.src = 'config.local.js'; + script.onload = resolve; + script.onerror = resolve; + document.head.appendChild(script); + }); +} + +// Hide broken favicons without inline event handlers (CSP-safe) +document.addEventListener('error', (e) => { + if (e.target.matches('.chip-favicon')) { + e.target.style.display = 'none'; + } +}, true); + +(async function init() { + await loadLocalConfig(); + await loadTabScope(); + updateScopeToggleUI(); + await renderDashboard(); +})(); diff --git a/extension/index.html b/extension/index.html index e944a2a80..af94cc32d 100644 --- a/extension/index.html +++ b/extension/index.html @@ -26,6 +26,16 @@

+
+
+ This window + + All windows +
+
- - - + diff --git a/extension/manifest.json b/extension/manifest.json index 92191e337..6957e5cd8 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Tab Out", - "version": "1.0.0", + "version": "1.0.1", "description": "Keep tabs on your tabs. New tab page that groups your open tabs by domain and lets you close them with style.", "permissions": ["tabs", "activeTab", "storage"], "chrome_url_overrides": { "newtab": "index.html" }, diff --git a/extension/style.css b/extension/style.css index c7bc0b5d3..085dce5f4 100644 --- a/extension/style.css +++ b/extension/style.css @@ -81,6 +81,80 @@ header { text-transform: uppercase; } +.header-right { + flex-shrink: 0; +} + +/* ---- Tab scope toggle (this window / all windows) ---- */ +.scope-toggle { + display: flex; + align-items: center; + gap: 10px; +} + +.scope-toggle-label { + font-size: 12px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.2px; + transition: color 0.2s; + user-select: none; +} + +.scope-toggle-label.active { + color: var(--ink); +} + +.scope-switch { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.scope-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.scope-switch-track { + display: block; + width: 40px; + height: 22px; + background: var(--warm-gray); + border-radius: 11px; + transition: background 0.2s; + position: relative; +} + +.scope-switch-track::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + background: var(--card-bg); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(26, 22, 19, 0.15); + transition: transform 0.2s; +} + +.scope-switch input:checked + .scope-switch-track { + background: var(--accent-sage); +} + +.scope-switch input:checked + .scope-switch-track::after { + transform: translateX(18px); +} + +.scope-switch input:focus-visible + .scope-switch-track { + outline: 2px solid var(--accent-sage); + outline-offset: 2px; +} + /* ---- Tab cleanup banner ---- */ .tab-cleanup-banner { background: linear-gradient(135deg, rgba(200, 113, 58, 0.04), rgba(200, 113, 58, 0.09));