diff --git a/README.md b/README.md index ee06284..2f6d76f 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,11 @@ ## 🆕 最近更新 -v0.12.1 +v0.12.2 | 功能 | 说明 | 截图 | |---|---|---| +| 📐 位置兼容模式 | 设置页新增「位置兼容模式」开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px) | | | 🔌 服务化基座 | 完整类型导出 + `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..38e258d 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); 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 | | @@ -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/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 c0e4950..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). @@ -660,6 +664,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { onChange={(next) => { applyPref({ interceptOpenPath: next }) }} /> +
+ + {t('settingsTitleBarTitle')} + {t('settingsTitleBarDesc')} + + + {/* + 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 }) }} + /> + +
{/* 侧边栏内容: one small card per registered tab type in a responsive @@ -783,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 8d4cafc..c450a36 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -167,6 +167,33 @@ 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 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(() => { + 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 * while narrow (mount, session switch, or a desktop→narrow transition), diff --git a/src/client/locales.ts b/src/client/locales.ts index fe66593..1ec4604 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -116,6 +116,10 @@ export const zh = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天区文件在侧边栏打开', settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用', + settingsTitleBarTitle: '位置兼容模式', + settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡', + settingsTitleBarStripTitle: '下移距离', + settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)', settingsSaveFailed: '保存失败', settingsConflict: '设置已被其他窗口修改,请重试', binaryNoPreview: '此文件类型不支持预览', @@ -333,6 +337,10 @@ 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', + 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 2d0895a..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). */ @@ -59,6 +60,12 @@ 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, + 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/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 diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 68f709d..5560abd 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -209,6 +209,29 @@ 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 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: 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: var(--dsh-title-bar-strip, 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..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, @@ -91,6 +97,8 @@ 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), + 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 0cd576d..42986a4 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -60,6 +60,21 @@ 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 + /** + * 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 @@ -148,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, @@ -159,6 +179,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontFamily: '', terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, + titleBarCompat: false, + titleBarStripPx: TITLE_BAR_STRIP_DEFAULT, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -179,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 c549ae1..2110edb 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -54,6 +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), + // 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({}) @@ -63,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, 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 7246561..78391ea 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -151,6 +151,28 @@ 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) + // 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 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"') + }) }) describe('FeatureSettingsRows (the secondary settings popup body)', () => { @@ -229,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 712a41f..d92cb77 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -548,6 +548,8 @@ describe('side card settings routes', () => { terminalFontFamily: '', 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 013bee1..1cccf71 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1161,6 +1161,8 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1185,6 +1187,8 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1209,6 +1213,8 @@ describe('side card preferences', () => { terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, + titleBarCompat: false, + titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, @@ -1245,6 +1251,29 @@ 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 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) @@ -1294,9 +1323,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, 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, 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) @@ -1332,7 +1361,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, 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) @@ -1340,7 +1369,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, 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'])