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:
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* The built-in browser tab: an address bar plus a sandboxed iframe.
|
||||
*
|
||||
* Security model (see browser.ts + the sandbox tokens below): the iframe is
|
||||
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
|
||||
* page can never sit on the GUI's origin, read its storage, or reach
|
||||
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
|
||||
* the GUI). The address bar only accepts http(s) and refuses loopback /
|
||||
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
|
||||
* sandbox attribute entirely for fully trusted sites — the visited page then
|
||||
* runs with the GUI's own origin and full session access, so a persistent
|
||||
* warning bar renders while it is off.
|
||||
*
|
||||
* The URL is persisted onto the tab (path/title via the patchTab reducer)
|
||||
* so a reload restores the visited page; the back/forward stack only tracks
|
||||
* address-bar navigations (in-frame link clicks are cross-origin and
|
||||
* invisible — a documented limitation).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
IconChevronLeftOutline14,
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { TabComponentProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
|
||||
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
|
||||
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
|
||||
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
|
||||
* popups open as normal tabs (they are cross-origin to the GUI either way).
|
||||
*/
|
||||
export const BROWSER_IFRAME_SANDBOX =
|
||||
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
|
||||
|
||||
export function BrowserView(props: TabComponentProps) {
|
||||
const { store, tab } = props
|
||||
// The current address (initialized from the persisted tab.path so a
|
||||
// reload restores the visited page).
|
||||
const [url, setUrl] = useState<string | undefined>(tab.path)
|
||||
const [input, setInput] = useState<string>(tab.path ?? '')
|
||||
/** Blocked/invalid hint shown under the address bar (null = none). */
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
/** Address-bar navigation history (in-frame clicks are not tracked). */
|
||||
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
|
||||
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
|
||||
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
|
||||
* side card setting; lasts until the tab unmounts or the user restores). */
|
||||
const [localUnlock, setLocalUnlock] = useState(false)
|
||||
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
|
||||
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
|
||||
* the probe verdict shown instead of the blank iframe. */
|
||||
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
|
||||
/** The user asked to load the refused site anyway (keeps the plain iframe). */
|
||||
const [forceEmbed, setForceEmbed] = useState(false)
|
||||
|
||||
// Probe every navigation (address bar, history, restored path): when the
|
||||
// target forbids embedding, show the reason + open-in-browser instead of
|
||||
// the browser's cryptic "refused to connect" blank frame. A failed probe
|
||||
// (unreachable) keeps the plain iframe.
|
||||
useEffect(() => {
|
||||
if (url === undefined) return
|
||||
let cancelled = false
|
||||
setEmbedBlocked(null)
|
||||
setForceEmbed(false)
|
||||
void api.browserProbe(url).then((probe) => {
|
||||
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
|
||||
}).catch(() => { /* unreachable: keep the plain iframe */ })
|
||||
return () => { cancelled = true }
|
||||
}, [url])
|
||||
|
||||
const persist = (nextUrl: string): void => {
|
||||
let host = nextUrl
|
||||
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
|
||||
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
|
||||
}
|
||||
|
||||
const navigateTo = (raw: string): void => {
|
||||
const result = normalizeBrowserUrl(raw, window.location.origin)
|
||||
if (result.kind === 'ok') {
|
||||
const next = result.url
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setMessage(null)
|
||||
// Push onto the stack, dropping any stale forward entries.
|
||||
setHistory(previous => [...previous.slice(0, cursor + 1), next])
|
||||
setCursor(previous => previous + 1)
|
||||
setReloadKey(key => key + 1)
|
||||
persist(next)
|
||||
return
|
||||
}
|
||||
setMessage(result.kind === 'invalid'
|
||||
? t('browserInvalid')
|
||||
: result.reason === 'scheme' ? t('browserBlockedScheme')
|
||||
: t('browserBlockedLoopback'))
|
||||
}
|
||||
|
||||
const goBack = (): void => {
|
||||
if (cursor <= 0) return
|
||||
const next = history[cursor - 1]!
|
||||
setCursor(cursor - 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
const goForward = (): void => {
|
||||
if (cursor >= history.length - 1) return
|
||||
const next = history[cursor + 1]!
|
||||
setCursor(cursor + 1)
|
||||
setUrl(next)
|
||||
setInput(next)
|
||||
setReloadKey(key => key + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.browser}>
|
||||
<div className={css.browserBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserBack')}
|
||||
title={t('browserBack')}
|
||||
disabled={cursor <= 0}
|
||||
onClick={goBack}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserForward')}
|
||||
title={t('browserForward')}
|
||||
disabled={cursor >= history.length - 1}
|
||||
onClick={goForward}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
onClick={() => { setReloadKey(key => key + 1) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
<input
|
||||
className={css.browserInput}
|
||||
value={input}
|
||||
placeholder={t('browserPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={event => { setInput(event.target.value) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') navigateTo(input)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserGo')}
|
||||
title={t('browserGo')}
|
||||
onClick={() => { navigateTo(input) }}
|
||||
>
|
||||
<IconLinkOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('browserOpenExternal')}
|
||||
title={t('browserOpenExternal')}
|
||||
disabled={url === undefined}
|
||||
onClick={() => {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
<SandboxStatusBar
|
||||
sandboxed={!noSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('browserNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{url === undefined ? (
|
||||
<div className={css.browserStart}>{t('browserStart')}</div>
|
||||
) : embedBlocked !== null && !forceEmbed ? (
|
||||
<BrowserEmbedBlocked
|
||||
url={embedBlocked}
|
||||
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
|
||||
onLoadAnyway={() => { setForceEmbed(true) }}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
||||
className={css.browserFrame}
|
||||
src={url}
|
||||
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed-refusal panel: shown when the probed site forbids being
|
||||
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
|
||||
* iframe would only show the browser's "refused to connect" blank. Explains
|
||||
* the reason and offers the real-browser open plus a load-anyway escape.
|
||||
* Exported so the copy and the actions are testable without a DOM.
|
||||
*/
|
||||
export function BrowserEmbedBlocked(props: {
|
||||
url: string
|
||||
onOpenInBrowser: () => void
|
||||
onLoadAnyway: () => void
|
||||
}) {
|
||||
const { url, onOpenInBrowser, onLoadAnyway } = props
|
||||
let host = url
|
||||
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
|
||||
return (
|
||||
<div className={css.browserBlocked}>
|
||||
<IconWarningOutline16 size={16} />
|
||||
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
|
||||
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
|
||||
<div className={css.browserBlockedActions}>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
|
||||
{t('browserOpenExternal')}
|
||||
</button>
|
||||
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
|
||||
{t('browserEmbedAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The live sandbox status row of the two built-in web surfaces (HTML
|
||||
* preview and the browser tab): a green "sandbox on" state with a one-tap
|
||||
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
|
||||
* temporary unlock) with a restore action.
|
||||
*
|
||||
* The temporary unlock is component state only — it never writes the
|
||||
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
|
||||
* it lasts until the surface unmounts (tab switch / file switch) or the
|
||||
* user restores the sandbox from the row. When the global setting already
|
||||
* drops the sandbox, no unlock/restore action is offered (changing the
|
||||
* global setting is the settings page's job) — the red warning stands.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function SandboxStatusBar(props: {
|
||||
/** The effective sandbox state (global pref OR the local temporary unlock). */
|
||||
sandboxed: boolean
|
||||
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
|
||||
local: boolean
|
||||
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
|
||||
dangerCopy: string
|
||||
onUnlock: () => void
|
||||
onRestore: () => void
|
||||
}) {
|
||||
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
|
||||
if (sandboxed) {
|
||||
const copy = t('sandboxStatusOn')
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onUnlock}
|
||||
>
|
||||
{t('sandboxUnlock')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
|
||||
<span className={css.sandboxDot} />
|
||||
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sandboxAction}
|
||||
onClick={onRestore}
|
||||
>
|
||||
{t('sandboxRestore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,12 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
and scroll inside the section when the content outgrows the panel. */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
|
||||
@@ -15,10 +15,11 @@ import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -26,6 +27,15 @@ import css from './sidebar.module.css'
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
* could read session data) and NO allow-top-navigation (a previewed page
|
||||
* must not hijack the GUI). The user can disable the sandbox per-feature
|
||||
* in the side card settings (warned); the toggle below reflects it.
|
||||
*/
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
@@ -136,13 +146,23 @@ export function TextEditor(props: FileViewerProps) {
|
||||
}
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
||||
// plus a per-surface temporary unlock. The unlock state starts at the
|
||||
// "default unsafe" pref so a preview can open straight into the red
|
||||
// unsandboxed state (still restorable from the status row). With the
|
||||
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
|
||||
// the previewed page then runs on the GUI's own origin with full session
|
||||
// access.
|
||||
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
|
||||
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css.editorHeader}>
|
||||
{markdown && (
|
||||
{(markdown || html) && (
|
||||
<div className={css.editorModeToggle}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,7 +198,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
<>
|
||||
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
|
||||
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
@@ -188,6 +208,29 @@ export function TextEditor(props: FileViewerProps) {
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
<>
|
||||
<SandboxStatusBar
|
||||
sandboxed={!htmlNoSandbox}
|
||||
local={localUnlock}
|
||||
dangerCopy={t('htmlNoSandboxWarning')}
|
||||
onUnlock={() => { setLocalUnlock(true) }}
|
||||
onRestore={() => { setLocalUnlock(false) }}
|
||||
/>
|
||||
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
|
||||
origin when unsandboxed; the route URL keeps the frame
|
||||
cross-origin by construction). The preview shows the SAVED
|
||||
file; the draft is only visible in edit mode. */}
|
||||
<iframe
|
||||
className={css.editorHtml}
|
||||
src={htmlUrl(scope, path)}
|
||||
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={path}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* hydrating at page load (a detached session would otherwise fail the
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
export class SidebarApiError extends Error {
|
||||
@@ -152,6 +154,10 @@ export const api = {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
/** Probe a URL's response headers (the sidebar browser's embeddability
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
@@ -172,3 +178,10 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
|
||||
if (download) params.set('download', '1')
|
||||
return `/sidebar/file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
|
||||
* fully encoded so the previewed page's relative assets resolve back into
|
||||
* the same route with the session scope intact. */
|
||||
export function htmlUrl(scope: SessionScope, path: string): string {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Pure URL policy for the built-in browser tab: normalize user input into
|
||||
* an http(s) URL, and refuse destinations that would be dangerous to embed
|
||||
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
|
||||
*
|
||||
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
|
||||
* is the primary security boundary; this module is the address-bar gate on
|
||||
* top of it: only http/https may be navigated, and loopback addresses are
|
||||
* refused so a browsed page cannot probe local services by user action.
|
||||
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
|
||||
* itself in the sidebar (debugging, mirroring); the sandbox still renders
|
||||
* it in an opaque origin with no same-origin privileges, exactly like any
|
||||
* other site.
|
||||
*/
|
||||
|
||||
/** Why a navigation attempt was refused. */
|
||||
export type BrowserBlockReason = 'scheme' | 'loopback'
|
||||
|
||||
/** Result of normalizing one address-bar input. */
|
||||
export type BrowserNavigateResult =
|
||||
| { kind: 'ok'; url: string }
|
||||
| { kind: 'blocked'; reason: BrowserBlockReason }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/** One browser.probe wire result (host fetch of the target's headers). */
|
||||
export interface BrowserProbeResult {
|
||||
reachable: boolean
|
||||
/** The final (post-redirect) URL; present when reachable. */
|
||||
url?: string
|
||||
status?: number
|
||||
xFrameOptions?: string
|
||||
/** The CSP frame-ancestors source list; present when the directive exists. */
|
||||
frameAncestors?: string[]
|
||||
}
|
||||
|
||||
/** Embeddability verdict of one probe. */
|
||||
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
|
||||
|
||||
/**
|
||||
* Decide whether a site can render inside the sidebar iframe. The signals
|
||||
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
|
||||
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
|
||||
* not allow `*` ('self' here means the SITE's own origin — never ours, so
|
||||
* it also blocks the sidebar). A site we could not reach yields 'unknown'
|
||||
* and the plain iframe stays.
|
||||
*/
|
||||
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
|
||||
if (probe.reachable !== true) return 'unknown'
|
||||
const xfo = probe.xFrameOptions?.trim().toUpperCase()
|
||||
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
|
||||
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
|
||||
return 'embeddable'
|
||||
}
|
||||
|
||||
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
||||
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
|
||||
const parts = host.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one address-bar input against the navigation policy.
|
||||
* @param input - raw user text.
|
||||
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
|
||||
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
|
||||
* is let through BEFORE the loopback check — its host is normally loopback.
|
||||
*/
|
||||
/** Schemes that must never reach the iframe, even without `//` (javascript:,
|
||||
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
||||
* they parse as hosts below. */
|
||||
const FORBIDDEN_SCHEMES = new Set([
|
||||
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
|
||||
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
|
||||
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
|
||||
])
|
||||
|
||||
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed === '') return { kind: 'invalid' }
|
||||
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
||||
// would match a naive scheme regex (dots are legal in schemes), so a
|
||||
// scheme prefix is only honored when it is http(s) or a known-forbidden
|
||||
// scheme; anything else is treated as a host and gets https://.
|
||||
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
||||
let withScheme: string
|
||||
if (schemeMatch === null) {
|
||||
withScheme = `https://${trimmed}`
|
||||
} else {
|
||||
const scheme = schemeMatch[1]!.toLowerCase()
|
||||
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
|
||||
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
|
||||
else withScheme = `https://${trimmed}`
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(withScheme)
|
||||
} catch {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
// The protocol backstop: any URL that still parses to a non-http(s)
|
||||
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
|
||||
// refused here.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
|
||||
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
|
||||
// the sidebar; the sandbox renders it in an opaque origin like any other
|
||||
// site). It must be checked before the loopback gate because its host is
|
||||
// normally loopback.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
|
||||
}
|
||||
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
|
||||
return { kind: 'ok', url: url.href }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / subagent / editor / diff) through the same
|
||||
* {@link BetterSidebarService} external plugins use — eating its own
|
||||
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
|
||||
* mints `terminal:<n>` ids through `createTab`.
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (explorer / git / terminal / browser / subagent / editor / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<n>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota).
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
@@ -16,7 +17,8 @@ import { TerminalView } from '../TerminalView.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import type { TabDescriptor } from '../service.ts'
|
||||
|
||||
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
|
||||
@@ -29,7 +31,7 @@ function uiTerminalCount(state: SidebarState): number {
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
}
|
||||
|
||||
/** The 6 built-in tab descriptors. */
|
||||
/** The 7 built-in tab descriptors. */
|
||||
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -130,6 +132,35 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
<TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
title: () => t('browser'),
|
||||
icon: (size: number) => <IconGlobeOutline16 size={size} />,
|
||||
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).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'browserNoSandbox',
|
||||
title: () => t('settingsBrowserSandboxTitle'),
|
||||
desc: () => t('settingsBrowserSandboxDesc'),
|
||||
}, {
|
||||
key: 'browserInterceptLinks',
|
||||
title: () => t('settingsBrowserLinksTitle'),
|
||||
desc: () => t('settingsBrowserLinksDesc'),
|
||||
}],
|
||||
},
|
||||
createTab: (state) => ({
|
||||
tab: {
|
||||
id: `browser:${state.nextBrowser}`,
|
||||
type: 'browser',
|
||||
title: t('browser'),
|
||||
},
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: (props) => <BrowserView {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
title: () => t('git'),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The 8 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
|
||||
* binary-download), exactly like external plugins register theirs. The
|
||||
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* The 9 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
|
||||
* code / binary-download), exactly like external plugins register theirs.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
*
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { FileViewerDescriptor } from '../service.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/** The 8 built-in file viewer descriptors. */
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -91,6 +92,28 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
fetchStrategy: 'fsRead',
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
title: () => t('viewerHtml'),
|
||||
icon: (size: number) => <IconHtmlOutline16 size={size} />,
|
||||
exts: ['html', 'htm'],
|
||||
fetchStrategy: 'fsRead',
|
||||
// Declarative settings: the sandbox escape hatch and the default-unsafe
|
||||
// start state render under this viewer's row in the Side card settings
|
||||
// page (both warned on).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'htmlViewerNoSandbox',
|
||||
title: () => t('settingsHtmlSandboxTitle'),
|
||||
desc: () => t('settingsHtmlSandboxDesc'),
|
||||
}, {
|
||||
key: 'htmlViewerDefaultUnsafe',
|
||||
title: () => t('settingsHtmlDefaultUnsafeTitle'),
|
||||
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
|
||||
}],
|
||||
},
|
||||
component: (props) => <TextEditor {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: () => t('viewerCode'),
|
||||
|
||||
@@ -83,3 +83,21 @@ export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** HTML viewer glyph: a document frame with a "‹/›" tag pair. */
|
||||
export const IconHtmlOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5.6 13.2 4.2 10l1.4-3.2M7.4 6.8 8.8 10l-1.4 3.2" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Browser tab glyph: a globe with meridians. */
|
||||
export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import { createBetterSidebarService } from './service.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
import { registerLinkInterception } from './link-intercept.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
@@ -158,6 +159,30 @@ export function apply(ctx: Context): void {
|
||||
'dsh-better-sidebar: open-path interception',
|
||||
)
|
||||
|
||||
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).
|
||||
return registerLinkInterception({
|
||||
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
|
||||
&& sidebarStore.getPrefs().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 })
|
||||
},
|
||||
selfOrigin: window.location.origin,
|
||||
})
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: link interception',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* their clicks must keep working inside the sidebar).
|
||||
*/
|
||||
|
||||
/** 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 (`<a>.href` already is). */
|
||||
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(anchorHref)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
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.
|
||||
try {
|
||||
if (url.origin === new URL(selfOrigin).origin) return null
|
||||
} catch {
|
||||
// Unparsable selfOrigin (never in practice): intercept defensively.
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
/** Whether a left-click may be taken over (unmodified left click only). */
|
||||
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
|
||||
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the document-level click capture that funnels external links
|
||||
* into the sidebar browser. Returns the disposer (HMR-safe).
|
||||
*/
|
||||
export function registerLinkInterception(opts: {
|
||||
takeoverEnabled: () => boolean
|
||||
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
|
||||
openInSidebar: (url: string) => void
|
||||
/** The GUI's own origin (window.location.origin at registration). */
|
||||
selfOrigin: string
|
||||
}): () => void {
|
||||
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
|
||||
event.preventDefault()
|
||||
opts.openInSidebar(url)
|
||||
}
|
||||
document.addEventListener('click', onClick, true)
|
||||
return () => { document.removeEventListener('click', onClick, true) }
|
||||
}
|
||||
@@ -136,6 +136,33 @@ const zh = {
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
viewerHtml: 'HTML',
|
||||
browser: '浏览器',
|
||||
browserPlaceholder: '输入网址,例如 example.com',
|
||||
browserGo: '前往',
|
||||
browserBack: '后退',
|
||||
browserForward: '前进',
|
||||
browserStart: '输入网址开始浏览(沙箱模式)',
|
||||
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
|
||||
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
|
||||
browserInvalid: '无效的网址',
|
||||
browserNoSandboxWarning: '沙箱已关闭:当前页面与界面同源,拥有完整会话权限(可在设置中恢复)',
|
||||
htmlNoSandboxWarning: '沙箱已关闭:此 HTML 与界面同源,可读取会话文件与内部接口(可在设置中恢复)',
|
||||
sandboxStatusOn: '沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用',
|
||||
sandboxUnlock: '临时解锁(不安全)',
|
||||
sandboxRestore: '恢复沙箱',
|
||||
settingsHtmlDefaultUnsafeTitle: 'HTML 预览默认以非沙箱模式打开(不安全)',
|
||||
settingsHtmlDefaultUnsafeDesc: '开启后,每次打开 HTML 文件时预览默认处于非沙箱状态(与界面同源,可读取会话文件与内部接口);可在状态行临时恢复沙箱',
|
||||
settingsHtmlSandboxTitle: '关闭 HTML 预览沙箱(不安全)',
|
||||
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
|
||||
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
|
||||
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
|
||||
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
|
||||
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
|
||||
browserOpenExternal: '在浏览器中打开',
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
browserEmbedAnyway: '仍然加载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
@@ -284,6 +311,33 @@ const en: Record<keyof typeof zh, string> = {
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
viewerHtml: 'HTML',
|
||||
browser: 'Browser',
|
||||
browserPlaceholder: 'Enter a URL, e.g. example.com',
|
||||
browserGo: 'Go',
|
||||
browserBack: 'Back',
|
||||
browserForward: 'Forward',
|
||||
browserStart: 'Enter a URL to start browsing (sandbox mode)',
|
||||
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
|
||||
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
|
||||
browserInvalid: 'Invalid URL',
|
||||
browserNoSandboxWarning: 'Sandbox off: the current page runs with full GUI privileges (re-enable in settings)',
|
||||
htmlNoSandboxWarning: 'Sandbox off: this HTML runs with full GUI privileges (re-enable in settings)',
|
||||
sandboxStatusOn: 'Sandbox mode: on · pages cannot access the GUI\'s data or local files; logins and third-party cookies may not work',
|
||||
sandboxUnlock: 'Temporarily disable (unsafe)',
|
||||
sandboxRestore: 'Restore sandbox',
|
||||
settingsHtmlDefaultUnsafeTitle: 'Open HTML previews unsandboxed by default (unsafe)',
|
||||
settingsHtmlDefaultUnsafeDesc: 'When on, every newly opened HTML preview starts in the unsandboxed state (same origin as the GUI — it can read session files and internal APIs); the status row still offers a one-tap restore',
|
||||
settingsHtmlSandboxTitle: 'Disable HTML preview sandbox (unsafe)',
|
||||
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
|
||||
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',
|
||||
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.',
|
||||
browserEmbedAnyway: 'Load anyway',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
|
||||
@@ -46,6 +46,18 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
|
||||
? record.htmlViewerDefaultUnsafe
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
|
||||
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
|
||||
? record.browserNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
|
||||
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
|
||||
? record.browserInterceptLinks
|
||||
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
|
||||
tabsEnabled: booleanMapOf(record.tabsEnabled),
|
||||
viewersEnabled: booleanMapOf(record.viewersEnabled),
|
||||
}
|
||||
|
||||
+32
-15
@@ -22,7 +22,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
@@ -194,9 +194,12 @@ export interface BetterSidebarService {
|
||||
* Open a tab (used by external tabs and the + menu). `title` overrides
|
||||
* the descriptor's title when given (the editor tab shows the file name);
|
||||
* when the descriptor provides `createTab` it mints the tab itself and
|
||||
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
|
||||
* `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
|
||||
* pre-set to the URL (the browser tab's navigation seed; the caller
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** Close a tab by id. */
|
||||
closeTab(tabId: string): void
|
||||
/** Subscribe to registry changes (register/dispose). */
|
||||
@@ -297,7 +300,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
return undefined
|
||||
}
|
||||
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
|
||||
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
|
||||
// A type the user disabled in settings never opens — neither from the
|
||||
// + menu nor from derived flows (file opens, subagent auto-open,
|
||||
// external plugins). Already-open tabs keep rendering.
|
||||
@@ -309,22 +312,36 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
const descriptor = tabs.get(seed.type)
|
||||
if (descriptor === undefined) return state
|
||||
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
|
||||
let tab: SidebarTab
|
||||
let next: SidebarState
|
||||
if (descriptor.createTab !== undefined) {
|
||||
const result = descriptor.createTab(state)
|
||||
if (result === null) return state
|
||||
const next = applyDedupe(state, result.tab, descriptor)
|
||||
return result.patch !== undefined ? { ...next, ...result.patch } : next
|
||||
tab = result.tab
|
||||
next = applyDedupe(state, result.tab, descriptor)
|
||||
if (result.patch !== undefined) next = { ...next, ...result.patch }
|
||||
} else {
|
||||
tab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
// A caller-provided title wins (the editor shows the file name);
|
||||
// otherwise the descriptor's (possibly i18n) title is the default.
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
}
|
||||
next = applyDedupe(state, tab, descriptor)
|
||||
}
|
||||
const tab: SidebarTab = {
|
||||
id: seed.id ?? seed.type,
|
||||
type: seed.type,
|
||||
// A caller-provided title wins (the editor shows the file name);
|
||||
// otherwise the descriptor's (possibly i18n) title is the default.
|
||||
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
|
||||
...(seed.path !== undefined ? { path: seed.path } : {}),
|
||||
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
|
||||
// A URL seed pre-fills the tab's path (the browser tab navigates to it
|
||||
// on mount). An explicit seed.title also wins over a createTab-minted
|
||||
// default title (e.g. the sidebar-browser's hostname title).
|
||||
if (seed.url !== undefined) {
|
||||
return patchTab(next, tab.id, {
|
||||
path: seed.url,
|
||||
...(seed.title !== undefined ? { title: seed.title } : {}),
|
||||
})
|
||||
}
|
||||
return applyDedupe(state, tab, descriptor)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -703,6 +703,186 @@
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
/* Live sandbox status row (HTML preview / browser tab): GREEN while the
|
||||
sandbox is on (with the one-tap temporary unlock), RED while it is off
|
||||
(global setting or temporary unlock) — the red state is the persistent
|
||||
warning that the page runs on the GUI's own origin. */
|
||||
.sandboxStatus {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
.sandboxStatusOn {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.sandboxStatusOff {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
||||
}
|
||||
|
||||
.sandboxDot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.sandboxStatusOff .sandboxDot {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.sandboxStatusText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sandboxAction {
|
||||
flex: none;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sandboxAction:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
|
||||
so the frame is cross-origin by construction even if the sandbox
|
||||
attribute is ever dropped; sandboxed by default (see TextEditor). */
|
||||
.editorHtml {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* ── Browser tab ──────────────────────────────────────────────────────── */
|
||||
|
||||
.browser {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.browserBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.browserInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.browserInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.browserMessage {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.browserFrame {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.browserStart {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Embed-refusal panel (X-Frame-Options / frame-ancestors): replaces the
|
||||
blank refused iframe with the reason + open-in-browser actions. */
|
||||
.browserBlocked {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.browserBlockedTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.browserBlockedDesc {
|
||||
max-width: 280px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.browserBlockedActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.browserBlockedButton {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browserBlockedButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.editorCm {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SidebarState {
|
||||
activePane: string | null
|
||||
/** Monotonic terminal tab counter (ids survive reloads). */
|
||||
nextTerminal: number
|
||||
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
|
||||
nextBrowser: number
|
||||
/** Explorer expansion set (absolute directory paths). */
|
||||
expanded: string[]
|
||||
splits: SplitNode
|
||||
@@ -129,6 +131,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
nextTerminal: 1,
|
||||
nextBrowser: 1,
|
||||
expanded: [],
|
||||
splits: leaf,
|
||||
}
|
||||
@@ -308,6 +311,36 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Update the display fields of one open tab (title / path) without
|
||||
* re-opening it. The browser tab persists its current URL and hostname
|
||||
* title through this reducer so a reload restores the visited page. A
|
||||
* missing tab id is a no-op. */
|
||||
export function patchTab(
|
||||
state: SidebarState,
|
||||
tabId: string,
|
||||
patch: { title?: string; path?: string },
|
||||
): SidebarState {
|
||||
let changed = false
|
||||
const walk = (node: SplitNode): SplitNode => {
|
||||
if (node.kind === 'leaf') {
|
||||
const tabs = node.tabs.map(tab => {
|
||||
if (tab.id !== tabId) return tab
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.path !== undefined ? { path: patch.path } : {}),
|
||||
}
|
||||
})
|
||||
return tabs === node.tabs ? node : { ...node, tabs }
|
||||
}
|
||||
const children = node.children.map(walk)
|
||||
return children === node.children ? node : { ...node, children }
|
||||
}
|
||||
const splits = walk(state.splits)
|
||||
return changed ? { ...state, splits } : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Land a tab in the active pane (or focus its existing instance by id).
|
||||
* Dedup strategies (single-instance, per-path, per-change) are owned by the
|
||||
@@ -572,6 +605,12 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
|
||||
return undefined
|
||||
}
|
||||
// nextBrowser arrived in a later build; a missing or malformed value on an
|
||||
// OLDER persisted state defaults to 1 so existing layouts keep loading
|
||||
// (unlike nextTerminal, which is strict — it predates the v1 shape).
|
||||
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
|
||||
? record.nextBrowser
|
||||
: 1
|
||||
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
|
||||
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
|
||||
const reid = new Map<string, string>()
|
||||
@@ -585,6 +624,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
// new tabs still land in the pane the user was using.
|
||||
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
|
||||
nextTerminal: record.nextTerminal,
|
||||
nextBrowser,
|
||||
expanded: record.expanded as string[],
|
||||
splits,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user