Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Tab Out is a Chrome extension that replaces your new tab page with a dashboard of everything you have open. Tabs are grouped by domain, with homepages (Gmail, X, LinkedIn, etc.) pulled into their own group. Close tabs with a satisfying swoosh + confetti.

This optimized fork adds a bookmarks bar sidebar and Chrome Manifest V3 CSP cleanup while preserving the original Tab Out experience.

No server. No account. No external API calls. Just a Chrome extension.

---
Expand All @@ -27,6 +29,7 @@ The agent will walk you through it. Takes about 1 minute.
- **Close tabs with style** with swoosh sound + confetti burst
- **Duplicate detection** flags when you have the same page open twice, with one-click cleanup
- **Click any tab to jump to it** across windows, no new tab opened
- **Browse your bookmarks bar** from the left sidebar when bookmarks are available
- **Save for later** bookmark tabs to a checklist before closing them
- **Localhost grouping** shows port numbers next to each tab so you can tell your vibe coding projects apart
- **Expandable groups** show the first 8 tabs with a clickable "+N more"
Expand Down Expand Up @@ -88,4 +91,6 @@ MIT

---

Built by [Zara](https://x.com/zarazhangrui)
Originally built by [Zara](https://x.com/zarazhangrui).

Optimized by [wuxiangyu05](https://github.com/wuxiangyu05), with original authorship and license attribution preserved.
163 changes: 159 additions & 4 deletions extension/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,42 @@ async function fetchOpenTabs() {
}
}

/**
* fetchBookmarksBar()
*
* Reads Chrome's bookmarks bar folder. Chrome usually assigns id "1" to
* it, but the tree lookup keeps this working across localized browsers.
*/
async function fetchBookmarksBar() {
if (!chrome.bookmarks) {
return {
status: 'missing-permission',
items: [],
};
}

try {
const tree = await chrome.bookmarks.getTree();
const rootChildren = tree[0]?.children || [];
const titleMatch = /^(bookmarks bar|书签栏|收藏夹栏|favorites bar)$/i;
const bar =
rootChildren.find(node => node.id === '1') ||
rootChildren.find(node => titleMatch.test(node.title || '')) ||
rootChildren[0];

return {
status: 'ok',
items: bar?.children || [],
};
} catch (err) {
console.warn('[tab-out] Could not read bookmarks bar:', err);
return {
status: 'read-error',
items: [],
};
}
}

/**
* closeTabsByUrls(urls)
*
Expand Down Expand Up @@ -442,6 +478,16 @@ function showToast(message) {
setTimeout(() => toast.classList.remove('visible'), 2500);
}

function loadOptionalConfig() {
return new Promise(resolve => {
const script = document.createElement('script');
script.src = 'config.local.js';
script.addEventListener('load', resolve, { once: true });
script.addEventListener('error', resolve, { once: true });
document.head.appendChild(script);
});
}

/**
* checkAndShowEmptyState()
*
Expand Down Expand Up @@ -611,6 +657,15 @@ function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function stripTitleNoise(title) {
if (!title) return '';
// Strip leading notification count: "(2) Title"
Expand Down Expand Up @@ -769,7 +824,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 `<div class="page-chip clickable${chipClass}" data-action="focus-tab" data-tab-url="${safeUrl}" title="${safeTitle}">
${faviconUrl ? `<img class="chip-favicon" src="${faviconUrl}" alt="" onerror="this.style.display='none'">` : ''}
${faviconUrl ? `<img class="chip-favicon hide-on-error" src="${faviconUrl}" alt="">` : ''}
<span class="chip-text">${label}</span>${dupeTag}
<div class="chip-actions">
<button class="chip-action chip-save" data-action="defer-single-tab" data-tab-url="${safeUrl}" data-tab-title="${safeTitle}" title="Save for later">
Expand Down Expand Up @@ -850,7 +905,7 @@ function renderDomainCard(group) {
try { domain = new URL(tab.url).hostname; } catch {}
const faviconUrl = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=16` : '';
return `<div class="page-chip clickable${chipClass}" data-action="focus-tab" data-tab-url="${safeUrl}" title="${safeTitle}">
${faviconUrl ? `<img class="chip-favicon" src="${faviconUrl}" alt="" onerror="this.style.display='none'">` : ''}
${faviconUrl ? `<img class="chip-favicon hide-on-error" src="${faviconUrl}" alt="">` : ''}
<span class="chip-text">${label}</span>${dupeTag}
<div class="chip-actions">
<button class="chip-action chip-save" data-action="defer-single-tab" data-tab-url="${safeUrl}" data-tab-title="${safeTitle}" title="Save for later">
Expand Down Expand Up @@ -974,7 +1029,7 @@ function renderDeferredItem(item) {
<input type="checkbox" class="deferred-checkbox" data-action="check-deferred" data-deferred-id="${item.id}">
<div class="deferred-info">
<a href="${item.url}" target="_blank" rel="noopener" class="deferred-title" title="${(item.title || '').replace(/"/g, '&quot;')}">
<img src="${faviconUrl}" alt="" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px" onerror="this.style.display='none'">${item.title || item.url}
<img class="deferred-favicon hide-on-error" src="${faviconUrl}" alt="">${item.title || item.url}
</a>
<div class="deferred-meta">
<span>${domain}</span>
Expand Down Expand Up @@ -1004,6 +1059,89 @@ function renderArchiveItem(item) {
}


/* ----------------------------------------------------------------
BOOKMARKS BAR — Render Left Column
---------------------------------------------------------------- */

function countBookmarkLinks(nodes) {
return nodes.reduce((count, node) => {
if (node.url) return count + 1;
return count + countBookmarkLinks(node.children || []);
}, 0);
}

async function hydrateBookmarkFolders(nodes) {
return Promise.all(nodes.map(async node => {
if (node.url) return node;
try {
const children = await chrome.bookmarks.getChildren(node.id);
return {
...node,
children: await hydrateBookmarkFolders(children),
};
} catch {
return { ...node, children: [] };
}
}));
}

function renderBookmarkNode(node, depth = 0) {
const title = escapeHtml(node.title || node.url || 'Untitled');

if (node.url) {
let domain = '';
try { domain = new URL(node.url).hostname; } catch {}
const faviconUrl = domain ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=16` : '';
const safeUrl = escapeHtml(node.url);

return `
<a class="bookmark-item" href="${safeUrl}" target="_top" title="${title}">
${faviconUrl ? `<img class="bookmark-favicon hide-on-error" src="${faviconUrl}" alt="">` : '<span class="bookmark-favicon-placeholder"></span>'}
<span class="bookmark-title">${title}</span>
</a>`;
}

const children = node.children || [];
return `
<details class="bookmark-folder" ${depth < 1 ? 'open' : ''}>
<summary>
<svg class="bookmark-folder-icon" 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="M2.25 12.75V6.75A2.25 2.25 0 0 1 4.5 4.5h5.379c.596 0 1.168.237 1.591.659l1.061 1.061c.422.422.994.659 1.591.659H19.5a2.25 2.25 0 0 1 2.25 2.25v3.621M2.25 12.75v4.5A2.25 2.25 0 0 0 4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25v-4.5M2.25 12.75h19.5" /></svg>
<span class="bookmark-title">${title}</span>
<span class="bookmark-folder-count">${countBookmarkLinks(children)}</span>
</summary>
<div class="bookmark-folder-children">
${children.length ? children.map(child => renderBookmarkNode(child, depth + 1)).join('') : '<div class="bookmark-empty-folder">Empty</div>'}
</div>
</details>`;
}

async function renderBookmarksColumn() {
const column = document.getElementById('bookmarksColumn');
const list = document.getElementById('bookmarksList');
const empty = document.getElementById('bookmarksEmpty');
const countEl = document.getElementById('bookmarksCount');
if (!column || !list || !empty) return;

const result = await fetchBookmarksBar();
const barItems = await hydrateBookmarkFolders(result.items);
const linkCount = countBookmarkLinks(barItems);

if (linkCount === 0) {
column.style.display = 'none';
list.style.display = 'none';
empty.style.display = 'none';
return;
}

column.style.display = 'block';
if (countEl) countEl.textContent = `${linkCount} link${linkCount !== 1 ? 's' : ''}`;

empty.style.display = 'none';
list.style.display = 'block';
list.innerHTML = barItems.map(node => renderBookmarkNode(node)).join('');
}


/* ----------------------------------------------------------------
MAIN DASHBOARD RENDERER
---------------------------------------------------------------- */
Expand Down Expand Up @@ -1164,6 +1302,9 @@ async function renderStaticDashboard() {
// --- Check for duplicate Tab Out tabs ---
checkTabOutDupes();

// --- Render bookmarks bar column ---
await renderBookmarksColumn();

// --- Render "Saved for Later" column ---
await renderDeferredColumn();
}
Expand Down Expand Up @@ -1479,4 +1620,18 @@ document.addEventListener('input', async (e) => {
/* ----------------------------------------------------------------
INITIALIZE
---------------------------------------------------------------- */
renderDashboard();
document.addEventListener('error', e => {
if (e.target?.classList?.contains('hide-on-error')) {
e.target.style.display = 'none';
}
}, true);

loadOptionalConfig().then(renderDashboard);

if (chrome.bookmarks) {
chrome.bookmarks.onCreated.addListener(renderBookmarksColumn);
chrome.bookmarks.onRemoved.addListener(renderBookmarksColumn);
chrome.bookmarks.onChanged.addListener(renderBookmarksColumn);
chrome.bookmarks.onMoved.addListener(renderBookmarksColumn);
chrome.bookmarks.onChildrenReordered.addListener(renderBookmarksColumn);
}
43 changes: 30 additions & 13 deletions extension/icons/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified extension/icons/icon128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified extension/icons/icon16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified extension/icons/icon48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 17 additions & 4 deletions extension/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,28 @@ <h1 id="greeting"></h1>
</div>

<!-- ================================================================
MAIN CONTENT AREA — two-column layout
Left: open tabs (domain/mission cards)
MAIN CONTENT AREA — three-column layout
Left: bookmarks bar
Center: open tabs (domain/mission cards)
Right: saved for later checklist
The right column only renders when it has items (JS controls this)
================================================================ -->
<div class="dashboard-columns" id="dashboardColumns">

<!-- LEFT COLUMN: Open tabs -->
<!-- LEFT COLUMN: Bookmarks bar -->
<aside class="bookmarks-column" id="bookmarksColumn" style="display:none">
<div class="section-header">
<h2>Bookmarks bar</h2>
<div class="section-line"></div>
<div class="section-count" id="bookmarksCount"></div>
</div>
<div class="bookmarks-list" id="bookmarksList"></div>
<div class="bookmarks-empty" id="bookmarksEmpty" style="display:none">
No bookmarks on the bar.
</div>
</aside>

<!-- CENTER COLUMN: Open tabs -->
<div class="active-section" id="openTabsSection" style="display:none">
<div class="section-header">
<h2 id="openTabsSectionTitle">Right now</h2>
Expand Down Expand Up @@ -127,7 +141,6 @@ <h2>Saved for later</h2>

<!-- Personal config (gitignored) — defines LOCAL_LANDING_PAGE_PATTERNS etc. -->
<!-- If the file doesn't exist, that's fine — app.js uses sensible defaults. -->
<script src="config.local.js" onerror="/* no personal config, that's fine */"></script>

<!-- Main dashboard logic — loads last so the DOM is ready -->
<script src="app.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Tab Out",
"version": "1.0.0",
"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"],
"permissions": ["tabs", "activeTab", "storage", "bookmarks"],
"chrome_url_overrides": { "newtab": "index.html" },
"background": { "service_worker": "background.js" },
"action": {
Expand Down
Loading