feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS (#284)

* feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS

核心只保留 Web 标准机制,壳专属适配全部下沉到用户空间(KISS):
- prefs 新增 titleBarScheme(默认 auto)/titleBarPresetId/customCss,旧 titleBarCompat
  布尔自动迁移(true→custom 保留 stripPx,false/缺失→auto)
- wco.ts:navigator.windowControlsOverlay 反应式几何(geometrychange 实时更新;
  visible=false 的幽灵 API 视为缺失),实现 #257
- titlebar-strip.ts 唯一 strip 取值链:WCO → dsh-desktop-titlebar-inset 契约参数
  → 预设 stripFor → 手动 px → 0;desktop-env.ts 移除 win32 32px 硬编码
- shell-presets.ts 数据驱动预设(准入:issue/PR 提及 + 100+ star;v1 仅
  anywhere-labs DSH Desktop:darwin 20px / win32 无 WCO 时 32px),opt-in 手动
- 预设/自定义 CSS 注入(data-dsh-preset-css / data-dsh-custom-css 标签),
  chrome 补稳定寻址面 data-dsh-toggle-cluster / data-dsh-panel
- 交互 chrome 统一 -webkit-app-region: no-drag(吸收 #111/#153)
- layout.css 底栏推挤复合锚点([data-pane] 与 :has(> [data-slot]) 双保险,
  rc.8 实测前者失效、后者命中,吸收 #226 思路)
- 设置 UI:位置兼容模式齿轮弹窗改三方案(自动检测/壳兼容方案/自定义方案)
- 测试:wco/titlebar-strip/shell-presets 单测 + prefs 迁移 + e2e 5 场景
  (保守 auto/WCO 反应式/预设/自定义 CSS/复合锚点),挂载门禁 12/12 全绿

核心 grep 无壳专属类名;为某壳做的兼容在另一壳会再坏——预设与自定义 CSS
是唯一出路(AGENTS.md §8 契约已更新)。

* refine(sidebar): 位置兼容模式改主行下拉四选项——自动检测/DSH官方Web/壳预设/自定义

按用户反馈定稿 UI:
- 主行下拉:自动检测(默认)/ DSH官方Web(显式零适配,连 WCO 也强制 0)/
  各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(保留齿轮弹窗:
  下移距离 + 自定义 CSS)
- 旧文档迁移:titleBarCompat=true 或 titleBarStripPx 非默认值(此前已有配置)
  → 默认落到 custom 方案保留用户数值;干净文档 → auto
- prefs 新增 'web' 方案;strip 取值链最优先处理 web→0
- 测试:prefs 迁移(stripPx 48→custom / 40→auto)、web 强制 0、下拉渲染断言
- 单测 693 通过,挂载 e2e 12/12 全绿

* refine(sidebar): 方案下拉复用 SelectMenu 组件,不用原生 <select>

- 抽出可复用 SelectMenu(primitives Menu:锚点按钮 + 下拉选项卡,支持
  icon/desc/多选),SelectRow 改为其薄壳
- 位置兼容模式方案下拉改用 SelectMenu;选项带描述(自动检测/DSH官方Web/
  各壳预设(命中带「已检测」后缀)/自定义方案)
- 移除原生 select 样式;单测 693 通过,挂载 e2e 12/12 全绿
This commit is contained in:
Atlas Zeng
2026-08-21 01:28:17 +08:00
committed by GitHub
parent 4e52923970
commit 50a888845f
26 changed files with 1312 additions and 181 deletions
+22
View File
@@ -462,6 +462,28 @@
color: var(--dsw-alias-label-secondary);
}
/* The multi-line custom-CSS input (scheme `custom`): monospace, generous
height, resizable — same surface tokens as the typed inputs. */
.cssTextArea {
width: 100%;
box-sizing: border-box;
min-height: 120px;
padding: 8px 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code, monospace);
font-size: 12px;
line-height: 1.6;
resize: vertical;
}
.cssTextArea:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
/* Secondary settings popup: a wider dialog than the primitive default (the
dialog portals to document.body; the doubled class keeps the width rule
above the primitive's .dialog regardless of stylesheet order). */
+205 -68
View File
@@ -38,7 +38,7 @@
* shows the wire error inline — a broken settings surface never crashes the
* shell.
*/
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconChevronDownOutline14,
@@ -59,11 +59,14 @@ import {
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
type TitleBarScheme,
} from '../prefs-shared.ts'
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
import { t } from './locales.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getShellPreset, getShellPresets } from './shell-presets.ts'
import type { SidebarStore } from './state.ts'
import type {
BetterSidebarService,
@@ -109,6 +112,17 @@ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
return (a.order ?? 100) - (b.order ?? 100)
}
/**
* The scheme dropdown's current value: the plain scheme, or `preset:<id>`
* while a preset is active. Falls back to `auto` when the stored preset id
* is no longer registered (the strip resolves to 0 then anyway).
*/
function titleBarSchemeValue(prefs: SidebarPrefs): string {
if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
const preset = getShellPreset(prefs.titleBarPresetId)
return preset !== undefined ? `preset:${preset.id}` : 'auto'
}
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
return (b.priority ?? 0) - (a.priority ?? 0)
@@ -318,24 +332,60 @@ function TypedRow(props: {
</div>
)
}
/**
* The multi-line custom-CSS input (scheme `custom`): a monospace textarea
* whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
* the parent's handler. Keyed by the stored value so an external commit
* remounts it with the canonical text (same pattern as TypedRow).
*/
function CssDraft(props: {
value: string
onCommit: (raw: string) => void
label: string
placeholder?: string
}) {
const { value, onCommit, label, placeholder } = props
const [draft, setDraft] = useState(value)
return (
<textarea
className={css.cssTextArea}
rows={6}
value={draft}
placeholder={placeholder}
aria-label={label}
spellCheck={false}
onChange={event => { setDraft(event.currentTarget.value) }}
onBlur={() => { onCommit(draft) }}
onKeyDown={event => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
}}
/>
)
}
/**
* One select row: a dropdown over the toggle's declared `options`. When any
* option carries an icon, the dropdown renders big-icon option cards (icon +
* title + desc) and the closed anchor shows the selected option's icon as
* well; without icons both are a single line of text. Single-pick commits the
* option's value and closes; `multi` toggles membership, commits the picked
* values as an array (in options order), and stays open.
* The reusable dropdown — the primitives Menu, NOT a native <select>: a
* closed anchor button (picked option text + chevron) opening one Menu item
* per option (big-icon cards when any option carries an icon). Single-pick
* commits the option's value and closes; `multi` toggles membership and
* commits the picked values as an array (in options order), staying open.
* Shared by the declarative select rows (SelectRow) and the title-bar
* scheme dropdown on the General row.
*/
function SelectRow(props: {
toggle: SidebarSettingToggle
title: string
function SelectMenu(props: {
label: string
value: unknown
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
options: readonly {
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
multi?: boolean
onSelect: (next: unknown) => void
placeholder?: string
}) {
const { toggle, title, value, onSelectValue } = props
const options = toggle.options ?? []
const multi = toggle.multi === true
const { label, value, options, multi, onSelect, placeholder } = props
const [open, setOpen] = useState(false)
const hasIcons = options.some(option => option.icon !== undefined)
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
@@ -346,7 +396,7 @@ function SelectRow(props: {
const option = options[index]
if (option === undefined) return
if (!multi) {
onSelectValue?.(toggle, option.value)
onSelect(option.value)
setOpen(false)
return
}
@@ -355,14 +405,14 @@ function SelectRow(props: {
if (at >= 0) current.splice(at, 1)
else current.push(option.value)
// Stable wire order: follow the declared options order, not pick order.
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
}
const anchor = (
<button
type="button"
className={css.selectAnchor}
aria-label={title}
aria-label={label}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => { setOpen(now => !now) }}
@@ -371,12 +421,55 @@ function SelectRow(props: {
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
)}
<span className={css.selectAnchorText}>
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
</span>
<IconChevronDownOutline14 size={12} />
</button>
)
return (
<Menu
open={open}
anchor={anchor}
items={options.map((option, index) => ({
id: String(index),
label: hasIcons
? (
<span className={css.selectOption}>
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
</span>
</span>
)
: textOf(option.title),
}))}
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
onSelect={(id) => { pick(Number(id)) }}
onClose={() => { setOpen(false) }}
portal
/>
)
}
/**
* One select row: a dropdown over the toggle's declared `options` (the
* shared SelectMenu). When any option carries an icon, the dropdown renders
* big-icon option cards (icon + title + desc) and the closed anchor shows
* the selected option's icon as well; without icons both are a single line
* of text. Single-pick commits the option's value and closes; `multi`
* toggles membership, commits the picked values as an array (in options
* order), and stays open.
*/
function SelectRow(props: {
toggle: SidebarSettingToggle
title: string
value: unknown
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
}) {
const { toggle, title, value, onSelectValue } = props
return (
<div className={css.popupRow}>
<span className={css.rowText}>
@@ -384,28 +477,12 @@ function SelectRow(props: {
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<span className={css.control}>
<Menu
open={open}
anchor={anchor}
items={options.map((option, index) => ({
id: String(index),
label: hasIcons
? (
<span className={css.selectOption}>
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
</span>
</span>
)
: textOf(option.title),
}))}
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
onSelect={(id) => { pick(Number(id)) }}
onClose={() => { setOpen(false) }}
portal
<SelectMenu
label={title}
value={value}
options={toggle.options ?? []}
multi={toggle.multi === true}
onSelect={(next) => { onSelectValue?.(toggle, next) }}
/>
</span>
</div>
@@ -498,6 +575,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
// The parsed desktop environment (URL stamps — see desktop-env.ts). Used
// ONLY to badge matching presets in the scheme dropdown ("已检测");
// nothing is auto-applied.
const detectedEnv = useMemo(() => parseDesktopEnv(), [])
// Whether the "add plugin" modal (a dashed card at the end of the
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
// (null = closed).
@@ -635,6 +716,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return raw
}
/**
* Pick the title-bar / shell compatibility scheme. Mirrors the legacy
* `titleBarCompat` flag (true = anything but the conservative auto) so
* documents stay readable by older plugin versions.
*/
/**
* Pick the title-bar / shell compatibility scheme from the dropdown. The
* option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
* a preset stores both the scheme and its id. Mirrors the legacy
* `titleBarCompat` flag (true for preset/custom) so documents stay
* readable by older plugin versions.
*/
const onSchemeSelect = (value: unknown): void => {
if (typeof value !== 'string') return
if (value === 'auto' || value === 'web' || value === 'custom') {
applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
return
}
if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
applyPref({
titleBarScheme: 'preset',
titleBarPresetId: value.slice('preset:'.length),
titleBarCompat: true,
})
}
}
/** Commit the free-form custom CSS (scheme `custom`). */
const commitCustomCss = (raw: string): void => {
applyPref({ customCss: raw })
}
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
@@ -800,12 +913,31 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</span>
<span className={css.control}>
{/*
The position-compat row's gear (same popup pattern as the
feature cards): opens a Modal with the strip-height number row.
Hidden while the mode is off — its related setting is dormant
then (the feature-card convention).
The scheme dropdown (the shared SelectMenu — NOT a native
select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
自定义方案. Matching presets carry a 「已检测」 desc badge
(suggestion only). The 自定义方案 row keeps its gear (the
popup with the shift distance + custom CSS) — the other
schemes need no further settings.
*/}
{prefs.titleBarCompat && (
<SelectMenu
label={t('settingsTitleBarTitle')}
value={titleBarSchemeValue(prefs)}
options={[
{ value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
{ value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
...getShellPresets().map(preset => ({
value: `preset:${preset.id}`,
title: preset.title,
desc: preset.detect?.(detectedEnv) === true
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
: preset.desc,
})),
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
]}
onSelect={onSchemeSelect}
/>
{prefs.titleBarScheme === 'custom' && (
<button
type="button"
className={css.rowGear}
@@ -816,11 +948,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
<IconSettingsOutline16 size={14} />
</button>
)}
<Switch
label={t('settingsTitleBarTitle')}
checked={prefs.titleBarCompat}
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
/>
</span>
</div>
</div>
@@ -948,10 +1075,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</Modal>
)}
{/* The position-compat strip popup (opened by the gear on the 常规
row): one number row for the reserved strip height in px. Same
Modal chrome and row machinery as the feature popups — mounted
only while open (the Modal SSR rule above). */}
{/* The custom-scheme popup (opened by the gear next to the scheme
dropdown when 自定义方案 is active): the shift distance in px and
the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
壳预设) need no further settings — the scheme itself is chosen on
the 常规 row. Mounted only while open (the Modal SSR rule above). */}
{stripSettingsOpen && (
<Modal
open
@@ -966,20 +1094,29 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</button>
)}
>
<FeatureSettingsRows
toggles={[{
key: 'titleBarStripPx',
type: 'number',
title: () => t('settingsTitleBarStripTitle'),
desc: () => t('settingsTitleBarStripDesc'),
min: TITLE_BAR_STRIP_MIN,
max: TITLE_BAR_STRIP_MAX,
unit: 'px',
}]}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
/>
<div className={css.popupRows}>
<FeatureSettingsRows
toggles={[{
key: 'titleBarStripPx',
type: 'number',
title: () => t('settingsTitleBarStripTitle'),
desc: () => t('settingsTitleBarStripDesc'),
min: TITLE_BAR_STRIP_MIN,
max: TITLE_BAR_STRIP_MAX,
unit: 'px',
}]}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
/>
<CssDraft
key={prefs.customCss}
value={prefs.customCss}
label={t('settingsCustomCssTitle')}
placeholder={t('settingsCustomCssPlaceholder')}
onCommit={commitCustomCss}
/>
</div>
</Modal>
)}
+62 -21
View File
@@ -44,6 +44,9 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
import { computeTitleBarStrip } from './titlebar-strip.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
@@ -86,6 +89,20 @@ const osFileDragShield = {
onDrop: swallowOsFileDrag,
}
/**
* Append one user-space stylesheet (preset or custom CSS) as a tagged
* `<style>` element. The tag attribute carries the source identity so the
* running configuration is inspectable in DevTools; the returned tag is
* removed by the caller's effect cleanup.
*/
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
const tag = document.createElement('style')
tag.setAttribute(attr, id)
tag.textContent = cssText
document.head.appendChild(tag)
return tag
}
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
@@ -229,25 +246,31 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
}, [collapsed])
// Position compatibility mode (titleBarCompat pref): Windows frameless
// windows draw the native title bar (minimize/maximize/close) at the
// window's top-right corner, OVER the web content. When enabled, the body
// attribute lets sidebar.module.css drop the toggle cluster below the
// strip and push the right panel's content below it. Auto-enabled in the
// win32 advanced desktop shell (the shell stamps the URL — see
// desktop-env.ts — and reserves a 32px overlay for the window controls);
// the manual pref and its strip height still win when the user sets them,
// and the manual switch only ever ADDS compat. The strip height rides a
// CSS variable so the rules stay declarative; the attribute rides the
// snapshot's prefs, so flipping the setting re-renders and re-applies
// immediately; the cleanup removes both on unmount/boundary swap so a
// crashed sidebar never leaves them behind.
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
// auto — CONSERVATIVE: only the standard Window Controls Overlay
// geometry contributes (the real caption-overlay height,
// reactive to maximize/restore). No URL stamp, no preset, no
// guess — plain browsers see zero modification.
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
// per-shell strip as the no-WCO fallback.
// custom — the user's own CSS (injected below) + the legacy manual
// strip px.
// The resolved strip drives the SAME body attribute + CSS variable as the
// legacy boolean did, so the CSS contract is unchanged (layout.css /
// sidebar.module.css); only the value source changed. The cleanup removes
// both on unmount/boundary swap so a crashed sidebar never leaves them
// behind.
const desktopEnv = parseDesktopEnv()
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
const titleBarStrip = snapshot.prefs.titleBarCompat
? snapshot.prefs.titleBarStripPx
: desktopEnv.win32OverlayTop
const wco = useSyncExternalStore(
useMemo(() => subscribeWco, []),
getWcoSnapshot,
)
const scheme = snapshot.prefs.titleBarScheme
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
const titleBarStrip = computeTitleBarStrip(
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
)
const titleBarCompat = titleBarStrip > 0
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
@@ -263,6 +286,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
}, [titleBarCompat, titleBarStrip])
// User-space CSS injection (the escape hatch): preset CSS (scheme
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
// the plugin's own styles — later in the cascade wins ties, and
// `!important` can override the JS-written inline strip variable. Each
// source gets its own tagged <style> so the running configuration stays
// inspectable; tags are removed on change/unmount so a stale stylesheet
// never outlives its fiber (HMR-safe).
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
useEffect(() => {
const tags: HTMLStyleElement[] = []
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
return () => { for (const tag of tags) tag.remove() }
}, [presetCss, customCss, preset?.id])
/**
* Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition),
@@ -891,7 +930,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (state === undefined || sessionId === undefined) {
return (
<div data-dsh-panel-host {...osFileDragShield}>
<div className={css.toggleCluster}>
<div className={css.toggleCluster} data-dsh-toggle-cluster>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
@@ -985,7 +1024,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
right end it really squeezes (the strip reserves its width via CSS),
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
<div className={css.toggleCluster} data-dsh-toggle-cluster>
{/*
Narrow viewports merge the two workbenches into the one drawer —
there is no bottom panel, so its toggle button is not offered.
@@ -1025,6 +1064,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
data-dsh-panel
style={{
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
@@ -1155,7 +1195,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
data-dsh-bottom-panel=""
data-dsh-panel
data-dsh-bottom-panel
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
+38 -24
View File
@@ -1,21 +1,18 @@
/**
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
* (Electron) stamps every render URL with `dsh-desktop-mode` and
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
* through its preload; community Tauri shells keep the native system frame
* (no stamps — plain-browser semantics). Parsed once per page and memoized
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
* memo for unit tests.
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
* preload. Parsed once per page and memoized (the URL never changes
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
*
* Geometry facts the sidebar adapts to (from the Electron runtime):
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
* — exactly where the toggle cluster sits, so it must drop below the
* strip and the right panel's content must clear it.
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
* top-left; nothing on the plugin's side touches that corner today, but
* the platform is still reported so left-edge controls can avoid it.
* - `compatibility` mode keeps the native frame — no adaptation needed.
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
* how to adapt. The strip height comes from standard signals first (the
* Window Controls Overlay API, see wco.ts), then the documented contract
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
* it reserves at the top), then the user's chosen scheme (preset / custom).
* The legacy win32-advanced 32px constant is gone from the core: it lives
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
* without the WCO API.
*/
export interface DesktopEnv {
/** Running inside a desktop shell (any URL stamp or preload marker). */
@@ -24,34 +21,51 @@ export interface DesktopEnv {
readonly mode: 'compatibility' | 'advanced' | null
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
readonly platform: string | null
/** Pixels the win32 advanced shell reserves at the top-right for window
* controls (0 elsewhere). */
readonly win32OverlayTop: number
/**
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
* reserves at the top of the web content for its own chrome (0–120,
* clamped; 0 when absent). Standard WCO geometry takes precedence over
* this whenever the API is available.
*/
readonly titlebarInset: number
}
let cached: DesktopEnv | undefined
/** Read the shell's desktop stamps (memoized per page). */
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
export function parseDesktopEnv(): DesktopEnv {
if (cached !== undefined) return cached
const hasWindow = typeof window !== 'undefined'
const hasPreloadMarker = hasWindow
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
// location.search includes the leading '?', which URLSearchParams does NOT
// strip (it would become part of the first key) — drop it explicitly.
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
// SSR (no window) resolves the empty environment: nothing desktop.
const params = hasWindow
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
: new URLSearchParams()
const modeParam = params.get('dsh-desktop-mode')
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
const platformParam = params.get('dsh-desktop-platform')
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
const desktop = mode !== null
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
const desktop = mode !== null || hasPreloadMarker
cached = {
desktop,
mode,
platform,
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
}
return cached
}
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
function parseTitlebarInset(raw: string | null): number {
if (raw === null) return 0
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return Math.min(120, Math.max(0, Math.round(parsed)))
}
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
export function resetDesktopEnvForTests(): void {
cached = undefined
+12 -4
View File
@@ -35,8 +35,14 @@
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's center column, anchored by data attributes (see above). */
#root [data-dsh-frame] > [data-pane="conversation"] {
/* The AppFrame's center column, anchored by data attributes (see above).
Composite selector: DSH 0.1.x versions name the center grid item
`[data-pane="conversation"]`, while rc.8-era shells put a
`[data-slot="conversation"]` child inside it — both selectors resolve to
the SAME element on live pages (verified), and keeping both future-proofs
the rule against a host rename without touching shell-specific markup. */
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
@@ -55,7 +61,8 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
transition: none;
}
@@ -80,7 +87,8 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversatio
@media (prefers-reduced-motion: reduce) {
#root,
#root [data-dsh-frame] > [data-pane="conversation"] {
#root [data-dsh-frame] > [data-pane="conversation"],
#root :has(> [data-slot="conversation"]) {
transition: none;
}
}
+24 -4
View File
@@ -154,9 +154,19 @@ export const zh = {
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
settingsTitleBarStripTitle: '下移距离',
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40;自定义方案下生效)',
settingsSchemeAutoTitle: '自动检测',
settingsSchemeAutoDesc: '保守方案:仅在 Window Controls Overlay 标准 API 可用时按真实标题栏高度让位;网页环境下不做任何修改',
settingsSchemeWebTitle: 'DSH官方Web',
settingsSchemeWebDesc: '显式声明运行在官方网页版:不做任何适配(连标准 WCO 几何也不适用)',
settingsSchemeCustomTitle: '自定义方案',
settingsSchemeCustomDesc: '完全由你控制:注入自定义 CSS(可覆盖内置样式),并指定标题栏下移距离',
settingsSchemeDetectedSuffix: '已检测',
settingsCustomCssTitle: '自定义 CSS',
settingsCustomCssDesc: '追加到页面末尾的样式(同优先级下后写胜出;覆盖 JS 内联变量需用 !important)',
settingsCustomCssPlaceholder: '/* 例:为自绘标题栏的壳预留 36px */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
@@ -422,9 +432,19 @@ export const en: Record<keyof typeof zh, string> = {
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsTitleBarTitle: 'Position compatibility mode',
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
settingsTitleBarStripTitle: 'Shift distance',
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40; applies under the custom scheme)',
settingsSchemeAutoTitle: 'Auto-detect',
settingsSchemeAutoDesc: 'Conservative: only the standard Window Controls Overlay API contributes (real caption-overlay height); plain web environments get no modification',
settingsSchemeWebTitle: 'DSH official web',
settingsSchemeWebDesc: 'Explicitly declare the official web UI: no adaptation at all (not even standard WCO geometry)',
settingsSchemeCustomTitle: 'Custom',
settingsSchemeCustomDesc: 'Full control: inject custom CSS (can override built-in styles) and set the title-bar shift distance',
settingsSchemeDetectedSuffix: 'detected',
settingsCustomCssTitle: 'Custom CSS',
settingsCustomCssDesc: 'Styles appended at the end of the page (later in the cascade wins ties; use !important to override JS-written inline variables)',
settingsCustomCssPlaceholder: '/* e.g. reserve 36px for a shell with a custom-drawn title bar */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
+43 -2
View File
@@ -14,11 +14,21 @@ import {
clampTitleBarStrip,
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
type SidebarPrefs,
type TitleBarScheme,
} from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
export type { SidebarPrefs }
export {
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent,
}
export type { SidebarPrefs, TitleBarScheme }
/** The settings wire face the preferences need (a subset of the plugin api). */
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
@@ -69,6 +79,22 @@ export function parsePrefs(value: unknown): SidebarPrefs {
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
// The title-bar scheme (auto | web | preset | custom). The schema
// declares the field WITHOUT a default, so documents written by older
// plugin versions resolve without it — migrate from the legacy fields:
// a document that ALREADY HAS VALUES (the manual compat flag on, or a
// non-default strip px — both only reachable through the old gear
// popup) maps to the `custom` scheme so the user's numbers keep
// working; a pristine document keeps the conservative `auto` scheme.
titleBarScheme: isTitleBarScheme(record.titleBarScheme)
? record.titleBarScheme
: (record.titleBarCompat === true || hasLegacyStripValue(record.titleBarStripPx) ? 'custom' : 'auto'),
titleBarPresetId: typeof record.titleBarPresetId === 'string'
? record.titleBarPresetId
: SIDEBAR_PREFS_DEFAULTS.titleBarPresetId,
customCss: typeof record.customCss === 'string'
? record.customCss
: SIDEBAR_PREFS_DEFAULTS.customCss,
titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
@@ -130,6 +156,21 @@ function booleanMapOf(value: unknown): Record<string, boolean> {
return out
}
/** Type guard for the title-bar scheme union (anything else falls back). */
function isTitleBarScheme(value: unknown): value is TitleBarScheme {
return typeof value === 'string' && (TITLE_BAR_SCHEMES as readonly string[]).includes(value)
}
/**
* Whether the legacy document carries an explicit strip value (only
* reachable through the old gear popup): a stored number different from the
* default counts as "the user already configured something" and migrates to
* the `custom` scheme.
*/
function hasLegacyStripValue(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value) && value !== TITLE_BAR_STRIP_DEFAULT
}
/**
* Read the resolved side card preferences through the plugin's settings route.
* @param settings - the settings wire face (the plugin api by default).
+83
View File
@@ -0,0 +1,83 @@
/**
* Built-in desktop-shell presets for the "位置兼容模式" secondary setting
* (scheme `preset`). DATA-DRIVEN by design: each shell's adaptation lives
* in one entry (strip values / optional CSS), the core applies it through
* the SAME generic strip variable and CSS-injection mechanism — adding a
* shell is adding data, never a code path.
*
* Inclusion rule (maintained in AGENTS.md §8): only shells that (a) appear
* in this repo's issues/PRs (a user actually hit a problem) and (b) have
* 100+ GitHub stars (a real user base). The mechanism is opt-in: auto
* detection never applies a preset — the settings badge only SUGGESTS it.
*/
import type { DesktopEnv } from './desktop-env.ts'
export interface ShellPreset {
/** Stable preset id (persisted in `titleBarPresetId`). */
readonly id: string
/** User-facing name of the shell. */
readonly title: string
/** One-line description shown in the settings popup. */
readonly desc: string
/**
* The top strip (px) this shell reserves over web content, per
* environment — the fallback used when neither the standard WCO API nor
* the `dsh-desktop-titlebar-inset` contract parameter is available.
* Return undefined when the shell needs no strip in that environment.
* MUST be pure (called during render).
*/
readonly stripFor?: (env: DesktopEnv) => number | undefined
/**
* Extra CSS applied while the preset is enabled (injected last, after the
* plugin's own styles). Targets the plugin's stable data attributes and
* shell-declared body/URL markers only — never other shells' class names.
* Empty for presets fully covered by the strip variable.
*/
readonly css?: string
/**
* Whether this shell's marker is currently visible (URL stamps / preload
* markers — see desktop-env.ts). Used ONLY for the settings "已检测"
* badge; never auto-applies anything. MUST be pure.
*/
readonly detect?: (env: DesktopEnv) => boolean
}
/**
* DeepSeek Harness Desktop (anywhere-labs, Electron, 16k+ stars — the most
* reported shell in this repo's issues/PRs). Advanced mode: macOS reserves
* a 20px caption row (traffic lights top-left), win32 draws the native
* window controls in a ~32px overlay (WCO reports the real height when
* available, which the auto scheme already consumes; the 32 is the
* no-WCO fallback). Compatibility mode keeps the native frame — nothing.
*/
const DSH_DESKTOP: ShellPreset = {
id: 'dsh-desktop',
title: 'DeepSeek Harness Desktop',
desc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
stripFor: (env) => {
if (env.mode !== 'advanced') return undefined
if (env.platform === 'darwin') return 20
if (env.platform === 'win32') return 32
return undefined
},
detect: (env) => env.mode === 'advanced',
}
const PRESETS: readonly ShellPreset[] = [
DSH_DESKTOP,
]
/** All built-in shell presets (registration order = settings list order). */
export function getShellPresets(): readonly ShellPreset[] {
return PRESETS
}
/** One preset by id, or undefined for an unknown/empty id. */
export function getShellPreset(id: string): ShellPreset | undefined {
return PRESETS.find(preset => preset.id === id)
}
/** The strip the active preset contributes for the given environment. */
export function presetStripFor(preset: ShellPreset | undefined, env: DesktopEnv): number | undefined {
return preset?.stripFor?.(env)
}
+15
View File
@@ -282,6 +282,21 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
padding-right: 40px;
}
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
(Electron `-webkit-app-region: drag`); pointer events over that strip are
swallowed as window drags unless the interactive element opts out
(issues #103/#111). The plugin's viewport-corner chrome must declare
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
wrappers) have no blanket button rule. The property is a no-op in plain
browsers and in shells without drag regions — safe everywhere. */
.toggleCluster,
.toggleButton,
.tabBar {
-webkit-app-region: no-drag;
}
/* ── Position compatibility mode (Windows native title bar) ────────────── */
/* Windows frameless / hidden-title-bar windows draw the native caption
+37
View File
@@ -0,0 +1,37 @@
/**
* The title-bar strip resolution chain — the ONE place that decides how
* many pixels the sidebar yields at the top. Standard signals first, then
* the user's chosen scheme; never a per-shell branch:
*
* 0. `web` scheme — EXPLICIT "DSH official web": never adapt, not even
* standard WCO geometry (the user declares the plain web UI).
* 1. Window Controls Overlay real geometry (standard API, authoritative
* when present — even 0, e.g. the overlay is hidden while maximized).
* 2. The `dsh-desktop-titlebar-inset` URL contract parameter (a shell
* declares the exact pixels it reserves).
* 3. The active shell preset's strip (scheme `preset` — opt-in data).
* 4. The legacy manual `titleBarStripPx` (scheme `custom`).
* 5. 0 — plain-browser semantics, nothing modified.
*
* The result drives `--dsh-title-bar-strip` + `body[data-dsh-title-bar-compat]`
* exactly like the legacy boolean did; only the VALUE source changed.
*/
import type { DesktopEnv } from './desktop-env.ts'
import type { TitleBarScheme } from '../prefs-shared.ts'
import type { WcoSnapshot } from './wco.ts'
import { presetStripFor, type ShellPreset } from './shell-presets.ts'
export function computeTitleBarStrip(
env: DesktopEnv,
wco: WcoSnapshot,
scheme: TitleBarScheme,
preset: ShellPreset | undefined,
customStripPx: number,
): number {
if (scheme === 'web') return 0
if (wco.present) return wco.height
if (env.titlebarInset > 0) return env.titlebarInset
if (scheme === 'preset') return presetStripFor(preset, env) ?? 0
if (scheme === 'custom') return customStripPx
return 0
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Reactive Window Controls Overlay geometry — the STANDARD web mechanism
* for frameless Chromium shells that draw the native caption buttons
* (minimize / maximize / close) OVER the web content (Electron
* `titleBarOverlay` on Windows; issue #257). Feature-detected: absent in
* plain browsers, macOS, Tauri etc., where the snapshot stays `NONE` and
* the sidebar adapts nothing — this is the conservative "auto" signal.
*
* The API reports the real titlebar rect (CSS px), which differs between
* shells and Electron versions (32 / 36 …), and fires `geometrychange`
* when the window maximizes / restores / moves — so the strip must be
* reactive, not memoized like the URL stamps.
*
* Module-level store so the Sidebar shell can subscribe via
* `useSyncExternalStore`; `setWcoSourceForTests` swaps the source for unit
* tests (no real API exists in jsdom/headless).
*/
export interface WcoSource {
readonly visible: boolean
getTitlebarAreaRect(): { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
addEventListener(type: 'geometrychange', listener: () => void): void
removeEventListener(type: 'geometrychange', listener: () => void): void
}
/** The current overlay geometry (present=false = API unavailable). */
export interface WcoSnapshot {
readonly present: boolean
readonly height: number
}
/** Snapshot when the API is unavailable (plain browser / non-overlay shell). */
export const WCO_NONE: WcoSnapshot = Object.freeze({ present: false, height: 0 })
type Listener = () => void
let source: WcoSource | undefined
let snapshot: WcoSnapshot = WCO_NONE
let attached = false
let sourceListener: (() => void) | undefined
const listeners = new Set<Listener>()
function read(): WcoSnapshot {
if (source === undefined) return WCO_NONE
try {
// A present-but-NOT-visible API is a PHANTOM: headless Chromium and
// macOS builds expose the interface but draw no overlay (empty rect,
// visible=false). Only a genuinely visible overlay is authoritative —
// otherwise the resolution chain would trust a zero rect and silently
// skip the shell's real reserved space.
if (source.visible !== true) return { present: false, height: 0 }
const rect = source.getTitlebarAreaRect()
const height = Math.round(rect.height)
return Number.isFinite(height) && height > 0
? { present: true, height }
: { present: true, height: 0 }
} catch {
// A hostile/racy API must never break the layout — treat as absent.
return { present: false, height: 0 }
}
}
function onGeometryChange(): void {
snapshot = read()
emit()
}
function emit(): void {
for (const listener of listeners) listener()
}
/** Attach the native geometrychange listener (once). */
function attach(): void {
if (attached) return
attached = true
// The test hook may have installed a source already; otherwise attach to
// the real navigator API (absent in plain browsers → stay NONE).
const candidate = source ?? (navigator as unknown as { windowControlsOverlay?: WcoSource }).windowControlsOverlay
if (candidate === undefined) return
source = candidate
sourceListener = onGeometryChange
snapshot = read()
source.addEventListener('geometrychange', sourceListener)
}
/** Detach the native listener (last subscriber left or source swapped). */
function detach(): void {
if (source !== undefined && sourceListener !== undefined) {
source.removeEventListener('geometrychange', sourceListener)
}
sourceListener = undefined
attached = false
}
/** Read the current snapshot (returns the frozen NONE when unavailable). */
export function getWcoSnapshot(): WcoSnapshot {
return snapshot
}
/**
* Subscribe to overlay geometry changes. Attaches to the real
* `navigator.windowControlsOverlay` on first subscribe; the disposer
* detaches the native listener when the last subscriber leaves.
*/
export function subscribeWco(onChange: Listener): () => void {
listeners.add(onChange)
attach()
return () => {
listeners.delete(onChange)
if (listeners.size === 0) detach()
}
}
/** Test hook: swap the geometry source (undefined = API unavailable). */
export function setWcoSourceForTests(next: WcoSource | undefined): void {
detach()
source = next
snapshot = next === undefined ? WCO_NONE : read()
if (next !== undefined && listeners.size > 0) attach()
emit()
}
+3
View File
@@ -129,6 +129,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
editorExplorer: z.boolean().default(false),
terminalShell: z.string().default(''),
terminalShellArgs: z.string().default(''),
titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]),
titleBarPresetId: z.string(),
customCss: z.string(),
titleBarCompat: z.boolean().default(false),
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
htmlViewerNoSandbox: z.boolean().default(false),
+41 -8
View File
@@ -85,17 +85,43 @@ export interface SidebarPrefs {
*/
terminalShellArgs: string
/**
* Position compatibility mode: reserves space at the top for the native
* Windows title bar (drawn at the window's top-right corner over the web
* content in frameless/hidden-title-bar windows). When on, the toggle
* cluster drops below the strip and the right panel's content starts
* below it. Off by default — the sidebar layout is untouched.
* Title-bar / shell compatibility scheme (the "位置兼容模式" setting):
* - `auto` (default): CONSERVATIVE — only the standard Window Controls
* Overlay API (present in frameless Chromium shells that draw the
* native caption buttons over web content) contributes real geometry;
* without it nothing is modified, so plain-browser (web) behavior is
* untouched.
* - `web`: EXPLICIT "DSH official web" — never adapt, not even WCO
* geometry (the user declares they run the plain web UI).
* - `preset`: apply the built-in shell preset named by
* `titleBarPresetId` (data-driven, opt-in — see shell-presets.ts).
* - `custom`: apply the free-form `customCss` (and the legacy
* `titleBarStripPx` strip).
*/
titleBarScheme: TitleBarScheme
/**
* The built-in shell preset id applied while `titleBarScheme` is
* `preset` ('' = no preset — nothing extra is applied).
*/
titleBarPresetId: string
/**
* Free-form CSS injected into the page (last in the cascade, so it can
* override the plugin's styles; use `!important` to override JS-written
* inline CSS variables). Applied while `titleBarScheme` is `custom`.
*/
customCss: string
/**
* LEGACY (kept for read-migration and downgrade mirroring only): position
* compatibility mode flag. The UI writes `titleBarScheme` instead; a
* stored `true` without a scheme migrates to the `custom` scheme (with
* `titleBarStripPx` preserved).
*/
titleBarCompat: boolean
/**
* The reserved top strip height in px when `titleBarCompat` is on
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
* the toggle cluster drops `strip + 3px` and the right panel's content
* LEGACY (kept for read-migration and downgrade mirroring only): the
* reserved top strip height in px used by the `custom` scheme (0–120,
* default 40). Drives the `--dsh-title-bar-strip` CSS variable: the
* toggle cluster drops `strip + 3px` and the right panel's content
* starts `strip` px below its top edge.
*/
titleBarStripPx: number
@@ -192,6 +218,10 @@ export const TITLE_BAR_STRIP_MIN = 0
export const TITLE_BAR_STRIP_MAX = 120
export const TITLE_BAR_STRIP_DEFAULT = 40
/** The title-bar / shell compatibility schemes (see {@link SidebarPrefs.titleBarScheme}). */
export const TITLE_BAR_SCHEMES = ['auto', 'web', 'preset', 'custom'] as const
export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number]
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: false,
@@ -206,6 +236,9 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
editorExplorer: false,
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
titleBarPresetId: '',
customCss: '',
titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false,