integrate train/misc: mermaid + layout-push + fs-search + markdown-images
This commit is contained in:
@@ -714,7 +714,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -364,6 +364,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
|
||||
|
||||
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
|
||||
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
|
||||
- 🖼️ **Markdown 预览支持本地/相对路径图片**:预览 `.md` 时把指向本地文件的图片目标(相对/绝对路径、引用式 `[id]: url`)重写为 `/sidebar/file` 媒体 URL 并显示(此前仅绝对 http(s) 图片能渲染,相对路径只显示 alt 文本)
|
||||
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
|
||||
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
|
||||
|
||||
|
||||
@@ -370,6 +370,7 @@ All changes since v0.14.0:
|
||||
|
||||
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
|
||||
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
|
||||
- 🖼️ **Markdown preview renders local/relative images**: image destinations that point at local files (relative/absolute paths, reference-style `[id]: url`) are rewritten to `/sidebar/file` media URLs and displayed in the preview (previously only absolute http(s) images rendered; relative paths showed just the alt text)
|
||||
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
|
||||
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
|
||||
|
||||
|
||||
+65
-26
@@ -42,7 +42,8 @@ import {
|
||||
} from './state.ts'
|
||||
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
|
||||
import { layoutPushSize } from './layout-push.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||
import { getShellPreset } from './shell-presets.ts'
|
||||
@@ -242,7 +243,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// — the merged display is the right sidebar alone, the bottom tabs thrown
|
||||
// into its strips. Widening never rewrites the migrated state: the tabs
|
||||
// keep living in the right tree.
|
||||
const narrow = useNarrowViewport()
|
||||
const viewport = useViewportSize()
|
||||
const narrow = isNarrowWidth(viewport.width)
|
||||
|
||||
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
|
||||
// when the visual viewport shrinks below the layout viewport, bottom-
|
||||
@@ -255,6 +257,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
|
||||
// at 0. rAF-throttled, same pattern as useNarrowViewport.
|
||||
const [keyboardInset, setKeyboardInset] = useState(0)
|
||||
const [visualViewportHeight, setVisualViewportHeight] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport
|
||||
if (vv === null || vv === undefined) return
|
||||
@@ -263,6 +266,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
frame = null
|
||||
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
|
||||
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
|
||||
setVisualViewportHeight(Math.max(0, Math.round(vv.height)))
|
||||
}
|
||||
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
|
||||
vv.addEventListener('resize', onResize)
|
||||
@@ -274,6 +278,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
// The bottom panel is offset above the on-screen keyboard. Cap its height
|
||||
// against that same visible area, not the taller layout viewport, so the
|
||||
// conversation keeps PANEL_MIN even on wide touch devices.
|
||||
const layoutViewportHeight = visualViewportHeight ?? viewport.height
|
||||
|
||||
// Current conversation (the sessions list feed).
|
||||
const sessionList = useSyncExternalStore(
|
||||
@@ -292,6 +300,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const state = snapshot.state
|
||||
const sessionId = snapshot.sessionId
|
||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||
const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({
|
||||
narrow,
|
||||
panelOpen: false,
|
||||
bottomOpen,
|
||||
width: 0,
|
||||
bottomHeight,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: layoutViewportHeight,
|
||||
}).height
|
||||
|
||||
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
|
||||
// session header's right-aligned utilities (the "Session log" download
|
||||
@@ -779,7 +796,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
|
||||
writeGeometry(width, height)
|
||||
const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0
|
||||
writeGeometry(width, bottomPush)
|
||||
}
|
||||
|
||||
// Drags write at most once per frame: pointer events fire several times
|
||||
@@ -876,13 +894,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// (clamped) instead of rolling back the flick.
|
||||
if (draggingWidth) {
|
||||
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
|
||||
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0)
|
||||
} else if (draggingBottom) {
|
||||
width = Math.min(state?.width ?? 0, window.innerWidth)
|
||||
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
|
||||
height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)))
|
||||
} else if (draggingCorner) {
|
||||
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
|
||||
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
|
||||
height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)))
|
||||
}
|
||||
}
|
||||
if (width !== undefined && height !== undefined) {
|
||||
@@ -906,12 +924,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
dragCommitted.current = true
|
||||
stopDragScheduling()
|
||||
const last = lastDragSize.current
|
||||
const adoptedWidth = !narrow && state?.panelOpen === true
|
||||
? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
|
||||
: 0
|
||||
const adoptedHeight = !narrow && state?.bottomOpen === true
|
||||
? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
|
||||
: 0
|
||||
const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({
|
||||
narrow,
|
||||
panelOpen: state?.panelOpen === true,
|
||||
bottomOpen: state?.bottomOpen === true,
|
||||
width: last?.width ?? state?.width ?? 0,
|
||||
bottomHeight: last?.height ?? state?.bottomHeight ?? 0,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: layoutViewportHeight,
|
||||
})
|
||||
applyDrag(adoptedWidth, adoptedHeight)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
@@ -928,14 +949,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
|
||||
// conversation keeps the full width behind the drawer.
|
||||
useEffect(() => {
|
||||
const width = !narrow && snapshot.state?.panelOpen === true
|
||||
? Math.min(snapshot.state.width, window.innerWidth)
|
||||
const { width, height } = layoutPushSize({
|
||||
narrow,
|
||||
panelOpen: snapshot.state?.panelOpen === true,
|
||||
bottomOpen: snapshot.state?.bottomOpen === true,
|
||||
width: snapshot.state?.width ?? 0,
|
||||
bottomHeight: snapshot.state?.bottomHeight ?? 0,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: layoutViewportHeight,
|
||||
})
|
||||
const bottomPush = !narrow && snapshot.state?.bottomOpen === true
|
||||
? height + keyboardInset
|
||||
: 0
|
||||
const height = !narrow && snapshot.state?.bottomOpen === true
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
writeGeometry(width, height)
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
writeGeometry(width, bottomPush)
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset])
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
@@ -1052,6 +1079,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
}
|
||||
|
||||
const bottomPanelHeight = layoutPushSize({
|
||||
narrow,
|
||||
panelOpen: state.panelOpen,
|
||||
// Keep the hidden panel's geometry ready for its slide-in transition;
|
||||
// only the layout push itself becomes zero while it is closed.
|
||||
bottomOpen: true,
|
||||
width: state.width,
|
||||
bottomHeight: state.bottomHeight,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: layoutViewportHeight,
|
||||
}).height
|
||||
|
||||
const onNewTab = (optionId: string): void => {
|
||||
const service = ctx.betterSidebar
|
||||
const descriptor = service?.getTab(optionId)
|
||||
@@ -1197,7 +1236,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
@@ -1211,7 +1250,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// pointermove (the rAF pending value) can be stale. Commit
|
||||
// from the up position (v0.13.1 semantics; issue #247).
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight)
|
||||
commitDrag(width, height, s => setWidth(s, width))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
@@ -1261,7 +1300,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
||||
scheduleDrag(width, height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
@@ -1273,7 +1312,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// Up position wins over the rAF pending value (see the width
|
||||
// strip handler — issue #247).
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
||||
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
@@ -1305,7 +1344,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
data-dsh-panel
|
||||
data-dsh-bottom-panel
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
height: bottomPanelHeight,
|
||||
left: centerRectRef.current.left,
|
||||
// Keep the panel above the on-screen keyboard when the visual
|
||||
// viewport shrinks (see the keyboardInset effect).
|
||||
@@ -1340,7 +1379,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
||||
scheduleDrag(Math.min(state.width, window.innerWidth), height)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
@@ -1351,7 +1390,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
// Up position wins over the rAF pending value (see the width
|
||||
// strip handler — issue #247).
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY)))
|
||||
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/v
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
@@ -252,6 +253,13 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const html = viewerId === 'html'
|
||||
/** The markdown source the preview renders (draft wins over saved content). */
|
||||
const mdText = draft ?? content ?? ''
|
||||
/** The preview source: `mdText` with local image destinations rewritten to
|
||||
* absolute media URLs (see {@link rewriteLocalImageUrls}); the raw
|
||||
* `mdText` stays untouched for selection/line lookup and for mermaid-block
|
||||
* detection, which are unaffected by image syntax. */
|
||||
const previewText = markdown
|
||||
? rewriteLocalImageUrls(mdText, scope, path, window.location.origin)
|
||||
: mdText
|
||||
/** md/mermaid block split for the preview (mermaid fences lift out). Split
|
||||
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
|
||||
const mdBlocks = useMemo(
|
||||
@@ -392,8 +400,8 @@ export function TextEditor(props: FileViewerProps) {
|
||||
parse; cross-fence references/footnotes stay intact); files
|
||||
without one render exactly as before. */}
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
|
||||
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
|
||||
@@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean {
|
||||
return width < NARROW_MAX_WIDTH
|
||||
}
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
|
||||
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
|
||||
@@ -27,16 +32,17 @@ export function isNarrowWidth(width: number): boolean {
|
||||
* resize listener is equally exact for a breakpoint that never changes
|
||||
* while the page is open.
|
||||
*/
|
||||
export function useNarrowViewport(): boolean {
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
|
||||
)
|
||||
export function useViewportSize(): ViewportSize {
|
||||
const [size, setSize] = useState<ViewportSize>(() => ({
|
||||
width: typeof window === 'undefined' ? 0 : window.innerWidth,
|
||||
height: typeof window === 'undefined' ? 0 : window.innerHeight,
|
||||
}))
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
let frame: number | null = null
|
||||
const measure = (): void => {
|
||||
frame = null
|
||||
setNarrow(isNarrowWidth(window.innerWidth))
|
||||
setSize({ width: window.innerWidth, height: window.innerHeight })
|
||||
}
|
||||
const onResize = (): void => {
|
||||
if (frame === null) frame = requestAnimationFrame(measure)
|
||||
@@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean {
|
||||
if (frame !== null) cancelAnimationFrame(frame)
|
||||
}
|
||||
}, [])
|
||||
return narrow
|
||||
return size
|
||||
}
|
||||
|
||||
export function useNarrowViewport(): boolean {
|
||||
return isNarrowWidth(useViewportSize().width)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`.
|
||||
* The conversation column (output + composer) must keep at least
|
||||
* {@link PANEL_MIN} of the viewport after the bottom panel claims height.
|
||||
*/
|
||||
import { PANEL_MIN } from './state.ts'
|
||||
|
||||
export interface LayoutPushInput {
|
||||
narrow: boolean
|
||||
panelOpen: boolean
|
||||
bottomOpen: boolean
|
||||
width: number
|
||||
bottomHeight: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
export interface LayoutPushSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function finiteNonNegative(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
/** Compute the live layout-push size. Narrow drawers float and push 0. */
|
||||
export function layoutPushSize(input: LayoutPushInput): LayoutPushSize {
|
||||
if (input.narrow) return { width: 0, height: 0 }
|
||||
const viewportWidth = finiteNonNegative(input.viewportWidth)
|
||||
const viewportHeight = finiteNonNegative(input.viewportHeight)
|
||||
const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight))
|
||||
return {
|
||||
width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0,
|
||||
height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0,
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
/* Grid/flex items default to min-height:auto. A long unbreakable token
|
||||
(OAuth URL) then grows this column past the viewport and clips the
|
||||
composer + left-rail Settings row. min-height:0 lets the cell shrink;
|
||||
overflow-wrap lets the token wrap instead of forcing its intrinsic
|
||||
size. The host's own descendants remain responsible for scrolling. */
|
||||
min-height: 0;
|
||||
overflow-wrap: anywhere;
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Markdown-preview local-image resolution. The shared `MarkdownText` (from
|
||||
* @deepseek-ai/dsh-client-ui-primitives) only renders absolute http(s) image
|
||||
* URLs — relative links are disabled for chat security — so a local image in
|
||||
* a previewed `.md` (``, an absolute `/cwd/img.png`, or a
|
||||
* reference definition) would otherwise fall back to its alt text. This
|
||||
* dependency-free helper rewrites those destinations into absolute
|
||||
* `/sidebar/file` media URLs (prefixed with the GUI's own origin) so
|
||||
* `MarkdownText` accepts them; the host media route then serves the bytes,
|
||||
* still restricted to files under the session cwd.
|
||||
*/
|
||||
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { isAbsolutePath } from './paths.ts'
|
||||
|
||||
/**
|
||||
* True for a destination that is a remote URL — an absolute `scheme:` URL
|
||||
* that is not a Windows drive path (`C:\...`). http/https/data/mailto etc.
|
||||
* all match here and are handed back to `MarkdownText` untouched.
|
||||
*/
|
||||
function isRemoteUrl(dest: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(dest) && !/^[A-Za-z]:[\\/]/.test(dest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `.`/`..` segments of an absolute local path, preserving its root
|
||||
* (POSIX `/`), its Windows drive (`C:\`), or its UNC `\\server\share`
|
||||
* prefix. The host's `requireAbsolute` (`path.resolve`) normalizes anyway,
|
||||
* but producing a canonical path here keeps the `/sidebar/file` URL clean.
|
||||
*/
|
||||
function normalizeLocalPath(path: string): string {
|
||||
const drive = /^([A-Za-z]:)[\\/]/.exec(path)?.[1]
|
||||
const body = drive !== undefined ? path.slice(drive.length) : path
|
||||
const parts = body.split(/[\\/]+/).filter((segment) => segment !== '' && segment !== '.')
|
||||
const out: string[] = []
|
||||
for (const part of parts) {
|
||||
if (part === '..') { out.pop(); continue }
|
||||
out.push(part)
|
||||
}
|
||||
if (drive !== undefined) return `${drive}\\${out.join('\\')}`
|
||||
const separator = path.startsWith('\\') ? '\\' : '/'
|
||||
const root = path.startsWith('/') ? '/' : path.startsWith('\\') ? '\\\\' : ''
|
||||
return `${root}${out.join(separator)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite markdown image destinations that point at local files into
|
||||
* absolute `/sidebar/file` media URLs. Relative destinations resolve against
|
||||
* the opened file's directory (normalizing `.`/`..` segments); absolute
|
||||
* local paths pass through. Remote (http/https/data/mailto) and `#`-anchor
|
||||
* destinations are left untouched for `MarkdownText`. Reference-style images
|
||||
* (`![x][id]` + `[id]: url`) are covered by rewriting their definition lines.
|
||||
* @param text - The raw markdown source (inline + reference images).
|
||||
* @param scope - The session scope (sessionId + cwd) for the media route.
|
||||
* @param filePath - The absolute path of the opened `.md` file.
|
||||
* @param origin - The GUI's own origin (`window.location.origin`); injected
|
||||
* so the core rewrite stays pure and unit-testable.
|
||||
* @returns The markdown with local image destinations rewritten in place.
|
||||
*/
|
||||
export function rewriteLocalImageUrls(
|
||||
text: string,
|
||||
scope: SessionScope,
|
||||
filePath: string,
|
||||
origin: string,
|
||||
): string {
|
||||
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
|
||||
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
|
||||
const mediaUrl = (candidate: string): string => {
|
||||
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
|
||||
// absolute so the shared MarkdownText http(s) allowlist accepts it.
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, path: candidate })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
return `${origin}/sidebar/file?${params.toString()}`
|
||||
}
|
||||
const resolve = (dest: string): string => {
|
||||
const trimmed = dest.trim()
|
||||
if (trimmed === '' || trimmed.startsWith('#')) return dest
|
||||
if (isRemoteUrl(trimmed)) return dest
|
||||
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
|
||||
return mediaUrl(normalizeLocalPath(candidate))
|
||||
}
|
||||
const inline = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, dest) => {
|
||||
return `})`
|
||||
})
|
||||
// Reference-style images: rewrite their `[id]: <url>` definitions.
|
||||
return inline.replace(/^(\s*\[[^\]]+\]:\s*)(<[^>]+>|[^\s]+)/gm, (_match, head, dest) => {
|
||||
return `${head}${resolve(dest.replace(/^<|>$/g, ''))}`
|
||||
})
|
||||
}
|
||||
@@ -48,6 +48,10 @@ function configureMermaid(): void {
|
||||
// forcing pure SVG text keeps labels visible and the HTML label channel
|
||||
// closed (strict already escapes label content).
|
||||
htmlLabels: false,
|
||||
// Mermaid 11 renders a large error SVG into document.body before rejecting
|
||||
// invalid diagrams. The component already has its own error fallback, so
|
||||
// suppress that global side effect to keep the DSH shell intact.
|
||||
suppressErrorRendering: true,
|
||||
theme: isDarkScheme() ? 'dark' : 'default',
|
||||
})
|
||||
}
|
||||
|
||||
+35
-8
@@ -3,14 +3,14 @@
|
||||
* Streams the tree with opendir and matches the query as a case-insensitive
|
||||
* substring of each entry's NAME (paths stay relative to the search root —
|
||||
* the client resolves them against the session cwd). No .gitignore semantics
|
||||
* (this is a name lookup, not a code search), but `.git` directories are
|
||||
* skipped outright (VCS internals are never useful results) and symlink
|
||||
* directories are NOT descended (cycle safety).
|
||||
* (this is a name lookup, not a code search), but known noise directories
|
||||
* (`.git`, `node_modules`, package-manager stores, build caches) are
|
||||
* skipped outright and symlink directories are NOT descended (cycle safety).
|
||||
*
|
||||
* Two performance budgets bound the walk: `maxMatches` (the client renders
|
||||
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
|
||||
* a node_modules forest — must not stall the host). Exceeding either stops
|
||||
* early with `truncated: true`.
|
||||
* the flat list) and `maxVisited` (a runaway tree — a home directory root
|
||||
* — must not stall the host). Exceeding either stops early with
|
||||
* `truncated: true`.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
@@ -33,6 +33,33 @@ export interface FsSearchOptions {
|
||||
const DEFAULT_MAX_MATCHES = 200
|
||||
const DEFAULT_MAX_VISITED = 100_000
|
||||
|
||||
/**
|
||||
* Directory names that are never useful filename-search results and would
|
||||
* burn the visit budget before the walk reaches project files. Compared
|
||||
* case-insensitively so `Node_Modules` / `.GIT` stay skipped on every
|
||||
* platform. The directory itself is neither matched nor descended.
|
||||
*/
|
||||
const SEARCH_SKIP_DIRS = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'.pnpm-store',
|
||||
'.yarn',
|
||||
'.turbo',
|
||||
'.turbopack',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'.cache',
|
||||
'.parcel-cache',
|
||||
'coverage',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'.umi',
|
||||
'.umi-production',
|
||||
'.dumi',
|
||||
])
|
||||
|
||||
/**
|
||||
* Search `root` recursively for entries whose name contains `query`
|
||||
* (case-insensitive).
|
||||
@@ -63,8 +90,8 @@ export async function searchFiles(root: string, query: string, opts: FsSearchOpt
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
// .git is VCS-internal noise: never matched, never descended.
|
||||
if (dirent.isDirectory() && dirent.name === '.git') continue
|
||||
// Dependency / VCS / build-output forests: never matched, never descended.
|
||||
if (dirent.isDirectory() && SEARCH_SKIP_DIRS.has(dirent.name.toLowerCase())) continue
|
||||
if (dirent.name.toLowerCase().includes(needle)) {
|
||||
matches.push(join(relative(root, dir), dirent.name))
|
||||
if (matches.length >= maxMatches) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useViewportSize } from '../src/client/breakpoints.ts'
|
||||
|
||||
describe('useViewportSize', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('publishes height-only resizes without requiring a breakpoint change', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frames.push(callback)
|
||||
return frames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
|
||||
|
||||
const observed: string[] = []
|
||||
function Probe(): null {
|
||||
const size = useViewportSize()
|
||||
observed.push(`${size.width}x${size.height}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const root = createRoot(host)
|
||||
await act(async () => { root.render(<Probe />) })
|
||||
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
await act(async () => { frames.shift()?.(0) })
|
||||
|
||||
expect(observed.at(-1)).toBe('1280x600')
|
||||
await act(async () => { root.unmount() })
|
||||
})
|
||||
})
|
||||
+25
-3
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* fs-search: the host's recursive file-name search behind the editor side
|
||||
* panel's search box. Matches are case-insensitive name substrings, reported
|
||||
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
|
||||
* symlinked directories are never descended (cycle safety), and the
|
||||
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
|
||||
* RELATIVE to the root ('/'-separated); noise directories (`.git`,
|
||||
* `node_modules`, build caches) are skipped, symlinked directories are
|
||||
* never descended (cycle safety), and the maxMatches/maxVisited budgets
|
||||
* stop a runaway walk with `truncated: true`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
@@ -86,6 +87,27 @@ describe('fs-search', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('never descends into node_modules or other noise directories', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
mkdirSync(join(dir, 'node_modules', 'left-pad'), { recursive: true })
|
||||
mkdirSync(join(dir, 'web', 'dist'), { recursive: true })
|
||||
writeFileSync(join(dir, 'node_modules', 'left-pad', 'guide.md'), 'dep')
|
||||
writeFileSync(join(dir, 'web', 'dist', 'bundle.js'), 'build')
|
||||
writeFileSync(join(dir, 'web', 'app.ts'), 'src')
|
||||
// A match hidden behind node_modules / dist must not appear; project
|
||||
// files after those forests must still be reachable within budget.
|
||||
expect((await searchFiles(dir, 'guide')).matches).toEqual(['docs/guide.md'])
|
||||
expect((await searchFiles(dir, 'left-pad')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'bundle')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'app.ts')).matches).toEqual(['web/app.ts'])
|
||||
expect((await searchFiles(dir, 'node_modules')).matches).toEqual([])
|
||||
expect((await searchFiles(dir, 'dist')).matches).toEqual([])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an empty (or whitespace) query matches nothing without walking', async () => {
|
||||
const dir = makeFixture()
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* layout.css must let the DSH conversation column shrink below its content
|
||||
* size. Without min-height:0 a long unbreakable URL grows the grid item
|
||||
* past the viewport and clips the composer.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync('src/client/layout.css', 'utf8')
|
||||
|
||||
describe('layout.css conversation column', () => {
|
||||
it('targets the AppFrame center column', () => {
|
||||
expect(css).toContain('[data-pane="conversation"]')
|
||||
expect(css).toContain('[data-slot="conversation"]')
|
||||
})
|
||||
|
||||
it('allows the center column to shrink and wrap long tokens', () => {
|
||||
expect(css).toMatch(/min-height:\s*0/)
|
||||
expect(css).toMatch(/overflow-wrap:\s*anywhere/)
|
||||
})
|
||||
|
||||
it('leaves overflow ownership to the host conversation descendants', () => {
|
||||
const conversationRule = css.match(
|
||||
/#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/,
|
||||
)?.[1]
|
||||
expect(conversationRule).toBeDefined()
|
||||
expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Layout-push size contract: the conversation column (output + composer)
|
||||
* must keep at least PANEL_MIN of the viewport after the bottom panel
|
||||
* claims its height. A stale persisted bottomHeight (or a cap at 100vh)
|
||||
* used to crush the composer off-screen.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts'
|
||||
import { layoutPushSize } from '../src/client/layout-push.ts'
|
||||
|
||||
describe('layoutPushSize', () => {
|
||||
it('pushes nothing on a narrow drawer viewport', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: true,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 390,
|
||||
viewportHeight: 700,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('pushes nothing when both panels are collapsed', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: false,
|
||||
width: 400,
|
||||
bottomHeight: 400,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
|
||||
it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => {
|
||||
const viewportHeight = 800
|
||||
const size = layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: false,
|
||||
bottomOpen: true,
|
||||
width: 0,
|
||||
bottomHeight: 10_000,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight,
|
||||
})
|
||||
expect(size.height).toBe(viewportHeight - PANEL_MIN)
|
||||
expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN)
|
||||
expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN)
|
||||
})
|
||||
|
||||
it('caps an oversize right panel at the viewport width', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: false,
|
||||
width: 10_000,
|
||||
bottomHeight: 0,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 1280, height: 0 })
|
||||
})
|
||||
|
||||
it('passes through in-range open sizes', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ width: 400, height: 220 })
|
||||
})
|
||||
|
||||
it('never pushes beyond a viewport smaller than the normal panel minima', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: 400,
|
||||
bottomHeight: 220,
|
||||
viewportWidth: 200,
|
||||
viewportHeight: 200,
|
||||
})).toEqual({ width: 200, height: 0 })
|
||||
})
|
||||
|
||||
it('turns non-finite geometry into a safe zero push', () => {
|
||||
expect(layoutPushSize({
|
||||
narrow: false,
|
||||
panelOpen: true,
|
||||
bottomOpen: true,
|
||||
width: Number.NaN,
|
||||
bottomHeight: Number.POSITIVE_INFINITY,
|
||||
viewportWidth: Number.NaN,
|
||||
viewportHeight: Number.POSITIVE_INFINITY,
|
||||
})).toEqual({ width: 0, height: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rewriteLocalImageUrls } from '../src/client/markdown-images.ts'
|
||||
import type { SessionScope } from '../src/client/api.ts'
|
||||
|
||||
const ORIGIN = 'http://127.0.0.1:3080'
|
||||
const scope: SessionScope = { sessionId: 'abc', cwd: '/repo' }
|
||||
|
||||
describe('rewriteLocalImageUrls', () => {
|
||||
it('resolves relative destinations against the opened file directory', () => {
|
||||
const md = '\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
expect(out).toContain(``)
|
||||
})
|
||||
|
||||
it('expects the rewritten destination to be an absolute http URL MarkdownText accepts', () => {
|
||||
const out = rewriteLocalImageUrls('', scope, '/repo/readme.md', ORIGIN)
|
||||
const url = out.match(/\]\((http[^)]+)\)/)?.[1]
|
||||
expect(url).toBeDefined()
|
||||
expect(new URL(url as string).protocol).toBe('http:')
|
||||
})
|
||||
|
||||
it('passes absolute local paths through unchanged', () => {
|
||||
const md = ''
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
})
|
||||
|
||||
it('keeps remote http(s)/data/mailto destinations untouched', () => {
|
||||
const md = '\n\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain('')
|
||||
expect(out).toContain('')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('leaves in-page anchors untouched', () => {
|
||||
const md = '\n\n[link](#top)'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('rewrites reference-style image definitions', () => {
|
||||
const md = '![logo][logo]\n\n[logo]: ./logo.svg'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain(`[logo]: ${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Flogo.svg&cwd=%2Frepo`)
|
||||
// The inline reference itself is left for MarkdownText to combine.
|
||||
expect(out).toContain('![logo][logo]')
|
||||
})
|
||||
|
||||
it('normalizes `.`/`..` segments in relative destinations', () => {
|
||||
const md = '\n'
|
||||
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/sub/readme.md', ORIGIN)
|
||||
expect(out).toContain(``)
|
||||
expect(out).toContain(``)
|
||||
expect(out).not.toContain('/.')
|
||||
})
|
||||
|
||||
it('drops the cwd param when the scope has no cwd', () => {
|
||||
const out = rewriteLocalImageUrls('', { sessionId: 'abc' }, '/repo/readme.md', ORIGIN)
|
||||
expect(out).toContain(`sessionId=abc&path=%2Frepo%2Fimg.png`)
|
||||
expect(out).not.toContain('cwd=')
|
||||
})
|
||||
|
||||
it('handles windows absolute paths as local (not remote)', () => {
|
||||
const md = ''
|
||||
const out = rewriteLocalImageUrls(md, scope, 'C:\\repo\\docs\\readme.md', ORIGIN)
|
||||
// A Windows drive path is absolute (not a remote scheme) → rewritten.
|
||||
const url = out.match(/\]\(([^)]+)\)/)?.[1] as string
|
||||
expect(url).toContain('/sidebar/file?')
|
||||
expect(decodeURIComponent(url)).toContain('C:\\repo\\img.png')
|
||||
})
|
||||
})
|
||||
@@ -16,13 +16,14 @@ import { act } from 'react-dom/test-utils'
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: () => {},
|
||||
initialize: vi.fn(),
|
||||
render: async () => ({
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>MOCK-DIAGRAM</text></svg>',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
import { MermaidMarkdown } from '../src/client/mermaid.tsx'
|
||||
|
||||
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
|
||||
@@ -56,6 +57,15 @@ describe('MermaidMarkdown', () => {
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('suppresses Mermaid global error rendering', async () => {
|
||||
vi.mocked(mermaid.initialize).mockClear()
|
||||
const { root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```')
|
||||
expect(mermaid.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suppressErrorRendering: true,
|
||||
}))
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('resolves cross-fence reference-style links (single markdown parse)', async () => {
|
||||
const text = [
|
||||
'[before][shared]',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/client/Sidebar.tsx', 'utf8')
|
||||
|
||||
describe('Sidebar layout-push integration', () => {
|
||||
it('does not bypass the shared bottom-height cap during width drags', () => {
|
||||
expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)')
|
||||
expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('caps panel geometry against the viewport visible above the keyboard', () => {
|
||||
expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))')
|
||||
expect(source).toContain('visualViewportHeight ?? viewport.height')
|
||||
expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4)
|
||||
expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]')
|
||||
})
|
||||
|
||||
it('adds the keyboard inset to the conversation push, not the panel height', () => {
|
||||
expect(source.match(/height \+ keyboardInset/g)).toHaveLength(2)
|
||||
expect(source).toContain('height: bottomPanelHeight')
|
||||
expect(source).not.toContain('height: bottomPanelHeight + keyboardInset')
|
||||
})
|
||||
|
||||
it('reapplies the visible-height cap to every vertical drag result', () => {
|
||||
expect(source).not.toMatch(/const height = clampHeight\(/)
|
||||
expect(source).not.toMatch(/height = clampHeight\(/)
|
||||
expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user