diff --git a/components/DangerConfirmButton.tsx b/components/DangerConfirmButton.tsx
index 81e2507..fe3a7d0 100644
--- a/components/DangerConfirmButton.tsx
+++ b/components/DangerConfirmButton.tsx
@@ -9,6 +9,7 @@ interface DangerConfirmButtonProps {
onConfirm: () => void;
disabled?: boolean;
autoCancelMs?: number;
+ size?: 'normal' | 'compact';
}
export function DangerConfirmButton({
@@ -18,6 +19,7 @@ export function DangerConfirmButton({
onConfirm,
disabled,
autoCancelMs = 4000,
+ size = 'normal',
}: DangerConfirmButtonProps) {
const t = useTranslation();
const [armed, setArmed] = useState(false);
@@ -45,18 +47,20 @@ export function DangerConfirmButton({
onConfirm();
}
+ const armedPadding = size === 'compact' ? 'px-3 py-1.5 text-xs' : 'px-3 py-2 text-sm';
+
if (armed) {
return (
@@ -70,6 +74,7 @@ export function DangerConfirmButton({
onClick={arm}
disabled={disabled}
variant="secondary"
+ size={size}
idleLabel={idleLabel}
/>
);
diff --git a/components/HistoryList.tsx b/components/HistoryList.tsx
index 70480f1..3f7e652 100644
--- a/components/HistoryList.tsx
+++ b/components/HistoryList.tsx
@@ -11,6 +11,9 @@ export function HistoryList({ items, onDelete }: HistoryListProps) {
return (
+ {items.length === 0 && (
+ - {t('noMatchingHistory')}
+ )}
{items.map((item) => (
-
{item.title || item.url}
diff --git a/components/StatusButton.tsx b/components/StatusButton.tsx
index 23874ee..bc252b0 100644
--- a/components/StatusButton.tsx
+++ b/components/StatusButton.tsx
@@ -12,6 +12,7 @@ interface StatusButtonProps {
doneLabel?: ReactNode;
failedLabel?: ReactNode;
variant?: 'primary' | 'secondary';
+ size?: 'normal' | 'compact';
className?: string;
}
@@ -21,6 +22,11 @@ const VARIANT_CLASSES: Record, string>
'border border-stone-300 hover:bg-stone-100 dark:border-stone-600 dark:hover:bg-stone-700',
};
+const SIZE_CLASSES: Record, string> = {
+ normal: 'w-full py-2 text-sm',
+ compact: 'px-3 py-1.5 text-xs whitespace-nowrap',
+};
+
export function StatusButton({
status,
onClick,
@@ -30,6 +36,7 @@ export function StatusButton({
doneLabel,
failedLabel,
variant = 'primary',
+ size = 'normal',
className = '',
}: StatusButtonProps) {
const t = useTranslation();
@@ -49,7 +56,7 @@ export function StatusButton({
diff --git a/components/VisitedSiteList.tsx b/components/VisitedSiteList.tsx
new file mode 100644
index 0000000..b27ff9c
--- /dev/null
+++ b/components/VisitedSiteList.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from '../hooks/useTranslation';
+import type { VisitedSite } from '../utils/clearing';
+
+interface VisitedSiteListProps {
+ sites: VisitedSite[];
+ busyHostnames: Set;
+ failedHostnames: Set;
+ onClear: (site: VisitedSite) => void;
+}
+
+export function VisitedSiteList({ sites, busyHostnames, failedHostnames, onClear }: VisitedSiteListProps) {
+ const t = useTranslation();
+
+ return (
+
+ {sites.length === 0 && (
+ - {t('noMatchingSites')}
+ )}
+ {sites.map((site) => {
+ const busy = busyHostnames.has(site.hostname);
+ return (
+ -
+ {site.hostname}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/components/options/PerSiteSection.tsx b/components/options/PerSiteSection.tsx
index 7751f3d..b4986a1 100644
--- a/components/options/PerSiteSection.tsx
+++ b/components/options/PerSiteSection.tsx
@@ -2,10 +2,12 @@ import type { Browser } from 'wxt/browser';
import { useTranslation } from '../../hooks/useTranslation';
import { isGeckoBased } from '../../utils/browser-info';
import type { DataTypeDef, DataTypeId } from '../../utils/data-types';
+import type { VisitedSite } from '../../utils/clearing';
import { DataTypeGrid } from '../DataTypeGrid';
import { OriginMappingsEditor } from '../OriginMappingsEditor';
import { type ClearStatus } from '../StatusButton';
import { SiteTabsSection } from './SiteTabsSection';
+import { VisitedSitesSection } from './VisitedSitesSection';
interface PerSiteSectionProps {
types: DataTypeDef[];
@@ -26,6 +28,14 @@ interface PerSiteSectionProps {
failedTabIds: Set;
reloadingTabIds: Set;
onClearTab: (tab: Browser.tabs.Tab) => void;
+ visitedSiteFilter: string;
+ onVisitedSiteFilterChange: (value: string) => void;
+ visitedSites: VisitedSite[];
+ visitedBulkStatus: ClearStatus;
+ onClearAllVisitedSites: () => void;
+ busyVisitedHostnames: Set;
+ failedVisitedHostnames: Set;
+ onClearVisitedSite: (site: VisitedSite) => void;
}
export function PerSiteSection({
@@ -47,6 +57,14 @@ export function PerSiteSection({
failedTabIds,
reloadingTabIds,
onClearTab,
+ visitedSiteFilter,
+ onVisitedSiteFilterChange,
+ visitedSites,
+ visitedBulkStatus,
+ onClearAllVisitedSites,
+ busyVisitedHostnames,
+ failedVisitedHostnames,
+ onClearVisitedSite,
}: PerSiteSectionProps) {
const t = useTranslation();
@@ -89,18 +107,32 @@ export function PerSiteSection({
{!isGeckoBased() && (
-
+ <>
+
+
+ >
)}
+
+
);
}
diff --git a/components/options/VisitedSitesSection.tsx b/components/options/VisitedSitesSection.tsx
new file mode 100644
index 0000000..d68b1ef
--- /dev/null
+++ b/components/options/VisitedSitesSection.tsx
@@ -0,0 +1,65 @@
+import { useTranslation } from '../../hooks/useTranslation';
+import type { VisitedSite } from '../../utils/clearing';
+import { DangerConfirmButton } from '../DangerConfirmButton';
+import { type ClearStatus } from '../StatusButton';
+import { VisitedSiteList } from '../VisitedSiteList';
+
+interface VisitedSitesSectionProps {
+ filter: string;
+ onFilterChange: (value: string) => void;
+ sites: VisitedSite[];
+ status: ClearStatus;
+ onClearAll: () => void;
+ busyHostnames: Set;
+ failedHostnames: Set;
+ onClearSite: (site: VisitedSite) => void;
+}
+
+export function VisitedSitesSection({
+ filter,
+ onFilterChange,
+ sites,
+ status,
+ onClearAll,
+ busyHostnames,
+ failedHostnames,
+ onClearSite,
+}: VisitedSitesSectionProps) {
+ const t = useTranslation();
+
+ return (
+
+
+ {t('visitedSites')}
+
+
{t('visitedSitesDescription')}
+
+
+
onFilterChange(e.target.value)}
+ placeholder={t('filterVisitedSitesPlaceholder')}
+ className="flex-1 rounded-md border border-stone-300 bg-stone-50 dark:border-stone-600 dark:bg-stone-800 px-3 py-1.5 text-sm placeholder:text-stone-500 focus:outline-none focus:border-blue-500"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx
index cff1dd6..0a65063 100644
--- a/entrypoints/options/App.tsx
+++ b/entrypoints/options/App.tsx
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { browser, type Browser } from 'wxt/browser';
+import { HelpPanel } from '../../components/HelpPanel';
import { GlobalSection } from '../../components/options/GlobalSection';
+import { LanguageSection } from '../../components/options/LanguageSection';
import { PerSiteSection } from '../../components/options/PerSiteSection';
import { SettingsHeader, type OptionsTab } from '../../components/options/SettingsHeader';
-import { LanguageSection } from '../../components/options/LanguageSection';
import { ThemeSection } from '../../components/options/ThemeSection';
-import { HelpPanel } from '../../components/HelpPanel';
import { type ClearStatus } from '../../components/StatusButton';
import { useReloadGuard } from '../../hooks/useReloadGuard';
import { useTheme } from '../../hooks/useTheme';
@@ -17,18 +17,22 @@ import {
clearLinkedOrigin,
clearTab,
clearTabData,
+ dedupeSitesByHostname,
linkedOriginsFor,
requestOriginPermission,
siteScopedIds,
tabCookieStoreId,
tabDomain,
tabOrigin,
+ type VisitedSite,
} from '../../utils/clearing';
+import { mapWithConcurrency } from '../../utils/concurrency';
import { siteScopedDataTypes } from '../../utils/data-types';
type Tab = Browser.tabs.Tab;
const SITE_TYPES = siteScopedDataTypes();
+const VISITED_CLEAR_CONCURRENCY = 3;
export default function App() {
useTheme();
@@ -61,6 +65,12 @@ export default function App() {
const [historyFilter, setHistoryFilter] = useState('');
const [historyBulkStatus, setHistoryBulkStatus] = useState('idle');
const [globalStatus, setGlobalStatus] = useState('idle');
+ const [visitedHistory, setVisitedHistory] = useState([]);
+ const [visitedSiteFilter, setVisitedSiteFilter] = useState('');
+ const [busyVisitedHostnames, setBusyVisitedHostnames] = useState>(new Set());
+ const [failedVisitedHostnames, setFailedVisitedHostnames] = useState>(new Set());
+ const [visitedBulkStatus, setVisitedBulkStatus] = useState('idle');
+ const [clearedHostnames, setClearedHostnames] = useState>(new Set());
const { reloadingTabIds, markReloading, isReloading } = useReloadGuard();
useEffect(() => {
@@ -75,6 +85,10 @@ export default function App() {
}
}, [selectedTypesGlobal]);
+ useEffect(() => {
+ browser.history.search({ text: '', maxResults: 1000 }).then(setVisitedHistory);
+ }, []);
+
const filteredTabs = useMemo(() => {
const q = siteFilter.trim().toLowerCase();
if (!q) return tabs;
@@ -87,6 +101,20 @@ export default function App() {
return history.filter((item) => (item.title ?? item.url ?? '').toLowerCase().includes(q));
}, [history, historyFilter]);
+ const visitedSites = useMemo(
+ () =>
+ dedupeSitesByHostname(visitedHistory.map((item) => item.url)).filter(
+ (site) => !clearedHostnames.has(site.hostname),
+ ),
+ [visitedHistory, clearedHostnames],
+ );
+
+ const filteredVisitedSites = useMemo(() => {
+ const q = visitedSiteFilter.trim().toLowerCase();
+ if (!q) return visitedSites;
+ return visitedSites.filter((site) => site.hostname.toLowerCase().includes(q));
+ }, [visitedSites, visitedSiteFilter]);
+
async function handleGlobalClear() {
setGlobalStatus('clearing');
try {
@@ -192,6 +220,80 @@ export default function App() {
setTimeout(() => setBulkStatus('idle'), 1500);
}
+ async function clearVisitedSite(site: VisitedSite): Promise {
+ const ids = siteScopedIds(selectedTypesSite);
+ await clearLinkedOrigin(site.origin, ids);
+ if (useOriginMappings) {
+ await Promise.all(
+ linkedOriginsFor(linkedOrigins, site.origin).map((target) =>
+ clearLinkedOrigin(target, ids),
+ ),
+ );
+ }
+ return true;
+ }
+
+ async function handleClearVisitedSite(site: VisitedSite) {
+ setBusyVisitedHostnames((s) => new Set(s).add(site.hostname));
+ setFailedVisitedHostnames((s) => {
+ const next = new Set(s);
+ next.delete(site.hostname);
+ return next;
+ });
+ try {
+ const granted = await requestOriginPermission();
+ if (!granted) throw new Error('permission denied');
+ await clearVisitedSite(site);
+ setClearedHostnames((s) => new Set(s).add(site.hostname));
+ } catch (err) {
+ console.error('Bleep: visited site clear failed', err);
+ setFailedVisitedHostnames((s) => new Set(s).add(site.hostname));
+ }
+ setBusyVisitedHostnames((s) => {
+ const next = new Set(s);
+ next.delete(site.hostname);
+ return next;
+ });
+ }
+
+ async function handleClearAllVisitedSites() {
+ const targets = filteredVisitedSites;
+ if (targets.length === 0) return;
+ setVisitedBulkStatus('clearing');
+ try {
+ const granted = await requestOriginPermission();
+ if (!granted) {
+ setVisitedBulkStatus('failed');
+ setTimeout(() => setVisitedBulkStatus('idle'), 1500);
+ return;
+ }
+ setBusyVisitedHostnames(new Set(targets.map((s) => s.hostname)));
+ const results = await mapWithConcurrency(targets, VISITED_CLEAR_CONCURRENCY, async (site) => {
+ try {
+ await clearVisitedSite(site);
+ return { site, ok: true };
+ } catch (err) {
+ console.error('Bleep: visited site clear failed', site.hostname, err);
+ return { site, ok: false };
+ }
+ });
+ const failed = new Set();
+ const cleared = new Set();
+ for (const { site, ok } of results) {
+ if (ok) cleared.add(site.hostname);
+ else failed.add(site.hostname);
+ }
+ setClearedHostnames((s) => new Set([...s, ...cleared]));
+ setFailedVisitedHostnames(failed);
+ setVisitedBulkStatus(failed.size === 0 ? 'done' : 'failed');
+ } catch (err) {
+ console.error('Bleep: clear-all-visited-sites failed', err);
+ setVisitedBulkStatus('failed');
+ }
+ setBusyVisitedHostnames(new Set());
+ setTimeout(() => setVisitedBulkStatus('idle'), 1500);
+ }
+
async function handleDeleteHistoryItem(url: string) {
await browser.history.deleteUrl({ url });
setHistory((h) => h.filter((item) => item.url !== url));
@@ -202,7 +304,9 @@ export default function App() {
setHistoryBulkStatus('clearing');
try {
await Promise.all(
- filteredHistory.map((item) => (item.url ? browser.history.deleteUrl({ url: item.url }) : undefined)),
+ filteredHistory.map((item) =>
+ item.url ? browser.history.deleteUrl({ url: item.url }) : undefined,
+ ),
);
const removed = new Set(filteredHistory.map((item) => item.url));
setHistory((h) => h.filter((item) => !removed.has(item.url)));
@@ -267,6 +371,14 @@ export default function App() {
failedTabIds={failedTabIds}
reloadingTabIds={reloadingTabIds}
onClearTab={handleSiteClear}
+ visitedSiteFilter={visitedSiteFilter}
+ onVisitedSiteFilterChange={setVisitedSiteFilter}
+ visitedSites={filteredVisitedSites}
+ visitedBulkStatus={visitedBulkStatus}
+ onClearAllVisitedSites={handleClearAllVisitedSites}
+ busyVisitedHostnames={busyVisitedHostnames}
+ failedVisitedHostnames={failedVisitedHostnames}
+ onClearVisitedSite={handleClearVisitedSite}
/>
)}
diff --git a/utils/clearing.firefox.test.ts b/utils/clearing.firefox.test.ts
new file mode 100644
index 0000000..8ebe93c
--- /dev/null
+++ b/utils/clearing.firefox.test.ts
@@ -0,0 +1,158 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { browser } from 'wxt/browser';
+import { clearLinkedOrigin, clearTabData } from './clearing';
+
+beforeEach(() => {
+ vi.stubEnv('FIREFOX', 'true');
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+let onUpdatedListeners: Array<(tabId: number, info: { status?: string }) => void>;
+let nextTabId: number;
+let calls: string[];
+
+function stubBrowserApis() {
+ onUpdatedListeners = [];
+ nextTabId = 1;
+ calls = [];
+
+ Object.assign(browser, {
+ tabs: {
+ create: vi.fn(async (props: { url: string }) => {
+ const tab = { id: nextTabId++, url: props.url };
+ calls.push('tabs.create');
+ setTimeout(() => onUpdatedListeners.forEach((fn) => fn(tab.id, { status: 'complete' })), 0);
+ return tab;
+ }),
+ remove: vi.fn(async () => {
+ calls.push('tabs.remove');
+ }),
+ onUpdated: {
+ addListener: (fn: (tabId: number, info: { status?: string }) => void) => {
+ onUpdatedListeners.push(fn);
+ },
+ removeListener: (fn: (tabId: number, info: { status?: string }) => void) => {
+ onUpdatedListeners = onUpdatedListeners.filter((l) => l !== fn);
+ },
+ },
+ },
+ scripting: {
+ executeScript: vi.fn(async () => {
+ calls.push('scripting.executeScript');
+ return [{ result: { failed: {} } }];
+ }),
+ },
+ cookies: {
+ getAll: vi.fn(async () => {
+ calls.push('cookies.getAll');
+ return [];
+ }),
+ remove: vi.fn().mockResolvedValue(undefined),
+ },
+ browsingData: {
+ remove: vi.fn(async () => {
+ calls.push('browsingData.remove');
+ }),
+ settings: vi.fn().mockResolvedValue({ dataToRemove: {} }),
+ },
+ });
+}
+
+beforeEach(() => {
+ stubBrowserApis();
+});
+
+describe('clearLinkedOrigin (Firefox path)', () => {
+ it('clears cookies and IndexedDB natively, and cache via a background tab', async () => {
+ await clearLinkedOrigin('https://auth.domain.com', ['cache', 'cookies', 'indexedDB']);
+
+ expect(browser.tabs.create).toHaveBeenCalledWith(
+ expect.objectContaining({ url: 'https://auth.domain.com', active: false }),
+ );
+ expect(browser.scripting.executeScript).toHaveBeenCalledWith(
+ expect.objectContaining({ target: expect.objectContaining({ allFrames: true }) }),
+ );
+ expect(browser.tabs.remove).toHaveBeenCalled();
+ expect(browser.cookies.getAll).toHaveBeenCalled();
+ expect(browser.browsingData.remove).toHaveBeenCalledWith(
+ { hostnames: ['auth.domain.com'] },
+ { indexedDB: true },
+ );
+ });
+
+ it('closes the background tab before clearing cookies/IndexedDB, so a page reload cannot repopulate them after the wipe', async () => {
+ await clearLinkedOrigin('https://auth.domain.com', ['cache', 'cookies', 'indexedDB']);
+
+ const tabRemoveIndex = calls.indexOf('tabs.remove');
+ const cookiesIndex = calls.indexOf('cookies.getAll');
+ const indexedDbIndex = calls.indexOf('browsingData.remove');
+
+ expect(tabRemoveIndex).toBeGreaterThanOrEqual(0);
+ expect(tabRemoveIndex).toBeLessThan(cookiesIndex);
+ expect(tabRemoveIndex).toBeLessThan(indexedDbIndex);
+ });
+
+ it('passes the cookieStoreId (Firefox container) through to the background tab', async () => {
+ await clearLinkedOrigin('https://auth.domain.com', ['cache'], 'firefox-container-1');
+ expect(browser.tabs.create).toHaveBeenCalledWith(
+ expect.objectContaining({ cookieStoreId: 'firefox-container-1' }),
+ );
+ });
+
+ it('skips opening a tab entirely when only cookies/IndexedDB are requested', async () => {
+ await clearLinkedOrigin('https://auth.domain.com', ['cookies', 'indexedDB']);
+ expect(browser.tabs.create).not.toHaveBeenCalled();
+ expect(browser.cookies.getAll).toHaveBeenCalled();
+ expect(browser.browsingData.remove).toHaveBeenCalled();
+ });
+
+ it('still clears cookies/IndexedDB even if the background-tab step fails, then rethrows', async () => {
+ (browser.scripting.executeScript as ReturnType).mockRejectedValue(
+ new Error('boom'),
+ );
+
+ await expect(
+ clearLinkedOrigin('https://auth.domain.com', ['cache', 'cookies', 'indexedDB']),
+ ).rejects.toThrow('boom');
+
+ expect(browser.tabs.remove).toHaveBeenCalled();
+ expect(browser.cookies.getAll).toHaveBeenCalled();
+ expect(browser.browsingData.remove).toHaveBeenCalled();
+ });
+
+ it('does nothing when none of the requested ids are site-scoped', async () => {
+ await clearLinkedOrigin('https://auth.domain.com', ['history']);
+ expect(browser.tabs.create).not.toHaveBeenCalled();
+ expect(browser.cookies.getAll).not.toHaveBeenCalled();
+ expect(browser.browsingData.remove).not.toHaveBeenCalled();
+ });
+});
+
+describe('clearTabData (Firefox path)', () => {
+ it('clears an already-open tab via the content script, no navigation needed', async () => {
+ const ok = await clearTabData({ id: 42, url: 'https://domain.com' }, [
+ 'cache',
+ 'cookies',
+ 'indexedDB',
+ ]);
+
+ expect(ok).toBe(true);
+ expect(browser.tabs.create).not.toHaveBeenCalled();
+ expect(browser.scripting.executeScript).toHaveBeenCalledWith(
+ expect.objectContaining({ target: { tabId: 42, allFrames: true } }),
+ );
+ expect(browser.cookies.getAll).toHaveBeenCalled();
+ expect(browser.browsingData.remove).toHaveBeenCalledWith(
+ { hostnames: ['domain.com'] },
+ { indexedDB: true },
+ );
+ });
+
+ it('returns false without touching any browser API when the tab has no url', async () => {
+ const ok = await clearTabData({ id: 42 }, ['cache']);
+ expect(ok).toBe(false);
+ expect(browser.scripting.executeScript).not.toHaveBeenCalled();
+ });
+});
diff --git a/utils/clearing.test.ts b/utils/clearing.test.ts
index f0c7de8..90a267c 100644
--- a/utils/clearing.test.ts
+++ b/utils/clearing.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ dedupeSitesByHostname,
isValidHost,
linkedOriginsFor,
parseOriginMappings,
@@ -118,6 +119,31 @@ describe('linkedOriginsFor', () => {
});
});
+describe('dedupeSitesByHostname', () => {
+ it('collapses multiple visits to the same host into one entry', () => {
+ const urls = ['https://domain.com/a', 'https://domain.com/b', 'https://other.com'];
+ expect(dedupeSitesByHostname(urls)).toEqual([
+ { hostname: 'domain.com', origin: 'https://domain.com' },
+ { hostname: 'other.com', origin: 'https://other.com' },
+ ]);
+ });
+
+ it('keeps the origin of the first (most recent) occurrence', () => {
+ const urls = ['http://domain.com/first', 'https://domain.com/second'];
+ expect(dedupeSitesByHostname(urls)).toEqual([{ hostname: 'domain.com', origin: 'http://domain.com' }]);
+ });
+
+ it('skips missing and unparsable urls', () => {
+ expect(dedupeSitesByHostname([undefined, 'not a url', 'https://domain.com'])).toEqual([
+ { hostname: 'domain.com', origin: 'https://domain.com' },
+ ]);
+ });
+
+ it('returns an empty list for no input', () => {
+ expect(dedupeSitesByHostname([])).toEqual([]);
+ });
+});
+
describe('tabOrigin / tabDomain / tabCookieStoreId', () => {
it('extracts origin and hostname from a tab url', () => {
const tab = { url: 'https://sub.domain.com:8443/path?x=1' };
diff --git a/utils/clearing.ts b/utils/clearing.ts
index 7426fa7..316cf49 100644
--- a/utils/clearing.ts
+++ b/utils/clearing.ts
@@ -235,10 +235,7 @@ export function parseOriginMappings(raw: string): OriginMapping[] {
const [sourcePart, targetsPart = ''] = line.split('=>');
return {
source: (sourcePart ?? '').trim(),
- targets: targetsPart
- .split(',')
- .map(normalizeTargetOrigin)
- .filter(Boolean),
+ targets: targetsPart.split(',').map(normalizeTargetOrigin).filter(Boolean),
};
})
.filter((m) => m.source && m.targets.length > 0);
@@ -275,15 +272,6 @@ function waitForTabComplete(tabId: number, ms: number): Promise {
);
}
-/** Clears an origin with no open tab of its own — e.g. an SSO/auth domain a site
- * silently talks to (see clearCookiesForOrigin's partition note: this is exactly the
- * shape of "session survives clearing the site itself" cases). Chrome can scope
- * browsingData.remove to it directly with no tab needed. On Firefox, cookies and
- * IndexedDB go through the same native calls used for the active tab (no tab
- * required for either); the remaining storage types have no such native path, so a
- * background tab is opened briefly to run the same content-script clear, then closed.
- * ponytail: that background tab flashes in the tab strip for a moment — acceptable
- * given this only runs on an explicit clear click. */
export async function clearLinkedOrigin(
origin: string,
ids: DataTypeId[],
@@ -293,34 +281,38 @@ export async function clearLinkedOrigin(
if (scoped.length === 0) return;
if (import.meta.env.FIREFOX) {
+ const scriptIds = scoped.filter((id) => id !== 'cookies' && id !== 'indexedDB');
+ let scriptError: unknown;
+ if (scriptIds.length > 0) {
+ try {
+ const tab = await browser.tabs.create({
+ url: origin,
+ active: false,
+ ...(cookieStoreId ? { cookieStoreId } : {}),
+ } as Parameters[0]);
+ try {
+ if (tab.id != null) {
+ await waitForTabComplete(tab.id, 10000).catch(() => {});
+ await withTimeout(runContentScriptClear(tab.id, scriptIds), 15000);
+ }
+ } finally {
+ if (tab.id != null) await browser.tabs.remove(tab.id).catch(() => {});
+ }
+ } catch (err) {
+ scriptError = err;
+ }
+ }
+
const tasks: Promise[] = [];
if (scoped.includes('cookies')) {
- tasks.push(clearCookiesForOrigin(origin, cookieStoreId));
+ tasks.push(withTimeout(clearCookiesForOrigin(origin, cookieStoreId), 15000));
}
if (scoped.includes('indexedDB')) {
- tasks.push(clearIndexedDBForHostname(new URL(origin).hostname));
- }
- const scriptIds = scoped.filter((id) => id !== 'cookies' && id !== 'indexedDB');
- if (scriptIds.length > 0) {
- tasks.push(
- (async () => {
- const tab = await browser.tabs.create({
- url: origin,
- active: false,
- ...(cookieStoreId ? { cookieStoreId } : {}),
- } as Parameters[0]);
- try {
- if (tab.id != null) {
- await waitForTabComplete(tab.id, 10000).catch(() => {});
- await withTimeout(runContentScriptClear(tab.id, scriptIds), 15000);
- }
- } finally {
- if (tab.id != null) await browser.tabs.remove(tab.id).catch(() => {});
- }
- })(),
- );
+ tasks.push(withTimeout(clearIndexedDBForHostname(new URL(origin).hostname), 15000));
}
await Promise.all(tasks);
+
+ if (scriptError) throw scriptError;
} else {
await clearSiteViaBrowsingData(origin, ids);
}
@@ -349,6 +341,25 @@ export function tabDomain(tab: { url?: string }): string | null {
}
}
+export interface VisitedSite {
+ hostname: string;
+ origin: string;
+}
+
+export function dedupeSitesByHostname(urls: Array): VisitedSite[] {
+ const seen = new Map();
+ for (const url of urls) {
+ if (!url) continue;
+ try {
+ const parsed = new URL(url);
+ if (!seen.has(parsed.hostname)) seen.set(parsed.hostname, parsed.origin);
+ } catch {
+ // not a parsable URL — skip
+ }
+ }
+ return Array.from(seen, ([hostname, origin]) => ({ hostname, origin }));
+}
+
export async function clearTabData(
tab: { id?: number; url?: string; cookieStoreId?: string },
ids: DataTypeId[],
diff --git a/utils/concurrency.test.ts b/utils/concurrency.test.ts
new file mode 100644
index 0000000..ccc4d04
--- /dev/null
+++ b/utils/concurrency.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { mapWithConcurrency } from './concurrency';
+
+describe('mapWithConcurrency', () => {
+ it('returns results in the original order regardless of completion order', async () => {
+ const items = [30, 10, 20];
+ const result = await mapWithConcurrency(items, 3, (ms) => new Promise((r) => setTimeout(() => r(ms), ms)));
+ expect(result).toEqual([30, 10, 20]);
+ });
+
+ it('never runs more than `limit` at once', async () => {
+ let active = 0;
+ let maxActive = 0;
+ const items = Array.from({ length: 10 }, (_, i) => i);
+
+ await mapWithConcurrency(items, 3, async (i) => {
+ active++;
+ maxActive = Math.max(maxActive, active);
+ await new Promise((r) => setTimeout(r, 1));
+ active--;
+ return i;
+ });
+
+ expect(maxActive).toBeLessThanOrEqual(3);
+ });
+
+ it('processes every item exactly once', async () => {
+ const seen: number[] = [];
+ await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (i) => {
+ seen.push(i);
+ return i;
+ });
+ expect(seen.sort()).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it('handles an empty list', async () => {
+ expect(await mapWithConcurrency([], 5, async (i) => i)).toEqual([]);
+ });
+
+ it('handles a limit larger than the item count', async () => {
+ expect(await mapWithConcurrency([1, 2], 10, async (i) => i * 2)).toEqual([2, 4]);
+ });
+});
diff --git a/utils/concurrency.ts b/utils/concurrency.ts
new file mode 100644
index 0000000..c2f9d3b
--- /dev/null
+++ b/utils/concurrency.ts
@@ -0,0 +1,18 @@
+export async function mapWithConcurrency(
+ items: T[],
+ limit: number,
+ fn: (item: T, index: number) => Promise,
+): Promise {
+ const results: R[] = new Array(items.length);
+ let next = 0;
+
+ async function worker() {
+ while (next < items.length) {
+ const i = next++;
+ results[i] = await fn(items[i]!, i);
+ }
+ }
+
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+ return results;
+}
diff --git a/utils/i18n.ts b/utils/i18n.ts
index 7b188b2..9148cbc 100644
--- a/utils/i18n.ts
+++ b/utils/i18n.ts
@@ -38,6 +38,7 @@ const en = {
activeTabHint: 'Only the site open in this tab is affected.',
clearAllSites: 'Clear all sites',
yesClearEverything: 'Yes, clear everything',
+ yesClearAllVisited: 'Yes, clear these',
allSitesHint: 'Applies to every site, not just this tab.',
settings: 'Settings',
@@ -79,6 +80,12 @@ const en = {
clearAllCount: 'Clear all ({count})',
clear: 'Clear',
noMatchingTabs: 'No matching tabs.',
+ noMatchingHistory: 'No history yet.',
+ visitedSites: 'Visited sites',
+ visitedSitesDescription:
+ "Any site from your history, not just open tabs — cleared the same way as the checklist above. On Firefox, this only clears the default container; a site open in a named container (e.g. Multi-Account Containers) needs clearing from its own open tab instead.",
+ filterVisitedSitesPlaceholder: 'Filter visited sites by domain…',
+ noMatchingSites: 'No matching sites.',
recentHistory: 'Recent history',
remove: 'Remove',
@@ -129,6 +136,7 @@ const ru: typeof en = {
'Затрагивает только сайт, открытый в этой вкладке. При первом использовании запросит разрешение.',
clearAllSites: 'Очистить все сайты',
yesClearEverything: 'Да, очистить всё',
+ yesClearAllVisited: 'Да, очистить эти',
allSitesHint: 'Применяется ко всем сайтам, не только к этой вкладке.',
settings: 'Настройки',
@@ -170,6 +178,12 @@ const ru: typeof en = {
clearAllCount: 'Очистить все ({count})',
clear: 'Очистить',
noMatchingTabs: 'Нет подходящих вкладок.',
+ noMatchingHistory: 'История пуста.',
+ visitedSites: 'Посещённые сайты',
+ visitedSitesDescription:
+ 'Любой сайт из истории, а не только открытые вкладки — очищается так же, как и по чек-листу выше. В Firefox это очищает только контейнер по умолчанию; сайт, открытый в именованном контейнере (например, Multi-Account Containers), нужно очищать из его собственной открытой вкладки.',
+ filterVisitedSitesPlaceholder: 'Фильтр посещённых сайтов по домену…',
+ noMatchingSites: 'Нет подходящих сайтов.',
recentHistory: 'Недавняя история',
remove: 'Удалить',
@@ -220,6 +234,7 @@ const ro: typeof en = {
'Afectează doar site-ul deschis în această filă. Va cere permisiune o singură dată la prima utilizare.',
clearAllSites: 'Șterge toate site-urile',
yesClearEverything: 'Da, șterge totul',
+ yesClearAllVisited: 'Da, șterge acestea',
allSitesHint: 'Se aplică pentru toate site-urile, nu doar pentru această filă.',
settings: 'Setări',
@@ -261,6 +276,12 @@ const ro: typeof en = {
clearAllCount: 'Șterge toate ({count})',
clear: 'Șterge',
noMatchingTabs: 'Nicio filă corespunzătoare.',
+ noMatchingHistory: 'Niciun istoric încă.',
+ visitedSites: 'Site-uri vizitate',
+ visitedSitesDescription:
+ 'Orice site din istoric, nu doar filele deschise — șters la fel ca lista de mai sus. Pe Firefox, se șterge doar containerul implicit; un site deschis într-un container numit (ex. Multi-Account Containers) trebuie șters din propria filă deschisă.',
+ filterVisitedSitesPlaceholder: 'Filtrează site-urile vizitate după domeniu…',
+ noMatchingSites: 'Niciun site corespunzător.',
recentHistory: 'Istoric recent',
remove: 'Elimină',
@@ -311,6 +332,7 @@ const uk: typeof en = {
'Стосується лише сайту, відкритого в цій вкладці. При першому використанні запитає дозвіл.',
clearAllSites: 'Очистити всі сайти',
yesClearEverything: 'Так, очистити все',
+ yesClearAllVisited: 'Так, очистити ці',
allSitesHint: 'Застосовується до всіх сайтів, не лише до цієї вкладки.',
settings: 'Налаштування',
@@ -352,6 +374,12 @@ const uk: typeof en = {
clearAllCount: 'Очистити всі ({count})',
clear: 'Очистити',
noMatchingTabs: 'Немає відповідних вкладок.',
+ noMatchingHistory: 'Історія порожня.',
+ visitedSites: 'Відвідані сайти',
+ visitedSitesDescription:
+ 'Будь-який сайт з історії, а не лише відкриті вкладки — очищається так само, як за чек-листом вище. У Firefox це очищає лише контейнер за замовчуванням; сайт, відкритий у іменованому контейнері (напр. Multi-Account Containers), потрібно очищати з його власної відкритої вкладки.',
+ filterVisitedSitesPlaceholder: 'Фільтр відвіданих сайтів за доменом…',
+ noMatchingSites: 'Немає відповідних сайтів.',
recentHistory: 'Недавня історія',
remove: 'Видалити',