-

Tab Exporter

-

Export all your open tabs to a Markdown file

+

Tab Catcher

+

Export selected tabs to a Markdown file

@@ -25,14 +28,30 @@
+
+

Select Windows & Groups

+
+
Scanning windows...
+
+
+ + +
+
+

Export Options

- +
- - -
diff --git a/popup.js b/popup.js index b79a136..92f0dd6 100644 --- a/popup.js +++ b/popup.js @@ -1,30 +1,129 @@ +// Browser API compatibility shim (Chrome uses chrome.*, Firefox exposes browser.*) +const api = (typeof browser !== 'undefined' && browser.tabs) ? browser : chrome; + +// Cached window/tab/group data built at load time +let windowSelectorData = []; // [{windowId, focused, tabs, groups}] + +// Tab group color lookup (Chrome's string enum → CSS hex) +const GROUP_COLORS = { + grey: '#5f6368', blue: '#1a73e8', red: '#d93025', + yellow: '#f9ab00', green: '#1e8e3e', pink: '#d01884', + purple: '#a142f4', cyan: '#007b83', orange: '#fa903e' +}; + // Initialize extension document.addEventListener('DOMContentLoaded', async () => { - await updateStats(); + await buildSelectorData(); + renderSelector(); + updateStats(); setupEventListeners(); }); -// Update tab and window count statistics -async function updateStats() { +// Fetch and cache all window/tab/group data +async function buildSelectorData() { try { - const tabs = await chrome.tabs.query({}); - const windows = await chrome.windows.getAll(); - - document.getElementById('tab-count').textContent = tabs.length; - document.getElementById('window-count').textContent = windows.length; + const [allTabs, allWindows] = await Promise.all([ + api.tabs.query({}), + api.windows.getAll() + ]); + + windowSelectorData = await Promise.all(allWindows.map(async (win) => { + const tabs = allTabs.filter(t => t.windowId === win.id); + let groups = []; + + if (api.tabGroups && typeof api.tabGroups.query === 'function') { + try { + groups = await api.tabGroups.query({ windowId: win.id }); + } catch (_) { + // tabGroups API present but query failed — treat as no groups + } + } + + return { windowId: win.id, focused: win.focused, tabs, groups }; + })); } catch (error) { - console.error('Error updating stats:', error); + console.error('Error building selector data:', error); } } +// Update tab and window count statistics from cached data +function updateStats() { + const totalTabs = windowSelectorData.reduce((sum, w) => sum + w.tabs.length, 0); + document.getElementById('tab-count').textContent = totalTabs; + document.getElementById('window-count').textContent = windowSelectorData.length; +} + +// Render window/group checkboxes into #selector-list +function renderSelector() { + const list = document.getElementById('selector-list'); + + if (windowSelectorData.length === 0) { + list.innerHTML = '
No windows found.
'; + return; + } + + let html = ''; + windowSelectorData.forEach((win, index) => { + const label = `Window ${index + 1}${win.focused ? ' (active)' : ''}`; + const tabCount = win.tabs.length; + + html += ` +
+ `; + + if (win.groups.length > 0) { + win.groups.forEach(group => { + const color = GROUP_COLORS[group.color] || '#888888'; + const groupTitle = group.title || 'Unnamed group'; + const groupTabCount = win.tabs.filter(t => t.groupId === group.id).length; + + html += ` +
+ +
`; + }); + } + + html += '
'; + }); + + list.innerHTML = html; + + // Stagger animation delays on rendered items + list.querySelectorAll('.selector-item:not(.selector-item--group)').forEach((el, i) => { + el.style.animationDelay = `${i * 50}ms`; + }); +} + // Setup event listeners function setupEventListeners() { - const exportBtn = document.getElementById('export-btn'); - exportBtn.addEventListener('click', handleExport); - - // Keyboard accessibility for options - const checkboxes = document.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach(checkbox => { + document.getElementById('export-btn').addEventListener('click', handleExport); + + document.getElementById('select-all-btn').addEventListener('click', () => { + document.querySelectorAll('.window-checkbox, .group-checkbox') + .forEach(cb => { cb.checked = true; }); + }); + + document.getElementById('deselect-all-btn').addEventListener('click', () => { + document.querySelectorAll('.window-checkbox, .group-checkbox') + .forEach(cb => { cb.checked = false; }); + }); + + // Keyboard accessibility for option checkboxes + document.querySelectorAll('.options input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -34,54 +133,83 @@ function setupEventListeners() { }); } +// Return only the tabs from selected windows/groups +function getSelectedTabs() { + const checkedWindowIds = new Set( + [...document.querySelectorAll('.window-checkbox:checked')] + .map(cb => parseInt(cb.dataset.windowId)) + ); + const checkedGroupIds = new Set( + [...document.querySelectorAll('.group-checkbox:checked')] + .map(cb => parseInt(cb.dataset.groupId)) + ); + + const result = []; + for (const win of windowSelectorData) { + if (!checkedWindowIds.has(win.windowId)) continue; + + for (const tab of win.tabs) { + if (win.groups.length > 0 && tab.groupId && tab.groupId > 0) { + // Grouped tab: only include if its group is checked + if (checkedGroupIds.has(tab.groupId)) result.push(tab); + } else { + // Ungrouped tab (Chrome sentinel -1 or Firefox undefined): always include + result.push(tab); + } + } + } + return result; +} + // Main export handler async function handleExport() { const exportBtn = document.getElementById('export-btn'); const statusEl = document.getElementById('export-status'); - + // Get user options const includeDescriptions = document.getElementById('include-descriptions').checked; const groupByWindow = document.getElementById('group-by-window').checked; const includeTimestamp = document.getElementById('include-timestamp').checked; - + + // Get filtered tabs + const tabs = getSelectedTabs(); + + if (tabs.length === 0) { + statusEl.textContent = '! Select at least one window to export.'; + statusEl.className = 'status-message error'; + return; + } + // Disable button and show loading state exportBtn.disabled = true; const originalContent = exportBtn.innerHTML; exportBtn.innerHTML = 'Exporting...'; - + statusEl.textContent = 'Collecting tab information...'; statusEl.className = 'status-message loading'; - + try { - // Get all tabs - const tabs = await chrome.tabs.query({}); - - // Fetch descriptions if needed if (includeDescriptions) { statusEl.textContent = `Fetching descriptions (0/${tabs.length})...`; await fetchDescriptions(tabs, statusEl); } - - // Generate markdown content + const markdown = await generateMarkdown(tabs, { includeDescriptions, groupByWindow, includeTimestamp }); - - // Trigger download + downloadMarkdown(markdown); - - // Show success message - statusEl.textContent = '✓ Successfully exported ' + tabs.length + ' tabs!'; + + statusEl.textContent = '✓ Exported ' + tabs.length + ' tabs successfully.'; statusEl.className = 'status-message success'; - + } catch (error) { console.error('Export error:', error); statusEl.textContent = '✗ Error: ' + error.message; statusEl.className = 'status-message error'; } finally { - // Re-enable button exportBtn.disabled = false; exportBtn.innerHTML = originalContent; } @@ -92,37 +220,36 @@ async function fetchDescriptions(tabs, statusEl) { for (let i = 0; i < tabs.length; i++) { const tab = tabs[i]; statusEl.textContent = `Fetching descriptions (${i + 1}/${tabs.length})...`; - + try { - // Try to execute script to get description - const results = await chrome.scripting.executeScript({ + const results = await api.scripting.executeScript({ target: { tabId: tab.id }, func: extractDescription }); - + if (results && results[0] && results[0].result) { tab.description = results[0].result; } } catch (error) { - // Tab might not support script injection (e.g., chrome:// pages) + // Tab doesn't support script injection (e.g. about:, chrome://, moz:// pages) tab.description = null; } } } -// Function to extract description (runs in tab context) +// Function to extract description — runs in tab context function extractDescription() { const metaDesc = document.querySelector('meta[name="description"]'); const ogDesc = document.querySelector('meta[property="og:description"]'); - return (metaDesc && metaDesc.content) || - (ogDesc && ogDesc.content) || + return (metaDesc && metaDesc.content) || + (ogDesc && ogDesc.content) || null; } // Generate markdown content async function generateMarkdown(tabs, options) { let markdown = '# Browser Tabs Export\n\n'; - + if (options.includeTimestamp) { const now = new Date(); const timestamp = now.toLocaleString('en-US', { @@ -131,22 +258,18 @@ async function generateMarkdown(tabs, options) { }); markdown += `**Exported:** ${timestamp}\n\n`; } - + markdown += `**Total Tabs:** ${tabs.length}\n\n`; markdown += '---\n\n'; - + if (options.groupByWindow) { - // Group tabs by window - const windows = await chrome.windows.getAll(); + // Group selected tabs by their window, using cached window data order const tabsByWindow = {}; - tabs.forEach(tab => { - if (!tabsByWindow[tab.windowId]) { - tabsByWindow[tab.windowId] = []; - } + if (!tabsByWindow[tab.windowId]) tabsByWindow[tab.windowId] = []; tabsByWindow[tab.windowId].push(tab); }); - + let windowIndex = 1; for (const windowId in tabsByWindow) { markdown += `## Window ${windowIndex}\n\n`; @@ -155,30 +278,27 @@ async function generateMarkdown(tabs, options) { windowIndex++; } } else { - // List all tabs together markdown += formatTabs(tabs, options.includeDescriptions); } - + return markdown; } -// Format tabs as markdown +// Format tabs as markdown list function formatTabs(tabs, includeDescriptions) { let content = ''; - - tabs.forEach((tab, index) => { - // List item with title and URL + + tabs.forEach(tab => { content += `- **${escapeMarkdown(tab.title)}**\n`; content += ` - URL: ${tab.url}\n`; - - // Description if available + if (includeDescriptions && tab.description) { content += ` - Description: ${escapeMarkdown(tab.description)}\n`; } - + content += '\n'; }); - + return content; } @@ -188,20 +308,25 @@ function escapeMarkdown(text) { return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&'); } +// Escape HTML for safe DOM injection +function escapeHtml(text) { + if (!text) return ''; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + // Download markdown file function downloadMarkdown(content) { const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); - const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); const filename = `tabs-export-${timestamp}.md`; - - chrome.downloads.download({ - url: url, - filename: filename, - saveAs: true - }, (downloadId) => { - // Cleanup object URL after download starts - setTimeout(() => URL.revokeObjectURL(url), 100); - }); + + api.downloads.download({ url, filename, saveAs: true }); + + // Always clean up — Firefox's browser.downloads returns a Promise and ignores callbacks + setTimeout(() => URL.revokeObjectURL(url), 100); } diff --git a/styles.css b/styles.css index 7214afe..ce8f6b1 100644 --- a/styles.css +++ b/styles.css @@ -1,20 +1,21 @@ :root { - --primary-color: #2d8659; - --primary-hover: #36a169; - --primary-active: #236b47; - --secondary-color: #0d9488; - --alternate-color: #c4b5fd; - --success-color: #2d8659; - --error-color: #ef4444; - --text-primary: #bfdbfe; - --text-secondary: #93c5fd; - --bg-primary: linear-gradient(135deg, #1e1b4b 0%, #1e3a8a 100%); - --bg-secondary: rgba(255, 255, 255, 0.05); - --border-color: rgba(191, 219, 254, 0.2); - --shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - --shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4); - --radius: 8px; - --transition: all 0.2s ease; + --neon-green: #00ff88; + --neon-cyan: #00e5ff; + --neon-pink: #ff007f; + --neon-purple: #bf5fff; + --bg: #080810; + --bg-card: rgba(0, 255, 136, 0.04); + --text-primary: #e0e0e0; + --text-muted: #5a7a6a; + --border: rgba(0, 255, 136, 0.25); + --border-cyan: rgba(0, 229, 255, 0.2); + --glow-green: 0 0 8px #00ff88, 0 0 20px rgba(0, 255, 136, 0.3); + --glow-cyan: 0 0 8px #00e5ff, 0 0 20px rgba(0, 229, 255, 0.3); + --glow-pink: 0 0 8px #ff007f, 0 0 20px rgba(255, 0, 127, 0.3); + --radius: 2px; + --font-mono: 'Share Tech Mono', monospace; + --font-display: 'Orbitron', sans-serif; + --transition: all 0.15s ease; } * { @@ -24,11 +25,18 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - font-size: 14px; + font-family: var(--font-mono); + font-size: 13px; line-height: 1.5; color: var(--text-primary); - background: var(--bg-primary); + background-color: var(--bg); + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 255, 136, 0.015) 2px, + rgba(0, 255, 136, 0.015) 4px + ); width: 380px; min-height: 400px; } @@ -50,118 +58,265 @@ body { padding: 20px; } +/* Header */ header { text-align: center; - margin-bottom: 24px; + margin-bottom: 20px; padding-bottom: 16px; - border-bottom: 2px solid rgba(196, 181, 253, 0.3); + border-bottom: 1px solid var(--border); } h1 { - font-size: 24px; - font-weight: 600; - color: var(--alternate-color); + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + color: var(--neon-green); + text-shadow: var(--glow-green); + letter-spacing: 2px; + text-transform: uppercase; margin-bottom: 4px; } .subtitle { - font-size: 13px; - color: var(--text-secondary); + font-size: 11px; + color: var(--text-muted); + letter-spacing: 1px; + text-transform: uppercase; } /* Stats Section */ .stats { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 12px; - margin-bottom: 24px; + gap: 10px; + margin-bottom: 20px; } .stat-card { - background: var(--bg-secondary); + background: var(--bg-card); border-radius: var(--radius); - padding: 16px; + padding: 14px; text-align: center; + border: 1px solid var(--border); transition: var(--transition); - border: 1px solid var(--border-color); + animation: fadeSlideIn 0.3s ease both; } +.stat-card:nth-child(1) { animation-delay: 0ms; } +.stat-card:nth-child(2) { animation-delay: 60ms; } + .stat-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow); + border-color: var(--neon-cyan); + box-shadow: var(--glow-cyan); } .stat-number { display: block; - font-size: 28px; + font-family: var(--font-display); + font-size: 26px; font-weight: 700; - color: var(--secondary-color); + color: var(--neon-cyan); + text-shadow: var(--glow-cyan); line-height: 1; margin-bottom: 4px; } .stat-label { display: block; - font-size: 12px; - color: var(--text-secondary); + font-size: 10px; + color: var(--text-muted); text-transform: uppercase; + letter-spacing: 1px; +} + +/* Selector Section */ +.selector { + margin-bottom: 20px; +} + +.section-heading { + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--neon-green); + margin-bottom: 10px; +} + +#selector-list { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 2px; +} + +#selector-list::-webkit-scrollbar { + width: 4px; +} + +#selector-list::-webkit-scrollbar-track { + background: transparent; +} + +#selector-list::-webkit-scrollbar-thumb { + background: var(--neon-green); + border-radius: 2px; +} + +.selector-loading { + color: var(--text-muted); + font-size: 12px; + padding: 8px; + letter-spacing: 1px; +} + +.selector-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 8px; + transition: var(--transition); + animation: fadeSlideIn 0.3s ease both; +} + +.selector-item:hover { + border-color: var(--neon-green); +} + +.selector-item--group { + margin-left: 20px; + border-color: var(--border-cyan); +} + +.selector-item--group:hover { + border-color: var(--neon-cyan); +} + +.selector-item .checkbox-label { + display: flex; + align-items: center; + gap: 8px; + padding: 0; + border-radius: 0; + cursor: pointer; + user-select: none; +} + +.selector-item .checkbox-label:hover { + background: none; +} + +.selector-item-title { + font-size: 12px; + color: var(--text-primary); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.selector-item-meta { + font-size: 11px; + color: var(--text-muted); + margin-left: auto; + white-space: nowrap; + flex-shrink: 0; +} + +.selector-item-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.selector-actions { + display: flex; + gap: 16px; + margin-top: 8px; +} + +.btn-link { + background: none; + border: none; + padding: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--neon-cyan); + cursor: pointer; letter-spacing: 0.5px; + transition: var(--transition); + text-decoration: underline; + text-underline-offset: 2px; +} + +.btn-link:hover { + color: var(--neon-green); + text-shadow: var(--glow-green); +} + +.btn-link:focus { + outline: 2px solid var(--neon-cyan); + outline-offset: 2px; } /* Options Section */ .options { - margin-bottom: 24px; -} - -.section-heading { - font-size: 16px; - font-weight: 600; - margin-bottom: 12px; - color: var(--text-primary); + margin-bottom: 20px; } .option-group { - margin-bottom: 16px; + margin-bottom: 10px; + animation: fadeSlideIn 0.3s ease both; } +.option-group:nth-child(2) { animation-delay: 0ms; } +.option-group:nth-child(3) { animation-delay: 60ms; } +.option-group:nth-child(4) { animation-delay: 120ms; } + .checkbox-label { display: flex; align-items: center; cursor: pointer; user-select: none; - padding: 8px; - border-radius: 6px; + padding: 7px 8px; + border-radius: var(--radius); transition: var(--transition); + border: 1px solid transparent; } .checkbox-label:hover { - background: rgba(255, 255, 255, 0.08); + background: rgba(0, 255, 136, 0.05); + border-color: var(--border); } .checkbox-label input[type="checkbox"] { - width: 18px; - height: 18px; + width: 16px; + height: 16px; margin-right: 10px; cursor: pointer; - accent-color: var(--primary-color); - border: 1px solid var(--border-color); + accent-color: var(--neon-green); + flex-shrink: 0; } .checkbox-label input[type="checkbox"]:focus { - outline: 2px solid var(--alternate-color); + outline: 2px solid var(--neon-green); outline-offset: 2px; } .checkbox-label span { - font-size: 14px; - font-weight: 500; + font-size: 13px; } .help-text { - font-size: 12px; - color: var(--text-secondary); - margin-top: 4px; - margin-left: 28px; + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + margin-left: 26px; } /* Actions Section */ @@ -171,105 +326,120 @@ h1 { .btn { width: 100%; - padding: 14px 20px; - font-size: 15px; - font-weight: 600; - border: none; + padding: 12px 20px; + font-family: var(--font-display); + font-size: 13px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; border-radius: var(--radius); cursor: pointer; transition: var(--transition); display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: 10px; } .btn:focus { - outline: 2px solid var(--alternate-color); + outline: 2px solid var(--neon-green); outline-offset: 2px; } .btn-primary { - background: var(--primary-color); - color: white; - box-shadow: var(--shadow); + background: transparent; + color: var(--neon-green); + border: 1px solid var(--neon-green); + box-shadow: inset 0 0 12px rgba(0, 255, 136, 0.05); } .btn-primary:hover:not(:disabled) { - background: var(--primary-hover); - box-shadow: var(--shadow-hover); - transform: translateY(-1px); + background: rgba(0, 255, 136, 0.08); + box-shadow: var(--glow-green); } .btn-primary:active:not(:disabled) { - background: var(--primary-active); - transform: translateY(0); + background: rgba(0, 255, 136, 0.15); + box-shadow: none; } .btn-primary:disabled { - opacity: 0.6; + opacity: 0.4; cursor: not-allowed; } .btn-icon { - font-size: 18px; + font-size: 16px; } .status-message { - margin-top: 12px; - padding: 12px; - border-radius: 6px; - font-size: 13px; + margin-top: 10px; + padding: 10px; + border-radius: var(--radius); + font-size: 12px; text-align: center; - min-height: 40px; + min-height: 38px; display: flex; align-items: center; justify-content: center; + letter-spacing: 0.5px; } .status-message.success { - background: rgba(45, 134, 89, 0.2); - color: #6ee7b7; - border: 1px solid var(--success-color); + background: rgba(0, 255, 136, 0.07); + color: var(--neon-green); + border: 1px solid var(--neon-green); + text-shadow: var(--glow-green); } .status-message.error { - background: rgba(239, 68, 68, 0.2); - color: #fca5a5; - border: 1px solid var(--error-color); + background: rgba(255, 0, 127, 0.07); + color: var(--neon-pink); + border: 1px solid var(--neon-pink); + text-shadow: var(--glow-pink); } .status-message.loading { - background: rgba(13, 148, 136, 0.2); - color: var(--secondary-color); - border: 1px solid var(--secondary-color); + background: rgba(0, 229, 255, 0.07); + color: var(--neon-cyan); + border: 1px solid var(--neon-cyan); } /* Footer */ .footer { - padding-top: 16px; - border-top: 1px solid rgba(196, 181, 253, 0.3); + padding-top: 14px; + border-top: 1px solid var(--border); } .footer-text { - font-size: 12px; - color: var(--text-secondary); + font-size: 11px; + color: var(--text-muted); text-align: center; } kbd { display: inline-block; - padding: 2px 6px; - font-family: monospace; + padding: 1px 5px; + font-family: var(--font-mono); font-size: 11px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); - color: var(--alternate-color); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--neon-cyan); +} + +/* Animations */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } -/* Loading spinner animation */ @keyframes spin { to { transform: rotate(360deg); } }