feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间
新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster 下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口 右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关, zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema 默认值、设置页渲染与开关状态。
This commit is contained in:
@@ -660,6 +660,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
|
||||
@@ -167,6 +167,21 @@ 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 the user enables
|
||||
// the pref, the body attribute lets sidebar.module.css drop the toggle
|
||||
// cluster below the strip and push the right panel's content below it.
|
||||
// The attribute rides the snapshot's prefs, so flipping the setting
|
||||
// re-renders and re-applies immediately; the cleanup removes it on
|
||||
// unmount/boundary swap so a crashed sidebar never leaves it behind.
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat
|
||||
useEffect(() => {
|
||||
if (titleBarCompat) document.body.setAttribute('data-dsh-title-bar-compat', '')
|
||||
else document.body.removeAttribute('data-dsh-title-bar-compat')
|
||||
return () => { document.body.removeAttribute('data-dsh-title-bar-compat') }
|
||||
}, [titleBarCompat])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
|
||||
@@ -116,6 +116,8 @@ export const zh = {
|
||||
settingsWidthSuffix: '%',
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -333,6 +335,8 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsWidthSuffix: '%',
|
||||
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',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
|
||||
@@ -59,6 +59,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
|
||||
? record.htmlViewerNoSandbox
|
||||
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
|
||||
|
||||
@@ -209,6 +209,28 @@
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Position compatibility mode (Windows native title bar) ────────────── */
|
||||
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
strip (minimize / maximize / close) at the window's top-right corner,
|
||||
OVER the web content. When the user enables the "position compatibility"
|
||||
pref, the sidebar yields the strip: the toggle cluster drops below it and
|
||||
the right panel's content starts below it — the panel's surface still
|
||||
spans the full height, so the caption buttons float over empty sidebar
|
||||
fill (the VSCode hidden-title-bar look). The bottom panel is unaffected:
|
||||
it sits at the window's bottom, never under the top strip. The Sidebar
|
||||
shell toggles the body attribute from the prefs. */
|
||||
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
|
||||
top: 43px; /* 40px caption strip + the original 3px inset */
|
||||
}
|
||||
|
||||
:global(body[data-dsh-title-bar-compat]) .panel {
|
||||
/* The caption strip is drawn over the panel's own top edge: keep the
|
||||
surface full-height and push the content (the tab strip) below the
|
||||
strip instead. .panelResize is absolutely positioned and unaffected. */
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* The shared corner (both panels open): the intersection of the right
|
||||
panel's left edge and the bottom panel's top edge. Horizontal drags
|
||||
resize the width, vertical drags the height — the two panels drag
|
||||
|
||||
@@ -91,6 +91,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
browserNoSandbox: z.boolean().default(false),
|
||||
|
||||
@@ -60,6 +60,14 @@ export interface SidebarPrefs {
|
||||
* own enable switch gates it too (both must be on for the takeover).
|
||||
*/
|
||||
interceptOpenPath: boolean
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
titleBarCompat: boolean
|
||||
/**
|
||||
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
|
||||
* default) renders previewed HTML in an opaque-origin iframe that cannot
|
||||
@@ -159,6 +167,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
|
||||
@@ -54,6 +54,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The position-compat mode defaults OFF (the normal layout is default).
|
||||
expect(resolved.titleBarCompat).toBe(false)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
@@ -63,6 +65,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +151,24 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const html = renderSection(store, service)
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
|
||||
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
|
||||
const { store, service } = mount()
|
||||
let html = renderSection(store, service)
|
||||
// The general row renders its title and description.
|
||||
expect(html).toContain('Position compatibility mode')
|
||||
expect(html).toContain('Reserve space for the native Windows title bar')
|
||||
// Three general rows now: openByDefault + interceptOpenPath checked,
|
||||
// the new titleBarCompat row UNCHECKED (default off) — the checked
|
||||
// checkbox count stays at 2 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
|
||||
// When the pref is on, the new switch is checked too.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
|
||||
html = renderSection(store, service)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
|
||||
@@ -520,6 +520,7 @@ describe('side card settings routes', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
|
||||
+17
-4
@@ -1140,6 +1140,7 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
@@ -1164,6 +1165,7 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
@@ -1188,6 +1190,7 @@ describe('side card preferences', () => {
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
titleBarCompat: false,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
@@ -1224,6 +1227,16 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
|
||||
// Absent or malformed → the per-protocol defaults.
|
||||
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
|
||||
@@ -1273,9 +1286,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -1311,7 +1324,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { explorer: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -1319,7 +1332,7 @@ describe('side card preferences', () => {
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
|
||||
Reference in New Issue
Block a user