fix: 处理 Copilot 二轮 review 的 10 条评论
- BrowserView: 即使 host 在白名单,GUI 自身 origin 永不给 allow-same-origin(裸 host 条目覆盖所有端口时可提权) - intercept: revealInExplorer 相对路径经 resolveSidebarPath 解析后再 revealPaths(交付物路径是相对的) - intercept: ctx.betterSidebar 改为 ctx.get(#357 纤维链读取约定) - FileTree/EditorHost/TreePanel/tabs: 把 revealed 贯通到树行渲染 + 高亮类 + scrollIntoView(此前高亮永不渲染) - state.revealPaths: 重建祖先目录时保留前导分隔符(POSIX/UNC 此前目录永远折叠) - state.schedulePersist: 仅活动会话写入全局宽度(targeted open 不再用非活动会话的过期宽度覆盖拖拽值) - browser.probe: GET 回退显式取消 body(否则流式/大响应挂住 socket) - terminal-font: leading generic 前只放 symbols-only 字体;fully-patched 放 generic 后(避免 hijack 度量字体) - sidebar.module.css: 恢复拖拽反馈(#130 的 silent resize 与 PR 描述冲突,取 HEAD) - Sidebar: 剩余 4 处 ctx.betterSidebar 改 ctx.get - tests: 补 revealPaths/iframeSandboxFor/terminal-font 回归测试 + EditorHost/FileTree 测试补 revealed prop
This commit is contained in:
@@ -55,9 +55,25 @@ const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN =
|
||||
* the page access to the GUI — it stays cross-origin to it and to every
|
||||
* other site — but it does give it its OWN origin privileges (localStorage,
|
||||
* fetch without CORS), so it is only granted for the explicit allowlist.
|
||||
*
|
||||
* The GUI itself is the one hard exception: even when its own host is
|
||||
* allowlisted (a bare-host entry covers every port, so the GUI origin
|
||||
* matches), a page at the GUI's exact origin must never get
|
||||
* `allow-same-origin` — that would make it same-origin with its parent and
|
||||
* hand it the GUI's storage/API (and the ability to shed the sandbox). The
|
||||
* GUI keeps the opaque-origin sandbox no matter what the allowlist says.
|
||||
*/
|
||||
export function iframeSandboxFor(url: string | undefined, allowedLoopback: string): string | undefined {
|
||||
export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined {
|
||||
if (url === undefined) return undefined
|
||||
if (selfOrigin !== undefined) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return BROWSER_IFRAME_SANDBOX
|
||||
}
|
||||
if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX
|
||||
}
|
||||
return isAllowedLoopbackUrl(url, allowedLoopback)
|
||||
? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN
|
||||
: BROWSER_IFRAME_SANDBOX
|
||||
@@ -230,7 +246,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
|
||||
className={css.browserFrame}
|
||||
src={url}
|
||||
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback)}
|
||||
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback, window.location.origin)}
|
||||
referrerPolicy="no-referrer"
|
||||
allow=""
|
||||
title={url}
|
||||
|
||||
@@ -97,10 +97,11 @@ export function EditorHost(props: {
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
expanded: string[]
|
||||
revealed: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
||||
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
|
||||
const path = tab.path ?? ''
|
||||
const title = tab.title
|
||||
// A folder window: the model's `sidebar_open` (or any caller) opens a
|
||||
@@ -371,6 +372,7 @@ export function EditorHost(props: {
|
||||
sessionId={scope.sessionId}
|
||||
cwd={folderRoot ?? scope.cwd}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
@@ -489,6 +491,7 @@ export function EditorHost(props: {
|
||||
sessionId={scope.sessionId}
|
||||
cwd={scope.cwd}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
|
||||
+17
-1
@@ -106,6 +106,8 @@ export function FileTree(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
/** Files highlighted by a "Show in folder" reveal (absolute paths). */
|
||||
revealed: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
|
||||
@@ -134,7 +136,7 @@ export function FileTree(props: {
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
@@ -274,6 +276,16 @@ export function FileTree(props: {
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
// Bring a "Show in folder" reveal into view: the ancestors expand above
|
||||
// (revealPaths), but the row may not be scrolled into sight — a reveal on
|
||||
// a long tree should surface the highlighted file. Re-runs when the tree
|
||||
// data or reveal set changes (the row appears after its level loads).
|
||||
useEffect(() => {
|
||||
if (revealed.length === 0) return
|
||||
const row = bodyRef.current?.querySelector('[data-dsh-revealed]')
|
||||
row?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [revealed, data])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
@@ -431,7 +443,9 @@ export function FileTree(props: {
|
||||
className={clsx(
|
||||
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
|
||||
dropTarget === entry.path && css.explorerRowDropTarget,
|
||||
revealed.includes(entry.path) && css.explorerRowRevealed,
|
||||
)}
|
||||
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
@@ -461,7 +475,9 @@ export function FileTree(props: {
|
||||
className={clsx(
|
||||
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
|
||||
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
|
||||
revealed.includes(entry.path) && css.explorerRowRevealed,
|
||||
)}
|
||||
data-dsh-revealed={revealed.includes(entry.path) ? 'true' : undefined}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
|
||||
@@ -500,9 +500,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const scope = { sessionId }
|
||||
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
||||
if (request.kind === 'url') {
|
||||
ctx.betterSidebar?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||
} else if (request.kind === 'folder') {
|
||||
ctx.betterSidebar?.openTab({
|
||||
ctx.get('betterSidebar')?.openTab({
|
||||
type: 'editor',
|
||||
title,
|
||||
path: request.target,
|
||||
@@ -510,7 +510,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
meta: { dir: true },
|
||||
}, scope)
|
||||
} else {
|
||||
ctx.betterSidebar?.openFile(scope, request.target, title)
|
||||
ctx.get('betterSidebar')?.openFile(scope, request.target, title)
|
||||
}
|
||||
} catch {
|
||||
// Malformed push: ignore (the next push carries its own request).
|
||||
@@ -1620,7 +1620,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }}
|
||||
onResize={(w, h) => { store.reduce(s => resizeFloat(s, float.id, w, h)) }}
|
||||
onDock={(paneId) => { store.reduce(s => dockFloat(s, float.id, paneId ?? undefined)) }}
|
||||
onClose={() => { ctx.betterSidebar?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
||||
onClose={() => { ctx.get('betterSidebar')?.closeTab(float.tab.id, sessionId === undefined ? undefined : { sessionId, cwd }) }}
|
||||
/>
|
||||
))}
|
||||
{/*
|
||||
|
||||
@@ -45,6 +45,7 @@ export function TreePanel(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
revealed: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** File context-menu "open in a new tab" (passed through to FileTree). */
|
||||
@@ -63,7 +64,7 @@ export function TreePanel(props: {
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -221,6 +222,7 @@ export function TreePanel(props: {
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
revealed={revealed}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
|
||||
@@ -119,13 +119,14 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
|
||||
),
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
|
||||
component: ({ ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile }) => (
|
||||
<EditorHost
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
scope={scope}
|
||||
tab={tab}
|
||||
expanded={expanded ?? []}
|
||||
revealed={revealed ?? []}
|
||||
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
/>
|
||||
|
||||
@@ -46,7 +46,12 @@ export function revealInExplorer(
|
||||
): void {
|
||||
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
||||
const cwd = summary?.cwd
|
||||
const targets = files.length > 0 ? files : cwd === undefined ? [] : [cwd]
|
||||
// Deliverables report paths as-is (often relative to the session cwd), but
|
||||
// the explorer tree and revealPaths work on absolute paths — resolve every
|
||||
// target so the ancestors expand and the row actually matches.
|
||||
const targets = files.length > 0
|
||||
? files.map(path => resolveSidebarPath(cwd, path))
|
||||
: cwd === undefined ? [] : [cwd]
|
||||
store.reduce(state => revealPaths(state, cwd, targets))
|
||||
// A type-only open never auto-expands the panel (only content opens do,
|
||||
// see service.openTab) — so a reveal opens the panel itself when it is
|
||||
@@ -58,8 +63,10 @@ export function revealInExplorer(
|
||||
// touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
// Focus the single-instance editor home tab (the files window) where the
|
||||
// reveal highlight renders.
|
||||
ctx.betterSidebar?.openTab({ type: 'editor', title: t('files') })
|
||||
// reveal highlight renders. Read via ctx.get like every other internal
|
||||
// consumer (#357): the provider is not on this fiber chain, so a direct
|
||||
// ctx.betterSidebar read can throw before optional chaining applies.
|
||||
ctx.get('betterSidebar')?.openTab({ type: 'editor', title: t('files') })
|
||||
}
|
||||
|
||||
/** The intercepted produced-files row (visual twin of the deliverables chips). */
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
}
|
||||
|
||||
.panelResizeActive {
|
||||
/* Drag feedback disabled by local preference: no highlight while resizing. */
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
@@ -267,7 +267,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
}
|
||||
|
||||
.bottomResizeActive {
|
||||
/* Drag feedback disabled by local preference: no highlight while resizing. */
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
/* The bottom panel's close control: the app's icon-button pattern (28px
|
||||
@@ -504,8 +504,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.cornerHandle:hover {
|
||||
/* Only hover feedback stays; dragging highlight disabled by local preference. */
|
||||
.cornerHandle:hover,
|
||||
.cornerHandle[data-dragging] {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
@@ -932,6 +932,13 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* A "Show in folder" reveal highlight: the target row stays tinted until
|
||||
the transient reveal set is cleared (a reload starts unhighlighted). */
|
||||
.explorerRowRevealed {
|
||||
background: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover-accent) 18%, transparent);
|
||||
box-shadow: inset 2px 0 0 var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
.explorerDir {
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
}
|
||||
|
||||
+13
-3
@@ -730,8 +730,13 @@ export function revealPaths(state: SidebarState, cwd: string | undefined, files:
|
||||
revealed.push(file)
|
||||
const parts = file.split(/[\\/]+/).filter(part => part !== '' && part !== '.')
|
||||
const separator = file.includes('\\') ? '\\' : '/'
|
||||
// Keep the original leading separator(s) when rebuilding ancestor dirs:
|
||||
// FileTree matches expansion against ABSOLUTE paths, so dropping the
|
||||
// root (POSIX `/w/src` �W `w/src`) or a UNC prefix (`\\server\share`)
|
||||
// would leave every ancestor collapsed and the row unreachable.
|
||||
const prefix = file.startsWith('/') ? '/' : file.startsWith('\\\\') ? '\\\\' : file.startsWith('\\') ? '\\' : ''
|
||||
for (let i = rootParts.length; i < parts.length - 1; i++) {
|
||||
expanded.add(parts.slice(0, i + 1).join(separator))
|
||||
expanded.add(prefix + parts.slice(0, i + 1).join(separator))
|
||||
}
|
||||
}
|
||||
if (revealed.length === 0) return state
|
||||
@@ -1454,8 +1459,13 @@ export class SidebarStore {
|
||||
|
||||
private schedulePersist(sessionId: string, state: SidebarState): void {
|
||||
// Keep the cross-session width in sync: any width change (drag, fullscreen
|
||||
// toggle) becomes the shared width for every conversation.
|
||||
writeGlobalWidth(state.width)
|
||||
// toggle) on the ACTIVE session becomes the shared width for every
|
||||
// conversation. A targeted open persists an INACTIVE session (reduceFor)
|
||||
// and must not clobber that global — its width is stale by definition,
|
||||
// so writing it would break "last drag wins".
|
||||
if (sessionId === this.snapshot.sessionId) {
|
||||
writeGlobalWidth(state.width)
|
||||
}
|
||||
// Per-session debounce timers: one session's pending write must never
|
||||
// cancel another's (targeted opens schedule writes for INACTIVE
|
||||
// sessions while the active session may already have one pending —
|
||||
|
||||
@@ -59,6 +59,10 @@ export const ICON_FONT_FALLBACKS: readonly string[] = [
|
||||
'"MesloLGS NF"',
|
||||
]
|
||||
|
||||
/** The symbols-only patch count at the head of {@link ICON_FONT_FALLBACKS}
|
||||
* (these carry no Latin glyphs, so they can never hijack ASCII metrics). */
|
||||
const SYMBOLS_ONLY_COUNT = 2
|
||||
|
||||
/**
|
||||
* CSS generic font families. A generic is a catch-all that always resolves,
|
||||
* so icon fonts must be spliced in *before* the first one to stay reachable.
|
||||
@@ -141,7 +145,11 @@ function splitFamilies(stack: string): string[] {
|
||||
* keeps their exact priority.
|
||||
* - The icon fonts are spliced in ahead of the *first* generic family
|
||||
* (`monospace` etc.), because a generic always resolves: anything after it
|
||||
* would never be consulted.
|
||||
* would never be consulted. A stack that OPENS with a generic is the one
|
||||
* exception: only the symbols-only patches (no Latin) may precede it — a
|
||||
* fully-patched Nerd Font there would become xterm's measuring base font
|
||||
* and override the user/theme family precedence, so it is placed after the
|
||||
* generic instead.
|
||||
* - Idempotent — re-applying to an already-topped-up stack is a no-op, which
|
||||
* matters because `TerminalView` diffs the resolved value against the live
|
||||
* `term.options.fontFamily` before reflowing.
|
||||
@@ -154,12 +162,22 @@ export function withIconFontFallbacks(stack: string): string {
|
||||
if (entries.length === 0) return ICON_FONT_FALLBACKS.join(', ')
|
||||
|
||||
const present = new Set(entries.map(normalizeFamily))
|
||||
const additions = ICON_FONT_FALLBACKS.filter(family => !present.has(normalizeFamily(family)))
|
||||
if (additions.length === 0) return entries.join(', ')
|
||||
const notPresent = (family: string): boolean => !present.has(normalizeFamily(family))
|
||||
const symbolsOnly = ICON_FONT_FALLBACKS.slice(0, SYMBOLS_ONLY_COUNT).filter(notPresent)
|
||||
const patched = ICON_FONT_FALLBACKS.slice(SYMBOLS_ONLY_COUNT).filter(notPresent)
|
||||
if (symbolsOnly.length === 0 && patched.length === 0) return entries.join(', ')
|
||||
|
||||
const firstGeneric = entries.findIndex(entry => GENERIC_FAMILIES.has(normalizeFamily(entry)))
|
||||
const cut = firstGeneric === -1 ? entries.length : firstGeneric
|
||||
return [...entries.slice(0, cut), ...additions, ...entries.slice(cut)].join(', ')
|
||||
// A fully-patched distribution ships Latin glyphs: placed ahead of a
|
||||
// LEADING generic (cut === 0) it would become xterm's measuring/base font
|
||||
// and override the user/theme family precedence. Only the symbols-only
|
||||
// patches — which carry no Latin — may precede it; the patched fonts go
|
||||
// after the generic (still reachable per character, never the base).
|
||||
if (cut === 0) {
|
||||
return [...symbolsOnly, entries[0]!, ...patched, ...entries.slice(1)].join(', ')
|
||||
}
|
||||
return [...entries.slice(0, cut), ...symbolsOnly, ...patched, ...entries.slice(cut)].join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -554,6 +554,11 @@ function buildApi(
|
||||
const csp = response.headers.get('content-security-policy')
|
||||
const frameAncestors = extractFrameAncestors(csp)
|
||||
const xFrameOptions = response.headers.get('x-frame-options')
|
||||
// The GET fallbacks stream a real body that nothing reads; "body
|
||||
// discarded" is not automatic with fetch, so cancel it explicitly to
|
||||
// release the socket (a large/streaming response would otherwise stay
|
||||
// pinned after the timer clears).
|
||||
void response.body?.cancel()
|
||||
return {
|
||||
reachable: true,
|
||||
url: response.url,
|
||||
|
||||
@@ -62,6 +62,7 @@ function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, t
|
||||
scope: { sessionId: 'editor-home-session' },
|
||||
tab: tab(),
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggleDir: () => {},
|
||||
onReferenceFile: () => {},
|
||||
}))
|
||||
|
||||
@@ -96,7 +96,7 @@ function mount(ctx: Context, tab: () => SidebarTab): { container: HTMLDivElement
|
||||
act(() => {
|
||||
root.render(createElement(EditorHost, {
|
||||
ctx, store: ctx.betterSidebar as never, scope: { sessionId: 'editor-home-session' },
|
||||
tab: tab(), expanded: [], onToggleDir: () => {}, onReferenceFile: () => {},
|
||||
tab: tab(), expanded: [], revealed: [], onToggleDir: () => {}, onReferenceFile: () => {},
|
||||
}))
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -56,6 +56,7 @@ async function mountTree(busy = false): Promise<Harness> {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onReferenceFile: () => {},
|
||||
|
||||
@@ -65,6 +65,7 @@ async function mountTree(overrides: {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onOpenFileNewTab: () => {},
|
||||
|
||||
@@ -78,6 +78,7 @@ function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab:
|
||||
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
|
||||
tab,
|
||||
expanded: [],
|
||||
revealed: [],
|
||||
onToggleDir: () => {},
|
||||
onReferenceFile: () => {},
|
||||
}))
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
|
||||
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX } from '../src/client/BrowserView.tsx'
|
||||
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX, iframeSandboxFor } from '../src/client/BrowserView.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
@@ -140,6 +140,23 @@ describe('browser tab iframe sandbox', () => {
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('never grants allow-same-origin to the GUI\'s own origin, even when allowlisted', () => {
|
||||
// A bare-host allowlist entry covers every port, so an allowlisted GUI
|
||||
// host also matches the GUI's exact origin — but a page at the GUI's
|
||||
// origin must keep the opaque-origin sandbox: allow-same-origin there
|
||||
// would make it same-origin with its parent and hand it GUI storage/API.
|
||||
const allowlist = 'localhost'
|
||||
const guiOrigin = 'http://localhost:5173'
|
||||
expect(iframeSandboxFor('http://localhost:5173/', allowlist, guiOrigin)).toBe(BROWSER_IFRAME_SANDBOX)
|
||||
expect(iframeSandboxFor('http://localhost:5173/', allowlist, guiOrigin)).not.toContain('allow-same-origin')
|
||||
// Other allowlisted loopback origins still get the same-origin token.
|
||||
const sandbox = iframeSandboxFor('http://localhost:4000/', allowlist, guiOrigin)
|
||||
expect(sandbox).not.toBe(BROWSER_IFRAME_SANDBOX)
|
||||
expect(sandbox).toContain('allow-same-origin')
|
||||
// A different (non-GUI) origin with no allowlist stays opaque.
|
||||
expect(iframeSandboxFor('https://example.com/', '', guiOrigin)).toBe(BROWSER_IFRAME_SANDBOX)
|
||||
})
|
||||
|
||||
it('renders the live sandbox status row with the temporary unlock action', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
|
||||
+28
-1
@@ -4,7 +4,7 @@ import {
|
||||
dockFloat, FLOAT_MIN_H, FLOAT_MIN_W, floatTab, floatWithTab, insertLeafAt, makeDefaultState,
|
||||
migrateBottomTabs, moveFloat, moveTab, moveTabToEdge, openDiffTab,
|
||||
openTabInActivePane, patchTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplit,
|
||||
resizeSplitIn, sanitizeState, setBottomHeight,
|
||||
resizeSplitIn, revealPaths, sanitizeState, setBottomHeight,
|
||||
splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
|
||||
type SidebarLeaf, type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
@@ -1084,6 +1084,33 @@ describe('free windows (v0.16.0)', () => {
|
||||
expect(allLeaves(after.splits).flatMap(leaf => leaf.tabs).some(tab => tab.id === 'agent:u2')).toBe(true)
|
||||
})
|
||||
|
||||
describe('revealPaths (show in folder)', () => {
|
||||
it('expands ancestors with their ABSOLUTE path (leading separator preserved)', () => {
|
||||
// POSIX: the root (/w/src) is not itself expanded, but the subdirs
|
||||
// below it must be recorded as ABSOLUTE paths so FileTree's
|
||||
// `expanded.includes(entry.path)` matches.
|
||||
const base = makeDefaultState()
|
||||
const next = revealPaths(base, '/w/src', ['/w/src/sub/deep/a.ts'])
|
||||
expect(next.revealed).toEqual(['/w/src/sub/deep/a.ts'])
|
||||
expect(next.expanded).toContain('/w/src/sub')
|
||||
expect(next.expanded).toContain('/w/src/sub/deep')
|
||||
expect(next.expanded).not.toContain('w/src/sub')
|
||||
expect(next.expanded).not.toContain('/w/src')
|
||||
})
|
||||
|
||||
it('keeps a Windows drive-letter root and a UNC prefix', () => {
|
||||
const drive = revealPaths(makeDefaultState(), 'C:\\work', ['C:\\work\\src\\a.ts'])
|
||||
expect(drive.expanded).toContain('C:\\work\\src')
|
||||
const unc = revealPaths(makeDefaultState(), '\\\\server\\share', ['\\\\server\\share\\sub\\a.ts'])
|
||||
expect(unc.expanded).toContain('\\\\server\\share\\sub')
|
||||
})
|
||||
|
||||
it('resolves nothing to the same reference (no churn)', () => {
|
||||
const base = makeDefaultState()
|
||||
expect(revealPaths(base, '/w', [])).toBe(base)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeState (floats)', () => {
|
||||
const g = globalThis as Record<string, unknown>
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -54,6 +54,17 @@ describe('withIconFontFallbacks', () => {
|
||||
.toBeLessThan(indexOfFamily(stack, 'ui-monospace'))
|
||||
})
|
||||
|
||||
it('keeps fully-patched fonts BEHIND a leading generic (they carry Latin and would become the base)', () => {
|
||||
// A leading generic resolves, so prepending a fully-patched Nerd Font
|
||||
// (which ships Latin glyphs) would make it xterm's measuring/base font
|
||||
// and override the user/theme family precedence. Only the symbols-only
|
||||
// patches (no Latin) may precede it.
|
||||
const stack = withIconFontFallbacks('monospace')
|
||||
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"')).toBeLessThan(indexOfFamily(stack, 'monospace'))
|
||||
expect(indexOfFamily(stack, '"Hack Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, 'monospace'))
|
||||
expect(indexOfFamily(stack, '"JetBrainsMono Nerd Font Mono"')).toBeGreaterThan(indexOfFamily(stack, 'monospace'))
|
||||
})
|
||||
|
||||
it('splices ahead of the FIRST generic when several are present', () => {
|
||||
const stack = withIconFontFallbacks('Menlo, monospace, sans-serif')
|
||||
const nerd = indexOfFamily(stack, '"Symbols Nerd Font Mono"')
|
||||
|
||||
Reference in New Issue
Block a user