feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
基于 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(外链分流)
This commit is contained in:
+11
-6
@@ -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:<n> ids and bumps nextBrowser', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ 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: {} })
|
||||
})
|
||||
})
|
||||
|
||||
+50
-1
@@ -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()
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
+26
-4
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user