From 29f162dac024981d4af593dcdc957976ef0a5ad7 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 15 Aug 2026 15:05:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8D=E5=8A=A1=E5=8C=96=20API=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20urlTarget=20=E5=A4=96=E9=93=BE=E8=AE=A4?= =?UTF-8?q?=E9=A2=86=20+=20=E6=B5=8F=E8=A7=88=E5=99=A8=20HTTP/HTTPS=20?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E8=AE=BE=E7=BD=AE=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API: - TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册 顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })), 内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget' - 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks 保留为总闸(默认开,旧文档零迁移) - 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的 URL,协议/同源纯判定(shouldInterceptLink)签名不变 - 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom 点击接管门控、parsePrefs 新字段默认值;全默认字面量同步 - 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流) --- AGENTS.md | 34 ++++++- README.md | 4 +- README_EN.md | 4 +- ...8-15-url-target-browser-settings-design.md | 89 +++++++++++++++++++ src/client/builtins/tabs.tsx | 15 +++- src/client/index.tsx | 33 +++++-- src/client/link-intercept.ts | 27 +++--- src/client/locales.ts | 12 ++- src/client/prefs.ts | 6 ++ src/client/service.ts | 45 ++++++++++ src/config.ts | 2 + src/prefs-shared.ts | 30 +++++-- tests/builtins.spec.ts | 17 ++-- tests/link-intercept.spec.ts | 71 ++++++++++++++- tests/plugin-shape.spec.ts | 2 +- tests/service.spec.ts | 51 ++++++++++- tests/smoke.spec.ts | 2 + tests/unit.spec.ts | 30 ++++++- 18 files changed, 425 insertions(+), 49 deletions(-) create mode 100644 docs/plans/2026-08-15-url-target-browser-settings-design.md diff --git a/AGENTS.md b/AGENTS.md index 1a1fabe..1764081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,16 @@ interface TabDescriptor { * 省略时用默认 { id, type, title } + seed 里的 path/diff。 */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + /** + * 外链点击目标认领(v0.13.0+):聊天/界面外链被接管(`browserInterceptLinks` + * 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置 + * 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开—— + * URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被 + * 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会 + * 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser + * 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。 + */ + urlTarget?: (url: URL) => boolean /** * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行, @@ -156,7 +166,7 @@ interface TabDescriptor { */ settings?: { toggles?: readonly { - /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ + /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */ key: string title: string | (() => string) desc?: string | (() => string) @@ -288,6 +298,24 @@ ctx.effect(() => ) ``` +**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path: +```ts +ctx.effect(() => { + if (!ctx.betterSidebar.features.includes('urlTarget')) return + return ctx.betterSidebar.registerTab({ + id: 'my-plugin:web-docs', + title: () => 'Docs', + order: 80, + urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领 + createTab: (state) => ({ + tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' }, + patch: { nextBrowser: state.nextBrowser + 1 }, + }), + component: ({ tab, scope }) => , + }) +}) +``` + ### 3.4 内置 tab(不可重复注册) | id | order | single | hidden | 用途 | @@ -472,7 +500,7 @@ interface BetterSidebarService { readonly version: string /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | - * 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ + * 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 * session 未激活时 state/sessionId 为 undefined。 */ @@ -503,7 +531,7 @@ interface OpenTabSeed { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。 --- diff --git a/README.md b/README.md index 7894010..ee06284 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ✨ 功能一览 - **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 -- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe,外链默认在侧边栏打开 +- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 - **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止) @@ -38,7 +38,7 @@ | 功能 | 说明 | 截图 | |---|---|---| -| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`) | 服务化基座截图 | +| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`) | 服务化基座截图 | | ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | 添加插件截图 | | 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | | | 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | | diff --git a/README_EN.md b/README_EN.md index 1517cdb..749c00a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -20,7 +20,7 @@ ## ✨ Features - **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office -- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links open in the sidebar by default +- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings) - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert - **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill) @@ -38,7 +38,7 @@ | Feature | Description | Screenshot | |---|---|---| -| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) | Service API base screenshot | +| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) | Service API base screenshot | | ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | Add Plugins screenshot | | 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | | | 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | | diff --git a/docs/plans/2026-08-15-url-target-browser-settings-design.md b/docs/plans/2026-08-15-url-target-browser-settings-design.md new file mode 100644 index 0000000..681c3ff --- /dev/null +++ b/docs/plans/2026-08-15-url-target-browser-settings-design.md @@ -0,0 +1,89 @@ +# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置 + +**日期**:2026-08-15 +**状态**:已实施(本文档含实施偏差记录) +**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7) + +## 1. 目标 + +基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API: + +1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。 +2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内): + - 侧边打开 HTTP 网页(默认开) + - 侧边打开 HTTPS 网页(默认关) + - `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。 + +## 2. 非目标 + +- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。 +- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。 +- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。 +- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。 +- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。 + +## 3. 现状回顾 + +- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。 +- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。 +- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。 + +## 4. 设计 + +### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`) + +```ts +interface SidebarPrefs { + // … + browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移) + browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true + browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false +} +``` + +三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。 + +### 4.2 服务 API(`service.ts`) + +```ts +interface TabDescriptor { + // … + urlTarget?: (url: URL) => boolean +} + +/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */ +export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined +``` + +- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。 +- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。 +- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。 + +### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`) + +- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。 +- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。 + +### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`) + +浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。 + +## 5. 行为变化 + +- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。 +- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。 +- 总闸关闭 → 一律不拦截(旧文档语义保留)。 + +## 6. 测试 + +- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。 +- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。 +- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。 +- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。 +- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。 + +## 7. 实施偏差记录 + +- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。 +- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。 +- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。 diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 943d593..049d3e7 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -187,9 +187,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { title: () => t('browser'), icon: (size: number) => , order: 50, - // Declarative settings: the sandbox escape hatch and the link - // takeover render under this tab's row in the Side card settings - // page (the sandbox one is warned on). + // Declarative settings: the sandbox escape hatch, the link-takeover + // MASTER switch, and the per-protocol takeover switches (http on / + // https off by default) render under this tab's row in the Side card + // settings page (the sandbox one is warned on). settings: { toggles: [{ key: 'browserNoSandbox', @@ -199,6 +200,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] { key: 'browserInterceptLinks', title: () => t('settingsBrowserLinksTitle'), desc: () => t('settingsBrowserLinksDesc'), + }, { + key: 'browserInterceptHttp', + title: () => t('settingsBrowserHttpTitle'), + desc: () => t('settingsBrowserHttpDesc'), + }, { + key: 'browserInterceptHttps', + title: () => t('settingsBrowserHttpsTitle'), + desc: () => t('settingsBrowserHttpsDesc'), }], }, createTab: (state) => ({ diff --git a/src/client/index.tsx b/src/client/index.tsx index e0950f3..69af743 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -12,7 +12,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import type { Context } from '../context-types.ts' import { createSidebarStore } from './state.ts' -import { createBetterSidebarService } from './service.ts' +import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { resetChunks } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' import { Sidebar } from './Sidebar.tsx' @@ -153,16 +153,35 @@ export function apply(ctx: Context): void { ctx.effect( () => { try { - // External http(s) links in the chat/GUI open the sidebar browser - // instead of a new window (gated on the browserInterceptLinks pref - // AND the browser tab's enable switch; Ctrl/Cmd+click bypasses). + // External http(s) links in the chat/GUI open the sidebar instead + // of a new window. Gated on the browserInterceptLinks MASTER pref, + // the URL's protocol flag (browserInterceptHttp / Https — https + // defaults OFF: most https sites refuse iframe embedding), and the + // target tab's enable switch; Ctrl/Cmd+click always bypasses. The + // target is the first registered tab whose `urlTarget` claims the + // URL (enabled tabs only), else the built-in browser tab. + const urlTargetOf = (url: URL): string | undefined => { + const prefs = sidebarStore.getPrefs() + const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false) + return matchUrlTarget(enabled, url)?.id + } return registerLinkInterception({ - takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false - && sidebarStore.getPrefs().tabsEnabled['browser'] !== false, + takeoverEnabled: (url) => { + const prefs = sidebarStore.getPrefs() + if (prefs.browserInterceptLinks === false) return false + const protocolOn = url.protocol === 'https:' + ? prefs.browserInterceptHttps !== false + : prefs.browserInterceptHttp !== false + if (!protocolOn) return false + // A plugin claim is the target (already enabled-filtered); + // otherwise the built-in browser must be enabled. + return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false + }, openInSidebar: (url) => { let title: string | undefined try { title = new URL(url).hostname } catch { /* keep the default title */ } - ctx.betterSidebar?.openTab({ type: 'browser', url, title }) + const type = urlTargetOf(new URL(url)) ?? 'browser' + ctx.betterSidebar?.openTab({ type, url, title }) }, selfOrigin: window.location.origin, }) diff --git a/src/client/link-intercept.ts b/src/client/link-intercept.ts index af782d6..3d50c4a 100644 --- a/src/client/link-intercept.ts +++ b/src/client/link-intercept.ts @@ -1,10 +1,12 @@ /** * Chat/GUI external-link interception: clicking an http(s) link that points * OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the - * sidebar browser instead of a new browser tab. Gated by BOTH the - * `browserInterceptLinks` pref and the browser tab's enable switch; a - * Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the - * user can still force a real browser tab. + * sidebar instead of a new browser tab. Gated by the caller through + * `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's + * protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the + * target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click + * always bypasses the takeover so the user can still force a real browser + * tab. * * Only the GUI's OWN document is watched — links inside the browser tab's * sandboxed iframe live in another document and never bubble here (and @@ -13,7 +15,10 @@ /** The pure decision: the URL to open in the sidebar, or null to let the * click fall through. Extracted so the policy is unit-testable without a - * DOM. `anchorHref` must be the ABSOLUTE href (`.href` already is). */ + * DOM. `anchorHref` must be the ABSOLUTE href (`.href` already is). + * The protocol/same-origin policy lives HERE; the prefs gates (master + + * protocol flags + target enablement) live in the caller's + * `takeoverEnabled(url)` callback. */ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null { let url: URL try { @@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str } if (url.protocol !== 'http:' && url.protocol !== 'https:') return null // Same-origin links are GUI-internal navigation (settings pages, tool - // docs) — never routed into the sidebar browser. + // docs) — never routed into the sidebar. try { if (url.origin === new URL(selfOrigin).origin) return null } catch { @@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl /** * Register the document-level click capture that funnels external links - * into the sidebar browser. Returns the disposer (HMR-safe). + * into the sidebar. Returns the disposer (HMR-safe). */ export function registerLinkInterception(opts: { - takeoverEnabled: () => boolean - /** Open the sidebar browser tab at `url` (the caller resolves the session). */ + /** Whether the takeover may happen for THIS url (the caller's prefs + * gates: master switch, protocol flag, target enablement). */ + takeoverEnabled: (url: URL) => boolean + /** Open the sidebar tab at `url` (the caller resolves the target type). */ openInSidebar: (url: string) => void /** The GUI's own origin (window.location.origin at registration). */ selfOrigin: string @@ -51,13 +58,13 @@ export function registerLinkInterception(opts: { const onClick = (event: MouseEvent): void => { if (!isPlainLeftClick(event)) return if (event.defaultPrevented) return - if (!opts.takeoverEnabled()) return const target = event.target if (target === null || typeof (target as Element).closest !== 'function') return const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null if (anchor === null) return const url = shouldInterceptLink(anchor.href, opts.selfOrigin) if (url === null) return + if (!opts.takeoverEnabled(new URL(url))) return event.preventDefault() opts.openInSidebar(url) } diff --git a/src/client/locales.ts b/src/client/locales.ts index 448171a..b34973b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -166,7 +166,11 @@ export const zh = { settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)', settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启', settingsBrowserLinksTitle: '聊天区外链在侧边栏打开', - settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行', + settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行', + settingsBrowserHttpTitle: '侧边打开HTTP网页', + settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行', + settingsBrowserHttpsTitle: '侧边打开HTTPS网页', + settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅', browserOpenExternal: '在浏览器中打开', browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', @@ -378,7 +382,11 @@ export const en: Record = { settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)', settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites', settingsBrowserLinksTitle: 'Open chat external links in the sidebar', - settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses', + settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses', + settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar', + settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses', + settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar', + settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default', browserOpenExternal: 'Open in browser', browserEmbedBlocked: '{host} refused to be embedded', browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 9f993e5..2d0895a 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -71,6 +71,12 @@ export function parsePrefs(value: unknown): SidebarPrefs { browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean' ? record.browserInterceptLinks : SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks, + browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean' + ? record.browserInterceptHttp + : SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp, + browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean' + ? record.browserInterceptHttps + : SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps, tabsEnabled: booleanMapOf(record.tabsEnabled), viewersEnabled: booleanMapOf(record.viewersEnabled), pluginSettings: pluginSettingsMapOf(record.pluginSettings), diff --git a/src/client/service.ts b/src/client/service.ts index 2aa34c4..2476dca 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -169,6 +169,22 @@ export interface TabDescriptor { * When omitted, a default `{ id, type, title }` tab is created. */ createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial } | null + /** + * External-link target claim (v0.13.0+): when a GUI external-link click + * is taken over (the `browserInterceptLinks` master AND the URL's + * protocol flag — `browserInterceptHttp` / `browserInterceptHttps` — + * are on), the first registered tab whose `urlTarget(url)` returns true + * is opened with `openTab({ type, url, title: hostname })` — the URL is + * the whole payload (the tab reads it from `tab.path`). Registration + * order wins (first claim first served); a disabled tab type is skipped; + * a throwing predicate is swallowed (console.error, the type is skipped). + * The built-in browser tab declares NO urlTarget — it stays the implicit + * fallback target, so plugins can never be shadowed by it. To host more + * than one URL at a time, mint per-URL ids through `createTab` (the + * browser builtin's pattern); otherwise the id safety net focuses the + * existing tab of the same type and the new URL is not applied. + */ + urlTarget?: (url: URL) => boolean /** * Declarative settings shown in the Side card settings page: every * registered tab gets an enable/disable switch (icon + title + id), and @@ -373,6 +389,33 @@ function baseNameOf(path: string): string { return at === -1 ? path : path.slice(at + 1) } +/** + * Find the tab type that claims an intercepted external-link URL (v0.13.0+). + * Walks the descriptors in REGISTRATION order and returns the first one + * that declares `urlTarget` and matches `url`; a throwing predicate is + * swallowed (console.error, type skipped) so one broken plugin can never + * break the whole link pipeline. The caller passes the ENABLED tab + * descriptors (enablement is the caller's prefs domain — filter + * `service.getTabs()` through `tabsEnabled` before matching) and falls + * back to the built-in browser tab when nothing claims the URL (the + * browser never declares `urlTarget` itself, so it can never shadow a + * plugin claim). + */ +export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined { + for (const tab of tabs) { + if (tab.urlTarget === undefined) continue + let claimed = false + try { + claimed = tab.urlTarget(url) === true + } catch (error) { + console.error('[dsh-better-sidebar] urlTarget error:', error) + continue + } + if (claimed) return tab + } + return undefined +} + /** * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. @@ -390,6 +433,7 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1' * - 'stateSubscription': getSnapshot/subscribeState * - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence) * - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render + * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) */ export const SIDEBAR_FEATURES = [ 'badge', @@ -400,6 +444,7 @@ export const SIDEBAR_FEATURES = [ 'stateSubscription', 'tabMeta', 'pluginSettings', + 'urlTarget', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ diff --git a/src/config.ts b/src/config.ts index b64198e..5f0d4d3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -95,6 +95,8 @@ export const PrefsSchema: z = z.object({ htmlViewerDefaultUnsafe: z.boolean().default(false), browserNoSandbox: z.boolean().default(false), browserInterceptLinks: z.boolean().default(true), + browserInterceptHttp: z.boolean().default(true), + browserInterceptHttps: z.boolean().default(false), // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or // external): an absent key means enabled, so old documents resolve to {} // (everything on) with no migration. Non-boolean values fail validation. diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index a3b6210..0cd576d 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -85,13 +85,31 @@ export interface SidebarPrefs { */ browserNoSandbox: boolean /** - * Whether clicking an http(s) EXTERNAL link in the GUI (chat messages, - * tool rows, prose mentions) opens the sidebar browser instead of a new - * browser tab. On by default; gated on the browser tab's own enable - * switch (both must be on for the takeover). Ctrl/Cmd+click always - * bypasses the takeover. + * MASTER switch: whether clicking an EXTERNAL link in the GUI (chat + * messages, tool rows, prose mentions) is taken over into the sidebar at + * all. On by default; the per-protocol granularity lives in + * `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag + * must also be on), and the target tab's own enable switch gates it too. + * Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old + * documents keep their meaning with no migration (an explicit `false` + * stays "never take over"). */ browserInterceptLinks: boolean + /** + * Whether clicking an http EXTERNAL link in the GUI opens the sidebar + * (the built-in browser tab, or a plugin tab that declares `urlTarget`) + * instead of a new browser tab. On by default; gated on the + * `browserInterceptLinks` master and the target tab's own enable switch. + */ + browserInterceptHttp: boolean + /** + * Whether clicking an https EXTERNAL link in the GUI opens the sidebar + * instead of a new browser tab. OFF by default — most https sites (e.g. + * GitHub) refuse iframe embedding, so the system browser is the smoother + * default; gated on the `browserInterceptLinks` master and the target + * tab's own enable switch. + */ + browserInterceptHttps: boolean /** * Per-tab enable switches, keyed by tab descriptor id (`'explorer'`, * `'my-plugin:db'`). An ABSENT key means enabled — only an explicit @@ -145,6 +163,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {}, diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 14e726e..20ed655 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -67,14 +67,19 @@ describe('built-in tab registrations', () => { expect(toggles[1]?.type ?? 'switch').toBe('switch') }) - it('the browser tab declares its sandbox and link-takeover related settings', () => { + it('the browser tab declares its sandbox, link-takeover master and per-protocol settings', () => { const { service } = setup() const toggles = service.getTab('browser')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks']) - expect(toggles[0]?.title).toBeDefined() - expect(toggles[0]?.desc).toBeDefined() - expect(toggles[1]?.title).toBeDefined() - expect(toggles[1]?.desc).toBeDefined() + expect(toggles.map(t => t.key)).toEqual([ + 'browserNoSandbox', + 'browserInterceptLinks', + 'browserInterceptHttp', + 'browserInterceptHttps', + ]) + for (const toggle of toggles) { + expect(toggle.title).toBeDefined() + expect(toggle.desc).toBeDefined() + } }) it('the browser createTab mints browser: ids and bumps nextBrowser', () => { diff --git a/tests/link-intercept.spec.ts b/tests/link-intercept.spec.ts index 1d083c9..74d3e84 100644 --- a/tests/link-intercept.spec.ts +++ b/tests/link-intercept.spec.ts @@ -1,10 +1,12 @@ +// @vitest-environment jsdom /** * External-link interception policy tests: the pure decision (which http(s) - * external links are taken over into the sidebar browser) and the click - * shape (plain left clicks only — modified clicks always bypass). + * external links are taken over into the sidebar browser), the click shape + * (plain left clicks only — modified clicks always bypass), and the + * registered click capture (the caller's `takeoverEnabled(url)` gate). */ import { describe, expect, it } from 'vitest' -import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts' +import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts' const SELF = 'http://127.0.0.1:3080' @@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => { expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false) }) }) + +describe('registerLinkInterception', () => { + /** Dispatch one click on an anchor with the given absolute href. */ + const clickAnchor = (href: string, init: MouseEventInit = {}): void => { + const anchor = document.createElement('a') + anchor.href = href + document.body.appendChild(anchor) + anchor.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + ...init, + })) + anchor.remove() + } + + it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => { + const seen: string[] = [] + const opened: string[] = [] + const dispose = registerLinkInterception({ + takeoverEnabled: (url) => { + seen.push(url.protocol) + return url.protocol === 'http:' + }, + openInSidebar: (url) => { opened.push(url) }, + selfOrigin: SELF, + }) + // https → the gate refuses (the sidebar-takeover defaults: https off). + clickAnchor('https://example.com/page') + // http → the gate passes, the click is taken over with the absolute href. + clickAnchor('http://example.com/page') + expect(seen).toEqual(['https:', 'http:']) + expect(opened).toEqual(['http://example.com/page']) + dispose() + }) + + it('never consults the gate for same-origin or non-http(s) links', () => { + let gateCalls = 0 + const dispose = registerLinkInterception({ + takeoverEnabled: () => { gateCalls++; return true }, + openInSidebar: () => { throw new Error('must not open') }, + selfOrigin: SELF, + }) + clickAnchor(`${SELF}/settings`) + clickAnchor('mailto:a@b.c') + expect(gateCalls).toBe(0) + dispose() + }) + + it('bypasses the takeover on modified clicks even when the gate passes', () => { + let gateCalls = 0 + const dispose = registerLinkInterception({ + takeoverEnabled: () => { gateCalls++; return true }, + openInSidebar: () => { throw new Error('must not open') }, + selfOrigin: SELF, + }) + clickAnchor('http://example.com/', { ctrlKey: true }) + clickAnchor('http://example.com/', { metaKey: true }) + clickAnchor('http://example.com/', { button: 1 }) + expect(gateCalls).toBe(0) + dispose() + }) +}) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 1de4bcf..c549ae1 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -63,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/service.spec.ts b/tests/service.spec.ts index b4487a4..be18dde 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -20,7 +20,7 @@ if (g.localStorage === undefined) { } } -import { createBetterSidebarService, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts' +import { createBetterSidebarService, matchUrlTarget, SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from '../src/client/service.ts' import { createSidebarStore, allLeaves, makeDefaultState, openDiffTab, sanitizeState } from '../src/client/state.ts' describe('BetterSidebar service', () => { @@ -223,6 +223,55 @@ describe('matchFileViewer', () => { }) }) +describe('matchUrlTarget (v0.13.0)', () => { + /** A tab descriptor that claims a URL host. */ + const claimingTab = (id: string, host: string) => ({ + id, + title: id, + urlTarget: (url: URL): boolean => url.hostname === host, + component: () => null, + }) + + it('returns undefined when no tab declares urlTarget', () => { + const tabs = [ + { id: 'explorer', title: 'E', component: () => null }, + { id: 'git', title: 'G', component: () => null }, + ] + expect(matchUrlTarget(tabs, new URL('http://example.com/'))).toBeUndefined() + }) + + it('returns the first REGISTRATION-ORDER claim (first match wins)', () => { + const tabs = [ + claimingTab('my:one', 'example.com'), + claimingTab('my:two', 'example.com'), + ] + expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:one') + // A non-matching earlier tab yields to the next match. + const tabs2 = [ + claimingTab('my:one', 'other.com'), + claimingTab('my:two', 'example.com'), + ] + expect(matchUrlTarget(tabs2, new URL('https://example.com/x'))?.id).toBe('my:two') + }) + + it('skips a throwing predicate (the next claim still wins)', () => { + const tabs = [ + { id: 'my:broken', title: 'B', urlTarget: () => { throw new Error('boom') }, component: () => null }, + claimingTab('my:ok', 'example.com'), + ] + expect(matchUrlTarget(tabs, new URL('https://example.com/x'))?.id).toBe('my:ok') + }) + + it('never matches the built-in browser (no urlTarget declared — the caller falls back)', () => { + const tabs = [ + { id: 'browser', title: 'Browser', component: () => null }, + { id: 'my:ok', title: 'OK', urlTarget: () => true, component: () => null }, + ] + expect(matchUrlTarget(tabs, new URL('http://example.com/'))?.id).toBe('my:ok') + expect(matchUrlTarget([{ id: 'browser', title: 'Browser', component: () => null }], new URL('http://example.com/'))).toBeUndefined() + }) +}) + describe('service.openTab dedupe', () => { it('dedupeKey focuses existing tab instead of duplicating', () => { const store = createSidebarStore() diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index ef13e3a..5529fbf 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -524,6 +524,8 @@ describe('side card settings routes', () => { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, // The enable-switch maps default to {} (everything on). tabsEnabled: {}, viewersEnabled: {}, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 9c88765..c3a965e 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1144,6 +1144,8 @@ describe('side card preferences', () => { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {}, @@ -1166,6 +1168,8 @@ describe('side card preferences', () => { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {}, @@ -1188,6 +1192,8 @@ describe('side card preferences', () => { htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, + browserInterceptHttp: true, + browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {}, @@ -1218,6 +1224,22 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) }) + it('defaults the link-takeover protocol flags: http on, https off, master on', async () => { + // Absent or malformed → the per-protocol defaults. + expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true) + expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true) + expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false) + expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true) + expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false) + // Explicit booleans survive verbatim. + expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false) + expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true) + // The master is independent of the protocol flags (an explicit master + // false stays "never take over" regardless of the flags). + expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })))) + .toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }) + }) + it('resolves the terminal font prefs (family passthrough, size clamp)', async () => { // Absent → theme default (empty family) + default size. expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('') @@ -1251,9 +1273,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -1289,7 +1311,7 @@ describe('side card preferences', () => { it('skips the default explorer tab when the explorer type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} }) store.setSession('no-explorer') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -1297,7 +1319,7 @@ describe('side card preferences', () => { expect(state.splits.kind).toBe('leaf') // Re-enabling seeds the explorer tab again. const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) openStore.setSession('with-explorer') const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])