From 699ce448a1631dc2ce8e76669ab618d91cb8ca46 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Mon, 24 Aug 2026 18:40:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=20Copilot=20=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=20review=20=E7=9A=84=2010=20=E6=9D=A1=E8=AF=84?= =?UTF-8?q?=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/client/BrowserView.tsx | 20 ++++++++++++++++-- src/client/EditorHost.tsx | 5 ++++- src/client/FileTree.tsx | 18 +++++++++++++++- src/client/Sidebar.tsx | 8 +++---- src/client/TreePanel.tsx | 4 +++- src/client/builtins/tabs.tsx | 3 ++- src/client/intercept.tsx | 13 +++++++++--- src/client/sidebar.module.css | 15 +++++++++---- src/client/state.ts | 16 +++++++++++--- src/client/terminal-font.ts | 26 +++++++++++++++++++---- src/index.ts | 5 +++++ tests/editor-host.spec.tsx | 1 + tests/editor-refresh.spec.tsx | 2 +- tests/file-tree-drop.spec.tsx | 1 + tests/file-tree-open-with.spec.tsx | 1 + tests/markdown-manual-refresh.spec.tsx | 1 + tests/sandbox-views.spec.tsx | 19 ++++++++++++++++- tests/state.spec.ts | 29 +++++++++++++++++++++++++- tests/terminal-font.spec.ts | 11 ++++++++++ 19 files changed, 171 insertions(+), 27 deletions(-) diff --git a/src/client/BrowserView.tsx b/src/client/BrowserView.tsx index 92cd102..c44d41a 100644 --- a/src/client/BrowserView.tsx +++ b/src/client/BrowserView.tsx @@ -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} diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 2ae2124..d394c73 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index a65122b..7791201 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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>({}) 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) }} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d555296..97c8160 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 }) }} /> ))} {/* diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 8bb3f38..e304328 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -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(null) @@ -221,6 +222,7 @@ export function TreePanel(props: { sessionId={sessionId} cwd={cwd} expanded={expanded} + revealed={revealed} onToggle={onToggle} onOpenFile={onOpenFile} onOpenFileNewTab={onOpenFileNewTab} diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index ee644ac..18255d5 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -119,13 +119,14 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read ), }, - component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => ( + component: ({ ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile }) => ( { /* no-op */ })} onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })} /> diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index 68b7d37..f55de6c 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -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). */ diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 1793b2b..26e1862 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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); } diff --git a/src/client/state.ts b/src/client/state.ts index 1fdd885..7420e54 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -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 — diff --git a/src/client/terminal-font.ts b/src/client/terminal-font.ts index 9aad290..24e78b5 100644 --- a/src/client/terminal-font.ts +++ b/src/client/terminal-font.ts @@ -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(', ') } /** diff --git a/src/index.ts b/src/index.ts index c7498da..3852518 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 253db8c..88a97e0 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -62,6 +62,7 @@ function mountHost(ctx: Context, store: ReturnType, t scope: { sessionId: 'editor-home-session' }, tab: tab(), expanded: [], + revealed: [], onToggleDir: () => {}, onReferenceFile: () => {}, })) diff --git a/tests/editor-refresh.spec.tsx b/tests/editor-refresh.spec.tsx index a6e9734..23ace84 100644 --- a/tests/editor-refresh.spec.tsx +++ b/tests/editor-refresh.spec.tsx @@ -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 { diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index c76e661..f6d496c 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -56,6 +56,7 @@ async function mountTree(busy = false): Promise { sessionId: 's1', cwd: '/tmp', expanded: [], + revealed: [], onToggle: () => {}, onOpenFile: () => {}, onReferenceFile: () => {}, diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx index 8ac1e21..774542d 100644 --- a/tests/file-tree-open-with.spec.tsx +++ b/tests/file-tree-open-with.spec.tsx @@ -65,6 +65,7 @@ async function mountTree(overrides: { sessionId: 's1', cwd: '/tmp', expanded: [], + revealed: [], onToggle: () => {}, onOpenFile: () => {}, onOpenFileNewTab: () => {}, diff --git a/tests/markdown-manual-refresh.spec.tsx b/tests/markdown-manual-refresh.spec.tsx index dd95b79..d95acfd 100644 --- a/tests/markdown-manual-refresh.spec.tsx +++ b/tests/markdown-manual-refresh.spec.tsx @@ -78,6 +78,7 @@ function mount(ctx: Context, store: ReturnType, tab: scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' }, tab, expanded: [], + revealed: [], onToggleDir: () => {}, onReferenceFile: () => {}, })) diff --git a/tests/sandbox-views.spec.tsx b/tests/sandbox-views.spec.tsx index ddda60c..2b88157 100644 --- a/tests/sandbox-views.spec.tsx +++ b/tests/sandbox-views.spec.tsx @@ -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/'))) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 4ca4ec3..0142559 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -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 beforeEach(() => { diff --git a/tests/terminal-font.spec.ts b/tests/terminal-font.spec.ts index 01b47e2..b5c63d1 100644 --- a/tests/terminal-font.spec.ts +++ b/tests/terminal-font.spec.ts @@ -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"')