diff --git a/Makefile b/Makefile index 4d44079..fb0056a 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,35 @@ +VERSION := $(shell grep -o '"version": *"[^"]*"' src/manifest.firefox.json | grep -o '[0-9][0-9.]*') + +.PHONY: all clean build build-firefox build-chrome lint run + all: clean build -# clean clean: rm -rf dist/* -# build -build: - mkdir -p dist/{firefox,chrome} - - # copy manifest.json - cp src/manifest.json dist/firefox - cp src/manifest.json dist/chrome - - # copy background.js - cp src/background.js dist/firefox - cp src/background.js dist/chrome - - # copy popup - cp -rf src/popup dist/firefox - cp -rf src/popup dist/chrome - - # copy icons - cp -rf src/icons dist/firefox - cp -rf src/icons dist/chrome - - # zip the folders - (cd ./dist/firefox/; zip -rm power-close.zip *) - (cd ./dist/chrome/; zip -rm power-close.zip *) +build: build-firefox build-chrome + +build-firefox: + mkdir -p dist/firefox + cp src/manifest.firefox.json dist/firefox/manifest.json + cp src/background.js dist/firefox/ + cp -rf src/popup dist/firefox/ + cp -rf src/icons dist/firefox/ + cp -rf src/options dist/firefox/ + (cd dist/firefox && zip -rm ../power-close-$(VERSION)-firefox.zip *) + +build-chrome: + mkdir -p dist/chrome + cp src/manifest.chrome.json dist/chrome/manifest.json + cp src/background.js dist/chrome/ + cp -rf src/popup dist/chrome/ + cp -rf src/icons dist/chrome/ + cp -rf src/options dist/chrome/ + (cd dist/chrome && zip -rm ../power-close-$(VERSION)-chrome.zip *) + +# Requires: npm install -g web-ext +lint: build-firefox + web-ext lint --source-dir=dist/firefox + +run: build-firefox + web-ext run --source-dir=dist/firefox diff --git a/src/background.js b/src/background.js index f32f195..ae5618c 100644 --- a/src/background.js +++ b/src/background.js @@ -1,32 +1,107 @@ -(function () { - 'use strict'; - function closeTabsByKeyword(keyword) { - if (!keyword) { - return; +'use strict'; + +const DEFAULT_PREFS = { + excludeCurrentTab: true, + excludePinnedTabs: true, + excludeExtensionPages: true, + caseSensitive: false, +}; + +const INTERNAL_SCHEMES = [ + 'chrome://', + 'about:', + 'edge://', + 'opera://', + 'vivaldi://', + 'brave://', +]; + +function isInternalUrl(url) { + if (!url) return true; + return INTERNAL_SCHEMES.some(scheme => url.startsWith(scheme)); +} + +function isExtensionUrl(url) { + return url.startsWith('chrome-extension://') || url.startsWith('moz-extension://'); +} + +async function getPrefs() { + return chrome.storage.sync.get(DEFAULT_PREFS); +} + +async function closeTabsByKeyword(keyword, sourceTabId) { + if (!keyword) return; + + const prefs = await getPrefs(); + const needle = prefs.caseSensitive ? keyword : keyword.toLowerCase(); + const tabs = await chrome.tabs.query({}); + const toClose = []; + + for (const tab of tabs) { + if (isInternalUrl(tab.url)) continue; + if (prefs.excludeExtensionPages && isExtensionUrl(tab.url)) continue; + if (prefs.excludePinnedTabs && tab.pinned) continue; + if (prefs.excludeCurrentTab && tab.id === sourceTabId) continue; + + const haystack = prefs.caseSensitive ? tab.url : tab.url.toLowerCase(); + if (haystack.includes(needle)) { + toClose.push(tab.id); } + } - chrome.tabs.query({}, function (tabs) { - for (const tab of tabs) { - if (tab.url.indexOf(keyword) !== -1) { - chrome.tabs.remove(tab.id); - } - } - }); + if (toClose.length > 0) { + chrome.tabs.remove(toClose); } +} + +async function closeDuplicateTabs(sourceTabId) { + const prefs = await getPrefs(); + const tabs = await chrome.tabs.query({}); + const seen = new Set(); + const toClose = []; - chrome.runtime.onMessage.addListener(function (response) { - if (response.event !== 'text-entered') { - return; + for (const tab of tabs) { + if (isInternalUrl(tab.url)) continue; + if (prefs.excludePinnedTabs && tab.pinned) continue; + if (prefs.excludeCurrentTab && tab.id === sourceTabId) continue; + + if (seen.has(tab.url)) { + toClose.push(tab.id); + } else { + seen.add(tab.url); } + } - closeTabsByKeyword(response.text); - }); + if (toClose.length > 0) { + chrome.tabs.remove(toClose); + } +} +chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ + id: 'close-domain', title: 'Close all tabs from this domain', - onclick: function (event) { - const keyword = new URL(event.pageUrl).hostname; - closeTabsByKeyword(keyword); - } + contexts: ['page', 'image', 'link', 'frame'], + }); + chrome.contextMenus.create({ + id: 'close-duplicates', + title: 'Close duplicate tabs', + contexts: ['page', 'image', 'link', 'frame'], + }); +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === 'close-domain') { + const keyword = new URL(info.pageUrl).hostname; + closeTabsByKeyword(keyword, tab?.id); + } else if (info.menuItemId === 'close-duplicates') { + closeDuplicateTabs(tab?.id); + } +}); + +chrome.runtime.onMessage.addListener((message) => { + if (message.event !== 'text-entered') return; + chrome.tabs.query({ active: true, currentWindow: true }, (activeTabs) => { + closeTabsByKeyword(message.text, activeTabs[0]?.id); }); -})(); +}); diff --git a/src/manifest.json b/src/manifest.chrome.json similarity index 53% rename from src/manifest.json rename to src/manifest.chrome.json index fd6097b..79a1a38 100644 --- a/src/manifest.json +++ b/src/manifest.chrome.json @@ -1,14 +1,16 @@ { "manifest_version": 3, "name": "Power Close", - "version": "1.0.1", + "version": "1.1.0", "description": "A browser extension, for the tab addict, to close all tabs with urls containing a keyword or a domain name.", + "author": "Mosab Ibrahim", + "homepage_url": "https://github.com/ArabesqueLabs/power-close", "icons": { "16": "icons/16.png", "48": "icons/48.png", "128": "icons/128.png" }, - "browser_action": { + "action": { "default_icon": { "19": "icons/19.png", "38": "icons/38.png" @@ -16,15 +18,25 @@ "default_title": "Power Close", "default_popup": "popup/popup.html" }, - "author": "Mosab Ibrahim", "background": { - "scripts": [ - "background.js" - ] + "service_worker": "background.js" }, - "homepage_url": "https://github.com/mos3abof/firefox-power-close", "permissions": [ "tabs", - "contextMenus" - ] + "contextMenus", + "storage" + ], + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+X", + "mac": "Command+Shift+X" + }, + "description": "Open Power Close" + } + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false + } } diff --git a/src/manifest.firefox.json b/src/manifest.firefox.json new file mode 100644 index 0000000..1d466de --- /dev/null +++ b/src/manifest.firefox.json @@ -0,0 +1,48 @@ +{ + "manifest_version": 3, + "name": "Power Close", + "version": "1.1.0", + "description": "A browser extension, for the tab addict, to close all tabs with urls containing a keyword or a domain name.", + "author": "Mosab Ibrahim", + "homepage_url": "https://github.com/ArabesqueLabs/power-close", + "icons": { + "16": "icons/16.png", + "48": "icons/48.png", + "128": "icons/128.png" + }, + "action": { + "default_icon": { + "19": "icons/19.png", + "38": "icons/38.png" + }, + "default_title": "Power Close", + "default_popup": "popup/popup.html" + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": [ + "tabs", + "contextMenus", + "storage" + ], + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+X", + "mac": "Command+Shift+X" + }, + "description": "Open Power Close" + } + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false + }, + "browser_specific_settings": { + "gecko": { + "id": "power-close@arabesquelabs", + "strict_min_version": "109.0" + } + } +} diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..2517a67 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,77 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: system-ui, sans-serif; + font-size: 0.9rem; + color: #1f2937; + padding: 1.5rem 2rem; + max-width: 400px; + margin: 0; +} + +h1 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1.5rem; +} + +fieldset { + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 0.75rem 1rem 1rem; + margin: 0 0 1rem; +} + +legend { + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + padding: 0 0.25rem; +} + +label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + padding: 0.3rem 0; +} + +input[type="checkbox"] { + accent-color: #6366f1; + width: 15px; + height: 15px; + cursor: pointer; +} + +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.5rem; +} + +button { + padding: 0.4rem 1.25rem; + background: #6366f1; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; +} + +button:hover { + background: #4f46e5; +} + +#status { + font-size: 0.8rem; + color: #22c55e; + font-weight: 500; +} diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..abd5770 --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,40 @@ + + +
+ +