integrate train/misc: mermaid + layout-push + fs-search + markdown-images

This commit is contained in:
HuanLinOTO
2026-08-23 20:28:49 +08:00
18 changed files with 575 additions and 47 deletions
+42
View File
@@ -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
View File
@@ -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 {
+29
View File
@@ -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*:/)
})
})
+99
View File
@@ -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 })
})
})
+73
View File
@@ -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 = '![a](./img.png)\n![b](images/b.jpg)'
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fimg.png&cwd=%2Frepo)`)
expect(out).toContain(`![b](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fimages%2Fb.jpg&cwd=%2Frepo)`)
})
it('expects the rewritten destination to be an absolute http URL MarkdownText accepts', () => {
const out = rewriteLocalImageUrls('![a](./img.png)', 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 = '![a](/repo/assets/b.png)'
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/readme.md', ORIGIN)
expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fassets%2Fb.png&cwd=%2Frepo)`)
})
it('keeps remote http(s)/data/mailto destinations untouched', () => {
const md = '![a](https://example.com/x.jpg)\n![b](http://e.com/y.png)\n![c](data:image/png;base64,AAA)'
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
expect(out).toContain('![a](https://example.com/x.jpg)')
expect(out).toContain('![b](http://e.com/y.png)')
expect(out).toContain('![c](data:image/png;base64,AAA)')
})
it('leaves in-page anchors untouched', () => {
const md = '![a](#section)\n\n[link](#top)'
const out = rewriteLocalImageUrls(md, scope, '/repo/readme.md', ORIGIN)
expect(out).toContain('![a](#section)')
})
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 = '![a](./img.png)\n![b](../assets/b.png)'
const out = rewriteLocalImageUrls(md, scope, '/repo/docs/sub/readme.md', ORIGIN)
expect(out).toContain(`![a](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fsub%2Fimg.png&cwd=%2Frepo)`)
expect(out).toContain(`![b](${ORIGIN}/sidebar/file?sessionId=abc&path=%2Frepo%2Fdocs%2Fassets%2Fb.png&cwd=%2Frepo)`)
expect(out).not.toContain('/.')
})
it('drops the cwd param when the scope has no cwd', () => {
const out = rewriteLocalImageUrls('![a](./img.png)', { 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 = '![a](C:\\repo\\img.png)'
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')
})
})
+11 -1
View File
@@ -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]',
+30
View File
@@ -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)
})
})