From 1e7c72ccbe891e28f5f15b4acdce438a7dba9bd4 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 15 Aug 2026 15:24:46 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BD=8D=E7=BD=AE=E5=85=BC=E5=AE=B9=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E4=B8=BA=20Windows=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F=E9=A2=84=E7=95=99=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E7=A9=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster 下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口 右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关, zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema 默认值、设置页渲染与开关状态。 --- src/client/SideCardSection.tsx | 11 +++++++++++ src/client/Sidebar.tsx | 15 +++++++++++++++ src/client/locales.ts | 4 ++++ src/client/prefs.ts | 3 +++ src/client/sidebar.module.css | 22 ++++++++++++++++++++++ src/config.ts | 1 + src/prefs-shared.ts | 9 +++++++++ tests/plugin-shape.spec.ts | 4 +++- tests/side-card-section.spec.tsx | 18 ++++++++++++++++++ tests/smoke.spec.ts | 1 + tests/unit.spec.ts | 21 +++++++++++++++++---- 11 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index c0e4950..410cd1a 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -660,6 +660,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { onChange={(next) => { applyPref({ interceptOpenPath: next }) }} /> +
+ + {t('settingsTitleBarTitle')} + {t('settingsTitleBarDesc')} + + { applyPref({ titleBarCompat: next }) }} + /> +
{/* 侧边栏内容: one small card per registered tab type in a responsive diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8d4cafc..d4537e0 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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), diff --git a/src/client/locales.ts b/src/client/locales.ts index fe66593..5dc6c07 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -116,6 +116,8 @@ export const zh = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天区文件在侧边栏打开', settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', + settingsTitleBarTitle: '位置兼容模式', + settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡', settingsSaveFailed: '保存失败', settingsConflict: '设置已被其他窗口修改,请重试', binaryNoPreview: '此文件类型不支持预览', @@ -333,6 +335,8 @@ export const en: Record = { 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', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 2d0895a..ec92080 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -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, diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 68f709d..0d765b9 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 diff --git a/src/config.ts b/src/config.ts index 5f0d4d3..18582fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -91,6 +91,7 @@ export const PrefsSchema: z = 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), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 0cd576d..c0f04ed 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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, diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index c549ae1..8d0a0a7 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record })({ 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: {} }) }) }) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 7246561..c7f860c 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -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)', () => { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 5529fbf..bacd3a2 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -520,6 +520,7 @@ describe('side card settings routes', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + titleBarCompat: false, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index c3a965e..4229979 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -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']) From 648ff27b5fc8dedb2a1622a9e12e41506fb0dec8 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 15 Aug 2026 15:35:09 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=8D=87=E8=87=B3=20v0.12.2=EF=BC=88=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=BC=8F=E9=9A=8F=E6=9C=AC=E6=AC=A1?= =?UTF-8?q?=E5=8F=91=E5=B8=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++++------ README_EN.md | 15 ++++++++------- dsh.plugin.json | 2 +- package.json | 2 +- src/client/service.ts | 2 +- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ee06284..dbe2ca1 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,11 @@ ## 🆕 最近更新 -v0.12.1 +v0.12.2 | 功能 | 说明 | 截图 | |---|---|---| +| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭) | | | 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`) | 服务化基座截图 | | ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | 添加插件截图 | | 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | | @@ -66,10 +67,10 @@ irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/ ```sh # macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart +curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart # Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart ``` 不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。 @@ -95,7 +96,7 @@ minimumReleaseAgeExclude: - dsh-better-sidebar EOF -# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.1) +# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.2) npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar ``` @@ -126,7 +127,7 @@ npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sideba 3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载; 4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。 -`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。 +`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.2` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。 @@ -172,7 +173,7 @@ dsh plugin --profile web add dsh-better-sidebar 5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) ``` -更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。 +更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.2"` 再 `pnpm install`。 diff --git a/README_EN.md b/README_EN.md index 749c00a..d6817f5 100644 --- a/README_EN.md +++ b/README_EN.md @@ -34,10 +34,11 @@ ## 🆕 Recent Updates -v0.12.1 +v0.12.2 | Feature | Description | Screenshot | |---|---|---| +| 📐 Position compat mode | New "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default) | | | 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) | Service API base screenshot | | ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | Add Plugins screenshot | | 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | | @@ -66,10 +67,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot ```sh # macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.1 --restart +curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart # Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -Restart +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart ``` Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running. @@ -95,7 +96,7 @@ minimumReleaseAgeExclude: - dsh-better-sidebar EOF -# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.1) +# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.2) npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar ``` @@ -126,7 +127,7 @@ The one-click script does four things, all idempotent (safe to re-run): 3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`; 4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page). -`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.1` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified. +`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.2` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified. @@ -137,7 +138,7 @@ The one-click script does four things, all idempotent (safe to re-run): dsh plugin --profile web add dsh-better-sidebar ``` -or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. +or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.2"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. @@ -172,7 +173,7 @@ To debug local changes or track the dev branch, point the dependency at a local 5. Restart DSH and hard-refresh ``` -Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.1"` and re-run `pnpm install`. +Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.2"` and re-run `pnpm install`. diff --git a/dsh.plugin.json b/dsh.plugin.json index 8380720..254357f 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.12.1", + "version": "0.12.2", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index a8c8b31..83102ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.12.1", + "version": "0.12.2", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/src/client/service.ts b/src/client/service.ts index 2476dca..ee333bd 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -420,7 +420,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.12.1' +export const SIDEBAR_SERVICE_VERSION = '0.12.2' /** * Monotonic capability list consumers use to gate new API usage (features From 8ad1766390e4df8e351223f0d14d15a7db7e37a2 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 15 Aug 2026 15:46:56 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E4=BD=8D=E7=BD=AE=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E4=B8=8B=E7=A7=BB=E8=B7=9D=E7=A6=BB=EF=BC=88=E9=BD=BF?= =?UTF-8?q?=E8=BD=AE=E5=BC=B9=E7=AA=97=E6=95=B0=E5=AD=97=E8=BE=93=E5=85=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit titleBarStripPx 新偏好(0–120px,默认 40):侧边栏按钮与内容的下移量改为 CSS 变量 --dsh-title-bar-strip 驱动,Sidebar shell 随 prefs 写入。设置页 「常规」的位置兼容模式行在开启时显示齿轮小图标,弹窗内用数字行设置下移 距离(复用 FeatureSettingsRows 行控件与提交钳制)。 --- README.md | 2 +- README_EN.md | 2 +- src/client/SideCardSection.module.css | 26 +++++++++- src/client/SideCardSection.tsx | 68 +++++++++++++++++++++++++-- src/client/Sidebar.tsx | 26 +++++++--- src/client/locales.ts | 4 ++ src/client/prefs.ts | 6 ++- src/client/sidebar.module.css | 11 +++-- src/config.ts | 7 +++ src/prefs-shared.ts | 18 +++++++ tests/plugin-shape.spec.ts | 6 ++- tests/side-card-section.spec.tsx | 31 +++++++++++- tests/smoke.spec.ts | 1 + tests/unit.spec.ts | 24 ++++++++-- 14 files changed, 204 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dbe2ca1..2f6d76f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ | 功能 | 说明 | 截图 | |---|---|---| -| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭) | | +| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px) | | | 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`) | 服务化基座截图 | | ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | 添加插件截图 | | 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | | diff --git a/README_EN.md b/README_EN.md index d6817f5..38e258d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -38,7 +38,7 @@ | Feature | Description | Screenshot | |---|---|---| -| 📐 Position compat mode | New "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default) | | +| 📐 Position compat mode | New "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px) | | | 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) | Service API base screenshot | | ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | Add Plugins screenshot | | 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | | diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 18f9823..54aba59 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -126,7 +126,8 @@ } .cardMain:focus-visible, -.cardGear:focus-visible { +.cardGear:focus-visible, +.rowGear:focus-visible { outline: 2px solid var(--dsw-alias-border-l4); outline-offset: 2px; } @@ -266,6 +267,29 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* The gear icon button on a general settings ROW (the position-compat + row's "customize the strip height" entry): the same 16px icon-button + recipe as the card gear, sitting in the row's control area. */ +.rowGear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + flex: none; +} + +.rowGear:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + /* Settings-row recipe (the DSH General section rows): title/desc left, control right, hairline separators (stripped on the last row). */ .row { diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 410cd1a..72df028 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -52,6 +52,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { clampWidthPercent, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, type SidebarPrefs, @@ -380,6 +382,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { const [error, setError] = useState(null) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(null) + // Whether the position-compat strip popup (the gear on the 常规 row) is open. + const [stripSettingsOpen, setStripSettingsOpen] = useState(false) // Whether the "add plugin" modal (a dashed card at the end of the // 侧边栏内容 / 文件预览 grids) is open, and for which extension point // (null = closed). @@ -665,11 +669,30 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { {t('settingsTitleBarTitle')} {t('settingsTitleBarDesc')} - { applyPref({ titleBarCompat: next }) }} - /> + + {/* + 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). + */} + {prefs.titleBarCompat && ( + + )} + { applyPref({ titleBarCompat: next }) }} + /> + @@ -794,6 +817,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { )} + {/* 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). */} + {stripSettingsOpen && ( + { setStripSettingsOpen(false) }} + title={t('settingsTitleBarTitle')} + description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })} + closeLabel={t('close')} + className={css.popupDialog} + footer={( + + )} + > + t('settingsTitleBarStripTitle'), + desc: () => t('settingsTitleBarStripDesc'), + min: TITLE_BAR_STRIP_MIN, + max: TITLE_BAR_STRIP_MAX, + unit: 'px', + }]} + prefs={prefs} + onToggle={onToggleSetting} + onCommit={onCommitSetting} + /> + + )} + {/* The "add plugin" modal (opened by the dashed cards above): declares the extension point of the clicked kind, opens the GitHub topic, and lists the matching recommended plugin catalog with per-entry diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d4537e0..c450a36 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -172,15 +172,27 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // 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. + // The strip height is user-tunable (titleBarStripPx) and 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. const titleBarCompat = snapshot.prefs.titleBarCompat + const titleBarStrip = snapshot.prefs.titleBarStripPx 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]) + const root = document.documentElement + if (titleBarCompat) { + document.body.setAttribute('data-dsh-title-bar-compat', '') + root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`) + } else { + document.body.removeAttribute('data-dsh-title-bar-compat') + root.style.removeProperty('--dsh-title-bar-strip') + } + return () => { + document.body.removeAttribute('data-dsh-title-bar-compat') + root.style.removeProperty('--dsh-title-bar-strip') + } + }, [titleBarCompat, titleBarStrip]) /** * Bottom-panel merge on narrow viewports: whenever a session is current diff --git a/src/client/locales.ts b/src/client/locales.ts index 5dc6c07..1ec4604 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -118,6 +118,8 @@ export const zh = { settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', settingsTitleBarTitle: '位置兼容模式', settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡', + settingsTitleBarStripTitle: '下移距离', + settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)', settingsSaveFailed: '保存失败', settingsConflict: '设置已被其他窗口修改,请重试', binaryNoPreview: '此文件类型不支持预览', @@ -337,6 +339,8 @@ export const en: Record = { 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', + settingsTitleBarStripTitle: 'Shift distance', + settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)', settingsSaveFailed: 'Failed to save', settingsConflict: 'The setting changed in another window — please retry', binaryNoPreview: 'This file type cannot be previewed', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index ec92080..3bae810 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -11,12 +11,13 @@ import type { api } from './api.ts' import { clampTerminalFontSize, + clampTitleBarStrip, clampWidthPercent, SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs, } from '../prefs-shared.ts' -export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent } +export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent } export type { SidebarPrefs } /** The settings wire face the preferences need (a subset of the plugin api). */ @@ -62,6 +63,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { titleBarCompat: typeof record.titleBarCompat === 'boolean' ? record.titleBarCompat : SIDEBAR_PREFS_DEFAULTS.titleBarCompat, + titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx) + ? clampTitleBarStrip(record.titleBarStripPx) + : SIDEBAR_PREFS_DEFAULTS.titleBarStripPx, htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean' ? record.htmlViewerNoSandbox : SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox, diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 0d765b9..5560abd 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -217,18 +217,19 @@ 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. */ + fill (the VSCode hidden-title-bar look). The strip height is user-tunable + (`--dsh-title-bar-strip`, written by the Sidebar shell from + `titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it + sits at the window's bottom, never under the top strip. */ :global(body[data-dsh-title-bar-compat]) .toggleCluster { - top: 43px; /* 40px caption strip + the original 3px inset */ + top: calc(var(--dsh-title-bar-strip, 40px) + 3px); } :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; + padding-top: var(--dsh-title-bar-strip, 40px); } /* The shared corner (both panels open): the intersection of the right diff --git a/src/config.ts b/src/config.ts index 18582fb..a5bc8c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,9 @@ import { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, + TITLE_BAR_STRIP_DEFAULT, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -24,6 +27,9 @@ export { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, + TITLE_BAR_STRIP_DEFAULT, + TITLE_BAR_STRIP_MAX, + TITLE_BAR_STRIP_MIN, WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, @@ -92,6 +98,7 @@ export const PrefsSchema: z = z.object({ 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), + 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), htmlViewerDefaultUnsafe: z.boolean().default(false), browserNoSandbox: z.boolean().default(false), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index c0f04ed..42986a4 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -68,6 +68,13 @@ export interface SidebarPrefs { * below it. Off by default — the sidebar layout is untouched. */ 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 + * starts `strip` px below its top edge. + */ + titleBarStripPx: number /** * Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the * default) renders previewed HTML in an opaque-origin iframe that cannot @@ -156,6 +163,11 @@ export const TERMINAL_FONT_SIZE_MIN = 9 export const TERMINAL_FONT_SIZE_MAX = 32 export const TERMINAL_FONT_SIZE_DEFAULT = 13 +/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */ +export const TITLE_BAR_STRIP_MIN = 0 +export const TITLE_BAR_STRIP_MAX = 120 +export const TITLE_BAR_STRIP_DEFAULT = 40 + /** Fallback prefs used whenever the settings document is unreachable or malformed. */ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { openByDefault: true, @@ -168,6 +180,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, titleBarCompat: false, + titleBarStripPx: TITLE_BAR_STRIP_DEFAULT, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -188,3 +201,8 @@ export function clampWidthPercent(value: number): number { export function clampTerminalFontSize(value: number): number { return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value))) } + +/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */ +export function clampTitleBarStrip(value: number): number { + return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value))) +} diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 8d0a0a7..2110edb 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -54,8 +54,10 @@ 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). + // The position-compat mode defaults OFF (the normal layout is default), + // with the strip defaulting to 40px. expect(resolved.titleBarCompat).toBe(false) + expect(resolved.titleBarStripPx).toBe(40) // The enable-switch maps resolve to {} (everything on) for old documents. expect(resolved.tabsEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({}) @@ -65,6 +67,6 @@ describe('dsh-better-sidebar plugin export shape', () => { const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - 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: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index c7f860c..78391ea 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -163,11 +163,15 @@ describe('SideCardSection declarative inventory', () => { // 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) + // The row's gear (customize the shift distance) is dormant while the + // mode is off — the feature-card convention. + expect(html).not.toContain('Position compatibility mode Feature settings') - // When the pref is on, the new switch is checked too. + // When the pref is on, the new switch is checked and the gear appears. store.setPrefs({ ...store.getPrefs(), titleBarCompat: true }) html = renderSection(store, service) expect(html.match(/checked=""/g)?.length).toBe(3) + expect(html).toContain('aria-label="Position compatibility mode Feature settings"') }) }) @@ -247,6 +251,31 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => { expect(html).toContain('px') expect(html).not.toContain('type="checkbox"') }) + + it('renders the title-bar strip row: the pref value, the 0–120 bounds and the px suffix', () => { + const html = renderToString(createElement(FeatureSettingsRows, { + toggles: [{ + key: 'titleBarStripPx', + type: 'number', + title: () => 'Shift distance', + desc: () => 'Title-bar strip height in px', + min: 0, + max: 120, + unit: 'px', + }], + prefs: { ...prefs, titleBarStripPx: 64 }, + onToggle: () => {}, + onCommit: () => '64', + })) + expect(html).toContain('Shift distance') + expect(html).toContain('Title-bar strip height in px') + expect(html).toContain('type="number"') + expect(html).toContain('value="64"') + expect(html).toContain('min="0"') + expect(html).toContain('max="120"') + expect(html).toContain('px') + expect(html).not.toContain('type="checkbox"') + }) }) describe('mergePluginSetting (v0.12.0, codex review fix)', () => { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index bacd3a2..6722662 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -521,6 +521,7 @@ describe('side card settings routes', () => { terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index 4229979..d49c2f7 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1141,6 +1141,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1166,6 +1167,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1191,6 +1193,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1237,6 +1240,19 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true) }) + it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => { + // Absent or malformed → 40 (the strip default). + expect((await loadPrefs(wire({}))).titleBarStripPx).toBe(40) + expect((await loadPrefs(wire({ titleBarStripPx: 'yes' }))).titleBarStripPx).toBe(40) + // Out-of-range numbers clamp into 0–120. + expect((await loadPrefs(wire({ titleBarStripPx: -5 }))).titleBarStripPx).toBe(0) + expect((await loadPrefs(wire({ titleBarStripPx: 200 }))).titleBarStripPx).toBe(120) + expect((await loadPrefs(wire({ titleBarStripPx: 47.6 }))).titleBarStripPx).toBe(48) + // In-range values survive verbatim. + expect((await loadPrefs(wire({ titleBarStripPx: 0 }))).titleBarStripPx).toBe(0) + expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64) + }) + 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) @@ -1286,9 +1302,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, titleBarCompat: false, 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, titleBarStripPx: 40, 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, titleBarCompat: false, 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, titleBarStripPx: 40, 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) @@ -1324,7 +1340,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, titleBarCompat: false, 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, titleBarStripPx: 40, 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) @@ -1332,7 +1348,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, titleBarCompat: false, 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, titleBarStripPx: 40, 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'])