feat: 内置 HTML 预览器 + 沙箱浏览器 tab(v0.5.0)

- html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换
- browser tab:+ 菜单多开(browser:<n>),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser)
- 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复
- 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全)
- 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行
- 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载
- 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿
This commit is contained in:
Menghuan1918
2026-08-11 19:28:48 +08:00
parent 35cde82eb2
commit 302c20109f
36 changed files with 2084 additions and 58 deletions
+63 -5
View File
@@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
@@ -454,6 +454,52 @@ describe('sidebar state', () => {
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
const browserId = 'browser:1'
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
// A partial patch leaves the other field untouched.
s = patchTab(s, browserId, { title: 'example.org' })
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
// Other tabs are untouched.
expect(leaf.tabs[0]).toBeDefined()
})
it('patchTab is a no-op for a missing tab id', () => {
const s = state()
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
expect(after).toBe(s)
})
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the field: they must keep loading.
expect(sanitizeState(base)?.nextBrowser).toBe(1)
// A present valid value survives; a malformed one falls back to 1.
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
// The default state seeds 1.
expect(makeDefaultState().nextBrowser).toBe(1)
})
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string }[] }
@@ -838,6 +884,10 @@ describe('side card preferences', () => {
autoOpenSubagent: false,
agentTerminalTools: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -851,6 +901,10 @@ describe('side card preferences', () => {
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -864,6 +918,10 @@ describe('side card preferences', () => {
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -905,9 +963,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, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -920,7 +978,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, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -928,7 +986,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, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])