Merge pull request #42 from Leexunhuan743/main
feat: 快捷链接/搜索引擎/popup 增强 + 审计修复批次
This commit is contained in:
@@ -7,3 +7,5 @@ dist/*.zip
|
||||
CLAUDE.local.md
|
||||
CLAUDE.md
|
||||
.codegraph/
|
||||
lat.md/
|
||||
.pi
|
||||
|
||||
@@ -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 %%
|
||||
|
||||
@@ -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
@@ -52,7 +52,7 @@ Tab Harbor 会把标签页整理成更像工作区的结构:**按域名分组
|
||||
|
||||
### 切换主题
|
||||
|
||||
想让它更像你自己的工作台时,可以**切换主题、调透明度、调整文字和快捷链接大小、换背景图**。
|
||||
想让它更像你自己的工作台时,可以**切换主题、调透明度、调整文字和快捷链接大小、换背景图**。在**桌面设置 → 功能**里,你可以选择搜索框使用的**搜索引擎**(浏览器默认、Google、Bing、Baidu、Sogou、DuckDuckGo、Brave、Yandex 或自定义 URL),也可以选择**在新标签页上**点击快捷链接时是**在新标签页打开**还是在**当前标签页打开**;在**桌面设置 → 外观**里可以把快捷链接固定为每行 **4 列或 5 列**(竖屏保持自动),让每个链接的位置稳定不变。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+2279
-373
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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\);/);
|
||||
|
||||
Reference in New Issue
Block a user