diff --git a/README.md b/README.md index f63fd8b..66a489c 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,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 偶发失败修复 diff --git a/README_EN.md b/README_EN.md index cbbd363..0a9324f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -366,6 +366,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 diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index bcb406f..57137b6 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 - ? - : } + ? + : } )} {html && mode === 'preview' && ( diff --git a/src/client/markdown-images.ts b/src/client/markdown-images.ts new file mode 100644 index 0000000..115303c --- /dev/null +++ b/src/client/markdown-images.ts @@ -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` (`![alt](./img.png)`, 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 `![${alt}](${resolve(dest)})` + }) + // Reference-style images: rewrite their `[id]: ` definitions. + return inline.replace(/^(\s*\[[^\]]+\]:\s*)(<[^>]+>|[^\s]+)/gm, (_match, head, dest) => { + return `${head}${resolve(dest.replace(/^<|>$/g, ''))}` + }) +} diff --git a/tests/markdown-images.spec.ts b/tests/markdown-images.spec.ts new file mode 100644 index 0000000..d867ff5 --- /dev/null +++ b/tests/markdown-images.spec.ts @@ -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') + }) +})