- 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
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
/**
|
||
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
|
||
* configured under the terminal card's secondary settings) turned into the
|
||
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
|
||
* chain and clamping are unit-testable without mounting a terminal.
|
||
*/
|
||
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
|
||
|
||
/** The built-in fallback stack when neither the user nor the theme sets one. */
|
||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||
|
||
/**
|
||
* Icon fonts appended to whichever base stack wins, so shell prompts that
|
||
* draw glyphs from the Nerd Font Private Use Areas resolve to a real glyph
|
||
* instead of the missing-glyph box (aka tofu).
|
||
*
|
||
* Why this is needed even though the OS "should" fall back automatically:
|
||
* prompt frameworks (starship, powerlevel10k, oh-my-posh) take their icons
|
||
* from the PUA. Chromium's implicit system fallback reliably covers the
|
||
* *BMP* PUA (U+E000–U+F8FF — e.g. the U+E0B0 powerline separator) but NOT
|
||
* the *supplementary-plane* PUA-B (U+F0000+) where Nerd Fonts v3 relocated
|
||
* the Material Design icon set. Naming the families explicitly makes the
|
||
* browser consult them per character, which covers both planes.
|
||
*
|
||
* Deliberately NOT listed: color-emoji families. Chromium routes genuine
|
||
* emoji code points through a dedicated emoji fallback path (which is why
|
||
* emoji already render), so naming them buys nothing here — while placing a
|
||
* color font ahead of the generic family risks capturing BMP symbols the
|
||
* prompt expects in monospace (U+26A0 ⚠, U+2714 ✔ …) and rendering them as
|
||
* wide color glyphs that break the cell grid.
|
||
*
|
||
* Ordering rationale: the symbols-only patches ship glyphs without Latin,
|
||
* so they can never hijack ASCII metrics — the safest first hop. The
|
||
* fully-patched distributions follow for users who installed one of those
|
||
* instead. Both the `… Mono` and proportional family names are listed
|
||
* because the Nerd Fonts installers register them as distinct families.
|
||
*
|
||
* These are strictly *appended*, never prepended: xterm derives its cell
|
||
* metrics from the first entry, so the base font must stay in front or the
|
||
* whole grid would be re-measured against an icon font.
|
||
*/
|
||
export const ICON_FONT_FALLBACKS: readonly string[] = [
|
||
// Symbols-only patches (glyph coverage without Latin) — ideal fallbacks.
|
||
'"Symbols Nerd Font Mono"',
|
||
'"Symbols Nerd Font"',
|
||
// Common fully-patched Nerd Font distributions.
|
||
'"Hack Nerd Font Mono"',
|
||
'"Hack Nerd Font"',
|
||
'"JetBrainsMono Nerd Font Mono"',
|
||
'"JetBrainsMono Nerd Font"',
|
||
'"FiraCode Nerd Font Mono"',
|
||
'"FiraCode Nerd Font"',
|
||
'"CaskaydiaCove Nerd Font Mono"',
|
||
'"CaskaydiaCove Nerd Font"',
|
||
'"SauceCodePro Nerd Font Mono"',
|
||
'"UbuntuMono Nerd Font Mono"',
|
||
'"Iosevka Nerd Font Mono"',
|
||
'"MesloLGS Nerd Font Mono"',
|
||
'"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.
|
||
*/
|
||
const GENERIC_FAMILIES = new Set([
|
||
'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy',
|
||
'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded',
|
||
'math', 'emoji', 'fangsong',
|
||
])
|
||
|
||
/**
|
||
* CSS-wide keywords. These are only valid as an *entire* declaration value,
|
||
* so a stack cannot be appended to one.
|
||
*/
|
||
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer'])
|
||
|
||
/** Normalize one family name for comparison: unquote, collapse runs of
|
||
* whitespace, casefold. */
|
||
function normalizeFamily(family: string): string {
|
||
return family
|
||
.trim()
|
||
.replace(/^["']|["']$/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* Split a CSS font-family stack on its top-level commas.
|
||
*
|
||
* Naive `split(',')` would corrupt quoted family names containing a comma
|
||
* and function values such as `var(--x, monospace)`, so quotes (with
|
||
* backslash escapes) and parentheses are tracked.
|
||
*
|
||
* @param stack - a CSS font-family stack.
|
||
* @returns the trimmed, non-empty family entries in source order.
|
||
*/
|
||
function splitFamilies(stack: string): string[] {
|
||
const entries: string[] = []
|
||
let buffer = ''
|
||
let quote: string | null = null
|
||
let depth = 0
|
||
for (let i = 0; i < stack.length; i += 1) {
|
||
const char = stack[i] as string
|
||
if (quote !== null) {
|
||
// Escapes are copied verbatim so `\"` does not close the quote.
|
||
if (char === '\\' && i + 1 < stack.length) {
|
||
buffer += char + (stack[i + 1] as string)
|
||
i += 1
|
||
continue
|
||
}
|
||
buffer += char
|
||
if (char === quote) quote = null
|
||
continue
|
||
}
|
||
if (char === '"' || char === "'") {
|
||
quote = char
|
||
buffer += char
|
||
continue
|
||
}
|
||
if (char === '(') depth += 1
|
||
else if (char === ')') depth = Math.max(0, depth - 1)
|
||
else if (char === ',' && depth === 0) {
|
||
entries.push(buffer)
|
||
buffer = ''
|
||
continue
|
||
}
|
||
buffer += char
|
||
}
|
||
entries.push(buffer)
|
||
return entries.map(entry => entry.trim()).filter(entry => entry !== '')
|
||
}
|
||
|
||
/**
|
||
* Append {@link ICON_FONT_FALLBACKS} to a CSS font-family stack, keeping
|
||
* the caller's own entries and order intact.
|
||
*
|
||
* - Families already named in `stack` are not duplicated (quote-, case- and
|
||
* whitespace-insensitive), so a user who already lists their Nerd Font
|
||
* 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. 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.
|
||
*
|
||
* @param stack - a CSS font-family stack (base font first).
|
||
* @returns the stack with icon fallbacks merged in.
|
||
*/
|
||
export function withIconFontFallbacks(stack: string): string {
|
||
const entries = splitFamilies(stack)
|
||
if (entries.length === 0) return ICON_FONT_FALLBACKS.join(', ')
|
||
|
||
const present = new Set(entries.map(normalizeFamily))
|
||
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
|
||
// 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(', ')
|
||
}
|
||
|
||
/**
|
||
* Reduce one link of the base-family chain to a usable stack, or `''` when
|
||
* it cannot carry appended fallbacks so the next link should win.
|
||
*
|
||
* Rejecting CSS-wide keywords matters because the theme font arrives as a
|
||
* raw token value (`tokenValue('--ds-font-family-code')`); skins do set
|
||
* tokens to `initial`/`inherit`/`unset` (see `effectiveTokenValue` in
|
||
* `theme.ts`, which guards the color tokens for the same reason). Appending
|
||
* to such a value yields an invalid `font-family`, which the CSSOM discards
|
||
* silently — the terminal would lose the theme font *and* the icon fonts.
|
||
*/
|
||
function usableBase(value: string | undefined): string {
|
||
const trimmed = (value ?? '').trim()
|
||
if (trimmed === '') return ''
|
||
if (CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase())) return ''
|
||
return trimmed
|
||
}
|
||
|
||
/**
|
||
* Resolve the xterm font options for the given prefs.
|
||
*
|
||
* The base family keeps its existing precedence — user pref > theme code
|
||
* font > built-in stack — and then {@link withIconFontFallbacks} tops it up
|
||
* so prompt icons resolve regardless of which base won.
|
||
*
|
||
* @param prefs - the current side card preferences.
|
||
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
||
* token value, read live by the caller); undefined when the token is absent.
|
||
* @returns the `fontFamily` / `fontSize` xterm options.
|
||
*/
|
||
export function resolveTerminalFont(
|
||
prefs: SidebarPrefs,
|
||
themeFontFamily: string | undefined,
|
||
): { fontFamily: string; fontSize: number } {
|
||
const base = usableBase(prefs.terminalFontFamily)
|
||
|| usableBase(themeFontFamily)
|
||
|| DEFAULT_TERMINAL_FONT_FAMILY
|
||
return {
|
||
fontFamily: withIconFontFallbacks(base),
|
||
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
||
}
|
||
}
|