commit c1efa7b89e152b6f7030aefc90661b1a99173a8d Author: wehub-resource-sync Date: Mon Jul 13 12:34:04 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6316200 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 ` + + diff --git a/assets/readme/theme-custom-background.png b/assets/readme/theme-custom-background.png new file mode 100644 index 0000000..61e1091 Binary files /dev/null and b/assets/readme/theme-custom-background.png differ diff --git a/assets/readme/theme-soft-clay.png b/assets/readme/theme-soft-clay.png new file mode 100644 index 0000000..e1e534c Binary files /dev/null and b/assets/readme/theme-soft-clay.png differ diff --git a/assets/readme/theme-soft-green.png b/assets/readme/theme-soft-green.png new file mode 100644 index 0000000..65e0153 Binary files /dev/null and b/assets/readme/theme-soft-green.png differ diff --git a/assets/readme/theme-warm-neutral.png b/assets/readme/theme-warm-neutral.png new file mode 100644 index 0000000..9e291f6 Binary files /dev/null and b/assets/readme/theme-warm-neutral.png differ diff --git a/assets/readme/小型宣传图块.jpg b/assets/readme/小型宣传图块.jpg new file mode 100644 index 0000000..3cd03d2 Binary files /dev/null and b/assets/readme/小型宣传图块.jpg differ diff --git a/assets/readme/屏幕截图/feature-saved-drawer.jpg b/assets/readme/屏幕截图/feature-saved-drawer.jpg new file mode 100644 index 0000000..9561a01 Binary files /dev/null and b/assets/readme/屏幕截图/feature-saved-drawer.jpg differ diff --git a/assets/readme/屏幕截图/feature-tabs.jpg b/assets/readme/屏幕截图/feature-tabs.jpg new file mode 100644 index 0000000..14c6d19 Binary files /dev/null and b/assets/readme/屏幕截图/feature-tabs.jpg differ diff --git a/assets/readme/屏幕截图/feature-todos.jpg b/assets/readme/屏幕截图/feature-todos.jpg new file mode 100644 index 0000000..686efcd Binary files /dev/null and b/assets/readme/屏幕截图/feature-todos.jpg differ diff --git a/assets/readme/屏幕截图/theme-custom-background.jpg b/assets/readme/屏幕截图/theme-custom-background.jpg new file mode 100644 index 0000000..37373f1 Binary files /dev/null and b/assets/readme/屏幕截图/theme-custom-background.jpg differ diff --git a/assets/readme/屏幕截图/theme-soft-clay.jpg b/assets/readme/屏幕截图/theme-soft-clay.jpg new file mode 100644 index 0000000..2dc0809 Binary files /dev/null and b/assets/readme/屏幕截图/theme-soft-clay.jpg differ diff --git a/assets/readme/屏幕截图/theme-soft-green.jpg b/assets/readme/屏幕截图/theme-soft-green.jpg new file mode 100644 index 0000000..454ba36 Binary files /dev/null and b/assets/readme/屏幕截图/theme-soft-green.jpg differ diff --git a/assets/readme/屏幕截图/theme-warm-neutral.jpg b/assets/readme/屏幕截图/theme-warm-neutral.jpg new file mode 100644 index 0000000..717da73 Binary files /dev/null and b/assets/readme/屏幕截图/theme-warm-neutral.jpg differ diff --git a/assets/readme/顶部宣传图块.jpg b/assets/readme/顶部宣传图块.jpg new file mode 100644 index 0000000..cf56c81 Binary files /dev/null and b/assets/readme/顶部宣传图块.jpg differ diff --git a/docs/design-principles-and-lessons.md b/docs/design-principles-and-lessons.md new file mode 100644 index 0000000..ad1ab88 --- /dev/null +++ b/docs/design-principles-and-lessons.md @@ -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 无构建脚本的全局命名原则 + +在普通 ` + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension/list-order.js b/extension/list-order.js new file mode 100644 index 0000000..5a28042 --- /dev/null +++ b/extension/list-order.js @@ -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); diff --git a/extension/list-order.test.js b/extension/list-order.test.js new file mode 100644 index 0000000..134f161 --- /dev/null +++ b/extension/list-order.test.js @@ -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); +}); diff --git a/extension/manifest-sync.test.js b/extension/manifest-sync.test.js new file mode 100644 index 0000000..c8b8bc6 --- /dev/null +++ b/extension/manifest-sync.test.js @@ -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`, + ); + } +}); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..bf6a807 --- /dev/null +++ b/extension/manifest.json @@ -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" + } +} diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 0000000..f467a03 --- /dev/null +++ b/extension/popup/popup.css @@ -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; + } +} diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 0000000..a1cc2d6 --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,47 @@ + + + + + + Tab Harbor + + + + +
+ + + + + +
+ + + + + + + + + + + + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 0000000..84c343a --- /dev/null +++ b/extension/popup/popup.js @@ -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, '&').replace(/"/g, '"').replace(//g, '>'); +} + + +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 ` + + `; +} + +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 ` + + `; +} + +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 ` + + `; + }).join(''); + + return ` + + `; +} + +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(); diff --git a/extension/popup/popup.test.js b/extension/popup/popup.test.js new file mode 100644 index 0000000..1998dda --- /dev/null +++ b/extension/popup/popup.test.js @@ -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, '&').replace(/"/g, '"').replace(//g, '>'), + 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 & B < C > D "quote"'); +}); + +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('GitHub')); +}); + +test('renderShortcutCard renders svg icon when iconKind is svg', () => { + const shortcut = { + label: 'Dashboard', + url: 'https://dashboard.example.com', + iconKind: 'svg', + icon: '', + }; + 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('>★')); + 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"