* feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors) The file tree right-click menu gains an 'Open with >' submenu with the resource manager (reveal in file manager), VS Code, Cursor and Zed, plus user-defined custom editors (name + URL template + VSCode-family flag). Each submenu row carries a pushpin that promotes the target to a direct top-level menu item. A configurable SSH host (settings: Files card gear) switches VSCode-family openers to <scheme>://vscode-remote/ssh-remote+<host>/<path> and hides host-local targets (file manager / Zed / non-VSCode custom editors). External opens run through the new fenced /sidebar/api/open.external route which spawns the platform opener with an argv array (no shell); the client never navigates raw scheme URLs itself. The settings seam's render panel now appends after the declarative rows instead of replacing them. * feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment - Submenu rows: the custom label is now a block-level flex row filling the primitives itemLabel, so the pushpin docks at the row's far right (matching the trailing check of every other menu); menu row icons normalized to 16px (DSH native menus use 16, ours were 14). - The submenu parent row gains a right-aligned chevron (the primitives Menu renders no arrow for submenu parents). - Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing the </> file mark), VscLinkExternal for the open-with parent and the browser tab's open-external button (globally), SiCursor/SiZedindustries brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0 (CC0) as IconVscode16 since later simple-icons releases dropped it. All monochrome currentColor to follow the active skin. - tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias — the package's exports map lists require before import, so the shared conditionNames picked the unshakeable CJS entry and bloated the core bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB.
254 lines
9.6 KiB
TypeScript
254 lines
9.6 KiB
TypeScript
/**
|
|
* 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,
|
|
IconWarningOutline16,
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import { VscLinkExternal } from 'react-icons/vsc'
|
|
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')
|
|
}}
|
|
>
|
|
<VscLinkExternal 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>
|
|
)
|
|
}
|