chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:04 +08:00
commit c1efa7b89e
85 changed files with 26743 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+190
View File
@@ -0,0 +1,190 @@
When performing a code review, respond in Simplified Chinese.
## Design Context
### Product Overview
Tab Harbor is a calmer Chrome new tab workspace for people who live with many open tabs. It combines open-tab awareness, manual grouping, quick links, saved reads, and lightweight todos in one local-first surface so the browser can feel workable again instead of visually noisy.
### Users
Tab Harbor serves people who keep many browser tabs open while working, switch contexts frequently, and accumulate articles they intend to read. They open a new tab when they need to search, jump back into active work, and tame browsing clutter without leaving the browser.
Typical users include:
- knowledge workers doing research across many sources
- makers and developers juggling documentation, issue threads, and tools
- readers who save articles faster than they can finish them
- people who want a calmer browser workflow without adopting a separate productivity app
### Use Cases
Primary jobs to be done:
1. Re-enter work quickly when opening a new tab.
2. See what is already open before opening more duplicate or forgotten pages.
3. Separate "active now" tabs from "read later" material without losing either.
4. Keep lightweight task reminders close to the browsing context.
5. Tidy tab chaos without turning the browser into a heavy dashboard product.
Secondary jobs:
1. Personalize the workspace through themes, transparency, and background treatment.
2. Jump between groups or content areas quickly from lightweight navigation.
3. Archive or restore content later without anxiety about losing it.
### Brand Personality
Core personality words:
- calm
- literary
- composed
The product should reduce chaos without feeling sterile. It should feel thoughtful, private, and grounded, with the tone of a quiet reading desk rather than a loud productivity tool. It should support focused work with a gentle confidence, not with motivational energy or aggressive optimization language.
### Emotional Goals
The interface should help users feel:
- less visually overwhelmed
- more oriented when returning to work
- quietly in control of a messy browser session
- invited to continue reading, sorting, and acting without friction
It should not make users feel:
- rushed
- gamified
- monitored
- trapped inside a complicated system
### Aesthetic Direction
The visual direction should feel like a quiet reading desk rather than a loud productivity dashboard. The interface should stay editorial and paper-like, while preserving enough clarity and structure for fast scanning during active multitasking.
Desired qualities:
- warm, subtle, and materially believable
- lightweight rather than ornamental
- typographically intentional
- soft in atmosphere, precise in structure
The product should feel closer to a reading surface, pinboard, or annotated workspace than to a metrics dashboard, startup landing page, or neon developer tool.
### Theme Strategy
Default to light or softly tinted themes that support daytime reading and repeated new-tab use. Dark themes may exist, but they should feel muted, paper-like, and low-glare rather than cinematic or high-contrast.
Theme behavior expectations:
1. Neutrals should carry a subtle tint from the active theme.
2. Accent color usage should stay restrained and informative.
3. Theme changes should affect the whole environment, not just isolated buttons.
4. Custom backgrounds are optional atmosphere, never the main content.
5. Readability and scanability take precedence over visual flourish in every theme.
### References
Positive reference qualities:
- editorial interfaces with strong typography and restrained rhythm
- desk, notebook, library, and reading-material cues
- lightweight productivity tools that feel personal rather than corporate
- interfaces that communicate calm through spacing, contrast, and structure instead of empty minimalism
Reference the feeling, not the literal styling: we want the discipline of a well-arranged workspace, not nostalgia cosplay or skeuomorphic decoration.
### Anti-References
Tab Harbor should explicitly avoid looking like:
- a generic SaaS dashboard
- a glowing dark-mode developer homepage
- a wallpaper-first new tab page
- a gamified productivity tracker
- a card-heavy settings panel with thick borders and loud accents
Avoid:
- flashy gradients and neon contrast
- oversized hero-style metrics or promotional layout patterns
- decorative chrome that competes with browsing content
- UI that feels like an app launcher instead of an active workspace
### Design Principles
1. Calm first, but never vague: soothe the interface without hiding what matters.
2. Reading-friendly density: support heavy tab usage and article triage with strong scanability.
3. Gentle hierarchy: use contrast, spacing, and typography to guide attention without shouting.
4. Workspace, not wallpaper: every decorative choice should reinforce a thoughtful working surface.
5. Local and lightweight: the UI should feel immediate, private, and frictionless.
6. Personal, not performative: the interface should feel like a tool someone lives in, not a product trying to impress them.
### Interaction Preferences
1. Favor whitespace and simplicity over persistent explanation. If an interface element can be learned through use, avoid always-visible helper copy.
2. Use words sparingly. Prefer clear structure, spacing, and visual cues before adding labels or descriptive text.
3. Secondary controls should stay quiet. Drawer triggers, theme controls, and similar utilities should not compete with core browsing content.
4. Keep framing subtle. Large borders, thick outlines, or heavy container shapes should be reduced unless they serve a strong structural purpose.
5. Theme controls should feel compact and tool-like, not like a separate feature area. Keep proportions balanced and avoid tall, stretched option cards.
6. Theme previews are palette samples, not decorative icons. They should read as concise material swatches.
7. Empty and archived states should teach lightly through structure and action, not through long explanatory paragraphs.
8. Interactions should feel immediate and local. Prefer lightweight transitions and visible effect over theatrical motion.
### Visual Constraints
1. Reduce auxiliary UI noise first: borders, shadows, helper copy, and oversized controls should all be questioned before adding new elements.
2. Theme surfaces, hover states, tooltips, underline accents, and archive actions should derive from theme variables instead of hard-coded colors.
3. Adjustable controls should have visible effect. If a slider changes transparency or depth, the related surfaces must respond clearly enough to perceive.
4. Decorative elements must support orientation, atmosphere, or hierarchy. If they do none of those, remove them.
5. Avoid excessive card nesting. Flatten structure whenever content can stand on spacing and alignment alone.
6. Keep visual weight concentrated around content and utility, not around containers.
### Typography Guidance
1. Typography is a primary identity layer for this product, not just a utility decision.
2. Favor readable, characterful type pairings that support an editorial and literary tone.
3. Long text should feel comfortable for reading, but UI labels must stay crisp for scanning.
4. Size contrast should create hierarchy without turning the page into a marketing composition.
5. Avoid over-compressed, overly geometric, or trendy "tech brand" typography choices.
### Motion Guidance
1. Motion should clarify focus changes, panel behavior, hover feedback, and state transitions.
2. Prefer soft, brief, decelerating motion over springy or playful movement.
3. Motion should reinforce calmness and immediacy, never spectacle.
4. Reduced-motion users must retain full clarity of state without depending on animation.
### Accessibility Requirements
Baseline expectations:
1. Preserve clear keyboard navigation and visible focus states.
2. Maintain readable contrast across all supported themes.
3. Do not rely on color alone to communicate selection, state, or priority.
4. Support reduced-motion preferences for transitions and panel behavior.
5. Keep hit targets comfortable for repeated use, especially in compact utility controls.
Accessibility here is part of the emotional design: users should feel oriented and safe, not visually strained.
### Theme Panel Guidelines
1. The theme panel should read as a compact utility surface, not a feature page. Prefer balanced proportions over tall, narrow stacks.
2. Theme options should feel like concise selections, not content cards. Keep them short, low in height, and visually uniform.
3. Remove non-essential copy inside the panel. Use section labels and primary option names before adding extra description.
4. The theme option swatch represents the palette relationship: background or paper on one side, accent on the other. Treat it as a material sample, not an icon.
5. The panel shell and inner option shapes should belong to the same visual family. Outer radius, inner radius, spacing, and stroke weight should feel intentionally related.
6. Panel borders must stay subtle. If the container outline becomes a dominant visual element, reduce it.
7. Clicking anywhere outside the panel should close it. Theme controls should feel lightweight and easy to dismiss.
8. Theme-dependent details must stay synchronized: panel surface, option states, slider rail and thumb, tooltips, hover shadows, underline accents, and archive action colors should all respond to the active theme.
### Durable Decision Rules
Use these rules when a future design decision is ambiguous:
1. Prefer calmer over louder.
2. Prefer more legible over more decorative.
3. Prefer stronger structure over more copy.
4. Prefer local usefulness over feature theater.
5. Prefer a surface that supports returning to work over one that tries to impress on first glance.
## Implementation Guardrails
### UI Direction
1. Treat Tab Harbor as a quiet browser workspace, not a SaaS dashboard, wallpaper page, or gamified productivity tool.
2. Preserve the calm / literary / composed direction. New UI should feel like a reading desk or paper workspace, not a glowing developer tool.
3. Prefer scanability over spectacle. If a visual treatment makes the page louder before it makes it clearer, reject it.
4. Keep secondary controls quiet. Drawer triggers, theme controls, archive affordances, and helper actions must not compete with open-tab content.
5. Avoid excessive card nesting, oversized status surfaces, and decorative chrome that does not improve orientation, hierarchy, or atmosphere.
### Interaction And Accessibility
1. Critical actions must not depend on hover alone. Keyboard focus states must be visible and usable.
2. Use reduced-motion-safe interactions. Motion should clarify state changes, never be required to understand them.
3. Keep compact controls comfortably clickable. Do not optimize visual minimalism at the expense of hit area or usability.
4. Theme updates must propagate to the whole environment, including surfaces, hover states, tooltips, slider parts, archive actions, and underline accents.
### Frontend Architecture
1. This project uses plain HTML, CSS, and ordered `<script>` tags with no bundler. Treat script load order as part of the runtime contract.
2. In this environment, top-level `const`, `let`, and `function` declarations can conflict across files. When destructuring from `globalThis`, always use file-scoped prefixed aliases.
3. Keep `extension/app.js` as a thin orchestrator entry. Put substantial runtime logic in supporting files instead of growing the entry file again.
4. Prefer module boundaries by responsibility: UI helpers, theme controls, drawer management, dashboard runtime, and feature-specific subsystems.
### Refactor Safety
1. After any script split, rename top-level bindings defensively to avoid `Identifier has already been declared` startup failures.
2. A green `node --test extension/*.test.js` run is necessary but not sufficient for refactors that touch startup or script loading.
3. When the UI shows only static structure but not dynamic tab content, first suspect startup-time script errors before changing data logic.
4. For startup or module-loading regressions, verify the page in a real browser and inspect console/runtime exceptions before deeper code changes.
### Reference
For fuller rationale and concrete lessons learned, see `docs/design-principles-and-lessons.md`.
+8
View File
@@ -0,0 +1,8 @@
extension/config.local.js
.DS_Store
extension.zip
dist/*.zip
.idea
.vscode
CLAUDE.local.md
CLAUDE.md
+158
View File
@@ -0,0 +1,158 @@
## Design Context
### Product Overview
Tab Harbor is a calmer Chrome new tab workspace for people who live with many open tabs. It combines open-tab awareness, manual grouping, quick links, saved reads, and lightweight todos in one local-first surface so the browser can feel workable again instead of visually noisy.
### Users
Tab Harbor serves people who keep many browser tabs open while working, switch contexts frequently, and accumulate articles they intend to read. They open a new tab when they need to search, jump back into active work, and tame browsing clutter without leaving the browser.
Typical users include:
- knowledge workers doing research across many sources
- makers and developers juggling documentation, issue threads, and tools
- readers who save articles faster than they can finish them
- people who want a calmer browser workflow without adopting a separate productivity app
### Use Cases
Primary jobs to be done:
1. Re-enter work quickly when opening a new tab.
2. See what is already open before opening more duplicate or forgotten pages.
3. Separate "active now" tabs from "read later" material without losing either.
4. Keep lightweight task reminders close to the browsing context.
5. Tidy tab chaos without turning the browser into a heavy dashboard product.
Secondary jobs:
1. Personalize the workspace through themes, transparency, and background treatment.
2. Jump between groups or content areas quickly from lightweight navigation.
3. Archive or restore content later without anxiety about losing it.
### Brand Personality
Core personality words:
- calm
- literary
- composed
The product should reduce chaos without feeling sterile. It should feel thoughtful, private, and grounded, with the tone of a quiet reading desk rather than a loud productivity tool. It should support focused work with a gentle confidence, not with motivational energy or aggressive optimization language.
### Emotional Goals
The interface should help users feel:
- less visually overwhelmed
- more oriented when returning to work
- quietly in control of a messy browser session
- invited to continue reading, sorting, and acting without friction
It should not make users feel:
- rushed
- gamified
- monitored
- trapped inside a complicated system
### Aesthetic Direction
The visual direction should feel like a quiet reading desk rather than a loud productivity dashboard. The interface should stay editorial and paper-like, while preserving enough clarity and structure for fast scanning during active multitasking.
Desired qualities:
- warm, subtle, and materially believable
- lightweight rather than ornamental
- typographically intentional
- soft in atmosphere, precise in structure
The product should feel closer to a reading surface, pinboard, or annotated workspace than to a metrics dashboard, startup landing page, or neon developer tool.
### Theme Strategy
Default to light or softly tinted themes that support daytime reading and repeated new-tab use. Dark themes may exist, but they should feel muted, paper-like, and low-glare rather than cinematic or high-contrast.
Theme behavior expectations:
1. Neutrals should carry a subtle tint from the active theme.
2. Accent color usage should stay restrained and informative.
3. Theme changes should affect the whole environment, not just isolated buttons.
4. Custom backgrounds are optional atmosphere, never the main content.
5. Readability and scanability take precedence over visual flourish in every theme.
### References
Positive reference qualities:
- editorial interfaces with strong typography and restrained rhythm
- desk, notebook, library, and reading-material cues
- lightweight productivity tools that feel personal rather than corporate
- interfaces that communicate calm through spacing, contrast, and structure instead of empty minimalism
Reference the feeling, not the literal styling: we want the discipline of a well-arranged workspace, not nostalgia cosplay or skeuomorphic decoration.
### Anti-References
Tab Harbor should explicitly avoid looking like:
- a generic SaaS dashboard
- a glowing dark-mode developer homepage
- a wallpaper-first new tab page
- a gamified productivity tracker
- a card-heavy settings panel with thick borders and loud accents
Avoid:
- flashy gradients and neon contrast
- oversized hero-style metrics or promotional layout patterns
- decorative chrome that competes with browsing content
- UI that feels like an app launcher instead of an active workspace
### Design Principles
1. Calm first, but never vague: soothe the interface without hiding what matters.
2. Reading-friendly density: support heavy tab usage and article triage with strong scanability.
3. Gentle hierarchy: use contrast, spacing, and typography to guide attention without shouting.
4. Workspace, not wallpaper: every decorative choice should reinforce a thoughtful working surface.
5. Local and lightweight: the UI should feel immediate, private, and frictionless.
6. Personal, not performative: the interface should feel like a tool someone lives in, not a product trying to impress them.
### Interaction Preferences
1. Favor whitespace and simplicity over persistent explanation. If an interface element can be learned through use, avoid always-visible helper copy.
2. Use words sparingly. Prefer clear structure, spacing, and visual cues before adding labels or descriptive text.
3. Secondary controls should stay quiet. Drawer triggers, theme controls, and similar utilities should not compete with core browsing content.
4. Keep framing subtle. Large borders, thick outlines, or heavy container shapes should be reduced unless they serve a strong structural purpose.
5. Theme controls should feel compact and tool-like, not like a separate feature area. Keep proportions balanced and avoid tall, stretched option cards.
6. Theme previews are palette samples, not decorative icons. They should read as concise material swatches.
7. Empty and archived states should teach lightly through structure and action, not through long explanatory paragraphs.
8. Interactions should feel immediate and local. Prefer lightweight transitions and visible effect over theatrical motion.
### Visual Constraints
1. Reduce auxiliary UI noise first: borders, shadows, helper copy, and oversized controls should all be questioned before adding new elements.
2. Theme surfaces, hover states, tooltips, underline accents, and archive actions should derive from theme variables instead of hard-coded colors.
3. Adjustable controls should have visible effect. If a slider changes transparency or depth, the related surfaces must respond clearly enough to perceive.
4. Decorative elements must support orientation, atmosphere, or hierarchy. If they do none of those, remove them.
5. Avoid excessive card nesting. Flatten structure whenever content can stand on spacing and alignment alone.
6. Keep visual weight concentrated around content and utility, not around containers.
### Typography Guidance
1. Typography is a primary identity layer for this product, not just a utility decision.
2. Favor readable, characterful type pairings that support an editorial and literary tone.
3. Long text should feel comfortable for reading, but UI labels must stay crisp for scanning.
4. Size contrast should create hierarchy without turning the page into a marketing composition.
5. Avoid over-compressed, overly geometric, or trendy "tech brand" typography choices.
### Motion Guidance
1. Motion should clarify focus changes, panel behavior, hover feedback, and state transitions.
2. Prefer soft, brief, decelerating motion over springy or playful movement.
3. Motion should reinforce calmness and immediacy, never spectacle.
4. Reduced-motion users must retain full clarity of state without depending on animation.
### Accessibility Requirements
Baseline expectations:
1. Preserve clear keyboard navigation and visible focus states.
2. Maintain readable contrast across all supported themes.
3. Do not rely on color alone to communicate selection, state, or priority.
4. Support reduced-motion preferences for transitions and panel behavior.
5. Keep hit targets comfortable for repeated use, especially in compact utility controls.
Accessibility here is part of the emotional design: users should feel oriented and safe, not visually strained.
### Theme Panel Guidelines
1. The theme panel should read as a compact utility surface, not a feature page. Prefer balanced proportions over tall, narrow stacks.
2. Theme options should feel like concise selections, not content cards. Keep them short, low in height, and visually uniform.
3. Remove non-essential copy inside the panel. Use section labels and primary option names before adding extra description.
4. The theme option swatch represents the palette relationship: background or paper on one side, accent on the other. Treat it as a material sample, not an icon.
5. The panel shell and inner option shapes should belong to the same visual family. Outer radius, inner radius, spacing, and stroke weight should feel intentionally related.
6. Panel borders must stay subtle. If the container outline becomes a dominant visual element, reduce it.
7. Clicking anywhere outside the panel should close it. Theme controls should feel lightweight and easy to dismiss.
8. Theme-dependent details must stay synchronized: panel surface, option states, slider rail and thumb, tooltips, hover shadows, underline accents, and archive action colors should all respond to the active theme.
### Durable Decision Rules
Use these rules when a future design decision is ambiguous:
1. Prefer calmer over louder.
2. Prefer more legible over more decorative.
3. Prefer stronger structure over more copy.
4. Prefer local usefulness over feature theater.
5. Prefer a surface that supports returning to work over one that tries to impress on first glance.
@@ -0,0 +1 @@
{"type":"click","text":"B. 半宽紧凑版\n \n \n \n \n \n \n \n \n \n \n \n ChatGPT\n 6 tabs\n \n Close all\n \n Prompt draft\n Reasoning test\n Pricing page\n API docs\n \n \n \n \n \n B. 半宽紧凑版\n 宽度约 50%,同时压缩卡片 padding、tab 行高和图标尺寸,让一屏能容纳更多分组与标签页。","choice":"b","id":null,"timestamp":1776324903705}
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776326745658}
@@ -0,0 +1 @@
82206
@@ -0,0 +1,104 @@
<h2>紧凑布局方向</h2>
<p class="subtitle">比较标签页列宽、纵向密度,以及 Open tabs 顶部图标导航的摆法。我的推荐是 B。</p>
<div class="cards">
<div class="card" data-choice="a" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">A. 半宽舒适版</div>
<div class="mockup-body">
<div style="display:flex; justify-content:flex-start; gap:8px; margin-bottom:14px;">
<div style="width:28px;height:28px;border-radius:999px;background:#d7c8b9;"></div>
<div style="width:28px;height:28px;border-radius:999px;background:#d9d0c8;"></div>
<div style="width:28px;height:28px;border-radius:999px;background:#d7c8b9;"></div>
<div style="width:28px;height:28px;border-radius:999px;background:#d9d0c8;"></div>
</div>
<div style="width:56%; border:1px solid #d9d0c8; border-radius:10px; padding:10px 12px; background:#fffaf5;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px;">
<div style="font-weight:600;">GitHub</div>
<div style="font-size:11px; color:#b16d3a;">Close all</div>
</div>
<div style="font-size:12px; padding:6px 0; border-top:1px solid #eee4da;">Issue #431</div>
<div style="font-size:12px; padding:6px 0; border-top:1px solid #eee4da;">PR review notes</div>
<div style="font-size:12px; padding:6px 0; border-top:1px solid #eee4da;">Docs tab</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>A. 半宽舒适版</h3>
<p>把当前分组宽度缩到大约现在的一半,保留比较舒展的行高,图标导航独立一排。</p>
</div>
</div>
<div class="card" data-choice="b" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">B. 半宽紧凑版</div>
<div class="mockup-body">
<div style="display:flex; justify-content:flex-start; gap:6px; margin-bottom:10px; flex-wrap:wrap;">
<div style="width:24px;height:24px;border-radius:999px;background:#b16d3a;"></div>
<div style="width:24px;height:24px;border-radius:999px;background:#d9d0c8;"></div>
<div style="width:24px;height:24px;border-radius:999px;background:#d7c8b9;"></div>
<div style="width:24px;height:24px;border-radius:999px;background:#d9d0c8;"></div>
<div style="width:24px;height:24px;border-radius:999px;background:#d7c8b9;"></div>
</div>
<div style="width:50%; border:1px solid #d9d0c8; border-radius:10px; padding:9px 11px; background:#fffaf5;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:6px;">
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div style="font-weight:600;">ChatGPT</div>
<div style="font-size:10px; color:#b16d3a; background:#f8ede4; padding:2px 6px; border-radius:999px;">6 tabs</div>
</div>
<div style="font-size:11px; color:#b16d3a;">Close all</div>
</div>
<div style="font-size:11px; padding:4px 0; border-top:1px solid #eee4da;">Prompt draft</div>
<div style="font-size:11px; padding:4px 0; border-top:1px solid #eee4da;">Reasoning test</div>
<div style="font-size:11px; padding:4px 0; border-top:1px solid #eee4da;">Pricing page</div>
<div style="font-size:11px; padding:4px 0; border-top:1px solid #eee4da;">API docs</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>B. 半宽紧凑版</h3>
<p>宽度约 50%,同时压缩卡片 padding、tab 行高和图标尺寸,让一屏能容纳更多分组与标签页。</p>
</div>
</div>
<div class="card" data-choice="c" onclick="toggleSelect(this)">
<div class="card-image">
<div class="mockup">
<div class="mockup-header">C. 超密度导航版</div>
<div class="mockup-body">
<div style="display:flex; gap:4px; margin-bottom:10px; flex-wrap:wrap;">
<div style="width:20px;height:20px;border-radius:999px;background:#b16d3a;"></div>
<div style="width:20px;height:20px;border-radius:999px;background:#d9d0c8;"></div>
<div style="width:20px;height:20px;border-radius:999px;background:#d7c8b9;"></div>
<div style="width:20px;height:20px;border-radius:999px;background:#d9d0c8;"></div>
<div style="width:20px;height:20px;border-radius:999px;background:#d7c8b9;"></div>
<div style="width:20px;height:20px;border-radius:999px;background:#d9d0c8;"></div>
</div>
<div style="width:46%; border:1px solid #d9d0c8; border-radius:10px; padding:8px 10px; background:#fffaf5;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px;">
<div style="font-weight:600; font-size:13px;">GitHub</div>
<div style="font-size:10px; color:#b16d3a;">Close all</div>
</div>
<div style="font-size:10px; padding:3px 0; border-top:1px solid #eee4da;">Issue</div>
<div style="font-size:10px; padding:3px 0; border-top:1px solid #eee4da;">PR</div>
<div style="font-size:10px; padding:3px 0; border-top:1px solid #eee4da;">Actions</div>
<div style="font-size:10px; padding:3px 0; border-top:1px solid #eee4da;">Docs</div>
</div>
</div>
</div>
</div>
<div class="card-body">
<h3>C. 超密度导航版</h3>
<p>进一步缩窄卡片和图标,信息量最高,但可读性会更紧,容易显得拥挤。</p>
</div>
</div>
</div>
<div class="section">
<p class="subtitle">打开这个链接查看并点选你喜欢的方向:<strong>http://localhost:59364</strong></p>
<p class="subtitle">看完在终端告诉我你选 A / B / C,或者直接说“B 但不要那么紧”。</p>
</div>
+57
View File
@@ -0,0 +1,57 @@
# Tab Harbor Agent Guide
This file captures project-level design and implementation constraints for agents working in this repository.
## Design Direction
1. Tab Harbor is a quiet browser workspace, not a SaaS dashboard, wallpaper page, or gamified productivity product.
2. Preserve the calm / literary / composed identity. Interfaces should feel like a reading desk or paper workspace.
3. Prefer scanability over spectacle. If a change makes the page louder before it makes it clearer, reject it.
4. Keep secondary controls quiet. Theme controls, drawer triggers, archive actions, and helper affordances must not visually outrank tab content.
5. Avoid decorative chrome that does not improve hierarchy, orientation, or atmosphere.
## UI Guardrails
1. Do not rely on hover alone for critical controls or discoverability.
2. Keyboard focus must stay visible and usable.
3. Reduced-motion users must still understand every state change without animation.
4. Compact controls still need comfortable hit targets.
5. Theme changes must update the full environment, not just local controls.
## Interaction Lessons
1. Floating editors triggered from compact controls should anchor near the triggering element when practical; default corner placement is only a fallback.
2. For dense icon reordering, prefer explicit grid tracks over `flex-wrap` when cross-row drag behavior matters.
3. For icon drag-and-drop previews, use `ghost + slot` instead of converting the original node to `position: fixed`; keep the dragged visual under the pointer and the layout slot in the flow.
4. Drag preview hit-testing should be based on stable slot positions captured at drag start, not on the currently reflowing DOM alone.
5. FLIP animations in reorderable icon strips should only run for nodes whose order actually changed; avoid re-animating unaffected siblings.
## Frontend Architecture
1. This project is plain HTML, CSS, and ordered `<script>` tags with no bundler or ESM module system.
2. Script load order is part of the runtime contract. Treat changes to `index.html` script order as high impact.
3. Top-level bindings can collide across files. When destructuring from `globalThis`, use file-scoped prefixed aliases instead of shared short names.
4. Keep `extension/app.js` as a thin orchestrator entry. Do not let it grow back into a catch-all runtime file.
5. Prefer responsibility-based module boundaries such as:
- `ui-helpers.js`
- `theme-controls.js`
- `drawer-manager.js`
- `dashboard-runtime.js`
## Refactor Safety
1. After any script split, actively check for startup-time failures such as `Identifier has already been declared`.
2. A passing `node --test extension/*.test.js` run is necessary but not sufficient for startup refactors.
3. If the page shows static scaffolding but not dynamic tab data, first suspect runtime initialization failure before changing data logic.
4. For startup regressions, inspect real browser console/runtime errors before continuing to refactor.
## Validation
1. Run `node --test extension/*.test.js` after code changes that affect UI structure, script loading, or runtime behavior.
2. For script-loading or initialization changes, also verify the extension in a real browser session.
## Reference
Detailed rationale and lessons learned live in:
- `docs/design-principles-and-lessons.md`
- `.impeccable.md`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Han Zheng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+160
View File
@@ -0,0 +1,160 @@
# Privacy Policy for Tab Harbor
Last updated: 2026-05-27
Tab Harbor is a Chrome extension that turns the browser new tab page into a calmer workspace for open tabs, quick links, saved reads, and lightweight todos.
This Privacy Policy explains what data Tab Harbor accesses, how that data is used, and how it is stored.
## 1. Summary
Tab Harbor is designed to work locally in the browser.
It does not require a user account.
It does not sell user data.
It does not use remote code.
It does not send workspace data to a backend controlled by the developer.
Most extension data is stored locally through Chrome extension storage APIs.
## 2. Information the extension accesses
To provide its core features, Tab Harbor may access the following categories of data:
- Open tab metadata, including tab titles, URLs, and tab state
- Quick links created by the user
- Saved read-later items created by the user
- Todo items created by the user
- Theme, transparency, layout, and ordering preferences selected by the user
- Clipboard image or SVG content, but only when the user explicitly pastes a custom shortcut icon
Tab Harbor does not intentionally collect sensitive categories such as health data, financial account credentials, or personal communications.
## 3. How the data is used
Tab Harbor uses the accessed data only to provide the extensions single purpose:
- Show and organize the users current browser session in the new tab page
- Help the user review, group, focus, close, deduplicate, save, and restore tabs
- Let the user manage quick links, saved reads, and lightweight todos in one workspace
- Restore user preferences and layout state when the new tab page is opened again
- Let the user paste a custom icon into a shortcut when they explicitly choose to do so
Tab Harbor does not use this data for advertising, profiling, creditworthiness, or unrelated analytics.
## 4. Chrome permissions and why they are needed
Tab Harbor requests the following Chrome permissions:
### `tabs`
Used to read and manage the users open tabs so the extension can:
- display open tabs in the workspace
- group tabs by site or workflow
- focus a selected tab
- close or deduplicate tabs at the users request
- save or restore tabs as part of the read-later workflow
### `storage`
Used to store user-created and user-selected local state, including:
- quick links
- saved reads
- todo items
- theme settings
- group order and layout order
- other local workspace preferences
### `search`
Used when the user types into the built-in search field and explicitly submits a search.
This permission allows Tab Harbor to send the query through the browsers default search engine in the current tab.
### `clipboardRead`
Used only when the user explicitly pastes an image or SVG into the shortcut editor to create a custom shortcut icon.
Tab Harbor does not continuously monitor or silently read clipboard contents in the background.
### `tabGroups`
Used when the user enables Chrome tab group sync features so the extension can:
- read existing native Chrome tab groups
- create or update Chrome tab groups to match the workspace
- keep group titles, colors, and collapsed state aligned with the users chosen organization
- respond when Chrome tab groups change outside the Tab Harbor page
### `favicon`
Used to let Tab Harbor request and display site icons for tabs, saved sessions, and quick shortcuts more reliably.
This may include Chromes packaged favicon endpoint, a sites own `favicon.ico`, or a fallback favicon service when no better icon is available.
## 5. Data storage and retention
Tab Harbor stores extension data locally using Chrome extension storage APIs, primarily `chrome.storage.local`.
This includes items such as:
- quick links
- saved reads
- todos
- theme preferences
- layout and ordering preferences
This data remains in the browser profile until the user removes it through the extension UI, clears extension storage, or uninstalls the extension.
## 6. Data sharing
Tab Harbor does not sell user data.
Tab Harbor does not transfer user data to third parties except where necessary to perform a user-requested browser action, such as:
- opening a page URL chosen by the user
- sending a search query through the browsers default search engine after the user explicitly submits it
- requesting favicon resources needed to display site icons in the workspace
Aside from those normal browser actions initiated by the user, Tab Harbor does not send workspace data to a developer-controlled remote service.
## 7. Remote code
Tab Harbor does not use remote code.
All JavaScript and extension assets required to run the extension are packaged with the extension bundle. Tab Harbor does not download and execute external JavaScript or WebAssembly at runtime.
## 8. Third-party services
Tab Harbor relies on Chromes extension platform APIs to function.
When a user opens a webpage or submits a search through the extension, the browser may connect to the destination website or the configured default search engine as part of the users explicit action.
To render site icons, Tab Harbor may also request favicon resources from a pages origin or a fallback favicon service based on the page URLs already visible in the workspace.
## 9. User choices and controls
Users can control their data by:
- editing or deleting quick links
- deleting saved reads or todos
- changing theme and layout preferences
- uninstalling the extension
- clearing extension storage through browser settings
If you do not want Tab Harbor to access your open tabs, do not use the extension.
## 10. Childrens privacy
Tab Harbor is not directed to children under 13, and the developer does not knowingly collect personal information from children.
## 11. Changes to this policy
This Privacy Policy may be updated from time to time to reflect product changes, permission changes, or legal requirements.
The latest version will be published at the public URL associated with the extension listing.
## 12. Contact
If you have questions about this Privacy Policy or Tab Harbor, you may contact:
- Publisher contact email: `lunatic.halle@gmail.com`
+132
View File
@@ -0,0 +1,132 @@
# Tab Harbor
[English](README.md) | [简体中文](README.zh-CN.md)
**A calmer Chrome new tab dashboard for open tabs, quick links, saved sessions, and lightweight todos.**
Tab Harbor turns Chrome's new tab page into a place where you can keep working. You immediately see what is already open, what should be saved for later, and what still needs your attention.
<p align="center">
<img src="assets/readme/feature-tabs.png" alt="Tab Harbor overview" width="760">
</p>
## ✨ Core Highlights
- **Tabs are automatically organized by domain.** Tab Harbor groups open pages by domain, and moves homepage-style tabs into a dedicated `Homepages` group, so you can quickly see what you are actually working on.
- **You can still organize things around your own workflow.** When domain-based grouping is not enough, you can create manual groups, keep common quick links around, and jump back to the right section from the top icon rail.
- **Saved tabs now behave more like sessions.** You can choose what to save, add tabs to an existing saved session, restore them later, and keep the overview tidy with collapsed session groups when you do not need every tab in front of you.
- **Todos stay close, but out of the way.** The drawer lets you create, edit, delete, search, and archive todos without leaving the page.
- **It keeps getting calmer without getting heavier.** You can switch themes, tune transparency, adjust text and shortcut size, set a custom background, sleep inactive tabs, and clean duplicate tabs with one click, while everything still stays in `chrome.storage.local` with no backend or account.
## 🖼️ Feature Tour
<table>
<tr>
<td width="33.33%" valign="top">
<strong>Unified tab management</strong><br><br>
<img src="assets/readme/feature-tabs.png" alt="Tabs" width="100%">
</td>
<td width="33.33%" valign="top">
<strong>Saved sessions</strong><br><br>
<img src="assets/readme/feature-saved-drawer.png" alt="Saved sessions drawer" width="100%">
</td>
<td width="33.33%" valign="top">
<strong>Todos and quick jumping</strong><br><br>
<img src="assets/readme/feature-todos.png" alt="Todos" width="100%">
</td>
</tr>
</table>
### Unified tab management
Tab Harbor organizes tabs more like a workspace: **domain-based groups, manual groups, quick access links, and fast jumping from the top icon rail**. If you want to clean up the browser a bit more, you can **also remove duplicate tabs with one click**.
### Saved sessions
Pages you do not need right now can be **saved as sessions, added to an existing session, restored later, or kept collapsed for a quieter overview**.
### Todos and quick jumping
Tab Harbor also works as a tiny action layer: jot down todos, keep short descriptions, archive completed items, and jump back into the right group from the same page.
### 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**.
<table>
<tr>
<td><img src="assets/readme/theme-warm-neutral.png" alt="warm neutral" width="100%"></td>
<td><img src="assets/readme/theme-soft-green.png" alt="soft green" width="100%"></td>
</tr>
<tr>
<td><img src="assets/readme/theme-soft-clay.png" alt="soft clay" width="100%"></td>
<td><img src="assets/readme/theme-custom-background.png" alt="custom background" width="100%"></td>
</tr>
</table>
### Manual sleep control
When you want to slow a tab down without losing it, you can **sleep individual tabs or put an entire group to sleep from the workspace itself**.
## 🌊 Why It Feels Different
Most new tab pages try to be a search box, a wallpaper, or a speed dial. Tab Harbor is closer to a lightweight browser control room. It keeps the messy reality of browsing visible, but turns it into something calmer and more actionable.
That also means it is intentionally lightweight. There is no backend, no sync account, and no extra app to open. It lives exactly where the browsing chaos already happens.
## ⚡ Quick Use
### Install from the Chrome Web Store
1. Open Tab Harbor in the Chrome Web Store:
[Open Tab Harbor in the Chrome Web Store](https://chromewebstore.google.com/detail/tab-harbor/bkjihmeifgjifhkleokclpobdfnhiodf?authuser=0&hl=zh-CN)
2. Install it from the store.
3. Open a new tab in Chrome.
Due to package review, the actual version may appear a little later.
### Install with a coding agent
1. Give your coding agent this repo:
```text
https://github.com/V-IOLE-T/tab-harbor
```
2. Ask it to install the extension.
3. Open a new tab in Chrome.
### Install manually
1. Clone this repo:
```bash
git clone https://github.com/V-IOLE-T/tab-harbor.git
```
2. Open `chrome://extensions`
3. Turn on **Developer mode**
4. Click **Load unpacked**
5. Select the [`extension/`](extension/) folder in Chrome, or the repo root in Edge
6. Open a new tab
## 🔒 Fully Local
Tab Harbor runs entirely inside the extension. Open tabs come directly from Chrome, and saved sessions, todos, quick links, theme preferences, and layout state stay on your machine through `chrome.storage.local`.
If you publish this repo for other people, they get the code and assets, not your personal browsing data.
## 🛠️ Under the Hood
This is a Manifest V3 Chrome extension with a plain frontend stack and no build step required to use it. You can clone it, load it, and start using it without npm, without a dev server, and without standing up anything else.
## 🙏 Acknowledgements
- Tab Harbor is built on top of Zara's open-source project [tab-out](https://github.com/zarazhangrui/tab-out), which is the upstream repository and the starting point for this project.
- Thanks as well to the [Linux.do community](https://linux.do) for the ideas, feedback, and the kind of maker energy that helps projects like this keep evolving.
## 📄 License
MIT License
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`V-IOLE-T/tab-harbor`
- 原始仓库:https://github.com/V-IOLE-T/tab-harbor
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+131
View File
@@ -0,0 +1,131 @@
# Tab Harbor
[English](README.md) | [简体中文](README.zh-CN.md)
**一个更安静的新标签页工作台,把打开中的标签、快捷链接、已保存会话和轻量待办收进同一个顺手的空间里。**
Tab Harbor 会把 Chrome 的新标签页变成一个可以继续工作的地方。你会先看到自己现在到底开了什么、哪些内容适合稍后保存成会话、还有哪些事情还没处理完。
<p align="center">
<img src="assets/readme/feature-tabs.png" alt="Tab Harbor 总览" width="760">
</p>
## ✨ 核心亮点
- **标签页按域名自动整理。** Tab Harbor 会按域名整理打开中的页面,把首页型标签单独收到 `Homepages` 分组里,让你先看清自己到底在处理什么。
- **自动分组之外,你也还能按自己的工作流继续整理。** 当域名分组不够用时,你可以创建手动分组、保留常用的 quick links,并通过顶部图标很快跳回需要的区域。
- **已保存的标签页现在更像会话。** 你可以选择要保存哪些内容、把标签页加进已有会话、之后再恢复回来,也可以把会话折叠起来,让概览更干净。
- **待办一直在身边,但不会太吵。** 抽屉里可以新建、编辑、删除、搜索和归档待办,不用离开页面。
- **它尽量让工作台更安静,但不让系统更重。** 你可以切换主题、调透明度、调整文字和快捷链接大小、换背景图、让不活跃标签进入睡眠,也可以一键清理重复标签页;同时所有数据都还是保存在 `chrome.storage.local`,不需要服务器也不需要账号。
## 🖼️ 功能展示
<table>
<tr>
<td width="33.33%" valign="top">
<strong>标签页统一管理</strong><br><br>
<img src="assets/readme/feature-tabs.png" alt="标签页" width="100%">
</td>
<td width="33.33%" valign="top">
<strong>已保存会话</strong><br><br>
<img src="assets/readme/feature-saved-drawer.png" alt="已保存会话抽屉" width="100%">
</td>
<td width="33.33%" valign="top">
<strong>待办和跳转</strong><br><br>
<img src="assets/readme/feature-todos.png" alt="待办" width="100%">
</td>
</tr>
</table>
### 标签页统一管理
Tab Harbor 会把标签页整理成更像工作区的结构:**按域名分组、支持手动分组、保留快捷入口,并且能从顶部图标快速跳回对应区域**;想把浏览器收拾利落一点时,**也可以一键清理重复标签页**。
### 已保存会话
那些“现在先不看,但之后一定要回来”的页面,可以**先保存成会话、加入已有会话、之后再恢复,或者折叠起来保留一个更安静的概览**。
### 待办和跳转
除了整理标签页,你还可以在这里顺手记下待办、补一点简短说明、归档完成项,并从顶部图标快速回到当前任务相关的分组。
### 切换主题
想让它更像你自己的工作台时,可以**切换主题、调透明度、调整文字和快捷链接大小、换背景图**。
<table>
<tr>
<td><img src="assets/readme/theme-warm-neutral.png" alt="warm neutral" width="100%"></td>
<td><img src="assets/readme/theme-soft-green.png" alt="soft green" width="100%"></td>
</tr>
<tr>
<td><img src="assets/readme/theme-soft-clay.png" alt="soft clay" width="100%"></td>
<td><img src="assets/readme/theme-custom-background.png" alt="custom background" width="100%"></td>
</tr>
</table>
### 手动睡眠控制
当你想让某个标签页先“睡一会儿”,又不想真的丢掉它时,可以**直接把单个标签页或整个分组放进睡眠状态**。
## 🌊 为什么它用起来不一样
很多新标签页产品想做的是搜索框、壁纸页,或者更漂亮一点的快捷方式面板。Tab Harbor 更像一个很轻的浏览器控制台。它不会假装你没有开很多标签页,而是承认这种混乱就是现实,然后把它整理成更能工作的样子。
这也是它为什么尽量保持轻。没有后端,没有同步账号,也不需要你再开一个单独应用。所有混乱本来就发生在浏览器里,那它就直接在那里帮你收拾。
## ⚡ 快速使用
### 从 Google 应用商店安装
1. 打开 Tab Harbor 的 Chrome 应用商店页面:
[在 Chrome 应用商店打开 Tab Harbor](https://chromewebstore.google.com/detail/tab-harbor/bkjihmeifgjifhkleokclpobdfnhiodf?authuser=0&hl=zh-CN)
2. 直接从商店安装。
3. 在 Chrome 里打开一个新标签页。
由于商店版本需要打包审核,实际版本会稍微延后一点。
### 用 coding agent 安装
1. 把这个仓库地址给你的 coding agent
```text
https://github.com/V-IOLE-T/tab-harbor
```
2. 让它帮你安装扩展
3. 在 Chrome 里打开一个新标签页
### 手动安装
1. 克隆仓库:
```bash
git clone https://github.com/V-IOLE-T/tab-harbor.git
```
2. 打开 `chrome://extensions`
3. 开启 **Developer mode**
4. 点击 **Load unpacked**
5. 在 Chrome 里选择 [`extension/`](extension/) 文件夹,Edge 里可直接选仓库根目录
6. 打开一个新标签页
## 🔒 完全本地
Tab Harbor 完全运行在扩展内部。打开中的标签页直接来自 Chrome,已保存会话、Todos、Quick links、主题偏好和布局状态都留在你自己的机器上,通过 `chrome.storage.local` 保存。
如果你把这个仓库发到 GitHub 给别人用,他们拿到的是代码和资源文件,不会带上你的个人浏览数据。
## 🛠️ 底层
这是一个 Manifest V3 的 Chrome 扩展,前端结构很轻,使用时也不需要 build step。你可以直接 clone、直接加载、直接开始用,不需要 npm,不需要 dev server,也不需要先把别的东西跑起来。
## 🙏 致谢
- Tab Harbor 基于 Zara 的开源项目 [tab-out](https://github.com/zarazhangrui/tab-out) 继续发展,它也是本项目的上游仓库和最初出发点。
- 感谢 [Linux.do 社区](https://linux.do) 提供的灵感、反馈和很有生命力的开源氛围,让这个项目能持续往前长。
## 📄 License
MIT License
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+463
View File
@@ -0,0 +1,463 @@
:root {
color-scheme: light;
--ink: #1f1914;
--paper: #f6f0e6;
--paper-strong: #fffaf3;
--line: rgba(109, 84, 51, 0.16);
--accent: #c6723e;
--accent-soft: rgba(198, 114, 62, 0.12);
--sage: #5f7761;
--mist: #63768a;
--rose: #b46a70;
--shadow: 0 24px 60px rgba(69, 48, 22, 0.12);
--shadow-soft: 0 12px 24px rgba(69, 48, 22, 0.08);
font-family: "SF Pro Text", "DM Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1440px;
height: 860px;
overflow: hidden;
}
body {
background:
radial-gradient(circle at top left, rgba(198, 114, 62, 0.16), transparent 30%),
radial-gradient(circle at bottom right, rgba(95, 119, 97, 0.15), transparent 28%),
linear-gradient(180deg, #f9f4ec 0%, #f3ece0 100%);
color: var(--ink);
}
.stage,
.scene {
width: 100%;
height: 100%;
}
.scene {
display: none;
padding: 28px;
}
html[data-scene="overview"] .scene[data-scene="overview"],
html[data-scene="saved"] .scene[data-scene="saved"],
html[data-scene="todos"] .scene[data-scene="todos"],
html[data-scene="themes"] .scene[data-scene="themes"] {
display: block;
}
.window {
height: 100%;
border: 1px solid rgba(151, 121, 83, 0.14);
border-radius: 32px;
background: rgba(255, 250, 243, 0.76);
backdrop-filter: blur(14px);
box-shadow: var(--shadow);
padding: 24px 24px 28px;
}
.banner {
display: flex;
align-items: center;
gap: 12px;
background: rgba(98, 129, 105, 0.12);
border: 1px solid rgba(98, 129, 105, 0.18);
color: #38533d;
border-radius: 999px;
padding: 10px 14px;
font-size: 14px;
}
.banner.warning {
background: rgba(198, 114, 62, 0.12);
border-color: rgba(198, 114, 62, 0.2);
color: #7a4422;
}
.banner-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: currentColor;
opacity: 0.7;
}
.banner-action {
margin-left: auto;
padding: 7px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
font-weight: 600;
}
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-top: 20px;
}
.hero.compact {
margin-bottom: 18px;
}
.eyebrow {
margin: 0 0 10px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(70, 53, 35, 0.58);
}
h1,
h2 {
margin: 0;
font-family: "Iowan Old Style", "Georgia", serif;
font-weight: 600;
letter-spacing: -0.03em;
}
h1 {
max-width: 760px;
font-size: 52px;
line-height: 1;
}
h2 {
font-size: 30px;
line-height: 1.05;
}
.subcopy {
max-width: 760px;
margin: 16px 0 0;
color: rgba(48, 37, 26, 0.72);
font-size: 17px;
line-height: 1.6;
}
.search,
.theme-trigger,
.drawer-tabs {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 54px;
padding: 0 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 251, 245, 0.82);
box-shadow: var(--shadow-soft);
color: rgba(54, 41, 27, 0.7);
}
.search {
min-width: 340px;
justify-content: flex-start;
}
.search-icon {
font-size: 20px;
}
.shortcut-row {
display: flex;
gap: 12px;
margin-top: 22px;
}
.shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
padding: 12px 14px;
border-radius: 18px;
border: 1px solid rgba(111, 88, 58, 0.14);
background: rgba(255, 251, 246, 0.84);
box-shadow: var(--shadow-soft);
font-weight: 600;
}
.shortcut.github { color: #7c4a2d; }
.shortcut.docs { color: #4d6a7f; }
.shortcut.local { color: #456a52; }
.shortcut.read { color: #8a5260; }
.shortcut.add { min-width: 54px; }
.icon-rail {
display: flex;
gap: 10px;
margin-top: 22px;
}
.icon-rail.left {
margin-top: 18px;
}
.rail-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 44px;
border-radius: 16px;
background: rgba(255, 248, 240, 0.8);
border: 1px solid rgba(111, 88, 58, 0.14);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
color: rgba(65, 49, 31, 0.76);
}
.rail-pill.active {
background: var(--accent-soft);
border-color: rgba(198, 114, 62, 0.24);
color: #874b27;
}
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 22px;
}
.workspace-card,
.drawer,
.theme-panel {
border-radius: 26px;
border: 1px solid rgba(111, 88, 58, 0.14);
background: rgba(255, 251, 246, 0.86);
box-shadow: var(--shadow-soft);
}
.workspace-card {
padding: 20px 20px 18px;
}
.workspace-card.accent {
background:
linear-gradient(180deg, rgba(198, 114, 62, 0.11) 0%, rgba(255, 251, 246, 0.92) 100%);
}
.card-head,
.drawer-header,
.theme-header,
.archive-row,
.range-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.label {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(80, 60, 40, 0.52);
}
.badge,
.mini-action {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: rgba(198, 114, 62, 0.1);
color: #8a4d29;
font-size: 13px;
font-weight: 700;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.chip {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 12px;
border-radius: 14px;
background: rgba(244, 237, 226, 0.92);
color: rgba(58, 44, 29, 0.76);
font-size: 14px;
}
.split-layout,
.theme-layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.9fr);
gap: 20px;
margin-top: 12px;
height: calc(100% - 132px);
}
.left-stack {
display: grid;
gap: 18px;
align-content: start;
}
.drawer,
.theme-panel {
padding: 20px;
}
.list,
.todo-card {
display: grid;
gap: 12px;
margin-top: 16px;
}
.list-item,
.todo-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: rgba(243, 235, 224, 0.68);
}
.list-item p,
.todo-row p {
margin: 6px 0 0;
color: rgba(67, 51, 34, 0.66);
font-size: 13px;
}
.checkbox,
.todo-check {
width: 18px;
height: 18px;
margin-top: 2px;
border-radius: 999px;
border: 2px solid rgba(120, 94, 61, 0.4);
flex-shrink: 0;
}
.todo-check.checked {
background: var(--sage);
border-color: var(--sage);
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.86);
}
.archive {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid rgba(111, 88, 58, 0.1);
color: rgba(64, 49, 32, 0.68);
font-size: 14px;
}
.drawer-tabs {
padding: 6px;
min-height: auto;
gap: 8px;
}
.drawer-tab {
padding: 10px 16px;
border-radius: 14px;
color: rgba(67, 51, 34, 0.66);
}
.drawer-tab.active {
background: rgba(198, 114, 62, 0.12);
color: #8a4d29;
font-weight: 700;
}
.theme-layout {
align-items: start;
}
.swatches {
display: flex;
gap: 12px;
margin-top: 16px;
}
.swatch {
width: 54px;
height: 54px;
border-radius: 18px;
border: 1px solid rgba(111, 88, 58, 0.14);
box-shadow: var(--shadow-soft);
}
.swatch.paper { background: linear-gradient(135deg, #fff8ef, #d99865); }
.swatch.sage { background: linear-gradient(135deg, #edf5e8, #7c9b81); }
.swatch.mist { background: linear-gradient(135deg, #eef3f8, #7d95ab); }
.swatch.blush { background: linear-gradient(135deg, #f8efef, #c78a8f); }
.swatch.active {
outline: 3px solid rgba(198, 114, 62, 0.24);
}
.range-block {
margin-top: 18px;
padding: 16px;
border-radius: 20px;
background: rgba(244, 237, 226, 0.72);
}
.range-track {
position: relative;
height: 8px;
margin-top: 14px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(198, 114, 62, 0.28), rgba(198, 114, 62, 0.1));
}
.range-thumb {
position: absolute;
top: 50%;
left: 32%;
width: 18px;
height: 18px;
border-radius: 999px;
background: #9f5c33;
transform: translate(-50%, -50%);
box-shadow: 0 6px 16px rgba(94, 53, 25, 0.24);
}
.theme-preview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
.preview-card {
height: 160px;
border-radius: 22px;
background:
radial-gradient(circle at top left, rgba(198, 114, 62, 0.16), transparent 34%),
rgba(255, 249, 242, 0.92);
border: 1px solid rgba(111, 88, 58, 0.12);
}
.preview-card.warm {
background:
radial-gradient(circle at bottom right, rgba(95, 119, 97, 0.18), transparent 34%),
rgba(250, 244, 236, 0.92);
}
+382
View File
@@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Harbor README Mockups</title>
<link rel="stylesheet" href="./mockups.css">
</head>
<body>
<main class="stage">
<section class="scene" data-scene="overview">
<div class="window">
<div class="banner">
<span class="banner-dot"></span>
Domain-grouped tabs, custom groups, and one-click jumps from the top icon rail.
</div>
<header class="hero">
<div>
<p class="eyebrow">Tab Harbor</p>
<h1>Open tabs, sorted into calm workspaces.</h1>
<p class="subcopy">
Group by domain, keep quick links nearby, and give homepage tabs their own lane instead of letting
everything collapse into one endless browser strip.
</p>
</div>
<div class="search">
<span class="search-icon"></span>
<span>Search with your default engine...</span>
</div>
</header>
<div class="shortcut-row">
<span class="shortcut github">GitHub</span>
<span class="shortcut docs">Docs</span>
<span class="shortcut local">Localhost</span>
<span class="shortcut read">Read Later</span>
<span class="shortcut add">+</span>
</div>
<div class="icon-rail">
<span class="rail-pill active">GH</span>
<span class="rail-pill">DOC</span>
<span class="rail-pill">LD</span>
<span class="rail-pill">LP</span>
<span class="rail-pill">HP</span>
</div>
<div class="content-grid">
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">github.com</p>
<h2>5 open tabs</h2>
</div>
<span class="badge">Auto group</span>
</div>
<div class="chips">
<span class="chip">repo / issues</span>
<span class="chip">README polish</span>
<span class="chip">PR diff</span>
<span class="chip">Actions</span>
<span class="chip">notifications</span>
</div>
</article>
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">Docs</p>
<h2>4 open tabs</h2>
</div>
<span class="badge">Jump fast</span>
</div>
<div class="chips">
<span class="chip">Chrome extension API</span>
<span class="chip">Manifest V3</span>
<span class="chip">CDP screenshot notes</span>
<span class="chip">Theme reference</span>
</div>
</article>
<article class="workspace-card accent">
<div class="card-head">
<div>
<p class="label">Launch Prep</p>
<h2>3 pinned tabs</h2>
</div>
<span class="badge">Custom group</span>
</div>
<div class="chips">
<span class="chip">README checklist</span>
<span class="chip">release notes</span>
<span class="chip">community post draft</span>
</div>
</article>
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">Homepages</p>
<h2>4 landing tabs</h2>
</div>
<span class="badge">Clean top bar</span>
</div>
<div class="chips">
<span class="chip">YouTube</span>
<span class="chip">LinkedIn</span>
<span class="chip">X</span>
<span class="chip">Gmail</span>
</div>
</article>
</div>
</div>
</section>
<section class="scene" data-scene="saved">
<div class="window">
<header class="hero compact">
<div>
<p class="eyebrow">Saved For Later</p>
<h1>Turn “not now” tabs into an inbox.</h1>
</div>
<div class="drawer-tabs">
<span class="drawer-tab active">Saved</span>
<span class="drawer-tab">Todos</span>
</div>
</header>
<div class="split-layout">
<div class="left-stack">
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">linux.do</p>
<h2>3 open tabs</h2>
</div>
<span class="badge">Community</span>
</div>
<div class="chips">
<span class="chip">Extension feedback</span>
<span class="chip">Showcase thread</span>
<span class="chip">Launch notes</span>
</div>
</article>
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">localhost</p>
<h2>2 active tabs</h2>
</div>
<span class="badge">:3000 / :5173</span>
</div>
<div class="chips">
<span class="chip">preview build</span>
<span class="chip">extension sandbox</span>
</div>
</article>
</div>
<aside class="drawer">
<div class="drawer-header">
<div>
<p class="label">Saved for later</p>
<h2>5 waiting reads</h2>
</div>
<span class="mini-action">Search</span>
</div>
<div class="list">
<div class="list-item">
<div class="checkbox"></div>
<div>
<strong>Designing calm productivity surfaces</strong>
<p>Saved 2h ago · Medium</p>
</div>
</div>
<div class="list-item">
<div class="checkbox"></div>
<div>
<strong>Chrome tabs API reference</strong>
<p>Saved yesterday · developer.chrome.com</p>
</div>
</div>
<div class="list-item">
<div class="checkbox"></div>
<div>
<strong>Tab-out inspiration notes</strong>
<p>Saved yesterday · GitHub</p>
</div>
</div>
<div class="list-item">
<div class="checkbox"></div>
<div>
<strong>README screenshot checklist</strong>
<p>Saved 3m ago · local note</p>
</div>
</div>
</div>
<div class="archive">
<div class="archive-row">
<span>Archive</span>
<span>12 done</span>
</div>
</div>
</aside>
</div>
</div>
</section>
<section class="scene" data-scene="todos">
<div class="window">
<header class="hero compact">
<div>
<p class="eyebrow">Tasks In Context</p>
<h1>Keep todos where the tabs already live.</h1>
</div>
<div class="drawer-tabs">
<span class="drawer-tab">Saved</span>
<span class="drawer-tab active">Todos</span>
</div>
</header>
<div class="split-layout">
<div class="left-stack">
<article class="workspace-card accent">
<div class="card-head">
<div>
<p class="label">Top icon rail</p>
<h2>One click to jump</h2>
</div>
<span class="badge">Focused navigation</span>
</div>
<div class="icon-rail left">
<span class="rail-pill active">WK</span>
<span class="rail-pill">DOC</span>
<span class="rail-pill">GH</span>
<span class="rail-pill">RD</span>
</div>
<div class="chips">
<span class="chip">Work</span>
<span class="chip">Docs</span>
<span class="chip">GitHub</span>
<span class="chip">Reading</span>
</div>
</article>
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">Quick capture</p>
<h2>From a tab to a task</h2>
</div>
<span class="badge">No context switch</span>
</div>
<div class="chips">
<span class="chip">Write changelog</span>
<span class="chip">Trim duplicates</span>
<span class="chip">Reply in Linux.do</span>
</div>
</article>
</div>
<aside class="drawer">
<div class="drawer-header">
<div>
<p class="label">Todos</p>
<h2>4 active tasks</h2>
</div>
<span class="mini-action">New</span>
</div>
<div class="todo-card">
<div class="todo-row">
<span class="todo-check checked"></span>
<div>
<strong>Refresh README screenshots</strong>
<p>Feature showcase · in progress</p>
</div>
</div>
<div class="todo-row">
<span class="todo-check"></span>
<div>
<strong>Highlight core selling points</strong>
<p>Domain groups, custom groups, top rail jump</p>
</div>
</div>
<div class="todo-row">
<span class="todo-check"></span>
<div>
<strong>Add upstream acknowledgement</strong>
<p>Link to Zara's repo and Linux.do community</p>
</div>
</div>
</div>
<div class="archive">
<div class="archive-row">
<span>Archive</span>
<span>7 completed</span>
</div>
</div>
</aside>
</div>
</div>
</section>
<section class="scene" data-scene="themes">
<div class="window">
<div class="banner warning">
<span class="banner-dot"></span>
You have 2 Tab Harbor tabs open. Close extras and keep just this workspace.
<span class="banner-action">Close extras</span>
</div>
<header class="hero compact">
<div>
<p class="eyebrow">Theme Controls</p>
<h1>Switch themes, tune transparency, keep the browser tidy.</h1>
</div>
<div class="theme-trigger">Theme ▾</div>
</header>
<div class="theme-layout">
<article class="workspace-card">
<div class="card-head">
<div>
<p class="label">Cleanup actions</p>
<h2>Fast duplicate control</h2>
</div>
<span class="badge">One click</span>
</div>
<div class="chips">
<span class="chip">Close duplicate tabs</span>
<span class="chip">Close all in group</span>
<span class="chip">Keep one copy</span>
</div>
</article>
<aside class="theme-panel">
<div class="theme-header">
<div>
<p class="label">Appearance</p>
<h2>Paper themes</h2>
</div>
<span class="mini-action">Upload background</span>
</div>
<div class="swatches">
<span class="swatch paper active"></span>
<span class="swatch sage"></span>
<span class="swatch mist"></span>
<span class="swatch blush"></span>
</div>
<div class="range-block">
<div class="range-row">
<span>Surface transparency</span>
<strong>14%</strong>
</div>
<div class="range-track">
<span class="range-thumb"></span>
</div>
</div>
<div class="theme-preview">
<div class="preview-card"></div>
<div class="preview-card warm"></div>
</div>
</aside>
</div>
</div>
</section>
</main>
<script>
const scene = new URLSearchParams(window.location.search).get('scene') || 'overview';
document.documentElement.dataset.scene = scene;
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+399
View File
@@ -0,0 +1,399 @@
# Tab Harbor 设计原则与试错复盘
> 更新时间:2026-04-17
> 适用范围:`extension/` 新标签页界面、后续 UI 优化、结构重构、agent 协作
本文档把这次迭代中已经验证有效的设计原则、实现约束,以及踩过的坑整理成长期可复用的参考。目标不是记录一次性的对话结论,而是给后续设计、开发、重构和调试提供稳定依据。
---
## 1. 产品与视觉设计原则
### 1.1 核心定位
Tab Harbor 不是:
- 一个炫技型新标签页
- 一个强调“效率打卡”的 productivity dashboard
- 一个 wallpaper-first 的装饰页
Tab Harbor 更接近:
- 一个安静的浏览器工作台
- 一个可快速恢复上下文的 reading desk
- 一个把“打开中的标签、已保存会话、轻量待办”收进同一空间的本地工具
### 1.2 设计气质
产品气质固定为:
- calm
- literary
- composed
后续任何新增界面都应该满足以下判断:
- 是否在降低浏览器混乱感,而不是制造新噪音
- 是否更像工作台,而不是更像展示页
- 是否帮助用户快速回到手头工作,而不是先被界面本身吸引
### 1.3 视觉方向
推荐的视觉比喻:
- quiet reading desk
- paper-like workspace
- editorial but functional
应坚持的视觉特征:
- 轻微带色的中性色,而不是冷白或纯灰
- 可读性优先的排版,而不是夸张营销式层级
- 节制的强调色,而不是四处抢眼的高对比点缀
- 结构清晰、留白克制,而不是靠大量卡片和边框分组
明确避免:
- 通用 SaaS 后台感
- 发光暗黑开发者首页感
- 紫蓝霓虹、赛博发光、玻璃拟态堆叠
- 大量 hero metrics、宣传式数据块
- 把主题面板做成内容卡片墙
### 1.4 信息密度原则
Tab Harbor 的密度不是越低越好,也不是越高越好,而是“适合快速扫读”。
有效做法:
- 内容区密度可以偏高,但层级必须清楚
- 辅助控制应收敛,不与主内容争抢注意力
- 同一层级的元素要保持视觉家族感
- 非核心文案尽量删除,用结构和版式替代解释
应避免:
- 为了“简洁”而隐藏关键状态
- 为了“丰富”而重复说明用户已经知道的信息
- 所有区块都长得像一套模板复制出来的卡片
### 1.5 交互原则
交互应满足:
- immediate:操作后要立刻给反馈
- local:像浏览器里的工具,而不是远端产品
- quiet:反馈明确但不过度表演
推荐:
- hover/focus 反馈短、轻、清楚
- drawer / theme panel 的开关和关闭动作足够直接
- reduced motion 用户不依赖动画也能理解状态变化
- 键盘焦点必须可见
避免:
- 只靠 hover 暴露关键动作
- 需要解释性文案才能学会使用的控制
- 为了“精致”牺牲点击面积和可达性
### 1.6 主题系统原则
主题系统不是换几颗按钮颜色,而是整页环境变化。
必须同步响应主题的内容:
- 页面背景和中性色
- panel surface / card surface
- hover / tooltip / underline / badge / archive action
- slider rail / thumb
- drawer trigger / theme panel / active state
主题设计原则:
- 默认以 light / softly tinted 主题为主
- dark theme 可以有,但必须低眩光、纸面感、非电影感
- accent 是稀缺资源,不应滥用
- 自定义背景是 atmosphere,不是信息主体
---
## 2. 前端实现约束
### 2.1 当前项目的现实约束
这是一个无构建步骤的 Manifest V3 扩展页面,当前前端体系是:
- 纯 HTML
- 纯 CSS
- 多个按顺序加载的普通脚本文件
- 没有 bundler
- 没有 ESM import/export
这意味着:
- 所有脚本共享同一个页面运行时环境
- 加载顺序本身就是依赖管理的一部分
- 任何顶层命名冲突都可能在脚本加载阶段直接把页面打断
### 2.2 模块化原则
在这个项目里,模块化应优先采用“按职责拆文件 + 明确加载顺序 + 显式全局挂载”的策略,而不是半套模块化。
推荐模式:
- `ui-helpers.js`:时间文案、域名/标题清洗、toast/confetti、共享 icon
- `theme-controls.js`:主题状态、快捷链接、主题面板
- `drawer-manager.js`saved/todo/drawer 状态与渲染
- `dashboard-runtime.js`dashboard 主运行时
- `app.js`:只做 orchestrator
好的拆分标准:
- 写入边界清楚
- DOM 责任清楚
- 状态拥有者清楚
- 文件名能直接说明职责
不好的拆分标准:
- 只因为文件大就随意切
- 一个模块同时拥有状态、渲染、事件绑定、工具函数且无边界
- 让多个文件都“顺手”声明相同语义的顶层变量
### 2.3 Orchestrator 原则
入口文件 `app.js` 应尽量保持为:
- 读取运行时入口
- 做可失败的启动检查
- 调用初始化
不应再让 `app.js` 承担:
- 大量业务逻辑
- DOM 拼装
- 状态定义
- 子模块具体实现
### 2.4 无构建脚本的全局命名原则
在普通 `<script>` 环境中,顶层 `const` / `let` / `function` 不是“彼此天然隔离”的。
经验规则:
-`globalThis` 解构出来的依赖,尽量使用模块级前缀别名
- 不要在多个文件里重复声明同名顶层绑定
- 公共入口统一挂在单一命名空间对象上,例如:
- `globalThis.TabHarborDashboardRuntime`
推荐命名方式:
- `themeGetIconSources`
- `drawerNormalizeTodos`
- `runtimeGetGroupIcon`
- `appMountDashboardRuntime`
这样做虽然略长,但在无构建环境里比“简短优雅”更安全。
---
## 3. 这次踩过的坑
### 3.1 只跑静态测试,不足以证明页面真的能启动
本次最重要的教训之一:
`node --test extension/*.test.js` 全绿,并不等于浏览器页面真的能跑起来。
原因:
- 现有测试主要检查静态结构、关键字符串和 CSS 规则
- 它们无法发现浏览器加载阶段的脚本异常
- 它们也无法覆盖真实扩展权限环境下的初始化链
正确做法:
- 静态回归测试仍然要跑
- 但涉及脚本拆分、入口变更、运行时初始化时,必须补真实浏览器验证
### 3.2 无构建多脚本环境里,顶层重名会直接导致整页失效
本次真实出现过的阻塞性问题:
- 拆分后多个文件重复声明同名顶层 `const`
- 浏览器直接报 `Identifier has already been declared`
- 页面只剩静态骨架,看起来像“局部没渲染”,其实是启动链被打断
这是高优先级风险,因为:
- 错误发生在加载阶段
- 用户界面会呈现“半活着”的状态
- 如果只看截图,很容易误判成数据为空或权限问题
正确做法:
- 所有模块对外依赖统一加前缀别名
- 所有运行时入口统一通过单一全局对象暴露
- 脚本拆分后优先检查浏览器 console,而不是先改业务逻辑
### 3.3 入口 orchestrator 也可能踩到同样的命名冲突
本次另一个真实问题:
- `app.js` 已经变成 orchestrator 后
- 仍然把 `mountDashboardRuntime` 以原名解构到顶层
- 这和上游全局绑定再次冲突,导致入口脚本自身加载失败
经验:
- 即使 orchestrator 非常薄,也仍然属于共享全局环境的一部分
- “文件很小”不代表“不会污染全局”
正确做法:
- 入口同样使用别名,例如 `appMountDashboardRuntime`
### 3.4 扩展环境里的“看起来没加载”不一定是 Chrome 缓存问题
当页面只显示静态骨架时,常见误判是:
- Chrome 没 reload 干净
- tabs 数据为空
- 扩展权限丢了
这次的实际经验是:
- 如果 greeting/date/open tabs 全部不渲染
- 但静态 HTML 和 CSS 骨架还在
- 优先怀疑脚本初始化阶段异常
排查优先级应是:
1. 浏览器 console 是否有加载阶段异常
2. 入口脚本是否跑到初始化
3. 运行时对象是否成功挂到 `globalThis`
4. 再看 `chrome.tabs.query()`、storage、渲染分支
### 3.5 真实浏览器调试比猜测更省时间
这次最有效的调试动作不是继续读代码,而是:
- 启动独立浏览器实例
- 连接 DevTools 协议
- 直接抓页面里的 `Runtime.exceptionThrown`
一旦拿到真实异常,定位速度比截图推断快得多。
后续建议保留这个排查顺序:
- 先跑静态测试
- 再跑真实浏览器页级检查
- 有异常就先修启动链
- 启动链无异常,再进业务逻辑
### 3.6 紧凑图标拖拽不要直接把原节点提成 fixed
本次快捷图标拖拽里,最容易反复走弯路的点是:
- 直接把原卡片节点切到 `position: fixed`
- 再让这个原节点同时承担“鼠标下的拖拽视觉”和“列表里的排序占位”
这样做的副作用是:
- 鼠标下的卡片容易在某些时刻消失、抖动或偏离指针
- 原位置容易瞬间空掉,用户会以为元素丢了
- DOM 既在跟手移动,又在参与排序预览,跨行时尤其容易打架
正确做法:
- 使用 `ghost + slot` 双层模型
- `ghost` 只负责跟手显示,不参与列表布局
- `slot` 只负责列表中的排序预览,不承担可见拖拽本体
- 拖拽结束后,再把最终顺序持久化并整体重渲染
触发信号:
- 鼠标按下拖动时,元素“消失”或原位置瞬间空掉
- 跨行拖动时,同一个元素既像在跟手,又像在参与列表 reflow
适用范围:
- `extension/` 中类似 quick shortcuts 这种高密度、小卡片、支持跨行拖拽的区域
### 3.7 跨行拖拽命中不要依赖当前正在抖动的布局
快捷图标拖拽里另一个核心教训是:
- 如果预览命中完全依赖“当前这一帧重排后的 DOM 位置”
- 那么跨行移动时,行分布、目标位置、FLIP 量测都会彼此影响
- 结果就是:明明已经拖过去了,但不让位;或者刚让位又跳回来
本次试错证明了三件事:
1. 仅靠鼠标点 `clientX/clientY` 判定不够稳
2. 仅靠当前 reflow 后的 `flex-wrap` 位置判定,跨行尤其容易抖
3. 让位判定最好基于稳定槽位,而不是每一帧都“重新猜这次应该算哪一行哪一列”
正确做法:
- 在拖拽开始时记录稳定的槽位目标
- 预览时用被拖卡片的几何中心去命中槽位
- 命中结果只驱动 `slot` 移动
- 不要让“当前正在动画中的兄弟节点位置”反过来影响判定
如果后续仍需支持复杂跨行拖拽,优先级如下:
1. 显式 grid 轨道
2. 稳定 slot 目标
3. 再考虑更细的命中阈值
不推荐继续追加的错误方向:
-`flex-wrap` 上不断追加更多经验阈值
- 同时混用“当前 DOM 行分组”和“静态推测列数”两套算法
验证方式:
- 第二个拖到第一个前面
- 第二个拖到最后一个后面
- 从上一行拖到下一行,再拖回原行
- 观察是否存在“不让位”“无关兄弟闪回”“回到原位仍抖动”
### 3.8 重排动画应只作用于真正受影响的兄弟节点
本次另一个很容易被忽略的坑:
- 即使排序逻辑正确
- 如果每次 `pointermove` 都对整组兄弟重新做一次 FLIP
- 那些其实没换位的节点也会被反复清空 transform、重新量测、重新启动动画
用户看到的现象通常是:
- 从第一个往后拖时,后面无关图标反复闪回
- 从第二个拖到第三个后面时,第一个也跟着抖
正确做法:
- 新一轮预览前,先判断目标位置是否真的变化
- 只对索引确实变化的那部分兄弟节点执行 settle + FLIP
- 不要对整组节点统一扫一遍动画
验证方式:
- 拖动某个图标时,只有相邻受影响兄弟移动
- 未跨过命中线时,其余图标保持静止
### 3.9 紧凑工具触发的浮层应优先就近锚定
本次 `Add link` 弹窗也踩过一个体验坑:
- 固定把编辑弹窗放在右下角
- 对用户来说,触发点和反馈点距离过远
- 容易让人误以为没有打开,或需要重新找界面
正确做法:
- 打开弹窗时优先读取触发元素的 `getBoundingClientRect()`
- 浮层默认锚定在触发按钮附近
- 仅在空间不足时,再做视口内回退与防溢出处理
适用范围:
- 快捷图标编辑器
- 后续类似的轻量 panel / editor / popover
验证方式:
-`Add link` 打开时,弹窗应贴近按钮
- 从卡片上的编辑按钮打开时,弹窗应贴近对应卡片
- 视口边缘场景下不应溢出到屏幕外
---
## 4. 后续开发建议
### 4.1 设计迭代时
新增 UI 或交互前,先问 4 个问题:
- 它是否更接近 quiet desk,而不是 dashboard showcase
- 它是否帮助用户更快恢复上下文
- 它是否引入了新的噪音或新的学习负担
- 它是否尊重现有的 calm / literary / composed 方向
### 4.2 结构重构时
重构前先问 4 个问题:
- 这个职责边界是否天然清楚
- 这个文件是否拥有自己的状态
- 这个模块是否会引入新的顶层命名冲突
- 这次拆分后是否还有浏览器级验证,而不是只跑静态测试
### 4.3 调试时
遇到“页面骨架在,但数据没出来”的情况,优先走这个清单:
1. 打开浏览器 console 看是否有脚本加载异常
2. 看 orchestrator 是否成功调用 runtime
3. 看 runtime 是否成功挂载
4. 再看权限、数据和渲染逻辑
---
## 5. 可长期坚持的工程规则
建议把下面这些规则视作 Tab Harbor 前端的长期约束:
1. 设计优先服务“回到工作”,不是服务“界面存在感”。
2. 主题系统必须驱动整页环境,而不是只改局部控件。
3. 关键交互不能只在 hover 下可见,键盘焦点必须可达、可见。
4. 无构建多脚本环境中,顶层命名必须显式去冲突。
5. 结构重构后,必须至少做一次真实浏览器级验证。
6. `app.js` 保持 orchestrator 角色,不再回退成巨型逻辑文件。
如果后续实践证明这些规则仍然有效,建议继续把它们同步进更高层的协作文档,而不是只留在这份复盘里。
@@ -0,0 +1,54 @@
# Quiet Desk UI Refresh Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refresh the new tab experience so it scans faster, feels more like a quiet desk, and keeps the existing calm/productive identity.
**Architecture:** Adjust the extension's existing HTML/CSS-first layout instead of rebuilding the page. Keep the current data flows in `app.js`, add a small amount of supporting UI copy/state wiring, and tighten the visual system so the page becomes more legible without losing its editorial warmth.
**Tech Stack:** Plain HTML, CSS, vanilla JavaScript, Node `node:test`
---
### Task 1: Lock shared design context
**Files:**
- Create: `.github/copilot-instructions.md`
- Modify: `.impeccable.md`
- [ ] **Step 1: Mirror the approved design context into a shared agent instruction file**
- [ ] **Step 2: Keep the project-level design context wording aligned with the approved calm / peaceful / literary direction**
### Task 2: Rework the header, quick access area, and drawer triggers
**Files:**
- Modify: `extension/index.html`
- Modify: `extension/app.js`
- Modify: `extension/style.css`
- Test: `extension/ui-regression.test.js`
- [ ] **Step 1: Add clearer desk-oriented header support content and quick-link framing**
- [ ] **Step 2: Add visible labels/meta for the saved/todo drawer triggers**
- [ ] **Step 3: Wire header and trigger helper copy/counts from existing app state**
- [ ] **Step 4: Update regression coverage for the new structural hooks**
### Task 3: Tighten layout, typography, and interaction clarity
**Files:**
- Modify: `extension/style.css`
- Modify: `extension/app.js`
- Test: `extension/ui-regression.test.js`
- [ ] **Step 1: Replace the current font pairing with a less templated editorial + utility pairing**
- [ ] **Step 2: Reduce foggy surfaces / blur and rebalance page spacing for faster scanning**
- [ ] **Step 3: Strengthen group-nav, section headers, mission cards, and drawer readability**
- [ ] **Step 4: Clarify key copy and risky action labels where they currently feel too implicit**
### Task 4: Verify the refresh
**Files:**
- Test: `extension/*.test.js`
- [ ] **Step 1: Run the extension test suite with Node's test runner**
- [ ] **Step 2: Review failures and adjust HTML/CSS/JS or tests until green**
- [ ] **Step 3: Summarize the user-facing improvements and any residual risk**
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const {
mountDashboardRuntime: appMountDashboardRuntime,
} = globalThis.TabHarborDashboardRuntime || {};
const {
ready: appI18nReady,
} = globalThis.TabHarborI18n || {};
const {
TabHarborConfigReady: appConfigReady,
} = globalThis;
async function initializeApp() {
if (!appMountDashboardRuntime) {
throw new Error('Tab Harbor dashboard runtime is unavailable');
}
if (appConfigReady && typeof appConfigReady.then === 'function') {
await appConfigReady;
}
if (appI18nReady && typeof appI18nReady.then === 'function') {
await appI18nReady;
}
await appMountDashboardRuntime();
}
initializeApp();
+121
View File
@@ -0,0 +1,121 @@
'use strict';
(function attachBackgroundImage(globalScope) {
const MAX_BACKGROUND_EDGE = 1920;
const MAX_BACKGROUND_BYTES = 900 * 1024;
const BACKGROUND_IMAGE_TYPE = 'image/webp';
const BACKGROUND_IMAGE_QUALITIES = [0.82, 0.74, 0.66, 0.58, 0.5];
const BACKGROUND_IMAGE_EDGE_STEPS = [1, 0.88, 0.76, 0.64];
function constrainImageDimensions({ width, height, maxEdge = MAX_BACKGROUND_EDGE }) {
const safeWidth = Math.max(1, Math.round(Number(width) || 0));
const safeHeight = Math.max(1, Math.round(Number(height) || 0));
const safeMaxEdge = Math.max(1, Math.round(Number(maxEdge) || MAX_BACKGROUND_EDGE));
const largestEdge = Math.max(safeWidth, safeHeight);
if (largestEdge <= safeMaxEdge) {
return { width: safeWidth, height: safeHeight };
}
const scale = safeMaxEdge / largestEdge;
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
function estimateDataUrlBytes(dataUrl) {
const value = String(dataUrl || '');
const marker = 'base64,';
const markerIndex = value.indexOf(marker);
if (markerIndex === -1) return 0;
const payload = value.slice(markerIndex + marker.length);
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Could not read image'));
};
image.src = objectUrl;
});
}
async function compressImageFileForStorage(file, options = {}) {
if (!file || !String(file.type || '').startsWith('image/')) {
throw new Error('Please choose an image file');
}
const maxBytes = Math.max(32 * 1024, Number(options.maxBytes) || MAX_BACKGROUND_BYTES);
const maxEdge = Math.max(320, Number(options.maxEdge) || MAX_BACKGROUND_EDGE);
const image = await loadImageFromFile(file);
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('Could not prepare image compression');
}
let bestDataUrl = '';
let bestBytes = Number.POSITIVE_INFINITY;
for (const edgeStep of BACKGROUND_IMAGE_EDGE_STEPS) {
const dimensions = constrainImageDimensions({
width: image.naturalWidth,
height: image.naturalHeight,
maxEdge: Math.max(320, Math.round(maxEdge * edgeStep)),
});
canvas.width = dimensions.width;
canvas.height = dimensions.height;
context.clearRect(0, 0, dimensions.width, dimensions.height);
context.fillStyle = '#f6f0e6';
context.fillRect(0, 0, dimensions.width, dimensions.height);
context.drawImage(image, 0, 0, dimensions.width, dimensions.height);
for (const quality of BACKGROUND_IMAGE_QUALITIES) {
const dataUrl = canvas.toDataURL(BACKGROUND_IMAGE_TYPE, quality);
const bytes = estimateDataUrlBytes(dataUrl);
if (bytes < bestBytes) {
bestBytes = bytes;
bestDataUrl = dataUrl;
}
if (bytes <= maxBytes) {
return dataUrl;
}
}
}
if (bestDataUrl) {
throw new Error(`Image is too large even after compression (${Math.ceil(bestBytes / 1024)} KB)`);
}
throw new Error('Could not compress image');
}
const api = {
BACKGROUND_IMAGE_TYPE,
MAX_BACKGROUND_BYTES,
MAX_BACKGROUND_EDGE,
compressImageFileForStorage,
constrainImageDimensions,
estimateDataUrlBytes,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutBackgroundImage = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+41
View File
@@ -0,0 +1,41 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
constrainImageDimensions,
estimateDataUrlBytes,
MAX_BACKGROUND_EDGE,
} = require('./background-image.js');
test('constrainImageDimensions keeps smaller images unchanged', () => {
assert.deepEqual(
constrainImageDimensions({ width: 1200, height: 800, maxEdge: MAX_BACKGROUND_EDGE }),
{ width: 1200, height: 800 }
);
});
test('constrainImageDimensions scales landscape images down to the max edge', () => {
assert.deepEqual(
constrainImageDimensions({ width: 4000, height: 2000, maxEdge: 1920 }),
{ width: 1920, height: 960 }
);
});
test('constrainImageDimensions scales portrait images down to the max edge', () => {
assert.deepEqual(
constrainImageDimensions({ width: 1800, height: 3600, maxEdge: 1600 }),
{ width: 800, height: 1600 }
);
});
test('estimateDataUrlBytes converts base64 payload length to bytes', () => {
const payload = Buffer.from('tab-harbor-background').toString('base64');
const dataUrl = `data:image/jpeg;base64,${payload}`;
assert.equal(
estimateDataUrlBytes(dataUrl),
Buffer.byteLength('tab-harbor-background')
);
});
+160
View File
@@ -0,0 +1,160 @@
/**
* background.js — Service Worker
*
* Keeps Tab Harbor pages in sync when tabs change.
* The toolbar badge is intentionally kept empty.
*/
const TAB_HARBOR_BG_DEBUG = false;
if (TAB_HARBOR_BG_DEBUG) console.log('[tab-harbor bg] Service worker loaded, registering event listeners...');
// ─── Auto-close duplicate new tabs ───────────────────────────────────────────
function getNewTabUrls() {
return new Set([
chrome.runtime.getURL('index.html'),
chrome.runtime.getURL('extension/index.html'),
]);
}
function isNewTabBlank(tab, newTabUrls) {
const knownNewTabUrls = newTabUrls instanceof Set
? newTabUrls
: new Set(Array.isArray(newTabUrls) ? newTabUrls : [newTabUrls]);
const url = tab?.url || '';
const pendingUrl = tab?.pendingUrl || '';
if (pendingUrl && !knownNewTabUrls.has(pendingUrl) && pendingUrl !== 'chrome://newtab/') {
return false;
}
return (
url === 'chrome://newtab/' ||
knownNewTabUrls.has(url) ||
pendingUrl === 'chrome://newtab/' ||
knownNewTabUrls.has(pendingUrl) ||
url === '' ||
(tab.status === 'loading' && !url)
);
}
async function closeDuplicateNewTabs() {
try {
const stored = await chrome.storage.local.get('themePreferences');
const prefs = stored.themePreferences || {};
if (prefs.closeDuplicateNewTabsEnabled !== true) return;
const newTabUrls = getNewTabUrls();
const allTabs = await chrome.tabs.query({});
const blankTabs = allTabs.filter(tab => isNewTabBlank(tab, newTabUrls));
if (blankTabs.length <= 1) return;
// Keep the active tab; if none is active, keep the one with the largest id (newest)
const activeTab = blankTabs.find(tab => tab.active);
const toKeep = activeTab || blankTabs.reduce((a, b) => (a.id > b.id ? a : b));
const toClose = blankTabs.filter(tab => tab.id !== toKeep.id).map(tab => tab.id);
if (toClose.length > 0) await chrome.tabs.remove(toClose);
} catch (err) {
console.warn('[tab-harbor bg] closeDuplicateNewTabs error:', err.message);
}
}
async function updateBadge() {
try {
await chrome.action.setBadgeText({ text: '' });
} catch {
chrome.action.setBadgeText({ text: '' });
}
}
function getTabHarborDashboardUrls() {
const extensionId = chrome.runtime.id;
return new Set([
`chrome-extension://${extensionId}/index.html`,
`chrome-extension://${extensionId}/extension/index.html`,
]);
}
// ─── Event listeners ──────────────────────────────────────────────────────────
// Notify Tab Harbor pages when tabs change so they can refresh
async function notifyTabHarborPages(eventMeta = {}) {
try {
// Find all Tab Harbor dashboard pages
const dashboardUrls = getTabHarborDashboardUrls();
// Query all tabs and filter manually for more reliable matching
const allTabs = await chrome.tabs.query({});
const dashboardTabs = allTabs.filter(tab => {
if (!tab.url) return false;
// Tab Harbor can appear as either:
// 1. chrome-extension://EXTENSION_ID/index.html (direct access)
// 2. chrome-extension://EXTENSION_ID/extension/index.html (root manifest entry)
// 3. chrome://newtab/ with title "Tab Harbor" (new tab override)
return (
dashboardUrls.has(tab.url) ||
(tab.url === 'chrome://newtab/' && tab.title === 'Tab Harbor')
);
});
if (dashboardTabs.length === 0) return;
for (const tab of dashboardTabs) {
try {
await chrome.tabs.sendMessage(tab.id, {
action: 'tabs-changed',
source: eventMeta.source || 'tabs.changed',
triggerTabId: eventMeta.triggerTabId ?? null,
});
} catch (err) {
// Tab might be closed or not ready, ignore
console.warn(`[tab-harbor bg] Failed to notify tab ${tab.id}:`, err.message);
}
}
} catch (err) {
console.warn('[tab-harbor bg] Error in notifyTabHarborPages:', err);
}
}
// Update badge when the extension is first installed
chrome.runtime.onInstalled.addListener(() => {
updateBadge();
});
// Update badge when Chrome starts up
chrome.runtime.onStartup.addListener(() => {
updateBadge();
});
// Update badge and notify Tab Harbor pages whenever a tab is opened
chrome.tabs.onCreated.addListener((tab) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onCreated', triggerTabId: tab?.id });
closeDuplicateNewTabs();
});
// Update badge and notify Tab Harbor pages whenever a tab is closed
chrome.tabs.onRemoved.addListener((tabId) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onRemoved', triggerTabId: tabId });
});
// Update badge and notify Tab Harbor pages when a tab's URL changes (e.g. navigating to/from chrome://)
chrome.tabs.onUpdated.addListener((tabId) => {
updateBadge();
notifyTabHarborPages({ source: 'tabs.onUpdated', triggerTabId: tabId });
});
// ─── Initial run ─────────────────────────────────────────────────────────────
// Run once immediately when the service worker first loads
updateBadge();
// ─── Test exports ────────────────────────────────────────────────────────────
globalThis.TabHarborBackground = {
getNewTabUrls,
isNewTabBlank,
closeDuplicateNewTabs,
};
+197
View File
@@ -0,0 +1,197 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// Track calls to chrome.tabs.remove
let removedTabIds = [];
let storageData = {};
globalThis.chrome = {
runtime: {
id: 'test-extension-id',
getURL: path => `chrome-extension://test-extension-id/${path}`,
},
tabs: {
query: async () => [],
remove: async ids => { removedTabIds = removedTabIds.concat(ids); },
},
storage: {
local: {
get: async key => ({ [key]: storageData[key] || {} }),
},
},
action: {
setBadgeText: async () => {},
},
runtime: {
id: 'test-extension-id',
getURL: path => `chrome-extension://test-extension-id/${path}`,
onInstalled: { addListener: () => {} },
onStartup: { addListener: () => {} },
},
tabs: {
query: async () => [],
remove: async ids => { removedTabIds = removedTabIds.concat(ids); },
onCreated: { addListener: () => {} },
onRemoved: { addListener: () => {} },
onUpdated: { addListener: () => {} },
},
action: {
setBadgeText: async () => {},
},
};
require('./background.js');
const { isNewTabBlank, closeDuplicateNewTabs } = globalThis.TabHarborBackground;
// ─── isNewTabBlank ────────────────────────────────────────────────────────
const EXT_URL = 'chrome-extension://test-extension-id/index.html';
const ROOT_MANIFEST_EXT_URL = 'chrome-extension://test-extension-id/extension/index.html';
test('isNewTabBlank matches chrome://newtab/', () => {
assert.equal(isNewTabBlank({ url: 'chrome://newtab/' }, EXT_URL), true);
});
test('isNewTabBlank matches extension index.html', () => {
assert.equal(isNewTabBlank({ url: EXT_URL }, EXT_URL), true);
});
test('isNewTabBlank matches extension/index.html from root manifest entry', () => {
assert.equal(isNewTabBlank({ url: ROOT_MANIFEST_EXT_URL }, [EXT_URL, ROOT_MANIFEST_EXT_URL]), true);
});
test('isNewTabBlank matches empty url', () => {
assert.equal(isNewTabBlank({ url: '' }, EXT_URL), true);
});
test('isNewTabBlank matches loading tab with no url', () => {
assert.equal(isNewTabBlank({ url: undefined, status: 'loading' }, EXT_URL), true);
});
test('isNewTabBlank does not match loading restored tab with normal pendingUrl', () => {
assert.equal(isNewTabBlank({ url: undefined, pendingUrl: 'https://example.com', status: 'loading' }, EXT_URL), false);
});
test('isNewTabBlank matches loading new tab with new-tab pendingUrl', () => {
assert.equal(isNewTabBlank({ url: undefined, pendingUrl: ROOT_MANIFEST_EXT_URL, status: 'loading' }, [EXT_URL, ROOT_MANIFEST_EXT_URL]), true);
});
test('isNewTabBlank does not match normal url', () => {
assert.equal(isNewTabBlank({ url: 'https://example.com' }, EXT_URL), false);
});
test('isNewTabBlank does not match loading tab with url', () => {
assert.equal(isNewTabBlank({ url: 'https://example.com', status: 'loading' }, EXT_URL), false);
});
// ─── closeDuplicateNewTabs ────────────────────────────────────────────────
test('closeDuplicateNewTabs does nothing when feature is disabled', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: false } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
{ id: 2, url: 'chrome://newtab/', active: false },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, []);
});
test('closeDuplicateNewTabs does nothing when preference is missing', async () => {
storageData = { themePreferences: {} };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
{ id: 2, url: 'chrome://newtab/', active: false },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, []);
});
test('closeDuplicateNewTabs closes duplicate blank tabs when enabled', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: false },
{ id: 2, url: 'chrome://newtab/', active: true },
{ id: 3, url: 'https://example.com', active: false },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, [1]);
});
test('closeDuplicateNewTabs keeps active tab', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 10, url: EXT_URL, active: false },
{ id: 20, url: 'chrome://newtab/', active: true },
{ id: 30, url: '', active: false },
];
await closeDuplicateNewTabs();
// Should keep id=20 (active), close 10 and 30
assert.deepEqual(removedTabIds, [10, 30]);
});
test('closeDuplicateNewTabs keeps newest tab when none is active', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 5, url: 'chrome://newtab/', active: false },
{ id: 15, url: 'chrome://newtab/', active: false },
];
await closeDuplicateNewTabs();
// Should keep id=15 (largest), close id=5
assert.deepEqual(removedTabIds, [5]);
});
test('closeDuplicateNewTabs does nothing with a single blank tab', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
{ id: 2, url: 'https://example.com', active: false },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, []);
});
test('closeDuplicateNewTabs handles mixed blank tab types', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: false },
{ id: 2, url: EXT_URL, active: true },
{ id: 3, url: '', active: false },
{ id: 4, url: undefined, status: 'loading', active: false },
];
await closeDuplicateNewTabs();
// Keep id=2 (active), close 1, 3, 4
assert.deepEqual(removedTabIds, [1, 3, 4]);
});
test('closeDuplicateNewTabs treats root manifest new tab pages as blank tabs', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: ROOT_MANIFEST_EXT_URL, active: false },
{ id: 2, url: 'chrome://newtab/', active: true },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, [1]);
});
test('closeDuplicateNewTabs preserves restored tabs while their final URLs are pending', async () => {
storageData = { themePreferences: { closeDuplicateNewTabsEnabled: true } };
removedTabIds = [];
globalThis.chrome.tabs.query = async () => [
{ id: 1, url: 'chrome://newtab/', active: true },
{ id: 2, url: undefined, pendingUrl: 'https://example.com/a', status: 'loading', active: false },
{ id: 3, url: undefined, pendingUrl: 'https://example.com/b', status: 'loading', active: false },
];
await closeDuplicateNewTabs();
assert.deepEqual(removedTabIds, []);
});
+197
View File
@@ -0,0 +1,197 @@
'use strict';
(function attachChromeTabGroupImport(globalScope) {
const {
addSessionGroup,
normalizeSessionGroups,
} = globalScope.TabOutSessionGroups || {};
const EMPTY_META = {
entries: [],
};
function normalizeChromeImportedGroupMeta(input) {
const entries = Array.isArray(input?.entries)
? input.entries
.filter(entry => entry && entry.sessionGroupId)
.map(entry => ({
sessionGroupId: String(entry.sessionGroupId),
chromeGroupId: entry.chromeGroupId == null ? null : Number(entry.chromeGroupId),
windowId: entry.windowId == null ? 0 : Number(entry.windowId),
title: String(entry.title || 'Group').trim() || 'Group',
color: String(entry.color || 'grey'),
}))
: [];
return { entries };
}
function buildMetaSignature(entry) {
return [
String(entry.windowId ?? 0),
String(entry.title || 'Group').trim() || 'Group',
String(entry.color || 'grey'),
].join('::');
}
function buildUniqueGroupName(baseName, groups, excludeGroupId = '') {
const fallbackName = String(baseName || 'Group').trim() || 'Group';
const lowerFallback = fallbackName.toLowerCase();
const takenNames = new Set(
(groups || [])
.filter(group => group && group.id !== excludeGroupId)
.map(group => String(group.name || '').trim().toLowerCase())
.filter(Boolean)
);
if (!takenNames.has(lowerFallback)) return fallbackName;
let suffix = 2;
while (takenNames.has(`${lowerFallback} ${suffix}`)) suffix++;
return `${fallbackName} ${suffix}`;
}
function findReusableSessionGroupId(groups, assignments, nativeGroup) {
const nativeTabIds = Array.isArray(nativeGroup?.tabIds)
? nativeGroup.tabIds.map(tabId => String(tabId)).filter(Boolean).sort()
: [];
if (!nativeTabIds.length) return '';
const nativeTitle = String(nativeGroup?.title || '').trim().toLowerCase();
for (const group of groups || []) {
const groupId = String(group?.id || '');
if (!groupId) continue;
const assignedTabIds = Object.entries(assignments || {})
.filter(([, assignedGroupId]) => String(assignedGroupId) === groupId)
.map(([tabId]) => String(tabId))
.sort();
if (!assignedTabIds.length || assignedTabIds.length !== nativeTabIds.length) continue;
if (!assignedTabIds.every((tabId, index) => tabId === nativeTabIds[index])) continue;
const groupName = String(group?.name || '').trim().toLowerCase();
if (!nativeTitle || !groupName || nativeTitle === groupName) {
return groupId;
}
}
return '';
}
function reconcileChromeTabGroupImports({
currentState,
importedMeta,
nativeGroups,
}) {
if (typeof normalizeSessionGroups !== 'function' || typeof addSessionGroup !== 'function') {
throw new Error('Session group helpers are unavailable');
}
const normalizedState = normalizeSessionGroups(currentState);
const normalizedMeta = normalizeChromeImportedGroupMeta(importedMeta);
const managedIds = new Set(normalizedMeta.entries.map(entry => entry.sessionGroupId));
const sessionGroupIds = new Set(normalizedState.groups.map(group => group.id));
let groups = normalizedState.groups.filter(group => !managedIds.has(group.id) || sessionGroupIds.has(group.id));
let assignments = {};
for (const [tabId, groupId] of Object.entries(normalizedState.assignments)) {
if (managedIds.has(groupId)) continue;
assignments[tabId] = groupId;
}
const metaByChromeGroupId = new Map();
const metaBySignature = new Map();
for (const entry of normalizedMeta.entries) {
if (!sessionGroupIds.has(entry.sessionGroupId)) continue;
if (entry.chromeGroupId != null) metaByChromeGroupId.set(entry.chromeGroupId, entry);
metaBySignature.set(buildMetaSignature(entry), entry);
}
const nextMetaEntries = [];
const mappings = [];
for (const nativeGroup of (nativeGroups || [])) {
const chromeGroupId = nativeGroup?.chromeGroupId == null ? null : Number(nativeGroup.chromeGroupId);
const windowId = nativeGroup?.windowId == null ? 0 : Number(nativeGroup.windowId);
const title = String(nativeGroup?.title || 'Group').trim() || 'Group';
const color = String(nativeGroup?.color || 'grey');
const tabIds = Array.isArray(nativeGroup?.tabIds)
? nativeGroup.tabIds.filter(tabId => tabId != null).map(tabId => String(tabId))
: [];
if (tabIds.length === 0) continue;
const signature = buildMetaSignature({ windowId, title, color });
const existingMeta = (chromeGroupId != null && metaByChromeGroupId.get(chromeGroupId))
|| metaBySignature.get(signature)
|| null;
let sessionGroupId = existingMeta?.sessionGroupId || '';
let groupIndex = groups.findIndex(group => group.id === sessionGroupId);
if (groupIndex === -1) {
sessionGroupId = findReusableSessionGroupId(groups, normalizedState.assignments, nativeGroup);
groupIndex = groups.findIndex(group => group.id === sessionGroupId);
}
if (groupIndex === -1) {
const uniqueName = buildUniqueGroupName(title, groups);
const created = addSessionGroup({ groups, assignments }, uniqueName);
groups = created.state.groups;
assignments = created.state.assignments;
sessionGroupId = created.group.id;
groupIndex = groups.findIndex(group => group.id === sessionGroupId);
} else {
const uniqueName = buildUniqueGroupName(title, groups, sessionGroupId);
if (groups[groupIndex].name !== uniqueName) {
groups = groups.map(group => group.id === sessionGroupId
? { ...group, name: uniqueName }
: group);
}
}
for (const tabId of tabIds) {
assignments[tabId] = sessionGroupId;
}
if (chromeGroupId != null) {
mappings.push({
virtualGroupKey: `__session_group__:${sessionGroupId}`,
windowId,
chromeGroupId,
});
}
nextMetaEntries.push({
sessionGroupId,
chromeGroupId,
windowId,
title,
color,
});
}
const activeManagedIds = new Set(nextMetaEntries.map(entry => entry.sessionGroupId));
groups = groups.filter(group => !managedIds.has(group.id) || activeManagedIds.has(group.id));
assignments = Object.fromEntries(
Object.entries(assignments).filter(([, groupId]) => !managedIds.has(groupId) || activeManagedIds.has(groupId))
);
return {
state: normalizeSessionGroups({ groups, assignments }),
importedMeta: normalizeChromeImportedGroupMeta({ entries: nextMetaEntries }),
mappings,
};
}
const api = {
EMPTY_META,
normalizeChromeImportedGroupMeta,
reconcileChromeTabGroupImports,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutChromeTabGroupImport = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+182
View File
@@ -0,0 +1,182 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
require('./session-groups.js');
require('./chrome-tab-groups-import.js');
const {
reconcileChromeTabGroupImports,
} = globalThis.TabOutChromeTabGroupImport;
test('reconcileChromeTabGroupImports creates managed session groups for native Chrome groups', () => {
const result = reconcileChromeTabGroupImports({
currentState: { groups: [], assignments: {} },
importedMeta: { entries: [] },
nativeGroups: [
{
chromeGroupId: 101,
windowId: 1,
title: 'Work',
color: 'blue',
tabIds: [11, 12],
},
],
});
assert.equal(result.state.groups.length, 1);
assert.equal(result.state.groups[0].name, 'Work');
assert.equal(result.state.assignments['11'], result.state.groups[0].id);
assert.equal(result.state.assignments['12'], result.state.groups[0].id);
assert.deepEqual(result.importedMeta.entries, [
{
sessionGroupId: result.state.groups[0].id,
chromeGroupId: 101,
windowId: 1,
title: 'Work',
color: 'blue',
},
]);
assert.deepEqual(result.mappings, [
{
virtualGroupKey: `__session_group__:${result.state.groups[0].id}`,
windowId: 1,
chromeGroupId: 101,
},
]);
});
test('reconcileChromeTabGroupImports reuses existing managed group and updates its title', () => {
const result = reconcileChromeTabGroupImports({
currentState: {
groups: [
{ id: 'session-1', name: 'Old Work', createdAt: '2026-04-28T00:00:00.000Z' },
],
assignments: { '11': 'session-1' },
},
importedMeta: {
entries: [
{
sessionGroupId: 'session-1',
chromeGroupId: 101,
windowId: 1,
title: 'Old Work',
color: 'blue',
},
],
},
nativeGroups: [
{
chromeGroupId: 101,
windowId: 1,
title: 'Work',
color: 'red',
tabIds: [22],
},
],
});
assert.equal(result.state.groups.length, 1);
assert.equal(result.state.groups[0].id, 'session-1');
assert.equal(result.state.groups[0].name, 'Work');
assert.deepEqual(result.state.assignments, { '22': 'session-1' });
assert.equal(result.importedMeta.entries[0].color, 'red');
});
test('reconcileChromeTabGroupImports removes stale managed groups when Chrome groups disappear', () => {
const result = reconcileChromeTabGroupImports({
currentState: {
groups: [
{ id: 'managed-1', name: 'Imported', createdAt: '2026-04-28T00:00:00.000Z' },
{ id: 'manual-1', name: 'Manual', createdAt: '2026-04-28T00:00:00.000Z' },
],
assignments: {
'11': 'managed-1',
'22': 'manual-1',
},
},
importedMeta: {
entries: [
{
sessionGroupId: 'managed-1',
chromeGroupId: 101,
windowId: 1,
title: 'Imported',
color: 'blue',
},
],
},
nativeGroups: [],
});
assert.deepEqual(result.state.groups.map(group => group.id), ['manual-1']);
assert.deepEqual(result.state.assignments, { '22': 'manual-1' });
assert.deepEqual(result.importedMeta.entries, []);
});
test('reconcileChromeTabGroupImports matches persisted metadata when Chrome group ids change after restart', () => {
const result = reconcileChromeTabGroupImports({
currentState: {
groups: [
{ id: 'session-1', name: 'Work', createdAt: '2026-04-28T00:00:00.000Z' },
],
assignments: {},
},
importedMeta: {
entries: [
{
sessionGroupId: 'session-1',
chromeGroupId: 101,
windowId: 3,
title: 'Work',
color: 'blue',
},
],
},
nativeGroups: [
{
chromeGroupId: 808,
windowId: 3,
title: 'Work',
color: 'blue',
tabIds: [33],
},
],
});
assert.equal(result.state.groups[0].id, 'session-1');
assert.equal(result.state.assignments['33'], 'session-1');
assert.equal(result.importedMeta.entries[0].chromeGroupId, 808);
});
test('reconcileChromeTabGroupImports reuses an existing session group by matching assignments and title when metadata is missing', () => {
const result = reconcileChromeTabGroupImports({
currentState: {
groups: [
{ id: 'session-keep', name: 'Work', createdAt: '2026-04-28T00:00:00.000Z' },
],
assignments: {
'41': 'session-keep',
},
},
importedMeta: {
entries: [],
},
nativeGroups: [
{
chromeGroupId: 909,
windowId: 1,
title: 'Work',
color: 'blue',
tabIds: [41],
},
],
});
assert.equal(result.state.groups.length, 1);
assert.equal(result.state.groups[0].id, 'session-keep');
assert.equal(result.state.groups[0].name, 'Work');
assert.equal(result.state.assignments['41'], 'session-keep');
assert.equal(result.importedMeta.entries[0].sessionGroupId, 'session-keep');
});
+519
View File
@@ -0,0 +1,519 @@
'use strict';
(function attachChromeTabGroups(globalScope) {
const STORAGE_KEY = 'chromeTabGroupsEnabled';
const META_PERSIST_KEY = 'chromeTabGroupsMeta';
let cachedEnabled = false;
let chromeGroupMap = {};
let importMode = false;
let chromeEventMuteUntil = 0;
let chromeListenersAttached = false;
const chromeGroupSubscribers = new Set();
const GROUP_COLORS = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange'];
function muteChromeGroupEvents(durationMs = 250) {
chromeEventMuteUntil = Math.max(chromeEventMuteUntil, Date.now() + durationMs);
}
function shouldIgnoreChromeEvent() {
return !cachedEnabled || Date.now() < chromeEventMuteUntil;
}
function notifyChromeGroupSubscribers(event) {
if (shouldIgnoreChromeEvent()) return;
for (const subscriber of chromeGroupSubscribers) {
try {
subscriber(event);
} catch {}
}
}
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?.onRemoved, group => notifyChromeGroupSubscribers({ source: 'tabGroups.onRemoved', group })],
[chrome.tabs?.onAttached, (tabId, attachInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onAttached', tabId, attachInfo })],
[chrome.tabs?.onCreated, tab => notifyChromeGroupSubscribers({ source: 'tabs.onCreated', tab })],
[chrome.tabs?.onDetached, (tabId, detachInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onDetached', tabId, detachInfo })],
[chrome.tabs?.onMoved, async (tabId, moveInfo) => {
try {
const movedTab = await chrome.tabs.get(tabId);
if (movedTab?.groupId == null || Number(movedTab.groupId) < 0) return;
notifyChromeGroupSubscribers({ source: 'tabs.onMoved', tabId, moveInfo, tab: movedTab });
} catch {}
}],
[chrome.tabs?.onRemoved, (tabId, removeInfo) => notifyChromeGroupSubscribers({ source: 'tabs.onRemoved', tabId, removeInfo })],
[chrome.tabs?.onUpdated, (tabId, changeInfo, tab) => {
if (changeInfo?.groupId == null) return;
notifyChromeGroupSubscribers({ source: 'tabs.onUpdated', tabId, changeInfo, tab });
}],
];
for (const [eventSource, listener] of eventBindings) {
if (eventSource && typeof eventSource.addListener === 'function') {
eventSource.addListener(listener);
}
}
chromeListenersAttached = true;
}
function getGroupTitle(group) {
if (group.domain === '__landing-pages__') return 'Homepages';
if (group.label) return group.label;
try {
const hostname = group.domain.replace(/^__session_group__:/, '');
return friendlyDomain(hostname);
} catch {
return group.domain;
}
}
function assignGroupColor(groupKey, index) {
if (groupKey.startsWith('__session_group__:')) return 'blue';
if (groupKey === '__landing-pages__') return 'yellow';
return GROUP_COLORS[index % GROUP_COLORS.length];
}
async function loadChromeTabGroupsSetting() {
try {
const stored = await chrome.storage.local.get(STORAGE_KEY);
cachedEnabled = Boolean(stored[STORAGE_KEY]);
} catch {
cachedEnabled = false;
}
await loadPersistedChromeGroupMap();
return cachedEnabled;
}
async function saveChromeTabGroupsSetting(enabled) {
cachedEnabled = Boolean(enabled);
await chrome.storage.local.set({ [STORAGE_KEY]: cachedEnabled });
return cachedEnabled;
}
async function persistChromeGroupMap() {
try {
// Save group metadata (title, color) instead of raw groupIds,
// since Chrome tab group IDs are only stable within a session.
const meta = {};
for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) {
for (const chromeGroupId of Object.values(windowMap)) {
try {
const group = await chrome.tabGroups.get(chromeGroupId);
if (group && !meta[groupKey]) {
meta[groupKey] = { title: group.title, color: group.color };
}
} catch {}
}
}
await chrome.storage.local.set({ [META_PERSIST_KEY]: meta });
} catch {}
}
async function loadPersistedChromeGroupMap() {
try {
const result = await chrome.storage.local.get(META_PERSIST_KEY);
const meta = result[META_PERSIST_KEY];
if (!meta || Object.keys(meta).length === 0) return;
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;
}
}
chromeGroupMap = reconciled;
} catch {}
}
function isChromeApiAvailable() {
return typeof chrome !== 'undefined' &&
chrome.tabs && typeof chrome.tabs.group === 'function' &&
chrome.tabGroups && typeof chrome.tabGroups.update === 'function';
}
async function ungroupTabs(tabIds) {
if (!tabIds || tabIds.length === 0) return;
try {
await chrome.tabs.ungroup(tabIds);
} catch {
// Tab may have been closed already
}
}
async function reorderGroupedTabs(chromeGroupId, desiredTabIds, windowId) {
if (!chromeGroupId || !Array.isArray(desiredTabIds) || desiredTabIds.length <= 1) return;
let groupedTabs = [];
try {
groupedTabs = await chrome.tabs.query({ groupId: chromeGroupId });
} catch {
return;
}
if (!groupedTabs.length) return;
const currentTabs = groupedTabs
.filter(tab => desiredTabIds.includes(tab.id))
.sort((a, b) => a.index - b.index);
if (!currentTabs.length) return;
const currentOrder = currentTabs.map(tab => tab.id);
if (currentOrder.length === desiredTabIds.length &&
currentOrder.every((tabId, index) => tabId === desiredTabIds[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 {
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()) {
try {
await chrome.tabs.move(tabId, { windowId, index: baseIndex + offset });
} catch {}
}
}
async function removeAllChromeGroups() {
muteChromeGroupEvents();
const allTrackedTabIds = [];
for (const windowMap of Object.values(chromeGroupMap)) {
for (const chromeGroupId of Object.values(windowMap)) {
try {
const group = await chrome.tabGroups.get(chromeGroupId);
if (group) {
const tabs = await chrome.tabs.query({ groupId: chromeGroupId });
allTrackedTabIds.push(...tabs.map(t => t.id).filter(Boolean));
}
} catch {
// Group may have been removed externally
}
}
}
if (allTrackedTabIds.length > 0) {
await ungroupTabs(allTrackedTabIds);
}
chromeGroupMap = {};
await persistChromeGroupMap();
}
async function syncChromeTabGroups(domainGroups) {
muteChromeGroupEvents();
await loadPersistedChromeGroupMap();
if (!cachedEnabled) {
await removeAllChromeGroups();
return;
}
if (!isChromeApiAvailable()) return;
// Build desired state: { groupKey: { windowId: [tabIds] } }
const desired = {};
const desiredWindowOrders = {};
for (const group of domainGroups) {
const groupKey = group.domain;
for (const tab of (group.tabs || [])) {
if (tab.id == null) 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
let currentGroups = [];
try {
currentGroups = await chrome.tabGroups.query({});
} catch {}
const validGroupIds = new Set(currentGroups.map(g => g.id));
// Remove orphaned Chrome groups only for windows represented in this sync.
// Other windows may be managed by their own Tab Harbor new-tab page.
const desiredWindowIds = new Set(
Object.values(desired)
.flatMap(windowMap => Object.keys(windowMap))
.map(windowId => Number(windowId))
.filter(Number.isFinite)
);
for (const [groupKey, windowMap] of Object.entries(chromeGroupMap)) {
for (const [windowIdStr, chromeGroupId] of Object.entries(windowMap)) {
const windowId = Number(windowIdStr);
if (!validGroupIds.has(chromeGroupId)) {
delete windowMap[windowIdStr];
continue;
}
if (desiredWindowIds.has(windowId) && !desired[groupKey]?.[windowId]) {
try {
const tabs = await chrome.tabs.query({ groupId: chromeGroupId });
await ungroupTabs(tabs.map(t => t.id).filter(Boolean));
} catch {}
delete windowMap[windowIdStr];
}
}
if (Object.keys(windowMap).length === 0) {
delete chromeGroupMap[groupKey];
}
}
// Process each virtual group
let colorIndex = 0;
for (const [groupKey, windowMap] of Object.entries(desired)) {
const groupColor = assignGroupColor(groupKey, colorIndex);
const group = domainGroups.find(g => g.domain === groupKey);
const title = group ? getGroupTitle(group) : groupKey;
if (!groupKey.startsWith('__session_group__:')) {
colorIndex++;
}
for (const [windowIdStr, tabIds] of Object.entries(windowMap)) {
if (tabIds.length === 0) continue;
const windowId = Number(windowIdStr);
let chromeGroupId = chromeGroupMap[groupKey]?.[windowId];
// Reuse existing Chrome group if still valid
if (chromeGroupId != null && !validGroupIds.has(chromeGroupId)) {
chromeGroupId = null;
}
if (chromeGroupId == null) {
// In import mode, only reuse existing groups — don't create new ones
if (importMode) continue;
// Create new group
try {
chromeGroupId = await chrome.tabs.group({ tabIds });
} catch {
// Some tabs may have valid IDs but fail grouping; try one by one
for (const tabId of tabIds) {
try {
if (chromeGroupId == null) {
chromeGroupId = await chrome.tabs.group({ tabIds: tabId });
} else {
await chrome.tabs.group({ groupId: chromeGroupId, tabIds: tabId });
}
} catch {}
}
}
if (chromeGroupId != null) {
try {
await chrome.tabGroups.update(chromeGroupId, { title, color: groupColor, collapsed: true });
} catch {}
}
} else {
// Move tabs into existing group
try {
await chrome.tabs.group({ groupId: chromeGroupId, tabIds });
} catch {}
}
if (chromeGroupId != null) {
await reorderGroupedTabs(chromeGroupId, tabIds, windowId);
}
// Track the mapping
if (chromeGroupId != null) {
if (!chromeGroupMap[groupKey]) chromeGroupMap[groupKey] = {};
chromeGroupMap[groupKey][windowId] = chromeGroupId;
}
}
}
for (const [windowIdStr, orderedTabIds] of Object.entries(desiredWindowOrders)) {
await reorderWindowTabsByDesiredOrder(Number(windowIdStr), orderedTabIds);
}
await persistChromeGroupMap();
}
async function resetChromeGroupState() {
chromeGroupMap = {};
cachedEnabled = false;
importMode = false;
chromeEventMuteUntil = 0;
try {
await chrome.storage.local.remove(META_PERSIST_KEY);
} catch {}
}
function isChromeTabGroupsEnabled() {
return cachedEnabled;
}
function getChromeGroupCount() {
return Object.keys(chromeGroupMap).length;
}
async function populateChromeGroupMap(mappings) {
for (const { virtualGroupKey, windowId, chromeGroupId } of mappings) {
if (!chromeGroupMap[virtualGroupKey]) chromeGroupMap[virtualGroupKey] = {};
chromeGroupMap[virtualGroupKey][windowId] = chromeGroupId;
}
await persistChromeGroupMap();
}
async function queryExistingChromeGroups() {
try {
return await chrome.tabGroups.query({});
} catch {
return [];
}
}
async function collapseChromeTabGroupsInWindow(windowId) {
if (!cachedEnabled || !isChromeApiAvailable()) return;
const targetWindowId = Number(windowId);
if (!Number.isFinite(targetWindowId)) return;
let groups = [];
try {
groups = await chrome.tabGroups.query({});
} catch {
return;
}
const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId);
muteChromeGroupEvents();
for (const group of groupsInWindow) {
if (Boolean(group.collapsed)) continue;
try {
await chrome.tabGroups.update(group.id, { collapsed: true });
} catch {}
}
}
async function syncChromeTabGroupExpansionForTab(tab) {
if (!cachedEnabled || !isChromeApiAvailable()) return;
const targetGroupId = Number(tab?.groupId);
const targetWindowId = Number(tab?.windowId);
if (!Number.isFinite(targetGroupId) || targetGroupId < 0) return;
if (!Number.isFinite(targetWindowId)) return;
let groups = [];
try {
groups = await chrome.tabGroups.query({});
} catch {
return;
}
const groupsInWindow = groups.filter(group => Number(group?.windowId) === targetWindowId);
muteChromeGroupEvents();
for (const group of groupsInWindow) {
const nextCollapsed = group.id !== targetGroupId;
if (Boolean(group.collapsed) === nextCollapsed) continue;
try {
await chrome.tabGroups.update(group.id, { collapsed: nextCollapsed });
} catch {}
}
}
function setImportMode(enabled) {
importMode = Boolean(enabled);
}
function isImportMode() {
return importMode;
}
function subscribeToChromeTabGroupChanges(listener) {
if (typeof listener !== 'function') {
return () => {};
}
attachChromeListeners();
chromeGroupSubscribers.add(listener);
return () => {
chromeGroupSubscribers.delete(listener);
};
}
const api = {
loadChromeTabGroupsSetting,
saveChromeTabGroupsSetting,
syncChromeTabGroups,
resetChromeGroupState,
isChromeTabGroupsEnabled,
getChromeGroupCount,
populateChromeGroupMap,
queryExistingChromeGroups,
collapseChromeTabGroupsInWindow,
syncChromeTabGroupExpansionForTab,
setImportMode,
isImportMode,
subscribeToChromeTabGroupChanges,
STORAGE_KEY,
assignGroupColor,
getGroupTitle,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutChromeTabGroups = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+602
View File
@@ -0,0 +1,602 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
function createEventEmitter() {
const listeners = new Set();
return {
addListener(listener) {
listeners.add(listener);
},
removeListener(listener) {
listeners.delete(listener);
},
hasListener(listener) {
return listeners.has(listener);
},
emit(...args) {
for (const listener of listeners) listener(...args);
},
};
}
// Mock chrome APIs before loading the module
const mockStorage = {};
globalThis.chrome = {
storage: {
local: {
get: async (keys) => {
const key = Array.isArray(keys) ? keys[0] : keys;
return { [key]: mockStorage[key] ?? undefined };
},
set: async (items) => {
Object.assign(mockStorage, items);
},
},
},
tabs: {
group: async (opts) => 0,
get: async () => ({ id: 0, groupId: -1, windowId: 0 }),
move: async () => {},
ungroup: async () => {},
query: async (opts) => opts?.groupId != null ? [] : [],
onAttached: createEventEmitter(),
onCreated: createEventEmitter(),
onDetached: createEventEmitter(),
onMoved: createEventEmitter(),
onRemoved: createEventEmitter(),
onUpdated: createEventEmitter(),
},
tabGroups: {
query: async () => [],
update: async () => {},
get: async () => null,
onCreated: createEventEmitter(),
onRemoved: createEventEmitter(),
onUpdated: createEventEmitter(),
},
};
require('./chrome-tab-groups-sync.js');
const {
loadChromeTabGroupsSetting,
saveChromeTabGroupsSetting,
syncChromeTabGroups,
collapseChromeTabGroupsInWindow,
syncChromeTabGroupExpansionForTab,
resetChromeGroupState,
isChromeTabGroupsEnabled,
getChromeGroupCount,
populateChromeGroupMap,
queryExistingChromeGroups,
setImportMode,
subscribeToChromeTabGroupChanges,
assignGroupColor,
getGroupTitle,
} = globalThis.TabOutChromeTabGroups;
test('assignGroupColor returns blue for session groups', () => {
assert.equal(assignGroupColor('__session_group__:g1', 0), 'blue');
assert.equal(assignGroupColor('__session_group__:g2', 5), 'blue');
});
test('assignGroupColor returns yellow for landing pages', () => {
assert.equal(assignGroupColor('__landing-pages__', 0), 'yellow');
assert.equal(assignGroupColor('__landing-pages__', 3), 'yellow');
});
test('assignGroupColor cycles through colors for domain groups', () => {
const colors = ['grey', 'red', 'green', 'pink', 'purple', 'cyan', 'orange'];
colors.forEach((expected, i) => {
assert.equal(assignGroupColor('example.com', i), expected);
});
// Cycles back
assert.equal(assignGroupColor('test.com', 7), colors[0]);
});
test('getGroupTitle returns friendly domain for regular groups', () => {
const group = { domain: 'github.com', tabs: [] };
const title = getGroupTitle(group);
assert.equal(typeof title, 'string');
assert.ok(title.length > 0);
});
test('getGroupTitle uses label for custom groups', () => {
const group = { domain: 'custom-key', label: 'Work', tabs: [] };
assert.equal(getGroupTitle(group), 'Work');
});
test('getGroupTitle returns Homepages for landing pages', () => {
const group = { domain: '__landing-pages__', tabs: [] };
assert.equal(getGroupTitle(group), 'Homepages');
});
test('loadChromeTabGroupsSetting returns false by default', async () => {
resetChromeGroupState();
delete mockStorage.chromeTabGroupsEnabled;
const result = await loadChromeTabGroupsSetting();
assert.equal(result, false);
assert.equal(isChromeTabGroupsEnabled(), false);
});
test('saveChromeTabGroupsSetting persists and updates cached state', async () => {
resetChromeGroupState();
const saved = await saveChromeTabGroupsSetting(true);
assert.equal(saved, true);
assert.equal(isChromeTabGroupsEnabled(), true);
assert.equal(mockStorage.chromeTabGroupsEnabled, true);
const loaded = await loadChromeTabGroupsSetting();
assert.equal(loaded, true);
});
test('saveChromeTabGroupsSetting toggles off correctly', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
assert.equal(isChromeTabGroupsEnabled(), true);
await saveChromeTabGroupsSetting(false);
assert.equal(isChromeTabGroupsEnabled(), false);
assert.equal(mockStorage.chromeTabGroupsEnabled, false);
});
test('syncChromeTabGroups removes groups when disabled', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(false);
const groups = [
{ domain: 'github.com', tabs: [{ id: 1, windowId: 1, url: 'https://github.com' }] },
];
// Should not throw when disabled
await syncChromeTabGroups(groups);
assert.equal(getChromeGroupCount(), 0);
});
test('syncChromeTabGroups creates groups when enabled', async () => {
resetChromeGroupState();
let groupCallCount = 0;
let updateCallArgs = [];
globalThis.chrome.tabs.group = async (opts) => {
groupCallCount++;
return 100 + groupCallCount;
};
globalThis.chrome.tabGroups.update = async (id, opts) => {
updateCallArgs.push({ id, ...opts });
};
globalThis.chrome.tabGroups.query = async () => [];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
const groups = [
{ domain: 'github.com', tabs: [{ id: 1, windowId: 1, url: 'https://github.com' }] },
{ domain: 'example.com', tabs: [{ id: 2, windowId: 1, url: 'https://example.com' }] },
];
await syncChromeTabGroups(groups);
assert.equal(groupCallCount, 2);
assert.equal(updateCallArgs.length, 2);
assert.equal(updateCallArgs[0].collapsed, true);
assert.equal(updateCallArgs[1].collapsed, true);
assert.equal(updateCallArgs[0].color, 'grey');
assert.equal(updateCallArgs[1].color, 'red');
assert.equal(getChromeGroupCount(), 2);
});
test('syncChromeTabGroups handles tabs in different windows', async () => {
resetChromeGroupState();
let groupCallCount = 0;
globalThis.chrome.tabs.group = async (opts) => {
groupCallCount++;
return 200 + groupCallCount;
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
const groups = [
{
domain: 'github.com',
tabs: [
{ id: 1, windowId: 1, url: 'https://github.com' },
{ id: 3, windowId: 2, url: 'https://github.com/other' },
],
},
];
await syncChromeTabGroups(groups);
// Should create 1 group for each window → 2 total chrome.tabs.group calls
assert.equal(groupCallCount, 2);
});
test('syncChromeTabGroups reorders tabs inside a chrome group to match desired order', async () => {
resetChromeGroupState();
const moveCalls = [];
globalThis.chrome.tabs.group = async () => 901;
globalThis.chrome.tabs.move = async (tabId, opts) => {
moveCalls.push({ tabId, ...opts });
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [];
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 901) {
return [
{ id: 2, groupId: 901, windowId: 1, index: 7 },
{ id: 1, groupId: 901, windowId: 1, index: 8 },
];
}
return [];
};
await saveChromeTabGroupsSetting(true);
await syncChromeTabGroups([
{
domain: 'github.com',
tabs: [
{ id: 1, windowId: 1, url: 'https://github.com/1' },
{ id: 2, windowId: 1, url: 'https://github.com/2' },
],
},
]);
assert.deepEqual(moveCalls, [
{ tabId: 1, windowId: 1, index: 7 },
{ tabId: 2, windowId: 1, index: 8 },
]);
});
test('syncChromeTabGroups reorders grouped tabs across groups to match desired group order in a window', async () => {
resetChromeGroupState();
const moveCalls = [];
globalThis.chrome.tabs.group = async (opts) => opts.groupId ?? 0;
globalThis.chrome.tabs.move = async (tabId, opts) => {
moveCalls.push({ tabId, ...opts });
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [
{ id: 501, title: 'GitHub', color: 'grey', windowId: 1 },
{ id: 502, title: 'Bilibili', color: 'red', windowId: 1 },
];
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 501) {
return [
{ id: 11, groupId: 501, windowId: 1, index: 8 },
];
}
if (opts?.groupId === 502) {
return [
{ id: 22, groupId: 502, windowId: 1, index: 5 },
];
}
if (opts?.windowId === 1) {
return [
{ id: 22, windowId: 1, index: 5 },
{ id: 11, windowId: 1, index: 8 },
];
}
return [];
};
await saveChromeTabGroupsSetting(true);
await populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 },
{ virtualGroupKey: 'bilibili.com', windowId: 1, chromeGroupId: 502 },
]);
await syncChromeTabGroups([
{ domain: 'github.com', tabs: [{ id: 11, windowId: 1, url: 'https://github.com' }] },
{ 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 },
]
);
});
test('syncChromeTabGroups cleans up when disabled after being enabled', async () => {
resetChromeGroupState();
let ungroupCalls = [];
globalThis.chrome.tabs.ungroup = async (tabIds) => {
ungroupCalls.push(tabIds);
};
globalThis.chrome.tabs.group = async (opts) => 300;
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
const groups = [
{ domain: 'test.com', tabs: [{ id: 10, windowId: 1, url: 'https://test.com' }] },
];
await syncChromeTabGroups(groups);
assert.ok(getChromeGroupCount() > 0);
// Now disable
await saveChromeTabGroupsSetting(false);
await syncChromeTabGroups(groups);
assert.equal(getChromeGroupCount(), 0);
});
test('syncChromeTabGroups handles empty tabs gracefully', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
await syncChromeTabGroups([]);
assert.equal(getChromeGroupCount(), 0);
});
test('syncChromeTabGroups handles groups with no tabs', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
await syncChromeTabGroups([{ domain: 'empty.com', tabs: [] }]);
assert.equal(getChromeGroupCount(), 0);
});
test('populateChromeGroupMap adds mappings to internal state', () => {
resetChromeGroupState();
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 101 },
{ virtualGroupKey: 'github.com', windowId: 2, chromeGroupId: 102 },
]);
assert.equal(getChromeGroupCount(), 1);
});
test('queryExistingChromeGroups returns groups from chrome.tabGroups.query', async () => {
globalThis.chrome.tabGroups.query = async () => [
{ id: 1, title: 'Work', color: 'blue' },
{ id: 2, title: 'Research', color: 'red' },
];
const groups = await queryExistingChromeGroups();
assert.equal(groups.length, 2);
assert.equal(groups[0].title, 'Work');
});
test('syncChromeTabGroups reuses chromeGroupMap populated by populateChromeGroupMap', async () => {
resetChromeGroupState();
let lastGroupCall = null;
globalThis.chrome.tabs.group = async (opts) => {
lastGroupCall = opts;
return 500;
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [{ id: 500, title: 'GitHub', color: 'grey' }];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
// Pre-populate the mapping (simulating "pull from Chrome groups" scenario)
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 500 },
]);
const groups = [
{ domain: 'github.com', tabs: [{ id: 10, windowId: 1, url: 'https://github.com' }] },
];
await syncChromeTabGroups(groups);
// Should have reused the existing group — called with groupId (reuse path), not tabIds (create path)
assert.equal(lastGroupCall.groupId, 500);
assert.deepEqual(lastGroupCall.tabIds, [10]);
});
test('syncChromeTabGroups only removes obsolete mappings in synced windows', async () => {
resetChromeGroupState();
const ungroupedTabIds = [];
globalThis.chrome.tabs.group = async (opts) => opts.groupId ?? 700;
globalThis.chrome.tabs.ungroup = async (tabIds) => {
ungroupedTabIds.push(...tabIds);
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [
{ id: 501, title: 'GitHub', color: 'grey', windowId: 1 },
{ id: 502, title: 'GitHub', color: 'grey', windowId: 2 },
{ id: 503, title: 'Docs', color: 'blue', windowId: 2 },
];
globalThis.chrome.tabs.query = async (opts) => {
if (opts?.groupId === 502) return [{ id: 202, windowId: 2 }];
if (opts?.groupId === 503) return [{ id: 203, windowId: 2 }];
return [];
};
await saveChromeTabGroupsSetting(true);
populateChromeGroupMap([
{ virtualGroupKey: 'github.com', windowId: 1, chromeGroupId: 501 },
{ virtualGroupKey: 'github.com', windowId: 2, chromeGroupId: 502 },
{ virtualGroupKey: 'docs.example', windowId: 2, chromeGroupId: 503 },
]);
await syncChromeTabGroups([
{ domain: 'github.com', tabs: [{ id: 101, windowId: 1, url: 'https://github.com' }] },
]);
assert.deepEqual(ungroupedTabIds, []);
assert.equal(getChromeGroupCount(), 2);
});
test('syncChromeTabGroups in import mode skips creating new groups for ungrouped tabs', async () => {
resetChromeGroupState();
let createCalls = 0;
let reuseCalls = 0;
globalThis.chrome.tabs.group = async (opts) => {
if (opts.groupId != null) {
reuseCalls++;
return opts.groupId;
}
createCalls++;
return 600 + createCalls;
};
globalThis.chrome.tabGroups.update = async () => {};
globalThis.chrome.tabGroups.query = async () => [{ id: 500, title: 'Work', color: 'blue' }];
globalThis.chrome.tabs.query = async (opts) => [];
await saveChromeTabGroupsSetting(true);
// Simulate import: populate chromeGroupMap with existing Chrome groups
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: '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);
assert.equal(createCalls, 1);
});
test('subscribeToChromeTabGroupChanges notifies on external Chrome group changes', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
const events = [];
const unsubscribe = subscribeToChromeTabGroupChanges(event => {
events.push(event);
});
globalThis.chrome.tabGroups.onUpdated.emit(501, { title: 'Work' });
assert.equal(events.length, 1);
assert.equal(events[0].source, 'tabGroups.onUpdated');
unsubscribe();
});
test('subscribeToChromeTabGroupChanges ignores collapse-only group updates', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
const events = [];
const unsubscribe = subscribeToChromeTabGroupChanges(event => {
events.push(event);
});
globalThis.chrome.tabGroups.onUpdated.emit(501, { collapsed: true });
assert.equal(events.length, 0);
unsubscribe();
});
test('subscribeToChromeTabGroupChanges notifies when a grouped tab is moved', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
const events = [];
globalThis.chrome.tabs.get = async (tabId) => ({
id: tabId,
groupId: 501,
windowId: 1,
});
const unsubscribe = subscribeToChromeTabGroupChanges(event => {
events.push(event);
});
globalThis.chrome.tabs.onMoved.emit(42, { windowId: 1, fromIndex: 3, toIndex: 6 });
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(events.length, 1);
assert.equal(events[0].source, 'tabs.onMoved');
assert.equal(events[0].tabId, 42);
assert.equal(events[0].tab.groupId, 501);
unsubscribe();
});
test('syncChromeTabGroupExpansionForTab expands target group and collapses sibling groups in same window', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
const updateCalls = [];
globalThis.chrome.tabGroups.query = async () => [
{ 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 });
};
await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 });
assert.deepEqual(updateCalls, [
{ id: 101, collapsed: false },
{ id: 102, collapsed: true },
]);
});
test('syncChromeTabGroupExpansionForTab skips work when Chrome sync is disabled', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(false);
let queryCount = 0;
globalThis.chrome.tabGroups.query = async () => {
queryCount++;
return [];
};
await syncChromeTabGroupExpansionForTab({ groupId: 101, windowId: 1 });
assert.equal(queryCount, 0);
});
test('collapseChromeTabGroupsInWindow collapses every expanded group in the target window', async () => {
resetChromeGroupState();
await saveChromeTabGroupsSetting(true);
const updateCalls = [];
globalThis.chrome.tabGroups.query = async () => [
{ id: 101, windowId: 1, collapsed: false },
{ id: 102, windowId: 1, collapsed: true },
{ id: 103, windowId: 2, collapsed: false },
];
globalThis.chrome.tabGroups.update = async (id, opts) => {
updateCalls.push({ id, ...opts });
};
await collapseChromeTabGroupsInWindow(1);
assert.deepEqual(updateCalls, [
{ id: 101, collapsed: true },
]);
});
+17
View File
@@ -0,0 +1,17 @@
'use strict';
(function attachOptionalLocalConfig(globalScope) {
if (globalScope.TabHarborConfigReady && typeof globalScope.TabHarborConfigReady.then === 'function') {
return;
}
globalScope.TabHarborConfigReady = new Promise(resolve => {
const script = document.createElement('script');
script.src = 'config.local.js';
script.async = false;
script.dataset.tabHarborOptionalConfig = 'true';
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.appendChild(script);
});
})(typeof globalThis !== 'undefined' ? globalThis : window);
+9
View File
@@ -0,0 +1,9 @@
'use strict';
// Checked-in defaults. Private overrides can extend these via config.local.js.
globalThis.LOCAL_LANDING_PAGE_PATTERNS = Array.isArray(globalThis.LOCAL_LANDING_PAGE_PATTERNS)
? globalThis.LOCAL_LANDING_PAGE_PATTERNS
: [];
globalThis.LOCAL_CUSTOM_GROUPS = Array.isArray(globalThis.LOCAL_CUSTOM_GROUPS)
? globalThis.LOCAL_CUSTOM_GROUPS
: [];
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
'use strict';
(function attachDeferredTriggerPosition(globalScope) {
function normalizeTriggerPosition(input) {
const top = Number(input?.top);
return {
top: Number.isFinite(top) ? top : null,
};
}
function clampTriggerTop(top, viewportHeight, triggerHeight, margin = 24) {
const numericTop = Number(top);
if (!Number.isFinite(numericTop)) return null;
const minTop = margin;
const maxTop = Math.max(minTop, Number(viewportHeight) - Number(triggerHeight) - margin);
return Math.min(Math.max(numericTop, minTop), maxTop);
}
const api = {
clampTriggerTop,
normalizeTriggerPosition,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutDeferredTriggerPosition = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
@@ -0,0 +1,20 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
clampTriggerTop,
normalizeTriggerPosition,
} = require('./deferred-trigger-position.js');
test('normalizeTriggerPosition keeps finite top values only', () => {
assert.deepEqual(normalizeTriggerPosition({ top: 180 }), { top: 180 });
assert.deepEqual(normalizeTriggerPosition({ top: 'bad' }), { top: null });
});
test('clampTriggerTop keeps trigger inside viewport bounds', () => {
assert.equal(clampTriggerTop(8, 900, 48, 24), 24);
assert.equal(clampTriggerTop(300, 900, 48, 24), 300);
assert.equal(clampTriggerTop(880, 900, 48, 24), 828);
});
+430
View File
@@ -0,0 +1,430 @@
'use strict';
const {
escapeHtmlAttribute: drawerEscapeHtmlAttribute,
escapeHtml: drawerEscapeHtml,
} = globalThis.TabOutIconUtils || {};
const {
t: drawerT,
} = globalThis.TabHarborI18n || {};
const {
clampTriggerTop: drawerClampTriggerTop,
normalizeTriggerPosition: drawerNormalizeTriggerPosition,
} = globalThis.TabOutDeferredTriggerPosition || {};
const {
clearArchivedTodos: drawerClearArchivedTodos,
completeTodo: drawerCompleteTodo,
createTodo: drawerCreateTodo,
deleteTodo: drawerDeleteTodo,
normalizeTodos: drawerNormalizeTodos,
searchTodos: drawerSearchTodos,
splitTodos: drawerSplitTodos,
updateTodo: drawerUpdateTodo,
} = globalThis.TabOutTodosStore || {};
let deferredPanelOpen = false;
let deferredTriggerPosition = drawerNormalizeTriggerPosition ? drawerNormalizeTriggerPosition() : { top: null };
const DEFERRED_TRIGGER_POSITION_KEY = 'deferredTriggerPosition';
let deferredTriggerDragState = null;
let deferredTriggerSuppressClickUntil = 0;
let drawerView = 'todos';
let todoDetailId = '';
let todoSearchOpen = false;
let todoSearchQuery = '';
let drawerFocusReturnEl = null;
const TODOS_KEY = 'todos';
function createTodoEditorState(input = {}) {
return {
open: Boolean(input.open),
mode: input.mode === 'edit' ? 'edit' : 'create',
todoId: input.todoId || '',
title: input.title || '',
description: input.description || '',
error: input.error || '',
};
}
let todoEditorState = createTodoEditorState();
function drawerTodoText(key, fallback) {
return drawerT ? drawerT(key) : fallback;
}
function drawerEscapeAttr(value = '') {
if (drawerEscapeHtmlAttribute) return drawerEscapeHtmlAttribute(value);
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function openTodoEditor({ mode = 'create', todo = null } = {}) {
todoEditorState = createTodoEditorState({
open: true,
mode,
todoId: mode === 'edit' ? String(todo?.id || '') : '',
title: mode === 'edit' ? String(todo?.title || '') : '',
description: mode === 'edit' ? String(todo?.description || '') : '',
});
}
function closeTodoEditor() {
todoEditorState = createTodoEditorState();
}
function getTodoEditorState() {
return { ...todoEditorState };
}
function setTodoEditorField(field, value) {
if (field !== 'title' && field !== 'description') return;
todoEditorState = {
...todoEditorState,
[field]: String(value || ''),
error: '',
};
}
function setTodoEditorError(error) {
todoEditorState = {
...todoEditorState,
error: String(error || ''),
};
}
function focusTodoEditorTitle() {
const titleInput = document.querySelector('#todoEditorView [name="title"]');
if (titleInput?.focus) titleInput.focus({ preventScroll: true });
}
async function loadDeferredTriggerPosition() {
const stored = await chrome.storage.local.get(DEFERRED_TRIGGER_POSITION_KEY);
deferredTriggerPosition = drawerNormalizeTriggerPosition(stored[DEFERRED_TRIGGER_POSITION_KEY]);
return deferredTriggerPosition;
}
async function saveDeferredTriggerPosition(nextState) {
deferredTriggerPosition = drawerNormalizeTriggerPosition(nextState);
await chrome.storage.local.set({ [DEFERRED_TRIGGER_POSITION_KEY]: deferredTriggerPosition });
return deferredTriggerPosition;
}
function isMobileDeferredLayout() {
return window.matchMedia('(max-width: 960px)').matches;
}
function applyDeferredTriggerPosition() {
const triggerStack = document.getElementById('drawerTriggerStack');
if (!triggerStack) return;
if (isMobileDeferredLayout()) {
triggerStack.style.removeProperty('top');
return;
}
const triggerHeight = triggerStack.offsetHeight || 94;
const normalizedTop = drawerClampTriggerTop(
deferredTriggerPosition.top ?? window.innerHeight / 2 - triggerHeight / 2,
window.innerHeight,
triggerHeight,
24
);
triggerStack.style.top = `${normalizedTop}px`;
}
async function getTodos() {
const stored = await chrome.storage.local.get(TODOS_KEY);
return drawerNormalizeTodos(stored[TODOS_KEY]);
}
async function saveTodos(todos) {
const normalized = drawerNormalizeTodos(todos);
await chrome.storage.local.set({ [TODOS_KEY]: normalized });
return normalized;
}
async function createTodoItem(payload) {
const todos = await getTodos();
return saveTodos(drawerCreateTodo(todos, payload));
}
async function completeTodoItem(id) {
const todos = await getTodos();
return saveTodos(drawerCompleteTodo(todos, id));
}
async function deleteTodoItem(id) {
const todos = await getTodos();
return saveTodos(drawerDeleteTodo(todos, id));
}
async function updateTodoItem(id, payload) {
const todos = await getTodos();
return saveTodos(drawerUpdateTodo(todos, id, payload));
}
async function clearTodoArchiveItems() {
const todos = await getTodos();
return saveTodos(drawerClearArchivedTodos(todos));
}
function renderTodoArchiveItem(todo) {
const ago = todo.completedAt ? timeAgo(todo.completedAt) : timeAgo(todo.createdAt);
return `
<div class="archive-item">
<div class="archive-item-main">
<div class="archive-item-title">${drawerEscapeHtml ? drawerEscapeHtml(todo.title) : todo.title}</div>
<span class="archive-item-date">${ago}</span>
</div>
<button class="archive-item-delete" type="button" data-action="delete-todo-archive" data-todo-id="${todo.id}" aria-label="Delete archived todo" title="Delete archived todo">
<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 18 18 6M6 6l12 12" /></svg>
</button>
</div>`;
}
function renderTodoListItem(todo, { dragEnabled = true } = {}) {
const ago = timeAgo(todo.createdAt);
const dragHandle = dragEnabled
? `<button class="drawer-reorder-handle todo-reorder-handle" type="button" data-drag-handle="todo" aria-label="Drag to reorder todo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.2" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01" /></svg>
</button>`
: '';
return `
<div class="todo-item" data-todo-id="${todo.id}" data-drawer-sort-id="${todo.id}" data-drawer-sort-kind="todo">
<input type="checkbox" class="todo-checkbox" data-action="complete-todo" data-todo-id="${todo.id}">
<button class="todo-main" type="button" data-action="open-todo-detail" data-todo-id="${todo.id}">
<span class="todo-title">${drawerEscapeHtml ? drawerEscapeHtml(todo.title) : todo.title}</span>
<span class="todo-meta">${ago}</span>
</button>
<div class="todo-actions">
<button class="todo-action-btn todo-edit" type="button" data-action="edit-todo" data-todo-id="${todo.id}" aria-label="Edit todo" data-tooltip="Edit todo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.9" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" /></svg>
</button>
<button class="todo-action-btn todo-delete" type="button" data-action="delete-todo" data-todo-id="${todo.id}" aria-label="Delete todo" data-tooltip="Delete todo">
${ICONS.close}
</button>
${dragHandle}
</div>
</div>`;
}
function renderTodoDetail(todo) {
return `
<div class="todo-detail">
<button class="todo-back-btn" type="button" data-action="close-todo-detail">
<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="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
Back to list
</button>
<div class="todo-detail-card">
<h3>${drawerEscapeHtml ? drawerEscapeHtml(todo.title) : todo.title}</h3>
<p>${drawerEscapeHtml ? drawerEscapeHtml(todo.description || 'Add a note when this task needs more context.') : (todo.description || 'Add a note when this task needs more context.')}</p>
<div class="todo-detail-meta">Created ${timeAgo(todo.createdAt)}</div>
<div class="todo-detail-actions">
<button class="todo-action-btn todo-edit" type="button" data-action="edit-todo" data-todo-id="${todo.id}" aria-label="Edit todo" data-tooltip="Edit todo">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.9" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Z" /></svg>
</button>
<button class="todo-action-btn todo-delete" type="button" data-action="delete-todo" data-todo-id="${todo.id}" aria-label="Delete todo" data-tooltip="Delete todo">
${ICONS.close}
</button>
</div>
</div>
</div>`;
}
function renderTodoEditor() {
const isEdit = todoEditorState.mode === 'edit';
const editorTitle = isEdit
? drawerTodoText('todoEditorEditTitle', 'Edit todo')
: drawerTodoText('todoEditorCreateTitle', 'New todo');
const titleLabel = drawerTodoText('todoTitleLabel', 'Title');
const descriptionLabel = drawerTodoText('todoDescriptionLabel', 'Details');
const titlePlaceholder = drawerTodoText('todoEditorTitlePlaceholder', 'What needs attention?');
const descriptionPlaceholder = drawerTodoText('todoEditorDescriptionPlaceholder', 'Add notes, context, or a next step.');
const titleValue = drawerEscapeAttr(todoEditorState.title);
const descriptionValue = drawerEscapeHtml ? drawerEscapeHtml(todoEditorState.description) : todoEditorState.description;
const errorHtml = todoEditorState.error
? `<div class="todo-editor-error" id="todoEditorError">${drawerEscapeHtml ? drawerEscapeHtml(todoEditorState.error) : todoEditorState.error}</div>`
: '<div class="todo-editor-error" id="todoEditorError" aria-live="polite"></div>';
return `
<div class="todo-editor-view" id="todoEditorView" role="group" aria-label="${drawerEscapeAttr(editorTitle)}">
<form class="todo-editor-form" data-action="submit-todo-editor" novalidate>
<div class="todo-editor-head">
<h3>${drawerEscapeHtml ? drawerEscapeHtml(editorTitle) : editorTitle}</h3>
</div>
<label class="todo-editor-field">
<span class="todo-editor-label">${drawerEscapeHtml ? drawerEscapeHtml(titleLabel) : titleLabel}</span>
<input name="title" class="todo-editor-input" type="text" value="${titleValue}" placeholder="${drawerEscapeAttr(titlePlaceholder)}" data-action="update-todo-editor-field" data-field="title" aria-describedby="todoEditorError" autocomplete="off">
</label>
<label class="todo-editor-field">
<span class="todo-editor-label">${drawerEscapeHtml ? drawerEscapeHtml(descriptionLabel) : descriptionLabel}</span>
<textarea name="description" class="todo-editor-input todo-editor-textarea" placeholder="${drawerEscapeAttr(descriptionPlaceholder)}" data-action="update-todo-editor-field" data-field="description">${descriptionValue}</textarea>
</label>
${errorHtml}
<div class="todo-editor-actions">
<button class="theme-menu-action is-secondary" type="button" data-action="cancel-todo-editor">${drawerTodoText('cancelButton', 'Cancel')}</button>
<button class="theme-menu-action" type="submit" data-action="submit-todo-editor">${drawerTodoText('saveButton', 'Save')}</button>
</div>
</form>
</div>`;
}
async function renderTodoPanel() {
const panel = document.getElementById('todoPanel');
const countEl = document.getElementById('todoCount');
const list = document.getElementById('todoList');
const empty = document.getElementById('todoEmpty');
const archive = document.getElementById('todoArchive');
const archiveCount = document.getElementById('todoArchiveCount');
const archiveList = document.getElementById('todoArchiveList');
const clearArchiveBtn = document.getElementById('clearTodoArchiveBtn');
const detail = document.getElementById('todoDetailView');
const searchWrap = document.getElementById('todoSearchWrap');
const searchInput = document.getElementById('todoSearchInput');
const todoSearchToggle = document.getElementById('todoSearchToggle');
const todoArchiveToggle = document.getElementById('todoArchiveToggle');
const todoArchiveBody = document.getElementById('todoArchiveBody');
if (!panel) return;
const todos = await getTodos();
const { active, archived } = drawerSplitTodos(todos);
const filtered = drawerSearchTodos(active, todoSearchQuery);
const todoDragEnabled = !todoSearchQuery.trim() && !todoDetailId && !todoEditorState.open;
let editor = document.getElementById('todoEditorView');
countEl.textContent = `${active.length}`;
if (todoSearchToggle) {
todoSearchToggle.setAttribute('aria-expanded', String(todoSearchOpen));
}
searchWrap.style.display = todoSearchOpen ? 'block' : 'none';
searchWrap.hidden = !todoSearchOpen;
if (searchInput && searchInput.value !== todoSearchQuery) searchInput.value = todoSearchQuery;
if (todoArchiveToggle && todoArchiveBody) {
const archiveExpanded = todoArchiveBody.style.display !== 'none' && !todoArchiveBody.hidden;
todoArchiveToggle.setAttribute('aria-expanded', String(archiveExpanded));
}
if (todoEditorState.open) {
if (editor) {
const wrapper = document.createElement('div');
wrapper.innerHTML = renderTodoEditor().trim();
editor.replaceWith(wrapper.firstElementChild);
} else {
detail.insertAdjacentHTML('beforebegin', renderTodoEditor());
}
editor = document.getElementById('todoEditorView');
if (editor) editor.style.display = 'block';
detail.style.display = 'none';
list.style.display = 'none';
empty.style.display = 'none';
} else if (editor) {
editor.style.display = 'none';
editor.innerHTML = '';
}
if (!todoEditorState.open && todoDetailId) {
const todo = active.find(item => item.id === todoDetailId);
if (todo) {
detail.innerHTML = renderTodoDetail(todo);
detail.style.display = 'block';
list.style.display = 'none';
empty.style.display = 'none';
} else {
todoDetailId = '';
detail.style.display = 'none';
}
} else {
detail.style.display = 'none';
}
if (!todoEditorState.open && !todoDetailId) {
if (filtered.length > 0) {
list.innerHTML = filtered.map(todo => renderTodoListItem(todo, { dragEnabled: todoDragEnabled })).join('');
list.style.display = 'block';
empty.style.display = 'none';
} else {
list.style.display = 'none';
empty.style.display = 'block';
}
}
if (todoEditorState.open) {
archive.style.display = 'none';
if (clearArchiveBtn) clearArchiveBtn.style.display = 'none';
} else if (archived.length > 0) {
archiveCount.textContent = `(${archived.length})`;
archiveList.innerHTML = archived.map(todo => renderTodoArchiveItem(todo)).join('');
archive.style.display = 'block';
if (clearArchiveBtn) clearArchiveBtn.style.display = 'inline-flex';
} else {
archive.style.display = 'none';
if (clearArchiveBtn) clearArchiveBtn.style.display = 'none';
}
}
async function renderDeferredColumn() {
const column = document.getElementById('drawerColumn');
const triggerStack = document.getElementById('drawerTriggerStack');
const todoTrigger = document.getElementById('todoTrigger');
const overlay = document.getElementById('deferredOverlay');
const todoPanel = document.getElementById('todoPanel');
const titleButtons = document.querySelectorAll('.drawer-title-btn');
if (!column) return;
try {
await getTodos();
await loadDeferredTriggerPosition();
column.style.display = 'block';
if (triggerStack) triggerStack.style.display = 'flex';
if (todoTrigger) todoTrigger.style.display = 'inline-flex';
column.classList.toggle('open', deferredPanelOpen);
column.setAttribute('aria-hidden', String(!deferredPanelOpen));
if (todoTrigger) {
todoTrigger.setAttribute('aria-expanded', String(deferredPanelOpen));
todoTrigger.setAttribute('aria-label', deferredPanelOpen ? 'Close todos' : 'Open todos');
}
overlay.hidden = !deferredPanelOpen;
overlay.classList.toggle('visible', deferredPanelOpen);
applyDeferredTriggerPosition();
todoPanel?.classList.add('is-active');
titleButtons.forEach(button => {
button.classList.add('is-active');
button.setAttribute('aria-selected', 'true');
button.tabIndex = 0;
});
await renderTodoPanel();
} catch (err) {
console.warn('[tab-harbor] Could not load todos:', err);
column.style.display = 'none';
if (triggerStack) triggerStack.style.display = 'none';
overlay.hidden = true;
overlay.classList.remove('visible');
}
}
function setDeferredPanelOpen(nextOpen) {
const shouldOpen = Boolean(nextOpen);
if (shouldOpen) {
drawerFocusReturnEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
deferredPanelOpen = shouldOpen;
renderDeferredColumn().then(() => {
if (deferredPanelOpen) {
const preferredFocusId = todoSearchOpen ? 'todoSearchInput' : 'todoPanel';
const preferredTarget = document.getElementById(preferredFocusId);
if (preferredTarget?.focus) {
preferredTarget.focus({ preventScroll: true });
} else {
focusFirstElement(document.getElementById('drawerColumn'));
}
return;
}
drawerFocusReturnEl?.focus?.({ preventScroll: true });
drawerFocusReturnEl = null;
});
}
+96
View File
@@ -0,0 +1,96 @@
'use strict';
(function attachGroupOrder(globalScope) {
function uniqueKeys(keys = []) {
return [...new Set((keys || []).filter(Boolean).map(String))];
}
function normalizeGroupOrderState(input) {
const durableOrder = uniqueKeys(
input?.sessionOrder?.length
? input.sessionOrder
: (input?.pinnedOrder || [])
);
return {
sessionOrder: durableOrder,
pinnedOrder: durableOrder,
pinEnabled: false,
};
}
function mergeOrder(orderKeys, availableKeys) {
const seen = new Set();
const result = [];
for (const key of uniqueKeys(orderKeys)) {
if (!availableKeys.includes(key) || seen.has(key)) continue;
seen.add(key);
result.push(key);
}
for (const key of availableKeys) {
if (seen.has(key)) continue;
seen.add(key);
result.push(key);
}
return result;
}
function applyGroupOrder(groups, state) {
const normalizedState = normalizeGroupOrderState(state);
const availableKeys = groups.map(group => String(group.domain));
const preferredOrder = normalizedState.sessionOrder;
const finalKeys = mergeOrder(preferredOrder, availableKeys);
const orderMap = new Map(finalKeys.map((key, index) => [key, index]));
return [...groups].sort((a, b) => {
return (orderMap.get(String(a.domain)) ?? Number.MAX_SAFE_INTEGER) -
(orderMap.get(String(b.domain)) ?? Number.MAX_SAFE_INTEGER);
});
}
function createReorderedKeys(currentKeys, draggedKey, targetKey, placeAfter = false) {
const keys = uniqueKeys(currentKeys);
if (!draggedKey || !targetKey || draggedKey === targetKey) return keys;
const draggedIndex = keys.indexOf(String(draggedKey));
const targetIndex = keys.indexOf(String(targetKey));
if (draggedIndex === -1 || targetIndex === -1) return keys;
const nextKeys = [...keys];
nextKeys.splice(draggedIndex, 1);
const insertIndex = placeAfter
? targetIndex > draggedIndex ? targetIndex : targetIndex + 1
: targetIndex < draggedIndex ? targetIndex : targetIndex - 1;
nextKeys.splice(Math.max(0, insertIndex), 0, String(draggedKey));
return nextKeys;
}
function setPinEnabled(state, pinEnabled, currentOrderKeys = []) {
const normalizedState = normalizeGroupOrderState(state);
const orderKeys = normalizedState.sessionOrder.length > 0
? normalizedState.sessionOrder
: uniqueKeys(currentOrderKeys);
const durableOrder = mergeOrder(orderKeys, uniqueKeys(currentOrderKeys));
return normalizeGroupOrderState({
sessionOrder: durableOrder,
pinnedOrder: durableOrder,
pinEnabled: false,
});
}
const api = {
applyGroupOrder,
createReorderedKeys,
normalizeGroupOrderState,
setPinEnabled,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutGroupOrder = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+98
View File
@@ -0,0 +1,98 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
applyGroupOrder,
createReorderedKeys,
normalizeGroupOrderState,
setPinEnabled,
} = require('./group-order.js');
test('normalizeGroupOrderState normalizes to a single durable order', () => {
const state = normalizeGroupOrderState({
sessionOrder: ['github.com', 'chatgpt.com'],
pinnedOrder: ['chatgpt.com'],
pinEnabled: true,
});
assert.deepEqual(state.sessionOrder, ['github.com', 'chatgpt.com']);
assert.deepEqual(state.pinnedOrder, ['github.com', 'chatgpt.com']);
assert.equal(state.pinEnabled, false);
});
test('applyGroupOrder prefers session order, then appends unseen groups', () => {
const groups = [
{ domain: 'github.com' },
{ domain: 'chatgpt.com' },
{ domain: '__landing-pages__' },
];
const ordered = applyGroupOrder(groups, {
sessionOrder: ['chatgpt.com'],
pinnedOrder: ['github.com'],
pinEnabled: true,
});
assert.deepEqual(
ordered.map(group => group.domain),
['chatgpt.com', 'github.com', '__landing-pages__']
);
});
test('applyGroupOrder falls back to stored durable order when session order is empty', () => {
const groups = [
{ domain: 'github.com' },
{ domain: 'chatgpt.com' },
{ domain: '__landing-pages__' },
];
const ordered = applyGroupOrder(groups, {
sessionOrder: [],
pinnedOrder: ['__landing-pages__', 'github.com'],
pinEnabled: true,
});
assert.deepEqual(
ordered.map(group => group.domain),
['__landing-pages__', 'github.com', 'chatgpt.com']
);
});
test('createReorderedKeys moves dragged group before target group', () => {
const reordered = createReorderedKeys(
['github.com', 'chatgpt.com', '__landing-pages__'],
'__landing-pages__',
'github.com'
);
assert.deepEqual(reordered, ['__landing-pages__', 'github.com', 'chatgpt.com']);
});
test('createReorderedKeys can preview dropping after the hovered target', () => {
const reordered = createReorderedKeys(
['github.com', 'chatgpt.com', '__landing-pages__'],
'__landing-pages__',
'github.com',
true
);
assert.deepEqual(reordered, ['github.com', '__landing-pages__', 'chatgpt.com']);
});
test('setPinEnabled preserves the current durable order for legacy callers', () => {
const nextState = setPinEnabled(
{
sessionOrder: ['chatgpt.com', 'github.com'],
pinnedOrder: [],
pinEnabled: false,
},
true,
['chatgpt.com', 'github.com']
);
assert.equal(nextState.pinEnabled, false);
assert.deepEqual(nextState.sessionOrder, ['chatgpt.com', 'github.com']);
assert.deepEqual(nextState.pinnedOrder, ['chatgpt.com', 'github.com']);
});
+677
View File
@@ -0,0 +1,677 @@
'use strict';
(function attachTabHarborI18n(globalScope) {
const LANGUAGE_PREFERENCE_KEY = 'languagePreference';
const LANGUAGE_OPTIONS = new Set(['auto', 'en', 'zh-CN']);
const i18nNavigator = globalScope.navigator || {};
const i18nLanguage = String(i18nNavigator.language || i18nNavigator.userLanguage || 'en').toLowerCase();
function resolveAutoLocale() {
return i18nLanguage.startsWith('zh') ? 'zh-CN' : 'en';
}
function normalizeLanguagePreference(input) {
const value = String(input || 'auto');
return LANGUAGE_OPTIONS.has(value) ? value : 'auto';
}
function resolveLocaleByPreference(preference) {
const normalizedPreference = normalizeLanguagePreference(preference);
if (normalizedPreference === 'en') return 'en';
if (normalizedPreference === 'zh-CN') return 'zh-CN';
return resolveAutoLocale();
}
let i18nLanguagePreference = 'auto';
let i18nLocale = resolveLocaleByPreference(i18nLanguagePreference);
const i18nMessages = {
en: {
emptyTitle: 'Inbox zero, but for tabs.',
emptySubtitle: "You're free.",
greetingMorning: 'Good morning',
greetingAfternoon: 'Good afternoon',
greetingEvening: 'Good evening',
timeJustNow: 'just now',
timeMinAgo: '{count} min ago',
timeHourAgo: '1 hr ago',
timeHoursAgo: '{count} hrs ago',
timeYesterday: 'yesterday',
timeDaysAgo: '{count} days ago',
tabsWordSingular: 'tab',
tabsWordPlural: 'tabs',
groupsWordSingular: 'group',
groupsWordPlural: 'groups',
openTabsSectionTitle: 'Open tabs',
sectionSummary: '{tabs} {tabsWord} across {groups} {groupsWord}',
workspacePageHome: 'Home',
workspacePageSavedTabs: 'Saved tabs',
saveSessionButton: 'Save session',
closeAllTabsButton: 'Close all tabs',
emptyTabsCount: '0 domains',
promptNewGroupName: 'New group name',
promptRenameGroupName: 'Rename group',
promptTodoTitle: 'Todo title',
promptTodoDetails: 'Todo details (optional)',
editTodo: 'Edit todo',
deleteTodo: 'Delete todo',
todoEditorCreateTitle: 'New todo',
todoEditorEditTitle: 'Edit todo',
todoTitleLabel: 'Title',
todoDescriptionLabel: 'Details',
todoEditorTitlePlaceholder: 'What needs attention?',
todoEditorDescriptionPlaceholder: 'Add notes, context, or a next step.',
todoTitleRequired: 'Add a title before saving.',
toastTodoUpdated: 'Todo updated',
toastTodoDeleted: 'Todo deleted',
toastThemeUpdated: 'Theme updated',
toastThemeModeUpdated: 'Appearance mode: {mode}',
toastBackgroundCleared: 'Background cleared',
toastClosedExtraTabHarborTabs: 'Closed extra Tab Harbor tabs',
toastMovedTo: 'Moved to {name}',
toastCreatedGroup: 'Created {name}',
toastRenamedGroup: 'Renamed to {name}',
toastCouldNotCreateGroup: 'Could not create group',
toastMovedBackToOriginalGroup: 'Moved back to original group',
toastPinnedOrder: 'Pinned current order',
toastPinOrderOff: 'Pin order turned off',
toastTabClosed: 'Tab closed',
toastRestoredToOpenTabs: 'Restored to open tabs',
toastClosedDuplicatesKeptOne: 'Closed duplicates, kept one copy each',
toastAllTabsClosed: 'All tabs closed. Fresh start.',
toastQuickTabRemoved: 'Quick tab removed',
toastShortcutIconPasted: 'Shortcut icon pasted',
toastSvgIconPasted: 'SVG icon pasted',
toastClipboardNoImage: 'Clipboard does not contain an image or SVG',
toastClipboardUsePasteShortcut: 'Use Cmd/Ctrl+V inside the editor if direct clipboard access is unavailable',
toastClipboardTemporaryRef: 'This clipboard image is a temporary file reference. Use Cmd/Ctrl+V instead.',
toastShortcutIconUpdated: 'Shortcut icon updated',
toastCouldNotUseShortcutImage: 'Could not use shortcut image',
toastCouldNotPasteShortcutImage: 'Could not paste shortcut image',
toastQuickTabUpdated: 'Quick tab updated',
toastQuickTabAdded: 'Quick tab added',
toastCouldNotSaveShortcut: 'Could not save shortcut',
errorImageCompressionUnavailable: 'Image compression is unavailable',
errorPleaseEnterValidUrl: 'Please enter a valid URL',
addByUrlTitle: 'Add by URL',
shortcutEditTitle: 'Edit shortcut',
shortcutAddTitle: 'Add shortcut',
shortcutPreviewFallbackLabel: 'Shortcut',
shortcutPreviewCustomImageIcon: 'Custom image icon',
shortcutPreviewSvgIcon: 'SVG icon',
shortcutPreviewEmojiIcon: 'Emoji icon',
shortcutPreviewWebsiteIcon: 'Website icon',
shortcutPreviewHasCustomIcon: 'Custom icon will replace the site favicon.',
shortcutPreviewNoCustomIcon: 'Upload or paste an image, or type an emoji.',
closedTabsFromGroup: 'Closed {count} {tabsWord} from {groupLabel}',
closedDuplicatesCount: 'Close {count} duplicate{suffix}',
duplicatesCount: '{count} duplicate{suffix}',
tabsOpenBadge: '{count} {tabsWord} open',
tabsLabel: 'tabs',
moreCount: '+{count} more',
jumpToLabel: 'Jump to {label}',
homepagesLabel: 'Homepages',
moveToGroup: 'Move to group',
renameGroup: 'Rename group',
saveButton: 'Save',
cancelButton: 'Cancel',
noGroupsYet: 'No groups yet',
newGroupButton: '+ New group',
backToOriginalGroup: 'Back to original group',
saveGroupSession: 'Save group session',
saveTabSession: 'Save tab session',
closeThisTab: 'Close this tab',
discardTab: 'Sleep tab',
toastTabDiscarded: 'Tab sleeping',
toastTabDiscardFailed: 'Failed to sleep tab',
toastTabsDiscarded: '{count} tabs sleeping',
dragReorderTab: 'Drag to reorder tab',
closeGroup: 'Close group',
pinnedOrder: 'Pinned order',
pinOrder: 'Pin order',
deskSettings: 'Desk settings',
deskSettingsPanel: 'Desk settings panel',
settingsTabAppearance: 'Appearance',
settingsTabFeatures: 'Features',
appearanceMode: 'Appearance mode',
themeModeSystem: 'System',
themeModeLight: 'Light',
themeModeDark: 'Dark',
themeModeFollowingLight: 'Following system: light',
themeModeFollowingDark: 'Following system: dark',
deskPalette: 'Desk palette',
deskBackdrop: 'Desk backdrop',
uploadImage: 'Upload image',
clearText: 'Clear',
surfaceDepth: 'Surface depth',
uiScaleLabel: 'Text size',
shortcutScaleLabel: 'Shortcut size',
hitokotoLabel: 'Hitokoto',
editQuickTab: 'Edit quick tab',
removeQuickTab: 'Remove quick tab',
addQuickTab: 'Add quick tab',
addLink: 'Add link',
languageLabel: 'Language',
languageAuto: 'Auto',
languageEnglish: 'English',
languageChinese: 'Chinese',
chromeTabGroupsLabel: 'Chrome tab groups',
toastChromeTabGroupsOn: 'Chrome tab groups on',
toastChromeTabGroupsOff: 'Chrome tab groups off',
shortcutsLabel: 'Shortcuts',
popupShortcutsEmpty: 'No shortcuts yet.',
popupTabsEmpty: 'No tabs to display.',
ungroupedLabel: 'Ungrouped',
closeTabButton: 'Close',
popupRefreshLabel: 'Refresh popup content',
sessionWordSingular: 'session',
sessionWordPlural: 'sessions',
restoreSessionButton: 'Restore',
savedSessionSettings: 'Session settings',
savedSessionSettingsPanel: 'Session settings panel',
savedSessionRestoreModeLabel: 'Default open',
savedSessionRestoreModeCurrentWindow: 'Current window',
savedSessionRestoreModeNewWindow: 'New window',
savedSessionNavDisplayModeLabel: 'Top nav display',
savedSessionNavDisplayModeIcon: 'Icon',
savedSessionNavDisplayModeName: 'Group name',
sleepControlLabel: 'Manual sleep control',
closeDuplicateNewTabsLabel: 'Auto-close duplicate new tabs',
sleepAllTabsButton: 'Sleep all tabs in group',
deleteSessionButton: 'Delete session',
sessionPickerTitle: 'Choose what to save',
sessionPickerClose: 'Close tab picker',
sessionPickerModeLabel: 'Save mode',
sessionPickerNewSession: 'New group',
sessionPickerNewSessionNamePlaceholder: 'Group title (optional)',
sessionPickerExistingSession: 'Existing group',
sessionPickerTargetSessionLabel: 'Target session',
sessionPickerNoSavedSessions: 'No saved sessions yet',
sessionPickerAddToExisting: 'Add to session',
sessionPickerSaveAndClose: 'Save and close',
sessionPickerSelectedCount: '{count} selected',
sessionPickerEmpty: 'No restorable tabs in this window.',
toastSessionSaved: 'Saved {count} tabs and closed the originals',
toastSessionTabsAdded: 'Added {count} tabs to saved session; skipped {skipped} duplicates',
toastNoSessionTabsSelected: 'Select at least one tab',
toastSessionRestored: 'Restored {count} tabs',
toastSavedSessionTabRestored: 'Restored tab',
toastSessionDeleted: 'Session deleted',
toastSessionActionFailed: 'Could not update saved tabs',
},
'zh-CN': {
emptyTitle: '标签页清零了。',
emptySubtitle: '你现在很轻松。',
greetingMorning: '早上好',
greetingAfternoon: '下午好',
greetingEvening: '晚上好',
timeJustNow: '刚刚',
timeMinAgo: '{count} 分钟前',
timeHourAgo: '1 小时前',
timeHoursAgo: '{count} 小时前',
timeYesterday: '昨天',
timeDaysAgo: '{count} 天前',
tabsWordSingular: '个标签页',
tabsWordPlural: '个标签页',
groupsWordSingular: '组',
groupsWordPlural: '组',
openTabsSectionTitle: '打开的标签页',
sectionSummary: '{tabs} 个标签页,分布在 {groups} 个分组中',
workspacePageHome: '首页',
workspacePageSavedTabs: '已保存的标签页',
saveSessionButton: '保存会话',
closeAllTabsButton: '关闭全部标签页',
emptyTabsCount: '0 个域名分组',
promptNewGroupName: '新分组名称',
promptRenameGroupName: '重命名分组',
promptTodoTitle: '待办标题',
promptTodoDetails: '待办详情(可选)',
editTodo: '编辑待办',
deleteTodo: '删除待办',
todoEditorCreateTitle: '新建待办',
todoEditorEditTitle: '编辑待办',
todoTitleLabel: '标题',
todoDescriptionLabel: '详情',
todoEditorTitlePlaceholder: '接下来要处理什么?',
todoEditorDescriptionPlaceholder: '写下补充信息、背景或下一步。',
todoTitleRequired: '请先填写待办标题。',
toastTodoUpdated: '待办已更新',
toastTodoDeleted: '待办已删除',
toastThemeUpdated: '主题已更新',
toastThemeModeUpdated: '外观模式:{mode}',
toastBackgroundCleared: '背景已清除',
toastClosedExtraTabHarborTabs: '已关闭多余的 Tab Harbor 标签页',
toastMovedTo: '已移动到 {name}',
toastCreatedGroup: '已创建 {name}',
toastRenamedGroup: '已重命名为 {name}',
toastCouldNotCreateGroup: '无法创建分组',
toastMovedBackToOriginalGroup: '已移回原始分组',
toastPinnedOrder: '已固定当前顺序',
toastPinOrderOff: '已取消固定顺序',
toastTabClosed: '标签页已关闭',
toastRestoredToOpenTabs: '已恢复到打开标签页',
toastClosedDuplicatesKeptOne: '已关闭重复标签页,并为每个链接保留一个',
toastAllTabsClosed: '已关闭全部标签页,重新开始吧。',
toastQuickTabRemoved: '快捷链接已移除',
toastShortcutIconPasted: '已粘贴快捷图标',
toastSvgIconPasted: '已粘贴 SVG 图标',
toastClipboardNoImage: '剪贴板中没有图片或 SVG',
toastClipboardUsePasteShortcut: '若无法直接读取剪贴板,请在编辑器中使用 Cmd/Ctrl+V',
toastClipboardTemporaryRef: '当前剪贴板图片是临时文件引用,请改用 Cmd/Ctrl+V。',
toastShortcutIconUpdated: '快捷图标已更新',
toastCouldNotUseShortcutImage: '无法使用该快捷图片',
toastCouldNotPasteShortcutImage: '无法粘贴快捷图片',
toastQuickTabUpdated: '快捷链接已更新',
toastQuickTabAdded: '快捷链接已添加',
toastCouldNotSaveShortcut: '无法保存快捷链接',
errorImageCompressionUnavailable: '图片压缩不可用',
errorPleaseEnterValidUrl: '请输入有效 URL',
addByUrlTitle: '通过 URL 添加',
shortcutEditTitle: '编辑快捷链接',
shortcutAddTitle: '添加快捷链接',
shortcutPreviewFallbackLabel: '快捷链接',
shortcutPreviewCustomImageIcon: '自定义图片图标',
shortcutPreviewSvgIcon: 'SVG 图标',
shortcutPreviewEmojiIcon: 'Emoji 图标',
shortcutPreviewWebsiteIcon: '网站图标',
shortcutPreviewHasCustomIcon: '自定义图标会替换站点 favicon。',
shortcutPreviewNoCustomIcon: '上传或粘贴图片,或输入一个 emoji。',
closedTabsFromGroup: '已关闭来自 {groupLabel} 的 {count} 个标签页',
closedDuplicatesCount: '关闭 {count} 个重复标签页',
duplicatesCount: '{count} 个重复标签页',
tabsOpenBadge: '已打开 {count} 个标签页',
tabsLabel: '标签页',
moreCount: '+ 另外 {count} 个',
jumpToLabel: '跳转到 {label}',
homepagesLabel: '主页',
moveToGroup: '移动到分组',
renameGroup: '重命名分组',
saveButton: '保存',
cancelButton: '取消',
noGroupsYet: '还没有分组',
newGroupButton: '+ 新建分组',
backToOriginalGroup: '移回原始分组',
saveGroupSession: '保存分组会话',
saveTabSession: '保存标签页会话',
closeThisTab: '关闭此标签页',
discardTab: '休眠标签页',
toastTabDiscarded: '标签页已休眠',
toastTabDiscardFailed: '休眠失败',
toastTabsDiscarded: '已休眠 {count} 个标签页',
dragReorderTab: '拖拽重排标签页',
closeGroup: '关闭分组',
pinnedOrder: '已固定顺序',
pinOrder: '固定顺序',
deskSettings: '桌面设置',
deskSettingsPanel: '桌面设置面板',
settingsTabAppearance: '外观',
settingsTabFeatures: '功能',
appearanceMode: '外观模式',
themeModeSystem: '系统',
themeModeLight: '浅色',
themeModeDark: '深色',
themeModeFollowingLight: '当前跟随:浅色',
themeModeFollowingDark: '当前跟随:深色',
deskPalette: '桌面配色',
deskBackdrop: '桌面背景',
uploadImage: '上传图片',
clearText: '清除',
surfaceDepth: '透明度',
uiScaleLabel: '字体大小',
shortcutScaleLabel: '快捷链接大小',
hitokotoLabel: '一言',
editQuickTab: '编辑快捷链接',
removeQuickTab: '删除快捷链接',
addQuickTab: '添加快捷链接',
addLink: '添加链接',
languageLabel: '语言',
languageAuto: '自动',
languageEnglish: 'English',
languageChinese: '中文',
chromeTabGroupsLabel: 'Chrome 标签组',
toastChromeTabGroupsOn: '已启用 同步Chrome标签组',
toastChromeTabGroupsOff: '已禁用 同步Chrome标签组',
shortcutsLabel: '快捷方式',
popupShortcutsEmpty: '还没有快捷方式。',
popupTabsEmpty: '没有可显示的网页标签页。',
ungroupedLabel: '未分组',
closeTabButton: '关闭',
popupRefreshLabel: '刷新弹窗内容',
sessionWordSingular: '个会话',
sessionWordPlural: '个会话',
restoreSessionButton: '恢复',
savedSessionSettings: '会话设置',
savedSessionSettingsPanel: '会话设置面板',
savedSessionRestoreModeLabel: '默认打开方式',
savedSessionRestoreModeCurrentWindow: '此窗口',
savedSessionRestoreModeNewWindow: '新窗口',
savedSessionNavDisplayModeLabel: '顶部导航显示',
savedSessionNavDisplayModeIcon: '图标',
savedSessionNavDisplayModeName: '分组名称',
sleepControlLabel: '手动休眠控制',
closeDuplicateNewTabsLabel: '自动关闭重复新标签页',
sleepAllTabsButton: '休眠组内全部标签页',
deleteSessionButton: '删除会话',
sessionPickerTitle: '选择要保存的内容',
sessionPickerClose: '关闭标签页选择器',
sessionPickerModeLabel: '保存方式',
sessionPickerNewSession: '新建分组',
sessionPickerNewSessionNamePlaceholder: '分组标题(可选)',
sessionPickerExistingSession: '已有分组',
sessionPickerTargetSessionLabel: '目标会话',
sessionPickerNoSavedSessions: '还没有已保存会话',
sessionPickerAddToExisting: '加入分组',
sessionPickerSaveAndClose: '保存并关闭',
sessionPickerSelectedCount: '已选择 {count} 个',
sessionPickerEmpty: '当前窗口没有可恢复的网页标签页。',
toastSessionSaved: '已保存 {count} 个标签页,并关闭原标签页',
toastSessionTabsAdded: '已加入 {count} 个标签页到已保存会话;跳过 {skipped} 个重复项',
toastNoSessionTabsSelected: '请至少选择一个标签页',
toastSessionRestored: '已恢复 {count} 个标签页',
toastSavedSessionTabRestored: '标签页已恢复',
toastSessionDeleted: '会话已删除',
toastSessionActionFailed: '无法更新已保存标签页',
},
};
const i18nTextMapZh = {
'Workspace pages': '工作区页面',
'Home': '首页',
'Saved tabs': '已保存的标签页',
'Save current window': '保存当前窗口',
'Choose tabs': '选择标签页',
'Keep a window or a handpicked set of tabs, then bring it back when the desk is ready.': '保存整个窗口或手动挑选的标签页,等工作台准备好时再恢复。',
'Choose what to save': '选择要保存的内容',
'Close tab picker': '关闭标签页选择器',
'Save and close': '保存并关闭',
'Saved sessions': '已保存的会话',
'No saved sessions yet. Save a window when you want the memory back without losing the trail.': '还没有已保存会话。需要释放内存但不想丢失线索时,可以保存当前窗口。',
'Right now': '此刻',
'Search the web': '搜索网络',
'Search with your default engine...': '用默认搜索引擎搜索...',
'Open todos': '打开待办',
'Todos': '待办',
'Close right drawer': '关闭右侧抽屉',
'Archive': '归档',
'Clear archive': '清空归档',
'Search todos': '搜索待办',
'New todo': '新建待办',
'Search todos...': '搜索待办...',
'No tasks yet. Capture the next step when something needs a follow-up.': '还没有任务。需要后续跟进时,把下一步记在这里。',
'Open tabs': '打开的标签页',
'Add open tabs': '现有标签',
'Add by URL': '输入网址',
'Filter tabs...': '筛选标签页...',
'Filter open tabs': '筛选打开的标签页',
'Add selected': '添加选中项',
'Back': '返回',
'Edit shortcut': '编辑快捷链接',
'Close shortcut editor': '关闭快捷链接编辑器',
'Label': '标签',
'Optional shortcut label': '可选的快捷链接标签',
'Shortcut icon preview and paste target': '快捷图标预览与粘贴区域',
'Shortcut icon source': '快捷图标来源',
'Website': '网站',
'Emoji': 'Emoji',
'Image': '图片',
'SVG code': 'SVG 代码',
'Upload image': '上传图片',
'Paste an image with Cmd/Ctrl+V while the editor is focused.': '编辑器聚焦时可用 Cmd/Ctrl+V 粘贴图片。',
'Cancel': '取消',
'Save': '保存',
'Back to top': '返回顶部',
'Desk settings': '桌面设置',
'Desk settings panel': '桌面设置面板',
'Desk palette': '桌面配色',
'Desk backdrop': '桌面背景',
'Clear': '清除',
'Surface depth': '透明度',
'Text size': '字体大小',
'Shortcut size': '快捷链接大小',
'Pinned order': '已固定顺序',
'Pin order': '固定顺序',
'Move to group': '移动到分组',
'No groups yet': '还没有分组',
'+ New group': '+ 新建分组',
'Back to original group': '移回原始分组',
'Close this tab': '关闭此标签页',
'Close group': '关闭分组',
'Close extras': '关闭多余标签',
'You have': '你当前有',
'Tab Harbor tabs open. Keep just this one?': '个 Tab Harbor 标签页已打开。仅保留当前这个?',
'Drag to reorder tab': '拖拽重排标签页',
'Drag to reorder todo': '拖拽重排待办',
'Edit todo': '编辑待办',
'Delete todo': '删除待办',
'Delete archived todo': '删除已归档待办',
'Archive': '归档',
'Add link': '添加链接',
'Add quick tab': '添加快捷链接',
'Edit quick tab': '编辑快捷链接',
'Remove quick tab': '删除快捷链接',
'Sync Chrome tab groups': '同步Chrome标签组',
'Homepages': '主页',
};
function i18nFormat(template, vars = {}) {
return String(template || '').replace(/\{(\w+)\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : `{${key}}`;
});
}
function i18nT(key, vars = {}) {
const bundle = i18nMessages[i18nLocale] || i18nMessages.en;
const fallback = i18nMessages.en || {};
const template = Object.prototype.hasOwnProperty.call(bundle, key)
? bundle[key]
: fallback[key];
if (template == null) return key;
return i18nFormat(template, vars);
}
function i18nLocalizeText(input) {
const text = String(input == null ? '' : input);
if (i18nLocale !== 'zh-CN') return text;
if (!text.trim()) return text;
const leading = text.match(/^\s*/)?.[0] || '';
const trailing = text.match(/\s*$/)?.[0] || '';
const core = text.trim();
if (Object.prototype.hasOwnProperty.call(i18nTextMapZh, core)) {
return `${leading}${i18nTextMapZh[core]}${trailing}`;
}
let translated = core;
translated = translated
.replace(/^\+(\d+) more$/i, (_, count) => i18nT('moreCount', { count }))
.replace(/^(\d+) tabs? open$/i, (_, count) => i18nT('tabsOpenBadge', { count }))
.replace(/^(\d+) duplicate(?:s)?$/i, (_, count) => i18nT('duplicatesCount', { count }))
.replace(/^Close (\d+) duplicate(?:s)?$/i, (_, count) => i18nT('closedDuplicatesCount', { count }))
.replace(/^Created (.+)$/i, (_, rest) => `创建于 ${rest}`);
if (translated !== core) {
return `${leading}${translated}${trailing}`;
}
return text;
}
function i18nApplyToElement(el) {
if (!(el instanceof HTMLElement)) return;
if (el.dataset.i18n) {
const translated = i18nT(el.dataset.i18n);
if (el.textContent !== translated) {
el.textContent = translated;
}
}
if (el.dataset.i18nPlaceholder) {
const translated = i18nT(el.dataset.i18nPlaceholder);
if (el.getAttribute('placeholder') !== translated) {
el.setAttribute('placeholder', translated);
}
}
if (el.dataset.i18nAriaLabel) {
const translated = i18nT(el.dataset.i18nAriaLabel);
if (el.getAttribute('aria-label') !== translated) {
el.setAttribute('aria-label', translated);
}
}
if (el.dataset.i18nTitle) {
const translated = i18nT(el.dataset.i18nTitle);
if (el.getAttribute('title') !== translated) {
el.setAttribute('title', translated);
}
}
if (el.dataset.i18nTooltip) {
const translated = i18nT(el.dataset.i18nTooltip);
if (el.getAttribute('data-tooltip') !== translated) {
el.setAttribute('data-tooltip', translated);
}
}
const attrNames = ['aria-label', 'title', 'placeholder', 'data-tooltip'];
attrNames.forEach(attr => {
const current = el.getAttribute(attr);
if (!current) return;
const localized = i18nLocalizeText(current);
if (localized !== current) {
el.setAttribute(attr, localized);
}
});
}
function i18nLocalizeTextNodes(rootNode) {
if (i18nLocale !== 'zh-CN') return;
const root = rootNode || document.body;
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes = [];
let current = walker.nextNode();
while (current) {
nodes.push(current);
current = walker.nextNode();
}
nodes.forEach(textNode => {
const nextValue = i18nLocalizeText(textNode.nodeValue || '');
if (nextValue !== textNode.nodeValue) {
textNode.nodeValue = nextValue;
}
});
}
function i18nApplyDomTranslations(rootNode = document) {
const root = rootNode && rootNode.querySelectorAll ? rootNode : document;
const candidates = root.querySelectorAll
? root.querySelectorAll('[data-i18n],[data-i18n-placeholder],[data-i18n-aria-label],[data-i18n-title],[data-i18n-tooltip], [aria-label], [title], [placeholder], [data-tooltip]')
: [];
candidates.forEach(i18nApplyToElement);
if (root instanceof HTMLElement || root === document || root === document.body) {
i18nLocalizeTextNodes(root === document ? document.body : root);
}
if (document?.documentElement) {
document.documentElement.lang = i18nLocale === 'zh-CN' ? 'zh-CN' : 'en';
}
}
async function loadLanguagePreference() {
if (!globalScope.chrome?.storage?.local?.get) {
i18nLanguagePreference = 'auto';
i18nLocale = resolveLocaleByPreference(i18nLanguagePreference);
return i18nLanguagePreference;
}
try {
const stored = await globalScope.chrome.storage.local.get(LANGUAGE_PREFERENCE_KEY);
i18nLanguagePreference = normalizeLanguagePreference(stored[LANGUAGE_PREFERENCE_KEY]);
i18nLocale = resolveLocaleByPreference(i18nLanguagePreference);
return i18nLanguagePreference;
} catch {
i18nLanguagePreference = 'auto';
i18nLocale = resolveLocaleByPreference(i18nLanguagePreference);
return i18nLanguagePreference;
}
}
async function setLanguagePreference(preference, { persist = true, reload = false } = {}) {
i18nLanguagePreference = normalizeLanguagePreference(preference);
i18nLocale = resolveLocaleByPreference(i18nLanguagePreference);
if (persist && globalScope.chrome?.storage?.local?.set) {
await globalScope.chrome.storage.local.set({
[LANGUAGE_PREFERENCE_KEY]: i18nLanguagePreference,
});
}
if (reload) {
globalScope.location?.reload?.();
return i18nLanguagePreference;
}
i18nApplyDomTranslations(document);
return i18nLanguagePreference;
}
function getLanguagePreference() {
return i18nLanguagePreference;
}
function i18nObserve() {
if (!document?.body || i18nLocale !== 'zh-CN') return;
let scheduled = false;
const scheduleApply = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
i18nApplyDomTranslations(document.body);
});
};
const observer = new MutationObserver(scheduleApply);
observer.observe(document.body, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['aria-label', 'title', 'placeholder', 'data-tooltip'],
});
}
const waitForDomReady = () => {
if (document.readyState !== 'loading') return Promise.resolve();
return new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
});
};
const i18nReady = (async () => {
await loadLanguagePreference();
await waitForDomReady();
i18nApplyDomTranslations(document);
i18nObserve();
})();
globalScope.TabHarborI18n = {
get locale() {
return i18nLocale;
},
get isZh() {
return i18nLocale === 'zh-CN';
},
t: i18nT,
localizeText: i18nLocalizeText,
applyDomTranslations: i18nApplyDomTranslations,
getLanguagePreference,
setLanguagePreference,
ready: i18nReady,
};
})(typeof globalThis !== 'undefined' ? globalThis : window);
+230
View File
@@ -0,0 +1,230 @@
'use strict';
(function attachIconUtils(globalScope) {
const MULTI_PART_SUFFIXES = new Set([
'ac.cn',
'ac.jp',
'ac.uk',
'co.jp',
'co.nz',
'co.uk',
'com.au',
'com.br',
'com.cn',
'com.hk',
'com.sg',
'edu.cn',
'gov.cn',
'gov.uk',
'net.au',
'net.cn',
'org.au',
'org.cn',
'org.uk',
]);
const MULTI_TENANT_HOST_SUFFIXES = [
'blogspot.com',
'github.io',
'notion.site',
'substack.com',
'vercel.app',
'wordpress.com',
];
function getHostname(url) {
if (!url) return '';
try {
return new URL(url).hostname;
} catch {
return '';
}
}
function getPageOriginFaviconUrl(url = '') {
if (!url) return '';
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return `${parsed.origin}/favicon.ico`;
} catch {
return '';
}
}
function isIpAddress(hostname = '') {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(String(hostname || ''));
}
function getPrimaryDomain(hostname = '') {
const cleanHostname = String(hostname || '').trim().replace(/^www\./, '').toLowerCase();
if (!cleanHostname || cleanHostname === 'localhost' || isIpAddress(cleanHostname)) return cleanHostname;
if (MULTI_TENANT_HOST_SUFFIXES.some(suffix => cleanHostname.endsWith(`.${suffix}`))) {
return cleanHostname;
}
const parts = cleanHostname.split('.').filter(Boolean);
if (parts.length <= 2) return cleanHostname;
const trailingPair = parts.slice(-2).join('.');
if (MULTI_PART_SUFFIXES.has(trailingPair) && parts.length >= 3) {
return parts.slice(-3).join('.');
}
return parts.slice(-2).join('.');
}
function getGoogleFaviconUrl(hostname, size = 16) {
if (!hostname) return '';
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=${size}`;
}
function getFaviconUrl(input) {
const rawUrl = typeof input === 'string' ? input : (input?.domain ?? '');
if (!rawUrl) return { url: '', source: '', fallback: '' };
const size = typeof input === 'string' ? 128 : (input?.size ?? 128);
let protocol, hostname, pageUrl, fallbackOrigin;
try {
const parsed = new URL(rawUrl);
protocol = parsed.protocol;
hostname = parsed.hostname;
pageUrl = rawUrl;
fallbackOrigin = parsed.origin;
} catch {
if (/^[a-zA-Z0-9.-]+$/.test(rawUrl) && rawUrl.includes('.')) {
protocol = 'https:';
hostname = rawUrl;
pageUrl = `https://${rawUrl}`;
fallbackOrigin = pageUrl;
} else {
return { url: '', source: '', fallback: '' };
}
}
if (protocol !== 'http:' && protocol !== 'https:') {
return { url: '', source: '', fallback: '' };
}
const faviconBase = (typeof chrome !== 'undefined' && chrome.runtime?.getURL)
? chrome.runtime.getURL('_favicon/')
: '';
const chromeUrl = faviconBase
? `${faviconBase}?pageUrl=${encodeURIComponent(pageUrl)}&size=${size}`
: '';
const fallbackUrl = fallbackOrigin ? `${fallbackOrigin}/favicon.ico` : '';
return {
url: chromeUrl,
source: chromeUrl ? 'chrome' : '',
fallback: fallbackUrl,
};
}
function dedupeIconSources(sources = []) {
const seen = new Set();
return sources.filter(source => {
const normalized = String(source || '').trim();
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
}
function isStableIconUrl(url = '') {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'data:';
} catch {
return false;
}
}
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function escapeHtmlAttribute(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function getFallbackLabel(label, hostname = '') {
const cleanLabel = (label || '').trim();
if (cleanLabel) {
const tokens = cleanLabel
.split(/[\s./:_-]+/)
.filter(Boolean)
.slice(0, 2)
.map(token => token[0]?.toUpperCase() || '');
const joined = tokens.join('');
if (joined) return joined;
}
const cleanHost = hostname.replace(/^www\./, '');
return (cleanHost.slice(0, 2) || '?').toUpperCase();
}
function getIconSources({ favIconUrl = '', url = '' } = {}, size = 16) {
const hostname = getHostname(url);
const primaryDomain = getPrimaryDomain(hostname);
const faviconData = getFaviconUrl({ domain: url, size });
const originFaviconUrl = faviconData.fallback || getPageOriginFaviconUrl(url);
const sources = dedupeIconSources([
isStableIconUrl(favIconUrl) ? favIconUrl : '',
faviconData.url,
originFaviconUrl,
primaryDomain ? getGoogleFaviconUrl(primaryDomain, size) : '',
hostname && hostname !== primaryDomain ? getGoogleFaviconUrl(hostname, size) : '',
]);
return {
hostname,
primaryDomain,
originFaviconUrl,
sources,
};
}
function getGroupIcon(group, label, size = 32) {
const tabs = group?.tabs || [];
const preferredTab = tabs.find(tab => isStableIconUrl(tab?.favIconUrl)) || tabs.find(tab => tab?.url) || tabs[0] || {};
const { hostname, sources } = getIconSources(preferredTab, size);
return {
hostname,
src: sources[0] || '',
fallbackSrc: sources[1] || '',
fallbackSources: sources.slice(1),
fallbackLabel: getFallbackLabel(label, hostname),
};
}
const api = {
escapeHtml,
escapeHtmlAttribute,
getFallbackLabel,
getPageOriginFaviconUrl,
getGoogleFaviconUrl,
getGroupIcon,
getHostname,
getPrimaryDomain,
getFaviconUrl,
getIconSources,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutIconUtils = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+191
View File
@@ -0,0 +1,191 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
escapeHtmlAttribute,
getFallbackLabel,
getPageOriginFaviconUrl,
getGoogleFaviconUrl,
getGroupIcon,
getPrimaryDomain,
getIconSources,
getFaviconUrl,
} = require('./icon-utils.js');
test('getIconSources prefers real favicon before chrome, origin, and Google fallbacks', () => {
const savedChrome = globalThis.chrome;
globalThis.chrome = {
runtime: {
getURL: (path) => `chrome-extension://abc/${path}`,
},
};
try {
const iconData = getIconSources({
favIconUrl: 'https://example.com/favicon.ico',
url: 'https://www.example.com/page',
}, 32);
assert.equal(iconData.hostname, 'www.example.com');
assert.equal(iconData.primaryDomain, 'example.com');
assert.deepEqual(iconData.sources, [
'https://example.com/favicon.ico',
'chrome-extension://abc/_favicon/?pageUrl=https%3A%2F%2Fwww.example.com%2Fpage&size=32',
'https://www.example.com/favicon.ico',
'https://www.google.com/s2/favicons?domain=example.com&sz=32',
'https://www.google.com/s2/favicons?domain=www.example.com&sz=32',
]);
} finally {
if (savedChrome === undefined) {
delete globalThis.chrome;
} else {
globalThis.chrome = savedChrome;
}
}
});
test('getGroupIcon falls back through origin and Google sources when tab has no real favicon', () => {
const iconData = getGroupIcon({
tabs: [{ url: 'https://chatgpt.com/c/test' }],
}, 'ChatGPT', 32);
assert.equal(iconData.src, 'https://chatgpt.com/favicon.ico');
assert.equal(iconData.fallbackSrc, 'https://www.google.com/s2/favicons?domain=chatgpt.com&sz=32');
assert.deepEqual(iconData.fallbackSources, [
'https://www.google.com/s2/favicons?domain=chatgpt.com&sz=32',
]);
assert.equal(iconData.fallbackLabel, 'C');
});
test('getFallbackLabel derives stable initials from labels and hosts', () => {
assert.equal(getFallbackLabel('GitHub Issues', 'github.com'), 'GI');
assert.equal(getFallbackLabel('', 'www.wikipedia.org'), 'WI');
assert.equal(getGoogleFaviconUrl('github.com', 16), 'https://www.google.com/s2/favicons?domain=github.com&sz=16');
assert.equal(getPageOriginFaviconUrl('https://www.ict.ac.cn/yjsjy/zsxx/sszs/202605/t20260511_8199682.html'), 'https://www.ict.ac.cn/favicon.ico');
});
test('getIconSources uses chrome, origin, and Google fallback chain when no real favicon is available', () => {
const savedChrome = globalThis.chrome;
globalThis.chrome = {
runtime: {
getURL: (path) => `chrome-extension://abc/${path}`,
},
};
try {
const iconData = getIconSources({
url: 'https://www.example.com/page',
}, 32);
assert.deepEqual(iconData.sources, [
'chrome-extension://abc/_favicon/?pageUrl=https%3A%2F%2Fwww.example.com%2Fpage&size=32',
'https://www.example.com/favicon.ico',
'https://www.google.com/s2/favicons?domain=example.com&sz=32',
'https://www.google.com/s2/favicons?domain=www.example.com&sz=32',
]);
} finally {
if (savedChrome === undefined) {
delete globalThis.chrome;
} else {
globalThis.chrome = savedChrome;
}
}
});
test('getPrimaryDomain collapses regular subdomains but preserves common hosted tenants', () => {
assert.equal(getPrimaryDomain('foo.example.com'), 'example.com');
assert.equal(getPrimaryDomain('foo.bar.example.co.uk'), 'example.co.uk');
assert.equal(getPrimaryDomain('ict.ac.cn'), 'ict.ac.cn');
assert.equal(getPrimaryDomain('myproject.github.io'), 'myproject.github.io');
assert.equal(getPrimaryDomain('writer.substack.com'), 'writer.substack.com');
});
test('getIconSources skips unstable browser internal favicon urls but keeps Google fallback URLs', () => {
const iconData = getIconSources({
favIconUrl: 'chrome://favicon2/?pageUrl=https%3A%2F%2Fwww.ict.ac.cn',
url: 'https://www.ict.ac.cn/yjsjy/zsxx/sszs/202605/t20260511_8199682.html',
}, 32);
assert.deepEqual(iconData.sources, [
'https://www.ict.ac.cn/favicon.ico',
'https://www.google.com/s2/favicons?domain=ict.ac.cn&sz=32',
'https://www.google.com/s2/favicons?domain=www.ict.ac.cn&sz=32',
]);
});
test('getFaviconUrl builds fallback URL from URL string input (no chrome runtime)', () => {
const result = getFaviconUrl('https://www.example.com/page');
assert.equal(result.source, '');
assert.equal(result.url, '');
assert.equal(result.fallback, 'https://www.example.com/favicon.ico');
});
test('getFaviconUrl returns empty result for empty input', () => {
const result = getFaviconUrl({});
assert.equal(result.url, '');
assert.equal(result.source, '');
assert.equal(result.fallback, '');
});
test('getFaviconUrl returns empty result for non-HTTP URL', () => {
const result = getFaviconUrl('chrome://extensions/');
assert.equal(result.url, '');
assert.equal(result.source, '');
assert.equal(result.fallback, '');
});
test('getFaviconUrl builds fallback URL from options object', () => {
const result = getFaviconUrl({ domain: 'github.com', size: 64 });
assert.equal(result.fallback, 'https://github.com/favicon.ico');
});
test('getFaviconUrl preserves http protocol and localhost port in fallback URL', () => {
const result = getFaviconUrl('http://localhost:3000/app');
assert.equal(result.fallback, 'http://localhost:3000/favicon.ico');
});
test('getFaviconUrl preserves custom https port in fallback URL', () => {
const result = getFaviconUrl('https://example.com:8443/path');
assert.equal(result.fallback, 'https://example.com:8443/favicon.ico');
});
test('getFaviconUrl defaults size to 128 for string input', () => {
const result = getFaviconUrl('https://example.com');
assert.ok(result.url === '' || result.url.includes('size=128'),
'Chrome URL should use size=128 when available');
});
test('getFaviconUrl respects custom size in options', () => {
const result = getFaviconUrl({ domain: 'example.com', size: 32 });
assert.ok(result.url === '' || result.url.includes('size=32'),
'Chrome URL should use custom size when available');
});
test('getFaviconUrl generates chrome _favicon URL when chrome runtime is available', () => {
const savedGetURL = globalThis.chrome?.runtime?.getURL;
globalThis.chrome = {
runtime: {
getURL: (path) => `chrome-extension://abc/${path}`,
},
};
try {
const result = getFaviconUrl({ domain: 'https://example.com/path', size: 32 });
assert.ok(result.url.startsWith('chrome-extension://abc/_favicon/'), 'Chrome URL should use _favicon path');
assert.ok(result.url.includes('pageUrl='), 'Chrome URL should include pageUrl param');
assert.ok(result.url.includes('size=32'), 'Chrome URL should include size param');
assert.equal(result.fallback, 'https://example.com/favicon.ico');
} finally {
if (savedGetURL) {
globalThis.chrome = { runtime: { getURL: savedGetURL } };
} else {
delete globalThis.chrome;
}
}
});
test('escapeHtmlAttribute protects custom tooltip text', () => {
assert.equal(
escapeHtmlAttribute('ChatGPT "Projects" & Notes'),
'ChatGPT &quot;Projects&quot; &amp; Notes'
);
});
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" role="img" aria-labelledby="title desc">
<title id="title">Tab Harbor icon</title>
<desc id="desc">A Google-inspired four-color roundel with stacked tabs at the center.</desc>
<defs>
<clipPath id="icon-clip">
<circle cx="64" cy="64" r="56"/>
</clipPath>
<filter id="center-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#1f2a3a" flood-opacity="0.16"/>
</filter>
</defs>
<circle cx="64" cy="64" r="58" fill="#ffffff"/>
<circle cx="64" cy="64" r="56" fill="#f6f8fc" stroke="#dbe3ee" stroke-width="2"/>
<g clip-path="url(#icon-clip)">
<circle cx="38" cy="38" r="40" fill="#ea4335"/>
<circle cx="92" cy="38" r="40" fill="#fbbc04"/>
<circle cx="92" cy="90" r="40" fill="#34a853"/>
<circle cx="36" cy="92" r="40" fill="#4285f4"/>
</g>
<circle cx="64" cy="64" r="27" fill="#ffffff" filter="url(#center-shadow)"/>
<circle cx="64" cy="64" r="26" fill="#ffffff" stroke="#d8e0ee" stroke-width="1.5"/>
<g transform="translate(64 64)">
<rect x="-18" y="-11" width="22" height="16" rx="4" fill="#cfdcf3" stroke="#3a4a62" stroke-width="2.2"/>
<path d="M-14 -11h8l3 4h-11a3 3 0 0 0-3 3v-3a4 4 0 0 1 4-4Z" fill="#e8effb"/>
<rect x="-4" y="-3" width="22" height="16" rx="4" fill="#3a4a62"/>
<path d="M0 -3h8l3 4H0a4 4 0 0 0-4 4V1a4 4 0 0 1 4-4Z" fill="#55657d"/>
<rect x="2" y="1" width="10" height="2.5" rx="1.25" fill="#ffffff" opacity="0.96"/>
<rect x="2" y="6" width="14" height="2.5" rx="1.25" fill="#dce7f8" opacity="0.92"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

+386
View File
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Harbor</title>
<!-- Google Fonts: Libre Caslon Display (editorial display) + Public Sans (UI body) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link
href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Display&family=Public+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<div class="group-nav group-nav-wide" id="workspaceTopNav" style="display:none"></div>
<main class="workspace-page is-active" id="homePage" data-workspace-page="home">
<!-- ================================================================
TAB HARBOR DUPES BANNER — appears when you have extra Tab Harbor tabs open
Hidden by default; app.js shows it when dupeTabOutCount > 1
================================================================ -->
<div class="tab-cleanup-banner" id="tabOutDupeBanner" style="display:none">
<div class="tab-cleanup-left">
<div class="tab-cleanup-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
</div>
<div class="tab-cleanup-text">
You have <strong id="tabOutDupeCount"></strong> Tab Harbor tabs open. Keep just this one?
</div>
</div>
<button class="tab-cleanup-btn" data-action="close-tabout-dupes">Close extras</button>
</div>
<!-- ================================================================
MAIN CONTENT AREA — left column for open tabs, right rail for
greeting/date + drawer controls
================================================================ -->
<div class="dashboard-columns" id="dashboardColumns">
<!-- LEFT COLUMN: Open tabs -->
<div class="active-section" id="openTabsSection" style="display:none">
<div class="section-header">
<h2 id="openTabsSectionTitle">Right now</h2>
<div class="section-line"></div>
<div class="section-count" id="openTabsSectionCount"></div>
</div>
<div class="missions" id="openTabsMissions"></div>
</div>
<!-- RIGHT RAIL: greeting + date -->
<header class="page-header">
<div class="header-left">
<div class="header-title-row">
<!-- "Good afternoon" — written by getGreeting() in app.js -->
<h1 id="greeting"></h1>
<!-- "Friday, April 4, 2026" — written by getDateDisplay() in app.js -->
<div class="date" id="dateDisplay"></div>
</div>
<div class="hitokoto" id="hitokoto" style="display:none" aria-live="polite" role="note">
<span class="hitokoto-text" id="hitokotoText"></span>
<span class="hitokoto-from" id="hitokotoFrom"></span>
</div>
<form class="header-search" id="headerSearchForm">
<label class="header-search-label" for="headerSearchInput">Search the web</label>
<div class="header-search-shell">
<svg class="header-search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-4.35-4.35m1.85-5.15a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
</svg>
<input class="header-search-input" id="headerSearchInput" type="search" name="q"
placeholder="Search with your default engine..." autocomplete="off" spellcheck="false">
</div>
</form>
<section class="quick-tabs" id="quickTabsSection">
<div class="quick-tabs-grid" id="quickTabsList"></div>
</section>
</div>
</header>
</div><!-- end .dashboard-columns -->
<!-- ================================================================
FOOTER — global stats + refresh control
================================================================ -->
<footer>
<div class="footer-stats">
<div class="stat">
<div class="stat-num" id="statTabs"></div>
<div class="stat-label">Open tabs</div>
</div>
</div>
<div class="last-refresh">
<span class="footer-credit"><a class="footer-credit-link" href="https://github.com/V-IOLE-T/tab-harbor"
target="_top">Tab Harbor</a> by <a class="footer-credit-link" href="https://github.com/V-IOLE-T"
target="_top">OO</a></span>
</div>
</footer>
</main>
<main class="workspace-page" id="savedTabsPage" data-workspace-page="saved-tabs" hidden>
<div class="dashboard-columns saved-tabs-columns">
<section class="active-section saved-sessions-section">
<div class="section-header">
<h2>Saved sessions</h2>
<div class="section-line"></div>
<div class="section-count" id="savedSessionsCount"></div>
</div>
<div class="saved-sessions-list" id="savedSessionsList"></div>
<div class="saved-sessions-empty" id="savedSessionsEmpty" hidden>
No saved sessions yet. Save a window when you want the memory back without losing the trail.
</div>
</section>
<div class="page-header saved-tabs-header-spacer" aria-hidden="true"></div>
</div>
</main>
<div class="drawer-trigger-stack" id="drawerTriggerStack">
<button class="deferred-trigger todo-trigger" id="todoTrigger" type="button" aria-expanded="false"
aria-controls="drawerColumn" aria-label="Open todos">
<svg class="deferred-trigger-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="none"
aria-hidden="true">
<path
d="M288.384 173.488a94.208 94.208 0 0 1 93.392-81.488H646.24a94.208 94.208 0 0 1 93.376 81.488h62.176A94.16 94.16 0 0 1 896 267.568V584a32 32 0 1 1-64 0V267.584c0-16.56-13.472-30.08-30.224-30.08H738.24a94.24 94.24 0 0 1-92.016 73.712H381.76a94.24 94.24 0 0 1-92-73.728h-63.552a30.16 30.16 0 0 0-30.224 30.08V841.92c0 16.56 13.472 30.096 30.224 30.096H516a32 32 0 1 1 0 64H226.224a94.16 94.16 0 0 1-94.224-94.08V267.568c0-52.032 42.24-94.08 94.224-94.08h62.176z m93.392-17.488a30.16 30.16 0 0 0-30.224 30.08v31.04c0 16.56 13.472 30.096 30.24 30.096h264.432a30.16 30.16 0 0 0 30.224-30.08v-31.04c0-16.56-13.472-30.096-30.24-30.096H381.792z"
fill="currentColor" />
<path
d="M472 444a32 32 0 0 1 32-32h236a32 32 0 1 1 0 64H504a32 32 0 0 1-32-32zM472 668a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64h-192a32 32 0 0 1-32-32zM268 396a32 32 0 0 1 32-32h96a32 32 0 0 1 32 32v96a32 32 0 0 1-32 32h-96a32 32 0 0 1-32-32v-96z m64 32v32h32v-32h-32zM268 624a32 32 0 0 1 32-32h96a32 32 0 0 1 32 32v96a32 32 0 0 1-32 32h-96a32 32 0 0 1-32-32v-96z m64 32v32h32v-32h-32zM926.624 660.752a32 32 0 0 1 0 45.248L719.216 913.424a64 64 0 0 1-90.512 0l-85.328-85.344a32 32 0 1 1 45.248-45.248l85.328 85.328 207.424-207.408a32 32 0 0 1 45.248 0z"
fill="currentColor" />
</svg>
</button>
</div>
<div class="deferred-overlay" id="deferredOverlay" hidden></div>
<!-- RIGHT COLUMN: Drawer -->
<aside class="deferred-column" id="drawerColumn" style="display:none" aria-hidden="true" role="dialog"
aria-modal="false" aria-label="Todos" tabindex="-1">
<div class="deferred-shell">
<section class="drawer-panel is-active" id="todoPanel" role="tabpanel" aria-label="Todos" tabindex="-1">
<div class="section-header deferred-header todo-header">
<div class="drawer-title-switch">
<button class="drawer-title-btn is-active" type="button" role="tab" aria-selected="true"
aria-controls="todoPanel" data-view="todos">Todos</button>
</div>
<div class="section-line"></div>
<div class="section-count" id="todoCount">0</div>
<div class="drawer-header-actions todo-header-actions">
<button class="drawer-icon-btn" id="todoSearchToggle" type="button" data-action="toggle-todo-search"
aria-label="Search todos" aria-expanded="false" aria-controls="todoSearchWrap">
<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="m21 21-4.35-4.35m1.6-5.4a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
</svg>
</button>
<button class="drawer-icon-btn todo-plus-btn" id="todoNewBtn" type="button" data-action="create-todo"
aria-label="New todo">+</button>
<button class="drawer-icon-btn" type="button" data-action="close-drawer" aria-label="Close right drawer">
<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 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="todo-search-wrap" id="todoSearchWrap" style="display:none">
<input type="search" class="archive-search" id="todoSearchInput" placeholder="Search todos..."
aria-label="Search todos">
</div>
<div class="todo-detail-view" id="todoDetailView" style="display:none"></div>
<div class="todo-list" id="todoList"></div>
<div class="deferred-empty" id="todoEmpty" style="display:none">No tasks yet. Capture the next step when
something needs a follow-up.</div>
<div class="deferred-archive" id="todoArchive" style="display:none">
<div class="archive-header-row">
<button class="archive-toggle" id="todoArchiveToggle" type="button" aria-expanded="false"
aria-controls="todoArchiveBody">
<svg class="archive-chevron" 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="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
Archive
<span class="archive-count" id="todoArchiveCount"></span>
</button>
<button class="archive-clear-btn" id="clearTodoArchiveBtn" type="button"
data-action="clear-todo-archive">Clear archive</button>
</div>
<div class="archive-body" id="todoArchiveBody" style="display:none" hidden>
<div class="archive-list" id="todoArchiveList"></div>
</div>
</div>
</section>
</div>
</aside>
</div><!-- end .container -->
<!-- ================================================================
TOAST — floats above everything, used for action confirmations
Hidden by default via CSS (opacity:0, translateY)
================================================================ -->
<div class="toast" id="toast">
<!-- Checkmark circle icon (Heroicons outline) -->
<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 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span id="toastText"></span>
</div>
<div class="shortcut-editor-backdrop" id="shortcutEditorBackdrop" hidden></div>
<div class="shortcut-editor" id="shortcutEditor" hidden role="dialog" aria-modal="true"
aria-labelledby="shortcutEditorTitle">
<form class="shortcut-editor-form" id="shortcutEditorForm">
<div class="shortcut-editor-header">
<div class="shortcut-editor-header-main">
<button class="shortcut-editor-back" id="shortcutEditorBack" type="button"
data-action="return-to-tab-picker" aria-label="Back" hidden>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg>
</button>
<h2 class="shortcut-editor-title" id="shortcutEditorTitle">Edit shortcut</h2>
</div>
<button class="shortcut-editor-close" type="button" data-action="close-shortcut-editor"
aria-label="Close shortcut editor">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<label class="shortcut-editor-field">
<span class="shortcut-editor-label">URL</span>
<input class="shortcut-editor-input" id="shortcutEditorUrl" name="url" type="url"
placeholder="https://example.com" required>
</label>
<label class="shortcut-editor-field">
<span class="shortcut-editor-label">Label</span>
<input class="shortcut-editor-input" id="shortcutEditorLabel" name="label" type="text"
placeholder="Optional shortcut label">
</label>
<div class="shortcut-editor-section">
<div class="shortcut-editor-source-row">
<div class="shortcut-editor-preview" id="shortcutEditorPreview" tabindex="0"
aria-label="Shortcut icon preview and paste target">
<span class="shortcut-editor-preview-fallback" id="shortcutEditorPreviewFallback">A</span>
</div>
<div class="shortcut-editor-source-segments" id="shortcutEditorSource" role="tablist"
aria-label="Shortcut icon source">
<button class="shortcut-editor-source-chip" type="button" data-action="select-shortcut-source"
data-source="site" aria-pressed="true">Website</button>
<button class="shortcut-editor-source-chip" type="button" data-action="select-shortcut-source"
data-source="glyph" aria-pressed="false">Emoji</button>
<button class="shortcut-editor-source-chip" type="button" data-action="select-shortcut-source"
data-source="image" aria-pressed="false">Image</button>
<button class="shortcut-editor-source-chip" type="button" data-action="select-shortcut-source"
data-source="svg" aria-pressed="false">SVG</button>
</div>
</div>
<div class="shortcut-editor-mode-stage">
<div class="shortcut-editor-mode-group" id="shortcutEditorSiteGroup"></div>
<div class="shortcut-editor-field shortcut-editor-mode-group" id="shortcutEditorEmojiGroup">
<label class="shortcut-editor-inline-field" for="shortcutEditorEmoji">
<span class="shortcut-editor-label">Emoji</span>
<input class="shortcut-editor-input shortcut-editor-emoji-input" id="shortcutEditorEmoji" name="emoji"
type="text" inputmode="text" placeholder="✨" maxlength="4">
</label>
</div>
<div class="shortcut-editor-mode-group" id="shortcutEditorImageGroup">
<div class="shortcut-editor-actions-row">
<button class="theme-menu-action" type="button" data-action="upload-shortcut-icon">Upload image</button>
</div>
<div class="shortcut-editor-mode-note">Paste an image with Cmd/Ctrl+V while the editor is focused.</div>
</div>
<label class="shortcut-editor-field shortcut-editor-mode-group" id="shortcutEditorSvgGroup">
<span class="shortcut-editor-label">SVG code</span>
<textarea class="shortcut-editor-input shortcut-editor-textarea" id="shortcutEditorSvgCode" name="svgCode"
rows="4" placeholder="<svg ...>...</svg>"></textarea>
</label>
</div>
<input type="file" id="shortcutIconFileInput" accept="image/*" hidden>
</div>
<div class="shortcut-editor-footer">
<button class="theme-menu-action is-secondary" type="button" data-action="close-shortcut-editor">Cancel</button>
<button class="theme-menu-action" type="submit">Save</button>
</div>
</form>
</div>
<div class="tab-picker-backdrop" id="tabPickerBackdrop" hidden></div>
<div class="tab-picker" id="tabPicker" hidden role="dialog" aria-label="Add open tabs to quick shortcuts"
aria-modal="true">
<div class="tab-picker-header">
<div class="drawer-title-switch tab-picker-view-switch" id="tabPickerViewSwitch" role="tablist"
aria-label="Add shortcut views">
<button class="drawer-title-btn is-active" id="tabPickerTabsTab" type="button" role="tab"
aria-selected="true" aria-controls="tabPickerTabsPane" data-action="switch-tab-picker-view"
data-view="tabs">Add open tabs</button>
<button class="drawer-title-btn" id="tabPickerUrlTab" type="button" role="tab" aria-selected="false"
aria-controls="tabPickerUrlPane" data-action="switch-tab-picker-view" data-view="url">Add by URL</button>
</div>
<button class="tab-picker-close" type="button" data-action="close-tab-picker" aria-label="Close">
<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 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="tab-picker-search-wrap" id="tabPickerSearchWrap">
<svg class="tab-picker-search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-4.35-4.35m1.6-5.4a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
</svg>
<input class="tab-picker-search" id="tabPickerSearch" type="search" placeholder="Filter tabs..."
autocomplete="off" aria-label="Filter open tabs">
</div>
<div class="tab-picker-list" id="tabPickerList"></div>
<div class="tab-picker-editor-host" id="tabPickerEditorHost" hidden></div>
<div class="tab-picker-footer" id="tabPickerFooter" hidden>
<span class="tab-picker-footer-count" id="tabPickerFooterCount"></span>
<button class="tab-picker-footer-add" type="button" data-action="add-selected-tabs">Add selected</button>
<button class="tab-picker-footer-clear" type="button" data-action="clear-tab-picker-selection">Clear</button>
</div>
</div>
<button class="back-to-top" id="backToTopBtn" type="button" aria-label="Back to top" title="Back to top">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
</svg>
</button>
<!-- Checked-in defaults + optional local overrides -->
<script src="config.js"></script>
<script src="config-loader.js"></script>
<!-- Shared icon helpers — real favicons first, then graceful fallbacks -->
<script src="icon-utils.js"></script>
<script src="session-groups.js"></script>
<script src="group-order.js"></script>
<script src="deferred-trigger-position.js"></script>
<script src="todos-store.js"></script>
<script src="list-order.js"></script>
<script src="background-image.js"></script>
<script src="i18n.js"></script>
<script src="ui-helpers.js"></script>
<script src="tab-url-utils.js"></script>
<script src="tab-sessions.js"></script>
<script src="session-manager.js"></script>
<script src="theme-controls.js"></script>
<script src="drawer-manager.js"></script>
<script src="chrome-tab-groups-import.js"></script>
<script src="chrome-tab-groups-sync.js"></script>
<script src="dashboard-runtime.js"></script>
<!-- Main dashboard logic — loads last so the DOM is ready -->
<script src="app.js"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
'use strict';
(function attachListOrder(globalScope) {
function normalizeOrderIds(orderIds) {
if (!Array.isArray(orderIds)) return [];
return orderIds.map(id => String(id)).filter(Boolean);
}
function createReorderedIds(currentIds, draggedId, targetId, placeAfter = false) {
const ids = normalizeOrderIds(currentIds);
if (!draggedId || !targetId || draggedId === targetId) return ids;
const draggedKey = String(draggedId);
const targetKey = String(targetId);
const draggedIndex = ids.indexOf(draggedKey);
const targetIndex = ids.indexOf(targetKey);
if (draggedIndex === -1 || targetIndex === -1) return ids;
const nextIds = ids.slice();
nextIds.splice(draggedIndex, 1);
const insertIndex = placeAfter
? targetIndex > draggedIndex ? targetIndex : targetIndex + 1
: targetIndex < draggedIndex ? targetIndex : targetIndex - 1;
nextIds.splice(Math.max(0, insertIndex), 0, draggedKey);
return nextIds;
}
function reorderSubsetByIds(items, orderIds, includeItem) {
if (!Array.isArray(items)) return [];
const list = items.slice();
const shouldInclude = typeof includeItem === 'function' ? includeItem : () => true;
const subset = list.filter(shouldInclude);
const normalizedOrder = normalizeOrderIds(orderIds);
if (!subset.length || subset.length !== normalizedOrder.length) return list;
const subsetMap = new Map(subset.map(item => [String(item.id), item]));
if (subsetMap.size !== subset.length) return list;
if (normalizedOrder.some(id => !subsetMap.has(id))) return list;
let nextIndex = 0;
return list.map(item => {
if (!shouldInclude(item)) return item;
const reorderedItem = subsetMap.get(normalizedOrder[nextIndex]);
nextIndex += 1;
return reorderedItem || item;
});
}
const api = {
createReorderedIds,
normalizeOrderIds,
reorderSubsetByIds,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutListOrder = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
createReorderedIds,
reorderSubsetByIds,
} = require('./list-order.js');
test('createReorderedIds moves dragged shortcut before target shortcut', () => {
const reordered = createReorderedIds(
['shortcut-1', 'shortcut-2', 'shortcut-3'],
'shortcut-3',
'shortcut-1'
);
assert.deepEqual(reordered, ['shortcut-3', 'shortcut-1', 'shortcut-2']);
});
test('createReorderedIds can place dragged shortcut after target shortcut', () => {
const reordered = createReorderedIds(
['shortcut-1', 'shortcut-2', 'shortcut-3'],
'shortcut-1',
'shortcut-2',
true
);
assert.deepEqual(reordered, ['shortcut-2', 'shortcut-1', 'shortcut-3']);
});
test('reorderSubsetByIds reorders only the included active subset', () => {
const items = [
{ id: 'saved-1', completed: false, dismissed: false },
{ id: 'saved-2', completed: true, dismissed: false },
{ id: 'saved-3', completed: false, dismissed: false },
];
const reordered = reorderSubsetByIds(
items,
['saved-3', 'saved-1'],
item => !item.completed && !item.dismissed
);
assert.deepEqual(reordered.map(item => item.id), ['saved-3', 'saved-2', 'saved-1']);
});
test('reorderSubsetByIds leaves the list unchanged when ids are incomplete', () => {
const items = [
{ id: 'todo-1', completed: false, dismissed: false },
{ id: 'todo-2', completed: false, dismissed: false },
];
const reordered = reorderSubsetByIds(
items,
['todo-2'],
item => !item.completed && !item.dismissed
);
assert.deepEqual(reordered, items);
});
+98
View File
@@ -0,0 +1,98 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const extensionManifestPath = path.join(__dirname, 'manifest.json');
const rootManifestPath = path.join(__dirname, '..', 'manifest.json');
const extensionManifest = JSON.parse(fs.readFileSync(extensionManifestPath, 'utf8'));
const rootManifest = JSON.parse(fs.readFileSync(rootManifestPath, 'utf8'));
const PATH_PREFIX = 'extension/';
const SHARED_LITERAL_FIELDS = [
'manifest_version',
'name',
'version',
'description',
];
test('root manifest mirrors extension manifest on literal metadata fields', () => {
for (const field of SHARED_LITERAL_FIELDS) {
assert.deepEqual(
rootManifest[field],
extensionManifest[field],
`manifest field "${field}" must match between extension/manifest.json and root manifest.json`,
);
}
});
test('root manifest mirrors extension manifest on permissions array', () => {
assert.deepEqual(
rootManifest.permissions,
extensionManifest.permissions,
'permissions array must match between extension/manifest.json and root manifest.json',
);
});
test('root manifest mirrors extension manifest on icon sizes', () => {
assert.deepEqual(
Object.keys(rootManifest.icons || {}).sort(),
Object.keys(extensionManifest.icons || {}).sort(),
'icons sizes must match between extension/manifest.json and root manifest.json',
);
assert.deepEqual(
Object.keys(rootManifest.action?.default_icon || {}).sort(),
Object.keys(extensionManifest.action?.default_icon || {}).sort(),
'action.default_icon sizes must match between extension/manifest.json and root manifest.json',
);
});
test('root manifest paths prefix extension manifest paths with "extension/"', () => {
const pathPairs = [
{
label: 'chrome_url_overrides.newtab',
ext: extensionManifest.chrome_url_overrides?.newtab,
root: rootManifest.chrome_url_overrides?.newtab,
},
{
label: 'background.service_worker',
ext: extensionManifest.background?.service_worker,
root: rootManifest.background?.service_worker,
},
{
label: 'action.default_popup',
ext: extensionManifest.action?.default_popup,
root: rootManifest.action?.default_popup,
},
];
for (const size of Object.keys(extensionManifest.icons || {})) {
pathPairs.push({
label: `icons.${size}`,
ext: extensionManifest.icons[size],
root: rootManifest.icons?.[size],
});
}
for (const size of Object.keys(extensionManifest.action?.default_icon || {})) {
pathPairs.push({
label: `action.default_icon.${size}`,
ext: extensionManifest.action.default_icon[size],
root: rootManifest.action?.default_icon?.[size],
});
}
for (const { label, ext, root } of pathPairs) {
assert.equal(typeof ext, 'string', `${label} missing in extension manifest`);
assert.equal(typeof root, 'string', `${label} missing in root manifest`);
assert.equal(
root,
`${PATH_PREFIX}${ext}`,
`root manifest ${label} must equal "${PATH_PREFIX}${ext}" to stay in sync with extension manifest`,
);
}
});
+22
View File
@@ -0,0 +1,22 @@
{
"manifest_version": 3,
"name": "Tab Harbor",
"version": "1.1.1",
"description": "A new tab dashboard for organizing open tabs, quick links, todos, and saved tab sessions in one calm workspace.",
"permissions": ["tabs", "storage", "search", "clipboardRead", "tabGroups", "favicon"],
"chrome_url_overrides": { "newtab": "index.html" },
"background": { "service_worker": "background.js" },
"action": {
"default_title": "Tab Harbor",
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+593
View File
@@ -0,0 +1,593 @@
/* ============================================================
Popup — Tab Harbor Extension
Design: Calm, literary, composed — paper workspace aesthetic
============================================================ */
html, body {
width: 100%;
min-width: 380px;
max-width: 420px;
height: fit-content;
margin: 0;
padding: 0;
/* subtle paper grain */
background: var(--paper);
}
button {
font: inherit;
border: none;
background: none;
cursor: pointer;
}
body.popup-shell {
color: var(--ink);
box-shadow:
0 2px 8px var(--shadow),
0 12px 40px rgba(26, 22, 19, 0.10);
}
/* ── App Container ─────────────────────────────────────────── */
.popup-app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 14px 16px;
gap: 12px;
}
/* ── Header ────────────────────────────────────────────────── */
.popup-header {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.popup-view-switch {
flex: 1;
display: flex;
background: var(--warm-gray);
border-radius: 10px;
padding: 3px;
gap: 2px;
}
.popup-view-switch .drawer-title-btn {
flex: 1 1 0;
padding: 6px 10px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.01em;
transition: background 180ms ease, color 180ms ease, box-shadow 180ms ease;
border: 1px solid transparent;
}
.popup-view-switch .drawer-title-btn:hover {
color: var(--ink);
background: color-mix(in srgb, var(--card-bg) 55%, transparent);
}
.popup-view-switch .drawer-title-btn.is-active {
background: var(--card-bg);
color: var(--ink);
font-weight: 600;
box-shadow:
0 1px 3px var(--shadow),
0 1px 1px rgba(26, 22, 19, 0.04);
border-color: color-mix(in srgb, var(--warm-gray) 70%, transparent);
}
.popup-refresh-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--card-bg) 80%, transparent);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
transition: background 150ms ease, color 150ms ease, transform 100ms ease;
flex-shrink: 0;
}
.popup-refresh-btn:hover {
background: var(--workspace-chip-bg);
color: var(--workspace-accent);
}
.popup-refresh-btn:hover .btn-svg {
transform: rotate(90deg);
transition: all 150ms ease;
}
.popup-refresh-btn:active .btn-svg {
transform: rotate(180deg);
transition: all 150ms ease;
}
.popup-refresh-btn:active {
transform: scale(0.9);
}
/* ── Panel ──────────────────────────────────────────────────── */
.popup-panel {
display: flex;
flex-direction: column;
min-height: 0;
gap: 10px;
}
.popup-panel[hidden] {
display: none !important;
}
.popup-panel-title-row {
margin: 0 12px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.popup-panel-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.01em;
}
.popup-panel-count {
font-size: 11px;
font-weight: 600;
color: var(--muted);
background: var(--warm-gray);
padding: 2px 7px;
border-radius: 999px;
letter-spacing: 0.03em;
}
/* ── Shortcuts Grid ─────────────────────────────────────────── */
.popup-shortcuts-grid {
display: flex;
flex-wrap: wrap;
min-height: 0;
gap: 10px;
overflow-y: auto;
padding-right: 2px;
align-content: start;
}
/* Shortcut card — matches dashboard quick-shortcut-card */
.popup-shortcut-card {
position: relative;
width: 76px;
flex: 0 0 76px;
will-change: transform;
}
.popup-shortcut-card .quick-shortcut-edit,
.popup-shortcut-card .quick-shortcut-remove {
display: none;
}
.popup-shortcut-card .quick-shortcut-open {
border: none;
border-radius: 0;
background: transparent;
color: var(--ink);
min-height: 0;
padding: 0;
display: grid;
grid-template-rows: 40px auto;
justify-items: center;
align-content: start;
gap: 6px;
text-align: center;
cursor: pointer;
width: 100%;
transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), color 0.18s ease;
}
.popup-shortcut-card .quick-shortcut-open:hover {
transform: translateY(-1px);
}
.popup-shortcut-card .quick-shortcut-open:active {
transform: translateY(0) scale(0.97);
}
.popup-shortcut-card .quick-shortcut-icon-wrap {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: color-mix(in srgb, var(--card-bg) calc(var(--custom-surface-opacity, 14) + 24%), transparent);
flex-shrink: 0;
}
.popup-shortcut-card .quick-shortcut-icon {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
}
.popup-shortcut-card .quick-shortcut-icon-custom {
object-fit: contain;
}
.popup-shortcut-card .quick-shortcut-custom-glyph {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 19px;
line-height: 1;
}
.popup-shortcut-card .quick-shortcut-fallback {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--workspace-accent-soft);
color: var(--workspace-accent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
}
.popup-shortcut-card .quick-shortcut-label {
font-size: 11px;
line-height: 1.45;
color: color-mix(in srgb, var(--ink) 82%, var(--muted) 18%);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 100%;
}
/* ── Group Navigation ─────────────────────────────────────────── */
.popup-group-nav-wrap {
display: flex;
overflow-x: auto;
gap: 6px;
padding-bottom: 6px;
scrollbar-width: thin;
}
.popup-group-nav-wrap::-webkit-scrollbar {
height: 3px;
}
.popup-group-nav-wrap::-webkit-scrollbar-thumb {
background: var(--warm-gray);
border-radius: 999px;
}
.group-nav-button {
width: 40px;
height: 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);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: visible;
position: relative;
transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.18s ease, box-shadow 0.24s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.group-nav-button:hover {
background: var(--workspace-chip-bg);
color: var(--workspace-accent);
border-color: var(--workspace-accent-border);
}
.group-nav-icon {
width: 14px;
height: 14px;
border-radius: 3px;
object-fit: contain;
}
.group-nav-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: var(--workspace-accent-soft);
color: var(--workspace-chip-text);
font-size: 8px;
font-weight: 700;
border-radius: 3px;
}
/* ── Tabs List ──────────────────────────────────────────────── */
.popup-tabs-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 2px;
scrollbar-width: thin;
}
.popup-tab-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.popup-tab-group-title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
border-left: 2px solid var(--warm-gray);
padding-left: 6px;
}
.popup-tab-group-subtitle {
margin-top: -2px;
font-size: 11px;
color: var(--muted);
}
.popup-tab-group-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.popup-tab-row {
display: flex;
align-items: center;
cursor: pointer;
gap: 8px;
padding: 7px 10px;
border-radius: 10px;
background: var(--card-bg);
border: 1px solid color-mix(in srgb, var(--warm-gray) 45%, transparent);
box-shadow: 0 1px 2px var(--shadow);
transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.popup-tab-row:hover {
background: color-mix(in srgb, var(--card-bg) 88%, var(--workspace-accent-soft) 12%);
border-color: var(--workspace-accent-border);
box-shadow: 0 2px 5px var(--shadow);
}
.popup-tab-row:focus-within {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.popup-tab-favicon,
.popup-tab-favicon-fallback {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}
.popup-tab-favicon {
object-fit: contain;
}
.popup-tab-favicon-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--workspace-accent-soft);
color: var(--workspace-chip-text);
font-size: 8px;
font-weight: 700;
border-radius: 4px;
}
.popup-tab-title {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--ink);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.35;
max-height: calc(1.35em * 2);
white-space: normal;
word-break: break-word;
}
.popup-tab-close-btn {
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--muted);
border-radius: 4px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
opacity: 0.48;
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.popup-tab-close-btn svg {
width: 13px;
height: 13px;
}
.popup-tab-close-btn:hover {
opacity: 1;
color: var(--status-abandoned);
background: rgba(179, 90, 90, 0.08);
}
.popup-tab-close-btn:active {
opacity: 1;
transform: scale(0.94);
}
.popup-tab-close-btn.is-loading {
pointer-events: none;
opacity: 0.7;
}
/* ── Scrollbars ──────────────────────────────────────────────── */
.popup-shortcuts-grid::-webkit-scrollbar,
.popup-tabs-list::-webkit-scrollbar {
width: 4px;
}
.popup-shortcuts-grid::-webkit-scrollbar-thumb,
.popup-tabs-list::-webkit-scrollbar-thumb {
background: var(--warm-gray);
border-radius: 999px;
}
/* ── Empty State ─────────────────────────────────────────────── */
.popup-empty {
padding: 28px 12px;
text-align: center;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
/* ── Entrance Animations ─────────────────────────────────────── */
@keyframes popup-rise {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes popup-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes nav-rise {
from {
opacity: 0;
transform: translateY(4px) scale(0.94);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes tab-rise {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shortcut-rise {
from {
opacity: 0;
transform: scale(0.90) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Animation base classes — start invisible */
.is-entering,
.is-entering .group-nav-button,
.is-entering .popup-tab-group,
.is-entering .popup-tab-row,
.is-entering .popup-shortcut-card {
opacity: 0;
}
/* Popup shell mount */
body.popup-shell.is-ready {
animation: popup-rise 240ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Shortcuts */
.popup-shortcuts-grid.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 {
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 {
animation: popup-rise 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.popup-tabs-list.is-ready .popup-tab-row {
animation: tab-rise 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* ── Reduced Motion ──────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.popup-tab-row,
.popup-tab-close-btn,
.popup-refresh-btn,
.popup-shortcut-card .quick-shortcut-open,
.group-nav-button {
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 {
opacity: 1 !important;
animation: none !important;
transform: none !important;
}
}
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Harbor</title>
<link rel="stylesheet" href="../style.css">
<link rel="stylesheet" href="popup.css">
</head>
<body class="popup-shell">
<main class="popup-app">
<header class="popup-header">
<div class="drawer-title-switch popup-view-switch" role="tablist" aria-label="Popup views">
<button class="drawer-title-btn is-active" id="popupShortcutsTab" type="button" role="tab" aria-selected="true" aria-controls="popupShortcutsPanel" data-action="switch-popup-view" data-view="shortcuts" data-i18n="shortcutsLabel">快捷方式</button>
<button class="drawer-title-btn" id="popupTabsTab" type="button" role="tab" aria-selected="false" aria-controls="popupTabsPanel" data-action="switch-popup-view" data-view="tabs" data-i18n="openTabsSectionTitle">打开的标签页</button>
</div>
<button class="popup-refresh-btn" type="button" data-action="refresh-popup" data-i18n-aria-label="popupRefreshLabel" aria-label="Refresh popup content">
<svg class="btn-svg" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-ccw-icon lucide-refresh-ccw"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
</button>
</header>
<section class="popup-panel is-active" id="popupShortcutsPanel" role="tabpanel" aria-labelledby="popupShortcutsTab">
<div class="quick-tabs popup-shortcuts-grid" id="popupShortcutsList"></div>
<div class="popup-empty" id="popupShortcutsEmpty" data-i18n="popupShortcutsEmpty" hidden>还没有快捷方式。</div>
</section>
<section class="popup-panel" id="popupTabsPanel" role="tabpanel" aria-labelledby="popupTabsTab" hidden>
<div class="popup-group-nav group-nav popup-group-nav-wrap" id="popupGroupNav"></div>
<div class="popup-tabs-list" id="popupTabsList"></div>
<div class="popup-empty" id="popupTabsEmpty" data-i18n="popupTabsEmpty" hidden>没有可显示的网页标签页。</div>
</section>
</main>
<script src="../config.js"></script>
<script src="../config-loader.js"></script>
<script src="../icon-utils.js"></script>
<script src="../list-order.js"></script>
<script src="../ui-helpers.js"></script>
<script src="../background-image.js"></script>
<script src="../i18n.js"></script>
<script src="../theme-controls.js"></script>
<script src="../session-groups.js"></script>
<script src="../group-order.js"></script>
<script src="popup.js"></script>
</body>
</html>
+758
View File
@@ -0,0 +1,758 @@
'use strict';
const popupTheme = globalThis.TabOutThemeControls || {};
const popupIcons = globalThis.TabOutIconUtils || {};
const popupSessionGroups = globalThis.TabOutSessionGroups || {};
const popupGroupOrder = globalThis.TabOutGroupOrder || {};
const popupI18n = globalThis.TabHarborI18n || {};
const SESSION_GROUPS_KEY = 'sessionGroups';
const GROUP_ORDER_KEY = 'groupOrder';
const GROUP_TAB_ORDER_KEY = 'groupTabOrder';
const popupState = {
view: 'shortcuts',
openTabs: [],
quickShortcuts: [],
tabGroups: [],
sessionGroups: { groups: [], assignments: {} },
groupOrder: { sessionOrder: [], pinnedOrder: [], pinEnabled: false },
groupTabOrder: {},
};
// Test exposure
globalThis.popupState = popupState;
globalThis.buildPopupTabGroups = buildPopupTabGroups;
globalThis.getGroupDisplayLabel = getGroupDisplayLabel;
globalThis.escapeAttr = escapeAttr;
globalThis.getTabLabel = getTabLabel;
globalThis.isLandingPage = isLandingPage;
globalThis.matchCustomGroup = matchCustomGroup;
globalThis.renderShortcutCard = renderShortcutCard;
globalThis.renderTabGroup = renderTabGroup;
globalThis.renderGroupNav = renderGroupNav;
globalThis._resetPopupState = () => {
popupState.openTabs = [];
popupState.tabGroups = [];
popupState.sessionGroups = { groups: [], assignments: {} };
popupState.groupOrder = { sessionOrder: [], pinnedOrder: [], pinEnabled: false };
popupState.groupTabOrder = {};
popupState.quickShortcuts = [];
};
globalThis._skipLoadPopupState = false;
globalThis._popupIcons = popupIcons;
const POPUP_REFRESH_KEYS = new Set([
'quickShortcuts',
'sessionGroups',
'groupOrder',
'groupTabOrder',
'themePreferences',
'languagePreference',
]);
let popupRefreshTimer = null;
let popupRefreshInFlight = null;
let popupRefreshQueued = false;
function escapeAttr(value = '') {
return popupIcons.escapeHtmlAttribute ? popupIcons.escapeHtmlAttribute(value) : String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getTabHostname(tab) {
try {
if (tab?.url?.startsWith('file://')) return 'local-files';
return new URL(tab?.url || '').hostname || '';
} catch {
return '';
}
}
function getTabLabel(tab) {
const title = cleanTitle(
smartTitle(stripTitleNoise(tab.title || ''), tab.url || ''),
getTabHostname(tab)
);
if (title) return title;
try {
return friendlyDomain(new URL(tab.url).hostname) || tab.url;
} catch {
return tab.url || 'Tab';
}
}
function getTabOrderTokens(tab) {
const tokens = [];
if (tab?.id != null) tokens.push(String(tab.id));
if (tab?.url) tokens.push(String(tab.url));
return [...new Set(tokens.filter(Boolean))];
}
const filterTabs = popupTheme.filterRealTabs || (tabs => Array.isArray(tabs) ? tabs : []);
function getLandingPatterns() {
const base = [
{ hostname: 'mail.google.com', test: (_p, h) =>
!h.includes('#inbox/') && !h.includes('#sent/') && !h.includes('#search/') },
{ hostname: 'x.com', pathExact: ['/home'] },
{ hostname: 'www.linkedin.com', pathExact: ['/'] },
{ hostname: 'github.com', pathExact: ['/'] },
{ hostname: 'www.youtube.com', pathExact: ['/'] },
];
const local = typeof LOCAL_LANDING_PAGE_PATTERNS !== 'undefined' ? LOCAL_LANDING_PAGE_PATTERNS : [];
return [...base, ...local];
}
function isLandingPage(url) {
try {
const parsed = new URL(url);
return getLandingPatterns().some(p => {
const hostnameMatch = p.hostname
? parsed.hostname === p.hostname
: p.hostnameEndsWith
? parsed.hostname.endsWith(p.hostnameEndsWith)
: false;
if (!hostnameMatch) return false;
if (p.test) return p.test(parsed.pathname, url);
if (p.pathPrefix) return parsed.pathname.startsWith(p.pathPrefix);
if (p.pathExact) return p.pathExact.includes(parsed.pathname);
return parsed.pathname === '/';
});
} catch { return false; }
}
function getCustomGroups() {
return typeof LOCAL_CUSTOM_GROUPS !== 'undefined' ? LOCAL_CUSTOM_GROUPS : [];
}
function matchCustomGroup(url) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'file:') return null;
return getCustomGroups().find(r => {
const hostMatch = r.hostname
? parsed.hostname === r.hostname
: r.hostnameEndsWith
? parsed.hostname.endsWith(r.hostnameEndsWith)
: false;
if (!hostMatch) return false;
if (r.pathPrefix) return parsed.pathname.startsWith(r.pathPrefix);
return true;
}) || null;
} catch { return null; }
}
function normalizeGroupTabOrderState(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) return {};
return Object.fromEntries(
Object.entries(input)
.map(([groupKey, orderIds]) => [
String(groupKey),
Array.isArray(orderIds)
? [...new Set(orderIds.map(id => String(id)).filter(Boolean))]
: [],
])
.filter(([, orderIds]) => orderIds.length > 0)
);
}
function reorderGroupTabsByStoredUrls(tabs, groupKey) {
const orderIds = popupState.groupTabOrder[String(groupKey)] || [];
if (!Array.isArray(tabs) || !tabs.length || !orderIds.length) return Array.isArray(tabs) ? tabs.slice() : [];
const orderIndex = new Map(orderIds.map((id, index) => [String(id), index]));
return tabs
.map((tab, originalIndex) => {
const match = getTabOrderTokens(tab)
.map(token => orderIndex.get(token))
.find(index => Number.isInteger(index));
return {
tab,
originalIndex,
order: Number.isInteger(match) ? match : Number.MAX_SAFE_INTEGER,
};
})
.sort((a, b) => a.order - b.order || a.originalIndex - b.originalIndex)
.map(entry => entry.tab);
}
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;
});
}
async function loadPopupState() {
if (globalThis._skipLoadPopupState) return;
const shortcutsGetter = popupTheme.getQuickShortcuts;
if (typeof shortcutsGetter === 'function') {
popupState.quickShortcuts = await shortcutsGetter();
}
const [tabs, tabGroups, sgResult, goResult, groupTabOrderResult] = await Promise.all([
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),
]);
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.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)
: [];
const normalizeFn = popupSessionGroups.normalizeSessionGroups;
popupState.sessionGroups = normalizeFn ? normalizeFn(sgResult[SESSION_GROUPS_KEY]) : { groups: [], assignments: {} };
const normalizeOrderFn = popupGroupOrder.normalizeGroupOrderState;
popupState.groupOrder = normalizeOrderFn ? normalizeOrderFn(goResult[GROUP_ORDER_KEY]) : { sessionOrder: [], pinnedOrder: [], pinEnabled: false };
popupState.groupTabOrder = normalizeGroupTabOrderState(groupTabOrderResult[GROUP_TAB_ORDER_KEY]);
}
function buildPopupTabGroups() {
const { openTabs, sessionGroups, groupOrder } = popupState;
const sessionGroupMap = Object.fromEntries(
sessionGroups.groups.map(group => [
group.id,
{ domain: `__session_group__:${group.id}`, label: group.name, tabs: [], kind: 'session', manualGroupId: group.id },
])
);
const groupMap = {};
const landingTabs = [];
for (const tab of openTabs) {
const assignedGroupId = sessionGroups.assignments[String(tab.id)];
if (assignedGroupId && sessionGroupMap[assignedGroupId]) {
sessionGroupMap[assignedGroupId].tabs.push(tab);
continue;
}
if (isLandingPage(tab.url)) {
landingTabs.push(tab);
continue;
}
const customRule = matchCustomGroup(tab.url);
if (customRule) {
const key = customRule.groupKey;
if (!groupMap[key]) groupMap[key] = { domain: key, label: customRule.groupLabel, tabs: [], kind: 'custom' };
groupMap[key].tabs.push(tab);
continue;
}
let hostname;
try {
hostname = tab.url.startsWith('file://') ? 'local-files' : new URL(tab.url).hostname;
} catch {
continue;
}
if (!hostname) continue;
if (!groupMap[hostname]) groupMap[hostname] = { domain: hostname, label: hostname, tabs: [], kind: 'domain' };
groupMap[hostname].tabs.push(tab);
}
if (landingTabs.length > 0) {
groupMap['__landing-pages__'] = { domain: '__landing-pages__', label: '__landing-pages__', tabs: landingTabs, kind: 'landing' };
}
const landingHostnames = new Set(getLandingPatterns().map(p => p.hostname).filter(Boolean));
const landingSuffixes = getLandingPatterns().map(p => p.hostnameEndsWith).filter(Boolean);
function isLandingDomain(domain) {
if (landingHostnames.has(domain)) return true;
return landingSuffixes.some(s => domain.endsWith(s));
}
const sessionGroupsList = Object.values(sessionGroupMap).filter(g => g.tabs.length > 0);
const automaticGroups = Object.values(groupMap);
const sortedAutomatic = automaticGroups.sort((a, b) => {
const aIsLanding = a.domain === '__landing-pages__';
const bIsLanding = b.domain === '__landing-pages__';
if (aIsLanding !== bIsLanding) return aIsLanding ? -1 : 1;
const aIsPriority = isLandingDomain(a.domain);
const bIsPriority = isLandingDomain(b.domain);
if (aIsPriority !== bIsPriority) return aIsPriority ? -1 : 1;
return b.tabs.length - a.tabs.length;
});
const applyOrderFn = popupGroupOrder.applyGroupOrder;
const orderedManual = applyOrderFn ? applyOrderFn(sessionGroupsList, groupOrder) : sessionGroupsList;
const orderedAuto = applyOrderFn ? applyOrderFn(sortedAutomatic, groupOrder) : sortedAutomatic;
return [...orderedManual, ...orderedAuto];
}
function renderPopupShortcuts() {
const listEl = document.getElementById('popupShortcutsList');
const emptyEl = document.getElementById('popupShortcutsEmpty');
if (!listEl || !emptyEl) return;
listEl.classList.add('is-entering');
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')));
}
function renderShortcutCard(shortcut, index) {
const label = shortcut.label || shortcut.url;
const iconKind = String(shortcut.iconKind || '');
const iconData = popupIcons.getIconSources ? popupIcons.getIconSources({ url: shortcut.url }, 32) : { sources: [], hostname: '' };
const fallbackLabel = popupIcons.getFallbackLabel ? popupIcons.getFallbackLabel(label, iconData.hostname) : label.slice(0, 1).toUpperCase();
const safeUrl = escapeAttr(shortcut.url);
const safeLabel = escapeAttr(label);
const primaryIconUrl = iconKind === 'image'
? shortcut.icon
: iconKind === 'svg'
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(shortcut.icon || '')}`
: iconKind === 'glyph'
? ''
: (iconData.sources[0] || '');
const glyph = iconKind === 'glyph' ? shortcut.icon : '';
const fallbackSources = (iconKind === 'image' || iconKind === 'svg')
? iconData.sources
: iconData.sources.slice(1);
const fallbackSrc = escapeAttr(fallbackSources[0] || '');
const fallbackSrcset = fallbackSources.length > 1 ? escapeAttr(JSON.stringify(fallbackSources.slice(1))) : '';
return `
<div class="quick-shortcut-card popup-shortcut-card" style="--s:${index}">
<button class="quick-shortcut-open" type="button" data-action="open-popup-url" data-url="${safeUrl}" aria-label="${safeLabel}">
<span class="quick-shortcut-icon-wrap">
${primaryIconUrl ? `<img class="quick-shortcut-icon${iconKind === 'image' ? ' quick-shortcut-icon-custom' : ''}" src="${primaryIconUrl}" alt="" draggable="false" data-fallback-src="${fallbackSrc}"${fallbackSrcset ? ` data-fallback-srcset="${fallbackSrcset}"` : ''}>` : ''}
${glyph ? `<span class="quick-shortcut-custom-glyph" aria-hidden="true">${glyph}</span>` : ''}
<span class="quick-shortcut-fallback"${primaryIconUrl || glyph ? ' style="display:none"' : ''}>${fallbackLabel}</span>
</span>
<span class="quick-shortcut-label">${safeLabel}</span>
</button>
</div>
`;
}
function getGroupDisplayLabel(group) {
const i18n = globalThis.TabHarborI18n || {};
const t = i18n.t ? (key => i18n.t(key)) : (key => key);
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;
}
}
function renderGroupNav(group, index) {
const label = getGroupDisplayLabel(group);
const iconData = popupIcons.getGroupIcon ? popupIcons.getGroupIcon(group, label, 32) : { src: '', fallbackLabel: label.slice(0, 2).toUpperCase() };
const fallbackLabel = escapeAttr(iconData.fallbackLabel || label.slice(0, 2).toUpperCase());
const fallbackSrc = escapeAttr(iconData.fallbackSrc || '');
const fallbackSrcset = escapeAttr(JSON.stringify(iconData.fallbackSources?.slice(1) || []));
return `
<button class="group-nav-button" type="button" data-action="jump-popup-group" data-group-id="${escapeAttr(group.domain)}" aria-label="${escapeAttr(label)}" style="--s:${index}">
${iconData.src ? `<img class="group-nav-icon" src="${escapeAttr(iconData.src)}" alt="" draggable="false" data-fallback-src="${fallbackSrc}" data-fallback-srcset="${fallbackSrcset}">` : ''}
<span class="group-nav-fallback"${iconData.src ? ' style="display:none"' : ''}>${fallbackLabel}</span>
</button>
`;
}
function renderTabGroup(group, groupIndex) {
const label = getGroupDisplayLabel(group);
const rows = getOrderedUniqueTabsForGroup(group).map((tab, tabIndex) => {
const title = getTabLabel(tab);
const safeUrl = escapeAttr(tab.url || '');
const safeTitle = escapeAttr(title);
const iconData = popupIcons.getIconSources ? popupIcons.getIconSources(tab, 16) : { sources: [], hostname: '' };
const fallbackLabel = popupIcons.getFallbackLabel ? popupIcons.getFallbackLabel(title, iconData.hostname) : '?';
const fallbackSrc = escapeAttr(iconData.sources?.[1] || '');
const fallbackSrcset = iconData.sources?.length > 2 ? escapeAttr(JSON.stringify(iconData.sources.slice(2))) : '';
const closeLabel = popupI18n.t ? popupI18n.t('closeTabButton') : 'Close';
return `
<div class="popup-tab-row" style="--g:${groupIndex};--r:${tabIndex}" data-action="open-popup-url" data-url="${safeUrl}" data-tab-id="${tab.id}">
${iconData.sources?.[0] ? `<img class="popup-tab-favicon" src="${escapeAttr(iconData.sources[0])}" alt="" data-fallback-src="${fallbackSrc}"${fallbackSrcset ? ` data-fallback-srcset="${fallbackSrcset}"` : ''}>` : ''}
<span class="popup-tab-favicon-fallback"${iconData.sources?.[0] ? ' style="display:none"' : ''}>${escapeAttr(fallbackLabel)}</span>
<span class="popup-tab-title" title="${safeTitle}">${safeTitle}</span>
<button class="popup-tab-close-btn" type="button" data-action="close-popup-tab" data-tab-id="${tab.id}" aria-label="${escapeAttr(closeLabel)}" title="${escapeAttr(closeLabel)}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
`;
}).join('');
return `
<section class="popup-tab-group" data-group-id="${escapeAttr(group.domain)}" style="--s:${groupIndex}">
<h1 class="popup-tab-group-title">${escapeAttr(label)}</h1>
<div class="popup-tab-group-list">${rows}</div>
</section>
`;
}
function renderPopupTabs() {
const listEl = document.getElementById('popupTabsList');
const navEl = document.getElementById('popupGroupNav');
const emptyEl = document.getElementById('popupTabsEmpty');
if (!listEl || !navEl || !emptyEl) return;
const tabs = popupState.openTabs;
if (tabs.length === 0) {
if (navEl.innerHTML !== '') navEl.innerHTML = '';
if (listEl.innerHTML !== '') listEl.innerHTML = '';
if (emptyEl.hidden !== false) emptyEl.hidden = false;
return;
}
const groups = buildPopupTabGroups();
const newNavHtml = groups.map((g, i) => renderGroupNav(g, i)).join('');
const newListHtml = groups.map((g, i) => renderTabGroup(g, i)).join('');
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;
}
requestAnimationFrame(() => requestAnimationFrame(() => {
navEl.classList.add('is-ready');
listEl.classList.add('is-ready');
}));
}
function syncPopupView() {
const shortcutsTab = document.getElementById('popupShortcutsTab');
const tabsTab = document.getElementById('popupTabsTab');
const shortcutsPanel = document.getElementById('popupShortcutsPanel');
const tabsPanel = document.getElementById('popupTabsPanel');
const shortcutsList = document.getElementById('popupShortcutsList');
const tabsList = document.getElementById('popupTabsList');
const navEl = document.getElementById('popupGroupNav');
const isTabs = popupState.view === 'tabs';
shortcutsTab?.classList.toggle('is-active', !isTabs);
shortcutsTab?.setAttribute('aria-selected', String(!isTabs));
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');
});
if (shortcutsPanel) {
shortcutsPanel.hidden = isTabs;
shortcutsPanel.classList.toggle('is-active', !isTabs);
}
if (tabsPanel) {
tabsPanel.hidden = !isTabs;
tabsPanel.classList.toggle('is-active', isTabs);
}
// Re-trigger animation for the incoming active panel
if (!isTabs && shortcutsList) {
requestAnimationFrame(() => requestAnimationFrame(() => shortcutsList.classList.add('is-ready')));
} else if (isTabs && tabsList && navEl) {
requestAnimationFrame(() => requestAnimationFrame(() => {
tabsList.classList.add('is-ready');
navEl.classList.add('is-ready');
}));
}
}
async function openPopupUrl(url) {
if (!url) return;
const existing = await findTabByUrl(url);
if (existing) {
await chrome.tabs.update(existing.id, { active: true });
if (existing.windowId) await chrome.windows.update(existing.windowId, { focused: true });
} else {
await chrome.tabs.create({ url });
}
window.close();
}
async function findTabByUrl(url) {
try {
const tabs = await chrome.tabs.query({ url });
return tabs[0] || null;
} catch {
return null;
}
}
async function openPopupTab(tabId, fallbackUrl = '') {
if (!tabId) {
await openPopupUrl(fallbackUrl);
return;
}
let targetTab = null;
try {
targetTab = await chrome.tabs.get(tabId);
} catch {
targetTab = null;
}
if (!targetTab?.id) {
await openPopupUrl(fallbackUrl);
return;
}
let currentWindow = null;
try {
currentWindow = await chrome.windows.getCurrent();
} catch {
currentWindow = null;
}
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,
});
window.close();
return;
}
await chrome.tabs.update(targetTab.id, { active: true });
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();
}
await loadPopupState();
renderPopupShortcuts();
renderPopupTabs();
syncPopupView();
if (popupI18n.applyDomTranslations) {
popupI18n.applyDomTranslations(document.querySelector('.popup-app'));
}
if (popupTheme.syncPopupTheme) {
popupTheme.syncPopupTheme(document);
}
}
function schedulePopupRefresh(delay = 120) {
if (popupRefreshTimer) {
clearTimeout(popupRefreshTimer);
}
popupRefreshTimer = setTimeout(() => {
popupRefreshTimer = null;
void refreshPopupSafely();
}, delay);
}
async function refreshPopupSafely() {
if (popupRefreshInFlight) {
popupRefreshQueued = true;
return popupRefreshInFlight;
}
popupRefreshInFlight = (async () => {
try {
await refreshPopup();
} finally {
popupRefreshInFlight = null;
if (popupRefreshQueued) {
popupRefreshQueued = false;
schedulePopupRefresh(0);
}
}
})();
return popupRefreshInFlight;
}
function handlePopupStorageChanged(changes, areaName) {
if (areaName !== 'local') return;
const hasRelevantChange = Object.keys(changes || {}).some(key => POPUP_REFRESH_KEYS.has(key));
if (!hasRelevantChange) return;
schedulePopupRefresh();
}
function registerPopupAutoRefresh() {
const schedule = () => schedulePopupRefresh();
chrome.tabs?.onActivated?.addListener(schedule);
chrome.tabs?.onAttached?.addListener(schedule);
chrome.tabs?.onCreated?.addListener(schedule);
chrome.tabs?.onDetached?.addListener(schedule);
chrome.tabs?.onMoved?.addListener(schedule);
chrome.tabs?.onRemoved?.addListener(schedule);
chrome.tabs?.onUpdated?.addListener(schedule);
chrome.tabGroups?.onCreated?.addListener(schedule);
chrome.tabGroups?.onRemoved?.addListener(schedule);
chrome.tabGroups?.onUpdated?.addListener(schedule);
chrome.storage?.onChanged?.addListener(handlePopupStorageChanged);
chrome.runtime?.onMessage?.addListener(message => {
if (message?.action === 'tabs-changed') {
schedule();
}
});
}
function initializePopup() {
document.addEventListener('error', handlePopupGroupNavImageError, true);
registerPopupAutoRefresh();
document.addEventListener('click', async e => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'switch-popup-view') {
popupState.view = actionEl.dataset.view === 'tabs' ? 'tabs' : 'shortcuts';
syncPopupView();
return;
}
if (action === 'refresh-popup') {
await refreshPopup();
return;
}
if (action === 'close-popup-tab') {
e.preventDefault();
const tabId = Number(actionEl.dataset.tabId);
if (!tabId) return;
actionEl.classList.add('is-loading');
try {
await chrome.tabs.remove(tabId);
// Suppress auto-refresh scheduled by tabs.onRemoved to avoid double render
if (popupRefreshTimer) {
clearTimeout(popupRefreshTimer);
popupRefreshTimer = null;
}
popupRefreshQueued = false;
// Wait for any already-running refresh to settle before re-rendering
if (popupRefreshInFlight) {
try { await popupRefreshInFlight; } catch { /* swallow */ }
}
await refreshPopup();
// tabs.onActivated (from switching to a new active tab) may have
// rescheduled a refresh during our await — suppress it
if (popupRefreshTimer) {
clearTimeout(popupRefreshTimer);
popupRefreshTimer = null;
}
popupRefreshQueued = false;
} finally {
actionEl.classList.remove('is-loading');
}
return;
}
if (action === 'open-popup-url') {
e.preventDefault();
const tabId = Number(actionEl.dataset.tabId);
if (tabId) {
await openPopupTab(tabId, actionEl.dataset.url || '');
} else {
await openPopupUrl(actionEl.dataset.url || '');
}
return;
}
if (action === 'jump-popup-group') {
const groupId = actionEl.dataset.groupId || '';
const target = document.querySelector(`.popup-tab-group[data-group-id="${CSS.escape(groupId)}"]`);
target?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}
});
refreshPopupSafely()
.then(() => requestAnimationFrame(() => document.body.classList.add('is-ready')))
.catch(() => {
renderPopupShortcuts();
renderPopupTabs();
syncPopupView();
if (popupI18n.applyDomTranslations) {
popupI18n.applyDomTranslations(document.querySelector('.popup-app'));
}
document.body.classList.add('is-ready');
});
}
initializePopup();
+802
View File
@@ -0,0 +1,802 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// Mock chrome APIs used at module load time (initializePopup runs on require)
globalThis.chrome = {
tabs: { query: async () => [] },
tabGroups: { query: async () => [] },
storage: { local: { get: async () => ({}) } },
};
// Mock DOM globals before requiring popup.js
let rafCallbacks = [];
globalThis.requestAnimationFrame = fn => {
const id = Math.random();
rafCallbacks.push({ id, fn });
return id;
};
globalThis.flushRaf = () => {
const snapshot = [...rafCallbacks];
rafCallbacks = [];
snapshot.forEach(({ fn }) => fn());
};
globalThis.document = {
addEventListener: () => {},
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => ({ style: {} }),
getElementById: () => null,
};
globalThis.window = { close: () => {} };
// Mock popup.js dependencies (empty — functions should fall back safely)
globalThis.TabOutThemeControls = { filterRealTabs: tabs => Array.isArray(tabs) ? tabs : [] };
globalThis.TabOutIconUtils = {
escapeHtmlAttribute: v => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
getIconSources: () => ({ sources: [], hostname: '' }),
getFallbackLabel: (label, host) => (label || host || '').slice(0, 1).toUpperCase() || '?',
getGroupIcon: () => ({ src: '', fallbackLabel: '?' }),
};
globalThis.TabOutListOrder = {
reorderSubsetByIds: (items, orderIds, includeItem) => {
if (!Array.isArray(items)) return [];
const list = items.slice();
const shouldInclude = typeof includeItem === 'function' ? includeItem : () => true;
const subset = list.filter(shouldInclude);
const normalizedOrder = Array.isArray(orderIds) ? orderIds.map(id => String(id)).filter(Boolean) : [];
if (!subset.length || subset.length !== normalizedOrder.length) return list;
const subsetMap = new Map(subset.map(item => [String(item.id), item]));
if (subsetMap.size !== subset.length) return list;
if (normalizedOrder.some(id => !subsetMap.has(id))) return list;
let nextIndex = 0;
return list.map(item => {
if (!shouldInclude(item)) return item;
const reorderedItem = subsetMap.get(normalizedOrder[nextIndex]);
nextIndex += 1;
return reorderedItem || item;
});
},
};
globalThis.TabOutSessionGroups = { normalizeSessionGroups: v => v || { groups: [], assignments: {} } };
globalThis.TabOutGroupOrder = { normalizeGroupOrderState: v => v || { sessionOrder: [], pinnedOrder: [], pinEnabled: false } };
globalThis.TabHarborI18n = { t: key => key };
// Use the real ui-helpers.js implementations instead of inline mocks
require('../ui-helpers.js');
// Prevent LOCAL_* globals from interfering
globalThis.LOCAL_LANDING_PAGE_PATTERNS = undefined;
globalThis.LOCAL_CUSTOM_GROUPS = undefined;
require('./popup.js');
const {
escapeAttr,
getTabLabel,
isLandingPage,
matchCustomGroup,
getGroupDisplayLabel,
buildPopupTabGroups,
popupState,
renderShortcutCard,
renderTabGroup,
renderGroupNav,
} = globalThis;
function resetPopupTestState(opts = {}) {
globalThis.LOCAL_CUSTOM_GROUPS = opts.customGroups ?? [];
globalThis.LOCAL_LANDING_PAGE_PATTERNS = opts.landingPatterns ?? undefined;
// Mutate existing objects rather than replacing them — popup.js holds
// local references captured at module-load time (popupSessionGroups, popupGroupOrder).
globalThis.TabOutSessionGroups.normalizeSessionGroups = () => (opts.sessionGroups ?? { groups: [], assignments: {} });
globalThis.TabOutGroupOrder.applyGroupOrder = (list) => list;
globalThis.TabOutGroupOrder.normalizeGroupOrderState = () => (opts.groupOrder ?? { sessionOrder: [], pinnedOrder: [], pinEnabled: false });
if (typeof globalThis._resetPopupState === 'function') {
globalThis._resetPopupState();
}
}
// ---- escapeAttr ----
test('escapeAttr escapes & < > "', () => {
assert.equal(escapeAttr('A & B < C > D "quote"'), 'A &amp; B &lt; C &gt; D &quot;quote&quot;');
});
test('escapeAttr handles empty string', () => {
assert.equal(escapeAttr(''), '');
});
test('escapeAttr handles non-string input', () => {
assert.equal(escapeAttr(123), '123');
assert.equal(escapeAttr(null), 'null');
});
// ---- getTabLabel ----
test('getTabLabel returns stripped title when suffix matches domain', () => {
const tab = { title: 'My Page - example.com', url: 'https://www.example.com/page' };
assert.equal(getTabLabel(tab), 'My Page');
});
test('getTabLabel strips suffix after | when it matches friendly domain', () => {
// friendlyDomain('www.example.com') → 'Example' (strips TLD, capitalizes)
const tab = { title: 'Dashboard | Example', url: 'https://www.example.com' };
assert.equal(getTabLabel(tab), 'Dashboard');
});
test('getTabLabel strips suffix after — when it matches domain without TLD', () => {
const tab = { title: 'Welcome — example', url: 'https://example.com/home' };
assert.equal(getTabLabel(tab), 'Welcome');
});
test('getTabLabel keeps title unchanged when suffix does not match domain', () => {
const tab = { title: 'GitHub - awesome-org/awesome-repo', url: 'https://github.com' };
assert.equal(getTabLabel(tab), 'GitHub - awesome-org/awesome-repo');
});
test('getTabLabel uses URL as title when original title is empty', () => {
const tab = { title: '', url: 'https://www.example.com/path' };
assert.equal(getTabLabel(tab), 'https://www.example.com/path');
});
test('getTabLabel returns chrome:// URL when title is empty and URL is unparseable', () => {
const tab = { title: '', url: 'chrome://newtab' };
assert.equal(getTabLabel(tab), 'chrome://newtab');
});
test('getTabLabel falls back to "Tab" for missing title and url', () => {
assert.equal(getTabLabel({}), 'Tab');
assert.equal(getTabLabel({ title: '' }), 'Tab');
});
test('getTabLabel uses URL when title empty with www URL', () => {
const tab = { title: '', url: 'https://www.google.com/search' };
assert.equal(getTabLabel(tab), 'https://www.google.com/search');
});
// ---- isLandingPage ----
test('isLandingPage filters out Gmail inbox/sent/search URLs by hash', () => {
// base URL has no hash — passes
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/'), true);
// URL contains #inbox/ — filtered by hash check
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#inbox/'), false);
// URL contains #sent/ — filtered by hash check
assert.equal(isLandingPage('https://mail.google.com/mail/u/0/#sent/'), false);
});
test('isLandingPage matches x.com home timeline', () => {
assert.equal(isLandingPage('https://x.com/home'), true);
assert.equal(isLandingPage('https://x.com/explore'), false);
});
test('isLandingPage matches GitHub root', () => {
assert.equal(isLandingPage('https://github.com/'), true);
assert.equal(isLandingPage('https://github.com/user/repo'), false);
});
test('isLandingPage matches LinkedIn root', () => {
assert.equal(isLandingPage('https://www.linkedin.com/'), true);
assert.equal(isLandingPage('https://www.linkedin.com/feed/'), false);
});
test('isLandingPage matches YouTube root', () => {
assert.equal(isLandingPage('https://www.youtube.com/'), true);
assert.equal(isLandingPage('https://www.youtube.com/watch?v=abc'), false);
});
test('isLandingPage returns false for non-landing URLs', () => {
assert.equal(isLandingPage('https://github.com/notifications'), false);
assert.equal(isLandingPage('https://example.com/page'), false);
});
test('isLandingPage handles invalid URLs gracefully', () => {
assert.equal(isLandingPage(''), false);
assert.equal(isLandingPage('not-a-url'), false);
});
// ---- matchCustomGroup ----
test('matchCustomGroup matches exact hostname and returns the rule object', () => {
resetPopupTestState({
customGroups: [
{ hostname: 'github.com', pathPrefix: null, groupKey: 'gh', groupLabel: 'GitHub' },
],
});
const result = matchCustomGroup('https://github.com/user');
assert.equal(typeof result, 'object', 'should return rule object');
assert.equal(result.hostname, 'github.com');
assert.ok(matchCustomGroup('https://github.com/notifications') !== null);
assert.notEqual(matchCustomGroup('https://github.com/' + 'a'.repeat(100)), null);
assert.equal(matchCustomGroup('https://gitlab.com/'), null);
});
test('matchCustomGroup matches hostnameEndsWith', () => {
resetPopupTestState({
customGroups: [
{ hostname: null, hostnameEndsWith: '.notion.site', pathPrefix: null, groupKey: 'notion', groupLabel: 'Notion' },
],
});
assert.ok(matchCustomGroup('https://myworkspace.notion.site/') !== null, 'should match .notion.site');
assert.ok(matchCustomGroup('https://github.com/') === null, 'should not match github.com');
});
test('matchCustomGroup matches pathPrefix when specified', () => {
resetPopupTestState({
customGroups: [
{ hostname: 'github.com', pathPrefix: '/orgs/', groupKey: 'gh-org', groupLabel: 'GitHub Orgs' },
],
});
const result = matchCustomGroup('https://github.com/orgs/team');
assert.ok(result !== null, 'should match /orgs/ path');
assert.equal(result.hostname, 'github.com');
assert.equal(matchCustomGroup('https://github.com/user/repo'), null, 'should not match user path');
});
test('matchCustomGroup returns null for empty groups', () => {
resetPopupTestState();
assert.equal(matchCustomGroup('https://github.com/'), null);
});
test('matchCustomGroup handles invalid URLs gracefully', () => {
resetPopupTestState();
assert.equal(matchCustomGroup(''), null);
assert.equal(matchCustomGroup('://invalid'), null);
});
// ---- getGroupDisplayLabel ----
test('getGroupDisplayLabel returns translated labels for special kinds', () => {
globalThis.TabHarborI18n = { t: key => ({ homepagesLabel: 'Homepages', ungroupedLabel: 'Ungrouped' }[key] || key) };
assert.equal(getGroupDisplayLabel({ kind: 'landing', domain: '__landing-pages__' }), 'Homepages');
assert.equal(getGroupDisplayLabel({ kind: 'ungrouped', domain: '__ungrouped__' }), 'Ungrouped');
});
test('getGroupDisplayLabel returns group name for session kind', () => {
assert.equal(getGroupDisplayLabel({ kind: 'session', label: 'Work Tabs' }), 'Work Tabs');
});
test('getGroupDisplayLabel falls back to friendlyDomain for domain kind', () => {
assert.equal(getGroupDisplayLabel({ kind: 'domain', domain: 'www.google.com' }), 'Google');
assert.equal(getGroupDisplayLabel({ kind: 'custom', domain: 'github.com' }), 'GitHub');
});
test('getGroupDisplayLabel uses label for chrome-group kind', () => {
assert.equal(getGroupDisplayLabel({ kind: 'chrome-group', label: 'Research', domain: '__chrome_group__:1' }), 'Research');
});
test('getGroupDisplayLabel handles missing i18n by returning key', () => {
globalThis.TabHarborI18n = {};
globalThis.TabOutGroupOrder.normalizeGroupOrderState = () => ({});
assert.equal(getGroupDisplayLabel({ kind: 'ungrouped', domain: '__ungrouped__' }), 'ungroupedLabel');
});
// ---- buildPopupTabGroups integration ----
test('buildPopupTabGroups is exposed globally', () => {
assert.equal(typeof globalThis.buildPopupTabGroups, 'function');
});
test('buildPopupTabGroups groups session-assigned tabs', () => {
resetPopupTestState({ landingPatterns: [] });
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' },
};
const groups = globalThis.buildPopupTabGroups();
const sessionGroup = groups.find(g => g.kind === 'session');
assert.ok(sessionGroup, 'session group should exist');
assert.equal(sessionGroup.tabs.length, 1);
assert.equal(sessionGroup.tabs[0].id, 1);
});
test('buildPopupTabGroups groups domain tabs', () => {
resetPopupTestState();
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://github.com/user', title: 'GitHub', windowId: 1, active: false, groupId: null },
{ 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');
assert.ok(ghGroup, 'github.com group should exist');
assert.equal(ghGroup.tabs.length, 2);
const googleGroup = groups.find(g => g.domain === 'google.com');
assert.ok(googleGroup, 'google.com group should exist');
assert.equal(googleGroup.tabs.length, 1);
});
test('buildPopupTabGroups places landing pages group at top', () => {
resetPopupTestState();
globalThis.popupState.openTabs = [
{ 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');
assert.equal(groups[0].tabs.length, 2);
});
test('buildPopupTabGroups groups file:// URLs under local-files domain', () => {
resetPopupTestState();
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');
assert.ok(localGroup, 'local-files group should exist');
assert.equal(localGroup.tabs.length, 1);
assert.equal(localGroup.tabs[0].id, 1);
});
test('buildPopupTabGroups skips tabs with unparseable URLs', () => {
globalThis._skipLoadPopupState = true;
resetPopupTestState();
// Tabs with empty URLs can't be parsed — they are skipped entirely
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');
});
// ---- renderShortcutCard ----
test('renderShortcutCard renders image icon when iconKind is image', () => {
const shortcut = {
label: 'GitHub',
url: 'https://github.com',
iconKind: 'image',
icon: 'https://github.com/favicon.ico',
};
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('data-action="open-popup-url"'));
assert.ok(html.includes('data-url="https://github.com"'));
assert.ok(html.includes('quick-shortcut-icon-custom'));
assert.ok(html.includes('src="https://github.com/favicon.ico"'));
assert.ok(html.includes('<span class="quick-shortcut-label">GitHub</span>'));
});
test('renderShortcutCard renders svg icon when iconKind is svg', () => {
const shortcut = {
label: 'Dashboard',
url: 'https://dashboard.example.com',
iconKind: 'svg',
icon: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>',
};
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('data:image/svg+xml;charset=utf-8,'));
assert.ok(html.includes('quick-shortcut-icon'));
assert.ok(html.includes('quick-shortcut-label'));
});
test('renderShortcutCard renders glyph icon when iconKind is glyph', () => {
const shortcut = {
label: 'Bookmarks',
url: 'chrome://bookmarks',
iconKind: 'glyph',
icon: '★',
};
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('quick-shortcut-custom-glyph'));
assert.ok(html.includes('>★</span>'));
assert.ok(html.includes('quick-shortcut-fallback'));
});
test('renderShortcutCard renders fallback label when no icon', () => {
const shortcut = {
label: 'Example',
url: 'https://www.example.com',
};
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('quick-shortcut-fallback'));
assert.ok(!html.includes('quick-shortcut-icon"'), 'no quick-shortcut-icon img should be present');
});
test('renderShortcutCard escapes url and label in attributes', () => {
const shortcut = {
label: 'Test & "quoted" <label>',
url: 'https://example.com/path?a=1&b=2',
iconKind: '',
};
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('&amp;'));
assert.ok(html.includes('&quot;'));
assert.ok(html.includes('&lt;'));
});
// ---- renderTabGroup ----
test('renderTabGroup renders tab rows with favicon img', () => {
const group = {
domain: 'github.com',
label: 'GitHub',
kind: 'domain',
tabs: [
{ id: 1, url: 'https://github.com', title: 'GitHub Home', favIconUrl: 'https://github.com/favicon.ico' },
],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('popup-tab-group'));
assert.ok(html.includes('data-group-id="github.com"'));
assert.ok(html.includes('popup-tab-group-title'));
assert.ok(html.includes('data-action="open-popup-url"'));
assert.ok(html.includes('data-tab-id="1"'));
assert.ok(html.includes('data-action="close-popup-tab"'));
});
test('renderTabGroup renders fallback favicon when no faviconUrl', () => {
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [{ id: 2, url: 'https://example.com', title: 'Example' }],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('popup-tab-favicon-fallback'));
});
test('renderTabGroup includes fallback queue on favicon images', () => {
_restorePopupIcons();
try {
globalThis._popupIcons.getIconSources = () => ({
sources: [
'chrome-extension://abc/_favicon/?pageUrl=https%3A%2F%2Fexample.com&size=16',
'https://example.com/favicon.ico',
'https://fallback.example.com/favicon.ico',
],
hostname: 'example.com',
});
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [{ id: 3, url: 'https://example.com', title: 'Example' }],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('data-fallback-src="https://example.com/favicon.ico"'));
assert.ok(html.includes('data-fallback-srcset="[&quot;https://fallback.example.com/favicon.ico&quot;]"'));
} finally {
_restorePopupIcons();
}
});
test('renderTabGroup sets correct CSS custom properties', () => {
const group = {
domain: 'test.com',
label: 'Test',
kind: 'domain',
tabs: [
{ id: 10, url: 'https://test.com/a', title: 'A' },
{ id: 11, url: 'https://test.com/b', title: 'B' },
],
};
const html = renderTabGroup(group, 2);
assert.ok(html.includes('style="--g:2;--r:0"'));
assert.ok(html.includes('style="--g:2;--r:1"'));
});
test('renderTabGroup escapes title and url', () => {
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [{ id: 3, url: 'https://example.com?q="x"&y=1', title: 'Title & noise | Site' }],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('&amp;'));
assert.ok(html.includes('&quot;'));
});
test('renderTabGroup handles empty tabs array', () => {
const group = {
domain: 'empty.com',
label: 'Empty',
kind: 'domain',
tabs: [],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('popup-tab-group-list'));
assert.ok(!html.includes('popup-tab-row'));
});
// ---- renderGroupNav ----
// Restore _popupIcons to known state before each group of tests
const _origPopupIcons = { ...globalThis._popupIcons };
const _restorePopupIcons = () => { Object.assign(globalThis._popupIcons, _origPopupIcons); };
// ---- renderGroupNav ----
test('renderGroupNav renders button with label', () => {
_restorePopupIcons();
globalThis._popupIcons.getGroupIcon = () => ({ src: '', fallbackLabel: '?' });
const group = { domain: 'github.com', label: 'GitHub', kind: 'domain' };
const html = renderGroupNav(group, 0);
assert.ok(html.includes('group-nav-button'));
assert.ok(html.includes('data-action="jump-popup-group"'));
assert.ok(html.includes('data-group-id="github.com"'));
// getGroupDisplayLabel transforms domain kind label via friendlyDomain
assert.ok(html.includes('aria-label="GitHub"'));
assert.ok(html.includes('style="--s:0"'));
});
test('renderGroupNav renders icon img when provided', () => {
_restorePopupIcons();
globalThis._popupIcons.getGroupIcon = () => ({ src: 'https://example.com/icon.png', fallbackLabel: 'EX' });
const group = { domain: 'example.com', label: 'Example', kind: 'domain' };
const html = renderGroupNav(group, 1);
assert.ok(html.includes('group-nav-icon'));
assert.ok(html.includes('src="https://example.com/icon.png"'));
});
test('renderGroupNav uses fallback when no icon', () => {
_restorePopupIcons();
globalThis._popupIcons.getGroupIcon = () => ({ src: '', fallbackLabel: '?' });
const group = { domain: 'another.com', label: 'Another', kind: 'domain' };
const html = renderGroupNav(group, 0);
assert.ok(!html.includes('group-nav-icon'));
});
test('renderGroupNav escapes label in aria-label', () => {
_restorePopupIcons();
globalThis._popupIcons.getGroupIcon = () => ({ src: '', fallbackLabel: '?' });
// Use chrome-group kind so label is NOT run through friendlyDomain (which would drop special chars)
const group = { domain: '__chrome_group__:1', label: 'Test & "Special" <Label>', kind: 'chrome-group' };
const html = renderGroupNav(group, 0);
assert.ok(html.includes('aria-label="Test &amp; &quot;Special&quot; &lt;Label&gt;"'));
});
// ---- popupState exposure ----
test('popupState is exposed globally', () => {
assert.equal(typeof globalThis.popupState, 'object');
assert.equal(typeof globalThis.popupState.view, 'string');
assert.ok(Array.isArray(globalThis.popupState.openTabs));
assert.ok(Array.isArray(globalThis.popupState.quickShortcuts));
});
test('popupState.view defaults to shortcuts', () => {
assert.equal(globalThis.popupState.view, 'shortcuts');
});
// ---- getTabLabel: additional edge cases ----
test('getTabLabel handles file:// URLs by falling back to URL path', () => {
const tab = { title: '', url: 'file:///Users/local/doc.pdf' };
const label = getTabLabel(tab);
assert.ok(label.length > 0);
});
test('getTabLabel handles youtube watch page with URL-like title', () => {
const tab = { title: 'https://www.youtube.com/watch?v=abc', url: 'https://www.youtube.com/watch?v=abc' };
const label = getTabLabel(tab);
assert.equal(label, 'YouTube Video');
});
test('getTabLabel uses title over URL for youtube watch when title is meaningful', () => {
const tab = { title: 'Cool Video - YouTube', url: 'https://www.youtube.com/watch?v=abc' };
const label = getTabLabel(tab);
assert.ok(label.length > 0);
});
// ---- matchCustomGroup: additional edge cases ----
test('matchCustomGroup returns null for file:// URLs', () => {
globalThis.LOCAL_CUSTOM_GROUPS = [
{ hostname: 'example.com', pathPrefix: null, groupKey: 'ex', groupLabel: 'Example' },
];
assert.equal(matchCustomGroup('file:///Users/test/index.html'), null);
});
test('matchCustomGroup matches hostname without pathPrefix when pathPrefix is null', () => {
globalThis.LOCAL_CUSTOM_GROUPS = [
{ hostname: 'example.com', pathPrefix: null, groupKey: 'ex', groupLabel: 'Example' },
];
assert.ok(matchCustomGroup('https://example.com/any/path') !== null);
assert.ok(matchCustomGroup('https://example.com/') !== null);
});
// ---- buildPopupTabGroups: additional integration tests ----
test('buildPopupTabGroups groups tabs by custom group rules', () => {
resetPopupTestState({
customGroups: [
{ hostname: 'github.com', pathPrefix: null, groupKey: 'github', groupLabel: 'GitHub' },
{ hostname: 'gitlab.com', pathPrefix: null, groupKey: 'gitlab', groupLabel: 'GitLab' },
],
landingPatterns: [],
});
globalThis.popupState.openTabs = [
{ id: 1, url: 'https://github.com/user', title: 'GitHub', windowId: 1, active: false, groupId: null },
{ 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');
const gl = groups.find(g => g.kind === 'custom' && g.domain === 'gitlab');
const other = groups.find(g => g.kind === 'domain' && g.domain === 'other.com');
assert.ok(gh, 'github custom group should exist');
assert.equal(gh.tabs.length, 1);
assert.ok(gl, 'gitlab custom group should exist');
assert.equal(gl.tabs.length, 1);
assert.ok(other, 'other.com domain group should exist');
assert.equal(other.tabs.length, 1);
});
test('buildPopupTabGroups places landing pages before domain groups', () => {
resetPopupTestState();
globalThis.popupState.openTabs = [
{ 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');
const otherIdx = groups.findIndex(g => g.domain === 'other.com');
assert.ok(otherIdx > 0, 'domain group should come after landing');
});
test('buildPopupTabGroups handles custom landing page patterns', () => {
resetPopupTestState({
landingPatterns: [
{ hostname: 'news.ycombinator.com', pathExact: ['/news'] },
],
});
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');
assert.ok(landing, 'custom landing group should exist');
assert.equal(landing.tabs.length, 1);
assert.equal(landing.tabs[0].id, 1);
});
test('renderTabGroup reorders tabs by stored groupTabOrder', () => {
resetPopupTestState({ landingPatterns: [] });
globalThis.popupState.groupTabOrder = {
'example.com': ['https://example.com/a', 'https://example.com/b', 'https://example.com/c'],
};
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [
{ id: 1, url: 'https://example.com/b', title: 'B' },
{ id: 2, url: 'https://example.com/a', title: 'A' },
{ id: 3, url: 'https://example.com/c', title: 'C' },
],
};
const html = renderTabGroup(group, 0);
const idxA = html.indexOf('https://example.com/a');
const idxB = html.indexOf('https://example.com/b');
const idxC = html.indexOf('https://example.com/c');
assert.ok(idxA < idxB, 'A should appear before B');
assert.ok(idxB < idxC, 'B should appear before C');
});
test('renderTabGroup deduplicates tabs by URL', () => {
resetPopupTestState({ landingPatterns: [] });
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [
{ id: 1, url: 'https://example.com/page', title: 'First' },
{ id: 2, url: 'https://example.com/page', title: 'Duplicate' },
],
};
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');
});
// ---- isLandingPage: additional edge cases ----
test('isLandingPage matches custom landing patterns', () => {
resetPopupTestState({
landingPatterns: [
{ hostname: 'news.ycombinator.com', pathExact: ['/news'] },
],
});
assert.equal(isLandingPage('https://news.ycombinator.com/news'), true);
assert.equal(isLandingPage('https://news.ycombinator.com/item?id=123'), false);
});
test('isLandingPage handles hostnameEndsWith in patterns', () => {
resetPopupTestState({
landingPatterns: [
{ hostnameEndsWith: '.notion.site', pathExact: ['/'] },
],
});
assert.equal(isLandingPage('https://myworkspace.notion.site/'), true);
assert.equal(isLandingPage('https://myworkspace.notion.site/page'), false);
});
// ---- renderTabGroup: additional edge cases ----
test('renderTabGroup includes close button with correct data attributes', () => {
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [{ id: 5, url: 'https://example.com', title: 'Example' }],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('data-action="close-popup-tab"'));
assert.ok(html.includes('data-tab-id="5"'));
});
test('renderTabGroup renders tab title attribute for tooltip', () => {
const group = {
domain: 'example.com',
label: 'Example',
kind: 'domain',
tabs: [{ id: 1, url: 'https://example.com', title: 'Page & Title' }],
};
const html = renderTabGroup(group, 0);
assert.ok(html.includes('title="Page &amp; Title"'));
});
// ---- renderShortcutCard: additional edge cases ----
test('renderShortcutCard handles index 0 with correct CSS var', () => {
const shortcut = { label: 'Home', url: 'https://home.com' };
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('--s:0'));
});
test('renderShortcutCard uses url as label fallback', () => {
const shortcut = { url: 'https://no-label.com' };
const html = renderShortcutCard(shortcut, 0);
assert.ok(html.includes('https://no-label.com'));
});
// ---- renderGroupNav: additional edge cases ----
test('renderGroupNav includes fallback src on img', () => {
_restorePopupIcons();
globalThis._popupIcons.getGroupIcon = () => ({
src: 'https://example.com/icon.png',
fallbackLabel: 'EX',
fallbackSrc: 'https://example.com/fallback.png',
});
const group = { domain: 'example.com', label: 'Example', kind: 'domain' };
const html = renderGroupNav(group, 0);
assert.ok(html.includes('data-fallback-src="https://example.com/fallback.png"'));
});
+142
View File
@@ -0,0 +1,142 @@
'use strict';
(function attachSessionGroups(globalScope) {
const EMPTY_STATE = {
groups: [],
assignments: {},
};
function normalizeSessionGroups(input) {
const groups = Array.isArray(input?.groups)
? input.groups
.filter(group => group && group.id && group.name)
.map(group => ({
id: String(group.id),
name: String(group.name).trim(),
createdAt: group.createdAt || new Date().toISOString(),
}))
: [];
const assignments = {};
if (input?.assignments && typeof input.assignments === 'object') {
for (const [tabId, groupId] of Object.entries(input.assignments)) {
if (!groupId) continue;
assignments[String(tabId)] = String(groupId);
}
}
return {
groups,
assignments,
};
}
function addSessionGroup(state, name) {
const normalizedState = normalizeSessionGroups(state);
const cleanName = String(name || '').trim();
if (!cleanName) throw new Error('Group name is required');
const exists = normalizedState.groups.some(group => group.name.toLowerCase() === cleanName.toLowerCase());
if (exists) throw new Error('A group with that name already exists');
const group = {
id: `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: cleanName,
createdAt: new Date().toISOString(),
};
return {
state: {
...normalizedState,
groups: [...normalizedState.groups, group],
},
group,
};
}
function assignTabToSessionGroup(state, tabId, groupId) {
const normalizedState = normalizeSessionGroups(state);
const exists = normalizedState.groups.some(group => group.id === String(groupId));
if (!exists) throw new Error('Group not found');
return {
...normalizedState,
assignments: {
...normalizedState.assignments,
[String(tabId)]: String(groupId),
},
};
}
function clearTabSessionGroup(state, tabId) {
const normalizedState = normalizeSessionGroups(state);
const assignments = { ...normalizedState.assignments };
delete assignments[String(tabId)];
return {
...normalizedState,
assignments,
};
}
function renameSessionGroup(state, groupId, name) {
const normalizedState = normalizeSessionGroups(state);
const targetGroupId = String(groupId || '');
const cleanName = String(name || '').trim();
if (!targetGroupId) throw new Error('Group not found');
if (!cleanName) throw new Error('Group name is required');
const groupIndex = normalizedState.groups.findIndex(group => group.id === targetGroupId);
if (groupIndex === -1) throw new Error('Group not found');
const exists = normalizedState.groups.some(group =>
group.id !== targetGroupId && group.name.toLowerCase() === cleanName.toLowerCase()
);
if (exists) throw new Error('A group with that name already exists');
const groups = normalizedState.groups.map(group => (
group.id === targetGroupId
? { ...group, name: cleanName }
: group
));
return {
...normalizedState,
groups,
};
}
function pruneSessionGroups(state, openTabIds) {
const normalizedState = normalizeSessionGroups(state);
const openIds = new Set((openTabIds || []).map(tabId => String(tabId)));
const assignments = {};
for (const [tabId, groupId] of Object.entries(normalizedState.assignments)) {
if (openIds.has(tabId)) assignments[tabId] = groupId;
}
const activeGroupIds = new Set(Object.values(assignments));
const groups = normalizedState.groups.filter(group => activeGroupIds.has(group.id));
return {
groups,
assignments,
};
}
const api = {
addSessionGroup,
assignTabToSessionGroup,
clearTabSessionGroup,
normalizeSessionGroups,
pruneSessionGroups,
renameSessionGroup,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutSessionGroups = api;
globalScope.TabOutEmptySessionGroups = EMPTY_STATE;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+69
View File
@@ -0,0 +1,69 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
addSessionGroup,
assignTabToSessionGroup,
clearTabSessionGroup,
pruneSessionGroups,
renameSessionGroup,
} = require('./session-groups.js');
test('addSessionGroup creates a named group and keeps names unique', () => {
const initialState = { groups: [], assignments: {} };
const { state, group } = addSessionGroup(initialState, 'Work');
assert.equal(group.name, 'Work');
assert.equal(state.groups.length, 1);
assert.throws(() => addSessionGroup(state, 'Work'), /already exists/i);
});
test('assignTabToSessionGroup stores a single active assignment per tab', () => {
const initialState = {
groups: [{ id: 'g1', name: 'Work', createdAt: '2026-04-16T00:00:00.000Z' }],
assignments: {},
};
const assigned = assignTabToSessionGroup(initialState, 101, 'g1');
assert.equal(assigned.assignments['101'], 'g1');
const cleared = clearTabSessionGroup(assigned, 101);
assert.equal(cleared.assignments['101'], undefined);
});
test('pruneSessionGroups removes closed tabs and empty groups', () => {
const initialState = {
groups: [
{ id: 'g1', name: 'Work', createdAt: '2026-04-16T00:00:00.000Z' },
{ id: 'g2', name: 'Research', createdAt: '2026-04-16T00:01:00.000Z' },
],
assignments: {
'101': 'g1',
'102': 'g2',
},
};
const pruned = pruneSessionGroups(initialState, [101]);
assert.deepEqual(pruned.assignments, { '101': 'g1' });
assert.deepEqual(pruned.groups.map(group => group.id), ['g1']);
});
test('renameSessionGroup updates the target name and preserves uniqueness', () => {
const initialState = {
groups: [
{ id: 'g1', name: 'Work', createdAt: '2026-04-16T00:00:00.000Z' },
{ id: 'g2', name: 'Research', createdAt: '2026-04-16T00:01:00.000Z' },
],
assignments: {
'101': 'g1',
},
};
const renamed = renameSessionGroup(initialState, 'g1', 'Reading desk');
assert.equal(renamed.groups[0].name, 'Reading desk');
assert.equal(renamed.assignments['101'], 'g1');
assert.throws(() => renameSessionGroup(renamed, 'g1', 'Research'), /already exists/i);
});
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
SAVED_TAB_SESSION_COLLAPSED_KEY,
SAVED_TAB_SESSION_ORDER_KEY,
applySavedTabSessionOrder,
detachSavedSessionTabToNewSession,
buildSavedSessionTabToken,
buildSavedTabSessionOrder,
buildSavedTabSessionOrderWithInsertedSession,
moveSavedSessionTabsByTokens,
normalizeSavedTabSessionCollapsedState,
normalizeSavedTabSessionOrder,
parseSavedSessionTabToken,
reorderSavedSessionTabsByTokens,
} = require('./session-manager.js');
test('session manager exposes the saved session order storage key', () => {
assert.equal(SAVED_TAB_SESSION_ORDER_KEY, 'savedTabSessionOrder');
});
test('session manager exposes the saved session collapsed storage key', () => {
assert.equal(SAVED_TAB_SESSION_COLLAPSED_KEY, 'savedTabSessionCollapsedState');
});
test('normalizeSavedTabSessionOrder keeps unique non-empty ids in order', () => {
assert.deepEqual(
normalizeSavedTabSessionOrder(['session-a', '', null, 'session-b', 'session-a']),
['session-a', 'session-b']
);
});
test('normalizeSavedTabSessionCollapsedState keeps only explicit true flags for known sessions', () => {
assert.deepEqual(
normalizeSavedTabSessionCollapsedState(
{
'session-a': true,
'session-b': false,
'': true,
'session-c': 'yes',
},
[
{ id: 'session-a' },
{ id: 'session-b' },
]
),
{ 'session-a': true }
);
});
test('applySavedTabSessionOrder sorts sessions by persisted ids and appends new ones', () => {
const ordered = applySavedTabSessionOrder(
[
{ id: 'session-c', name: 'C' },
{ id: 'session-a', name: 'A' },
{ id: 'session-b', name: 'B' },
],
['session-b', 'session-a']
);
assert.deepEqual(
ordered.map(session => session.id),
['session-b', 'session-a', 'session-c']
);
});
test('buildSavedTabSessionOrder reflects the current ordered session ids', () => {
assert.deepEqual(
buildSavedTabSessionOrder([
{ id: 'session-b' },
{ id: 'session-a' },
{ id: 'session-b' },
]),
['session-b', 'session-a']
);
});
test('saved session tab tokens round-trip session id and index', () => {
const token = buildSavedSessionTabToken('session-a', 2);
assert.equal(token, 'session-a::2');
assert.deepEqual(parseSavedSessionTabToken(token), { sessionId: 'session-a', tabIndex: 2 });
});
test('reorderSavedSessionTabsByTokens reorders tabs within one session', () => {
const reordered = reorderSavedSessionTabsByTokens(
{
id: 'session-a',
tabs: [{ title: 'A' }, { title: 'B' }, { title: 'C' }],
},
['session-a::2', 'session-a::0', 'session-a::1']
);
assert.deepEqual(reordered.map(tab => tab.title), ['C', 'A', 'B']);
});
test('moveSavedSessionTabsByTokens preserves target tabs when moving across sessions', () => {
const moved = moveSavedSessionTabsByTokens({
sourceSession: {
id: 'session-a',
tabs: [{ title: 'A' }, { title: 'A2' }],
},
targetSession: {
id: 'session-b',
tabs: [{ title: 'B' }],
},
sourceOrderTokens: ['session-a::1'],
targetOrderTokens: ['session-a::0', 'session-b::0'],
draggedToken: 'session-a::0',
});
assert.deepEqual(moved.sourceTabs.map(tab => tab.title), ['A2']);
assert.deepEqual(moved.targetTabs.map(tab => tab.title), ['A', 'B']);
});
test('buildSavedTabSessionOrderWithInsertedSession inserts new sessions at the top, bottom, or before a gap target', () => {
const sessions = [
{ id: 'session-a', name: 'A' },
{ id: 'session-b', name: 'B' },
{ id: 'session-c', name: 'C' },
];
assert.deepEqual(
buildSavedTabSessionOrderWithInsertedSession(sessions, 'session-new', {
placement: 'before',
}),
['session-new', 'session-a', 'session-b', 'session-c']
);
assert.deepEqual(
buildSavedTabSessionOrderWithInsertedSession(sessions, 'session-new', {
placement: 'after',
}),
['session-a', 'session-b', 'session-c', 'session-new']
);
assert.deepEqual(
buildSavedTabSessionOrderWithInsertedSession(sessions, 'session-new', {
placement: 'before',
insertBeforeSessionId: 'session-c',
}),
['session-a', 'session-b', 'session-new', 'session-c']
);
});
test('detachSavedSessionTabToNewSession inserts a new middle session and rebuilds the source tabs/groups', () => {
const result = detachSavedSessionTabToNewSession({
sessions: [
{
id: 'session-a',
name: 'Morning',
savedAt: '2026-05-22T08:00:00.000Z',
source: 'manual',
tabs: [
{ url: 'https://a.test', title: 'A', groupKey: 'group-a', groupLabel: 'Group A' },
{ url: 'https://b.test', title: 'B', groupKey: 'group-b', groupLabel: 'Group B' },
],
groups: [
{ key: 'group-a', label: 'Group A', manualGroupId: '', tabUrls: ['https://a.test'] },
{ key: 'group-b', label: 'Group B', manualGroupId: '', tabUrls: ['https://b.test'] },
],
},
{
id: 'session-c',
name: 'Later',
savedAt: '2026-05-22T08:10:00.000Z',
source: 'manual',
tabs: [{ url: 'https://c.test', title: 'C' }],
groups: [],
},
],
draggedToken: 'session-a::1',
placement: 'before',
insertBeforeSessionId: 'session-c',
now: '2026-05-22T09:30:00.000Z',
});
assert.deepEqual(result.orderIds, [
'session-a',
result.createdSession.id,
'session-c',
]);
assert.deepEqual(result.sessions[0].tabs.map(tab => tab.url), ['https://a.test']);
assert.deepEqual(result.sessions[0].groups.map(group => group.key), ['group-a']);
assert.equal(result.createdSession.name, 'B');
assert.deepEqual(result.createdSession.tabs.map(tab => tab.url), ['https://b.test']);
});
test('detachSavedSessionTabToNewSession deletes the source session when its last tab is moved out', () => {
const result = detachSavedSessionTabToNewSession({
sessions: [
{
id: 'session-a',
name: 'Solo',
savedAt: '2026-05-22T08:00:00.000Z',
source: 'manual',
tabs: [{ url: 'https://solo.test', title: 'Solo' }],
groups: [],
},
{
id: 'session-b',
name: 'Next',
savedAt: '2026-05-22T08:10:00.000Z',
source: 'manual',
tabs: [{ url: 'https://next.test', title: 'Next' }],
groups: [],
},
],
draggedToken: 'session-a::0',
placement: 'before',
insertBeforeSessionId: 'session-b',
now: '2026-05-22T09:30:00.000Z',
});
assert.equal(result.sourceSessionRemoved, true);
assert.deepEqual(
result.sessions.map(session => session.id),
[result.createdSession.id, 'session-b']
);
});
+4769
View File
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
'use strict';
(function attachTabHarborTabSessions(globalScope) {
const urlUtils = globalScope.TabHarborTabUrlUtils || (
typeof require === 'function' ? require('./tab-url-utils.js') : {}
);
const {
getCanonicalTabUrl: sessionsGetCanonicalTabUrl,
isRestorableTabUrl: sessionsIsRestorableTabUrl,
parseSuspendedTabUrl: sessionsParseSuspendedTabUrl,
} = urlUtils;
const SAVED_TAB_SESSIONS_KEY = 'savedTabSessions';
const MANUAL_GROUP_PREFIX = '__session_group__:';
function createSessionId(now = new Date()) {
return `tab-session-${now.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeString(value = '') {
return String(value || '').trim();
}
function capitalizeLabel(value = '') {
const cleanValue = normalizeString(value);
if (!cleanValue) return '';
return cleanValue.charAt(0).toUpperCase() + cleanValue.slice(1);
}
function getHostnameFromUrl(value = '') {
try {
return normalizeString(new URL(String(value || '')).hostname || '');
} catch {
return '';
}
}
function getFriendlyHostnameLabel(hostname = '') {
const cleanHostname = normalizeString(hostname).replace(/^www\./, '');
if (!cleanHostname) return '';
if (cleanHostname === 'github.com') return 'GitHub';
if (cleanHostname === 'docs.github.com') return 'Docs';
const parts = cleanHostname.split('.').filter(Boolean);
if (parts.length >= 3) {
return parts[0]
.split(/[-_]/)
.map(capitalizeLabel)
.join(' ');
}
const base = (parts[0] || '')
.replace(/\.(co\.uk|co\.jp|com|org|net|io|co|ai|dev|app|so|me|xyz|info|us|uk)$/, '');
return base
.split(/[-_]/)
.map(capitalizeLabel)
.join(' ');
}
function stripSessionTitleNoise(title = '') {
return normalizeString(title)
.replace(/^\(\d+\+?\)\s*/, '')
.replace(/\s*\([\d,]+\+?\)\s*/g, ' ')
.replace(/\s+on X:\s*/, ': ')
.replace(/\s*\/\s*X\s*$/, '')
.trim();
}
function cleanDerivedSessionTitle(title = '', hostname = '') {
const cleanTitle = stripSessionTitleNoise(title);
if (!cleanTitle) return '';
if (cleanTitle.startsWith('http://') || cleanTitle.startsWith('https://')) return '';
const domain = normalizeString(hostname).replace(/^www\./, '');
const friendly = getFriendlyHostnameLabel(domain);
const domainWithoutSuffix = domain.replace(/\.\w+$/, '');
const separators = [' - ', ' | ', ' — ', ' · ', ' '];
for (const separator of separators) {
const separatorIndex = cleanTitle.lastIndexOf(separator);
if (separatorIndex === -1) continue;
const suffix = cleanTitle.slice(separatorIndex + separator.length).trim().toLowerCase();
if (
suffix === domain.toLowerCase() ||
suffix === friendly.toLowerCase() ||
suffix === domainWithoutSuffix.toLowerCase()
) {
const stripped = cleanTitle.slice(0, separatorIndex).trim();
if (stripped.length >= 2) return stripped;
}
}
return cleanTitle;
}
function normalizeSessionTab(input = {}) {
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(input.url || '') : normalizeString(input.url);
if (!canonicalUrl || !(sessionsIsRestorableTabUrl ? sessionsIsRestorableTabUrl(canonicalUrl) : true)) return null;
return {
url: canonicalUrl,
title: normalizeString(input.title) || canonicalUrl,
favIconUrl: normalizeString(input.favIconUrl),
groupKey: normalizeString(input.groupKey),
groupLabel: normalizeString(input.groupLabel),
manualGroupId: normalizeString(input.manualGroupId),
};
}
function normalizeSessionGroup(input = {}, validUrls = new Set()) {
const key = normalizeString(input.key);
if (!key) return null;
const tabUrls = Array.isArray(input.tabUrls)
? input.tabUrls
.map(url => sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(url) : normalizeString(url))
.filter(url => url && validUrls.has(url))
: [];
if (!tabUrls.length) return null;
return {
key,
label: normalizeString(input.label) || 'Group',
manualGroupId: normalizeString(input.manualGroupId),
tabUrls: [...new Set(tabUrls)],
};
}
function normalizeSavedTabSessions(input) {
if (!Array.isArray(input)) return [];
return input
.map(session => {
const tabs = Array.isArray(session?.tabs)
? session.tabs.map(normalizeSessionTab).filter(Boolean)
: [];
if (!tabs.length) return null;
const validUrls = new Set(tabs.map(tab => tab.url));
const groups = Array.isArray(session?.groups)
? session.groups.map(group => normalizeSessionGroup(group, validUrls)).filter(Boolean)
: [];
return {
id: normalizeString(session.id) || createSessionId(),
name: normalizeString(session.name) || 'Saved tabs',
savedAt: normalizeString(session.savedAt) || new Date().toISOString(),
source: normalizeString(session.source) || 'manual',
tabs,
groups,
};
})
.filter(Boolean);
}
function buildSessionGroupsFromTabs(tabs = []) {
const groupMap = new Map();
for (const tab of Array.isArray(tabs) ? tabs : []) {
const key = normalizeString(tab?.groupKey);
if (!key) continue;
if (!groupMap.has(key)) {
groupMap.set(key, {
key,
label: normalizeString(tab?.groupLabel) || 'Group',
manualGroupId: normalizeString(tab?.manualGroupId),
tabUrls: [],
});
}
const group = groupMap.get(key);
if (!group.tabUrls.includes(tab.url)) group.tabUrls.push(tab.url);
}
return [...groupMap.values()];
}
function createUniqueSavedTabSessionName(baseName, sessions = [], excludeSessionId = '') {
const fallbackName = normalizeString(baseName) || 'Saved tabs';
const lowerFallback = fallbackName.toLowerCase();
const takenNames = new Set(
(Array.isArray(sessions) ? sessions : [])
.filter(session => String(session?.id || '') !== String(excludeSessionId || ''))
.map(session => normalizeString(session?.name).toLowerCase())
.filter(Boolean)
);
if (!takenNames.has(lowerFallback)) return fallbackName;
let suffix = 2;
while (takenNames.has(`${lowerFallback} ${suffix}`)) suffix += 1;
return `${fallbackName} ${suffix}`;
}
function deriveSavedTabSessionName(tab = {}) {
const hostname = getHostnameFromUrl(tab?.url || '');
const title = cleanDerivedSessionTitle(tab?.title || '', hostname);
if (title) return title;
return getFriendlyHostnameLabel(hostname) || 'Saved tabs';
}
function createSavedTabSessionFromTab({
sourceSession = {},
tab = {},
existingSessions = [],
now = new Date().toISOString(),
} = {}) {
const normalizedTab = normalizeSessionTab(tab);
if (!normalizedTab) throw new Error('Dragged tab is not restorable');
const savedAt = normalizeString(now) || new Date().toISOString();
const baseName = deriveSavedTabSessionName({
...tab,
...normalizedTab,
});
return {
id: createSessionId(new Date(savedAt)),
name: createUniqueSavedTabSessionName(baseName, existingSessions),
savedAt,
source: normalizeString(sourceSession?.source) || 'manual',
tabs: [normalizedTab],
groups: buildSessionGroupsFromTabs([normalizedTab]),
};
}
function getTabId(tab = {}) {
return tab?.id == null ? '' : String(tab.id);
}
function getTabTitle(tab = {}, canonicalUrl = '') {
const suspended = sessionsParseSuspendedTabUrl
? sessionsParseSuspendedTabUrl(tab.url || '')
: { title: '' };
return normalizeString(suspended.title) || normalizeString(tab.title) || canonicalUrl;
}
function normalizeGroupLookupEntry(entry = {}) {
if (!entry || typeof entry !== 'object') return null;
const key = normalizeString(entry.key);
if (!key) return null;
return {
key,
label: normalizeString(entry.label) || 'Group',
manualGroupId: normalizeString(entry.manualGroupId),
};
}
function buildSessionSnapshot({
tabs = [],
groupLookup = new Map(),
selectedTabIds = null,
source = 'manual',
name = '',
now = new Date().toISOString(),
} = {}) {
const selectedIds = Array.isArray(selectedTabIds)
? new Set(selectedTabIds.map(String))
: null;
const savedAt = normalizeString(now) || new Date().toISOString();
const savedTabs = [];
const groupMap = new Map();
for (const tab of Array.isArray(tabs) ? tabs : []) {
const tabId = getTabId(tab);
if (selectedIds && !selectedIds.has(tabId)) continue;
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(tab?.url || '') : normalizeString(tab?.url);
if (!canonicalUrl || !(sessionsIsRestorableTabUrl ? sessionsIsRestorableTabUrl(canonicalUrl) : true)) continue;
const lookupEntry = typeof groupLookup?.get === 'function'
? normalizeGroupLookupEntry(groupLookup.get(tabId) || groupLookup.get(canonicalUrl))
: null;
const savedTab = {
url: canonicalUrl,
title: getTabTitle(tab, canonicalUrl),
favIconUrl: normalizeString(tab?.favIconUrl),
groupKey: lookupEntry?.key || '',
groupLabel: lookupEntry?.label || '',
manualGroupId: lookupEntry?.manualGroupId || '',
};
savedTabs.push(savedTab);
if (lookupEntry) {
if (!groupMap.has(lookupEntry.key)) {
groupMap.set(lookupEntry.key, {
key: lookupEntry.key,
label: lookupEntry.label,
manualGroupId: lookupEntry.manualGroupId,
tabUrls: [],
});
}
groupMap.get(lookupEntry.key).tabUrls.push(canonicalUrl);
}
}
if (!savedTabs.length) {
throw new Error('No restorable tabs selected');
}
const groups = [...groupMap.values()].map(group => ({
...group,
tabUrls: [...new Set(group.tabUrls)],
}));
return {
id: createSessionId(new Date(savedAt)),
name: normalizeString(name) || `Saved tabs ${new Date(savedAt).toLocaleString()}`,
savedAt,
source: normalizeString(source) || 'manual',
tabs: savedTabs,
groups,
};
}
function createRestoredSessionGroups({
existingState = { groups: [], assignments: {} },
session = {},
restoredTabs = [],
now = new Date().toISOString(),
} = {}) {
const normalizedState = {
groups: Array.isArray(existingState?.groups) ? existingState.groups.slice() : [],
assignments: existingState?.assignments && typeof existingState.assignments === 'object'
? { ...existingState.assignments }
: {},
};
const restoredByUrl = new Map();
for (const tab of Array.isArray(restoredTabs) ? restoredTabs : []) {
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(tab?.url || '') : normalizeString(tab?.url);
if (!canonicalUrl || tab?.id == null) continue;
if (!restoredByUrl.has(canonicalUrl)) restoredByUrl.set(canonicalUrl, []);
restoredByUrl.get(canonicalUrl).push(String(tab.id));
}
const sessionGroups = Array.isArray(session?.groups) ? session.groups : [];
sessionGroups
.filter(group => normalizeString(group?.key).startsWith(MANUAL_GROUP_PREFIX))
.forEach((group, index) => {
const groupName = normalizeString(group.label) || 'Restored group';
const groupId = `restored-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`;
normalizedState.groups.push({
id: groupId,
name: groupName,
createdAt: normalizeString(now) || new Date().toISOString(),
});
for (const url of Array.isArray(group.tabUrls) ? group.tabUrls : []) {
const tabIds = restoredByUrl.get(url) || [];
const tabId = tabIds.shift();
if (tabId) normalizedState.assignments[tabId] = groupId;
}
});
return normalizedState;
}
async function getSavedTabSessions() {
const stored = await chrome.storage.local.get(SAVED_TAB_SESSIONS_KEY);
return normalizeSavedTabSessions(stored[SAVED_TAB_SESSIONS_KEY]);
}
async function saveSavedTabSessions(sessions) {
const normalized = normalizeSavedTabSessions(sessions);
await chrome.storage.local.set({ [SAVED_TAB_SESSIONS_KEY]: normalized });
return normalized;
}
async function addSavedTabSession(session) {
const sessions = await getSavedTabSessions();
const normalized = normalizeSavedTabSessions([session])[0];
if (!normalized) throw new Error('No restorable tabs selected');
return saveSavedTabSessions([normalized, ...sessions]);
}
async function deleteSavedTabSession(sessionId) {
const targetId = normalizeString(sessionId);
const sessions = await getSavedTabSessions();
return saveSavedTabSessions(sessions.filter(session => session.id !== targetId));
}
async function renameSavedTabSession(sessionId, nextName) {
const targetId = normalizeString(sessionId);
const cleanName = normalizeString(nextName);
const sessions = await getSavedTabSessions();
return saveSavedTabSessions(sessions.map(session => {
if (session.id !== targetId) return session;
return {
...session,
name: cleanName || session.name,
};
}));
}
async function updateSavedTabSessionTabs(sessionId, nextTabs) {
const targetId = normalizeString(sessionId);
const normalizedTabs = Array.isArray(nextTabs)
? nextTabs.map(normalizeSessionTab).filter(Boolean)
: [];
const sessions = await getSavedTabSessions();
const nextSessions = [];
for (const session of sessions) {
if (session.id !== targetId) {
nextSessions.push(session);
continue;
}
if (!normalizedTabs.length) continue;
nextSessions.push({
...session,
tabs: normalizedTabs,
groups: buildSessionGroupsFromTabs(normalizedTabs),
});
}
return saveSavedTabSessions(nextSessions);
}
async function appendSavedTabSessionTabs(sessionId, tabsToAppend, {
skipDuplicateUrls = true,
} = {}) {
const targetId = normalizeString(sessionId);
const normalizedTabs = Array.isArray(tabsToAppend)
? tabsToAppend.map(normalizeSessionTab).filter(Boolean)
: [];
if (!normalizedTabs.length) throw new Error('No restorable tabs selected');
const sessions = await getSavedTabSessions();
let updatedSession = null;
let appendedCount = 0;
let skippedDuplicateCount = 0;
const nextSessions = sessions.map(session => {
if (session.id !== targetId) return session;
const existingUrls = new Set((session.tabs || []).map(tab => tab.url).filter(Boolean));
const nextTabs = [...(session.tabs || [])];
for (const tab of normalizedTabs) {
if (skipDuplicateUrls && existingUrls.has(tab.url)) {
skippedDuplicateCount += 1;
continue;
}
nextTabs.push(tab);
existingUrls.add(tab.url);
appendedCount += 1;
}
updatedSession = {
...session,
tabs: nextTabs,
groups: buildSessionGroupsFromTabs(nextTabs),
};
return updatedSession;
});
if (!updatedSession) throw new Error('Session not found');
const savedSessions = await saveSavedTabSessions(nextSessions);
const savedSession = savedSessions.find(session => session.id === targetId) || updatedSession;
return {
sessions: savedSessions,
session: savedSession,
appendedCount,
skippedDuplicateCount,
};
}
const api = {
SAVED_TAB_SESSIONS_KEY,
buildSessionSnapshot,
buildSessionGroupsFromTabs,
createSavedTabSessionFromTab,
createRestoredSessionGroups,
deleteSavedTabSession,
getSavedTabSessions,
addSavedTabSession,
appendSavedTabSessionTabs,
renameSavedTabSession,
updateSavedTabSessionTabs,
normalizeSavedTabSessions,
saveSavedTabSessions,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabHarborTabSessions = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+348
View File
@@ -0,0 +1,348 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildSessionSnapshot,
createSavedTabSessionFromTab,
createRestoredSessionGroups,
renameSavedTabSession,
appendSavedTabSessionTabs,
updateSavedTabSessionTabs,
normalizeSavedTabSessions,
} = require('./tab-sessions.js');
test('buildSessionSnapshot captures selected tabs with canonical urls and manual group metadata', () => {
const snapshot = buildSessionSnapshot({
tabs: [
{
id: 11,
url: 'extension://noogafoofpebimajpfpamcfhoaifemoa/suspended.html#ttl=Issue%20Thread&uri=https%3A%2F%2Fgithub.com%2FV-IOLE-T%2Ftab-harbor%2Fissues%2F25',
title: 'Suspended tab',
favIconUrl: 'https://github.com/favicon.ico',
windowId: 1,
},
{
id: 12,
url: 'chrome://settings',
title: 'Settings',
windowId: 1,
},
],
groupLookup: new Map([
['11', { key: '__session_group__:research', label: 'Research', manualGroupId: 'research' }],
]),
selectedTabIds: ['11', '12'],
source: 'selected',
now: '2026-05-22T08:00:00.000Z',
});
assert.equal(snapshot.tabs.length, 1);
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'] },
]);
});
test('buildSessionSnapshot rejects empty selected tabs', () => {
assert.throws(
() => buildSessionSnapshot({
tabs: [{ id: 1, url: 'chrome://newtab/', title: 'New tab' }],
selectedTabIds: ['1'],
}),
/no restorable tabs/i
);
});
test('normalizeSavedTabSessions drops invalid sessions and tabs', () => {
const sessions = normalizeSavedTabSessions([
{
id: 'session-a',
name: 'Morning research',
savedAt: '2026-05-22T08:00:00.000Z',
tabs: [
{ url: 'https://example.com/a', title: 'A' },
{ url: 'chrome://settings', title: 'Settings' },
],
groups: [{ key: 'example.com', label: 'Example', tabUrls: ['https://example.com/a', 'chrome://settings'] }],
},
{ id: 'empty', tabs: [] },
]);
assert.equal(sessions.length, 1);
assert.equal(sessions[0].name, 'Morning research');
assert.deepEqual(sessions[0].groups[0].tabUrls, ['https://example.com/a']);
});
test('createRestoredSessionGroups creates manual groups for restored manual session groups', () => {
const restored = createRestoredSessionGroups({
existingState: { groups: [], assignments: {} },
session: {
groups: [{ key: '__session_group__:research', label: 'Research', manualGroupId: 'research', tabUrls: ['https://a.test', 'https://b.test'] }],
},
restoredTabs: [
{ id: 101, url: 'https://a.test' },
{ id: 102, url: 'https://b.test' },
],
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);
});
test('renameSavedTabSession updates the matching session name', async () => {
const store = {
savedTabSessions: [
{
id: 'session-a',
name: 'Before',
savedAt: '2026-05-22T08:00:00.000Z',
source: 'manual',
tabs: [{ url: 'https://example.com', title: 'Example' }],
groups: [],
},
],
};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
const renamed = await renameSavedTabSession('session-a', 'After');
assert.equal(renamed[0].name, 'After');
assert.equal(store.savedTabSessions[0].name, 'After');
});
test('updateSavedTabSessionTabs reorders tabs and rebuilds groups', 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: 'group-a', groupLabel: 'Group A' },
{ url: 'https://b.test', title: 'B', groupKey: 'group-b', groupLabel: 'Group B' },
],
groups: [
{ key: 'group-a', label: 'Group A', manualGroupId: '', tabUrls: ['https://a.test'] },
{ key: 'group-b', label: 'Group B', manualGroupId: '', tabUrls: ['https://b.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: 'group-b', groupLabel: 'Group B' },
{ url: 'https://a.test', title: 'A', groupKey: 'group-a', groupLabel: 'Group A' },
]);
assert.deepEqual(updated[0].tabs.map(tab => tab.url), ['https://b.test', 'https://a.test']);
assert.deepEqual(updated[0].groups.map(group => group.key), ['group-b', 'group-a']);
});
test('updateSavedTabSessionTabs removes the session when no tabs remain', 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' }],
groups: [],
},
],
};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
const updated = await updateSavedTabSessionTabs('session-a', []);
assert.equal(updated.length, 0);
});
test('appendSavedTabSessionTabs appends new tabs, skips duplicate urls, and rebuilds groups', 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: 'group-a', groupLabel: 'Group A' },
],
groups: [
{ key: 'group-a', label: 'Group A', manualGroupId: '', tabUrls: ['https://a.test'] },
],
},
],
};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
const result = await appendSavedTabSessionTabs('session-a', [
{
url: 'extension://noogafoofpebimajpfpamcfhoaifemoa/suspended.html#ttl=Duplicate%20A&uri=https%3A%2F%2Fa.test',
title: 'Duplicate A',
groupKey: 'group-a',
groupLabel: 'Group A',
},
{ url: 'https://b.test', title: 'B', groupKey: 'group-b', groupLabel: 'Group B' },
]);
assert.equal(result.appendedCount, 1);
assert.equal(result.skippedDuplicateCount, 1);
assert.deepEqual(result.session.tabs.map(tab => tab.url), ['https://a.test', 'https://b.test']);
assert.deepEqual(result.session.groups.map(group => group.key), ['group-a', 'group-b']);
assert.equal(result.session.id, 'session-a');
assert.equal(result.session.name, 'Session');
assert.equal(result.session.savedAt, '2026-05-22T08:00:00.000Z');
assert.equal(result.session.source, 'manual');
});
test('appendSavedTabSessionTabs rejects missing target sessions and invalid tabs', 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' }],
groups: [],
},
],
};
global.chrome = {
storage: {
local: {
async get(key) {
return { [key]: store[key] };
},
async set(next) {
Object.assign(store, next);
},
},
},
};
await assert.rejects(
() => appendSavedTabSessionTabs('missing', [{ url: 'https://b.test', title: 'B' }]),
/session not found/i
);
await assert.rejects(
() => appendSavedTabSessionTabs('session-a', [{ url: 'chrome://settings', title: 'Settings' }]),
/no restorable tabs selected/i
);
});
test('createSavedTabSessionFromTab derives name, source, savedAt, tabs, and groups from one dragged tab', () => {
const created = createSavedTabSessionFromTab({
sourceSession: {
id: 'session-a',
name: 'Original',
source: 'window',
},
tab: {
url: 'https://github.com/openai/openai-node/issues/123',
title: 'SDK issue - GitHub',
favIconUrl: 'https://github.com/favicon.ico',
groupKey: '__session_group__:bugs',
groupLabel: 'Bugs',
manualGroupId: 'bugs',
},
existingSessions: [],
now: '2026-05-22T09:30:00.000Z',
});
assert.equal(created.name, 'SDK issue');
assert.equal(created.source, 'window');
assert.equal(created.savedAt, '2026-05-22T09:30:00.000Z');
assert.equal(created.tabs.length, 1);
assert.deepEqual(created.groups, [
{
key: '__session_group__:bugs',
label: 'Bugs',
manualGroupId: 'bugs',
tabUrls: ['https://github.com/openai/openai-node/issues/123'],
},
]);
});
test('createSavedTabSessionFromTab falls back to friendly domain and de-duplicates duplicate session names', () => {
const created = createSavedTabSessionFromTab({
sourceSession: {
id: 'session-a',
name: 'Original',
source: '',
},
tab: {
url: 'https://docs.github.com/en/actions',
title: '',
favIconUrl: '',
groupKey: '',
groupLabel: '',
manualGroupId: '',
},
existingSessions: [
{
id: 'session-b',
name: 'Docs',
savedAt: '2026-05-22T08:00:00.000Z',
source: 'manual',
tabs: [{ url: 'https://example.com', title: 'Example' }],
groups: [],
},
],
now: '2026-05-22T09:30:00.000Z',
});
assert.equal(created.name, 'Docs 2');
assert.equal(created.source, 'manual');
});
+93
View File
@@ -0,0 +1,93 @@
'use strict';
(function attachTabHarborTabUrlUtils(globalScope) {
const RESTORABLE_PROTOCOLS = new Set(['http:', 'https:', 'file:']);
const INTERNAL_PROTOCOLS = new Set([
'about:',
'brave:',
'chrome:',
'chrome-extension:',
'edge:',
'extension:',
]);
function safeDecode(value = '') {
try {
return decodeURIComponent(String(value || ''));
} catch {
return String(value || '');
}
}
function parseSuspendedTabUrl(url = '') {
const raw = String(url || '').trim();
const emptyResult = {
isSuspended: false,
originalUrl: '',
title: '',
};
if (!raw) return emptyResult;
let parsed;
try {
parsed = new URL(raw);
} catch {
return emptyResult;
}
const protocol = parsed.protocol.toLowerCase();
const pathname = parsed.pathname || '';
if (protocol !== 'extension:' && protocol !== 'chrome-extension:') {
return emptyResult;
}
if (!pathname.endsWith('/suspended.html') && pathname !== '/suspended.html') {
return emptyResult;
}
const hash = raw.includes('#') ? raw.slice(raw.indexOf('#') + 1) : '';
const params = new URLSearchParams(hash);
const originalUrl = safeDecode(params.get('uri') || '').trim();
const title = safeDecode(params.get('ttl') || '').trim();
return {
isSuspended: true,
originalUrl,
title,
};
}
function getCanonicalTabUrl(url = '') {
const raw = String(url || '').trim();
if (!raw) return '';
const suspended = parseSuspendedTabUrl(raw);
if (suspended.isSuspended && suspended.originalUrl) return suspended.originalUrl;
return raw;
}
function isRestorableTabUrl(url = '') {
const canonicalUrl = getCanonicalTabUrl(url);
if (!canonicalUrl) return false;
try {
const parsed = new URL(canonicalUrl);
if (RESTORABLE_PROTOCOLS.has(parsed.protocol)) return true;
if (INTERNAL_PROTOCOLS.has(parsed.protocol)) return false;
return false;
} catch {
return false;
}
}
const api = {
getCanonicalTabUrl,
isRestorableTabUrl,
parseSuspendedTabUrl,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabHarborTabUrlUtils = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
getCanonicalTabUrl,
isRestorableTabUrl,
parseSuspendedTabUrl,
} = require('./tab-url-utils.js');
test('parseSuspendedTabUrl extracts original uri and title from Marvellous Suspender URLs', () => {
const suspendedUrl = 'extension://noogafoofpebimajpfpamcfhoaifemoa/suspended.html#ttl=V-IOLE-T%2Ftab-harbor&pos=0&uri=https%3A%2F%2Fgithub.com%2FV-IOLE-T%2Ftab-harbor';
const parsed = parseSuspendedTabUrl(suspendedUrl);
assert.equal(parsed.isSuspended, true);
assert.equal(parsed.originalUrl, 'https://github.com/V-IOLE-T/tab-harbor');
assert.equal(parsed.title, 'V-IOLE-T/tab-harbor');
});
test('getCanonicalTabUrl leaves ordinary urls alone and unwraps suspended urls', () => {
assert.equal(
getCanonicalTabUrl('https://example.com/docs'),
'https://example.com/docs'
);
assert.equal(
getCanonicalTabUrl('extension://noogafoofpebimajpfpamcfhoaifemoa/suspended.html#ttl=Docs&uri=https%3A%2F%2Fexample.com%2Fdocs'),
'https://example.com/docs'
);
});
test('isRestorableTabUrl rejects browser internals and unsafe protocols', () => {
assert.equal(isRestorableTabUrl('https://example.com'), true);
assert.equal(isRestorableTabUrl('http://example.com'), true);
assert.equal(isRestorableTabUrl('file:///Users/test/report.pdf'), true);
assert.equal(isRestorableTabUrl('chrome://settings'), false);
assert.equal(isRestorableTabUrl('about:blank'), false);
assert.equal(isRestorableTabUrl('javascript:alert(1)'), false);
});
test('parseSuspendedTabUrl tolerates malformed suspended hashes', () => {
const parsed = parseSuspendedTabUrl('extension://noogafoofpebimajpfpamcfhoaifemoa/suspended.html#ttl=OnlyTitle');
assert.equal(parsed.isSuspended, true);
assert.equal(parsed.originalUrl, '');
assert.equal(parsed.title, 'OnlyTitle');
});
test('parseSuspendedTabUrl only unwraps extension suspended pages', () => {
const ordinaryPage = 'https://example.com/suspended.html#ttl=Docs&uri=https%3A%2F%2Fother.test';
const parsed = parseSuspendedTabUrl(ordinaryPage);
assert.equal(parsed.isSuspended, false);
assert.equal(getCanonicalTabUrl(ordinaryPage), ordinaryPage);
});
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {}, activeElement: null, documentElement: { style: {} }, body: { classList: { remove: () => {} } }, querySelectorAll: () => [], createElement: () => ({}) };
globalThis.TabOutIconUtils = {};
globalThis.TabOutBackgroundImage = {};
globalThis.TabOutListOrder = { reorderSubsetByIds: (a, b) => a };
globalThis.TabHarborTodos = { load: async () => [], save: async () => {} };
globalThis.TabHarborDashboardRuntime = null;
globalThis.chrome = { runtime: { lastError: null }, storage: { local: { get: async () => ({}), set: async () => {} } } };
globalThis.localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
globalThis.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {} };
globalThis.window = {
matchMedia: query => ({
media: query,
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}),
};
require('./theme-controls.js');
const {
filterRealTabs,
getResolvedThemeDefinition,
getResolvedTone,
normalizeShortcutUrl,
normalizeQuickShortcuts,
normalizeThemePreferences,
} = globalThis.TabOutThemeControls;
// ---- normalizeThemePreferences / resolved theme ----
test('normalizeThemePreferences migrates legacy midnight to dark mist', () => {
const result = normalizeThemePreferences({ themeId: 'midnight', surfaceOpacity: 19 });
assert.equal(result.mode, 'dark');
assert.equal(result.paletteId, 'mist');
assert.equal(result.surfaceOpacity, 19);
});
test('normalizeThemePreferences migrates legacy light theme ids to light palette families', () => {
const result = normalizeThemePreferences({ themeId: 'sage' });
assert.equal(result.mode, 'light');
assert.equal(result.paletteId, 'sage');
});
test('normalizeThemePreferences keeps explicit mode and palette values', () => {
const result = normalizeThemePreferences({
mode: 'system',
paletteId: 'blush',
surfaceOpacity: 9,
uiScale: 112,
shortcutScale: 124,
savedSessionRestoreMode: 'current-window',
savedSessionNavDisplayMode: 'icon',
});
assert.equal(result.mode, 'system');
assert.equal(result.paletteId, 'blush');
assert.equal(result.surfaceOpacity, 9);
assert.equal(result.uiScale, 112);
assert.equal(result.shortcutScale, 124);
assert.equal(result.savedSessionRestoreMode, 'current-window');
assert.equal(result.savedSessionNavDisplayMode, 'icon');
});
test('normalizeThemePreferences clamps size controls to calm readable ranges', () => {
const small = normalizeThemePreferences({ uiScale: 40, shortcutScale: 40 });
assert.equal(small.uiScale, 100);
assert.equal(small.shortcutScale, 100);
const large = normalizeThemePreferences({ uiScale: 180, shortcutScale: 180 });
assert.equal(large.uiScale, 120);
assert.equal(large.shortcutScale, 130);
});
test('normalizeThemePreferences defaults saved session restore mode to new-window', () => {
const result = normalizeThemePreferences({});
assert.equal(result.savedSessionRestoreMode, 'new-window');
assert.equal(result.savedSessionNavDisplayMode, 'name');
});
test('normalizeThemePreferences falls back to new-window for invalid saved session restore mode', () => {
const result = normalizeThemePreferences({
savedSessionRestoreMode: 'hidden-window',
savedSessionNavDisplayMode: 'label',
});
assert.equal(result.savedSessionRestoreMode, 'new-window');
assert.equal(result.savedSessionNavDisplayMode, 'name');
});
test('normalizeThemePreferences defaults closeDuplicateNewTabsEnabled to false', () => {
const result = normalizeThemePreferences({});
assert.equal(result.closeDuplicateNewTabsEnabled, false);
});
test('normalizeThemePreferences preserves closeDuplicateNewTabsEnabled when true', () => {
const result = normalizeThemePreferences({ closeDuplicateNewTabsEnabled: true });
assert.equal(result.closeDuplicateNewTabsEnabled, true);
});
test('normalizeThemePreferences rejects non-boolean closeDuplicateNewTabsEnabled', () => {
assert.equal(normalizeThemePreferences({ closeDuplicateNewTabsEnabled: 'yes' }).closeDuplicateNewTabsEnabled, false);
assert.equal(normalizeThemePreferences({ closeDuplicateNewTabsEnabled: 1 }).closeDuplicateNewTabsEnabled, false);
assert.equal(normalizeThemePreferences({ closeDuplicateNewTabsEnabled: null }).closeDuplicateNewTabsEnabled, false);
});
test('getResolvedTone follows system preference when mode is system', () => {
const originalMatchMedia = globalThis.window.matchMedia;
globalThis.window.matchMedia = query => ({
media: query,
matches: true,
addEventListener: () => {},
removeEventListener: () => {},
});
try {
assert.equal(getResolvedTone({ mode: 'system' }), 'dark');
} finally {
globalThis.window.matchMedia = originalMatchMedia;
}
});
test('getResolvedThemeDefinition resolves dark tokens from palette family', () => {
const theme = getResolvedThemeDefinition({ mode: 'dark', paletteId: 'paper' });
assert.equal(theme.name, 'Paper');
assert.equal(theme.tone, 'dark');
assert.equal(theme.vars['--paper'], '#1a1613');
});
// ---- filterRealTabs ----
test('filterRealTabs removes chrome:// internal pages', () => {
const tabs = [
{ id: 1, url: 'https://github.com' },
{ id: 2, url: 'chrome://newtab' },
{ id: 3, url: 'chrome://settings' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 1);
assert.equal(result[0].id, 1);
});
test('filterRealTabs removes chrome-extension:// URLs', () => {
const tabs = [
{ id: 1, url: 'https://example.com' },
{ id: 2, url: 'chrome-extension://abc123/background.html' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 1);
assert.equal(result[0].id, 1);
});
test('filterRealTabs removes about:blank and about:s pages', () => {
const tabs = [
{ id: 1, url: 'https://example.com' },
{ id: 2, url: 'about:blank' },
{ id: 3, url: 'about:settings' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 1);
assert.equal(result[0].id, 1);
});
test('filterRealTabs removes edge:// and brave:// URLs', () => {
const tabs = [
{ id: 1, url: 'https://example.com' },
{ id: 2, url: 'edge://settings' },
{ id: 3, url: 'brave://rewards' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 1);
assert.equal(result[0].id, 1);
});
test('filterRealTabs preserves tabs with no url field', () => {
const tabs = [
{ id: 1, title: 'GitHub' },
{ id: 2, url: 'https://github.com' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 2);
});
test('filterRealTabs is case-insensitive for protocol prefix', () => {
const tabs = [
{ id: 1, url: 'CHROME://settings' },
{ id: 2, url: 'Chrome-extension://abc' },
];
const result = filterRealTabs(tabs);
assert.equal(result.length, 2); // url.startsWith is case-sensitive in JS
});
test('filterRealTabs handles empty array', () => {
assert.equal(filterRealTabs([]).length, 0);
});
// ---- normalizeShortcutUrl ----
test('normalizeShortcutUrl strips leading/trailing whitespace', () => {
// URL() normalizes to canonical form (adds trailing slash for bare domains)
assert.equal(normalizeShortcutUrl(' https://example.com '), 'https://example.com/');
assert.equal(normalizeShortcutUrl(' https://github.com/user '), 'https://github.com/user');
});
test('normalizeShortcutUrl handles empty input', () => {
assert.equal(normalizeShortcutUrl(''), '');
assert.equal(normalizeShortcutUrl(' '), '');
});
test('normalizeShortcutUrl preserves valid URLs', () => {
assert.equal(normalizeShortcutUrl('https://github.com/user/repo'), 'https://github.com/user/repo');
});
test('normalizeShortcutUrl adds https for host-only URLs', () => {
assert.equal(normalizeShortcutUrl('github.com'), 'https://github.com/');
assert.equal(normalizeShortcutUrl('example.org/path'), 'https://example.org/path');
});
test('normalizeShortcutUrl supports internationalized domains and paths', () => {
assert.equal(
normalizeShortcutUrl('例子.测试/路径'),
'https://xn--fsqu00a.xn--0zwm56d/%E8%B7%AF%E5%BE%84'
);
});
// ---- normalizeQuickShortcuts ----
test('normalizeQuickShortcuts ignores non-array input', () => {
assert.deepEqual(normalizeQuickShortcuts(null), []);
assert.deepEqual(normalizeQuickShortcuts(undefined), []);
assert.deepEqual(normalizeQuickShortcuts('not an array'), []);
});
test('normalizeQuickShortcuts filters shortcuts missing url only', () => {
const input = [
{ id: 'a', url: 'https://a.com' },
{ id: 'b', url: '' },
{ id: '', url: 'https://c.com' }, // id missing is ok, id is auto-generated
];
const result = normalizeQuickShortcuts(input);
assert.equal(result.length, 2);
});
test('normalizeQuickShortcuts defaults iconKind to empty string for no icon', () => {
const input = [{ id: 's1', url: 'https://ex.com' }];
const result = normalizeQuickShortcuts(input);
assert.equal(result[0].iconKind, '');
});
test('normalizeQuickShortcuts infers iconKind from icon content', () => {
const input = [{ id: 's1', url: 'https://ex.com', icon: '🌟' }];
const result = normalizeQuickShortcuts(input);
assert.equal(result[0].iconKind, 'glyph');
assert.equal(result[0].icon, '🌟');
});
test('normalizeQuickShortcuts normalizes icon URL to image kind', () => {
const input = [{ id: 's1', url: 'https://ex.com', icon: 'https://ex.com/f.png' }];
const result = normalizeQuickShortcuts(input);
assert.equal(result[0].iconKind, 'image');
assert.equal(result[0].icon, 'https://ex.com/f.png');
});
+119
View File
@@ -0,0 +1,119 @@
'use strict';
(function attachTodosStore(globalScope) {
function normalizeTodos(input) {
if (!Array.isArray(input)) return [];
return input
.filter(todo => todo && todo.id && todo.title)
.map(todo => ({
id: String(todo.id),
title: String(todo.title).trim(),
description: String(todo.description || ''),
createdAt: todo.createdAt || new Date().toISOString(),
completed: Boolean(todo.completed),
completedAt: todo.completedAt || null,
dismissed: Boolean(todo.dismissed),
}));
}
function createTodo(todos, { title, description = '' }) {
const cleanTitle = String(title || '').trim();
if (!cleanTitle) throw new Error('Todo title is required');
const nextTodo = {
id: `todo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: cleanTitle,
description: String(description || '').trim(),
createdAt: new Date().toISOString(),
completed: false,
completedAt: null,
dismissed: false,
};
return [...normalizeTodos(todos), nextTodo];
}
function updateTodo(todos, id, updates = {}) {
const hasTitle = Object.prototype.hasOwnProperty.call(updates, 'title');
const cleanTitle = hasTitle ? String(updates.title || '').trim() : '';
const hasDescription = Object.prototype.hasOwnProperty.call(updates, 'description');
const cleanDescription = hasDescription ? String(updates.description || '').trim() : '';
if (hasTitle && !cleanTitle) throw new Error('Todo title is required');
return normalizeTodos(todos).map(todo => {
if (todo.id !== String(id)) return todo;
const nextTodo = { ...todo };
if (hasTitle) nextTodo.title = cleanTitle;
if (hasDescription) nextTodo.description = cleanDescription;
return nextTodo;
});
}
function completeTodo(todos, id) {
return normalizeTodos(todos).map(todo => {
if (todo.id !== String(id)) return todo;
return {
...todo,
completed: true,
completedAt: new Date().toISOString(),
};
});
}
function deleteTodo(todos, id) {
return normalizeTodos(todos).map(todo => {
if (todo.id !== String(id)) return todo;
return {
...todo,
dismissed: true,
};
});
}
function clearArchivedTodos(todos) {
return normalizeTodos(todos).map(todo => {
if (!todo.completed || todo.dismissed) return todo;
return {
...todo,
dismissed: true,
};
});
}
function splitTodos(todos) {
const visible = normalizeTodos(todos).filter(todo => !todo.dismissed);
return {
active: visible.filter(todo => !todo.completed),
archived: visible.filter(todo => todo.completed),
};
}
function searchTodos(todos, query) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return normalizeTodos(todos);
return normalizeTodos(todos).filter(todo =>
todo.title.toLowerCase().includes(needle) ||
todo.description.toLowerCase().includes(needle)
);
}
const api = {
completeTodo,
clearArchivedTodos,
createTodo,
deleteTodo,
normalizeTodos,
searchTodos,
splitTodos,
updateTodo,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
globalScope.TabOutTodosStore = api;
})(typeof globalThis !== 'undefined' ? globalThis : window);
+154
View File
@@ -0,0 +1,154 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
completeTodo,
clearArchivedTodos,
createTodo,
deleteTodo,
normalizeTodos,
searchTodos,
splitTodos,
updateTodo,
} = require('./todos-store.js');
test('createTodo adds a new active todo with title and description', () => {
const todos = createTodo([], {
title: 'Write launch post',
description: 'Draft the outline before dinner',
});
assert.equal(todos.length, 1);
assert.equal(todos[0].title, 'Write launch post');
assert.equal(todos[0].completed, false);
});
test('completeTodo archives a todo without dismissing it', () => {
const todos = completeTodo([
{
id: 'todo-1',
title: 'Review notes',
description: '',
createdAt: '2026-04-16T00:00:00.000Z',
completed: false,
completedAt: null,
dismissed: false,
},
], 'todo-1');
const { active, archived } = splitTodos(todos);
assert.equal(active.length, 0);
assert.equal(archived.length, 1);
assert.equal(archived[0].completed, true);
});
test('deleteTodo hides a todo from both active and archive views', () => {
const todos = deleteTodo([
{
id: 'todo-1',
title: 'Review notes',
description: '',
createdAt: '2026-04-16T00:00:00.000Z',
completed: true,
completedAt: '2026-04-16T01:00:00.000Z',
dismissed: false,
},
], 'todo-1');
const { active, archived } = splitTodos(todos);
assert.equal(active.length, 0);
assert.equal(archived.length, 0);
});
test('updateTodo edits title and description without changing completion state', () => {
const todos = updateTodo([
{
id: 'todo-1',
title: 'Review notes',
description: 'Old context',
createdAt: '2026-04-16T00:00:00.000Z',
completed: true,
completedAt: '2026-04-16T01:00:00.000Z',
dismissed: false,
},
], 'todo-1', {
title: ' Review launch notes ',
description: ' New context ',
completed: false,
dismissed: true,
});
assert.equal(todos[0].title, 'Review launch notes');
assert.equal(todos[0].description, 'New context');
assert.equal(todos[0].completed, true);
assert.equal(todos[0].dismissed, false);
});
test('updateTodo rejects an empty title', () => {
assert.throws(() => updateTodo([
{
id: 'todo-1',
title: 'Review notes',
description: '',
createdAt: '2026-04-16T00:00:00.000Z',
completed: false,
completedAt: null,
dismissed: false,
},
], 'todo-1', { title: ' ' }), /Todo title is required/);
});
test('clearArchivedTodos removes all archived todos but keeps active ones', () => {
const todos = clearArchivedTodos([
{
id: 'todo-1',
title: 'Archived item',
description: '',
createdAt: '2026-04-16T00:00:00.000Z',
completed: true,
completedAt: '2026-04-16T01:00:00.000Z',
dismissed: false,
},
{
id: 'todo-2',
title: 'Active item',
description: '',
createdAt: '2026-04-16T00:00:00.000Z',
completed: false,
completedAt: null,
dismissed: false,
},
]);
const { active, archived } = splitTodos(todos);
assert.equal(active.length, 1);
assert.equal(archived.length, 0);
});
test('searchTodos matches title and description text', () => {
const todos = normalizeTodos([
{
id: 'todo-1',
title: 'Write docs',
description: 'Update onboarding page',
createdAt: '2026-04-16T00:00:00.000Z',
completed: false,
completedAt: null,
dismissed: false,
},
{
id: 'todo-2',
title: 'Prep launch',
description: 'Draft release copy',
createdAt: '2026-04-16T00:00:00.000Z',
completed: false,
completedAt: null,
dismissed: false,
},
]);
assert.equal(searchTodos(todos, 'onboarding').length, 1);
assert.equal(searchTodos(todos, 'launch').length, 1);
});
+503
View File
@@ -0,0 +1,503 @@
'use strict';
const {
locale: uiLocale = 'en',
t: uiT,
} = globalThis.TabHarborI18n || {};
/* ----------------------------------------------------------------
UI HELPERS
---------------------------------------------------------------- */
function playCloseSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const t = ctx.currentTime;
const duration = 0.25;
const buffer = ctx.createBuffer(1, ctx.sampleRate * duration, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
const pos = i / data.length;
const env = pos < 0.1 ? pos / 0.1 : Math.pow(1 - (pos - 0.1) / 0.9, 1.5);
data[i] = (Math.random() * 2 - 1) * env;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = 2.0;
filter.frequency.setValueAtTime(4000, t);
filter.frequency.exponentialRampToValueAtTime(400, t + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.15, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
source.connect(filter).connect(gain).connect(ctx.destination);
source.start(t);
setTimeout(() => ctx.close(), 500);
} catch {
// Audio not supported — fail silently
}
}
function shootConfetti(x, y) {
const colors = [
'#c8713a',
'#e8a070',
'#5a7a62',
'#8aaa92',
'#5a6b7a',
'#8a9baa',
'#d4b896',
'#b35a5a',
];
const particleCount = 17;
for (let i = 0; i < particleCount; i++) {
const el = document.createElement('div');
const isCircle = Math.random() > 0.5;
const size = 5 + Math.random() * 6;
const color = colors[Math.floor(Math.random() * colors.length)];
el.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
width: ${size}px;
height: ${size}px;
background: ${color};
border-radius: ${isCircle ? '50%' : '2px'};
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
opacity: 1;
`;
document.body.appendChild(el);
const angle = Math.random() * Math.PI * 2;
const speed = 60 + Math.random() * 120;
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed - 80;
const gravity = 200;
const startTime = performance.now();
const duration = 700 + Math.random() * 200;
function frame(now) {
const elapsed = (now - startTime) / 1000;
const progress = elapsed / (duration / 1000);
if (progress >= 1) {
el.remove();
return;
}
const px = vx * elapsed;
const py = vy * elapsed + 0.5 * gravity * elapsed * elapsed;
const opacity = progress < 0.5 ? 1 : 1 - (progress - 0.5) * 2;
const rotate = elapsed * 200 * (isCircle ? 0 : 1);
el.style.transform = `translate(calc(-50% + ${px}px), calc(-50% + ${py}px)) rotate(${rotate}deg)`;
el.style.opacity = opacity;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
}
function animateCardOut(card) {
if (!card) return;
const rect = card.getBoundingClientRect();
shootConfetti(rect.left + rect.width / 2, rect.top + rect.height / 2);
card.classList.add('closing');
setTimeout(() => {
card.remove();
checkAndShowEmptyState();
}, 300);
}
function showToast(message, { action } = {}) {
const toast = document.getElementById('toast');
const toastText = document.getElementById('toastText');
if (!toast || !toastText) return;
const toastAction = document.getElementById('toastAction');
if (!toast || !toastText) return;
toastText.textContent = message;
if (action && toastAction) {
toastAction.textContent = action.label;
toastAction.hidden = false;
toastAction.onclick = async () => {
try {
await Promise.resolve(action.fn());
} catch (error) {
console.error('Toast action failed:', error);
} finally {
toast.classList.remove('visible');
}
};
} else if (toastAction) {
toastAction.hidden = true;
toastAction.onclick = null;
}
toast.classList.add('visible');
setTimeout(() => toast.classList.remove('visible'), 2500);
}
function setImageFallbackAttributes(imgEl, fallbackUrl = '') {
if (!imgEl) return;
const fallbackSources = Array.isArray(fallbackUrl)
? fallbackUrl.map(url => String(url || '').trim()).filter(Boolean)
: String(fallbackUrl || '').trim()
? [String(fallbackUrl || '').trim()]
: [];
if (fallbackSources.length) {
imgEl.dataset.fallbackSrc = fallbackSources[0];
if (fallbackSources.length > 1) {
imgEl.dataset.fallbackSrcset = JSON.stringify(fallbackSources.slice(1));
} else {
delete imgEl.dataset.fallbackSrcset;
}
} else {
delete imgEl.dataset.fallbackSrc;
delete imgEl.dataset.fallbackSrcset;
}
delete imgEl.dataset.fallbackApplied;
}
function revealImageFallback(imgEl) {
if (!imgEl) return;
imgEl.style.display = 'none';
const sibling = imgEl.nextElementSibling;
if (!sibling) return;
if (
sibling.classList.contains('group-nav-fallback') ||
sibling.classList.contains('chip-favicon-fallback') ||
sibling.classList.contains('inline-favicon-fallback') ||
sibling.classList.contains('popup-tab-favicon-fallback') ||
sibling.classList.contains('quick-shortcut-fallback') ||
sibling.classList.contains('tab-picker-favicon-fallback')
) {
sibling.style.display = (
sibling.classList.contains('inline-favicon-fallback') ||
sibling.classList.contains('tab-picker-favicon-fallback')
) ? 'inline-flex' : 'flex';
}
}
function handleImageFallbackError(imgEl) {
if (!imgEl) return;
const fallbackQueue = [];
const primaryFallback = String(imgEl.dataset.fallbackSrc || '').trim();
if (primaryFallback) fallbackQueue.push(primaryFallback);
const serializedQueue = String(imgEl.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 {
// Ignore malformed fallback queues and continue to inline fallback.
}
}
const currentSrc = String(imgEl.currentSrc || imgEl.src || '').trim();
const nextFallback = fallbackQueue.find(url => url && url !== currentSrc && url !== String(imgEl.dataset.fallbackApplied || '').trim());
if (nextFallback) {
const remaining = fallbackQueue.filter(url => url && url !== nextFallback);
imgEl.dataset.fallbackApplied = nextFallback;
imgEl.dataset.fallbackSrc = nextFallback;
if (remaining.length) {
imgEl.dataset.fallbackSrcset = JSON.stringify(remaining);
} else {
delete imgEl.dataset.fallbackSrcset;
}
imgEl.src = nextFallback;
return;
}
revealImageFallback(imgEl);
}
if (!globalThis.__tabHarborImageFallbackBound) {
document.addEventListener('error', event => {
const target = event.target;
if (!(target instanceof HTMLImageElement)) return;
if (!('fallbackSrc' in target.dataset) && !('fallbackSrcset' in target.dataset)) return;
handleImageFallbackError(target);
}, true);
globalThis.__tabHarborImageFallbackBound = true;
}
function renderMissionsEmptyState() {
return `
<div class="missions-empty-state">
<div class="empty-checkmark">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<div class="empty-title">${uiT ? uiT('emptyTitle') : 'Inbox zero, but for tabs.'}</div>
<div class="empty-subtitle">${uiT ? uiT('emptySubtitle') : "You're free."}</div>
</div>
`;
}
function checkAndShowEmptyState() {
const missionsEl = document.getElementById('openTabsMissions');
if (!missionsEl) return;
const remaining = missionsEl.querySelectorAll('.mission-card:not(.closing)').length;
if (remaining > 0) return;
missionsEl.innerHTML = renderMissionsEmptyState();
const countEl = document.getElementById('openTabsSectionCount');
if (countEl) countEl.textContent = uiT ? uiT('emptyTabsCount') : '0 domains';
}
function timeAgo(dateStr) {
if (!dateStr) return '';
const then = new Date(dateStr);
const now = new Date();
const diffMins = Math.floor((now - then) / 60000);
const diffHours = Math.floor((now - then) / 3600000);
const diffDays = Math.floor((now - then) / 86400000);
if (diffMins < 1) return uiT ? uiT('timeJustNow') : 'just now';
if (diffMins < 60) return uiT ? uiT('timeMinAgo', { count: diffMins }) : `${diffMins} min ago`;
if (diffHours < 24) {
if (!uiT) return `${diffHours} hr${diffHours !== 1 ? 's' : ''} ago`;
return diffHours === 1
? uiT('timeHourAgo')
: uiT('timeHoursAgo', { count: diffHours });
}
if (diffDays === 1) return uiT ? uiT('timeYesterday') : 'yesterday';
return uiT ? uiT('timeDaysAgo', { count: diffDays }) : `${diffDays} days ago`;
}
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return uiT ? uiT('greetingMorning') : 'Good morning';
if (hour < 17) return uiT ? uiT('greetingAfternoon') : 'Good afternoon';
return uiT ? uiT('greetingEvening') : 'Good evening';
}
function getDateDisplay() {
const locale = uiLocale === 'zh-CN' ? 'zh-CN' : 'en-US';
return new Date().toLocaleDateString(locale, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
const FRIENDLY_DOMAINS = {
'github.com': 'GitHub',
'www.github.com': 'GitHub',
'gist.github.com': 'GitHub Gist',
'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'music.youtube.com': 'YouTube Music',
'x.com': 'X',
'www.x.com': 'X',
'twitter.com': 'X',
'www.twitter.com': 'X',
'reddit.com': 'Reddit',
'www.reddit.com': 'Reddit',
'old.reddit.com': 'Reddit',
'substack.com': 'Substack',
'www.substack.com': 'Substack',
'medium.com': 'Medium',
'www.medium.com': 'Medium',
'linkedin.com': 'LinkedIn',
'www.linkedin.com': 'LinkedIn',
'stackoverflow.com': 'Stack Overflow',
'www.stackoverflow.com': 'Stack Overflow',
'news.ycombinator.com': 'Hacker News',
'google.com': 'Google',
'www.google.com': 'Google',
'mail.google.com': 'Gmail',
'docs.google.com': 'Google Docs',
'drive.google.com': 'Google Drive',
'calendar.google.com': 'Google Calendar',
'meet.google.com': 'Google Meet',
'gemini.google.com': 'Gemini',
'chatgpt.com': 'ChatGPT',
'www.chatgpt.com': 'ChatGPT',
'chat.openai.com': 'ChatGPT',
'claude.ai': 'Claude',
'www.claude.ai': 'Claude',
'code.claude.com': 'Claude Code',
'notion.so': 'Notion',
'www.notion.so': 'Notion',
'figma.com': 'Figma',
'www.figma.com': 'Figma',
'slack.com': 'Slack',
'app.slack.com': 'Slack',
'discord.com': 'Discord',
'www.discord.com': 'Discord',
'wikipedia.org': 'Wikipedia',
'en.wikipedia.org': 'Wikipedia',
'amazon.com': 'Amazon',
'www.amazon.com': 'Amazon',
'netflix.com': 'Netflix',
'www.netflix.com': 'Netflix',
'spotify.com': 'Spotify',
'open.spotify.com': 'Spotify',
'vercel.com': 'Vercel',
'www.vercel.com': 'Vercel',
'npmjs.com': 'npm',
'www.npmjs.com': 'npm',
'developer.mozilla.org': 'MDN',
'arxiv.org': 'arXiv',
'www.arxiv.org': 'arXiv',
'huggingface.co': 'Hugging Face',
'www.huggingface.co': 'Hugging Face',
'producthunt.com': 'Product Hunt',
'www.producthunt.com': 'Product Hunt',
'xiaohongshu.com': 'RedNote',
'www.xiaohongshu.com': 'RedNote',
'local-files': 'Local Files',
};
function friendlyDomain(hostname) {
if (!hostname) return '';
if (FRIENDLY_DOMAINS[hostname]) return FRIENDLY_DOMAINS[hostname];
if (hostname.endsWith('.substack.com') && hostname !== 'substack.com') {
return capitalize(hostname.replace('.substack.com', '')) + "'s Substack";
}
if (hostname.endsWith('.github.io')) {
return capitalize(hostname.replace('.github.io', '')) + ' (GitHub Pages)';
}
const clean = hostname
.replace(/^www\./, '')
.replace(/\.(co\.uk|co\.jp|com|org|net|io|co|ai|dev|app|so|me|xyz|info|us|uk)$/, '');
return clean.split('.').map(part => capitalize(part)).join(' ');
}
function capitalize(str) {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
function stripTitleNoise(title) {
if (!title) return '';
title = title.replace(/^\(\d+\+?\)\s*/, '');
title = title.replace(/\s*\([\d,]+\+?\)\s*/g, ' ');
title = title.replace(/\s*[\-\u2010-\u2015]\s*[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '');
title = title.replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '');
title = title.replace(/\s+on X:\s*/, ': ');
title = title.replace(/\s*\/\s*X\s*$/, '');
return title.trim();
}
function cleanTitle(title, hostname) {
if (!title || !hostname) return title || '';
const friendly = friendlyDomain(hostname);
const domain = hostname.replace(/^www\./, '');
const seps = [' - ', ' | ', ' — ', ' · ', ' '];
for (const sep of seps) {
const idx = title.lastIndexOf(sep);
if (idx === -1) continue;
const suffix = title.slice(idx + sep.length).trim();
const suffixLow = suffix.toLowerCase();
if (
suffixLow === domain.toLowerCase() ||
suffixLow === friendly.toLowerCase() ||
suffixLow === domain.replace(/\.\w+$/, '').toLowerCase() ||
domain.toLowerCase().includes(suffixLow) ||
friendly.toLowerCase().includes(suffixLow)
) {
const cleaned = title.slice(0, idx).trim();
if (cleaned.length >= 5) return cleaned;
}
}
return title;
}
function smartTitle(title, url) {
if (!url) return title || '';
let pathname = '';
let hostname = '';
try {
const u = new URL(url);
pathname = u.pathname;
hostname = u.hostname;
} catch {
return title || '';
}
const titleIsUrl = !title || title === url || title.startsWith(hostname) || title.startsWith('http');
if ((hostname === 'x.com' || hostname === 'twitter.com' || hostname === 'www.x.com') && pathname.includes('/status/')) {
const username = pathname.split('/')[1];
if (username) return titleIsUrl ? `Post by @${username}` : title;
}
if (hostname === 'github.com' || hostname === 'www.github.com') {
const parts = pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const [owner, repo, ...rest] = parts;
if (rest[0] === 'issues' && rest[1]) return `${owner}/${repo} Issue #${rest[1]}`;
if (rest[0] === 'pull' && rest[1]) return `${owner}/${repo} PR #${rest[1]}`;
if (rest[0] === 'blob' || rest[0] === 'tree') return `${owner}/${repo}${rest.slice(2).join('/')}`;
if (titleIsUrl) return `${owner}/${repo}`;
}
}
if ((hostname === 'www.youtube.com' || hostname === 'youtube.com') && pathname === '/watch') {
if (titleIsUrl) return 'YouTube Video';
}
if ((hostname === 'www.reddit.com' || hostname === 'reddit.com' || hostname === 'old.reddit.com') && pathname.includes('/comments/')) {
const parts = pathname.split('/').filter(Boolean);
const subIdx = parts.indexOf('r');
if (subIdx !== -1 && parts[subIdx + 1] && titleIsUrl) {
return `r/${parts[subIdx + 1]} post`;
}
}
return title || url;
}
// Test exposure
globalThis.friendlyDomain = friendlyDomain;
globalThis.stripTitleNoise = stripTitleNoise;
globalThis.cleanTitle = cleanTitle;
globalThis.smartTitle = smartTitle;
globalThis.capitalize = capitalize;
const ICONS = {
tabs: `<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="M3 8.25V18a2.25 2.25 0 0 0 2.25 2.25h13.5A2.25 2.25 0 0 0 21 18V8.25m-18 0V6a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 6v2.25m-18 0h18" /></svg>`,
close: `<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 18 18 6M6 6l12 12" /></svg>`,
archive: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor" aria-hidden="true"><path d="M845.312 0.512H32.512v1022.976h958.976v-876.8L845.312 0.512z m-172.864 62.976v256H351.488v-256h320.96zM287.488 960.512V605.76l29.184-29.248h390.656l29.184 29.248v354.752H287.488z m640 0h-126.976V585.152L727.424 512H296.576L223.488 585.152v375.36H96.512V63.488h190.976v320.448h449.024V63.488h79.68l111.296 112.32v784.704z m-384-832h65.984v128H543.488v-128z" /></svg>`,
focus: `<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="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>`,
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>`,
};
+262
View File
@@ -0,0 +1,262 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// Mock globals needed at module load time
globalThis.TabHarborI18n = {};
// Prevent top-level DOM code from throwing
globalThis.document = {
addEventListener: () => {},
createElement: () => ({ style: {} }),
getElementById: () => null,
};
globalThis.window = globalThis;
require('./ui-helpers.js');
const {
friendlyDomain,
stripTitleNoise,
cleanTitle,
smartTitle,
capitalize,
} = globalThis;
// ---- capitalize ----
test('capitalize uppercases first letter', () => {
assert.equal(capitalize('github'), 'Github');
assert.equal(capitalize('hello world'), 'Hello world');
});
test('capitalize handles empty string', () => {
assert.equal(capitalize(''), '');
});
test('capitalize handles null/undefined', () => {
assert.equal(capitalize(null), '');
assert.equal(capitalize(undefined), '');
});
// ---- friendlyDomain ----
test('friendlyDomain returns friendly name for known domains', () => {
assert.equal(friendlyDomain('github.com'), 'GitHub');
assert.equal(friendlyDomain('www.github.com'), 'GitHub');
assert.equal(friendlyDomain('youtube.com'), 'YouTube');
assert.equal(friendlyDomain('mail.google.com'), 'Gmail');
assert.equal(friendlyDomain('developer.mozilla.org'), 'MDN');
assert.equal(friendlyDomain('chatgpt.com'), 'ChatGPT');
assert.equal(friendlyDomain('claude.ai'), 'Claude');
assert.equal(friendlyDomain('news.ycombinator.com'), 'Hacker News');
});
test('friendlyDomain handles substack subdomains', () => {
assert.equal(friendlyDomain('example.substack.com'), "Example's Substack");
});
test('friendlyDomain handles github.io pages', () => {
assert.equal(friendlyDomain('myproject.github.io'), 'Myproject (GitHub Pages)');
});
test('friendlyDomain strips www and TLD then capitalizes unknown domains', () => {
assert.equal(friendlyDomain('www.example.org'), 'Example');
assert.equal(friendlyDomain('mail.somecompany.co.uk'), 'Mail Somecompany');
});
test('friendlyDomain capitalizes multi-part unknown domains', () => {
assert.equal(friendlyDomain('api.internal.example.com'), 'Api Internal Example');
});
test('friendlyDomain handles empty/null input', () => {
assert.equal(friendlyDomain(''), '');
assert.equal(friendlyDomain(null), '');
assert.equal(friendlyDomain(undefined), '');
});
test('friendlyDomain handles local-files special domain', () => {
assert.equal(friendlyDomain('local-files'), 'Local Files');
});
// ---- stripTitleNoise ----
test('stripTitleNoise removes notification count prefix', () => {
assert.equal(stripTitleNoise('(3) GitHub'), 'GitHub');
assert.equal(stripTitleNoise('(99+) Messages'), 'Messages');
});
test('stripTitleNoise removes inline notification counts', () => {
assert.equal(stripTitleNoise('Inbox (5+) new'), 'Inbox new');
assert.equal(stripTitleNoise('Title (1,234)'), 'Title');
});
test('stripTitleNoise removes email noise after dash separators', () => {
assert.equal(stripTitleNoise('Check - user@example.com'), 'Check');
assert.equal(stripTitleNoise('Inbox admin@site.org mail'), 'Inbox mail');
});
test('stripTitleNoise removes bare email addresses', () => {
assert.equal(stripTitleNoise('Contact user@example.com here'), 'Contact here');
});
test('stripTitleNoise handles on X: replacement', () => {
assert.equal(stripTitleNoise('Post on X: hello world'), 'Post: hello world');
});
test('stripTitleNoise strips trailing / X', () => {
assert.equal(stripTitleNoise('Share / X'), 'Share');
assert.equal(stripTitleNoise('Article / X'), 'Article');
});
test('stripTitleNoise returns clean title unchanged', () => {
assert.equal(stripTitleNoise('Clean Title'), 'Clean Title');
assert.equal(stripTitleNoise('No noise here'), 'No noise here');
});
test('stripTitleNoise trims whitespace', () => {
assert.equal(stripTitleNoise(' Title '), 'Title');
});
test('stripTitleNoise handles empty string', () => {
assert.equal(stripTitleNoise(''), '');
});
// ---- cleanTitle ----
test('cleanTitle strips suffix when it matches exact domain', () => {
assert.equal(cleanTitle('My Page - example.com', 'example.com'), 'My Page');
assert.equal(cleanTitle('Dashboard | example.com', 'www.example.com'), 'Dashboard');
});
test('cleanTitle strips suffix when it matches friendly domain (capitalized)', () => {
// friendlyDomain('example.com') → 'Example'. suffix 'Example' matches.
assert.equal(cleanTitle('Dashboard | Example', 'example.com'), 'Dashboard');
});
test('cleanTitle strips suffix when it matches domain without TLD', () => {
// friendlyDomain('example.com') → 'Example'. suffix 'example' matches domain without .com
// Title must be >= 5 chars after stripping
assert.equal(cleanTitle('Welcome Page — example', 'example.com'), 'Welcome Page');
});
test('cleanTitle strips suffix when domain includes suffix', () => {
assert.equal(cleanTitle('API Reference - api', 'api.example.com'), 'API Reference');
});
test('cleanTitle keeps title when suffix does not match domain', () => {
assert.equal(cleanTitle('GitHub - awesome-org', 'github.com'), 'GitHub - awesome-org');
assert.equal(cleanTitle('Random Text | unrelated site', 'example.com'), 'Random Text | unrelated site');
});
test('cleanTitle keeps short cleaned titles (under 5 chars)', () => {
assert.equal(cleanTitle('AB - example.com', 'example.com'), 'AB - example.com');
});
test('cleanTitle handles all separator types', () => {
assert.equal(cleanTitle('Title · example.com', 'example.com'), 'Title');
assert.equal(cleanTitle('Title example.com', 'example.com'), 'Title');
assert.equal(cleanTitle('Title — example.com', 'example.com'), 'Title');
});
test('cleanTitle strips www from domain before matching', () => {
assert.equal(cleanTitle('Page - www.example.com', 'www.example.com'), 'Page - www.example.com');
});
test('cleanTitle handles empty title or hostname', () => {
assert.equal(cleanTitle('', 'example.com'), '');
assert.equal(cleanTitle('Title', ''), 'Title');
});
test('cleanTitle handles separator with domain-friendly-includes match', () => {
// friendlyDomain('example.com') returns 'Example' (stripped TLD + capitalized)
// 'example.com'.toLowerCase().includes('example') → true ✓
assert.equal(cleanTitle('Site Name - example', 'example.com'), 'Site Name');
});
test('cleanTitle handles known domain friendly names in suffix matching', () => {
// friendlyDomain('github.com') returns 'GitHub'
assert.equal(cleanTitle('My Repos - GitHub', 'github.com'), 'My Repos');
assert.equal(cleanTitle('Dashboard | GitHub', 'github.com'), 'Dashboard');
});
// ---- smartTitle ----
test('smartTitle returns YouTube Video for youtube watch pages with URL-like title', () => {
assert.equal(smartTitle('https://www.youtube.com/watch?v=abc', 'https://www.youtube.com/watch?v=abc'), 'YouTube Video');
assert.equal(smartTitle('', 'https://www.youtube.com/watch?v=abc'), 'YouTube Video');
});
test('smartTitle returns original title for youtube watch with meaningful title', () => {
assert.equal(smartTitle('Cool Video', 'https://www.youtube.com/watch?v=abc'), 'Cool Video');
});
test('smartTitle returns Post by @username for x.com status URLs with URL-like title', () => {
assert.equal(smartTitle('https://x.com/jack/status/123', 'https://x.com/jack/status/123'), 'Post by @jack');
assert.equal(smartTitle('', 'https://twitter.com/elonmusk/status/456'), 'Post by @elonmusk');
});
test('smartTitle returns original title for x.com status with meaningful title', () => {
assert.equal(smartTitle('Interesting take', 'https://x.com/user/status/789'), 'Interesting take');
});
test('smartTitle returns GitHub issue title for issue URLs with URL-like title', () => {
assert.equal(
smartTitle('https://github.com/owner/repo/issues/42', 'https://github.com/owner/repo/issues/42'),
'owner/repo Issue #42'
);
});
test('smartTitle returns GitHub PR title for PR URLs with URL-like title', () => {
assert.equal(
smartTitle('', 'https://github.com/owner/repo/pull/100'),
'owner/repo PR #100'
);
});
test('smartTitle returns URL as title when original title is empty', () => {
assert.equal(smartTitle('', 'https://www.example.com/path'), 'https://www.example.com/path');
});
test('smartTitle returns title when it is not URL-like', () => {
assert.equal(smartTitle('My Page Title', 'https://www.example.com'), 'My Page Title');
});
test('smartTitle handles empty URL', () => {
assert.equal(smartTitle('Title', ''), 'Title');
assert.equal(smartTitle('', ''), '');
});
test('smartTitle handles invalid URL gracefully', () => {
assert.equal(smartTitle('', 'not-a-url'), '');
});
test('smartTitle does not treat youtube non-watch pages as video', () => {
assert.equal(
smartTitle('', 'https://www.youtube.com/results?search_query=foo'),
'https://www.youtube.com/results?search_query=foo'
);
});
test('smartTitle does not mangle title that starts with hostname but is actual text', () => {
// titleIsUrl checks title.startsWith(hostname) — but the real title is text
// e.g. "github.com is great" with url "https://github.com"
const label = smartTitle('github.com is great', 'https://github.com');
assert.equal(label, 'github.com is great');
});
test('smartTitle handles Reddit post URLs', () => {
assert.equal(
smartTitle('https://www.reddit.com/r/programming/comments/abc/title', 'https://www.reddit.com/r/programming/comments/abc/title'),
'r/programming post'
);
});
test('capitalize handles empty string', () => {
assert.equal(capitalize(''), '');
});
test('capitalize handles single character', () => {
assert.equal(capitalize('a'), 'A');
});
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"manifest_version": 3,
"name": "Tab Harbor",
"version": "1.1.1",
"description": "A new tab dashboard for organizing open tabs, quick links, todos, and saved tab sessions in one calm workspace.",
"permissions": ["tabs", "storage", "search", "clipboardRead", "tabGroups", "favicon"],
"chrome_url_overrides": { "newtab": "extension/index.html" },
"background": { "service_worker": "extension/background.js" },
"action": {
"default_title": "Tab Harbor",
"default_popup": "extension/popup/popup.html",
"default_icon": {
"16": "extension/icons/icon16.png",
"48": "extension/icons/icon48.png"
}
},
"icons": {
"16": "extension/icons/icon16.png",
"48": "extension/icons/icon48.png",
"128": "extension/icons/icon128.png"
}
}
+601
View File
@@ -0,0 +1,601 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Harbor Privacy Policy</title>
<meta name="description" content="Privacy Policy for Tab Harbor, a calmer Chrome new tab workspace for tabs, quick links, saved reads, and lightweight todos.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Display&family=Public+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #1a1613;
--paper: #f8f5f0;
--card: #fffdf9;
--warm-gray: #e8e2da;
--muted: #80776f;
--accent: #8a653f;
--accent-soft: #f2e7db;
--accent-border: #d4b396;
--shadow: 0 18px 42px rgba(26, 22, 19, 0.08);
--line: rgba(212, 179, 150, 0.24);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Public Sans", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(242, 231, 219, 0.8), transparent 34%),
radial-gradient(circle at top right, rgba(232, 226, 218, 0.55), transparent 28%),
var(--paper);
line-height: 1.7;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.04;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
.page {
width: min(920px, calc(100vw - 32px));
margin: 0 auto;
padding: 36px 0 72px;
position: relative;
z-index: 1;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
padding: 0 2px 12px;
border-bottom: 1px solid var(--line);
}
.brandmark {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
color: var(--ink);
text-decoration: none;
}
.brandmark-badge {
width: 38px;
height: 38px;
border-radius: 12px;
background: color-mix(in srgb, var(--accent-soft) 72%, var(--card) 28%);
border: 1px solid color-mix(in srgb, var(--accent-border) 74%, var(--card) 26%);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
flex-shrink: 0;
overflow: hidden;
}
.brandmark-badge img {
width: 24px;
height: 24px;
display: block;
}
.brandmark-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.brandmark-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
}
.brandmark-meta {
font-size: 11px;
color: var(--muted);
}
.topbar-links {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
justify-content: flex-end;
}
.topbar-link {
display: inline-flex;
align-items: center;
color: var(--accent);
text-decoration: none;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
}
.topbar-link + .topbar-link::before {
content: "/";
margin-right: 14px;
color: color-mix(in srgb, var(--muted) 76%, var(--paper) 24%);
}
.hero {
padding: 6px 2px 28px;
}
.eyebrow {
margin: 0 0 14px;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
font-weight: 700;
}
h1 {
margin: 0;
font-family: "Libre Caslon Display", serif;
font-size: clamp(42px, 7vw, 64px);
line-height: 0.95;
font-weight: 400;
letter-spacing: -0.03em;
}
.subtitle {
margin: 18px 0 0;
max-width: 60ch;
font-size: 16px;
color: color-mix(in srgb, var(--ink) 78%, var(--muted) 22%);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 18px;
color: var(--muted);
font-size: 12px;
letter-spacing: 0.01em;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 12px;
}
.meta-item + .meta-item::before {
content: "";
width: 18px;
height: 1px;
background: rgba(212, 179, 150, 0.45);
transform: translateY(-1px);
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 244px;
gap: 18px;
align-items: start;
}
.card {
background: rgba(255, 253, 249, 0.9);
border: 1px solid rgba(212, 179, 150, 0.35);
border-radius: 24px;
box-shadow: var(--shadow);
padding: 28px 28px 12px;
backdrop-filter: blur(10px);
}
.toc-card {
position: sticky;
top: 18px;
padding: 18px 18px 8px;
}
.toc-title {
margin: 0 0 12px;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
font-weight: 700;
}
.toc {
display: grid;
gap: 10px;
}
.toc a {
color: var(--accent);
text-decoration: none;
font-size: 13px;
font-weight: 600;
line-height: 1.45;
}
.toc a:hover,
.toc a:focus-visible {
text-decoration: underline;
text-underline-offset: 3px;
}
section {
padding-bottom: 18px;
margin-bottom: 8px;
border-bottom: 1px solid rgba(212, 179, 150, 0.18);
}
section:last-of-type {
border-bottom: none;
}
h2 {
margin: 0 0 12px;
font-size: 22px;
line-height: 1.2;
font-weight: 600;
color: var(--ink);
}
.legal-note {
margin: 0 0 20px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid rgba(212, 179, 150, 0.24);
background: linear-gradient(180deg, rgba(242, 231, 219, 0.54), rgba(255, 253, 249, 0.78));
}
.legal-note-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.legal-note p:last-child {
margin-bottom: 0;
}
p {
margin: 0 0 14px;
}
ul {
margin: 0 0 16px;
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.95em;
background: rgba(232, 226, 218, 0.6);
padding: 2px 6px;
border-radius: 6px;
}
.contact {
margin-top: 10px;
padding: 18px 20px;
border-radius: 18px;
background: color-mix(in srgb, var(--accent-soft) 58%, var(--card) 42%);
border: 1px solid rgba(212, 179, 150, 0.28);
}
.footer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px 20px;
margin-top: 26px;
padding: 16px 4px 0;
border-top: 1px solid rgba(212, 179, 150, 0.2);
color: var(--muted);
font-size: 12px;
}
.footer-brand {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.footer-title {
color: var(--ink);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.footer-links {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
justify-content: flex-end;
}
a {
color: var(--accent);
}
a:hover,
a:focus-visible {
text-underline-offset: 3px;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
.toc-card {
position: static;
order: -1;
}
.toc {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 16px;
}
}
@media (max-width: 720px) {
.page {
width: min(100vw - 24px, 920px);
padding: 22px 0 42px;
}
.card {
padding: 22px 20px 8px;
border-radius: 20px;
}
.subtitle {
font-size: 15px;
}
.topbar {
padding-bottom: 12px;
align-items: flex-start;
flex-direction: column;
}
.topbar-links {
justify-content: flex-start;
}
.footer {
grid-template-columns: 1fr;
}
.footer-links {
justify-content: flex-start;
}
}
</style>
</head>
<body>
<main class="page">
<div class="topbar">
<a class="brandmark" href="https://github.com/V-IOLE-T/tab-harbor" target="_blank" rel="noreferrer">
<span class="brandmark-badge" aria-hidden="true">
<img src="extension/icons/icon128.png" alt="">
</span>
<span class="brandmark-text">
<span class="brandmark-title">Tab Harbor</span>
<span class="brandmark-meta">Chrome extension privacy page</span>
</span>
</a>
<div class="topbar-links">
<a class="topbar-link" href="https://github.com/V-IOLE-T/tab-harbor" target="_blank" rel="noreferrer">Back to project home</a>
<a class="topbar-link" href="#contact">Contact</a>
<a class="topbar-link" href="https://github.com/V-IOLE-T/tab-harbor/blob/main/PRIVACY.md" target="_blank" rel="noreferrer">Markdown</a>
</div>
</div>
<header class="hero">
<p class="eyebrow">Tab Harbor</p>
<h1>Privacy Policy</h1>
<p class="subtitle">
Tab Harbor is a calmer Chrome new tab workspace for open tabs, quick links, saved reads, and lightweight todos.
This page explains what data the extension accesses, why it needs that data, and how that data is handled.
</p>
<div class="meta" aria-label="Policy metadata">
<span class="meta-item">Last updated: 2026-05-27</span>
<span class="meta-item">Local-first by default</span>
<span class="meta-item">No remote code</span>
</div>
</header>
<div class="layout">
<article class="card">
<div class="legal-note">
<p class="legal-note-title">Privacy Snapshot</p>
<p>Tab Harbor is a local-first Chrome extension. It organizes tabs, quick links, saved reads, and lightweight todos inside the new tab page. It does not require an account, does not sell user data, and does not use remote code.</p>
</div>
<section id="summary">
<h2>1. Summary</h2>
<p>Tab Harbor is designed to work locally in the browser.</p>
<ul>
<li>It does not require a user account.</li>
<li>It does not sell user data.</li>
<li>It does not use remote code.</li>
<li>It does not send workspace data to a developer-controlled backend.</li>
</ul>
<p>Most extension data is stored locally through Chrome extension storage APIs.</p>
</section>
<section id="data-accessed">
<h2>2. Information the extension accesses</h2>
<p>To provide its core features, Tab Harbor may access the following categories of data:</p>
<ul>
<li>Open tab metadata, including tab titles, URLs, and tab state</li>
<li>Quick links created by the user</li>
<li>Saved read-later items created by the user</li>
<li>Todo items created by the user</li>
<li>Theme, transparency, layout, and ordering preferences selected by the user</li>
<li>Clipboard image or SVG content, but only when the user explicitly pastes a custom shortcut icon</li>
</ul>
<p>Tab Harbor does not intentionally collect sensitive categories such as health data, financial account credentials, or personal communications.</p>
</section>
<section id="data-use">
<h2>3. How the data is used</h2>
<p>Tab Harbor uses the accessed data only to provide the extensions single purpose:</p>
<ul>
<li>Show and organize the users current browser session in the new tab page</li>
<li>Help the user review, group, focus, close, deduplicate, save, and restore tabs</li>
<li>Let the user manage quick links, saved reads, and lightweight todos in one workspace</li>
<li>Restore user preferences and layout state when the new tab page is opened again</li>
<li>Let the user paste a custom icon into a shortcut when they explicitly choose to do so</li>
</ul>
<p>Tab Harbor does not use this data for advertising, profiling, creditworthiness, or unrelated analytics.</p>
</section>
<section id="permissions">
<h2>4. Chrome permissions and why they are needed</h2>
<p>Tab Harbor requests the following Chrome permissions:</p>
<p><strong><code>tabs</code></strong></p>
<ul>
<li>display open tabs in the workspace</li>
<li>group tabs by site or workflow</li>
<li>focus a selected tab</li>
<li>close or deduplicate tabs at the users request</li>
<li>save or restore tabs as part of the read-later workflow</li>
</ul>
<p><strong><code>storage</code></strong></p>
<ul>
<li>save quick links, saved reads, todos, theme settings, layout order, and other local workspace preferences</li>
</ul>
<p><strong><code>search</code></strong></p>
<ul>
<li>let the user submit searches from the built-in search field through the browsers default search engine</li>
</ul>
<p><strong><code>clipboardRead</code></strong></p>
<ul>
<li>let the user explicitly paste an image or SVG into the shortcut editor for a custom shortcut icon</li>
</ul>
<p><strong><code>tabGroups</code></strong></p>
<ul>
<li>read existing native Chrome tab groups</li>
<li>create or update Chrome tab groups to match the workspace</li>
<li>keep group titles, colors, and collapsed state aligned with the users chosen organization</li>
<li>respond when Chrome tab groups change outside the Tab Harbor page</li>
</ul>
<p><strong><code>favicon</code></strong></p>
<ul>
<li>request and display site icons for tabs, saved sessions, and quick shortcuts more reliably</li>
<li>use Chromes packaged favicon endpoint, a sites own <code>favicon.ico</code>, or a fallback favicon service when no better icon is available</li>
</ul>
</section>
<section id="storage">
<h2>5. Data storage and retention</h2>
<p>Tab Harbor stores extension data locally using Chrome extension storage APIs, primarily <code>chrome.storage.local</code>.</p>
<p>This includes items such as quick links, saved reads, todos, theme preferences, and layout state.</p>
<p>This data remains in the browser profile until the user removes it through the extension UI, clears extension storage, or uninstalls the extension.</p>
</section>
<section id="sharing">
<h2>6. Data sharing</h2>
<p>Tab Harbor does not sell user data.</p>
<p>Tab Harbor does not transfer user data to third parties except where necessary to perform a user-requested browser action, such as opening a page chosen by the user, submitting a search through the browsers default search engine, or requesting favicon resources needed to display site icons in the workspace.</p>
<p>Aside from those normal browser actions initiated by the user, Tab Harbor does not send workspace data to a developer-controlled remote service.</p>
</section>
<section id="remote-code">
<h2>7. Remote code</h2>
<p>Tab Harbor does not use remote code.</p>
<p>All JavaScript and extension assets required to run the extension are packaged with the extension bundle. Tab Harbor does not download and execute external JavaScript or WebAssembly at runtime.</p>
</section>
<section id="third-party">
<h2>8. Third-party services</h2>
<p>Tab Harbor relies on Chromes extension platform APIs to function.</p>
<p>When a user opens a webpage or submits a search through the extension, the browser may connect to the destination website or the configured default search engine as part of the users explicit action.</p>
<p>To render site icons, Tab Harbor may also request favicon resources from a pages origin or a fallback favicon service based on the page URLs already visible in the workspace.</p>
</section>
<section id="choices">
<h2>9. User choices and controls</h2>
<p>Users can control their data by editing or deleting quick links, deleting saved reads or todos, changing theme and layout preferences, uninstalling the extension, or clearing extension storage through browser settings.</p>
<p>If you do not want Tab Harbor to access your open tabs, do not use the extension.</p>
</section>
<section id="children">
<h2>10. Childrens privacy</h2>
<p>Tab Harbor is not directed to children under 13, and the developer does not knowingly collect personal information from children.</p>
</section>
<section id="changes">
<h2>11. Changes to this policy</h2>
<p>This Privacy Policy may be updated from time to time to reflect product changes, permission changes, or legal requirements.</p>
<p>The latest version will be published at the public URL associated with the extension listing.</p>
</section>
<section id="contact">
<h2>12. Contact</h2>
<div class="contact">
<p>If you have questions about this Privacy Policy or Tab Harbor, you may contact:</p>
<p><strong>Publisher contact email:</strong> <a href="mailto:lunatic.halle@gmail.com">lunatic.halle@gmail.com</a></p>
</div>
</section>
</article>
<aside class="card toc-card">
<p class="toc-title">Contents</p>
<nav class="toc" aria-label="Table of contents">
<a href="#summary">1. Summary</a>
<a href="#data-accessed">2. Information the extension accesses</a>
<a href="#data-use">3. How the data is used</a>
<a href="#permissions">4. Chrome permissions</a>
<a href="#storage">5. Data storage and retention</a>
<a href="#sharing">6. Data sharing</a>
<a href="#remote-code">7. Remote code</a>
<a href="#third-party">8. Third-party services</a>
<a href="#choices">9. User choices and controls</a>
<a href="#children">10. Childrens privacy</a>
<a href="#changes">11. Changes to this policy</a>
<a href="#contact">12. Contact</a>
</nav>
</aside>
</div>
<footer class="footer">
<div class="footer-brand">
<span class="footer-title">Tab Harbor</span>
<span>Privacy page for Chrome Web Store review and public reference.</span>
<span>Copyright © 2026 Tab Harbor. All rights reserved.</span>
</div>
<div class="footer-links">
<a href="https://github.com/V-IOLE-T/tab-harbor" target="_blank" rel="noreferrer">GitHub repository</a>
<a href="https://github.com/V-IOLE-T/tab-harbor/blob/main/PRIVACY.md" target="_blank" rel="noreferrer">Source markdown</a>
</div>
</footer>
</main>
</body>
</html>