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
+253
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+6
View File
@@ -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 {
+46 -3
View File
@@ -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}
/>
</>
)}
</>
)
}
+13
View File
@@ -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)
}
+119
View File
@@ -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 }
}
+38 -7
View File
@@ -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'),
+28 -5
View File
@@ -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'),
+18
View File
@@ -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>
)
+25
View File
@@ -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
+66
View File
@@ -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) }
}
+54
View File
@@ -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',
+12
View File
@@ -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
View File
@@ -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
})
}
+180
View File
@@ -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;
+40
View File
@@ -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,
}