Merge pull request #42 from Leexunhuan743/main

feat: 快捷链接/搜索引擎/popup 增强 + 审计修复批次
This commit is contained in:
OO
2026-08-20 23:31:38 +08:00
committed by GitHub
26 changed files with 6333 additions and 717 deletions
+2
View File
@@ -7,3 +7,5 @@ dist/*.zip
CLAUDE.local.md
CLAUDE.md
.codegraph/
lat.md/
.pi
+108
View File
@@ -55,3 +55,111 @@ This file captures project-level design and implementation constraints for agent
Detailed rationale and lessons learned live in:
- `docs/design-principles-and-lessons.md`
- `.impeccable.md`
%% lat:begin %%
# Before starting work
- Run `lat search` to find sections relevant to your task. Read them to understand the design intent before writing code.
- Run `lat expand` on user prompts to expand any `[[refs]]` — this resolves section names to file locations and provides context.
# Post-task checklist (REQUIRED — do not skip)
After EVERY task, before responding to the user:
- [ ] Update `lat.md/` if you added or changed any functionality, architecture, tests, or behavior
- [ ] Run `lat check` — all wiki links and code refs must pass
- [ ] Do not skip these steps. Do not consider your task done until both are complete.
---
# What is lat.md?
This project uses [lat.md](https://www.npmjs.com/package/lat.md) to maintain a structured knowledge graph of its architecture, design decisions, and test specs in the `lat.md/` directory. It is a set of cross-linked markdown files that describe **what** this project does and **why** — the domain concepts, key design decisions, business logic, and test specifications. Use it to ground your work in the actual architecture rather than guessing.
# Commands
```bash
lat locate "Section Name" # find a section by name (exact, fuzzy)
lat refs "file#Section" # find what references a section
lat search "natural language" # semantic search across all sections
lat expand "user prompt text" # expand [[refs]] to resolved locations
lat check # validate all links and code refs
```
Run `lat --help` when in doubt about available commands or options.
If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead.
# Syntax primer
- **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`.
- **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python/Rust/Go/C files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`, `[[src/lib.rs#Greeter#greet]]` (Rust impl method), `[[src/app.go#Greeter#Greet]]` (Go method), `[[src/app.h#Greeter]]` (C struct). `lat check` validates these exist.
- **Code refs**: `// @lat: [[section-id]]` (JS/TS/Rust/Go/C) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
# Test specs
Key tests can be described as sections in `lat.md/` files (e.g. `tests.md`). Add frontmatter to require that every leaf section is referenced by a `// @lat:` or `# @lat:` comment in test code:
```markdown
---
lat:
require-code-mention: true
---
# Tests
Authentication and authorization test specifications.
## User login
Verify credential validation and error handling for the login endpoint.
### Rejects expired tokens
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
### Handles missing password
Login request without a password field returns 400 with a descriptive error.
```
Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable. (This is a specific case of the general leading paragraph rule below.)
Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file:
```python
# @lat: [[tests#User login#Rejects expired tokens]]
def test_rejects_expired_tokens():
...
# @lat: [[tests#User login#Handles missing password]]
def test_handles_missing_password():
...
```
Do not duplicate refs. One `@lat:` comment per spec section, placed at the test that covers it. `lat check` will flag any spec section not covered by a code reference, and any code reference pointing to a nonexistent section.
# Section structure
Every section in `lat.md/` **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content. The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph serves as the section's overview and is used in search results, command output, and RAG context — keeping it concise guarantees the section's essence is always captured.
```markdown
# Good Section
Brief overview of what this section documents and why it matters.
More detail can go in subsequent paragraphs, code blocks, or lists.
## Child heading
Details about this child topic.
```
```markdown
# Bad Section
## Child heading
Details about this child topic.
```
The second example is invalid because `Bad Section` has no leading paragraph. `lat check` validates this rule and reports errors for missing or overly long leading paragraphs.
%% lat:end %%
+1 -1
View File
@@ -51,7 +51,7 @@ Tab Harbor also works as a tiny action layer: jot down todos, keep short descrip
### Theme switching
When you want the page to feel more like your own workspace, you can **switch themes, tune transparency, adjust text and shortcut size, and use a custom background image**.
When you want the page to feel more like your own workspace, you can **switch themes, tune transparency, adjust text and shortcut size, and use a custom background image**. In **Desk settings → Features** you can choose the **search engine** for the search bar (browser default, Google, Bing, Baidu, Sogou, DuckDuckGo, Brave, Yandex, or a custom URL), and whether clicking a quick link **on the new-tab page** opens it in a **new tab** or in the **current tab**; in **Desk settings → Appearance** you can fix quick links to **4 or 5 columns per row** (portrait stays automatic) so every link keeps its place.
<table>
<tr>
+1 -1
View File
@@ -52,7 +52,7 @@ Tab Harbor 会把标签页整理成更像工作区的结构:**按域名分组
### 切换主题
想让它更像你自己的工作台时,可以**切换主题、调透明度、调整文字和快捷链接大小、换背景图**。
想让它更像你自己的工作台时,可以**切换主题、调透明度、调整文字和快捷链接大小、换背景图**。在**桌面设置 → 功能**里,你可以选择搜索框使用的**搜索引擎**(浏览器默认、Google、Bing、Baidu、Sogou、DuckDuckGo、Brave、Yandex 或自定义 URL),也可以选择**在新标签页上**点击快捷链接时是**在新标签页打开**还是在**当前标签页打开**;在**桌面设置 → 外观**里可以把快捷链接固定为每行 **4 列或 5 列**(竖屏保持自动),让每个链接的位置稳定不变。
<table>
<tr>
+19
View File
@@ -38,6 +38,7 @@ function buildChrome() {
onCreated: { addListener: (cb) => { captured.onCreated = cb; } },
onRemoved: { addListener: (cb) => { captured.onRemoved = cb; } },
onUpdated: { addListener: (cb) => { captured.onUpdated = cb; } },
onReplaced: { addListener: (cb) => { captured.onReplaced = cb; } },
},
storage: {
local: { get: async () => ({}) },
@@ -109,3 +110,21 @@ test('non-connection errors from sendMessage are surfaced via console.warn', asy
assert.strictEqual(state.warnCalls.length, 1);
assert.match(state.warnCalls[0].join(' '), /unexpected broadcast failure/);
});
test('tab replacement broadcasts via chrome.runtime.sendMessage', async () => {
const { chrome, captured, state } = buildChrome();
chrome.runtime.sendMessage = async (msg) => {
state.sendMessageCalls.push(msg);
};
loadBackground(chrome);
assert.ok(captured.onReplaced, 'background should register a tabs.onReplaced listener');
await captured.onReplaced(123);
assert.strictEqual(state.sendMessageCalls.length, 1);
assert.deepStrictEqual(state.sendMessageCalls[0], {
action: 'tabs-changed',
source: 'tabs.onReplaced',
triggerTabId: 123,
});
});
+30
View File
@@ -13,6 +13,13 @@ if (TAB_HARBOR_BG_DEBUG)
// ─── Auto-close duplicate new tabs ───────────────────────────────────────────
// Tabs created within this window are exempt from duplicate-blank-tab cleanup:
// session restore creates many tabs in a burst and their navigation has not
// committed yet (url is empty), which would otherwise look like a pile of
// accidentally-opened blank new-tab pages and get closed.
const NEW_TAB_GRACE_PERIOD_MS = 5000;
const createdRecentlyAt = new Map(); // tabId -> timestamp
function getNewTabUrls() {
return new Set([
chrome.runtime.getURL("index.html"),
@@ -21,6 +28,20 @@ function getNewTabUrls() {
}
function isNewTabBlank(tab, newTabUrls) {
// A discarded (sleeping) tab is never an accidentally-opened blank new-tab
// page: session restore creates tabs and discards them so they start asleep,
// and a discarded tab may carry an empty/uncommitted url.
if (tab?.discarded) return false;
// Freshly-created tabs may not have committed their navigation yet (empty
// url). Give a restore batch (or any burst of new tabs) a grace period so
// the cleanup does not close tabs that are about to navigate to a real page.
if (tab?.id != null) {
const createdAt = createdRecentlyAt.get(tab.id);
if (createdAt != null && Date.now() - createdAt < NEW_TAB_GRACE_PERIOD_MS) {
return false;
}
if (createdAt != null) createdRecentlyAt.delete(tab.id);
}
const knownNewTabUrls =
newTabUrls instanceof Set
? newTabUrls
@@ -112,6 +133,7 @@ chrome.runtime.onStartup.addListener(() => {
// Update badge and notify Tab Harbor pages whenever a tab is opened
chrome.tabs.onCreated.addListener((tab) => {
if (tab?.id != null) createdRecentlyAt.set(tab.id, Date.now());
updateBadge();
notifyTabHarborPages({ source: "tabs.onCreated", triggerTabId: tab?.id });
closeDuplicateNewTabs();
@@ -129,6 +151,14 @@ chrome.tabs.onUpdated.addListener((tabId) => {
notifyTabHarborPages({ source: "tabs.onUpdated", triggerTabId: tabId });
});
// A tab can be replaced with a different tab id (OAuth/redirect flows,
// prerendering). Without this, pages keep chips for tab ids that no longer
// exist, and actions on those stale chips corrupt grouping state.
chrome.tabs.onReplaced.addListener((addedTabId) => {
updateBadge();
notifyTabHarborPages({ source: "tabs.onReplaced", triggerTabId: addedTabId });
});
// ─── Initial run ─────────────────────────────────────────────────────────────
// Run once immediately when the service worker first loads
+57 -1
View File
@@ -33,13 +33,21 @@ globalThis.chrome = {
tabs: {
query: async () => [],
remove: async ids => { removedTabIds = removedTabIds.concat(ids); },
onCreated: { addListener: () => {} },
onCreated: { addListener: (fn) => { globalThis.__tabHarborOnCreated = fn; } },
onRemoved: { addListener: () => {} },
onUpdated: { addListener: () => {} },
onReplaced: { addListener: () => {} },
},
action: {
setBadgeText: async () => {},
},
runtime: {
id: 'test-extension-id',
getURL: path => `chrome-extension://test-extension-id/${path}`,
onInstalled: { addListener: () => {} },
onStartup: { addListener: () => {} },
sendMessage: async () => {},
},
};
require('./background.js');
@@ -195,3 +203,51 @@ test('closeDuplicateNewTabs preserves restored tabs while their final URLs are p
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, []);
});
test('isNewTabBlank never matches a discarded (sleeping) tab even with an empty url', () => {
// Session restore discards freshly-created tabs so they start asleep; a
// discarded tab may carry an empty/uncommitted url. It must not be treated
// as an accidentally-opened blank new-tab page.
assert.equal(isNewTabBlank({ url: '', discarded: true }, EXT_URL), false);
assert.equal(isNewTabBlank({ url: undefined, status: 'loading', discarded: true }, EXT_URL), false);
assert.equal(isNewTabBlank({ url: 'chrome://newtab/', discarded: true }, EXT_URL), false);
// Non-discarded blank tabs are still matched as before.
assert.equal(isNewTabBlank({ url: '', discarded: false }, EXT_URL), true);
});
test('closeDuplicateNewTabs never closes discarded (sleeping) tabs', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
// Restore-created tabs that were discarded right away: empty urls, asleep.
{ id: 2, url: '', discarded: true, active: false },
{ id: 3, url: '', discarded: true, active: false },
{ id: 4, url: undefined, status: 'loading', discarded: true, active: false },
];
await closeDuplicateNewTabs();
// Only the genuine blank new-tab page (id 1, kept active) is in play; the
// discarded tabs are never closed.
assert.deepEqual(removedTabIds, []);
});
test('closeDuplicateNewTabs exempts freshly-created tabs within the grace period', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
// Simulate the background's own onCreated bookkeeping: a restore batch just
// created tabs 2 and 3 whose navigation has not committed (empty url).
if (typeof globalThis.__tabHarborOnCreated === 'function') {
globalThis.__tabHarborOnCreated({ id: 2, url: '' });
globalThis.__tabHarborOnCreated({ id: 3, url: '' });
}
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
{ id: 2, url: '', active: false },
{ id: 3, url: '', active: false },
];
await closeDuplicateNewTabs();
// Freshly-created tabs are exempt (they may still be committing their
// navigation); only the genuine blank tab 1 remains and it is the active
// one, so nothing is closed.
assert.deepEqual(removedTabIds, []);
});
File diff suppressed because it is too large Load Diff
+292 -73
View File
@@ -10,6 +10,7 @@
let importMode = false;
let chromeEventMuteUntil = 0;
let chromeListenersAttached = false;
let chromeGroupsLastError = '';
const chromeGroupSubscribers = new Set();
const GROUP_COLORS = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange'];
@@ -19,7 +20,10 @@
}
function shouldIgnoreChromeEvent() {
return !cachedEnabled || Date.now() < chromeEventMuteUntil;
// Only the short echo-mute window suppresses notifications. Whether the
// sync PUSH is enabled is a separate concern — live card recognition
// listens to Chrome group events even when the push toggle is off.
return Date.now() < chromeEventMuteUntil;
}
function notifyChromeGroupSubscribers(event) {
@@ -31,22 +35,16 @@
}
}
function shouldNotifyChromeGroupUpdate(changeInfo) {
if (!changeInfo || typeof changeInfo !== 'object') return false;
const changedKeys = Object.keys(changeInfo);
if (changedKeys.length === 0) return false;
return changedKeys.some(key => key !== 'collapsed');
}
function attachChromeListeners() {
if (chromeListenersAttached || typeof chrome === 'undefined') return;
const eventBindings = [
[chrome.tabGroups?.onCreated, group => notifyChromeGroupSubscribers({ source: 'tabGroups.onCreated', group })],
[chrome.tabGroups?.onUpdated, (groupId, changeInfo) => {
// Collapsing or expanding a group should not trigger a full import cycle.
if (!shouldNotifyChromeGroupUpdate(changeInfo)) return;
notifyChromeGroupSubscribers({ source: 'tabGroups.onUpdated', groupId, changeInfo });
[chrome.tabGroups?.onUpdated, (group) => {
// Chrome passes the full updated TabGroup object (there is no separate
// changeInfo parameter). Collapse-only updates also arrive here; the
// dashboard side debounces the re-render, so a full notify is safe.
notifyChromeGroupSubscribers({ source: 'tabGroups.onUpdated', group });
}],
[chrome.tabGroups?.onRemoved, group => notifyChromeGroupSubscribers({ source: 'tabGroups.onRemoved', group })],
[chrome.tabs?.onAttached, (tabId, attachInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onAttached', tabId, attachInfo })],
@@ -86,12 +84,75 @@
}
}
// Session groups and the landing-pages card keep dedicated colors (blue /
// yellow); regular domain mirrors cycle through the shared palette. The
// assigned color is part of a mirror's title+color identity fingerprint
// (see loadPersistedChromeGroupMap), so the palette order must stay stable.
function assignGroupColor(groupKey, index) {
if (groupKey.startsWith('__session_group__:')) return 'blue';
if (groupKey === '__landing-pages__') return 'yellow';
return GROUP_COLORS[index % GROUP_COLORS.length];
}
/**
* currentMappingCandidates(groupKey, windowIdKey, matches)
*
* Returns the in-session mappings that are still among the ambiguous
* candidates (each as { windowId, id }). For per-window meta keys only that
* window's mapping counts; for legacy flat meta ('any' key) the in-session
* map is keyed by the real window id, so every window mapping of this group
* key is considered — the session may legitimately hold mirrors for the same
* group key in several windows (C4 follow-up).
*/
function currentMappingCandidates(groupKey, windowIdKey, matches) {
const windowMap = chromeGroupMap?.[groupKey];
if (!windowMap) return [];
if (windowIdKey !== 'any') {
const currentId = windowMap[windowIdKey];
const match = currentId != null ? matches.find(g => Number(g.id) === Number(currentId)) : null;
return match ? [{ windowId: match.windowId, id: match.id }] : [];
}
const kept = [];
for (const [windowIdStr, chromeGroupId] of Object.entries(windowMap)) {
if (chromeGroupId != null && matches.some(g => Number(g.id) === Number(chromeGroupId))) {
kept.push({ windowId: windowIdStr, id: chromeGroupId });
}
}
return kept;
}
/**
* isGroupIdentityFree(title, color, windowId, currentGroups)
*
* A mirror's identity is its window + title + color fingerprint. Returns
* true when no group in the given window already carries that fingerprint.
* Used before creating a new mirror so its identity can never stay
* ambiguous for loadPersistedChromeGroupMap (C4 follow-up).
*/
function isGroupIdentityFree(title, color, windowId, currentGroups) {
if (!Array.isArray(currentGroups)) return true;
return !currentGroups.some(g =>
Number(g.windowId) === Number(windowId) &&
g.title === title &&
g.color === color
);
}
/**
* pickUncollidingGroupColor(title, preferred, windowId, currentGroups)
*
* Returns the first palette color that keeps the mirror's fingerprint
* unique in the window; falls back to the preferred color when every
* palette color collides (extreme case).
*/
function pickUncollidingGroupColor(title, preferred, windowId, currentGroups) {
if (!Array.isArray(currentGroups)) return preferred;
for (const color of GROUP_COLORS) {
if (isGroupIdentityFree(title, color, windowId, currentGroups)) return color;
}
return preferred;
}
async function loadChromeTabGroupsSetting() {
try {
const stored = await chrome.storage.local.get(STORAGE_KEY);
@@ -111,15 +172,19 @@
async function persistChromeGroupMap() {
try {
// Save group metadata (title, color) instead of raw groupIds,
// since Chrome tab group IDs are only stable within a session.
// Save group metadata (title, color) per window instead of raw groupIds,
// since Chrome tab group IDs are only stable within a session. Keeping
// the window id in the key lets reload match within the SAME window, so a
// user-created group that happens to share title+color in another window
// is never misclassified as a dashboard mirror.
const meta = {};
for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) {
for (const chromeGroupId of Object.values(windowMap)) {
for (const [windowIdStr, chromeGroupId] of Object.entries(windowMap)) {
try {
const group = await chrome.tabGroups.get(chromeGroupId);
if (group && !meta[groupKey]) {
meta[groupKey] = { title: group.title, color: group.color };
if (group) {
if (!meta[groupKey]) meta[groupKey] = {};
meta[groupKey][windowIdStr] = { title: group.title, color: group.color };
}
} catch {}
}
@@ -137,15 +202,46 @@
const currentGroups = await chrome.tabGroups.query({});
const reconciled = {};
for (const [groupKey, info] of Object.entries(meta)) {
// Match by title + color — the values we control. After restart,
// Chrome assigns new groupIds, but our groups retain their title/color.
const match = currentGroups.find(g =>
g.title === info.title && g.color === info.color
);
if (match) {
if (!reconciled[groupKey]) reconciled[groupKey] = {};
reconciled[groupKey][match.windowId] = match.id;
for (const [groupKey, stored] of Object.entries(meta)) {
// Stored shape is { windowId: { title, color } }. Older snapshots may
// be flat { title, color } — fall back to matching any window for them.
const perWindow = stored && typeof stored === 'object' && !('title' in stored) && !('color' in stored)
? stored
: { any: stored };
for (const [windowIdKey, info] of Object.entries(perWindow)) {
if (!info || typeof info !== 'object') continue;
// Match by window + title + color — the values we control. After
// restart Chrome assigns new groupIds, but our groups retain their
// title/color within the same window.
const matches = currentGroups.filter(g =>
(windowIdKey === 'any' || Number(g.windowId) === Number(windowIdKey)) &&
g.title === info.title && g.color === info.color
);
// If more than one group has the same title+color (legacy flat
// snapshots across windows, or same-window duplicates after the
// per-window migration), auto-binding would be a coin toss that can
// mark a user group as a mirror. Prefer the mapping THIS session
// already established when it is still among the candidates — that
// group is the mirror we created/managed, so reusing it is not a
// guess. Otherwise skip and let the next persist (or a toggle off/on)
// rebuild a correct mapping (C4).
if (matches.length > 1) {
const currentMatches = currentMappingCandidates(groupKey, windowIdKey, matches);
if (currentMatches.length > 0) {
for (const cm of currentMatches) {
if (!reconciled[groupKey]) reconciled[groupKey] = {};
reconciled[groupKey][cm.windowId] = cm.id;
}
continue;
}
console.warn(`[tab-harbor] ambiguous chromeTabGroupsMeta for ${groupKey}; skipping auto-bind`);
continue;
}
const match = matches[0];
if (match) {
if (!reconciled[groupKey]) reconciled[groupKey] = {};
reconciled[groupKey][match.windowId] = match.id;
}
}
}
@@ -154,9 +250,15 @@
}
function isChromeApiAvailable() {
return typeof chrome !== 'undefined' &&
const available = typeof chrome !== 'undefined' &&
chrome.tabs && typeof chrome.tabs.group === 'function' &&
chrome.tabGroups && typeof chrome.tabGroups.update === 'function';
if (!available) chromeGroupsLastError = 'chrome.tabGroups API unavailable';
return available;
}
function getChromeGroupsLastError() {
return chromeGroupsLastError;
}
async function ungroupTabs(tabIds) {
@@ -169,7 +271,16 @@
}
async function reorderGroupedTabs(chromeGroupId, desiredTabIds, windowId) {
if (!chromeGroupId || !Array.isArray(desiredTabIds) || desiredTabIds.length <= 1) return;
if (!chromeGroupId || !Array.isArray(desiredTabIds) || desiredTabIds.length === 0) return;
// Callers may pass chip tokens (string ids) or raw tab ids — normalize to
// numbers so the strict comparisons and chrome.tabs.move receive numbers.
const desiredIds = desiredTabIds
.map(id => Number(id))
.filter(Number.isFinite);
if (desiredIds.length === 0) return;
const desiredSet = new Set(desiredIds.map(String));
let groupedTabs = [];
try {
@@ -181,50 +292,41 @@
if (!groupedTabs.length) return;
const currentTabs = groupedTabs
.filter(tab => desiredTabIds.includes(tab.id))
.filter(tab => desiredSet.has(String(tab.id)))
.sort((a, b) => a.index - b.index);
if (!currentTabs.length) return;
// Only move tabs that are actually in the target group. Callers may pass a
// superset (e.g. after a partial group failure); moving absent ids would
// drag ungrouped tabs into the group's strip area (C15).
const currentSet = new Set(currentTabs.map(tab => String(tab.id)));
const idsToMove = desiredIds.filter(id => currentSet.has(String(id)));
if (idsToMove.length <= 1) return;
const currentOrder = currentTabs.map(tab => tab.id);
if (currentOrder.length === desiredTabIds.length &&
currentOrder.every((tabId, index) => tabId === desiredTabIds[index])) {
if (idsToMove.length === currentOrder.length &&
currentOrder.every((tabId, index) => tabId === idsToMove[index])) {
return;
}
const baseIndex = Math.min(...currentTabs.map(tab => tab.index));
for (const [offset, tabId] of desiredTabIds.entries()) {
try {
await chrome.tabs.move(tabId, { windowId, index: baseIndex + offset });
} catch {}
}
}
async function reorderWindowTabsByDesiredOrder(windowId, desiredTabIds) {
if (!Number.isFinite(windowId) || !Array.isArray(desiredTabIds) || desiredTabIds.length <= 1) return;
let windowTabs = [];
try {
windowTabs = await chrome.tabs.query({ windowId });
} catch {
// Prefer the group's own window from the live query: callers pass a
// windowId that may come from a stale snapshot or a placeholder row. The
// live query result is authoritative for the group's real window.
const liveWindowId = currentTabs[0]?.windowId != null ? Number(currentTabs[0].windowId) : NaN;
const effectiveWindowId = Number.isFinite(liveWindowId) ? liveWindowId : Number(windowId);
if (!Number.isFinite(effectiveWindowId)) {
console.warn('[tab-harbor] reorderGroupedTabs: no usable windowId, skipping reorder');
return;
}
const relevantTabs = windowTabs
.filter(tab => desiredTabIds.includes(tab.id))
.sort((a, b) => a.index - b.index);
if (relevantTabs.length <= 1) return;
const currentOrder = relevantTabs.map(tab => tab.id);
if (currentOrder.length === desiredTabIds.length &&
currentOrder.every((tabId, index) => tabId === desiredTabIds[index])) {
return;
}
const baseIndex = Math.min(...relevantTabs.map(tab => tab.index));
for (const [offset, tabId] of desiredTabIds.entries()) {
for (const [offset, tabId] of idsToMove.entries()) {
try {
await chrome.tabs.move(tabId, { windowId, index: baseIndex + offset });
} catch {}
await chrome.tabs.move(tabId, { windowId: effectiveWindowId, index: baseIndex + offset });
} catch (err) {
// Do not swallow silently: a failed move means the panel order and the
// native group order diverge and the user cannot see why.
console.warn(`[tab-harbor] reorderGroupedTabs: move tab ${tabId} failed:`, err);
}
}
}
@@ -251,6 +353,74 @@
await persistChromeGroupMap();
}
function getManagedChromeGroupIds() {
const ids = new Set();
for (const windowMap of Object.values(chromeGroupMap)) {
for (const chromeGroupId of Object.values(windowMap)) {
if (chromeGroupId != null) ids.add(chromeGroupId);
}
}
return ids;
}
/**
* queryUserChromeGroups(windowId)
*
* Returns the native Chrome tab groups of the given window that the DASHBOARD
* does not manage (i.e. groups the user created in the browser, not the
* mirror groups this extension pushed). Each entry carries the group's live
* title/color, its tab ids and its strip position (min tab index) so the
* dashboard can render one card per group, ordered like the tab strip.
*/
async function queryUserChromeGroups(windowId) {
if (!isChromeApiAvailable()) return [];
let hadPartialFailure = false;
try {
// Let the API filter by window (windowId is always a real window id from
// getDashboardWindowIdForOpenTabs); fall back to an unfiltered query only
// for a defensive non-finite id.
const groups = Number.isFinite(Number(windowId))
? await chrome.tabGroups.query({ windowId: Number(windowId) })
: await chrome.tabGroups.query({});
const managed = getManagedChromeGroupIds();
const result = [];
for (const group of groups) {
if (managed.has(group.id)) continue;
let tabs = [];
try {
tabs = await chrome.tabs.query({ groupId: group.id });
} catch (err) {
// C6: a single group's tab query failing must not be silently
// treated as "this group is empty"; keep the diagnostic so the
// dashboard can distinguish API failure from genuinely no groups.
hadPartialFailure = true;
chromeGroupsLastError = err?.message || String(err || 'queryUserChromeGroups tabs.query failed');
console.warn(`[tab-harbor] queryUserChromeGroups: tabs.query failed for group ${group.id}:`, err);
continue;
}
if (!tabs.length) continue;
const positions = tabs.map(t => t.index).filter(Number.isFinite);
result.push({
id: group.id,
windowId: Number(group.windowId),
title: group.title || '',
color: group.color || 'grey',
collapsed: Boolean(group.collapsed),
minIndex: positions.length ? Math.min(...positions) : Number.MAX_SAFE_INTEGER,
tabIds: tabs.map(t => t.id).filter(id => id != null),
});
}
if (!hadPartialFailure) chromeGroupsLastError = '';
return result.sort((a, b) => a.minIndex - b.minIndex);
} catch (err) {
// Never silently pretend there are no user groups: keep a diagnostic and
// let the dashboard surface a visible failure state.
chromeGroupsLastError = err?.message || String(err || 'queryUserChromeGroups failed');
console.warn('[tab-harbor] queryUserChromeGroups failed:', err);
return [];
}
}
async function syncChromeTabGroups(domainGroups) {
muteChromeGroupEvents();
await loadPersistedChromeGroupMap();
@@ -263,22 +433,32 @@
if (!isChromeApiAvailable()) return;
// Build desired state: { groupKey: { windowId: [tabIds] } }
const managedGroupIds = getManagedChromeGroupIds();
const desired = {};
const desiredWindowOrders = {};
for (const group of domainGroups) {
const groupKey = group.domain;
// Manual groups stay dashboard-internal, and live Chrome-group cards are
// already native groups — neither is pushed to Chrome.
if (group.isManual || group.isChromeGroup) continue;
if (groupKey.startsWith('__session_group__:') || groupKey.startsWith('__chrome_group__:')) continue;
for (const tab of (group.tabs || [])) {
if (tab.id == null) continue;
// C7 safety net: tabs that the live snapshot reports as already inside
// an UNMANAGED native Chrome group must never be pushed into a
// dashboard mirror, even if queryUserChromeGroups failed and the card
// was not built. Tabs inside dashboard-managed mirror groups stay in
// desired so sync can continue managing those mirrors.
if (Number.isInteger(tab.groupId) && tab.groupId >= 0 && !managedGroupIds.has(tab.groupId)) continue;
const windowId = tab.windowId != null ? tab.windowId : 0;
if (!desired[groupKey]) desired[groupKey] = {};
if (!desired[groupKey][windowId]) desired[groupKey][windowId] = [];
desired[groupKey][windowId].push(tab.id);
if (!desiredWindowOrders[windowId]) desiredWindowOrders[windowId] = [];
desiredWindowOrders[windowId].push(tab.id);
}
}
// Collect current Chrome tab groups to check existence
// Collect current Chrome tab groups to check existence. Full-window query
// on purpose: this sync manages mirrors across every window represented in
// `desired` and also cleans stale mappings in other windows.
let currentGroups = [];
try {
currentGroups = await chrome.tabGroups.query({});
@@ -288,12 +468,21 @@
// Remove orphaned Chrome groups only for windows represented in this sync.
// Other windows may be managed by their own Tab Harbor new-tab page.
// Include the windows of MANUAL/Chrome-group cards too: their groups are
// skipped from `desired`, but a stale mirror mapping for those windows must
// still be cleaned up — otherwise a manual group that once had a mirror
// would keep its Chrome group forever.
const desiredWindowIds = new Set(
Object.values(desired)
.flatMap(windowMap => Object.keys(windowMap))
.map(windowId => Number(windowId))
.filter(Number.isFinite)
);
for (const group of domainGroups) {
for (const tab of (group.tabs || [])) {
if (tab?.windowId != null) desiredWindowIds.add(Number(tab.windowId));
}
}
for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) {
for (const [windowIdStr, chromeGroupId] of Object.entries(windowMap)) {
const windowId = Number(windowIdStr);
@@ -341,6 +530,16 @@
// In import mode, only reuse existing groups — don't create new ones
if (importMode) continue;
// C4 follow-up: creating a mirror whose title+color fingerprint
// already exists in this window (a user-created group, or the residue
// of an earlier ambiguous fingerprint) would keep the identity
// ambiguous and churn a new mirror on every load. Pick a
// non-colliding color so the new mirror gets a unique fingerprint.
let creationColor = groupColor;
if (!isGroupIdentityFree(title, groupColor, windowId, currentGroups)) {
creationColor = pickUncollidingGroupColor(title, groupColor, windowId, currentGroups);
}
// Create new group
try {
chromeGroupId = await chrome.tabs.group({ tabIds });
@@ -359,7 +558,7 @@
if (chromeGroupId != null) {
try {
await chrome.tabGroups.update(chromeGroupId, { title, color: groupColor, collapsed: true });
await chrome.tabGroups.update(chromeGroupId, { title, color: creationColor, collapsed: true });
} catch {}
}
} else {
@@ -381,9 +580,9 @@
}
}
for (const [windowIdStr, orderedTabIds] of Object.entries(desiredWindowOrders)) {
await reorderWindowTabsByDesiredOrder(Number(windowIdStr), orderedTabIds);
}
// The dashboard no longer reorders the whole window tab strip: card order
// follows Chrome's group strip order (see queryUserChromeGroups), so the
// window layout is left to the user.
await persistChromeGroupMap();
}
@@ -429,12 +628,17 @@
let groups = [];
try {
groups = await chrome.tabGroups.query({});
groups = await chrome.tabGroups.query({ windowId: targetWindowId });
} catch {
return;
}
const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId);
// Only dashboard-managed mirror groups are collapsed. User-created groups
// keep whatever collapsed state the user chose — this helper runs when the
// Tab Harbor new-tab page gains focus, and must not fight the user's own
// group layout.
const managed = getManagedChromeGroupIds();
const groupsInWindow = groups.filter(group => managed.has(group.id));
muteChromeGroupEvents();
for (const group of groupsInWindow) {
@@ -455,12 +659,17 @@
let groups = [];
try {
groups = await chrome.tabGroups.query({});
groups = await chrome.tabGroups.query({ windowId: targetWindowId });
} catch {
return;
}
const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId);
// Expand/collapse applies to dashboard-managed mirror groups only; the
// user's own groups keep their state. If the focused group itself is a
// user group, leave every group untouched.
const managed = getManagedChromeGroupIds();
if (!managed.has(targetGroupId)) return;
const groupsInWindow = groups.filter(group => managed.has(group.id));
muteChromeGroupEvents();
for (const group of groupsInWindow) {
@@ -498,6 +707,11 @@
resetChromeGroupState,
isChromeTabGroupsEnabled,
getChromeGroupCount,
getManagedChromeGroupIds,
queryUserChromeGroups,
getChromeGroupsLastError,
reorderGroupedTabs,
muteChromeGroupEvents,
populateChromeGroupMap,
queryExistingChromeGroups,
collapseChromeTabGroupsInWindow,
@@ -505,9 +719,14 @@
setImportMode,
isImportMode,
subscribeToChromeTabGroupChanges,
loadPersistedChromeGroupMap,
persistChromeGroupMap,
STORAGE_KEY,
assignGroupColor,
getGroupTitle,
isGroupIdentityFree,
pickUncollidingGroupColor,
currentMappingCandidates,
};
if (typeof module !== 'undefined' && module.exports) {
+555 -40
View File
@@ -33,6 +33,11 @@ globalThis.chrome = {
set: async (items) => {
Object.assign(mockStorage, items);
},
remove: async (keys) => {
for (const key of [].concat(keys)) {
delete mockStorage[key];
}
},
},
},
tabs: {
@@ -69,14 +74,36 @@ const {
resetChromeGroupState,
isChromeTabGroupsEnabled,
getChromeGroupCount,
getManagedChromeGroupIds,
queryUserChromeGroups,
getChromeGroupsLastError,
populateChromeGroupMap,
queryExistingChromeGroups,
setImportMode,
subscribeToChromeTabGroupChanges,
loadPersistedChromeGroupMap,
persistChromeGroupMap,
reorderGroupedTabs,
assignGroupColor,
getGroupTitle,
isGroupIdentityFree,
pickUncollidingGroupColor,
currentMappingCandidates,
} = globalThis.TabOutChromeTabGroups;
// Stub chrome.tabGroups.query with REAL semantics: when queryInfo.windowId is
// given, only groups of that window are returned (the implementation relies on
// this filtering after the windowId-parameter migration).
function stubTabGroupsQuery(groups) {
globalThis.chrome.tabGroups.query = async (opts) => {
let all = typeof groups === 'function' ? groups() : groups;
if (opts && opts.windowId != null) {
all = all.filter(g => Number(g.windowId) === Number(opts.windowId));
}
return all;
};
}
test('assignGroupColor returns blue for session groups', () => {
assert.equal(assignGroupColor('__session_group__:g1', 0), 'blue');
assert.equal(assignGroupColor('__session_group__:g2', 5), 'blue');
@@ -184,8 +211,11 @@ test('syncChromeTabGroups creates groups when enabled', async () => {
assert.equal(updateCallArgs.length, 2);
assert.equal(updateCallArgs[0].collapsed, true);
assert.equal(updateCallArgs[1].collapsed, true);
// Mirrors rotate through the palette (grey, then red) — the order is part
// of the title+color fingerprint, so it is asserted as a behavior anchor.
assert.equal(updateCallArgs[0].color, 'grey');
assert.equal(updateCallArgs[1].color, 'red');
// getChromeGroupCount counts distinct group keys, not per-window mappings.
assert.equal(getChromeGroupCount(), 2);
});
@@ -220,6 +250,41 @@ test('syncChromeTabGroups handles tabs in different windows', async () => {
assert.equal(groupCallCount, 2);
});
test('syncChromeTabGroups skips unmanaged user-group tabs but keeps managed mirror tabs', async () => {
resetChromeGroupState();
const groupCalls = [];
globalThis.chrome.tabs.group = async (opts) => {
groupCalls.push(opts);
return 900 + groupCalls.length;
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [{ id: 101, title: 'GitHub', color: 'grey' }];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
]);
const groups = [
{
domain: 'github.com',
tabs: [
// Managed mirror tab: must still be included in the mirror sync.
{ id: 1, windowId: 1, url: 'https://github.com', groupId: 101 },
// Unmanaged user-created group tab: must never be pulled into the mirror.
{ id: 2, windowId: 1, url: 'https://github.com/2', groupId: 202 },
],
},
];
await syncChromeTabGroups(groups);
assert.equal(groupCalls.length, 1);
assert.deepEqual(groupCalls[0].tabIds, [1]);
});
test('syncChromeTabGroups reorders tabs inside a chrome group to match desired order', async () => {
resetChromeGroupState();
const moveCalls = [];
@@ -258,7 +323,225 @@ test('syncChromeTabGroups reorders tabs inside a chrome group to match desired o
]);
});
test('syncChromeTabGroups reorders grouped tabs across groups to match desired group order in a window', async () => {
test('reorderGroupedTabs prefers the live group window over a stale caller windowId (C5)', async () => {
resetChromeGroupState();
const moveCalls = [];
globalThis.chrome.tabs.move = async (tabId, opts) => {
moveCalls.push({ tabId, ...opts });
};
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 901) {
return [
{ id: 2, groupId: 901, windowId: 7, index: 3 },
{ id: 1, groupId: 901, windowId: 7, index: 4 },
];
}
return [];
};
// Caller passes a WRONG windowId (stale snapshot/placeholder); the reorder
// must use the window the tabs actually live in (7), not the caller's 1.
await reorderGroupedTabs(901, [1, 2], 1);
assert.deepEqual(moveCalls, [
{ tabId: 1, windowId: 7, index: 3 },
{ tabId: 2, windowId: 7, index: 4 },
]);
});
test('reorderGroupedTabs applies a full desired order at the group start (mid-card drop)', async () => {
resetChromeGroupState();
const moveCalls = [];
globalThis.chrome.tabs.move = async (tabId, opts) => {
moveCalls.push({ tabId, ...opts });
};
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 901) {
// Existing tabs at 0/1; the dropped batch was appended at 4 by
// chrome.tabs.group. The caller passes the FULL panel order.
return [
{ id: 1, groupId: 901, windowId: 7, index: 0 },
{ id: 2, groupId: 901, windowId: 7, index: 1 },
{ id: 3, groupId: 901, windowId: 7, index: 4 },
];
}
return [];
};
await reorderGroupedTabs(901, [3, 1, 2], 999);
assert.deepEqual(moveCalls, [
{ tabId: 3, windowId: 7, index: 0 },
{ tabId: 1, windowId: 7, index: 1 },
{ tabId: 2, windowId: 7, index: 2 },
]);
});
test('reorderGroupedTabs only moves tabs that are actually in the group (C15 superset)', async () => {
resetChromeGroupState();
const moveCalls = [];
globalThis.chrome.tabs.move = async (tabId, opts) => {
moveCalls.push({ tabId, ...opts });
};
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 901) {
return [
{ id: 1, groupId: 901, windowId: 7, index: 0 },
{ id: 2, groupId: 901, windowId: 7, index: 1 },
];
}
return [];
};
// desired includes id 3, which is NOT in the group (a caller may pass a
// superset after a partial failure). Only ids actually in the group may be
// moved — dragging an absent id would pull an ungrouped tab into the strip.
await reorderGroupedTabs(901, ['2', '1', '3'], 7);
assert.deepEqual(moveCalls, [
{ tabId: 2, windowId: 7, index: 0 },
{ tabId: 1, windowId: 7, index: 1 },
]);
});
test('loadPersistedChromeGroupMap keeps the current mapping among ambiguous candidates (C4)', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
// The mirror mapping established this session points at 901 — the NON-first
// candidate. A blind first-match bind would pick 900 (the user's group); the
// ambiguous-binding preference must keep 901.
await populateChromeGroupMap([{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 901 }]);
// Seed persisted meta as if an earlier sync stored the mirror fingerprint.
await chrome.storage.local.set({
chromeTabGroupsMeta: { 'github.com': { '1': { title: 'Github', color: 'grey' } } },
});
// Two groups share the fingerprint: 900 (user's group) and 901 (our mirror).
globalThis.chrome.tabGroups.query = async () => [
{ id: 900, windowId: 1, title: 'Github', color: 'grey' },
{ id: 901, windowId: 1, title: 'Github', color: 'grey' },
];
await loadPersistedChromeGroupMap();
const managed = getManagedChromeGroupIds();
assert.ok(managed.has(901), 'the current mirror mapping is kept (not the first-match guess)');
assert.ok(!managed.has(900), 'the user group is not hijacked');
delete globalThis.chrome.tabGroups.query;
});
test('syncChromeTabGroups avoids a colliding title+color when creating a new mirror (C4)', async () => {
resetChromeGroupState();
const updates = [];
globalThis.friendlyDomain = () => 'Github';
globalThis.chrome.tabs.group = async (opts) => opts.groupId ?? 902;
globalThis.chrome.tabs.query = async (opts) => opts?.groupId === 902 ? [] : [];
// A user-created group already holds the mirror's would-be fingerprint.
globalThis.chrome.tabGroups.query = async () => [
{ id: 101, windowId: 1, title: 'Github', color: 'grey' },
];
globalThis.chrome.tabGroups.update = async (groupId, props) => updates.push({ groupId, props });
await saveChromeTabGroupsSetting(true);
const groups = [{
domain: 'github.com',
tabs: [{ id: 1, windowId: 1, url: 'https://github.com', groupId: -1 }],
}];
await syncChromeTabGroups(groups);
// The new mirror must not reuse the colliding grey identity, otherwise the
// title+color fingerprint stays ambiguous and churns on every load.
const mirrorUpdate = updates.find(u => u.groupId === 902);
assert.ok(mirrorUpdate, 'a new mirror group was created');
assert.notEqual(mirrorUpdate.props.color, 'grey');
delete globalThis.friendlyDomain;
delete globalThis.chrome.tabs.group;
delete globalThis.chrome.tabs.query;
delete globalThis.chrome.tabGroups.query;
delete globalThis.chrome.tabGroups.update;
});
test('isGroupIdentityFree detects title+color collisions per window (C4)', () => {
const groups = [
{ id: 1, windowId: 5, title: 'GitHub', color: 'grey' },
{ id: 2, windowId: 5, title: 'GitHub', color: 'red' },
{ id: 3, windowId: 6, title: 'GitHub', color: 'grey' },
];
// Same window + same title+color → not free.
assert.equal(isGroupIdentityFree('GitHub', 'grey', 5, groups), false);
// Same window but different color → free.
assert.equal(isGroupIdentityFree('GitHub', 'blue', 5, groups), true);
// Same title+color in a window that itself holds such a group → not free.
assert.equal(isGroupIdentityFree('GitHub', 'grey', 6, groups), false);
// Same title+color in a window with no such group → free.
assert.equal(isGroupIdentityFree('GitHub', 'grey', 7, groups), true);
// No live groups → free.
assert.equal(isGroupIdentityFree('X', 'grey', 1, []), true);
assert.equal(isGroupIdentityFree('X', 'grey', 1, null), true);
});
test('pickUncollidingGroupColor returns the preferred color when free, else rotates (C4)', () => {
const colliding = (color) => [{ id: 1, windowId: 5, title: 'GitHub', color }];
// Preferred grey is free → stays grey.
assert.equal(pickUncollidingGroupColor('GitHub', 'grey', 5, colliding('red')), 'grey');
// Grey is taken → the first free palette color (red) is chosen.
assert.equal(pickUncollidingGroupColor('GitHub', 'grey', 5, colliding('grey')), 'red');
// Every palette color is taken → falls back to the preferred color.
const allTaken = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange']
.map((color, i) => ({ id: i + 1, windowId: 5, title: 'GitHub', color }));
assert.equal(pickUncollidingGroupColor('GitHub', 'grey', 5, allTaken), 'grey');
});
test('currentMappingCandidates prefers the in-session mapping per window key (C4)', async () => {
resetChromeGroupState();
await populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 },
{ virtualGroupKey: 'github.com', windowId: 2, chromeGroupId: 502 },
]);
const matches = [
{ id: 500, windowId: 1, title: 'Github', color: 'grey' },
{ id: 501, windowId: 1, title: 'Github', color: 'grey' },
{ id: 502, windowId: 2, title: 'Github', color: 'grey' },
];
// Per-window key: only that window's mapping counts.
assert.deepEqual(currentMappingCandidates('github.com', '1', matches), [{ windowId: 1, id: 501 }]);
// Legacy 'any' key: every in-session window mapping among the candidates.
// Note: per-window returns the live group's numeric windowId, while 'any'
// returns the map's string window key — both are valid reconciled keys.
assert.deepEqual(
currentMappingCandidates('github.com', 'any', matches).sort((a, b) => a.windowId - b.windowId),
[{ windowId: '1', id: 501 }, { windowId: '2', id: 502 }]
);
// No in-session mapping → nothing kept.
assert.deepEqual(currentMappingCandidates('other.com', 'any', matches), []);
assert.deepEqual(currentMappingCandidates('other.com', '1', matches), []);
});
test('legacy flat meta keeps the in-session mapping among ambiguous candidates (C4)', async () => {
resetChromeGroupState();
// A session mapping in window 1; the legacy flat snapshot (no window id)
// is seeded AFTER the mapping's persist so it cannot be clobbered.
await populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 },
]);
await chrome.storage.local.set({
chromeTabGroupsMeta: { 'github.com': { title: 'Github', color: 'grey' } },
});
// Two windows share the fingerprint — ambiguous for the flat key. The
// in-session window-1 mapping (501) must still be retained, not skipped.
globalThis.chrome.tabGroups.query = async () => [
{ id: 501, windowId: 1, title: 'Github', color: 'grey' },
{ id: 502, windowId: 2, title: 'Github', color: 'grey' },
];
await loadPersistedChromeGroupMap();
const managed = getManagedChromeGroupIds();
assert.ok(managed.has(501), 'the in-session mapping is kept for legacy flat meta');
assert.ok(!managed.has(502), 'the other window group is not hijacked');
delete globalThis.chrome.tabGroups.query;
});
test('syncChromeTabGroups no longer reorders the window tab strip (card order follows Chrome)', async () => {
resetChromeGroupState();
const moveCalls = [];
@@ -273,14 +556,10 @@ test('syncChromeTabGroups reorders grouped tabs across groups to match desired g
];
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 501) {
return [
{ id: 11, groupId: 501, windowId: 1, index: 8 },
];
return [{ id: 11, groupId: 501, windowId: 1, index: 8 }];
}
if (opts?.groupId === 502) {
return [
{ id: 22, groupId: 502, windowId: 1, index: 5 },
];
return [{ id: 22, groupId: 502, windowId: 1, index: 5 }];
}
if (opts?.windowId === 1) {
return [
@@ -302,13 +581,9 @@ test('syncChromeTabGroups reorders grouped tabs across groups to match desired g
{ domain: 'bilibili.com', tabs: [{ id: 22, windowId: 1, url: 'https://bilibili.com' }] },
]);
assert.deepEqual(
moveCalls.slice(-2),
[
{ tabId: 11, windowId: 1, index: 5 },
{ tabId: 22, windowId: 1, index: 6 },
]
);
// Decision: card order follows Chrome's group strip order — the dashboard
// never forces a window-wide tab reorder anymore.
assert.equal(moveCalls.length, 0);
});
test('syncChromeTabGroups cleans up when disabled after being enabled', async () => {
@@ -373,6 +648,82 @@ test('queryExistingChromeGroups returns groups from chrome.tabGroups.query', asy
assert.equal(groups[0].title, 'Work');
});
test('getManagedChromeGroupIds reports only dashboard-managed mirror groups', async () => {
resetChromeGroupState();
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
]);
const managed = getManagedChromeGroupIds();
assert.ok(managed.has(101));
assert.ok(!managed.has(999));
});
test('queryUserChromeGroups returns unmanaged groups with strip positions, sorted', async () => {
resetChromeGroupState();
// 101 is dashboard-managed; 202 and 303 were created by the user in Chrome.
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
]);
stubTabGroupsQuery([
{ id: 101, title: 'GitHub', color: 'grey', windowId: 1 },
{ id: 202, title: 'Work', color: 'blue', windowId: 1 },
{ id: 303, title: 'Research', color: 'red', windowId: 1 },
{ id: 404, title: 'Other window', color: 'green', windowId: 2 },
]);
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 101) return [{ id: 10, index: 0 }];
if (opts?.groupId === 202) return [{ id: 21, index: 4 }, { id: 22, index: 5 }];
if (opts?.groupId === 303) return [{ id: 31, index: 2 }];
if (opts?.groupId === 404) return [{ id: 41, index: 0 }];
return [];
};
const groups = await queryUserChromeGroups(1);
// Managed group 101 and other-window group 404 are excluded; order by minIndex.
assert.deepEqual(groups.map(g => g.id), [303, 202]);
assert.equal(groups[0].title, 'Research');
assert.equal(groups[1].minIndex, 4);
assert.deepEqual(groups[1].tabIds, [21, 22]);
});
test('queryUserChromeGroups records failures so the dashboard can distinguish empty from error', async () => {
resetChromeGroupState();
globalThis.chrome.tabGroups.query = async () => { throw new Error('denied'); };
const groups = await queryUserChromeGroups(1);
assert.deepEqual(groups, []);
assert.ok(getChromeGroupsLastError().length > 0);
globalThis.chrome.tabGroups.query = async () => [];
await queryUserChromeGroups(1);
assert.equal(getChromeGroupsLastError(), '');
});
test('queryUserChromeGroups keeps other groups when one group query partially fails (C6)', async () => {
resetChromeGroupState();
// 202's tab query rejects transiently; 303 succeeds. The partial failure
// must not drag the whole result down to "no groups", nor be cleared like a
// clean success — the diagnostic stays so the dashboard can tell part-failed
// from "really no groups".
stubTabGroupsQuery([
{ id: 202, title: 'Work', color: 'blue', windowId: 1 },
{ id: 303, title: 'Research', color: 'red', windowId: 1 },
]);
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 202) throw new Error('transient query failure');
if (opts?.groupId === 303) return [{ id: 31, index: 2 }];
return [];
};
const groups = await queryUserChromeGroups(1);
// The healthy group survives in the result.
assert.deepEqual(groups.map(g => g.id), [303]);
// The partial failure is NOT cleared like a clean success.
assert.ok(getChromeGroupsLastError().length > 0);
delete globalThis.chrome.tabGroups.query;
delete globalThis.chrome.tabs.query;
});
test('syncChromeTabGroups reuses chromeGroupMap populated by populateChromeGroupMap', async () => {
resetChromeGroupState();
let lastGroupCall = null;
@@ -438,7 +789,7 @@ test('syncChromeTabGroups only removes obsolete mappings in synced windows', asy
assert.equal(getChromeGroupCount(), 2);
});
test('syncChromeTabGroups in import mode skips creating new groups for ungrouped tabs', async () => {
test('syncChromeTabGroups skips manual groups; stale manual mirrors are cleaned up', async () => {
resetChromeGroupState();
let createCalls = 0;
let reuseCalls = 0;
@@ -457,32 +808,151 @@ test('syncChromeTabGroups in import mode skips creating new groups for ungrouped
await saveChromeTabGroupsSetting(true);
// Simulate import: populate chromeGroupMap with existing Chrome groups
// A legacy managed mapping for a manual group: it must NOT be reused — manual
// groups are dashboard-internal and never create Chrome groups anymore.
populateChromeGroupMap([
{ virtualGroupKey: '__session_group__:g1', windowId: 1, chromeGroupId: 500 },
]);
// Enable import mode: only reuse, don't create
setImportMode(true);
const groups = [
{ domain: '__session_group__:g1', label: 'Work', tabs: [{ id: 1, windowId: 1, url: 'https://a.com' }] },
{ domain: '__session_group__:g1', isManual: true, label: 'Work', tabs: [{ id: 1, windowId: 1, url: 'https://a.com' }] },
// The flag branch is what actually keeps this group internal: its domain
// does NOT carry the __session_group__: prefix, so removing the
// isManual/isChromeGroup guard would wrongly push it to Chrome.
{ domain: 'work-manual', isManual: true, label: 'Manual', tabs: [{ id: 2, windowId: 1, url: 'https://b.com' }] },
{ domain: 'github.com', tabs: [{ id: 5, windowId: 1, url: 'https://github.com' }] },
];
await syncChromeTabGroups(groups);
// Work group reused existing Chrome group
assert.equal(reuseCalls, 1);
// github.com was SKIPPED (import mode, no matching Chrome group)
assert.equal(createCalls, 0);
// Disable import mode and sync again — now github.com should get a new group
setImportMode(false);
await syncChromeTabGroups(groups);
// The manual groups were skipped entirely — no reuse of the old Chrome group.
assert.equal(reuseCalls, 0);
// Domain cards still get their mirror groups (one create for github.com).
assert.equal(createCalls, 1);
});
test('stale manual-group mirror is ungrouped when its window appears in the sync', async () => {
resetChromeGroupState();
const ungrouped = [];
let createCalls = 0;
globalThis.chrome.tabs.group = async (opts) => {
if (opts.groupId != null) return opts.groupId;
createCalls++;
return 600 + createCalls;
};
globalThis.chrome.tabs.ungroup = async (tabIds) => ungrouped.push(...tabIds);
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [{ id: 500, title: 'Work', color: 'blue', windowId: 2 }];
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 500) return [{ id: 1, windowId: 2 }];
return [];
};
await saveChromeTabGroupsSetting(true);
// Legacy mapping for a manual group that is no longer pushed. The manual
// group's tabs live in window 2 while the only domain mirror in this sync is
// in window 1 — so the cleanup loop that adds manual/Chrome-card windows to
// desiredWindowIds is the only thing that can reach the stale mirror (C9).
populateChromeGroupMap([
{ virtualGroupKey: '__session_group__:g1', windowId: 2, chromeGroupId: 500 },
]);
await syncChromeTabGroups([
{ domain: '__session_group__:g1', label: 'Work', tabs: [{ id: 1, windowId: 2, url: 'https://a.com' }] },
{ domain: 'github.com', tabs: [{ id: 5, windowId: 1, url: 'https://github.com' }] },
]);
assert.deepEqual(ungrouped, [1]);
assert.equal(getChromeGroupCount(), 1); // only the github.com mirror remains
});
test('persisted group meta reloads per window, so a same-title same-color user group is never mistaken for a mirror', async () => {
resetChromeGroupState();
// Mirror for github.com lives in window 1; a USER group with identical
// title+color exists in window 2. Reload must only re-bind the window-1
// group, never the user's window-2 group (C10).
globalThis.chrome.tabGroups.query = async () => [
// User group first on purpose: if the implementation ignored the window
// id and just took the first title+color match, it would bind 502 instead
// of the real mirror 501 (C10).
{ id: 502, title: 'GitHub', color: 'grey', windowId: 2 },
{ id: 501, title: 'GitHub', color: 'grey', windowId: 1 },
];
globalThis.chrome.tabGroups.get = async (id) => ({
id,
title: id === 501 ? 'GitHub' : 'GitHub',
color: id === 501 ? 'grey' : 'grey',
});
await saveChromeTabGroupsSetting(true);
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 },
]);
await persistChromeGroupMap();
// Simulate restart: fresh map, same live groups. In real Chrome the
// persisted meta survives a restart; resetChromeGroupState clears storage,
// so re-seed the same meta the persist step wrote above.
resetChromeGroupState();
await loadChromeTabGroupsSetting();
await chrome.storage.local.set({
chromeTabGroupsMeta: { 'github.com': { '1': { title: 'GitHub', color: 'grey' } } },
});
await loadPersistedChromeGroupMap();
const managed = getManagedChromeGroupIds();
assert.ok(managed.has(501));
assert.ok(!managed.has(502));
});
test('persisted group meta falls back to any-window match for legacy flat snapshots', async () => {
resetChromeGroupState();
globalThis.chrome.tabGroups.query = async () => [
{ id: 501, title: 'GitHub', color: 'grey', windowId: 1 },
];
globalThis.chrome.tabGroups.get = async () => ({ id: 501, title: 'GitHub', color: 'grey' });
// Old flat shape { title, color } — must still reconcile.
globalThis.chrome.storage.local.set({
chromeTabGroupsMeta: { 'github.com': { title: 'GitHub', color: 'grey' } },
});
await saveChromeTabGroupsSetting(true);
await loadChromeTabGroupsSetting();
await loadPersistedChromeGroupMap();
const managed = getManagedChromeGroupIds();
assert.ok(managed.has(501));
});
test('legacy flat meta skips auto-bind when multiple windows share title+color', async () => {
resetChromeGroupState();
// Two windows contain a same-title+color group. The legacy flat snapshot has
// no window id, so auto-binding would be a coin toss that can mark the wrong
// (user) group as a dashboard mirror — skip it instead.
globalThis.chrome.tabGroups.query = async () => [
{ id: 501, title: 'GitHub', color: 'grey', windowId: 1 },
{ id: 502, title: 'GitHub', color: 'grey', windowId: 2 },
];
globalThis.chrome.tabGroups.get = async (id) => ({ id, title: 'GitHub', color: 'grey' });
globalThis.chrome.storage.local.set({
chromeTabGroupsMeta: { 'github.com': { title: 'GitHub', color: 'grey' } },
});
await saveChromeTabGroupsSetting(true);
await loadChromeTabGroupsSetting();
await loadPersistedChromeGroupMap();
const managed = getManagedChromeGroupIds();
assert.equal(managed.size, 0);
});
test('subscribeToChromeTabGroupChanges notifies on external Chrome group changes', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
@@ -492,15 +962,16 @@ test('subscribeToChromeTabGroupChanges notifies on external Chrome group changes
events.push(event);
});
globalThis.chrome.tabGroups.onUpdated.emit(501, { title: 'Work' });
globalThis.chrome.tabGroups.onUpdated.emit({ id: 501, title: 'Work', color: 'grey', windowId: 1, collapsed: false });
assert.equal(events.length, 1);
assert.equal(events[0].source, 'tabGroups.onUpdated');
assert.equal(events[0].group.id, 501);
unsubscribe();
});
test('subscribeToChromeTabGroupChanges ignores collapse-only group updates', async () => {
test('subscribeToChromeTabGroupChanges notifies with the single-arg TabGroup payload', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
@@ -509,9 +980,13 @@ test('subscribeToChromeTabGroupChanges ignores collapse-only group updates', asy
events.push(event);
});
globalThis.chrome.tabGroups.onUpdated.emit(501, { collapsed: true });
// Chrome calls the onUpdated callback with the full updated group object —
// there is no separate changeInfo argument. Collapse-only updates therefore
// still notify; the dashboard debounces the resulting re-render.
globalThis.chrome.tabGroups.onUpdated.emit({ id: 501, title: 'Work', color: 'grey', windowId: 1, collapsed: true });
assert.equal(events.length, 0);
assert.equal(events.length, 1);
assert.equal(events[0].group.collapsed, true);
unsubscribe();
});
@@ -546,13 +1021,19 @@ test('subscribeToChromeTabGroupChanges notifies when a grouped tab is moved', as
test('syncChromeTabGroupExpansionForTab expands target group and collapses sibling groups in same window', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
// 101 and 102 are dashboard-managed mirrors; 103 is a user group in another
// window and must be left alone.
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
{ virtualGroupKey: 'example.com', windowId: 1, chromeGroupId: 102 },
]);
const updateCalls = [];
globalThis.chrome.tabGroups.query = async () => [
stubTabGroupsQuery([
{ id: 101, windowId: 1, collapsed: true },
{ id: 102, windowId: 1, collapsed: false },
{ id: 103, windowId: 2, collapsed: false },
];
]);
globalThis.chrome.tabGroups.update = async (id, opts) => {
updateCalls.push({ id, ...opts });
};
@@ -565,31 +1046,65 @@ test('syncChromeTabGroupExpansionForTab expands target group and collapses sibli
]);
});
test('syncChromeTabGroupExpansionForTab leaves user-created groups untouched', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
// 102 is a dashboard-managed mirror in the same window. The guard must stop
// before touching ANY group when the focused group is the user group 101;
// without the guard, 102 would be collapsed below.
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 102 },
]);
const updateCalls = [];
stubTabGroupsQuery([
{ id: 101, windowId: 1, collapsed: false },
{ id: 102, windowId: 1, collapsed: false },
]);
globalThis.chrome.tabGroups.update = async (id, opts) => {
updateCalls.push({ id, ...opts });
};
// 101 is a USER group (not in chromeGroupMap): focusing a tab inside it must
// not expand/collapse anything, managed or not.
await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 });
assert.deepEqual(updateCalls, []);
});
test('syncChromeTabGroupExpansionForTab skips work when Chrome sync is disabled', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(false);
let queryCount = 0;
globalThis.chrome.tabGroups.query = async () => {
stubTabGroupsQuery(() => {
queryCount++;
return [];
};
});
await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 });
assert.equal(queryCount, 0);
});
test('collapseChromeTabGroupsInWindow collapses every expanded group in the target window', async () => {
test('collapseChromeTabGroupsInWindow collapses only dashboard-managed groups in the target window', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
// 101 is a dashboard mirror; 102 and 103 are user groups (unmanaged) and
// must keep their collapsed state no matter the focus event.
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
]);
const updateCalls = [];
globalThis.chrome.tabGroups.query = async () => [
stubTabGroupsQuery([
{ id: 101, windowId: 1, collapsed: false },
{ id: 102, windowId: 1, collapsed: true },
// User group in the same window and expanded: removing the managed filter
// would collapse it too, so this mock is what makes the guard visible.
{ id: 102, windowId: 1, collapsed: false },
{ id: 103, windowId: 2, collapsed: false },
];
]);
globalThis.chrome.tabGroups.update = async (id, opts) => {
updateCalls.push({ id, ...opts });
};
+9 -1
View File
@@ -26,6 +26,7 @@
'chromeTabGroupsMeta',
'importedChromeSessionGroups',
'deferredTriggerPosition',
'popupView',
];
const STORAGE_DEFAULTS = {
@@ -44,6 +45,7 @@
chromeTabGroupsMeta: null,
importedChromeSessionGroups: { entries: [] },
deferredTriggerPosition: { top: null },
popupView: 'shortcuts',
};
function isValidConfigObject(value) {
@@ -57,7 +59,12 @@
exportedAt: new Date().toISOString(),
};
for (const key of STORAGE_KEYS) {
config[key] = key in data ? data[key] : null;
// chrome.storage.local.get(keys[]) resolves every requested key — unset
// keys come back as undefined, not absent. Serialize those as explicit
// null so importConfig can reset them to defaults on the target device
// (a missing key would be skipped by the importer instead).
const value = data[key];
config[key] = value === undefined ? null : value;
}
return JSON.stringify(config, null, 2);
}
@@ -75,6 +82,7 @@
return Array.isArray(value);
}
if (key === 'languagePreference') return typeof value === 'string';
if (key === 'popupView') return typeof value === 'string';
if (key === 'chromeTabGroupsEnabled') return typeof value === 'boolean';
return isValidConfigObject(value);
}
+41
View File
@@ -44,6 +44,10 @@ async function withMockStorage(initial, fn) {
}
}
test('STORAGE_KEYS includes popupView so popup view memory survives export/import', () => {
assert.ok(STORAGE_KEYS.includes('popupView'), 'popupView must round-trip through config export/import');
});
test('exportConfig returns the complete versioned configuration with custom icons', async () => {
const initial = {
themePreferences: { mode: 'dark', paletteId: 'sage' },
@@ -90,6 +94,42 @@ test('exportConfig works with empty/missing data', async () => {
});
});
test('exportConfig serializes unset keys as null under real Chrome get semantics', async () => {
// Real chrome.storage.local.get(keys[]) resolves EVERY requested key —
// unset keys come back as undefined (not absent). exportConfig must turn
// those into explicit null so a later import resets them to defaults on the
// target device; a missing key would be skipped by the importer instead.
const store = { themePreferences: { mode: 'dark' } };
const chromeLike = {
storage: {
local: {
async get(keys) {
const result = {};
for (const key of keys) result[key] = key in store ? store[key] : undefined;
return result;
},
async set(payload) {
Object.assign(store, payload);
},
},
},
};
const originalChrome = globalThis.chrome;
globalThis.chrome = chromeLike;
try {
const json = await exportConfig();
const parsed = JSON.parse(json);
assert.deepEqual(parsed.themePreferences, { mode: 'dark' });
assert.equal(parsed.quickShortcuts, null);
assert.equal(parsed.savedTabSessions, null);
assert.equal(parsed.popupView, null);
for (const key of STORAGE_KEYS) assert.ok(key in parsed, `missing ${key}`);
} finally {
globalThis.chrome = originalChrome;
}
});
test('importConfig writes the complete configuration to storage', async () => {
const incoming = {
version: CONFIG_VERSION,
@@ -115,6 +155,7 @@ test('importConfig writes the complete configuration to storage', async () => {
chromeTabGroupsMeta: null,
importedChromeSessionGroups: { entries: [] },
deferredTriggerPosition: { top: null },
popupView: 'tabs',
};
const jsonString = JSON.stringify(incoming);
File diff suppressed because it is too large Load Diff
+96
View File
@@ -126,6 +126,27 @@
toastTabDiscardFailed: 'Failed to sleep tab',
toastTabsDiscarded: '{count} tabs sleeping',
dragReorderTab: 'Drag to reorder tab',
dragReorderTabSelect: 'Drag to reorder; Enter or Space to select',
batchBarLabel: 'Selected tabs',
batchSelectedCount: '{count} selected',
batchCloseTabs: 'Close selected',
batchSleepTabs: 'Sleep selected',
batchMergeChromeGroup: 'Merge into Chrome group',
batchSaveSession: 'Save session',
batchClearSelection: 'Clear selection',
batchCloseDuplicates: 'Close selected duplicates',
batchMergeGroupTitle: 'Selected ({count})',
toastBatchClosed: 'Closed {count} tabs',
toastBatchSleepNone: 'Selected tabs are active',
toastBatchClosedDuplicates: 'Closed {count} duplicate tabs',
toastBatchNoDuplicates: 'No duplicate tabs in selection',
toastBatchMergedChromeGroup: 'Merged {count} tabs into a Chrome tab group',
toastBatchMergedChromeGroupWithSkipped: 'Merged {count} tabs into a Chrome tab group ({skipped} skipped)',
toastBatchMergedGroupRenameFailed: 'Merged {count} tabs, but could not name the group',
toastBatchMergeNoEligible: 'No eligible tabs to merge',
toastBatchMergeCleanupFailed: 'Merged tabs, but could not update saved groups',
toastChromeGroupsUnavailable: 'Could not read Chrome tab groups',
toastTabAlreadyClosed: 'Tab already closed',
closeGroup: 'Close group',
pinnedOrder: 'Pinned order',
pinOrder: 'Pin order',
@@ -177,6 +198,32 @@
savedSessionNavDisplayModeName: 'Group name',
sleepControlLabel: 'Manual sleep control',
closeDuplicateNewTabsLabel: 'Auto-close duplicate new tabs',
chromeGroupPlaceholder: 'Loading…',
groupCardTabsLabel: 'Merge into Chrome group',
mergeAllGroupTitle: 'Open tabs ({count})',
toastGroupCreated: 'Created Chrome tab group',
toastGroupCreateFailed: 'Could not create Chrome tab group',
quickShortcutOpenModeLabel: 'Open quick links in current tab',
quickShortcutColsLabel: 'Quick links per row',
quickShortcutColsAuto: 'Auto',
quickShortcutCols4: '4 columns',
quickShortcutCols5: '5 columns',
searchEngineLabel: 'Search engine',
searchEngineDefault: 'Browser default',
searchEngineGoogle: 'Google',
searchEngineBing: 'Bing',
searchEngineBaidu: 'Baidu',
searchEngineSogou: 'Sogou',
searchEngineDuckDuckGo: 'DuckDuckGo',
searchEngineBrave: 'Brave',
searchEngineYandex: 'Yandex',
searchEngineCustom: 'Custom',
customSearchUrlLabel: 'Custom search URL',
customSearchUrlHint: 'Use {query} or %s as the placeholder',
searchPlaceholderDefault: 'Search with your default engine...',
searchPlaceholderEngine: 'Search with {engine}...',
searchPlaceholderCustom: 'Search with a custom engine...',
toastInvalidCustomSearchUrl: 'Invalid custom search URL, using browser default',
settingsExportImport: 'Configuration backup',
settingsExport: 'Export',
settingsImport: 'Import',
@@ -185,6 +232,7 @@
toastConfigImported: 'Imported {keys} settings',
toastConfigImportFailed: 'Could not import config',
sleepAllTabsButton: 'Sleep all tabs in group',
sleepAllOpenTabsButton: 'Sleep all tabs',
deleteSessionButton: 'Delete session',
sessionPickerTitle: 'Choose what to save',
sessionPickerClose: 'Close tab picker',
@@ -306,6 +354,27 @@
toastTabDiscardFailed: '休眠失败',
toastTabsDiscarded: '已休眠 {count} 个标签页',
dragReorderTab: '拖拽重排标签页',
dragReorderTabSelect: '拖动重排;回车或空格选择',
batchBarLabel: '所选标签页',
batchSelectedCount: '已选 {count} 行',
batchCloseTabs: '关闭所选',
batchSleepTabs: '休眠所选',
batchMergeChromeGroup: '合并为 Chrome 标签组',
batchSaveSession: '保存会话',
batchClearSelection: '取消选择',
batchCloseDuplicates: '关闭选中重复标签',
batchMergeGroupTitle: '所选标签页 ({count})',
toastBatchClosed: '已关闭 {count} 个标签页',
toastBatchSleepNone: '所选标签页均为活动页',
toastBatchClosedDuplicates: '已关闭 {count} 个重复标签页',
toastBatchNoDuplicates: '所选标签页中没有重复',
toastBatchMergedChromeGroup: '已将 {count} 个标签页合并为 Chrome 标签组',
toastBatchMergedChromeGroupWithSkipped: '已将 {count} 个标签页合并为 Chrome 标签组(跳过 {skipped} 个)',
toastBatchMergedGroupRenameFailed: '已合并 {count} 个标签页,但无法命名该组',
toastBatchMergeNoEligible: '没有可合并的标签页',
toastBatchMergeCleanupFailed: '已合并标签页,但无法更新已保存的分组',
toastChromeGroupsUnavailable: '无法读取 Chrome 标签组',
toastTabAlreadyClosed: '标签页已关闭',
closeGroup: '关闭分组',
pinnedOrder: '已固定顺序',
pinOrder: '固定顺序',
@@ -357,6 +426,32 @@
savedSessionNavDisplayModeName: '分组名称',
sleepControlLabel: '手动休眠控制',
closeDuplicateNewTabsLabel: '自动关闭重复新标签页',
chromeGroupPlaceholder: '加载中…',
groupCardTabsLabel: '合并为 Chrome 标签组',
mergeAllGroupTitle: '打开的标签页 ({count})',
toastGroupCreated: '已创建 Chrome 标签组',
toastGroupCreateFailed: '创建 Chrome 标签组失败',
quickShortcutOpenModeLabel: '在当前标签页打开快捷链接',
quickShortcutColsLabel: '快捷链接每行',
quickShortcutColsAuto: '自动',
quickShortcutCols4: '4 列',
quickShortcutCols5: '5 列',
searchEngineLabel: '搜索引擎',
searchEngineDefault: '浏览器默认',
searchEngineGoogle: 'Google',
searchEngineBing: 'Bing',
searchEngineBaidu: 'Baidu',
searchEngineSogou: 'Sogou',
searchEngineDuckDuckGo: 'DuckDuckGo',
searchEngineBrave: 'Brave',
searchEngineYandex: 'Yandex',
searchEngineCustom: '自定义',
customSearchUrlLabel: '自定义搜索 URL',
customSearchUrlHint: '用 {query} 或 %s 作为占位符',
searchPlaceholderDefault: '用默认搜索引擎搜索...',
searchPlaceholderEngine: '用 {engine} 搜索...',
searchPlaceholderCustom: '用自定义搜索引擎搜索...',
toastInvalidCustomSearchUrl: '自定义搜索 URL 无效,已改用浏览器默认',
settingsExportImport: '配置备份',
settingsExport: '导出',
settingsImport: '导入',
@@ -365,6 +460,7 @@
toastConfigImported: '已导入 {keys} 项设置',
toastConfigImportFailed: '导入配置失败',
sleepAllTabsButton: '休眠组内全部标签页',
sleepAllOpenTabsButton: '休眠全部标签页',
deleteSessionButton: '删除会话',
sessionPickerTitle: '选择要保存的内容',
sessionPickerClose: '关闭标签页选择器',
+74 -35
View File
@@ -4,10 +4,17 @@
============================================================ */
html, body {
width: 100%;
width: 400px;
min-width: 380px;
max-width: 420px;
height: fit-content;
/* Override style.css body { min-height: 100vh } so the window fits its
content up to max-height instead of always filling the viewport. */
min-height: 0;
max-height: 520px;
/* The inner panels (.popup-tabs-list, .popup-shortcuts-grid) own the
vertical scroll; never let the popup document scroll too. */
overflow: hidden;
margin: 0;
padding: 0;
/* subtle paper grain */
@@ -31,7 +38,8 @@ body.popup-shell {
/* ── App Container ─────────────────────────────────────────── */
.popup-app {
width: 100%;
height: 100%;
height: fit-content;
max-height: 520px;
display: flex;
flex-direction: column;
padding: 14px 16px;
@@ -167,6 +175,22 @@ body.popup-shell {
align-content: start;
}
/* Fixed column counts follow the dashboard "quick links per row" setting. */
.popup-shortcuts-grid.is-fixed-cols-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.popup-shortcuts-grid.is-fixed-cols-5 {
display: grid;
grid-template-columns: repeat(5, 1fr);
}
.popup-shortcuts-grid.is-fixed-cols-4 .popup-shortcut-card,
.popup-shortcuts-grid.is-fixed-cols-5 .popup-shortcut-card {
width: 100%;
}
/* Shortcut card — matches dashboard quick-shortcut-card */
.popup-shortcut-card {
position: relative;
@@ -266,24 +290,33 @@ body.popup-shell {
/* ── Group Navigation ─────────────────────────────────────────── */
.popup-group-nav-wrap {
display: flex;
flex-wrap: nowrap;
flex: 0 0 auto;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
gap: 6px;
padding-bottom: 6px;
scrollbar-width: thin;
/* This element also carries the shared .group-nav class (style.css), which
would add margin-bottom: 14px below the icon row — override it so the
row's spacing is controlled here, not by the dashboard nav rules. */
margin: 0;
/* Top headroom so the shared style.css hover lift (translateY(-1px)) never
clips the button's top edge against overflow-y: hidden. */
padding-top: 2px;
padding-bottom: 2px;
/* Keep horizontal scrolling, but never show the nav scrollbar. */
scrollbar-width: none;
-ms-overflow-style: none;
}
.popup-group-nav-wrap::-webkit-scrollbar {
height: 3px;
}
.popup-group-nav-wrap::-webkit-scrollbar-thumb {
background: var(--warm-gray);
border-radius: 999px;
display: none;
}
.group-nav-button {
width: 40px;
height: 40px;
flex: 0 0 40px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--warm-gray) 70%, var(--workspace-accent-border) 30%);
background: color-mix(in srgb, var(--card-bg) calc(var(--custom-surface-opacity) + 64%), transparent);
@@ -331,7 +364,6 @@ body.popup-shell {
flex-direction: column;
gap: 8px;
padding-right: 2px;
scrollbar-width: thin;
}
.popup-tab-group {
@@ -425,12 +457,12 @@ body.popup-shell {
}
.popup-tab-close-btn {
width: 22px;
height: 22px;
width: 34px;
height: 34px;
border: none;
background: transparent;
color: var(--muted);
border-radius: 4px;
border-radius: 6px;
padding: 0;
display: inline-flex;
align-items: center;
@@ -442,8 +474,8 @@ body.popup-shell {
}
.popup-tab-close-btn svg {
width: 13px;
height: 13px;
width: 19px;
height: 19px;
}
.popup-tab-close-btn:hover {
@@ -463,15 +495,18 @@ body.popup-shell {
}
/* ── Scrollbars ──────────────────────────────────────────────── */
.popup-shortcuts-grid::-webkit-scrollbar,
.popup-tabs-list::-webkit-scrollbar {
width: 4px;
/* Scrollbars are hidden everywhere (like the group nav): the panels still
scroll with the wheel and keyboard, and transient overflow (e.g. during
the entry animation) can never flash a visible scrollbar. */
.popup-shortcuts-grid,
.popup-tabs-list {
scrollbar-width: none;
-ms-overflow-style: none;
}
.popup-shortcuts-grid::-webkit-scrollbar-thumb,
.popup-tabs-list::-webkit-scrollbar-thumb {
background: var(--warm-gray);
border-radius: 999px;
.popup-shortcuts-grid::-webkit-scrollbar,
.popup-tabs-list::-webkit-scrollbar {
display: none;
}
/* ── Empty State ─────────────────────────────────────────────── */
@@ -514,7 +549,8 @@ body.popup-shell {
@keyframes tab-rise {
from {
opacity: 0;
transform: translateY(5px);
/* Below the 4px row gap so a rising row never covers the row beneath it. */
transform: translateY(3px);
}
to {
opacity: 1;
@@ -533,8 +569,9 @@ body.popup-shell {
}
}
/* Animation base classes — start invisible */
.is-entering,
/* Animation base classes — children start invisible; the container itself
stays visible so a lingering .is-entering (cleared on the next same-view
sync) can never blank the whole panel. */
.is-entering .group-nav-button,
.is-entering .popup-tab-group,
.is-entering .popup-tab-row,
@@ -547,22 +584,25 @@ body.popup-shell.is-ready {
animation: popup-rise 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Entrance animations run only during an actual view switch: the
.is-entering class exists only then (added by syncPopupView), so content
replaced by a background refresh never replays them from opacity 0. */
/* Shortcuts */
.popup-shortcuts-grid.is-ready .popup-shortcut-card {
.popup-shortcuts-grid.is-entering.is-ready .popup-shortcut-card {
animation: shortcut-rise 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Tabs — nav buttons stagger */
.popup-group-nav-wrap.is-ready .group-nav-button {
.popup-group-nav-wrap.is-entering.is-ready .group-nav-button {
animation: nav-rise 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Tabs — groups stagger, rows cascade after */
.popup-tabs-list.is-ready .popup-tab-group {
.popup-tabs-list.is-entering.is-ready .popup-tab-group {
animation: popup-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.popup-tabs-list.is-ready .popup-tab-row {
.popup-tabs-list.is-entering.is-ready .popup-tab-row {
animation: tab-rise 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@@ -576,16 +616,15 @@ body.popup-shell.is-ready {
transition: none !important;
transform: none !important;
}
.is-entering,
.is-entering .group-nav-button,
.is-entering .popup-tab-group,
.is-entering .popup-tab-row,
.is-entering .popup-shortcut-card,
body.popup-shell.is-ready,
.popup-shortcuts-grid.is-ready .popup-shortcut-card,
.popup-group-nav-wrap.is-ready .group-nav-button,
.popup-tabs-list.is-ready .popup-tab-group,
.popup-tabs-list.is-ready .popup-tab-row {
.popup-shortcuts-grid.is-entering.is-ready .popup-shortcut-card,
.popup-group-nav-wrap.is-entering.is-ready .group-nav-button,
.popup-tabs-list.is-entering.is-ready .popup-tab-group,
.popup-tabs-list.is-entering.is-ready .popup-tab-row {
opacity: 1 !important;
animation: none !important;
transform: none !important;
+1
View File
@@ -33,6 +33,7 @@
<script src="../config.js"></script>
<script src="../config-loader.js"></script>
<script src="../tab-url-utils.js"></script>
<script src="../icon-utils.js"></script>
<script src="../list-order.js"></script>
<script src="../ui-helpers.js"></script>
+227 -111
View File
@@ -9,19 +9,36 @@ const popupI18n = globalThis.TabHarborI18n || {};
const SESSION_GROUPS_KEY = 'sessionGroups';
const GROUP_ORDER_KEY = 'groupOrder';
const GROUP_TAB_ORDER_KEY = 'groupTabOrder';
const GROUP_LABEL_OVERRIDES_KEY = 'groupLabelOverrides';
const POPUP_VIEW_KEY = 'popupView';
const popupState = {
view: 'shortcuts',
openTabs: [],
quickShortcuts: [],
tabGroups: [],
sessionGroups: { groups: [], assignments: {} },
groupOrder: { sessionOrder: [], pinnedOrder: [], pinEnabled: false },
groupTabOrder: {},
groupLabelOverrides: {},
};
// Resolve the remembered view synchronously (localStorage is sync) so the
// very first paint shows the correct panel. chrome.storage is async and only
// reconciles later; without this the popup flashes the default shortcuts
// view for the first frame(s) when the remembered view is tabs.
try {
popupState.view = localStorage.getItem(POPUP_VIEW_KEY) === 'tabs' ? 'tabs' : 'shortcuts';
} catch {
popupState.view = 'shortcuts';
}
// Test exposure
globalThis.popupState = popupState;
globalThis.loadPopupView = loadPopupView;
globalThis.loadPopupState = loadPopupState;
globalThis.renderPopupShortcuts = renderPopupShortcuts;
globalThis.renderPopupTabs = renderPopupTabs;
globalThis.syncPopupView = syncPopupView;
globalThis.buildPopupTabGroups = buildPopupTabGroups;
globalThis.getGroupDisplayLabel = getGroupDisplayLabel;
globalThis.escapeAttr = escapeAttr;
@@ -32,11 +49,12 @@ globalThis.renderShortcutCard = renderShortcutCard;
globalThis.renderTabGroup = renderTabGroup;
globalThis.renderGroupNav = renderGroupNav;
globalThis._resetPopupState = () => {
popupState.view = 'shortcuts';
popupState.openTabs = [];
popupState.tabGroups = [];
popupState.sessionGroups = { groups: [], assignments: {} };
popupState.groupOrder = { sessionOrder: [], pinnedOrder: [], pinEnabled: false };
popupState.groupTabOrder = {};
popupState.groupLabelOverrides = {};
popupState.quickShortcuts = [];
};
globalThis._skipLoadPopupState = false;
@@ -47,6 +65,7 @@ const POPUP_REFRESH_KEYS = new Set([
'sessionGroups',
'groupOrder',
'groupTabOrder',
'groupLabelOverrides',
'themePreferences',
'languagePreference',
]);
@@ -94,8 +113,10 @@ const filterTabs = popupTheme.filterRealTabs || (tabs => Array.isArray(tabs) ? t
function getLandingPatterns() {
const base = [
// Gmail inbox/sent/search views are content tabs; other Gmail views
// (label views, bare front page) stay in the landing group.
{ hostname: 'mail.google.com', test: (_p, h) =>
!h.includes('#inbox/') && !h.includes('#sent/') && !h.includes('#search/') },
!h.includes('#inbox') && !h.includes('#sent') && !h.includes('#search/') },
{ hostname: 'x.com', pathExact: ['/home'] },
{ hostname: 'www.linkedin.com', pathExact: ['/'] },
{ hostname: 'github.com', pathExact: ['/'] },
@@ -181,53 +202,80 @@ function reorderGroupTabsByStoredUrls(tabs, groupKey) {
function getOrderedUniqueTabsForGroup(group) {
const tabs = Array.isArray(group?.tabs) ? group.tabs : [];
const orderedTabs = reorderGroupTabsByStoredUrls(tabs, group?.domain);
const seenUrls = new Set();
return orderedTabs.filter(tab => {
const url = String(tab?.url || '').trim();
if (!url) return true;
if (seenUrls.has(url)) return false;
seenUrls.add(url);
return true;
});
// Reorder by the stored per-group order; every tab stays visible so
// duplicate URLs can be seen and closed, matching the dashboard.
return reorderGroupTabsByStoredUrls(tabs, group?.domain);
}
async function loadPopupView() {
try {
const stored = await chrome.storage.local.get(POPUP_VIEW_KEY);
popupState.view = stored[POPUP_VIEW_KEY] === 'tabs' ? 'tabs' : 'shortcuts';
// Keep the synchronous first-frame mirror in sync: chrome.storage is the
// source of truth (config import/export round-trips through it), so when
// it differs from the localStorage mirror, persist the resolved view back
// instead of letting the two diverge permanently (which would flash the
// stale first frame on every popup open).
try {
localStorage.setItem(POPUP_VIEW_KEY, popupState.view);
} catch { /* storage may be unavailable; chrome.storage remains authoritative */ }
} catch {
popupState.view = 'shortcuts';
}
return popupState.view;
}
function normalizeGroupLabelOverrides(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
return Object.fromEntries(
Object.entries(input)
.filter(([, label]) => typeof label === 'string' && label.trim())
.map(([key, label]) => [String(key), label.trim()])
);
}
async function loadPopupState() {
if (globalThis._skipLoadPopupState) return;
const shortcutsGetter = popupTheme.getQuickShortcuts;
if (typeof shortcutsGetter === 'function') {
popupState.quickShortcuts = await shortcutsGetter();
}
const popupUrlUtils = globalThis.TabHarborTabUrlUtils || {};
const parseSuspendedUrl = popupUrlUtils.parseSuspendedTabUrl || (() => ({ isSuspended: false, originalUrl: '', title: '' }));
const canonicalizeUrl = popupUrlUtils.getCanonicalTabUrl || (url => String(url || ''));
const [tabs, tabGroups, sgResult, goResult, groupTabOrderResult] = await Promise.all([
const [quickShortcuts, tabs, sgResult, goResult, groupTabOrderResult, labelOverridesResult] = await Promise.all([
typeof shortcutsGetter === 'function' ? shortcutsGetter() : Promise.resolve([]),
chrome.tabs.query({}),
chrome.tabGroups.query({}),
chrome.storage.local.get(SESSION_GROUPS_KEY),
chrome.storage.local.get(GROUP_ORDER_KEY),
chrome.storage.local.get(GROUP_TAB_ORDER_KEY),
chrome.storage.local.get(GROUP_LABEL_OVERRIDES_KEY),
]);
popupState.openTabs = filterTabs(tabs).map(tab => ({
id: tab.id,
url: tab.url || '',
title: tab.title || '',
favIconUrl: tab.favIconUrl || '',
windowId: tab.windowId,
active: Boolean(tab.active),
groupId: tab.groupId,
}));
popupState.quickShortcuts = Array.isArray(quickShortcuts) ? quickShortcuts : [];
// Suspended tabs (chrome-extension://…/suspended.html#uri=…) stay visible
// and group under their original URL, matching the dashboard — but only
// when the original URL exists. Uri-less suspended pages fall through to
// the normal filter and are dropped like other internal pages.
popupState.openTabs = tabs.filter(tab => {
const rawUrl = String(tab.url || '').trim();
if (!rawUrl) return true;
const suspended = parseSuspendedUrl(rawUrl);
if (suspended.isSuspended && suspended.originalUrl) return true;
return filterTabs([tab]).length > 0;
}).map(tab => {
const rawUrl = String(tab.url || '').trim();
const suspended = parseSuspendedUrl(rawUrl);
return {
id: tab.id,
url: canonicalizeUrl(rawUrl),
title: suspended.title || tab.title || '',
favIconUrl: tab.favIconUrl || '',
windowId: tab.windowId,
active: Boolean(tab.active),
groupId: tab.groupId,
};
});
popupState.tabGroups = Array.isArray(tabGroups)
? tabGroups
.map(group => ({
id: group.id,
title: group.title || '',
color: group.color || '',
collapsed: Boolean(group.collapsed),
tabs: popupState.openTabs.filter(tab => tab.groupId === group.id),
}))
.filter(group => group.tabs.length > 0)
: [];
popupState.groupLabelOverrides = normalizeGroupLabelOverrides(labelOverridesResult[GROUP_LABEL_OVERRIDES_KEY]);
const normalizeFn = popupSessionGroups.normalizeSessionGroups;
popupState.sessionGroups = normalizeFn ? normalizeFn(sgResult[SESSION_GROUPS_KEY]) : { groups: [], assignments: {} };
@@ -243,12 +291,13 @@ function buildPopupTabGroups() {
const sessionGroupMap = Object.fromEntries(
sessionGroups.groups.map(group => [
group.id,
{ domain: `__session_group__:${group.id}`, label: group.name, tabs: [], kind: 'session', manualGroupId: group.id },
{ domain: `__session_group__:${group.id}`, label: group.name, tabs: [], kind: 'session', manualGroupId: group.id, createdAt: group.createdAt },
])
);
const groupMap = {};
const landingTabs = [];
const ungroupedTabs = [];
for (const tab of openTabs) {
const assignedGroupId = sessionGroups.assignments[String(tab.id)];
@@ -274,18 +323,27 @@ function buildPopupTabGroups() {
try {
hostname = tab.url.startsWith('file://') ? 'local-files' : new URL(tab.url).hostname;
} catch {
ungroupedTabs.push(tab);
continue;
}
if (!hostname) {
ungroupedTabs.push(tab);
continue;
}
if (!hostname) continue;
if (!groupMap[hostname]) groupMap[hostname] = { domain: hostname, label: hostname, tabs: [], kind: 'domain' };
groupMap[hostname].tabs.push(tab);
const primaryDomain = popupIcons.getPrimaryDomain ? popupIcons.getPrimaryDomain(hostname) : hostname;
if (!groupMap[primaryDomain]) groupMap[primaryDomain] = { domain: primaryDomain, label: '', tabs: [], kind: 'domain' };
groupMap[primaryDomain].tabs.push(tab);
}
if (landingTabs.length > 0) {
groupMap['__landing-pages__'] = { domain: '__landing-pages__', label: '__landing-pages__', tabs: landingTabs, kind: 'landing' };
}
if (ungroupedTabs.length > 0) {
groupMap['__ungrouped__'] = { domain: '__ungrouped__', label: '__ungrouped__', tabs: ungroupedTabs, kind: 'ungrouped' };
}
const landingHostnames = new Set(getLandingPatterns().map(p => p.hostname).filter(Boolean));
const landingSuffixes = getLandingPatterns().map(p => p.hostnameEndsWith).filter(Boolean);
function isLandingDomain(domain) {
@@ -293,7 +351,10 @@ function buildPopupTabGroups() {
return landingSuffixes.some(s => domain.endsWith(s));
}
const sessionGroupsList = Object.values(sessionGroupMap).filter(g => g.tabs.length > 0);
// Same ordering as the dashboard: oldest session group first, then automatic groups.
const sessionGroupsList = Object.values(sessionGroupMap)
.filter(g => g.tabs.length > 0)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const automaticGroups = Object.values(groupMap);
const sortedAutomatic = automaticGroups.sort((a, b) => {
@@ -307,24 +368,35 @@ function buildPopupTabGroups() {
});
const applyOrderFn = popupGroupOrder.applyGroupOrder;
const orderedManual = applyOrderFn ? applyOrderFn(sessionGroupsList, groupOrder) : sessionGroupsList;
const orderedAuto = applyOrderFn ? applyOrderFn(sortedAutomatic, groupOrder) : sortedAutomatic;
return [...orderedManual, ...orderedAuto];
const mergedGroups = [...sessionGroupsList, ...sortedAutomatic];
return applyOrderFn ? applyOrderFn(mergedGroups, groupOrder) : mergedGroups;
}
let popupShortcutsRenderKey = '';
function renderPopupShortcuts() {
const listEl = document.getElementById('popupShortcutsList');
const emptyEl = document.getElementById('popupShortcutsEmpty');
if (!listEl || !emptyEl) return;
listEl.classList.add('is-entering');
// Skip re-rendering when neither the shortcuts nor the column setting
// changed — background tab refreshes must not reset the grid.
const cols = popupTheme.getQuickShortcutCols ? popupTheme.getQuickShortcutCols() : 'auto';
const renderKey = `${JSON.stringify(popupState.quickShortcuts)}|${cols}`;
if (renderKey === popupShortcutsRenderKey) return;
popupShortcutsRenderKey = renderKey;
// The dashboard "quick links per row" setting controls the popup grid too.
listEl.classList.toggle('is-fixed-cols-4', cols === '4');
listEl.classList.toggle('is-fixed-cols-5', cols === '5');
listEl.innerHTML = popupState.quickShortcuts.length
? popupState.quickShortcuts.map((s, i) => renderShortcutCard(s, i)).join('')
: '';
emptyEl.hidden = popupState.quickShortcuts.length > 0;
requestAnimationFrame(() => requestAnimationFrame(() => listEl.classList.add('is-ready')));
requestAnimationFrame(() => requestAnimationFrame(() => {
listEl.classList.add('is-ready');
}));
}
function renderShortcutCard(shortcut, index) {
@@ -365,12 +437,16 @@ function renderShortcutCard(shortcut, index) {
function getGroupDisplayLabel(group) {
const i18n = globalThis.TabHarborI18n || {};
const t = i18n.t ? (key => i18n.t(key)) : (key => key);
if (!group) return 'Group';
if (popupState.groupLabelOverrides[group.domain]) {
return popupState.groupLabelOverrides[group.domain];
}
switch (group.kind) {
case 'landing': return t('homepagesLabel');
case 'session': return group.label;
case 'chrome-group': return group.label;
case 'ungrouped': return t('ungroupedLabel');
default: return friendlyDomain(group.domain) || group.domain;
default: return group.label || (friendlyDomain(group.domain) || group.domain);
}
}
@@ -440,11 +516,9 @@ function renderPopupTabs() {
if (navEl.innerHTML !== newNavHtml) {
navEl.innerHTML = newNavHtml;
navEl.classList.add('is-entering');
}
if (listEl.innerHTML !== newListHtml) {
listEl.innerHTML = newListHtml;
listEl.classList.add('is-entering');
}
if (emptyEl.hidden !== true) {
emptyEl.hidden = true;
@@ -456,6 +530,8 @@ function renderPopupTabs() {
}));
}
let lastSyncedPopupView = '';
function syncPopupView() {
const shortcutsTab = document.getElementById('popupShortcutsTab');
const tabsTab = document.getElementById('popupTabsTab');
@@ -471,10 +547,23 @@ function syncPopupView() {
tabsTab?.classList.toggle('is-active', isTabs);
tabsTab?.setAttribute('aria-selected', String(isTabs));
// Strip animation classes so they replay on re-enter
[shortcutsList, tabsList, navEl].forEach(el => {
el?.classList.remove('is-ready', 'is-entering');
});
// Strip animation classes only when the view actually switched, so
// background refreshes do not replay the entry animation.
const viewChanged = lastSyncedPopupView !== popupState.view;
lastSyncedPopupView = popupState.view;
if (viewChanged) {
[shortcutsList, tabsList, navEl].forEach(el => {
el?.classList.remove('is-ready', 'is-entering');
});
// Hide the incoming panel until its entry animation starts (re-triggered
// below), so content painted after a view switch never flashes unstyled.
if (!isTabs) {
shortcutsList?.classList.add('is-entering');
} else {
tabsList?.classList.add('is-entering');
navEl?.classList.add('is-entering');
}
}
if (shortcutsPanel) {
shortcutsPanel.hidden = isTabs;
@@ -485,17 +574,35 @@ function syncPopupView() {
tabsPanel.classList.toggle('is-active', isTabs);
}
// Re-trigger animation for the incoming active panel
if (!isTabs && shortcutsList) {
requestAnimationFrame(() => requestAnimationFrame(() => shortcutsList.classList.add('is-ready')));
// Re-trigger animation for the incoming active panel (skip when the
// class is already present so background refreshes cause no mutations).
// is-entering (set above on view switch) stays on while the entrance
// animation plays; only the same-view sync below clears it.
if (!isTabs && shortcutsList && !shortcutsList.classList.contains('is-ready')) {
requestAnimationFrame(() => requestAnimationFrame(() => {
shortcutsList.classList.add('is-ready');
}));
} else if (isTabs && tabsList && navEl) {
requestAnimationFrame(() => requestAnimationFrame(() => {
tabsList.classList.add('is-ready');
navEl.classList.add('is-ready');
if (!tabsList.classList.contains('is-ready')) tabsList.classList.add('is-ready');
if (!navEl.classList.contains('is-ready')) navEl.classList.add('is-ready');
}));
}
// Background refreshes must not replay the entrance animation: the child
// animations are bound to .is-entering.is-ready, so clearing is-entering
// here (and on every later same-view sync) keeps replaced content visible.
if (!viewChanged) {
[shortcutsList, tabsList, navEl].forEach(el => {
el?.classList.remove('is-entering');
});
}
}
// Popup quick links always open a new active tab, regardless of the
// dashboard "open in current tab" setting: navigating the current tab here
// would replace the real page behind the popup, which is destructive. The
// open-mode toggle (and Ctrl/Shift modifiers) apply to the new-tab page only.
async function openPopupUrl(url) {
if (!url) return;
await chrome.tabs.create({ url, active: true });
@@ -528,13 +635,27 @@ async function openPopupTab(tabId, fallbackUrl = '') {
}
if (currentWindow?.id && targetTab.windowId && targetTab.windowId !== currentWindow.id) {
const targetUrl = targetTab.url || fallbackUrl;
if (!targetUrl) return;
await chrome.tabs.create({
windowId: currentWindow.id,
url: targetUrl,
active: true,
});
// Activate the existing tab in its own window instead of duplicating it
// here — the tabs view exists to avoid opening duplicates. Mirrors the
// dashboard focus-tab behavior (tabs.update + windows.update).
try {
await chrome.tabs.update(targetTab.id, { active: true });
} catch {
// The tab closed meanwhile — fall back to opening its URL here.
const targetUrl = targetTab.url || fallbackUrl;
if (targetUrl) {
await chrome.tabs.create({
windowId: currentWindow.id,
url: targetUrl,
active: true,
});
}
window.close();
return;
}
// Activating the tab is the essential step; focusing its window is
// best-effort — if it fails the tab is already active where it lives.
await chrome.windows.update(targetTab.windowId, { focused: true }).catch(() => {});
window.close();
return;
}
@@ -543,46 +664,6 @@ async function openPopupTab(tabId, fallbackUrl = '') {
window.close();
}
function handlePopupGroupNavImageError(event) {
const target = event.target;
if (!(target instanceof HTMLImageElement)) return;
if (!target.classList.contains('group-nav-icon')) return;
const fallbackQueue = [];
const primaryFallback = String(target.dataset.fallbackSrc || '').trim();
if (primaryFallback) fallbackQueue.push(primaryFallback);
const serializedQueue = String(target.dataset.fallbackSrcset || '').trim();
if (serializedQueue) {
try {
const parsed = JSON.parse(serializedQueue);
if (Array.isArray(parsed)) {
fallbackQueue.push(...parsed.map(url => String(url || '').trim()).filter(Boolean));
}
} catch {}
}
const currentSrc = String(target.currentSrc || target.src || '').trim();
const nextFallback = fallbackQueue.find(url => url && url !== currentSrc && url !== String(target.dataset.fallbackApplied || '').trim());
if (nextFallback) {
const remaining = fallbackQueue.filter(url => url && url !== nextFallback);
target.dataset.fallbackApplied = nextFallback;
target.dataset.fallbackSrc = nextFallback;
if (remaining.length) {
target.dataset.fallbackSrcset = JSON.stringify(remaining);
} else {
delete target.dataset.fallbackSrcset;
}
target.src = nextFallback;
return;
}
target.style.display = 'none';
const sibling = target.nextElementSibling;
if (sibling?.classList.contains('group-nav-fallback')) {
sibling.style.display = '';
}
}
async function refreshPopup() {
if (popupTheme.loadThemePreferences) {
await popupTheme.loadThemePreferences();
@@ -618,6 +699,9 @@ async function refreshPopupSafely() {
popupRefreshInFlight = (async () => {
try {
await refreshPopup();
} catch (err) {
// A failed refresh keeps the previous snapshot; surface for debugging.
console.warn('[tab-harbor popup] refresh failed:', err?.message || err);
} finally {
popupRefreshInFlight = null;
if (popupRefreshQueued) {
@@ -659,9 +743,23 @@ function registerPopupAutoRefresh() {
}
function initializePopup() {
document.addEventListener('error', handlePopupGroupNavImageError, true);
registerPopupAutoRefresh();
const groupNavWrap = document.getElementById('popupGroupNav');
if (groupNavWrap) {
// The nav scrollbar is hidden; let the vertical wheel scroll it
// horizontally — but only when it can actually scroll, so a wheel over
// a short nav (no overflow) passes through untouched.
groupNavWrap.addEventListener('wheel', (e) => {
const list = e.target.closest('.group-nav-list') || groupNavWrap;
if (list.scrollWidth <= list.clientWidth) return;
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
list.scrollLeft += e.deltaY;
}
}, { passive: false });
}
document.addEventListener('click', async e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
@@ -670,11 +768,13 @@ function initializePopup() {
if (action === 'switch-popup-view') {
popupState.view = actionEl.dataset.view === 'tabs' ? 'tabs' : 'shortcuts';
syncPopupView();
void chrome.storage.local.set({ [POPUP_VIEW_KEY]: popupState.view });
try { localStorage.setItem(POPUP_VIEW_KEY, popupState.view); } catch { /* storage unavailable */ }
return;
}
if (action === 'refresh-popup') {
await refreshPopup();
await refreshPopupSafely();
return;
}
@@ -695,7 +795,8 @@ function initializePopup() {
if (popupRefreshInFlight) {
try { await popupRefreshInFlight; } catch { /* swallow */ }
}
await refreshPopup();
// The safe pipeline keeps the previous snapshot if this refresh fails.
await refreshPopupSafely();
// tabs.onActivated (from switching to a new active tab) may have
// rescheduled a refresh during our await — suppress it
if (popupRefreshTimer) {
@@ -711,6 +812,11 @@ function initializePopup() {
if (action === 'open-popup-url') {
e.preventDefault();
// A close in flight makes the close button unclickable (pointer-events:
// none), so a fast second click lands on the row itself — ignore it or
// the user's "close" intent would re-open the tab.
const rowEl = actionEl.closest('.popup-tab-row');
if (rowEl?.querySelector('.popup-tab-close-btn.is-loading')) return;
const tabId = Number(actionEl.dataset.tabId);
if (tabId) {
await openPopupTab(tabId, actionEl.dataset.url || '');
@@ -727,7 +833,17 @@ function initializePopup() {
}
});
refreshPopupSafely()
// Apply the remembered view before the first paint (scripts run during
// parse, ahead of any frame) so opening on the tabs view never flashes
// the default shortcuts panel. loadPopupView below reconciles with
// chrome.storage for imports/exports.
syncPopupView();
loadPopupView()
.then(() => {
syncPopupView();
return refreshPopupSafely();
})
.then(() => requestAnimationFrame(() => document.body.classList.add('is-ready')))
.catch(() => {
renderPopupShortcuts();
+349 -15
View File
@@ -23,6 +23,15 @@ globalThis.flushRaf = () => {
snapshot.forEach(({ fn }) => fn());
};
// popup.js resolves the remembered view synchronously from localStorage at
// module load — mock it so the require-time read is safe and predictable.
globalThis.localStorage = {
_store: {},
getItem: key => (key in globalThis.localStorage._store ? globalThis.localStorage._store[key] : null),
setItem: (key, value) => { globalThis.localStorage._store[key] = String(value); },
removeItem: key => { delete globalThis.localStorage._store[key]; },
};
globalThis.document = {
addEventListener: () => {},
querySelector: () => null,
@@ -99,6 +108,169 @@ function resetPopupTestState(opts = {}) {
}
}
// ---- popup view persistence ----
test('loadPopupView restores the saved tabs view', async () => {
resetPopupTestState();
const originalGet = globalThis.chrome.storage.local.get;
globalThis.chrome.storage.local.get = async key => (key === 'popupView' ? { popupView: 'tabs' } : {});
try {
const view = await loadPopupView();
assert.equal(view, 'tabs');
assert.equal(popupState.view, 'tabs');
} finally {
globalThis.chrome.storage.local.get = originalGet;
}
});
test('loadPopupView falls back to shortcuts for missing or invalid values', async () => {
resetPopupTestState();
const originalGet = globalThis.chrome.storage.local.get;
globalThis.chrome.storage.local.get = async () => ({});
try {
assert.equal(await loadPopupView(), 'shortcuts');
} finally {
globalThis.chrome.storage.local.get = originalGet;
}
globalThis.chrome.storage.local.get = async key => (key === 'popupView' ? { popupView: 'garbage' } : {});
try {
assert.equal(await loadPopupView(), 'shortcuts');
assert.equal(popupState.view, 'shortcuts');
} finally {
globalThis.chrome.storage.local.get = originalGet;
}
});
test('refreshing popup state preserves the live view instead of resetting it', async () => {
resetPopupTestState();
popupState.view = 'tabs';
const originalGet = globalThis.chrome.storage.local.get;
globalThis.chrome.storage.local.get = async () => ({ popupView: 'shortcuts' });
try {
await loadPopupState();
assert.equal(popupState.view, 'tabs');
} finally {
globalThis.chrome.storage.local.get = originalGet;
}
});
test('loadPopupState unwraps suspended tabs to their original URL', async () => {
resetPopupTestState();
const originalGet = globalThis.chrome.storage.local.get;
const originalUtils = globalThis.TabHarborTabUrlUtils;
globalThis.chrome.storage.local.get = async () => ({});
globalThis.TabHarborTabUrlUtils = {
parseSuspendedTabUrl: url => {
const raw = String(url || '');
const uri = raw.includes('#uri=') ? decodeURIComponent(raw.split('#uri=')[1].split('&')[0]) : '';
return raw.includes('/suspended.html') && uri ? { isSuspended: true, originalUrl: uri, title: 'Suspended title' } : { isSuspended: false, originalUrl: '', title: '' };
},
getCanonicalTabUrl: url => {
const raw = String(url || '');
const uri = raw.includes('#uri=') ? decodeURIComponent(raw.split('#uri=')[1].split('&')[0]) : '';
return raw.includes('/suspended.html') && uri ? uri : raw;
},
};
try {
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome-extension://suspender/suspended.html#uri=https%3A%2F%2Fgithub.com%2Fissue%2F42', title: 'Suspended tab', windowId: 1, active: false },
{ id: 2, url: '', title: 'Loading', windowId: 1, active: false },
];
await loadPopupState();
const urls = popupState.openTabs.map(t => t.url);
assert.ok(urls.includes('https://github.com/issue/42'), 'suspended tab unwraps to its original URL');
assert.equal(popupState.openTabs.find(t => t.id === 1).title, 'Suspended title', 'suspended title is used');
assert.ok(urls.includes(''), 'mid-load tab with empty URL stays visible');
} finally {
globalThis.chrome.tabs.query = async () => [];
globalThis.chrome.storage.local.get = originalGet;
globalThis.TabHarborTabUrlUtils = originalUtils;
}
});
// ---- popup shortcuts column setting ----
test('renderPopupShortcuts applies the dashboard column setting to the grid', async () => {
resetPopupTestState();
const originalGetById = globalThis.document.getElementById;
const originalGetCols = globalThis.TabOutThemeControls.getQuickShortcutCols;
const toggles = {};
const listEl = {
classList: { add: () => {}, remove: () => {}, toggle: (cls, on) => { toggles[cls] = on; } },
innerHTML: '',
querySelectorAll: () => [],
};
const emptyEl = { hidden: true };
globalThis.document.getElementById = id => (id === 'popupShortcutsList' ? listEl : id === 'popupShortcutsEmpty' ? emptyEl : null);
try {
globalThis.TabOutThemeControls.getQuickShortcutCols = () => '4';
popupState.quickShortcuts = [{ id: 'a', url: 'https://a.com', label: 'A' }];
renderPopupShortcuts();
assert.equal(toggles['is-fixed-cols-4'], true);
assert.equal(toggles['is-fixed-cols-5'], false);
globalThis.TabOutThemeControls.getQuickShortcutCols = () => '5';
renderPopupShortcuts();
assert.equal(toggles['is-fixed-cols-4'], false);
assert.equal(toggles['is-fixed-cols-5'], true);
} finally {
globalThis.document.getElementById = originalGetById;
globalThis.TabOutThemeControls.getQuickShortcutCols = originalGetCols;
}
});
test('renderPopupShortcuts falls back to auto when the setting getter is missing', async () => {
resetPopupTestState();
const originalGetById = globalThis.document.getElementById;
const toggles = {};
const listEl = {
classList: { add: () => {}, remove: () => {}, toggle: (cls, on) => { toggles[cls] = on; } },
innerHTML: '',
querySelectorAll: () => [],
};
globalThis.document.getElementById = id => (id === 'popupShortcutsList' ? listEl : id === 'popupShortcutsEmpty' ? { hidden: true } : null);
try {
delete globalThis.TabOutThemeControls.getQuickShortcutCols;
popupState.quickShortcuts = [];
renderPopupShortcuts();
assert.equal(toggles['is-fixed-cols-4'], false);
assert.equal(toggles['is-fixed-cols-5'], false);
} finally {
globalThis.document.getElementById = originalGetById;
}
});
test('renderPopupShortcuts skips re-render when shortcuts and columns are unchanged', async () => {
resetPopupTestState();
const originalGetById = globalThis.document.getElementById;
const originalGetCols = globalThis.TabOutThemeControls.getQuickShortcutCols;
const listEl = {
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
innerHTML: '',
querySelectorAll: () => [],
};
globalThis.document.getElementById = id => (id === 'popupShortcutsList' ? listEl : id === 'popupShortcutsEmpty' ? { hidden: true } : null);
globalThis.TabOutThemeControls.getQuickShortcutCols = () => 'auto';
try {
popupState.quickShortcuts = [{ id: 'a', url: 'https://a.com', label: 'A' }];
renderPopupShortcuts();
listEl.innerHTML = 'DIRTY';
// Unchanged data + columns -> the render is skipped, DOM is preserved.
renderPopupShortcuts();
assert.equal(listEl.innerHTML, 'DIRTY');
// Changed shortcuts -> the render runs again.
popupState.quickShortcuts = [
{ id: 'a', url: 'https://a.com', label: 'A' },
{ id: 'b', url: 'https://b.com', label: 'B' },
];
renderPopupShortcuts();
assert.notEqual(listEl.innerHTML, 'DIRTY');
} finally {
globalThis.document.getElementById = originalGetById;
globalThis.TabOutThemeControls.getQuickShortcutCols = originalGetCols;
}
});
// ---- escapeAttr ----
test('escapeAttr escapes & < > "', () => {
@@ -285,7 +457,6 @@ test('buildPopupTabGroups groups session-assigned tabs', () => {
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://github.com', title: 'GitHub', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
globalThis.popupState.sessionGroups = {
groups: [{ id: 's1', name: 'Work' }],
assignments: { '1': 's1' },
@@ -298,6 +469,29 @@ test('buildPopupTabGroups groups session-assigned tabs', () => {
assert.equal(sessionGroup.tabs[0].id, 1);
});
test('buildPopupTabGroups orders session groups by createdAt like the dashboard', () => {
resetPopupTestState({ landingPatterns: [] });
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://a.com', title: 'A', windowId: 1, active: false, groupId: null },
{ id: 2, url: 'https://b.com', title: 'B', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.sessionGroups = {
// Listed newest-first on purpose: only the createdAt sort (not insertion
// order) can produce the expected [older, newer] output.
groups: [
{ id: 'newer', name: 'Newer', createdAt: '2026-02-01T00:00:00.000Z' },
{ id: 'older', name: 'Older', createdAt: '2026-01-01T00:00:00.000Z' },
],
assignments: { '1': 'newer', '2': 'older' },
};
const groups = globalThis.buildPopupTabGroups();
const sessionGroupsOrdered = groups.filter(g => g.kind === 'session');
assert.equal(sessionGroupsOrdered.length, 2, 'both session groups present');
assert.equal(sessionGroupsOrdered[0].manualGroupId, 'older', 'oldest session group comes first');
assert.equal(sessionGroupsOrdered[1].manualGroupId, 'newer', 'newest session group comes second');
});
test('buildPopupTabGroups groups domain tabs', () => {
resetPopupTestState();
@@ -306,7 +500,6 @@ test('buildPopupTabGroups groups domain tabs', () => {
{ id: 2, url: 'https://github.com/org/team', title: 'Team', windowId: 1, active: false, groupId: null },
{ id: 3, url: 'https://google.com/search', title: 'Search', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
const ghGroup = groups.find(g => g.domain === 'github.com');
@@ -324,7 +517,6 @@ test('buildPopupTabGroups places landing pages group at top', () => {
{ id: 1, url: 'https://github.com/', title: 'GitHub', windowId: 1, active: false, groupId: null },
{ id: 2, url: 'https://www.youtube.com/', title: 'YouTube', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
assert.equal(groups[0].kind, 'landing', 'landing group should be first');
@@ -337,7 +529,6 @@ test('buildPopupTabGroups groups file:// URLs under local-files domain', () => {
globalThis.popupState.openTabs = [
{ id: 1, url: 'file:///path/to/file', title: 'Local File', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
const localGroup = groups.find(g => g.domain === 'local-files');
@@ -346,20 +537,157 @@ test('buildPopupTabGroups groups file:// URLs under local-files domain', () => {
assert.equal(localGroup.tabs[0].id, 1);
});
test('buildPopupTabGroups skips tabs with unparseable URLs', () => {
test('buildPopupTabGroups keeps tabs with unparseable URLs in an ungrouped bucket', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
// Tabs with empty URLs can't be parsed — they are skipped entirely
// Tabs with empty URLs can't be parsed — they stay visible in an ungrouped bucket.
const tabA = { id: 1, url: '', title: 'Tab A', windowId: 1, active: false, groupId: 10 };
const tabB = { id: 2, url: '', title: 'Tab B', windowId: 1, active: false, groupId: 10 };
globalThis.popupState.openTabs = [tabA, tabB];
globalThis.popupState.tabGroups = [
{ id: 10, title: 'Research', color: 'blue', collapsed: false, tabs: [tabA, tabB] },
];
const groups = globalThis.buildPopupTabGroups();
assert.equal(groups.length, 0, 'tabs with unparseable URLs produce no groups');
assert.equal(groups.length, 1, 'unparseable tabs form a single ungrouped bucket');
assert.equal(groups[0].kind, 'ungrouped');
assert.equal(groups[0].tabs.length, 2);
});
test('buildPopupTabGroups groups by primary domain so www and bare variants merge', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
const originalPrimaryDomain = globalThis.TabOutIconUtils.getPrimaryDomain;
globalThis.TabOutIconUtils.getPrimaryDomain = hostname => String(hostname || '').replace(/^www\./, '').toLowerCase();
try {
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://www.github.com/a', title: 'A', windowId: 1, active: false },
{ id: 2, url: 'https://github.com/b', title: 'B', windowId: 1, active: false },
];
const groups = globalThis.buildPopupTabGroups();
assert.equal(groups.length, 1, 'www and bare variants share one group');
assert.equal(groups[0].domain, 'github.com');
assert.equal(groups[0].tabs.length, 2);
} finally {
globalThis.TabOutIconUtils.getPrimaryDomain = originalPrimaryDomain;
}
});
test('getGroupDisplayLabel honors persisted group label overrides', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
globalThis.popupState.groupLabelOverrides = { 'github.com': '工作代码', '__landing-pages__': '主页' };
const domainGroup = { domain: 'github.com', label: '', tabs: [], kind: 'domain' };
const landingGroup = { domain: '__landing-pages__', label: '__landing-pages__', tabs: [], kind: 'landing' };
assert.equal(globalThis.getGroupDisplayLabel(domainGroup), '工作代码');
assert.equal(globalThis.getGroupDisplayLabel(landingGroup), '主页');
});
test('getGroupDisplayLabel prefers the custom group label over the derived domain', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
const group = { domain: 'gh-org', label: 'GitHub Orgs', tabs: [], kind: 'custom' };
assert.equal(globalThis.getGroupDisplayLabel(group), 'GitHub Orgs');
});
test('renderPopupTabs updates content without replaying the entry animation', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
const classes = { nav: new Set(), list: new Set() };
const makeEl = (name) => ({
innerHTML: '',
classList: {
add: cls => classes[name].add(cls),
remove: cls => classes[name].delete(cls),
contains: cls => classes[name].has(cls),
},
});
const originalGet = globalThis.document.getElementById;
const originalBody = globalThis.document.body;
globalThis.document.body = { classList: { add: () => {} } };
globalThis.document.getElementById = id => {
if (id === 'popupTabsList') return makeEl('list');
if (id === 'popupGroupNav') return makeEl('nav');
if (id === 'popupTabsEmpty') return { hidden: false };
return null;
};
try {
// Drain any double-rAF callbacks queued by earlier tests.
flushRaf(); flushRaf(); flushRaf(); flushRaf();
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://example.com/', title: 'Example', windowId: 1, active: false },
];
globalThis.renderPopupTabs();
// Background refreshes must not hide the list: is-entering is only added
// by syncPopupView on an actual view switch.
assert.ok(!classes.list.has('is-entering'), 'content refresh must not add is-entering');
assert.ok(!classes.nav.has('is-entering'), 'nav refresh must not add is-entering');
flushRaf();
flushRaf();
assert.ok(classes.list.has('is-ready'), 'is-ready applied');
} finally {
globalThis.document.getElementById = originalGet;
globalThis.document.body = originalBody;
}
});
test('syncPopupView adds is-entering to the incoming panel only on view switch', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
const classes = { shortcuts: new Set(), tabs: new Set(), nav: new Set() };
const makeEl = (name) => ({
innerHTML: '',
hidden: false,
classList: {
add: cls => classes[name].add(cls),
remove: cls => classes[name].delete(cls),
toggle: (cls, on) => { classes[name][on ? 'add' : 'delete'](cls); },
contains: cls => classes[name].has(cls),
},
setAttribute: () => {},
});
const originalGet = globalThis.document.getElementById;
const originalBody = globalThis.document.body;
globalThis.document.body = { classList: { add: () => {} } };
globalThis.document.getElementById = id => {
const map = {
popupShortcutsTab: 'shortcuts',
popupTabsTab: 'tabs',
popupShortcutsPanel: 'shortcuts',
popupTabsPanel: 'tabs',
popupShortcutsList: 'shortcuts',
popupTabsList: 'tabs',
popupGroupNav: 'nav',
};
return map[id] ? makeEl(map[id]) : null;
};
try {
flushRaf(); flushRaf(); flushRaf(); flushRaf();
globalThis.popupState.view = 'tabs';
globalThis.syncPopupView();
assert.ok(classes.tabs.has('is-entering'), 'incoming tabs panel is hidden until its animation starts');
assert.ok(classes.nav.has('is-entering'), 'incoming nav is hidden until its animation starts');
assert.ok(!classes.shortcuts.has('is-entering'), 'outgoing shortcuts panel is not hidden');
flushRaf();
flushRaf();
assert.ok(classes.tabs.has('is-entering'), 'is-entering stays while the entrance animation plays');
assert.ok(classes.tabs.has('is-ready'), 'is-ready applied to the tabs panel');
// Same-view sync (background refresh) clears is-entering so replaced
// content never replays the entrance animation.
globalThis.syncPopupView();
assert.ok(!classes.tabs.has('is-entering'), 'same-view sync clears is-entering');
assert.ok(!classes.nav.has('is-entering'), 'same-view sync clears nav is-entering');
assert.ok(classes.tabs.has('is-ready'), 'is-ready is preserved across refreshes');
} finally {
globalThis.document.getElementById = originalGet;
globalThis.document.body = originalBody;
resetPopupTestState();
}
});
// ---- renderShortcutCard ----
@@ -635,7 +963,6 @@ test('buildPopupTabGroups groups tabs by custom group rules', () => {
{ id: 2, url: 'https://gitlab.com/project', title: 'GitLab', windowId: 1, active: false, groupId: null },
{ id: 3, url: 'https://other.com/page', title: 'Other', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
const gh = groups.find(g => g.kind === 'custom' && g.domain === 'github');
@@ -656,7 +983,6 @@ test('buildPopupTabGroups places landing pages before domain groups', () => {
{ id: 1, url: 'https://github.com/', title: 'GitHub Home', windowId: 1, active: false, groupId: null },
{ id: 2, url: 'https://other.com/page', title: 'Other', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
assert.equal(groups[0].kind, 'landing', 'landing group should be first');
@@ -674,7 +1000,6 @@ test('buildPopupTabGroups handles custom landing page patterns', () => {
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://news.ycombinator.com/news', title: 'HN', windowId: 1, active: false, groupId: null },
];
globalThis.popupState.tabGroups = [];
const groups = globalThis.buildPopupTabGroups();
const landing = groups.find(g => g.kind === 'landing');
@@ -708,7 +1033,7 @@ test('renderTabGroup reorders tabs by stored groupTabOrder', () => {
assert.ok(idxB < idxC, 'B should appear before C');
});
test('renderTabGroup deduplicates tabs by URL', () => {
test('renderTabGroup shows duplicate URLs as separate rows', () => {
resetPopupTestState({ landingPatterns: [] });
const group = {
@@ -723,11 +1048,20 @@ test('renderTabGroup deduplicates tabs by URL', () => {
const html = renderTabGroup(group, 0);
// Each tab row has data-tab-id in both the row <div> and the close <button>
const rowCount = (html.match(/popup-tab-row/g) || []).length;
assert.equal(rowCount, 1, 'duplicate URL tab should be removed, leaving 1 row');
assert.equal(rowCount, 2, 'duplicate URL tabs stay visible as separate rows');
});
// ---- isLandingPage: additional edge cases ----
test('isLandingPage treats bare Gmail inbox and sent hashes as content tabs', () => {
resetPopupTestState({ landingPatterns: [] });
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#inbox'), false, 'bare #inbox is a content tab');
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#inbox/12345'), false, 'open conversation is a content tab');
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#sent'), false, 'bare #sent is a content tab');
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/'), true, 'Gmail front page without a view is a landing');
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#label/work'), true, 'label views stay landing');
});
test('isLandingPage matches custom landing patterns', () => {
resetPopupTestState({
landingPatterns: [
+2 -2
View File
@@ -1181,7 +1181,7 @@ function detachSavedSessionTabToNewSession({
if (sessionManagerPage === 'saved-tabs') await renderSavedTabsPage();
showManagerToast(managerT
? managerT('toastSessionSaved', { count: result?.session?.tabs?.length || 0 })
: 'Session saved');
: `Saved ${result?.session?.tabs?.length || 0} tabs and closed the originals`);
} catch (error) {
console.error('[tab-harbor] Failed to save current window session:', error);
showManagerToast(getErrorToast());
@@ -1196,7 +1196,7 @@ function detachSavedSessionTabToNewSession({
await renderSavedTabsPage();
showManagerToast(managerT
? managerT('toastSessionRestored', { count: result?.restoredCount || 0 })
: 'Session restored');
: `Restored ${result?.restoredCount || 0} tabs`);
} catch (error) {
console.error('[tab-harbor] Failed to restore tab session:', error);
showManagerToast(getErrorToast());
+211 -35
View File
@@ -1,7 +1,8 @@
/* ============================================================
Tab Mission Control — Dashboard Styles
Extracted directly from mockup.html — do not modify unless
also updating the mockup. This is the approved design.
Tab Harbor — Dashboard Styles
Living stylesheet for the new-tab dashboard. Theme palettes
and their variables live in theme-controls.js (THEME_FAMILIES);
this sheet consumes them via var().
============================================================ */
:root {
@@ -66,6 +67,8 @@
--quick-shortcut-icon-wrap-size: 40px;
--quick-shortcut-icon-size: 22px;
--quick-shortcut-label-size: 11px;
/* User surface/border/badge intensity inputs (0-100) feed the derived
floating/panel opacity values below via clamp(). */
--floating-surface-opacity: clamp(
82%,
calc(var(--custom-surface-opacity) + 54%),
@@ -387,6 +390,20 @@ body.theme-tone-dark::before {
width: 100%;
}
/* Manual column count for landscape layouts — portrait (<=960px) keeps auto-fill */
@media (min-width: 961px) {
.quick-tabs-grid.is-fixed-cols-4 {
grid-template-columns: repeat(4, 1fr);
}
.quick-tabs-grid.is-fixed-cols-5 {
grid-template-columns: repeat(5, 1fr);
}
.quick-tabs-grid.is-fixed-cols-4 .quick-shortcut-card,
.quick-tabs-grid.is-fixed-cols-5 .quick-shortcut-card {
width: 100%;
}
}
.quick-shortcut-card {
position: relative;
width: var(--quick-shortcut-card-size);
@@ -1478,7 +1495,16 @@ body.theme-tone-dark .deferred-shell {
flex: 0 0 13ch;
}
#openTabsSection .section-count,
/* The open-tabs count now hosts up to five 30px icon buttons; it must size to
its content instead of the old 96px text-count column, or the buttons get
compressed. The saved-sessions count keeps its fixed column. */
#openTabsSection .section-count {
width: auto;
flex: 0 0 auto;
justify-content: flex-end;
gap: 8px;
}
.saved-sessions-section .section-count {
width: 96px;
flex: 0 0 96px;
@@ -1528,7 +1554,10 @@ body.theme-tone-dark .deferred-shell {
flex-wrap: nowrap;
flex: 1;
min-width: 0;
min-height: 40px;
/* Top headroom so the hover lift (translateY(-1px)) never clips the
button's top edge against overflow-y: hidden. */
min-height: 42px;
padding-top: 2px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
@@ -1558,6 +1587,7 @@ body.theme-tone-dark .deferred-shell {
.group-nav-button {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 999px;
border: 1px solid
color-mix(
@@ -1687,7 +1717,7 @@ body.group-dragging .group-nav-button {
var(--workspace-accent-border) 26%
);
border-radius: 16px;
padding: 14px 16px;
padding: 16px 16px 15px;
display: flex;
flex-direction: column;
gap: 8px;
@@ -1704,7 +1734,9 @@ body.group-dragging .group-nav-button {
color-mix(in srgb, var(--workspace-accent) 6%, transparent);
}
/* Colored top border instead of side bar for grid cards */
/* Colored top border instead of side bar for grid cards. The base ::before
rule is currently disabled (see the commented block below), so the
has-*-bar state rules are inert until the base rule is restored. */
/*.mission-card::before {*/
/* content: '';*/
/* position: absolute;*/
@@ -1725,6 +1757,39 @@ body.group-dragging .group-nav-button {
background: var(--warm-gray);
}
/* User-created Chrome group cards wear their native group color on the card
name and the tab rows' drag handles — a quiet accent that follows the
group's real Chrome color without adding a separate bar. The NAME uses
the pure native group color so it visually matches the group chip in the
browser tab strip (light palettes may show lower contrast for very light
group colors — accepted tradeoff for visual consistency). Hover/focus
feedback uses an underline instead of a color shift so keyboard focus
stays visible without relying on contrast. The drag handles keep the
pure group color too. */
.mission-card.chrome-group-card .mission-name {
color: var(--chrome-group-color, var(--ink));
}
.mission-card.chrome-group-card .mission-rename-trigger:hover .mission-name,
.mission-card.chrome-group-card .mission-rename-trigger:focus-visible .mission-name {
color: var(--chrome-group-color, var(--ink));
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1.5px;
}
.mission-card.chrome-group-card .chip-reorder-handle {
color: var(--chrome-group-color, color-mix(in srgb, var(--ink) 84%, var(--muted) 16%));
}
.mission-card.chrome-group-card .chip-reorder-handle:hover,
.mission-card.chrome-group-card .chip-reorder-handle:focus-visible {
/* Hover/focus must be visibly different from the resting pure group
color; mix toward the ink so the feedback stays readable in light
themes too (C29). */
color: color-mix(in srgb, var(--chrome-group-color, var(--ink)) 55%, var(--ink));
}
.mission-card:hover {
box-shadow: 0 16px 30px
color-mix(in srgb, var(--workspace-accent) 12%, transparent);
@@ -1944,16 +2009,102 @@ body.group-dragging .group-nav-button {
line-height: 1.4;
}
/* Handle-click selection: selected rows reorder together when dragged. */
.page-chip.is-selected {
background: color-mix(in srgb, var(--workspace-chip-bg) 82%, var(--workspace-accent-border) 18%);
box-shadow: inset 2px 0 0 var(--workspace-accent-border);
}
/* During a drag the rest of the selection dims so the moving set is obvious. */
body.page-chip-list-dragging .page-chip.is-selected:not(.is-dragging) {
opacity: 0.55;
}
/* Batch drag count badge (shown when more than one row is moving). */
.page-chip-drag-badge {
position: fixed;
z-index: 9999;
transform: translate(-50%, -100%);
background: var(--workspace-chip-bg-strong);
border: 1px solid var(--workspace-chip-border);
color: var(--workspace-chip-text);
font-size: var(--ui-font-12);
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
pointer-events: none;
box-shadow: 0 2px 8px rgba(26, 22, 19, 0.18);
}
/* Batch actions replace the section header's icon actions while a
multi-selection exists — same row, same height, same quiet style. */
.page-chip-batch-bar {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.page-chip-batch-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
color: color-mix(in srgb, var(--muted) 82%, var(--ink) 18%);
cursor: pointer;
transition:
color 0.15s ease,
opacity 0.15s ease;
position: relative;
}
.page-chip-batch-action svg {
width: 14px;
height: 14px;
}
.page-chip-batch-action:hover {
color: var(--ink);
}
.page-chip-batch-action[data-action="batch-close-tabs"]:hover {
color: var(--status-abandoned);
}
.page-chip-batch-action:focus-visible {
outline: 2px solid var(--workspace-accent-border);
outline-offset: 1px;
}
/* The selection count replaces the section title; let it size to its own
content instead of the fixed 13ch column. */
#openTabsSection.has-chip-selection .section-header h2 {
width: auto;
flex: 0 0 auto;
}
/* The grip is a pure drag surface: no scrolling/selection gestures on touch. */
.chip-reorder-handle {
touch-action: none;
}
.page-chip > .chip-reorder-handle {
flex-shrink: 0;
margin-left: 1px;
width: 30px;
height: 30px;
/* Generous grab target (>=32px) without inflating the row height. */
height: 36px;
}
.page-chip:last-child,
.page-chip-overflow {
border-bottom: none;
/* Overflow rows ("+N more" expansion) stay out of the list until expanded.
They are direct list children so drag/selection still see them. */
.page-chip--collapsed {
display: none;
}
/* Favicon */
@@ -1994,7 +2145,7 @@ body.group-dragging .group-nav-button {
margin-right: 4px;
}
/* Title text — wraps naturally, no truncation */
/* Title text — wraps up to 2 lines, then ellipsis */
.chip-text {
flex: 1;
min-width: 0;
@@ -2005,12 +2156,6 @@ body.group-dragging .group-nav-button {
overflow: hidden;
}
/* Duplicate badge */
.chip-dupe-badge {
color: var(--accent-amber);
font-weight: 600;
}
/* Duplicate border highlight */
.chip-has-dupes {
border-color: rgba(200, 113, 58, 0.25);
@@ -2152,8 +2297,6 @@ body.group-dragging .group-nav-button {
padding: 4px 2px;
}
/* mission-meta hidden — see above */
.mission-time {
font-size: 12px;
font-size: var(--ui-font-12);
@@ -2179,19 +2322,13 @@ body.group-dragging .group-nav-button {
}
/* ---- Actions row ---- */
.actions {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.section-icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
flex-shrink: 0;
padding: 0;
border: none;
background: transparent;
@@ -2219,6 +2356,7 @@ body.group-dragging .group-nav-button {
.chip-action[data-tooltip]::after,
.group-action-icon[data-tooltip]::after,
.section-icon-action[data-tooltip]::after,
.page-chip-batch-action[data-tooltip]::after,
.todo-action-btn[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
@@ -2250,6 +2388,8 @@ body.group-dragging .group-nav-button {
.group-action-icon[data-tooltip]:focus-visible::after,
.section-icon-action[data-tooltip]:hover::after,
.section-icon-action[data-tooltip]:focus-visible::after,
.page-chip-batch-action[data-tooltip]:hover::after,
.page-chip-batch-action[data-tooltip]:focus-visible::after,
.todo-action-btn[data-tooltip]:hover::after,
.todo-action-btn[data-tooltip]:focus-visible::after {
opacity: 1;
@@ -2354,7 +2494,7 @@ body.group-dragging .group-nav-button {
}
.workspace-page-switch-btn {
min-height: 24px;
min-height: 28px;
padding: 0;
border: none;
border-radius: 0;
@@ -2362,7 +2502,7 @@ body.group-dragging .group-nav-button {
color: color-mix(in srgb, var(--muted) 82%, var(--ink) 18%);
cursor: pointer;
font-family: "Public Sans", sans-serif;
font-size: var(--ui-font-12);
font-size: var(--ui-font-14);
font-weight: 600;
transition:
color 0.15s ease,
@@ -2837,6 +2977,40 @@ body.group-dragging .group-nav-button {
box-shadow: var(--focus-ring-shadow);
}
.theme-menu-text-input {
width: 100%;
min-height: 34px;
border: 1px solid
color-mix(
in srgb,
var(--workspace-accent-border) 28%,
var(--warm-gray) 72%
);
border-radius: 10px;
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
color: var(--ink);
padding: 0 10px;
font-family: "Public Sans", sans-serif;
font-size: var(--ui-font-12);
outline: none;
}
.theme-menu-text-input:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
box-shadow: var(--focus-ring-shadow);
}
.theme-menu-text-input::placeholder {
color: var(--muted);
}
.theme-menu-hint {
font-size: var(--ui-font-10);
color: var(--muted);
line-height: 1.45;
}
.saved-session-nav-button.is-name-mode {
width: auto;
max-width: 220px;
@@ -3599,7 +3773,10 @@ footer {
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(12px);
/* Rise offset stays below the 12px card/deferred-item rhythm so a
rising element never overlaps the one below it (no "blocked by the
card above" flash during entrance). */
transform: translateY(8px);
}
to {
opacity: 1;
@@ -3630,7 +3807,7 @@ body.entry-animations-enabled .deferred-column .section-header {
}
/* ================================================================
STATIC DEFAULT VIEW — new elements added for "AI on demand"
MISSIONS CARDS — DEFAULT VIEW
================================================================ */
/* ---- Neutral status bar (domain-grouped cards, no AI color) ---- */
@@ -3651,9 +3828,8 @@ body.entry-animations-enabled .deferred-column .section-header {
================================================================ */
.missions-empty-state {
/* Takes the full width — spans all columns */
column-span: all;
/* Full-width strip (column-span is inert without a multi-column
container; the missions area is a grid) */
display: flex;
flex-direction: column;
align-items: center;
@@ -3666,7 +3842,7 @@ body.entry-animations-enabled .deferred-column .section-header {
animation: fadeUp 0.5s ease both;
}
/* CSS-drawn checkmark in a glowing circle */
/* Icon in a CSS-drawn glowing circle */
.empty-checkmark {
width: 56px;
height: 56px;
+38 -5
View File
@@ -13,6 +13,7 @@
const SAVED_TAB_SESSIONS_KEY = 'savedTabSessions';
const MANUAL_GROUP_PREFIX = '__session_group__:';
const CHROME_GROUP_PREFIX = '__chrome_group__:';
function createSessionId(now = new Date()) {
return `tab-session-${now.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -105,6 +106,7 @@
groupKey: normalizeString(input.groupKey),
groupLabel: normalizeString(input.groupLabel),
manualGroupId: normalizeString(input.manualGroupId),
chromeGroupColor: normalizeString(input.chromeGroupColor),
};
}
@@ -122,6 +124,7 @@
key,
label: normalizeString(input.label) || 'Group',
manualGroupId: normalizeString(input.manualGroupId),
chromeGroupColor: normalizeString(input.chromeGroupColor),
tabUrls: [...new Set(tabUrls)],
};
}
@@ -164,6 +167,7 @@
key,
label: normalizeString(tab?.groupLabel) || 'Group',
manualGroupId: normalizeString(tab?.manualGroupId),
chromeGroupColor: normalizeString(tab?.chromeGroupColor),
tabUrls: [],
});
}
@@ -243,6 +247,7 @@
key,
label: normalizeString(entry.label) || 'Group',
manualGroupId: normalizeString(entry.manualGroupId),
chromeGroupColor: normalizeString(entry.chromeGroupColor),
};
}
@@ -279,6 +284,7 @@
groupKey: lookupEntry?.key || '',
groupLabel: lookupEntry?.label || '',
manualGroupId: lookupEntry?.manualGroupId || '',
chromeGroupColor: lookupEntry?.chromeGroupColor || '',
};
savedTabs.push(savedTab);
@@ -289,6 +295,7 @@
key: lookupEntry.key,
label: lookupEntry.label,
manualGroupId: lookupEntry.manualGroupId,
chromeGroupColor: lookupEntry.chromeGroupColor,
tabUrls: [],
});
}
@@ -335,10 +342,14 @@
restoredByUrl.get(canonicalUrl).push(String(tab.id));
}
// Native Chrome groups cannot be re-created here (no chrome.* access in
// this module) — return ordered plans for the caller to execute.
const chromeGroupPlans = [];
const sessionGroups = Array.isArray(session?.groups) ? session.groups : [];
sessionGroups
.filter(group => normalizeString(group?.key).startsWith(MANUAL_GROUP_PREFIX))
.forEach((group, index) => {
sessionGroups.forEach((group, index) => {
const groupKey = normalizeString(group?.key);
if (groupKey.startsWith(MANUAL_GROUP_PREFIX)) {
const groupName = normalizeString(group.label) || 'Restored group';
const groupId = `restored-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`;
normalizedState.groups.push({
@@ -352,9 +363,31 @@
const tabId = tabIds.shift();
if (tabId) normalizedState.assignments[tabId] = groupId;
}
});
} else if (groupKey.startsWith(CHROME_GROUP_PREFIX)) {
// Restore as a fresh native Chrome group: title + color + the tab
// order recorded in tabUrls (the native group id is session-local and
// cannot be reused). Every restored tab matching a recorded url joins —
// a url appears once in tabUrls but may map to several restored tabs.
const planTabIds = [];
for (const url of Array.isArray(group.tabUrls) ? group.tabUrls : []) {
const tabIds = restoredByUrl.get(url) || [];
if (tabIds.length) {
planTabIds.push(...tabIds);
restoredByUrl.set(url, []);
}
}
if (planTabIds.length > 0) {
chromeGroupPlans.push({
title: normalizeString(group.label) || 'Restored group',
color: normalizeString(group.chromeGroupColor) || 'grey',
tabIds: planTabIds,
});
}
}
// Domain keys (plain hostnames) are derived automatically on restore.
});
return normalizedState;
return { state: normalizedState, chromeGroupPlans };
}
async function getSavedTabSessions() {
+195 -8
View File
@@ -4,13 +4,15 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const {
addSavedTabSession,
appendSavedTabSessionTabs,
buildSessionSnapshot,
createSavedTabSessionFromTab,
createRestoredSessionGroups,
renameSavedTabSession,
appendSavedTabSessionTabs,
updateSavedTabSessionTabs,
getSavedTabSessions,
normalizeSavedTabSessions,
renameSavedTabSession,
updateSavedTabSessionTabs,
} = require('./tab-sessions.js');
test('buildSessionSnapshot captures selected tabs with canonical urls and manual group metadata', () => {
@@ -42,7 +44,51 @@ test('buildSessionSnapshot captures selected tabs with canonical urls and manual
assert.equal(snapshot.tabs[0].url, 'https://github.com/V-IOLE-T/tab-harbor/issues/25');
assert.equal(snapshot.tabs[0].title, 'Issue Thread');
assert.deepEqual(snapshot.groups, [
{ key: '__session_group__:research', label: 'Research', manualGroupId: 'research', tabUrls: ['https://github.com/V-IOLE-T/tab-harbor/issues/25'] },
{ key: '__session_group__:research', label: 'Research', manualGroupId: 'research', chromeGroupColor: '', tabUrls: ['https://github.com/V-IOLE-T/tab-harbor/issues/25'] },
]);
});
test('buildSessionSnapshot records chrome group membership with color and tab order', () => {
const snapshot = buildSessionSnapshot({
tabs: [
{ id: 21, url: 'https://a.test/1', title: 'A1', windowId: 1 },
{ id: 22, url: 'https://b.test/1', title: 'B1', windowId: 1 },
],
groupLookup: new Map([
['21', { key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue' }],
['22', { key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue' }],
]),
selectedTabIds: ['21', '22'],
source: 'selected',
now: '2026-05-22T08:00:00.000Z',
});
assert.equal(snapshot.tabs.length, 2);
assert.deepEqual(snapshot.groups, [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue', tabUrls: ['https://a.test/1', 'https://b.test/1'] },
]);
});
test('buildSessionSnapshot partial selection keeps the group identity with only the selected members', () => {
const snapshot = buildSessionSnapshot({
tabs: [
{ id: 31, url: 'https://x.test/1', title: 'X1', windowId: 1 },
{ id: 32, url: 'https://x.test/2', title: 'X2', windowId: 1 },
{ id: 33, url: 'https://x.test/3', title: 'X3', windowId: 1 },
],
groupLookup: new Map([
['31', { key: '__session_group__:g1', label: 'G1', manualGroupId: 'g1' }],
['32', { key: '__session_group__:g1', label: 'G1', manualGroupId: 'g1' }],
['33', { key: '__session_group__:g1', label: 'G1', manualGroupId: 'g1' }],
]),
selectedTabIds: ['31'],
source: 'selected',
now: '2026-05-22T08:00:00.000Z',
});
assert.equal(snapshot.tabs.length, 1);
assert.deepEqual(snapshot.groups, [
{ key: '__session_group__:g1', label: 'G1', manualGroupId: 'g1', chromeGroupColor: '', tabUrls: ['https://x.test/1'] },
]);
});
@@ -89,10 +135,150 @@ test('createRestoredSessionGroups creates manual groups for restored manual sess
now: '2026-05-22T08:00:00.000Z',
});
assert.equal(restored.groups.length, 1);
assert.equal(restored.groups[0].name, 'Research');
assert.equal(restored.assignments['101'], restored.groups[0].id);
assert.equal(restored.assignments['102'], restored.groups[0].id);
assert.equal(restored.state.groups.length, 1);
assert.equal(restored.state.groups[0].name, 'Research');
assert.equal(restored.state.assignments['101'], restored.state.groups[0].id);
assert.equal(restored.state.assignments['102'], restored.state.groups[0].id);
assert.deepEqual(restored.chromeGroupPlans, []);
});
test('createRestoredSessionGroups returns chrome group plans with ordered tab ids', () => {
const restored = createRestoredSessionGroups({
existingState: { groups: [], assignments: {} },
session: {
groups: [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue', tabUrls: ['https://a.test/1', 'https://b.test/1'] },
// Domain groups are derived automatically on restore and never planned.
{ key: 'example.com', label: 'Example', tabUrls: ['https://a.test/1'] },
],
},
restoredTabs: [
{ id: 201, url: 'https://a.test/1' },
{ id: 202, url: 'https://b.test/1' },
],
now: '2026-05-22T08:00:00.000Z',
});
assert.equal(restored.state.groups.length, 0);
assert.deepEqual(restored.chromeGroupPlans, [
{ title: 'Work', color: 'blue', tabIds: ['201', '202'] },
]);
});
test('createRestoredSessionGroups chrome plan skips tabs that were not restored', () => {
const restored = createRestoredSessionGroups({
existingState: { groups: [], assignments: {} },
session: {
groups: [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'red', tabUrls: ['https://a.test/1', 'https://b.test/1', 'https://c.test/1'] },
],
},
restoredTabs: [
{ id: 301, url: 'https://a.test/1' },
// https://b.test/1 and https://c.test/1 were not restored
],
now: '2026-05-22T08:00:00.000Z',
});
assert.deepEqual(restored.chromeGroupPlans, [
{ title: 'Work', color: 'red', tabIds: ['301'] },
]);
});
test('createRestoredSessionGroups chrome plan includes every restored tab sharing a recorded url', () => {
const restored = createRestoredSessionGroups({
existingState: { groups: [], assignments: {} },
session: {
groups: [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue', tabUrls: ['https://a.test/1'] },
],
},
restoredTabs: [
{ id: 401, url: 'https://a.test/1' },
{ id: 402, url: 'https://a.test/1' },
],
now: '2026-05-22T08:00:00.000Z',
});
assert.deepEqual(restored.chromeGroupPlans, [
{ title: 'Work', color: 'blue', tabIds: ['401', '402'] },
]);
});
test('chrome group colors survive the save to reload storage round-trip', async () => {
const store = {};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
const snapshot = buildSessionSnapshot({
tabs: [
{ id: 51, url: 'https://a.test/1', title: 'A1', windowId: 1 },
{ id: 52, url: 'https://b.test/1', title: 'B1', windowId: 1 },
],
groupLookup: new Map([
['51', { key: '__chrome_group__:88', label: 'Pink', manualGroupId: '', chromeGroupColor: 'pink' }],
['52', { key: '__chrome_group__:88', label: 'Pink', manualGroupId: '', chromeGroupColor: 'pink' }],
]),
selectedTabIds: ['51', '52'],
source: 'selected',
now: '2026-05-22T08:00:00.000Z',
});
const [saved] = await addSavedTabSession(snapshot);
const reloaded = await getSavedTabSessions();
assert.equal(saved.groups[0].chromeGroupColor, 'pink');
assert.equal(reloaded[0].groups[0].chromeGroupColor, 'pink');
});
test('updateSavedTabSessionTabs rebuilds groups keeping chrome group colors', async () => {
const store = {
savedTabSessions: [
{
id: 'session-a',
name: 'Session',
savedAt: '2026-05-22T08:00:00.000Z',
source: 'manual',
tabs: [
{ url: 'https://a.test', title: 'A', groupKey: '__chrome_group__:77', groupLabel: 'Work', chromeGroupColor: 'blue' },
],
groups: [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue', tabUrls: ['https://a.test'] },
],
},
],
};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
const updated = await updateSavedTabSessionTabs('session-a', [
{ url: 'https://b.test', title: 'B', groupKey: '__chrome_group__:77', groupLabel: 'Work', chromeGroupColor: 'blue' },
{ url: 'https://a.test', title: 'A', groupKey: '__chrome_group__:77', groupLabel: 'Work', chromeGroupColor: 'blue' },
]);
assert.deepEqual(updated[0].groups, [
{ key: '__chrome_group__:77', label: 'Work', manualGroupId: '', chromeGroupColor: 'blue', tabUrls: ['https://b.test', 'https://a.test'] },
]);
});
test('renameSavedTabSession updates the matching session name', async () => {
@@ -310,6 +496,7 @@ test('createSavedTabSessionFromTab derives name, source, savedAt, tabs, and grou
key: '__session_group__:bugs',
label: 'Bugs',
manualGroupId: 'bugs',
chromeGroupColor: '',
tabUrls: ['https://github.com/openai/openai-node/issues/123'],
},
]);
+92
View File
@@ -62,10 +62,25 @@ const THEME_MODE_ORDER = ['system', 'light', 'dark'];
const THEME_PALETTE_ORDER = ['paper', 'sage', 'mist', 'blush'];
const SAVED_SESSION_RESTORE_MODE_ORDER = ['current-window', 'new-window'];
const SAVED_SESSION_NAV_DISPLAY_MODE_ORDER = ['icon', 'name'];
const QUICK_SHORTCUT_OPEN_MODE_ORDER = ['new-tab', 'current-tab'];
const QUICK_SHORTCUT_COLS_ORDER = ['auto', '4', '5'];
const SEARCH_ENGINE_PRESETS = {
google: { name: 'Google', url: 'https://www.google.com/search?q=' },
bing: { name: 'Bing', url: 'https://www.bing.com/search?q=' },
baidu: { name: 'Baidu', url: 'https://www.baidu.com/s?wd=' },
sogou: { name: 'Sogou', url: 'https://www.sogou.com/web?query=' },
duckduckgo: { name: 'DuckDuckGo', url: 'https://duckduckgo.com/?q=' },
brave: { name: 'Brave Search', url: 'https://search.brave.com/search?q=' },
yandex: { name: 'Yandex', url: 'https://yandex.com/search/?text=' },
};
const SEARCH_ENGINE_ORDER = ['default', ...Object.keys(SEARCH_ENGINE_PRESETS), 'custom'];
const VALID_THEME_MODES = new Set(THEME_MODE_ORDER);
const VALID_THEME_PALETTES = new Set(THEME_PALETTE_ORDER);
const VALID_SAVED_SESSION_RESTORE_MODES = new Set(SAVED_SESSION_RESTORE_MODE_ORDER);
const VALID_SAVED_SESSION_NAV_DISPLAY_MODES = new Set(SAVED_SESSION_NAV_DISPLAY_MODE_ORDER);
const VALID_QUICK_SHORTCUT_OPEN_MODES = new Set(QUICK_SHORTCUT_OPEN_MODE_ORDER);
const VALID_QUICK_SHORTCUT_COLS = new Set(QUICK_SHORTCUT_COLS_ORDER);
const VALID_SEARCH_ENGINES = new Set(SEARCH_ENGINE_ORDER);
const THEME_MODE_LABEL_KEYS = {
system: 'themeModeSystem',
light: 'themeModeLight',
@@ -253,6 +268,10 @@ let themePreferences = {
closeDuplicateNewTabsEnabled: false,
savedSessionRestoreMode: 'new-window',
savedSessionNavDisplayMode: 'name',
quickShortcutOpenMode: 'new-tab',
quickShortcutCols: 'auto',
searchEngine: 'default',
customSearchUrl: '',
};
let systemThemeMediaQuery = null;
@@ -278,6 +297,9 @@ function normalizeThemePreferences(input) {
: 100;
const rawSavedSessionRestoreMode = String(next.savedSessionRestoreMode || 'new-window');
const rawSavedSessionNavDisplayMode = String(next.savedSessionNavDisplayMode || 'name');
const rawQuickShortcutOpenMode = String(next.quickShortcutOpenMode || 'new-tab');
const rawQuickShortcutCols = String(next.quickShortcutCols || 'auto');
const rawSearchEngine = String(next.searchEngine || 'default');
return {
mode: VALID_THEME_MODES.has(rawMode) ? rawMode : 'system',
paletteId: VALID_THEME_PALETTES.has(rawPaletteId) ? rawPaletteId : 'paper',
@@ -290,9 +312,46 @@ function normalizeThemePreferences(input) {
closeDuplicateNewTabsEnabled: next.closeDuplicateNewTabsEnabled === true,
savedSessionRestoreMode: VALID_SAVED_SESSION_RESTORE_MODES.has(rawSavedSessionRestoreMode) ? rawSavedSessionRestoreMode : 'new-window',
savedSessionNavDisplayMode: VALID_SAVED_SESSION_NAV_DISPLAY_MODES.has(rawSavedSessionNavDisplayMode) ? rawSavedSessionNavDisplayMode : 'name',
quickShortcutOpenMode: VALID_QUICK_SHORTCUT_OPEN_MODES.has(rawQuickShortcutOpenMode) ? rawQuickShortcutOpenMode : 'new-tab',
quickShortcutCols: VALID_QUICK_SHORTCUT_COLS.has(rawQuickShortcutCols) ? rawQuickShortcutCols : 'auto',
searchEngine: VALID_SEARCH_ENGINES.has(rawSearchEngine) ? rawSearchEngine : 'default',
customSearchUrl: typeof next.customSearchUrl === 'string' ? next.customSearchUrl : '',
};
}
function getQuickShortcutOpenMode(preferences = themePreferences) {
return normalizeThemePreferences(preferences).quickShortcutOpenMode;
}
function getQuickShortcutCols(preferences = themePreferences) {
return normalizeThemePreferences(preferences).quickShortcutCols;
}
function getSearchEngine(preferences = themePreferences) {
return normalizeThemePreferences(preferences).searchEngine;
}
function getCustomSearchUrl(preferences = themePreferences) {
return normalizeThemePreferences(preferences).customSearchUrl;
}
function buildSearchUrlForQuery(query, preferences = themePreferences) {
const text = String(query || '').trim();
if (!text) return '';
const prefs = normalizeThemePreferences(preferences);
let url = '';
if (prefs.searchEngine === 'custom') {
url = String(prefs.customSearchUrl || '').trim();
} else if (prefs.searchEngine !== 'default') {
url = SEARCH_ENGINE_PRESETS[prefs.searchEngine]?.url || '';
}
if (!url) return '';
const encoded = encodeURIComponent(text);
if (url.includes('{query}')) return url.replace(/\{query\}/g, encoded);
if (url.includes('%s')) return url.replace(/%s/g, encoded);
return `${url}${encoded}`;
}
function getSavedSessionRestoreMode(preferences = themePreferences) {
return normalizeThemePreferences(preferences).savedSessionRestoreMode;
}
@@ -525,6 +584,12 @@ function applyThemePreferences() {
body.classList.toggle('theme-tone-dark', theme.tone === 'dark');
}
const quickTabsList = document.getElementById('quickTabsList');
if (quickTabsList) {
quickTabsList.classList.toggle('is-fixed-cols-4', themePreferences.quickShortcutCols === '4');
quickTabsList.classList.toggle('is-fixed-cols-5', themePreferences.quickShortcutCols === '5');
}
if (themePreferences.customBackground) {
root.style.setProperty('--page-custom-background', `url("${themePreferences.customBackground}")`);
if (body) {
@@ -1366,11 +1431,16 @@ function renderQuickShortcutAddCard() {
`;
}
let lastQuickShortcutsRenderKey = '';
async function renderQuickShortcuts() {
const list = document.getElementById('quickTabsList');
if (!list) return;
const shortcuts = await getQuickShortcuts();
const renderKey = `${JSON.stringify(shortcuts)}|${getQuickShortcutCols()}`;
if (renderKey === lastQuickShortcutsRenderKey && list.innerHTML) return;
lastQuickShortcutsRenderKey = renderKey;
list.innerHTML = `${shortcuts.map(renderQuickShortcutCard).join('')}${renderQuickShortcutAddCard()}`;
}
@@ -1856,6 +1926,22 @@ document.addEventListener('click', async (e) => {
if (Date.now() < quickShortcutSuppressClickUntil) return;
const url = actionEl.dataset.shortcutUrl;
if (!url) return;
if (e.ctrlKey || e.metaKey) {
await chrome.tabs.create({ url, active: false });
return;
}
if (e.shiftKey) {
await chrome.windows.create({ url, focused: true });
return;
}
if (getQuickShortcutOpenMode() === 'current-tab') {
if (typeof navigateCurrentTabToUrl === 'function') {
const navigated = await navigateCurrentTabToUrl(url).catch(() => false);
if (navigated) return;
}
await openOrFocusUrl(url);
return;
}
await openOrFocusUrl(url);
return;
}
@@ -2151,9 +2237,15 @@ async function saveSavedSessionNavDisplayMode(mode) {
}
globalThis.TabOutThemeControls = {
SEARCH_ENGINE_PRESETS,
buildSearchUrlForQuery,
filterRealTabs,
getCustomSearchUrl,
getQuickShortcutCols,
getQuickShortcutOpenMode,
getSavedSessionNavDisplayMode,
getSavedSessionRestoreMode,
getSearchEngine,
getResolvedThemeDefinition,
getResolvedTone,
getQuickShortcuts,
+81
View File
@@ -24,6 +24,7 @@ globalThis.window = {
require('./theme-controls.js');
const {
buildSearchUrlForQuery,
filterRealTabs,
getResolvedThemeDefinition,
getResolvedTone,
@@ -91,6 +92,86 @@ test('normalizeThemePreferences falls back to new-window for invalid saved sessi
assert.equal(result.savedSessionNavDisplayMode, 'name');
});
test('normalizeThemePreferences defaults quick shortcut open mode to new-tab', () => {
const result = normalizeThemePreferences({});
assert.equal(result.quickShortcutOpenMode, 'new-tab');
});
test('normalizeThemePreferences preserves quick shortcut open mode when current-tab', () => {
const result = normalizeThemePreferences({ quickShortcutOpenMode: 'current-tab' });
assert.equal(result.quickShortcutOpenMode, 'current-tab');
});
test('normalizeThemePreferences falls back to new-tab for invalid quick shortcut open mode', () => {
const result = normalizeThemePreferences({ quickShortcutOpenMode: 'new-window' });
assert.equal(result.quickShortcutOpenMode, 'new-tab');
});
test('normalizeThemePreferences defaults quick shortcut columns to auto', () => {
const result = normalizeThemePreferences({});
assert.equal(result.quickShortcutCols, 'auto');
});
test('normalizeThemePreferences preserves quick shortcut columns when fixed', () => {
const result = normalizeThemePreferences({ quickShortcutCols: '4' });
assert.equal(result.quickShortcutCols, '4');
assert.equal(normalizeThemePreferences({ quickShortcutCols: '5' }).quickShortcutCols, '5');
});
test('normalizeThemePreferences falls back to auto for invalid quick shortcut columns', () => {
const result = normalizeThemePreferences({ quickShortcutCols: '6' });
assert.equal(result.quickShortcutCols, 'auto');
});
test('normalizeThemePreferences defaults search engine to browser default', () => {
const result = normalizeThemePreferences({});
assert.equal(result.searchEngine, 'default');
assert.equal(result.customSearchUrl, '');
});
test('normalizeThemePreferences preserves search engine preset and custom URL', () => {
const result = normalizeThemePreferences({ searchEngine: 'baidu', customSearchUrl: 'https://example.com/search?q={query}' });
assert.equal(result.searchEngine, 'baidu');
assert.equal(result.customSearchUrl, 'https://example.com/search?q={query}');
});
test('normalizeThemePreferences falls back to default for invalid search engine', () => {
const result = normalizeThemePreferences({ searchEngine: 'yahoo' });
assert.equal(result.searchEngine, 'default');
});
test('buildSearchUrlForQuery returns empty for browser default engine', () => {
assert.equal(buildSearchUrlForQuery('hello', { searchEngine: 'default' }), '');
assert.equal(buildSearchUrlForQuery(''), '');
});
test('buildSearchUrlForQuery uses preset search URLs with encoded query', () => {
const googleUrl = buildSearchUrlForQuery('hello world', { searchEngine: 'google' });
assert.equal(googleUrl, 'https://www.google.com/search?q=hello%20world');
const baiduUrl = buildSearchUrlForQuery('测试', { searchEngine: 'baidu' });
assert.equal(baiduUrl, 'https://www.baidu.com/s?wd=' + encodeURIComponent('测试'));
});
test('buildSearchUrlForQuery replaces placeholders in custom URL', () => {
const braces = buildSearchUrlForQuery('a b', { searchEngine: 'custom', customSearchUrl: 'https://x.example/s?q={query}' });
assert.equal(braces, 'https://x.example/s?q=a%20b');
const percent = buildSearchUrlForQuery('a b', { searchEngine: 'custom', customSearchUrl: 'https://x.example/s?q=%s' });
assert.equal(percent, 'https://x.example/s?q=a%20b');
const appended = buildSearchUrlForQuery('a b', { searchEngine: 'custom', customSearchUrl: 'https://x.example/s?q=' });
assert.equal(appended, 'https://x.example/s?q=a%20b');
});
test('buildSearchUrlForQuery uses per-engine params for sogou and yandex', () => {
const sogouUrl = buildSearchUrlForQuery('测试', { searchEngine: 'sogou' });
assert.equal(sogouUrl, 'https://www.sogou.com/web?query=' + encodeURIComponent('测试'));
const yandexUrl = buildSearchUrlForQuery('test query', { searchEngine: 'yandex' });
assert.equal(yandexUrl, 'https://yandex.com/search/?text=test%20query');
});
test('buildSearchUrlForQuery returns empty when custom URL is unset', () => {
assert.equal(buildSearchUrlForQuery('hello', { searchEngine: 'custom', customSearchUrl: '' }), '');
});
test('normalizeThemePreferences defaults closeDuplicateNewTabsEnabled to false', () => {
const result = normalizeThemePreferences({});
assert.equal(result.closeDuplicateNewTabsEnabled, false);
+3
View File
@@ -500,4 +500,7 @@ const ICONS = {
move: `<svg 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="M7.5 6.75h12m-12 5.25h12m-12 5.25h12M4.5 6.75h.008v.008H4.5V6.75Zm0 5.25h.008v.008H4.5V12Zm0 5.25h.008v.008H4.5v-.008Z" /></svg>`,
pin: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none" aria-hidden="true"><path d="M648.728381 130.779429a73.142857 73.142857 0 0 1 22.674286 15.433142l191.561143 191.756191a73.142857 73.142857 0 0 1-22.137905 118.564571l-67.876572 30.061715-127.341714 127.488-10.093714 140.239238a73.142857 73.142857 0 0 1-124.684191 46.445714l-123.66019-123.782095-210.724572 211.699809-51.833904-51.614476 210.846476-211.821714-127.926857-128.024381a73.142857 73.142857 0 0 1 46.299428-124.635429l144.237715-10.776381 125.074285-125.220571 29.379048-67.779048a73.142857 73.142857 0 0 1 96.207238-38.034285z m-29.086476 67.120761l-34.913524 80.530286-154.087619 154.331429-171.398095 12.751238 303.323428 303.542857 12.044191-167.399619 156.233143-156.428191 80.384-35.59619-191.585524-191.73181z" fill="currentColor" /></svg>`,
moon: `<svg 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="M20.354 15.354A9 9 0 0 1 8.646 3.646 9.003 9.003 0 0 0 12 21a9.003 9.003 0 0 0 8.354-5.646z" /></svg>`,
mergeGroup: `<svg 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="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3" /></svg>`,
deselect: `<svg 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="M9.75 9.75l4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>`,
closeDuplicates: `<svg 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="M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25m8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 0 0-2.25 2.25v6" /></svg>`,
};
+325 -16
View File
@@ -12,6 +12,7 @@ const runtimeJs = fs.readFileSync(path.join(__dirname, 'dashboard-runtime.js'),
const themeJs = fs.readFileSync(path.join(__dirname, 'theme-controls.js'), 'utf8');
const drawerJs = fs.readFileSync(path.join(__dirname, 'drawer-manager.js'), 'utf8');
const helperJs = fs.readFileSync(path.join(__dirname, 'ui-helpers.js'), 'utf8');
const i18nJs = fs.readFileSync(path.join(__dirname, 'i18n.js'), 'utf8');
const tabSessionsJs = fs.existsSync(path.join(__dirname, 'tab-sessions.js'))
? fs.readFileSync(path.join(__dirname, 'tab-sessions.js'), 'utf8')
: '';
@@ -19,6 +20,7 @@ const sessionManagerJs = fs.existsSync(path.join(__dirname, 'session-manager.js'
? fs.readFileSync(path.join(__dirname, 'session-manager.js'), 'utf8')
: '';
const popupJs = fs.readFileSync(path.join(__dirname, 'popup', 'popup.js'), 'utf8');
const popupCss = fs.readFileSync(path.join(__dirname, 'popup', 'popup.css'), 'utf8');
const popupHtml = fs.readFileSync(path.join(__dirname, 'popup', 'popup.html'), 'utf8');
const configJs = fs.readFileSync(path.join(__dirname, 'config.js'), 'utf8');
const configLoaderJs = fs.readFileSync(path.join(__dirname, 'config-loader.js'), 'utf8');
@@ -92,8 +94,8 @@ test('saved-session picker scopes visible tabs to the selected entry point', ()
assert.match(runtimeJs, /const newSessionName = String\(tabSessionPickerState\.newSessionName \|\| ''\)\.trim\(\);/);
assert.match(runtimeJs, /saveSelectedTabSession\(\s*selectedIds,\s*tabSessionPickerState\.source \|\| 'selected',\s*newSessionName\s*\)/);
assert.match(runtimeJs, /if \(action === 'save-current-window-session'\) \{[\s\S]{0,280}openTabSessionPicker\(\{ source: 'current-window' \}\)/);
assert.match(runtimeJs, /if \(action === 'save-single-tab-session'\) \{[\s\S]{0,360}openTabSessionPicker\(\{\s*source: 'single-tab',\s*initialTabIds: \[tabId\],\s*scopeTabIds: \[tabId\],\s*\}\)/);
assert.match(runtimeJs, /if \(action === 'save-domain-session'\) \{[\s\S]{0,720}openTabSessionPicker\(\{\s*source: 'group',\s*initialTabIds: tabIds,\s*scopeTabIds: tabIds,\s*\}\)/);
assert.match(runtimeJs, /if \(action === 'save-single-tab-session'\) \{[\s\S]{0,360}openSessionPickerForTabs\(\[tabId\], 'single-tab'\);/);
assert.match(runtimeJs, /if \(action === 'save-domain-session'\) \{[\s\S]{0,720}openSessionPickerForTabs\(tabIds, 'group'\);/);
assert.doesNotMatch(runtimeJs, /saveSelectedTabSession\(\[tabId\], 'single-tab'\)/);
assert.doesNotMatch(runtimeJs, /saveSelectedTabSession\(tabIds, 'group'\)/);
});
@@ -102,7 +104,11 @@ test('home and saved section headers share a fixed title column and top nav stay
const css = fs.readFileSync(path.join(__dirname, 'style.css'), 'utf8');
assert.match(css, /#openTabsSection \.section-header h2,\s*\.saved-sessions-section \.section-header h2 \{[\s\S]*width:\s*13ch;/);
assert.match(css, /#openTabsSection \.section-count,\s*\.saved-sessions-section \.section-count \{[\s\S]*width:\s*96px;/);
// The open-tabs count hosts up to five icon buttons: it sizes to content and
// keeps a comfortable gap; only the saved-sessions count stays a fixed column.
assert.match(css, /#openTabsSection \.section-count \{[\s\S]*width:\s*auto;[\s\S]*flex:\s*0 0 auto;[\s\S]*gap:\s*8px;/);
assert.match(css, /\.saved-sessions-section \.section-count \{[\s\S]*width:\s*96px;/);
assert.match(css, /\.section-icon-action \{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;[\s\S]*flex-shrink:\s*0;/);
assert.match(css, /\.group-nav-wide \{[\s\S]*min-height:\s*40px;[\s\S]*flex-wrap:\s*nowrap;[\s\S]*align-items:\s*center;/);
assert.match(css, /\.group-nav-list \{[\s\S]*flex-wrap:\s*nowrap;/);
assert.match(css, /\.group-nav-list \{[\s\S]*min-height:\s*40px;/);
@@ -133,8 +139,8 @@ test('desk settings separates appearance and feature controls', () => {
});
test('manual sleep control places per-tab moon action first', () => {
assert.match(runtimeJs, /function buildOverflowChips[\s\S]*<div class="chip-actions">\s*\$\{sleepControlEnabled && !tab\.active \? `\s*<button class="chip-action chip-discard"[\s\S]*<button class="chip-action chip-session-save"/);
assert.match(runtimeJs, /function renderDomainCard[\s\S]*<div class="chip-actions">\s*\$\{sleepControlEnabled && !tab\.active \? `<button class="chip-action chip-discard"[\s\S]*<button class="chip-action chip-session-save"/);
assert.match(runtimeJs, /function buildPageChipHtml\(tab, group, urlCounts = \{\}, collapsed = false\) \{[\s\S]*<div class="chip-actions">\s*\$\{sleepControlEnabled && !tab\.active && !tab\.discarded && !isPlaceholder \? `<button class="chip-action chip-discard"[\s\S]*<button class="chip-action chip-session-save"/);
assert.match(runtimeJs, /const pageChips = orderedTabs\.map\(\(tab, index\) => buildPageChipHtml\(tab, group, urlCounts, index >= 8 && !isOverflowExpanded\)\)\.join\(''\)[\s\S]*buildOverflowChips\(extraCount\)/);
});
test('icon-only actions use themed tooltips instead of native title hovers', () => {
@@ -201,9 +207,15 @@ test('new tab dashboard scopes visible open tabs to its own browser window', ()
assert.match(runtimeJs, /async function queryTabsForDashboardWindow\(\) \{[\s\S]*chrome\.tabs\.query\(\{\s*windowId: currentWindowId\s*\}\)/);
assert.doesNotMatch(runtimeJs, /if \(currentDashboardWindowId != null\) return currentDashboardWindowId;/);
assert.match(runtimeJs, /await loadSessionGroups\(getOpenTabIdsForSessionPruning\(\)\)/);
assert.match(runtimeJs, /async function closeTabsByUrls\(urls\) \{[\s\S]*const allTabs = await queryTabsForDashboardWindow\(\);/);
assert.match(runtimeJs, /async function closeTabsExact\(urls\) \{[\s\S]*const allTabs = await queryTabsForDashboardWindow\(\);/);
assert.match(runtimeJs, /async function closeDuplicateTabs\(urls, keepOne = true\) \{[\s\S]*const allTabs = await queryTabsForDashboardWindow\(\);/);
assert.match(runtimeJs, /async function closeTabsByUrlsSafely\(urls,\s*\{ exact = false, playSound = true \} = \{\}\) \{[\s\S]*const allTabs = await queryTabsForDashboardWindow\(\);/);
assert.match(runtimeJs, /async function closeDuplicatesByUrls\(urls,\s*\{ keepOne = true, playSound = true \} = \{\}\) \{[\s\S]*const allTabs = await queryTabsForDashboardWindow\(\);/);
// Closing tabs must never remove a window's last tab: that would close
// the window and, for the last window, exit the browser.
assert.match(runtimeJs, /function ensureWindowsKeepLastTab\(allTabs, toCloseIds\) \{[\s\S]*winTabs\.every\(t => toCloseSet\.has\(t\.id\)\)[\s\S]*const keep = winTabs\.find\(t => t\.active\) \|\| winTabs\[0\];[\s\S]*toCloseSet\.delete\(keep\.id\);/);
assert.match(runtimeJs, /const safeToClose = ensureWindowsKeepLastTab\(allTabs, tabIds\);/);
// Closing a domain group starts the card exit immediately (instant visual
// feedback), closes the tabs in the background, then rebuilds the area.
assert.match(runtimeJs, /if \(action === 'close-domain-tabs'\) \{[\s\S]*animateCardOut\(card\);[\s\S]*if \(idx !== -1\) domainGroups\.splice\(idx, 1\);[\s\S]*await closeTabsByUrlsSafely\(urls, \{ exact: useExact, playSound: false \}\);[\s\S]*await renderDashboard\(\);/);
assert.doesNotMatch(runtimeJs, /await loadSessionGroups\(openTabs\.map\(tab => tab\.id\)\)/);
assert.doesNotMatch(runtimeJs, /await loadSessionGroups\(realTabs\.map\(tab => tab\.id\)\)/);
});
@@ -258,10 +270,15 @@ test('toast helper tolerates missing optional action button node', () => {
assert.match(helperJs, /\} else if \(toastAction\) \{/);
});
test('popup group nav keeps visible fallback labels and popup-local image fallback handling', () => {
test('popup group nav fallback is consumed by the shared image fallback pipeline only', () => {
assert.match(popupJs, /class="group-nav-fallback"/);
assert.match(popupJs, /data-fallback-src=/);
assert.match(popupJs, /document\.addEventListener\('error', handlePopupGroupNavImageError, true\)/);
// The popup must NOT register its own document-level error handler: ui-helpers
// already owns a capture listener for the same data-fallback-* queue, and two
// consumers would skip fallback levels (double consumption).
assert.doesNotMatch(popupJs, /handlePopupGroupNavImageError/);
assert.match(helperJs, /__tabHarborImageFallbackBound/);
assert.match(popupHtml, /<script src="\.\.\/ui-helpers\.js"><\/script>/);
});
test('popup auto-refreshes when tabs and local storage change', () => {
@@ -272,9 +289,12 @@ test('popup auto-refreshes when tabs and local storage change', () => {
assert.match(popupJs, /const POPUP_REFRESH_KEYS = new Set/);
});
test('popup opens tabs from other windows in the current window instead of focusing the old window', () => {
test('popup activates a cross-window tab in its own window instead of duplicating it', () => {
assert.match(popupJs, /targetTab\.windowId !== currentWindow\.id/);
assert.match(popupJs, /await chrome\.tabs\.create\(\{\s*windowId: currentWindow\.id,/);
// Mirrors the dashboard focus-tab behavior: activate the existing tab and
// focus its window, never copy the URL into the current window.
assert.match(popupJs, /await chrome\.tabs\.update\(targetTab\.id, \{ active: true \}\)/);
assert.match(popupJs, /await chrome\.windows\.update\(targetTab\.windowId, \{ focused: true \}\)/);
assert.match(popupJs, /await openPopupTab\(tabId, actionEl\.dataset\.url \|\| ''\)/);
});
@@ -349,6 +369,10 @@ test('background keeps the toolbar badge empty', () => {
assert.doesNotMatch(backgroundJs, /String\(count\)/);
});
test('background notifies pages when a tab is replaced (stale chip root cause)', () => {
assert.match(backgroundJs, /chrome\.tabs\.onReplaced\.addListener\(\(addedTabId\) => \{\s*updateBadge\(\);\s*notifyTabHarborPages\(\{ source: "tabs\.onReplaced", triggerTabId: addedTabId \}\)/);
});
test('manifest keeps only permissions required by the shipped runtime', () => {
const manifest = fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8');
@@ -568,6 +592,8 @@ test('theme menu styles and custom background layer are defined', () => {
assert.match(css, /\.drawer-title-btn\.is-active,\s*\.drawer-title-btn\[aria-selected="true"\]\s*\{[\s\S]*text-decoration-color:\s*var\(--drawer-tab-underline-active\);/);
assert.match(css, /\.archive-clear-btn\s*\{[\s\S]*color:\s*var\(--workspace-chip-text\);/);
assert.match(css, /\.todo-detail-card\s*\{[\s\S]*background:\s*color-mix\(\s*in\s+srgb,\s*var\(--card-bg\)\s+96%,\s*var\(--paper\)\s+4%\s*\);/);
// Image handling anchors: the compress path is kept and the legacy
// readFileAsDataUrl path must stay gone.
assert.match(appJs, /compressImageFileForStorage/);
assert.doesNotMatch(appJs, /readFileAsDataUrl/);
assert.match(html, /<script src="background-image\.js"><\/script>/);
@@ -753,7 +779,39 @@ test('todo list and tab chips expose drag handles with drag-state styling', () =
assert.match(appJs, /data-chip-drag-handle="tab"/);
assert.match(appJs, /const chipItem = e\.target\.closest\('\[data-chip-sort-id\]'\);/);
assert.match(appJs, /const chipAction = e\.target\.closest\('\.chip-actions'\);/);
// The whole row is the drag surface: the handle is the visible grip, but a
// press on the row body arms the same drag.
assert.match(appJs, /if \(chipItem && !chipAction && e\.button === 0\)/);
assert.match(appJs, /originatedFromHandle: Boolean\(chipHandle\),/);
// Handle-click toggles a row into the highlight-only selection; dragging a
// selected row reorders the whole selection together within its group.
assert.match(appJs, /function togglePageChipSelection\(chipId\) \{[\s\S]*selectedPageChipIds\.add\(key\);/);
assert.match(appJs, /row\.classList\.toggle\('is-selected', selected\);/);
assert.match(appJs, /if \(selectedPageChipIds\.size > 0 && !e\.target\.closest\('\.mission-card'\) && !e\.target\.closest\('#pageChipBatchBar'\)\) \{\s*clearPageChipSelection\(\);/);
assert.match(appJs, /const finalDistance = Math\.hypot\(e\.clientX - pageChipDragState\.x, e\.clientY - pageChipDragState\.y\);\s*if \(!pageChipDragState\.moved && finalDistance < 4\) \{[\s\S]*togglePageChipSelection\(draggedPageChipId\);/);
assert.match(appJs, /function buildBatchOrderedIdsFromList\(listEl, movingIds\) \{[\s\S]*rest\.splice\(dropPos, 0, \.\.\.moving\);/);
assert.match(appJs, /const orderIds = buildBatchOrderedIdsFromList\(targetListEl, movingIds\);/);
// Cross-group moves and new-group creation move the whole selection when
// the dragged row is selected.
assert.match(appJs, /function collectMovingTabIds\(movingChipIds, fallbackGroupKey = ''\) \{[\s\S]*const chipGroupKey = findGroupKeyForChip\(chipId\) \|\| String\(fallbackGroupKey \|\| ''\);[\s\S]*tabIds\.push\(\.\.\.getTabIdsForGroupChip\(chipGroupKey, chipId\)\);/);
assert.match(appJs, /function findGroupKeyForChip\(chipId\) \{[\s\S]*\(group\.tabs \|\| \[\]\)\.some\(tab => getTabOrderTokens\(tab\)\.includes\(key\)\)/);
assert.match(appJs, /getTabOrderTokens\(tab\)\.some\(token => movingSet\.has\(String\(token\)\)\)/);
assert.match(appJs, /async function saveCrossGroupTabRowOrder\(sourceGroupKey, targetGroupKey, targetListEl, movingIds\) \{[\s\S]*Array\.isArray\(movingIds\)[\s\S]*const chipsByGroup = \{\};[\s\S]*findGroupKeyForChip\(id\) \|\| String\(sourceGroupKey \|\| ''\)[\s\S]*buildCrossGroupTargetOrder\(targetListEl, ids\)/);
assert.match(appJs, /saveCrossGroupTabRowOrder\(sourceGroupKey, movedGroup\.groupKey, targetListEl, getMovingPageChipIds\(\)\)/);
// Every source card a batch move leaves must be patched, not just the
// dragged row's card — otherwise moved rows linger in their old cards.
assert.match(appJs, /for \(const g of \(movedGroup\.sourceGroupKeys \|\| \[\]\)\) changedGroupKeys\.add\(g\);/);
assert.match(appJs, /return \{ \.\.\.created\.group, sourceGroupKeys \};/);
assert.match(appJs, /\$\{isSelected \? ' is-selected' : ''\}/);
assert.match(css, /\.page-chip\.is-selected \{[\s\S]*background: color-mix\(in srgb, var\(--workspace-chip-bg\) 82%, var\(--workspace-accent-border\) 18%\);/);
// Drag feedback: the rest of the selection dims and a count badge appears.
assert.match(css, /body\.page-chip-list-dragging \.page-chip\.is-selected:not\(\.is-dragging\) \{\s*opacity: 0\.55;/);
assert.match(css, /\.page-chip-drag-badge \{[\s\S]*position: fixed;[\s\S]*pointer-events: none;/);
assert.match(css, /\.chip-reorder-handle \{\s*touch-action: none;/);
assert.match(appJs, /function updatePageChipDragBadge\(count\) \{[\s\S]*badge\.textContent = count > 1 \? `×\$\{count\}` : '';/);
assert.match(appJs, /if \(chipItem && !chipAction && e\.button === 0\) \{\s*\/\/ Re-entrancy guard[\s\S]{0,600}e\.preventDefault\(\);/);
assert.match(appJs, /if \(e\.key !== 'Escape'\) return;[\s\S]{0,300}if \(draggedPageChipId && pageChipDragState\) \{\s*clearPageChipDragState\(\{ removeNode: false \}\);/);
assert.match(appJs, /movingChipIds: getMovingPageChipIds\(\),/);
assert.match(appJs, /e\.stopPropagation\(\);/);
assert.match(appJs, /document\.body\.classList\.add\('page-chip-drag-armed'\)/);
assert.match(appJs, /const GROUP_TAB_ORDER_KEY = 'groupTabOrder'/);
@@ -788,7 +846,7 @@ test('todo list and tab chips expose drag handles with drag-state styling', () =
assert.match(appJs, /dragHandleEl\.setPointerCapture\(e\.pointerId\)/);
assert.match(appJs, /async function finishPageChipDrag\(\)/);
assert.match(appJs, /let requiresOpenTabsRebuild = true;/);
assert.match(appJs, /requiresOpenTabsRebuild = false;/);
assert.match(appJs, /requiresOpenTabsRebuild = !pageChipPlaceholderEl;/);
assert.match(appJs, /clearPageChipDragState\(\{ removeNode: requiresOpenTabsRebuild \}\)/);
assert.match(appJs, /if \(!requiresOpenTabsRebuild\) \{[\s\S]*finish-local-reorder-commit[\s\S]*await syncChromeTabGroupsWithoutImportEcho\(\);/);
assert.match(appJs, /document\.addEventListener\('pointercancel', async \(e\) => \{/);
@@ -802,7 +860,7 @@ test('todo list and tab chips expose drag handles with drag-state styling', () =
assert.match(appJs, /if \(!movedGroup\.targetWasManualGroup\) \{[\s\S]*buildPersistentGroupOrderReplacingKey\(movedGroup\.groupKey, targetGroupKey\)/);
assert.match(appJs, /const clampedPoint = clampPageChipClientPoint\(clientX, clientY\);/);
assert.match(appJs, /if \(!pageChipDragState\.moved\) \{[\s\S]*const distance = Math\.hypot\(e\.clientX - pageChipDragState\.x, e\.clientY - pageChipDragState\.y\);[\s\S]*if \(distance >= 4\) \{/);
assert.match(appJs, /updateDraggedPageChipPosition\(e\.clientX, e\.clientY\);[\s\S]*syncPageChipDropTarget\(e\.clientX, e\.clientY\);/);
assert.match(appJs, /updateDraggedPageChipPosition\(e\.clientX, e\.clientY\);[\s\S]*if \(!stickyIsNonSource\) \{\s*(?:\/\/[^\n]*\n\s*)*previewPageChipOrder\(e\.clientX, e\.clientY\);/);
assert.match(appJs, /clearPageChipDragState\(\{ removeNode: moved \}\)/);
assert.match(appJs, /let suppressPageChipClickUntil = 0;/);
assert.match(appJs, /if \(Date\.now\(\) < suppressPageChipClickUntil\) return;/);
@@ -814,7 +872,7 @@ test('todo list and tab chips expose drag handles with drag-state styling', () =
assert.doesNotMatch(drawerJs, /title="Drag to reorder"/);
assert.match(css, /\.drawer-reorder-handle\s*\{/);
assert.match(css, /\.todo-reorder-handle\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/);
assert.match(css, /\.page-chip > \.chip-reorder-handle\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*30px;/);
assert.match(css, /\.page-chip > \.chip-reorder-handle\s*\{[\s\S]*width:\s*30px;[\s\S]*height:\s*36px;/);
assert.match(css, /\.chip-reorder-handle\s*\{[\s\S]*opacity:\s*1;[\s\S]*border:\s*1px solid/);
assert.match(css, /\.drawer-reorder-placeholder\s*\{/);
assert.match(css, /body\.page-chip-list-dragging\s*\{/);
@@ -985,11 +1043,27 @@ test('saved session restore supports both current-window and new-window modes',
assert.match(runtimeJs, /async function restoreSavedTabToBrowser\(tabUrl\)/);
assert.match(runtimeJs, /const \{ windowId \} = await openSavedTabsInCurrentWindow\(\[\{ url: tabUrl \}]\);/);
assert.match(sessionManagerJs, /runtime\.restoreSavedTabToBrowser/);
// Saved sessions re-create native Chrome groups (fresh groups with the
// recorded title/color, ordered tabs) after the tabs are opened.
assert.match(runtimeJs, /const \{ state: nextSessionGroups, chromeGroupPlans \} = runtimeCreateRestoredSessionGroups\(\{/);
assert.match(runtimeJs, /await restoreChromeGroupsForSession\(chromeGroupPlans, windowId\);/);
assert.match(runtimeJs, /async function restoreChromeGroupsForSession\(plans, windowId\) \{/);
assert.match(runtimeJs, /const groupId = await chrome\.tabs\.group\(groupOptions\);/);
// Chrome creates the fresh group in the CALLER's window by default, which
// would drag tabs created in the restore target window across windows;
// createProperties.windowId pins the new group to the target window so a
// new-window restore keeps chrome-group tabs in place with the other tabs.
assert.match(runtimeJs, /const groupOptions = windowId != null\s*\? \{ tabIds: planTabIds, createProperties: \{ windowId: Number\(windowId\) \} \}\s*: \{ tabIds: planTabIds \};/);
assert.match(runtimeJs, /const groupId = await chrome\.tabs\.group\(groupOptions\);/);
assert.match(runtimeJs, /await chrome\.tabGroups\.update\(groupId, \{/);
assert.match(runtimeJs, /reorderGroupedTabs\(groupId, planTabIds\.map\(String\), windowId\)/);
});
test('saved tabs top nav supports icon and name display modes', () => {
const css = fs.readFileSync(path.join(__dirname, 'style.css'), 'utf8');
// Nav chips keep their circular size and scroll instead of squeezing.
assert.match(css, /\.group-nav-button \{\s*width: 40px;[\s\S]*flex-shrink: 0;/);
assert.match(sessionManagerJs, /const navDisplayMode = getSavedSessionNavDisplayModeValue\(\);/);
assert.match(sessionManagerJs, /if \(navDisplayMode === 'name'\)/);
assert.match(sessionManagerJs, /class="group-nav-button saved-session-nav-button is-name-mode"/);
@@ -1001,11 +1075,231 @@ test('saved tabs top nav supports icon and name display modes', () => {
assert.match(css, /\.saved-session-nav-label\s*\{[\s\S]*white-space:\s*nowrap;[\s\S]*text-overflow:\s*ellipsis;/);
});
test('quick shortcuts always open a new active tab from the dashboard and popup', () => {
test('quick shortcuts default to a new active tab and can opt into the current tab', () => {
assert.match(runtimeJs, /async function openOrFocusUrl\(url\)\s*\{\s*if \(!url\) return false;\s*await chrome\.tabs\.create\(\{\s*url,\s*active:\s*true\s*\}\);\s*return true;\s*\}/);
assert.match(popupJs, /async function openPopupUrl\(url\)\s*\{\s*if \(!url\) return;\s*await chrome\.tabs\.create\(\{\s*url,\s*active:\s*true\s*\}\);\s*window\.close\(\);\s*\}/);
assert.doesNotMatch(popupJs, /async function findTabByUrl\(/);
assert.match(runtimeJs, /const fallbackUrl = `https:\/\/www\.google\.com\/search\?q=\$\{encodeURIComponent\(text\)\}`;\s*await navigateCurrentTabToUrl\(fallbackUrl\);/);
// New-tab stays the default so existing behavior is preserved.
assert.match(themeJs, /quickShortcutOpenMode:\s*'new-tab'/);
assert.match(themeJs, /getQuickShortcutOpenMode\(\) === 'current-tab'/);
assert.match(themeJs, /typeof navigateCurrentTabToUrl === 'function'/);
assert.match(themeJs, /await navigateCurrentTabToUrl\(url\)\.catch\(\(\) => false\)/);
// Ctrl/Cmd+click opens in a background tab; Shift+click opens a new window.
assert.match(themeJs, /if \(e\.ctrlKey \|\| e\.metaKey\) \{\s*await chrome\.tabs\.create\(\{ url, active: false \}\);\s*return;\s*\}/);
assert.match(themeJs, /if \(e\.shiftKey\) \{\s*await chrome\.windows\.create\(\{ url, focused: true \}\);\s*return;\s*\}/);
assert.match(themeJs, /await openOrFocusUrl\(url\);\s*return;\s*}\s*if \(action === 'close-shortcut-editor'\)/);
// The Features panel exposes a switch for the mode and persists it.
assert.match(runtimeJs, /id="themeMenuFeaturesPanel"[\s\S]*toggle-quick-shortcut-open-mode/);
assert.match(runtimeJs, /type="button" data-action="toggle-quick-shortcut-open-mode"/);
assert.match(runtimeJs, /saveThemePreferences\(\{\s*quickShortcutOpenMode: nextMode\s*\}\)/);
assert.match(runtimeJs, /toggleSwitch\.classList\.toggle\('is-active', nextMode === 'current-tab'\)/);
assert.match(runtimeJs, /toggleSwitch\.setAttribute\('aria-pressed', String\(nextMode === 'current-tab'\)\)/);
assert.match(themeJs, /globalThis\.TabOutThemeControls = \{\s*SEARCH_ENGINE_PRESETS,\s*buildSearchUrlForQuery,\s*filterRealTabs,\s*getCustomSearchUrl,\s*getQuickShortcutCols,\s*getQuickShortcutOpenMode,/);
assert.match(i18nJs, /quickShortcutOpenModeLabel: 'Open quick links in current tab'/);
assert.match(i18nJs, /quickShortcutOpenModeLabel: '在当前标签页打开快捷链接'/);
});
test('quick links per row can be fixed to 4 or 5 columns in landscape only', () => {
const css = fs.readFileSync(path.join(__dirname, 'style.css'), 'utf8');
// Preference defaults to auto so existing auto-fill behavior is preserved.
assert.match(themeJs, /quickShortcutCols:\s*'auto'/);
assert.match(themeJs, /quickShortcutCols: VALID_QUICK_SHORTCUT_COLS\.has\(rawQuickShortcutCols\) \? rawQuickShortcutCols : 'auto'/);
// applyThemePreferences pins the grid class from the saved preference.
assert.match(themeJs, /quickTabsList\.classList\.toggle\('is-fixed-cols-4', themePreferences\.quickShortcutCols === '4'\)/);
assert.match(themeJs, /quickTabsList\.classList\.toggle\('is-fixed-cols-5', themePreferences\.quickShortcutCols === '5'\)/);
// Landscape (>=961px) applies fixed tracks; portrait keeps auto-fill.
assert.match(css, /@media \(min-width: 961px\)[\s\S]*\.quick-tabs-grid\.is-fixed-cols-4 \{\s*grid-template-columns: repeat\(4, 1fr\);[\s\S]*\.quick-tabs-grid\.is-fixed-cols-5 \{\s*grid-template-columns: repeat\(5, 1fr\);[\s\S]*\.quick-tabs-grid\.is-fixed-cols-4 \.quick-shortcut-card,[\s\S]*\.quick-tabs-grid\.is-fixed-cols-5 \.quick-shortcut-card \{\s*width: 100%;/);
// The fixed rules must NOT leak into the portrait (max-width: 960px) block —
// scan the whole block, not just its head.
const portraitStart = css.indexOf('@media (max-width: 960px)');
const portraitEnd = css.indexOf('@media (prefers-reduced-motion');
const portraitBlock = portraitStart >= 0 && portraitEnd > portraitStart
? css.slice(portraitStart, portraitEnd)
: '';
assert.ok(portraitBlock.length > 100, 'portrait media block should be present for the leak check');
assert.doesNotMatch(portraitBlock, /is-fixed-cols/);
// The Appearance panel exposes an Auto / 4 / 5 choice row.
assert.match(runtimeJs, /data-action="select-quick-shortcut-cols"[\s\S]*data-cols="auto"[\s\S]*data-cols="4"[\s\S]*data-cols="5"/);
assert.match(runtimeJs, /const cols = \['auto', '4', '5'\]\.includes\(actionEl\.dataset\.cols\) \? actionEl\.dataset\.cols : 'auto';/);
assert.match(runtimeJs, /saveThemePreferences\(\{\s*quickShortcutCols: cols\s*\}\)/);
// renderThemeMenu does not refresh this row, so the handler patches it in place.
assert.match(runtimeJs, /document\.querySelectorAll\('\[data-action="select-quick-shortcut-cols"\]'\)\.forEach\(option => \{[\s\S]*option\.dataset\.cols === cols[\s\S]*option\.classList\.toggle\('is-active', isActive\)[\s\S]*option\.setAttribute\('aria-pressed', String\(isActive\)\)/);
assert.match(i18nJs, /quickShortcutColsLabel: 'Quick links per row'/);
assert.match(i18nJs, /quickShortcutColsAuto: 'Auto'/);
assert.match(i18nJs, /quickShortcutCols4: '4 columns'/);
assert.match(i18nJs, /quickShortcutCols5: '5 columns'/);
assert.match(i18nJs, /quickShortcutColsLabel: '快捷链接每行'/);
assert.match(i18nJs, /quickShortcutColsAuto: '自动'/);
assert.match(i18nJs, /quickShortcutCols4: '4 列'/);
assert.match(i18nJs, /quickShortcutCols5: '5 列'/);
});
test('search engine can be customized with presets or a custom URL', () => {
// Browser default stays the default so existing search behavior is preserved.
assert.match(themeJs, /searchEngine:\s*'default'/);
assert.match(themeJs, /customSearchUrl:\s*''/);
assert.match(themeJs, /searchEngine: VALID_SEARCH_ENGINES\.has\(rawSearchEngine\) \? rawSearchEngine : 'default',/);
// Presets use the correct per-engine query params (Baidu uses wd=, Sogou query=, Yandex text=).
assert.match(themeJs, /SEARCH_ENGINE_PRESETS = \{[\s\S]*google: \{ name: 'Google', url: 'https:\/\/www\.google\.com\/search\?q=' \},[\s\S]*baidu: \{ name: 'Baidu', url: 'https:\/\/www\.baidu\.com\/s\?wd=' \},[\s\S]*sogou: \{ name: 'Sogou', url: 'https:\/\/www\.sogou\.com\/web\?query=' \},[\s\S]*brave: \{ name: 'Brave Search', url: 'https:\/\/search\.brave\.com\/search\?q=' \},[\s\S]*yandex: \{ name: 'Yandex', url: 'https:\/\/yandex\.com\/search\/\?text=' \}/);
// The builder supports {query}, %s and append semantics.
assert.match(themeJs, /if \(url\.includes\('\{query\}'\)\) return url\.replace\(\/\\\{query\\\}\/g, encoded\);/);
assert.match(themeJs, /if \(url\.includes\('%s'\)\) return url\.replace\(\/%s\/g, encoded\);/);
assert.match(themeJs, /return `\$\{url\}\$\{encoded\}`;/);
// runDefaultSearch prefers the configured engine, then falls back to chrome.search.
assert.match(runtimeJs, /const searchUrl = runtimeBuildSearchUrlForQuery \? runtimeBuildSearchUrlForQuery\(text\) : '';/);
assert.match(runtimeJs, /if \(searchUrl\) \{\s*let validSearchUrl = false;[\s\S]*if \(validSearchUrl\) \{\s*const navigated = await navigateCurrentTabToUrl\(searchUrl\)\.catch\(\(\) => false\);\s*if \(navigated\) return;/);
// The Features panel exposes the engine choice row and the conditional custom URL row.
assert.match(runtimeJs, /data-action="select-search-engine"[\s\S]*data-engine="default"[\s\S]*data-engine="google"[\s\S]*data-engine="bing"[\s\S]*data-engine="baidu"[\s\S]*data-engine="sogou"[\s\S]*data-engine="duckduckgo"[\s\S]*data-engine="brave"[\s\S]*data-engine="yandex"[\s\S]*data-engine="custom"/);
assert.match(runtimeJs, /id="customSearchUrlSection"[\s\S]*data-action="change-custom-search-url"/);
assert.match(runtimeJs, /const engine = \['default', 'google', 'bing', 'baidu', 'sogou', 'duckduckgo', 'brave', 'yandex', 'custom'\]\.includes\(actionEl\.dataset\.engine\) \? actionEl\.dataset\.engine : 'default';/);
assert.match(runtimeJs, /saveThemePreferences\(\{\s*searchEngine: engine\s*\}\);\s*syncSearchPlaceholder\(\);/);
assert.match(runtimeJs, /querySelectorAll\('\[data-action="select-search-engine"\]'\)\.forEach\(option => \{[\s\S]*option\.dataset\.engine === engine[\s\S]*option\.classList\.toggle\('is-active', isActive\)[\s\S]*option\.setAttribute\('aria-pressed', String\(isActive\)\)/);
assert.match(runtimeJs, /themePreferences = normalizeThemePreferences\(\{\s*\.\.\.themePreferences,\s*customSearchUrl: customSearchUrlInput\.value,\s*\}\);\s*await chrome\.storage\.local\.set\(\{ \[THEME_PREFERENCES_KEY\]: themePreferences \}\);\s*syncSearchPlaceholder\(\);/);
assert.match(i18nJs, /searchEngineLabel: 'Search engine'/);
assert.match(i18nJs, /searchEngineDefault: 'Browser default'/);
assert.match(i18nJs, /searchEngineBaidu: 'Baidu'/);
assert.match(i18nJs, /searchEngineLabel: '搜索引擎'/);
assert.match(i18nJs, /searchEngineDefault: '浏览器默认'/);
assert.match(i18nJs, /searchEngineBaidu: 'Baidu'/);
assert.match(i18nJs, /customSearchUrlHint: 'Use \{query\} or %s as the placeholder'/);
assert.match(i18nJs, /customSearchUrlHint: '用 \{query\} 或 %s 作为占位符'/);
// The search box placeholder follows the selected engine.
assert.match(runtimeJs, /function syncSearchPlaceholder\(\) \{/);
assert.match(runtimeJs, /placeholder = runtimeT[\s\S]*\? runtimeT\('searchPlaceholderEngine', \{ engine: engineName \}\)[\s\S]*: `Search with \$\{engineName\}\.\.\.`;/);
// Empty custom URL falls back to the default placeholder so UI matches behavior.
assert.match(runtimeJs, /const customUrl = \(\(typeof themePreferences !== 'undefined' && themePreferences\.customSearchUrl\) \|\| ''\)\.trim\(\);/);
assert.match(runtimeJs, /placeholder = customUrl[\s\S]*\? \(runtimeT \? runtimeT\('searchPlaceholderCustom'\) : 'Search with a custom engine\.\.\.'\)[\s\S]*: \(runtimeT \? runtimeT\('searchPlaceholderDefault'\) : 'Search with your default engine\.\.\.'\);/);
assert.match(runtimeJs, /placeholder = runtimeT \? runtimeT\('searchPlaceholderDefault'\) : 'Search with your default engine\.\.\.';/);
assert.match(runtimeJs, /await loadThemePreferences\(\);\s*syncSearchPlaceholder\(\);/);
assert.match(runtimeJs, /saveThemePreferences\(\{\s*searchEngine: engine\s*\}\);\s*syncSearchPlaceholder\(\);/);
assert.match(runtimeJs, /customSection\.style\.display = engine === 'custom' \? '' : 'none';/);
assert.match(runtimeJs, /navHost\.addEventListener\('wheel', \(e\) => \{[\s\S]*e\.target\.closest\('\.group-nav-list'\)[\s\S]*if \(list\.scrollWidth <= list\.clientWidth\) return;[\s\S]*if \(Math\.abs\(e\.deltaY\) > Math\.abs\(e\.deltaX\)\) \{[\s\S]*e\.preventDefault\(\);[\s\S]*list\.scrollLeft \+= e\.deltaY;/);
assert.match(runtimeJs, /navHost\.dataset\.wheelHijackAttached = '1';/);
// Malformed custom URLs must not replace the workspace with an error page.
assert.match(runtimeJs, /validSearchUrl = \/\^https\?:\$\/\.test\(new URL\(searchUrl\)\.protocol\);/);
assert.match(runtimeJs, /showToast\(runtimeT \? runtimeT\('toastInvalidCustomSearchUrl'\) : 'Invalid custom search URL, using browser default'\);/);
// The custom URL input escapes its value and points at the hint.
assert.match(runtimeJs, /value="\$\{runtimeEscapeHtmlAttribute \? runtimeEscapeHtmlAttribute\(\(\(typeof themePreferences !== 'undefined' && themePreferences\.customSearchUrl\) \|\| ''\)\) : ''\}" placeholder="https:\/\/example\.com\/search\?q=\{query\}"[\s\S]*aria-describedby="customSearchUrlHint"/);
assert.match(runtimeJs, /theme-menu-hint" id="customSearchUrlHint">\$\{runtimeT \? runtimeT\('customSearchUrlHint'\) : 'Use \{query\} or %s as the placeholder'\}/);
assert.match(i18nJs, /searchPlaceholderEngine: 'Search with \{engine\}\.\.\.'/);
assert.match(i18nJs, /searchPlaceholderEngine: '用 \{engine\} 搜索\.\.\.'/);
assert.match(i18nJs, /searchPlaceholderCustom: '用自定义搜索引擎搜索\.\.\.'/);
assert.match(i18nJs, /toastInvalidCustomSearchUrl: 'Invalid custom search URL, using browser default'/);
assert.match(i18nJs, /toastInvalidCustomSearchUrl: '自定义搜索 URL 无效,已改用浏览器默认'/);
// Bing and DuckDuckGo preset URLs are pinned too.
assert.match(themeJs, /bing: \{ name: 'Bing', url: 'https:\/\/www\.bing\.com\/search\?q=' \},[\s\S]*duckduckgo: \{ name: 'DuckDuckGo', url: 'https:\/\/duckduckgo\.com\/\?q=' \},/);
assert.match(i18nJs, /searchEngineBrave: 'Brave'/);
assert.match(i18nJs, /searchEngineSogou: 'Sogou'/);
assert.match(i18nJs, /searchEngineYandex: 'Yandex'/);
assert.match(i18nJs, /searchEngineCustom: '自定义'/);
assert.match(i18nJs, /customSearchUrlLabel: '自定义搜索 URL'/);
assert.match(i18nJs, /searchPlaceholderDefault: '用默认搜索引擎搜索\.\.\.'/);
});
test('popup scrolls inside panels only, never the document', () => {
// The popup clamps its height and hides document overflow so the native
// window scrollbar never appears next to the panel scrollbars; the width
// is fixed so switching views never resizes the popup window.
assert.match(popupCss, /html, body \{\s*[\s\S]*width: 400px;[\s\S]*height: fit-content;[\s\S]*max-height: 520px;[\s\S]*overflow: hidden;/);
assert.match(popupCss, /\.popup-app \{\s*[\s\S]*height: fit-content;[\s\S]*max-height: 520px;/);
assert.match(popupCss, /\.popup-tabs-list \{[\s\S]*overflow-y: auto;/);
assert.match(popupCss, /\.popup-shortcuts-grid \{[\s\S]*overflow-y: auto;/);
// Panel scrollbars are hidden like the nav's, so transient overflow (e.g.
// during the entry animation) can never flash a visible scrollbar.
assert.match(popupCss, /\.popup-shortcuts-grid,\s*\.popup-tabs-list \{\s*scrollbar-width: none;/);
assert.match(popupCss, /\.popup-shortcuts-grid::-webkit-scrollbar,\s*\.popup-tabs-list::-webkit-scrollbar \{\s*display: none;/);
assert.doesNotMatch(popupCss, /\.popup-tabs-list \{[\s\S]*scrollbar-width: thin;/);
// The top group nav keeps horizontal scrolling but never shows a scrollbar.
assert.match(popupCss, /\.popup-group-nav-wrap \{\s*[\s\S]*flex-wrap: nowrap;[\s\S]*flex: 0 0 auto;[\s\S]*overflow-x: auto;[\s\S]*overflow-y: hidden;[\s\S]*scrollbar-width: none;/);
assert.match(popupCss, /\.popup-group-nav-wrap::-webkit-scrollbar \{\s*display: none;/);
// Nav chips keep their size and overflow horizontally instead of shrinking.
assert.match(popupCss, /\.group-nav-button \{\s*width: 40px;[\s\S]*flex: 0 0 40px;/);
// With the nav scrollbar hidden, the vertical wheel scrolls it horizontally —
// but only when the nav can actually overflow, so short navs pass wheels through.
assert.match(popupJs, /groupNavWrap\.addEventListener\('wheel', \(e\) => \{[\s\S]*if \(list\.scrollWidth <= list\.clientWidth\) return;[\s\S]*if \(Math\.abs\(e\.deltaY\) > Math\.abs\(e\.deltaX\)\) \{[\s\S]*e\.preventDefault\(\);[\s\S]*list\.scrollLeft \+= e\.deltaY;/);
// The dashboard "quick links per row" setting drives the popup grid too.
assert.match(popupJs, /const cols = popupTheme\.getQuickShortcutCols \? popupTheme\.getQuickShortcutCols\(\) : 'auto';/);
assert.match(popupJs, /listEl\.classList\.toggle\('is-fixed-cols-4', cols === '4'\);/);
assert.match(popupJs, /listEl\.classList\.toggle\('is-fixed-cols-5', cols === '5'\);/);
// Shortcut grids skip re-rendering when data and columns are unchanged.
assert.match(themeJs, /let lastQuickShortcutsRenderKey = '';/);
assert.match(themeJs, /if \(renderKey === lastQuickShortcutsRenderKey && list\.innerHTML\) return;/);
assert.match(popupJs, /let popupShortcutsRenderKey = '';/);
assert.match(popupJs, /if \(renderKey === popupShortcutsRenderKey\) return;/);
// The popup entry animation replays only on view switches, not refreshes.
assert.match(popupJs, /const viewChanged = lastSyncedPopupView !== popupState\.view;/);
assert.match(popupJs, /if \(viewChanged\) \{\s*\[shortcutsList, tabsList, navEl\]\.forEach\(el => \{\s*el\?\.classList\.remove\('is-ready', 'is-entering'\);/);
// The remembered view is shown before the async refresh resolves, so the
// popup never flashes the default shortcuts panel when opened on tabs.
assert.match(popupJs, /localStorage\.getItem\(POPUP_VIEW_KEY\) === 'tabs' \? 'tabs' : 'shortcuts'/);
assert.match(popupJs, /localStorage\.setItem\(POPUP_VIEW_KEY, popupState\.view\)/);
assert.match(popupJs, /\/\/ Apply the remembered view before the first paint[\s\S]*syncPopupView\(\);/);
assert.match(popupJs, /loadPopupView\(\)\s*\.then\(\(\) => \{\s*syncPopupView\(\);\s*return refreshPopupSafely\(\);/);
// The incoming panel hides only on an actual view switch (is-entering added
// by syncPopupView); renderers must never add it on background refreshes.
assert.match(popupJs, /if \(viewChanged\) \{[\s\S]*shortcutsList\?\.classList\.add\('is-entering'\)/);
assert.match(popupJs, /if \(viewChanged\) \{[\s\S]*tabsList\?\.classList\.add\('is-entering'\)/);
assert.doesNotMatch(popupJs, /listEl\.classList\.add\('is-entering'\)/);
// Entrance animations are bound to .is-entering.is-ready, so refresh
// content (is-entering cleared by same-view sync) never replays them.
assert.match(popupJs, /if \(!viewChanged\) \{\s*\[shortcutsList, tabsList, navEl\]\.forEach\(el => \{\s*el\?\.classList\.remove\('is-entering'\)/);
assert.doesNotMatch(popupJs, /classList\.remove\('is-entering'\);\s*listEl\.classList\.add\('is-ready'\)/);
assert.match(popupCss, /\.popup-tabs-list\.is-entering\.is-ready \.popup-tab-group \{/);
assert.match(popupCss, /\.popup-tabs-list\.is-entering\.is-ready \.popup-tab-row \{/);
assert.match(popupCss, /\.popup-group-nav-wrap\.is-entering\.is-ready \.group-nav-button \{/);
assert.match(popupCss, /\.popup-shortcuts-grid\.is-entering\.is-ready \.popup-shortcut-card \{/);
assert.doesNotMatch(popupCss, /\.popup-tabs-list\.is-ready \.popup-tab-group \{/);
// The popup window fits its content instead of inheriting min-height:100vh.
const htmlBodyStart = popupCss.indexOf('html, body {');
const htmlBodyEnd = popupCss.indexOf('\n}', htmlBodyStart);
const htmlBodyBlock = htmlBodyStart >= 0 && htmlBodyEnd > htmlBodyStart
? popupCss.slice(htmlBodyStart, htmlBodyEnd)
: '';
assert.ok(htmlBodyBlock.length > 50, 'html,body rule block should be present');
assert.match(htmlBodyBlock, /min-height: 0;/);
// Suspended tabs unwrap to their original URL like the dashboard — but only
// when the original URL exists (uri-less suspended pages fall through to
// the normal internal-page filter).
assert.match(popupJs, /suspended\.isSuspended && suspended\.originalUrl\) return true/);
assert.match(popupHtml, /<script src="\.\.\/tab-url-utils\.js"><\/script>/);
// Session groups sort by createdAt like the dashboard.
assert.match(popupJs, /\.sort\(\(a, b\) => new Date\(a\.createdAt\) - new Date\(b\.createdAt\)\)/);
// The close path refreshes through the safe pipeline; double-click close
// cannot penetrate into the row's open handler.
assert.match(popupJs, /await refreshPopupSafely\(\);/);
assert.match(popupJs, /popup-tab-close-btn\.is-loading'\)\) return;/);
// Deduplicating tabs re-renders the open-tabs area immediately instead of
// leaving stale duplicate chips until the next event-driven refresh.
// Chrome-group cards dedup within their own tab set (C12); regular cards
// keep the URL-wide path. Each branch gets its own assertion — a single
// alternation would also match a version that dropped the C12 scoping.
assert.match(runtimeJs, /if \(action === 'dedup-keep-one'\) \{[\s\S]*await closeDuplicatesInSelection\(chromeTabIds, \{ playSound: false \}\)[\s\S]*playCloseSound\(\);[\s\S]*await renderDashboard\(\);/);
assert.match(runtimeJs, /if \(action === 'dedup-keep-one'\) \{[\s\S]*await closeDuplicatesByUrls\(urls, \{ keepOne: true, playSound: false \}\)[\s\S]*playCloseSound\(\);[\s\S]*await renderDashboard\(\);/);
// Cards can merge all their tabs into one native Chrome tab group; the
// section header reuses the action with scope="all" to group every card.
assert.match(runtimeJs, /data-action="group-card-tabs" data-domain-id="\$\{stableId\}"/);
assert.match(runtimeJs, /if \(action === 'group-card-tabs'\) \{[\s\S]*const scope = actionEl\.dataset\.scope \|\| '';[\s\S]*const label = scope === 'all'[\s\S]*getGroupDisplayLabel\(group\);[\s\S]*await mergeTabsIntoChromeGroup\(tabIds, \{ title: label, color \}\)/);
assert.match(i18nJs, /groupCardTabsLabel: 'Merge into Chrome group'/);
assert.match(i18nJs, /groupCardTabsLabel: '合并为 Chrome 标签组'/);
assert.match(i18nJs, /toastGroupCreated: 'Created Chrome tab group'/);
assert.match(i18nJs, /toastGroupCreated: '已创建 Chrome 标签组'/);
// Renamed group labels reach the popup; grouping uses the primary domain.
assert.match(popupJs, /GROUP_LABEL_OVERRIDES_KEY/);
assert.match(popupJs, /popupState\.groupLabelOverrides\[group\.domain\]/);
assert.match(popupJs, /popupIcons\.getPrimaryDomain \? popupIcons\.getPrimaryDomain\(hostname\) : hostname/);
assert.match(popupJs, /const mergedGroups = \[\.\.\.sessionGroupsList, \.\.\.sortedAutomatic\];/);
assert.match(popupJs, /ungroupedTabs\.length > 0/);
// A failed refresh keeps the previous snapshot instead of rejecting.
assert.match(popupJs, /console\.warn\('\[tab-harbor popup\] refresh failed:'/);
// Duplicate URLs stay visible (aligned with the dashboard) and Gmail's bare
// inbox is a content tab, not a landing page.
assert.doesNotMatch(popupJs, /seenUrls/);
assert.match(popupJs, /!h\.includes\('#inbox'\)/);
assert.match(runtimeJs, /!h\.includes\('#inbox'\)/);
assert.match(popupCss, /\.popup-shortcuts-grid\.is-fixed-cols-4 \{\s*display: grid;[\s\S]*grid-template-columns: repeat\(4, 1fr\);/);
assert.match(popupCss, /\.popup-shortcuts-grid\.is-fixed-cols-5 \{\s*display: grid;[\s\S]*grid-template-columns: repeat\(5, 1fr\);/);
assert.match(popupCss, /\.popup-shortcuts-grid\.is-fixed-cols-4 \.popup-shortcut-card,[\s\S]*\.popup-shortcuts-grid\.is-fixed-cols-5 \.popup-shortcut-card \{\s*width: 100%;/);
});
test('keyboard focus receives explicit visible treatment', () => {
@@ -1115,7 +1409,22 @@ test('discard tab supports per-chip, group-level, and global sleep-all with gate
assert.match(runtimeJs, /data-action="sleep-all-open-tabs"/);
assert.match(runtimeJs, /page-chip--discarded/);
assert.match(runtimeJs, /if \(action === 'discard-tab'\)\s*\{[\s\S]*await fetchOpenTabs\(\);[\s\S]*await renderDashboard\(\);/);
// Group/global sleep skip already-discarded tabs, so "nothing left to
// sleep" is a silent no-op instead of a misleading failure toast.
assert.match(runtimeJs, /getOrderedUniqueTabsForGroup\(group\)\.filter\(t => !t\.active && !t\.discarded\)/);
assert.match(runtimeJs, /getRealTabs\(\)\.filter\(t => !t\.active && !t\.discarded\)/);
assert.match(runtimeJs, /if \(action === 'sleep-domain-tabs'\)\s*\{[\s\S]*await fetchOpenTabs\(\);[\s\S]*await renderDashboard\(\);/);
// The section-header sleep-all uses its own "all tabs" label; the per-group
// card keeps the "in group" label.
assert.match(runtimeJs, /data-action="sleep-all-open-tabs" aria-label="\$\{runtimeT \? runtimeT\('sleepAllOpenTabsButton'\) : 'Sleep all tabs'\}"/);
assert.match(runtimeJs, /data-action="sleep-domain-tabs"[\s\S]{0,120}aria-label="\$\{runtimeT \? runtimeT\('sleepAllTabsButton'\) : 'Sleep all tabs in group'\}"/);
const i18nJs = fs.readFileSync(path.join(__dirname, 'i18n.js'), 'utf8');
assert.match(i18nJs, /sleepAllOpenTabsButton: 'Sleep all tabs'/);
assert.match(i18nJs, /sleepAllOpenTabsButton: '休眠全部标签页'/);
// Both keys must stay distinct with their own wording — collapsing them to
// a shared string would silently rename one of the two buttons.
assert.match(i18nJs, /sleepAllTabsButton: 'Sleep all tabs in group'/);
assert.match(i18nJs, /sleepAllTabsButton: '休眠组内全部标签页'/);
const css = fs.readFileSync(path.join(__dirname, 'style.css'), 'utf8');
assert.match(css, /\.chip-discard:hover\s*\{[\s\S]*color:\s*var\(--muted\);/);