- 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 个测试全绿
237 lines
9.3 KiB
TypeScript
237 lines
9.3 KiB
TypeScript
/**
|
|
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
|
|
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
|
|
* save, and a preview/edit toggle for markdown files. Registered as the
|
|
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
|
|
* fetches the content through the fsRead strategy and passes it in props,
|
|
* so this component never fetches or dispatches — it only edits.
|
|
*
|
|
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
|
|
* row below the host's title bar, VSCode-style.
|
|
*/
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import clsx from 'clsx'
|
|
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, 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'
|
|
|
|
/** 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')
|
|
/** The editor's current text (null while clean); preview renders this. */
|
|
const [draft, setDraft] = useState<string | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
|
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
const viewRef = useRef<CodeMirrorView | null>(null)
|
|
const savingRef = useRef(false)
|
|
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
|
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
|
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
|
const [dark, setDark] = useState(() => isDarkScheme())
|
|
|
|
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
|
|
|
// A new file (tab switch) starts clean: fresh preview mode, no draft.
|
|
useEffect(() => {
|
|
setMode('preview')
|
|
setDraft(null)
|
|
setDirty(false)
|
|
setSaveState('idle')
|
|
}, [content])
|
|
|
|
// Create the CodeMirror editor once the content is loaded. The view owns
|
|
// the document; React only tracks dirty/draft state through the update
|
|
// listener. For markdown the view stays mounted while previewing (hidden),
|
|
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
|
// colors live in a compartment so a scheme flip reconfigures only that
|
|
// part — the document, undo history and scroll position survive.
|
|
useEffect(() => {
|
|
if (content === undefined) return
|
|
const host = hostRef.current
|
|
if (host === null) return
|
|
const language = languageForPath(path)
|
|
const themeComp = new CmThemeCompartment()
|
|
themeCompRef.current = themeComp
|
|
const state = EditorState.create({
|
|
doc: content,
|
|
extensions: [
|
|
CodeMirrorView.lineWrapping,
|
|
lineNumbers(),
|
|
history(),
|
|
EditorState.tabSize.of(2),
|
|
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
|
|
cmSurfaceTheme,
|
|
themeComp.of(dark),
|
|
...(language !== null ? [language] : []),
|
|
CodeMirrorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
setDraft(update.state.doc.toString())
|
|
setDirty(true)
|
|
}
|
|
}),
|
|
keymap.of([
|
|
{
|
|
key: 'Mod-s',
|
|
preventDefault: true,
|
|
run: () => { save(); return true },
|
|
},
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
],
|
|
})
|
|
const view = new CodeMirrorView({ state, parent: host })
|
|
viewRef.current = view
|
|
return () => {
|
|
view.destroy()
|
|
viewRef.current = null
|
|
themeCompRef.current = null
|
|
}
|
|
// The keymap's save() reads live refs; scope/path are stable for a
|
|
// tab's lifetime, and the dark flip is handled by the reconfigure
|
|
// effect below (recreating the view here would drop the draft).
|
|
}, [content, path])
|
|
|
|
// Scheme flip: re-theme in place (the compartment holds only the
|
|
// scheme-dependent extensions; everything else is untouched).
|
|
useEffect(() => {
|
|
const view = viewRef.current
|
|
const themeComp = themeCompRef.current
|
|
if (view === null || themeComp === null) return
|
|
view.dispatch({ effects: themeComp.reconfigure(dark) })
|
|
}, [dark])
|
|
|
|
// The editor may have been display:none while previewing; re-measure when
|
|
// it becomes visible again (CodeMirror sizes itself on reveal).
|
|
useEffect(() => {
|
|
if (mode === 'edit') viewRef.current?.requestMeasure()
|
|
}, [mode])
|
|
|
|
const save = (): void => {
|
|
const view = viewRef.current
|
|
if (view === null || savingRef.current) return
|
|
savingRef.current = true
|
|
setSaveState('saving')
|
|
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
|
|
savingRef.current = false
|
|
setDraft(null)
|
|
setDirty(false)
|
|
setSaveState('saved')
|
|
}).catch(() => {
|
|
savingRef.current = false
|
|
setSaveState('failed')
|
|
})
|
|
}
|
|
|
|
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 || html) && (
|
|
<div className={css.editorModeToggle}>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
|
|
onClick={() => { setMode('preview') }}
|
|
>
|
|
{t('preview')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
|
|
onClick={() => { setMode('edit') }}
|
|
>
|
|
{t('edit')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
|
|
{editable && (
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('save')}
|
|
title={`${t('save')} (Ctrl/Cmd+S)`}
|
|
onClick={save}
|
|
>
|
|
<IconCheckOutline16 />
|
|
</button>
|
|
)}
|
|
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
|
|
</div>
|
|
{editable && (
|
|
<>
|
|
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
|
<div
|
|
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
|
|
ref={hostRef}
|
|
/>
|
|
</>
|
|
)}
|
|
{markdown && mode === 'preview' && (
|
|
<div className={css.editorMd}>
|
|
<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}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|