Skip to content

Commit 0cf54ac

Browse files
authored
Merge branch 'omdsh-dev:main' into feat/tab-rename
2 parents e3fee83 + bcd6023 commit 0cf54ac

5 files changed

Lines changed: 276 additions & 5 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,15 @@
4444

4545
### v0.13.1
4646

47+
**✨ 新功能**
48+
49+
- 📊 **Markdown 预览安全渲染 Mermaid 图表**[#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
50+
- 🖥️ **终端 shell 与 shellArgs 可配置**[#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml``better-sidebar.config` 可指定 `shell` / `shellArgs``shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
51+
4752
**🐛 修复**
4853

49-
- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**[#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错)
54+
- 🔗 **聚合双挂载自动退让**[#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
55+
- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**[#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错)
5056

5157
### v0.13.0
5258

README_EN.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,15 @@
4343

4444
### v0.13.1
4545

46+
**✨ New features**
47+
48+
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
49+
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
50+
4651
**🐛 Fixes**
4752

48-
- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message
53+
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
54+
- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message
4955

5056
### v0.13.0
5157

src/client/Sidebar.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
544544
pendingDrag.current = null
545545
}
546546

547+
/** Set once a drag's pointerup handler commits — premature capture loss
548+
* (pointercancel / lostpointercapture without pointerup) must then be told
549+
* apart from a normal release. */
550+
const dragCommitted = useRef(false)
551+
/** Abort a drag whose pointer stream was interrupted (pointercancel, or
552+
* capture lost before pointerup): no pointerup will arrive, so without
553+
* this the dragging state would stick true and center-column measurement
554+
* would stay paused forever — the bottom panel freezes at stale edges and
555+
* stops tracking sidebar/app-rail layout changes. Reverts the DOM and the
556+
* layout variables to the store's committed sizes. */
557+
const abortDrag = (reset: () => void): void => {
558+
if (dragCommitted.current) return
559+
stopDragScheduling()
560+
applyDrag(
561+
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
562+
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
563+
)
564+
reset()
565+
}
566+
547567
// Layout push: the app shell gives up the panel's width/height while the
548568
// panels are open (0 while collapsed), so the conversation and input bar
549569
// are squeezed instead of covered. The margins are capped at the viewport
@@ -800,6 +820,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
800820
onPointerDown={(event) => {
801821
event.preventDefault()
802822
event.currentTarget.setPointerCapture(event.pointerId)
823+
dragCommitted.current = false
803824
widthDrag.current = { startX: event.clientX, startWidth: state.width }
804825
setDraggingWidth(true)
805826
}}
@@ -812,12 +833,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
812833
}}
813834
onPointerUp={(event) => {
814835
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
836+
dragCommitted.current = true
815837
event.currentTarget.releasePointerCapture(event.pointerId)
816838
const { startX, startWidth } = widthDrag.current
817839
stopDragScheduling()
818840
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
819841
setDraggingWidth(false)
820842
}}
843+
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
844+
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
821845
/>
822846
)}
823847
<div className={css.panelBody}>
@@ -850,6 +874,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
850874
onPointerDown={(event) => {
851875
event.preventDefault()
852876
event.currentTarget.setPointerCapture(event.pointerId)
877+
dragCommitted.current = false
853878
cornerDrag.current = {
854879
startX: event.clientX,
855880
startY: event.clientY,
@@ -867,12 +892,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
867892
}}
868893
onPointerUp={(event) => {
869894
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
895+
dragCommitted.current = true
870896
event.currentTarget.releasePointerCapture(event.pointerId)
871897
const { startX, startY, startWidth, startHeight } = cornerDrag.current
872898
stopDragScheduling()
873899
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
874900
setDraggingCorner(false)
875901
}}
902+
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
903+
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
876904
/>
877905
)}
878906
</div>
@@ -912,6 +940,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
912940
onPointerDown={(event) => {
913941
event.preventDefault()
914942
event.currentTarget.setPointerCapture(event.pointerId)
943+
dragCommitted.current = false
915944
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
916945
setDraggingBottom(true)
917946
}}
@@ -923,12 +952,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
923952
}}
924953
onPointerUp={(event) => {
925954
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
955+
dragCommitted.current = true
926956
event.currentTarget.releasePointerCapture(event.pointerId)
927957
const { startY, startHeight } = bottomDrag.current
928958
stopDragScheduling()
929959
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
930960
setDraggingBottom(false)
931961
}}
962+
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
963+
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
932964
/>
933965
{/*
934966
The bottom panel's own close control at its tab strip's right end

src/client/TabBar.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,38 @@ export function TabBar(props: {
117117
onRename?.(tab.id, next)
118118
}
119119

120+
// Middle-click close: the press target is recorded on middle mousedown
121+
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
122+
// indicator is inert here because the strip hides its scrollbar and only
123+
// the wheel handler scrolls) and the close settles on the first middle
124+
// mouseup OVER that same tab. Release-position semantics match VS Code
125+
// (microsoft/vscode#101028) and what users expect from Chrome tabs
126+
// (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
127+
// close. The browser dispatches auxclick to the nearest common ancestor of
128+
// the press/release targets when they differ, so any drift, autoscroll
129+
// scroll, or tab-list reflow between press and release would otherwise
130+
// swallow the close; settling on the recorded press target at mouseup
131+
// keeps release semantics without depending on auxclick delivery.
132+
const onCloseRef = useRef(onClose)
133+
const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
134+
useEffect(() => {
135+
onCloseRef.current = onClose
136+
})
137+
useEffect(() => {
138+
const onMouseUp = (event: MouseEvent): void => {
139+
if (event.button !== 1) return
140+
const pressed = middlePressed.current
141+
middlePressed.current = null
142+
// Close only when the release lands on the pressed tab; a drag-away
143+
// release cancels the press (one-shot per press).
144+
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
145+
onCloseRef.current(pressed.id)
146+
}
147+
}
148+
window.addEventListener('mouseup', onMouseUp)
149+
return () => { window.removeEventListener('mouseup', onMouseUp) }
150+
}, [])
151+
120152
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
121153
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
122154
// native NON-passive listener: React registers onWheel passively at the
@@ -194,11 +226,15 @@ export function TabBar(props: {
194226
if (payload !== null) onDropTab(payload, tab.id)
195227
}}
196228
onClick={() => { onActivate(tab.id) }}
197-
onAuxClick={(event) => {
198-
// Middle-click closes the tab (and suppresses autoscroll).
229+
onMouseDown={(event) => {
230+
// Middle-click close: record the press target and disarm
231+
// Chrome's middle-click autoscroll (its indicator is inert
232+
// here — the strip scrolls via the wheel handler only). The
233+
// close itself settles on the first middle mouseup over this
234+
// same tab (window-level), keeping release semantics.
199235
if (event.button === 1) {
200236
event.preventDefault()
201-
onClose(tab.id)
237+
middlePressed.current = { id: tab.id, node: event.currentTarget }
202238
}
203239
}}
204240
>
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* Tab-strip middle-click close tests. The press target is recorded on middle
3+
* mousedown (preventDefaulted to disarm Chrome's middle-click autoscroll)
4+
* and the close settles on the first middle mouseup OVER that same tab —
5+
* release-position semantics matching VS Code (microsoft/vscode#101028) and
6+
* user expectations for Chrome tabs (crbug/40679924): pressing on a tab and
7+
* releasing elsewhere cancels. The browser dispatches auxclick to the common
8+
* ancestor of the press/release targets when they differ (or suppresses it
9+
* entirely), so settling on the recorded press target keeps release
10+
* semantics without depending on auxclick delivery. Left-button paths
11+
* (activate / drag) and wheel scrolling are untouched.
12+
*/
13+
// @vitest-environment jsdom
14+
import { afterEach, describe, expect, it, vi } from 'vitest'
15+
import { createElement } from 'react'
16+
import { createRoot, type Root } from 'react-dom/client'
17+
import { act } from 'react-dom/test-utils'
18+
19+
// The act() environment flag (React 18.2 reads it before flushing effects).
20+
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
21+
22+
import { TabBar } from '../src/client/TabBar.tsx'
23+
import type { SidebarTab } from '../src/client/state.ts'
24+
25+
function mountBar(): {
26+
tab0: HTMLElement
27+
tab1: HTMLElement
28+
onClose: ReturnType<typeof vi.fn>
29+
onActivate: ReturnType<typeof vi.fn>
30+
unmount: () => void
31+
} {
32+
const container = document.createElement('div')
33+
document.body.append(container)
34+
const onClose = vi.fn()
35+
const onActivate = vi.fn()
36+
const tabs: SidebarTab[] = [
37+
{ id: 't1', type: 'explorer', title: 'Explorer' },
38+
{ id: 't2', type: 'git', title: 'Git' },
39+
]
40+
const root: Root = createRoot(container)
41+
act(() => {
42+
root.render(createElement(TabBar, {
43+
paneId: 'pane:1',
44+
tabs,
45+
active: 't1',
46+
onActivate,
47+
onClose,
48+
onNewTab: () => {},
49+
newTabOptions: [],
50+
onDropTab: () => {},
51+
}))
52+
})
53+
const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[]
54+
expect(tabEls.length).toBe(2)
55+
// noUncheckedIndexedAccess: the length assertion above is the guard, but
56+
// the compiler still sees `HTMLElement | undefined` through indexing.
57+
const tab0 = tabEls[0]!
58+
const tab1 = tabEls[1]!
59+
return {
60+
tab0,
61+
tab1,
62+
onClose,
63+
onActivate,
64+
unmount: () => {
65+
act(() => { root.unmount() })
66+
container.remove()
67+
},
68+
}
69+
}
70+
71+
/** Dispatch a native mouse event; returns the event (to read defaultPrevented). */
72+
function mouse(target: EventTarget, type: string, button: number): MouseEvent {
73+
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button })
74+
target.dispatchEvent(event)
75+
return event
76+
}
77+
78+
afterEach(() => {
79+
document.body.innerHTML = ''
80+
vi.restoreAllMocks()
81+
})
82+
83+
describe('TabBar middle-click close', () => {
84+
it('closes when the middle mouseup lands on the same tab', () => {
85+
const { tab0, onClose, unmount } = mountBar()
86+
try {
87+
mouse(tab0, 'mousedown', 1)
88+
mouse(tab0, 'mouseup', 1)
89+
expect(onClose).toHaveBeenCalledWith('t1')
90+
} finally {
91+
unmount()
92+
}
93+
})
94+
95+
it('preventDefaults the middle mousedown (disarms browser autoscroll)', () => {
96+
const { tab0, unmount } = mountBar()
97+
try {
98+
const event = mouse(tab0, 'mousedown', 1)
99+
expect(event.defaultPrevented).toBe(true)
100+
} finally {
101+
unmount()
102+
}
103+
})
104+
105+
it('a release elsewhere (drag-away) cancels the close — one-shot', () => {
106+
const { tab0, onClose, unmount } = mountBar()
107+
try {
108+
// Press on tab 0, release on unrelated element: no close.
109+
mouse(tab0, 'mousedown', 1)
110+
mouse(document.body, 'mouseup', 1)
111+
expect(onClose).not.toHaveBeenCalled()
112+
// A later middle mouseup over the tab (no new press) must NOT close.
113+
mouse(tab0, 'mouseup', 1)
114+
expect(onClose).not.toHaveBeenCalled()
115+
} finally {
116+
unmount()
117+
}
118+
})
119+
120+
it('a release over a DIFFERENT tab does not close', () => {
121+
const { tab0, tab1, onClose, unmount } = mountBar()
122+
try {
123+
mouse(tab0, 'mousedown', 1)
124+
mouse(tab1, 'mouseup', 1)
125+
expect(onClose).not.toHaveBeenCalled()
126+
} finally {
127+
unmount()
128+
}
129+
})
130+
131+
it('middle mousedown on the close button closes (release over the tab)', () => {
132+
const { tab0, onClose, unmount } = mountBar()
133+
try {
134+
const button = tab0.querySelector('button') as HTMLButtonElement
135+
expect(button).not.toBeNull()
136+
mouse(button, 'mousedown', 1)
137+
mouse(button, 'mouseup', 1)
138+
expect(onClose).toHaveBeenCalledWith('t1')
139+
} finally {
140+
unmount()
141+
}
142+
})
143+
144+
it('left mousedown does not close; left click activates', () => {
145+
const { tab0, onClose, onActivate, unmount } = mountBar()
146+
try {
147+
mouse(tab0, 'mousedown', 0)
148+
mouse(tab0, 'mouseup', 0)
149+
expect(onClose).not.toHaveBeenCalled()
150+
mouse(tab0, 'click', 0)
151+
expect(onActivate).toHaveBeenCalledWith('t1')
152+
} finally {
153+
unmount()
154+
}
155+
})
156+
157+
it('a middle auxclick alone (no mousedown) does not close', () => {
158+
// Pins the contract: the close settles on the mouseup of the recorded
159+
// press, not on auxclick delivery — the browser redirects auxclick to
160+
// the common ancestor (or suppresses it) when press/release targets
161+
// differ, which was the failure mode.
162+
const { tab0, onClose, unmount } = mountBar()
163+
try {
164+
mouse(tab0, 'auxclick', 1)
165+
expect(onClose).not.toHaveBeenCalled()
166+
} finally {
167+
unmount()
168+
}
169+
})
170+
171+
it('a left mouseup after a middle press does not consume the press', () => {
172+
const { tab0, onClose, unmount } = mountBar()
173+
try {
174+
mouse(tab0, 'mousedown', 1)
175+
mouse(window, 'mouseup', 0)
176+
expect(onClose).not.toHaveBeenCalled()
177+
mouse(tab0, 'mouseup', 1)
178+
expect(onClose).toHaveBeenCalledWith('t1')
179+
} finally {
180+
unmount()
181+
}
182+
})
183+
184+
it('stops closing after unmount', () => {
185+
const { tab0, onClose, unmount } = mountBar()
186+
mouse(tab0, 'mousedown', 1)
187+
unmount()
188+
mouse(tab0, 'mouseup', 1)
189+
expect(onClose).not.toHaveBeenCalled()
190+
})
191+
})

0 commit comments

Comments
 (0)