refactor: upgrade markdown renderer from 4.x to 15.x (#4548)

* feat: add auto-scroll and update styles for ChatEditor

* refactor: add test data in debug tree node

* style: improve scm action button style

* chore: remove useless code

* chore: fix markdown render

* feat: upgrade marked from 4.x to 15.x

* chore: fix auto scroll

* chore: fix markdown parse

* chore: add deps

* chore: scroll into view while output

* chore: update deps

* chore: update highlight element ref
This commit is contained in:
Dan 2025-05-14 16:37:31 +08:00 committed by GitHub
parent 197a201402
commit 0d8a0cdd55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 235 additions and 133 deletions

View File

@ -1,5 +1,6 @@
import capitalize from 'lodash/capitalize';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import throttle from 'lodash/throttle';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Highlight from 'react-highlight';
import { Image } from '@opensumi/ide-components/lib/image';
@ -77,15 +78,36 @@ export const CodeEditorWithHighlight = (props: Props) => {
return () => dispose.dispose();
}, []);
const throttledScrollToBottom = useRef(
throttle(
() => {
if (ref.current) {
const highlightElement = ref.current;
const codeElement = (highlightElement as any)?.el?.querySelector('code');
const childs = codeElement?.children;
if (childs) {
const lastChild = childs[childs.length - 1];
if (lastChild) {
if ((lastChild as any).scrollIntoViewIfNeeded) {
(lastChild as any).scrollIntoViewIfNeeded();
} else {
lastChild.scrollIntoView(false);
}
}
}
}
},
150,
{ leading: true, trailing: true },
),
).current;
useEffect(() => {
if (ref.current) {
const highlightElement = ref.current;
const preElement = highlightElement?.querySelector?.('pre');
if (preElement) {
preElement.scrollTop = preElement.scrollHeight;
}
}
}, [input]);
throttledScrollToBottom();
return () => {
throttledScrollToBottom.cancel();
};
}, [input, throttledScrollToBottom]);
const handleCopy = useCallback(async () => {
setIsCoping(true);

View File

@ -9,6 +9,8 @@ import { IMarkdownString, MarkdownString } from '@opensumi/monaco-editor-core/es
import { CodeEditorWithHighlight } from './ChatEditor';
import styles from './components.module.less';
import type { Token, Tokens, TokensList } from 'marked';
interface MarkdownProps {
markdown: IMarkdownString | string;
agentId?: string;
@ -24,7 +26,7 @@ export const ChatMarkdown = (props: MarkdownProps) => {
const ref = useRef<HTMLDivElement | null>(null);
const appConfig = useInjectable<AppConfig>(AppConfig);
const [reactParser, setReactParser] = useState<MarkdownReactParser>();
const [tokensList, setTokensList] = useState<marked.TokensList>();
const [tokensList, setTokensList] = useState<TokensList>();
useEffect(() => {
const element = ref.current;
@ -64,8 +66,6 @@ export const ChatMarkdown = (props: MarkdownProps) => {
...marked.defaults,
...props.markedOptions,
renderer: reactParser,
mangle: false,
headerIds: false,
smartypants: false,
};
@ -75,14 +75,11 @@ export const ChatMarkdown = (props: MarkdownProps) => {
}
let renderedMarkdown: string;
let tokensList: marked.TokensList;
let tokensList: TokensList;
if (props.fillInIncompleteTokens) {
const opts = {
...markedOptions,
};
const tokens = marked.lexer(value, opts);
const tokens = marked.lexer(value, markedOptions);
const newTokens = fillInIncompleteTokens(tokens);
renderedMarkdown = marked.parser(newTokens, opts);
renderedMarkdown = marked.parser(newTokens, markedOptions);
tokensList = newTokens;
} else {
const tokens = marked.lexer(value, markedOptions);
@ -113,9 +110,9 @@ export function postProcessCodeBlockLanguageId(lang: string | undefined): string
return lang;
}
export function fillInIncompleteTokens(tokens: marked.TokensList): marked.TokensList {
export function fillInIncompleteTokens(tokens: TokensList): TokensList {
let i: number;
let newTokens: marked.Token[] | undefined;
let newTokens: Token[] | undefined;
for (i = 0; i < tokens.length; i++) {
const token = tokens[i];
// 代码块补全,完整的代码块 type=code
@ -142,7 +139,7 @@ export function fillInIncompleteTokens(tokens: marked.TokensList): marked.Tokens
}
if (newTokens) {
const newTokensList = [...tokens.slice(0, i), ...newTokens] as marked.TokensList;
const newTokensList = [...tokens.slice(0, i), ...newTokens] as TokensList;
newTokensList.links = tokens.links;
return newTokensList;
}
@ -150,48 +147,48 @@ export function fillInIncompleteTokens(tokens: marked.TokensList): marked.Tokens
return tokens;
}
function completeCodeBlock(tokens: marked.Token[]): marked.Token[] {
function completeCodeBlock(tokens: Token[]): Token[] {
const mergedRawText = mergeRawTokenText(tokens);
return marked.lexer(mergedRawText + '\n```');
}
function completeCodespan(token: marked.Token): marked.Token {
function completeCodespan(token: Token): Token {
return completeWithString(token, '`');
}
function completeStar(tokens: marked.Token): marked.Token {
function completeStar(tokens: Token): Token {
return completeWithString(tokens, '*');
}
function completeUnderscore(tokens: marked.Token): marked.Token {
function completeUnderscore(tokens: Token): Token {
return completeWithString(tokens, '_');
}
function completeLinkTarget(tokens: marked.Token): marked.Token {
function completeLinkTarget(tokens: Token): Token {
return completeWithString(tokens, ')');
}
function completeLinkText(tokens: marked.Token): marked.Token {
function completeLinkText(tokens: Token): Token {
return completeWithString(tokens, '](about:blank)');
}
function completeDoublestar(tokens: marked.Token): marked.Token {
function completeDoublestar(tokens: Token): Token {
return completeWithString(tokens, '**');
}
function completeDoubleUnderscore(tokens: marked.Token): marked.Token {
function completeDoubleUnderscore(tokens: Token): Token {
return completeWithString(tokens, '__');
}
function completeWithString(tokens: marked.Token[] | marked.Token, closingString: string): marked.Token {
function completeWithString(tokens: Token[] | Token, closingString: string): Token {
const mergedRawText = mergeRawTokenText(Array.isArray(tokens) ? tokens : [tokens]);
// If it was completed correctly, this should be a single token.
// Expecting either a Paragraph or a List
return marked.lexer(mergedRawText + closingString)[0] as marked.Token;
return marked.lexer(mergedRawText + closingString)[0] as Token;
}
function completeTable(tokens: marked.Token[]): marked.Token[] | undefined {
function completeTable(tokens: Token[]): Token[] | undefined {
const mergedRawText = mergeRawTokenText(tokens);
const lines = mergedRawText.split('\n');
@ -225,12 +222,12 @@ function completeTable(tokens: marked.Token[]): marked.Token[] | undefined {
return undefined;
}
function mergeRawTokenText(tokens: marked.Token[]): string {
function mergeRawTokenText(tokens: Token[]): string {
return tokens.reduce((mergedTokenText, token) => mergedTokenText + token.raw, '');
}
function completeSingleLinePattern(token: marked.Tokens.ListItem | marked.Tokens.Paragraph): marked.Token | undefined {
for (const { type, raw } of token.tokens) {
function completeSingleLinePattern(token: Tokens.ListItem | Tokens.Paragraph | Tokens.Generic): Token | undefined {
for (const { type, raw } of token.tokens ?? []) {
if (type !== 'text') {
continue;
}

View File

@ -599,6 +599,7 @@
line-height: 20px;
vertical-align: middle;
font-size: 12px;
word-break: break-all;
}
.thumbnail_container {

View File

@ -7,6 +7,8 @@ import { IMarkdownString } from '@opensumi/ide-core-browser';
import styles from './comments.module.less';
import { markdownCss } from './markdown.style';
import type { Tokens } from 'marked';
const ShadowContent = ({ root, children }) => ReactDOM.createPortal(children, root);
export const CommentsBody: React.FC<{
@ -18,8 +20,8 @@ export const CommentsBody: React.FC<{
const renderer = React.useMemo(() => {
const renderer = createMarkedRenderer();
renderer.link = (href, title, text) =>
`<a target="_blank" rel="noopener" href="${href}" title="${title}">${text}</a>`;
renderer.link = ({ href, title, text }: Tokens.Link): string =>
`<a target="_blank" rel="noopener" href="${href}" title="${title || ''}">${text}</a>`;
return renderer;
}, []);
@ -43,8 +45,6 @@ export const CommentsBody: React.FC<{
gfm: true,
breaks: false,
pedantic: false,
smartLists: true,
smartypants: false,
renderer,
}),
}}

View File

@ -22,7 +22,7 @@
"@rc-component/mini-decimal": "^1.0.1",
"fuzzy": "^0.1.3",
"lodash": "^4.17.21",
"marked": "4.0.10",
"marked": "15.0.11",
"raf": "^3.4.1",
"rc-dialog": "^9.6.0",
"rc-dropdown": "~2.4.1",
@ -40,7 +40,6 @@
},
"devDependencies": {
"@opensumi/ide-dev-tool": "workspace:*",
"@types/marked": "^4.0.7",
"@types/react-window": "^1.8.5",
"prop-types": "^15.8.1"
}

View File

@ -3,6 +3,8 @@ import React, { ReactNode } from 'react';
import { HeadingLevels, MarkdownReactRenderer } from './render';
import type { Token, Tokens } from 'marked';
/**
* marked.Renderer React Markdown
*/
@ -15,12 +17,13 @@ export class MarkdownReactParser extends marked.Renderer {
this.renderer = options.renderer;
}
parse(tokens: marked.Token[]): ReactNode[] {
parse(tokens: Token[]): ReactNode[] {
return tokens.map((token, index) => {
const element = (() => {
switch (token.type) {
case 'html': {
return this.renderer.html(token.text);
const htmlToken = token as Tokens.HTML;
return this.renderer.html(htmlToken.text);
}
case 'space': {
@ -28,27 +31,29 @@ export class MarkdownReactParser extends marked.Renderer {
}
case 'heading': {
const level = token.depth as HeadingLevels;
return this.renderer.heading(this.parseInline(token.tokens), level);
const headingToken = token as Tokens.Heading;
const level = headingToken.depth as HeadingLevels;
return this.renderer.heading(this.parseInline(headingToken.tokens), level);
}
case 'paragraph': {
return this.renderer.paragraph(this.parseInline(token.tokens));
const paragraphToken = token as Tokens.Generic;
return this.renderer.paragraph(this.parseInline(paragraphToken.tokens));
}
case 'text': {
const textToken = token as marked.Tokens.Text;
return textToken.tokens ? this.parseInline(textToken.tokens) : token.text;
const textToken = token as Tokens.Text;
return textToken.tokens ? this.parseInline(textToken.tokens) : textToken.text;
}
case 'blockquote': {
const blockquoteToken = token as marked.Tokens.Blockquote;
const blockquoteToken = token as Tokens.Blockquote;
const quote = this.parse(blockquoteToken.tokens);
return this.renderer.blockquote(quote);
}
case 'list': {
const listToken = token as marked.Tokens.List;
const listToken = token as Tokens.List;
const children = listToken.items.map((item, itemIndex) => {
const listItemChildren: ReactNode[] = [];
@ -64,20 +69,21 @@ export class MarkdownReactParser extends marked.Renderer {
});
});
return this.renderer.list(children, token.ordered);
return this.renderer.list(children, listToken.ordered);
}
case 'code': {
return this.renderer.code(token.text, token.lang);
const codeToken = token as Tokens.Code;
return this.renderer.code(codeToken.text, codeToken.lang);
}
case 'table': {
const tableToken = token as marked.Tokens.Table;
const tableToken = token as Tokens.Table;
const headerCells = tableToken.header.map((cell, cellIndex) =>
React.cloneElement(
this.renderer.tableCell(this.parseInline(cell.tokens), {
header: true,
align: token.align[cellIndex],
align: tableToken.align[cellIndex],
}) as React.ReactElement,
{ key: `header-cell-${cellIndex}` },
),
@ -93,7 +99,7 @@ export class MarkdownReactParser extends marked.Renderer {
React.cloneElement(
this.renderer.tableCell(this.parseInline(cell.tokens), {
header: false,
align: token.align[cellIndex],
align: tableToken.align[cellIndex],
}) as React.ReactElement,
{ key: `body-cell-${rowIndex}-${cellIndex}` },
),
@ -128,40 +134,48 @@ export class MarkdownReactParser extends marked.Renderer {
});
}
parseInline(tokens: marked.Token[] = []): ReactNode[] {
parseInline(tokens: Token[] = []): ReactNode[] {
return tokens.map((token) => {
switch (token.type) {
case 'text': {
const text = htmlUnescape(token.text);
const textToken = token as Tokens.Text;
const text = htmlUnescape(textToken.text);
return this.renderer.text(text);
}
case 'strong': {
return this.renderer.strong(this.parseInline(token.tokens));
const strongToken = token as Tokens.Strong;
return this.renderer.strong(this.parseInline(strongToken.tokens));
}
case 'em': {
return this.renderer.em(this.parseInline(token.tokens));
const emToken = token as Tokens.Em;
return this.renderer.em(this.parseInline(emToken.tokens));
}
case 'del': {
return this.renderer.del(this.parseInline(token.tokens));
const delToken = token as Tokens.Del;
return this.renderer.del(this.parseInline(delToken.tokens));
}
case 'codespan': {
return this.renderer.codespan(unescape(token.text));
const codespanToken = token as Tokens.Codespan;
return this.renderer.codespan(htmlUnescape(codespanToken.text));
}
case 'link': {
return this.renderer.link(token.href, this.parseInline(token.tokens));
const linkToken = token as Tokens.Link;
return this.renderer.link(linkToken.href, this.parseInline(linkToken.tokens));
}
case 'image': {
return this.renderer.image(token.href, token.text, token.title);
const imageToken = token as Tokens.Image;
return this.renderer.image(imageToken.href, imageToken.text, imageToken.title);
}
case 'html': {
return this.renderer.html(token.text);
const htmlToken = token as Tokens.HTML;
return this.renderer.html(htmlToken.text);
}
case 'br': {
@ -169,7 +183,8 @@ export class MarkdownReactParser extends marked.Renderer {
}
case 'escape': {
return this.renderer.text(token.text);
const escapeToken = token as Tokens.Escape;
return this.renderer.text(escapeToken.text);
}
default: {

View File

@ -3,9 +3,11 @@ import React from 'react';
import { DATA_SET_COMMAND, IOpenerShape, RenderWrapper } from './render';
import type { Tokens } from 'marked';
interface IMarkdownProps {
value?: string;
renderer: marked.Renderer;
renderer: Renderer;
opener?: IOpenerShape;
}
@ -13,13 +15,16 @@ export const linkify = (href: string | null, title: string | null, text: string)
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" title="${title ?? href}">${text}</a>`;
export class DefaultMarkedRenderer extends Renderer {
link(href: string | null, title: string | null, text: string): string {
return linkify(href, title, text);
link({ href, title, text }: Tokens.Link): string {
return linkify(href, title || null, text);
}
}
export function Markdown(props: IMarkdownProps) {
const parseMarkdown = (text: string, renderer: any) => marked.parse(text, { renderer });
const parseMarkdown = (text: string, renderer: any) => {
const result = marked.parse(text, { renderer, async: false });
return typeof result === 'string' ? result : '';
};
const [htmlContent, setHtmlContent] = React.useState(parseMarkdown(props.value || '', props.renderer));

View File

@ -156,7 +156,8 @@ export abstract class PromptHandle {
validateBoxClassName += this._validateClassName;
this.$validate.classList.value = validateBoxClassName;
this.$validate.innerHTML = toMarkdownHtml(validateMessage.message || '', { renderer: this.markdownRenderer });
const htmlContent = toMarkdownHtml(validateMessage.message || '', { renderer: this.markdownRenderer });
this.$validate.innerHTML = typeof htmlContent === 'string' ? htmlContent : '';
this.$.parentElement?.parentElement?.classList.remove(
VALIDATE_CLASS_NAME.INFO,
VALIDATE_CLASS_NAME.ERROR,

View File

@ -1,33 +1,23 @@
import { Renderer, marked } from 'marked';
import type { MarkedOptions, Token, Tokens } from 'marked';
export { marked };
export type IMarkedOptions = marked.MarkedOptions;
export type IMarkedOptions = MarkedOptions;
export const createMarkedRenderer = () => new Renderer();
export const parseMarkdown = (
value: string,
options?: IMarkedOptions,
callback?: (error: any, parseResult: string) => void,
) => {
if (!callback) {
return marked.parse(value, options);
}
if (options) {
marked.parse(value, options, callback);
} else {
marked.parse(value, callback);
}
};
export const parseMarkdown = (value: string, options?: IMarkedOptions) => marked.parse(value, options);
export const toMarkdownHtml = (value: string, options?: IMarkedOptions) => marked(value, options);
export const parseWithoutEscape = (token: marked.Token) => {
export const parseWithoutEscape = (token: Token) => {
// 这里兼容下 vscode 的写法vscode 这里没有处理 markdown 语法
// 否则会出现 (\\) 被解析成 () 期望是 (\)
if (token.type === 'escape') {
token.text = token.raw;
const escapeToken = token as Tokens.Escape;
escapeToken.text = escapeToken.raw;
}
return token;

View File

@ -29,6 +29,7 @@
"jsonc-parser": "^2.1.0",
"keycode": "^2.2.0",
"lodash": "^4.17.21",
"marked": "15.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",

View File

@ -6,6 +6,8 @@ import { isString } from '@opensumi/ide-core-common';
import { IOpenerService } from '../opener';
import type { Tokens } from 'marked';
export const toMarkdown = (
content: string | React.ReactNode,
opener?: IOpenerService,
@ -26,16 +28,16 @@ export const toMarkdown = (
export const toMarkdownHtml = (message: string, options?: IMarkedOptions): string => {
const renderer = createMarkedRenderer();
renderer.link = (href, title, text) =>
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" href="javascript:void(0)" title="${title}">${text}</a>`;
renderer.link = ({ href, title, text }: Tokens.Link): string =>
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" href="javascript:void(0)" title="${title || ''}">${text}</a>`;
return toHtml(message, {
const result = toHtml(message, {
gfm: true,
breaks: false,
pedantic: false,
smartLists: true,
smartypants: false,
renderer,
...(options || {}),
});
return typeof result === 'string' ? result : '';
};

View File

@ -41,7 +41,6 @@ import {
TestMessageType,
} from '@opensumi/ide-testing/lib/common/testCollection';
import { CommandsConverter } from '../../hosted/api/vscode/ext.host.command';
import { IDataTransferItem, VSDataTransfer } from './data-transfer';
@ -234,8 +233,8 @@ export namespace MarkdownString {
return '';
};
const renderer = createMarkedRenderer();
renderer.link = collectUri;
renderer.image = (href) => (href ? collectUri(parseHrefAndDimensions(href).href) : '');
renderer.link = ({ href }) => (href ? collectUri(href) : '');
renderer.image = ({ href }) => (href ? collectUri(parseHrefAndDimensions(href).href) : '');
toMarkdownHtml(res.value, { renderer });

View File

@ -3,7 +3,7 @@ import React from 'react';
import { IMarkedOptions } from '@opensumi/ide-components/lib/utils';
import { CancellationTokenSource, Disposable, URI, useInjectable } from '@opensumi/ide-core-browser';
import { IMarkdownService } from '../common';
import { IMarkdownService, MarkdownOptions } from '../common';
export const Markdown = ({
content,
@ -12,7 +12,7 @@ export const Markdown = ({
onLinkClick,
}: {
content: string;
options?: IMarkedOptions;
options?: MarkdownOptions;
onLoaded?: () => void;
onLinkClick?: (uri: URI) => void;
}) => {

View File

@ -4,7 +4,7 @@ import { CancellationToken, Disposable, Event, IDisposable, IOpenerService, URI
import { HttpOpener } from '@opensumi/ide-core-browser/lib/opener/http-opener';
import { IWebviewService } from '@opensumi/ide-webview';
import { IMarkdownService } from '../common';
import { IMarkdownService, MarkdownOptions } from '../common';
import { markdownCss } from './mardown.style';
@ -25,7 +25,7 @@ export class MarkdownServiceImpl implements IMarkdownService {
content: string,
container: HTMLElement,
cancellationToken: CancellationToken,
options?: IMarkedOptions,
options?: MarkdownOptions,
onUpdate?: Event<string>,
onLinkClick?: (uri: URI) => void,
): Promise<IDisposable> {
@ -71,15 +71,20 @@ export class MarkdownServiceImpl implements IMarkdownService {
return disposer;
}
async getBody(content: string, options: IMarkedOptions | undefined): Promise<string> {
return new Promise((resolve, reject) => {
parseMarkdown(content, options, (err, result) => {
if (err) {
reject(err);
}
resolve(removeEmbeddedSVGs(renderBody(result)));
});
});
async getBody(content: string, options: MarkdownOptions | undefined): Promise<string> {
// marked 15.x 不再支持回调形式
try {
const result = parseMarkdown(content, options as IMarkedOptions);
if (typeof result === 'string') {
return removeEmbeddedSVGs(renderBody(result));
}
// 处理异步结果
const htmlContent = await result;
return removeEmbeddedSVGs(renderBody(htmlContent));
} catch (err) {
// 错误处理
return renderBody('Failed to parse markdown.');
}
}
}

View File

@ -1,11 +1,19 @@
import { CancellationToken, Event, IDisposable, URI } from '@opensumi/ide-core-common';
export interface MarkdownOptions {
baseUrl?: string;
gfm?: boolean;
breaks?: boolean;
pedantic?: boolean;
renderer?: any;
}
export interface IMarkdownService {
previewMarkdownInContainer(
content: string,
container: HTMLElement,
cancellationToken: CancellationToken,
options?: { baseUrl?: string | undefined },
options?: MarkdownOptions,
onUpdate?: Event<string>,
onLinkClick?: (uri: URI) => void,
): Promise<IDisposable>;

View File

@ -8,6 +8,8 @@ import { MayCancelablePromise, MessageType, localize, uuid } from '@opensumi/ide
import { AbstractMessageService, IMessageService, MAX_MESSAGE_LENGTH, OpenMessageOptions } from '../common';
import type { Token } from 'marked';
@Injectable()
export class MessageService extends AbstractMessageService implements IMessageService {
@Autowired(IOpenerService)
@ -63,8 +65,13 @@ export class MessageService extends AbstractMessageService implements IMessageSe
}
const description = from && typeof from === 'string' ? `${localize('component.message.origin')}: ${from}` : '';
const key = uuid();
const processToken = (token: Token): void => {
parseWithoutEscape(token);
};
const promise = open<T>(
toMarkdown(message, this.openerService, { walkTokens: parseWithoutEscape }),
toMarkdown(message, this.openerService, { walkTokens: processToken }),
type,
closable,
key,

View File

@ -28,6 +28,8 @@ import { getPreferenceItemLabel, knownPrefIdMappings } from '../common';
import { PreferenceSettingsService } from './preference-settings.service';
import styles from './preferences.module.less';
import type { Tokens } from 'marked';
interface IPreferenceItemProps {
preferenceName: string;
localizedName?: string;
@ -262,7 +264,7 @@ class PreferenceMarkedRender extends DefaultMarkedRenderer {
@Autowired(IPreferenceSettingsService)
preferenceSettingService: PreferenceSettingsService;
codespan(text: string): string {
codespan({ text }: Tokens.Codespan): string {
if (text.startsWith('#') && text.endsWith('#')) {
const _prefId = text.slice(1, text.length - 1);
const prefId = knownPrefIdMappings[_prefId] ?? _prefId;
@ -271,9 +273,9 @@ class PreferenceMarkedRender extends DefaultMarkedRenderer {
const preferenceTitle = getPreferenceItemLabel(preference);
return linkify(`${PreferenceMarkedRender.openerScheme}${preferenceTitle}`, prefId, preferenceTitle);
}
return super.codespan(prefId);
return super.codespan({ text: prefId } as Tokens.Codespan);
}
return super.codespan(text);
return super.codespan({ text } as Tokens.Codespan);
}
}

View File

@ -110,11 +110,37 @@ export const TestTreeContainer: FC<{ viewState?: ViewState }> = ({ viewState })
const testDto = new TestDto(result.id, test, taskId, messageIndex);
const getLabel = () => {
if (typeof message === 'string') {
return firstLine(message);
}
const node = {
type: ETestTreeType.MESSAGE,
context: uri,
label: firstLine(message.value),
id: uri.toString(),
icon: '',
notExpandable: false,
location,
rawItem: { ...testMessage, dto: testDto },
};
parseMarkdownText(message.value)
.then((parsedText) => {
node.label = firstLine(parsedText);
})
.catch(() => {
// 解析失败时保持原始值
});
return node.label;
};
return {
type: ETestTreeType.MESSAGE,
context: uri,
// ** 这里应该解析 markdown 转为纯文本信息 **
label: firstLine(typeof message === 'string' ? message : parseMarkdownText(message.value)),
label: getLabel(),
id: uri.toString(),
icon: '',
notExpandable: false,

View File

@ -339,13 +339,32 @@ class TestMessageDecoration implements ITestDecoration {
const message =
typeof testMessage.message === 'string'
? removeAnsiEscapeCodes(testMessage.message)
: parseMarkdownText(testMessage.message.value);
: parseMarkdownText(
typeof testMessage.message === 'object' && 'value' in testMessage.message
? (testMessage.message.value as string)
: String(testMessage.message),
)
.then((text) => text)
.catch(() =>
typeof testMessage.message === 'object' && 'value' in testMessage.message
? (testMessage.message.value as string)
: String(testMessage.message),
);
// 确保我们可以为装饰器提供字符串内容
const contentText =
typeof message === 'string'
? message
: typeof testMessage.message === 'object' && 'value' in testMessage.message
? (testMessage.message.value as string)
: String(testMessage.message);
this.codeEditorService.registerDecorationType(
'test-message-decoration',
this.decorationId,
{
after: {
contentText: message,
contentText,
color: `${testMessageSeverityColors[severity]}`,
fontSize: `${this.monacoEditor.getOption(EditorOption.fontSize)}px`,
fontFamily: `var(${FONT_FAMILY_VAR})`,
@ -357,7 +376,13 @@ class TestMessageDecoration implements ITestDecoration {
);
const options = this.codeEditorService.resolveDecorationOptions(this.decorationId, true);
options.hoverMessage = typeof message === 'string' ? new MarkdownString().appendText(message) : message;
// 使用 MarkdownString 来显示消息
options.hoverMessage =
typeof message === 'string'
? new MarkdownString().appendText(message)
: typeof testMessage.message === 'object' && 'value' in testMessage.message
? testMessage.message
: new MarkdownString().appendText(String(testMessage.message));
options.afterContentClassName = `${options.afterContentClassName} testing-inline-message-content`;
options.zIndex = 10;
options.className = `testing-inline-message-margin testing-inline-message-severity-${severity}`;

View File

@ -76,8 +76,11 @@ export const firstLine = (str: string) => {
const domParser = new DOMParser();
export const parseMarkdownText = (value: string) =>
domParser.parseFromString(parseMarkdown(value) || '', 'text/html').documentElement.outerText;
export const parseMarkdownText = async (value: string): Promise<string> => {
const result = parseMarkdown(value);
const html = typeof result === 'string' ? result : await result;
return domParser.parseFromString(html || '', 'text/html').documentElement.outerText;
};
export const isDiffable = (
message: ITestErrorMessage,

View File

@ -27,6 +27,7 @@ const ghostDepsWhiteLists = [
'sumi',
'vscode-textmate',
'react-window',
'marked',
'vscode-languageserver-types',
'react-is',
'ws',

View File

@ -3482,11 +3482,10 @@ __metadata:
"@opensumi/ide-utils": "workspace:*"
"@opensumi/react-custom-scrollbars-2": "npm:^4.3.4"
"@rc-component/mini-decimal": "npm:^1.0.1"
"@types/marked": "npm:^4.0.7"
"@types/react-window": "npm:^1.8.5"
fuzzy: "npm:^0.1.3"
lodash: "npm:^4.17.21"
marked: "npm:4.0.10"
marked: "npm:15.0.11"
prop-types: "npm:^15.8.1"
raf: "npm:^3.4.1"
rc-dialog: "npm:^9.6.0"
@ -3538,6 +3537,7 @@ __metadata:
jsonc-parser: "npm:^2.1.0"
keycode: "npm:^2.2.0"
lodash: "npm:^4.17.21"
marked: "npm:15.0.11"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-is: "npm:^18.2.0"
@ -5729,13 +5729,6 @@ __metadata:
languageName: node
linkType: hard
"@types/marked@npm:^4.0.7":
version: 4.3.2
resolution: "@types/marked@npm:4.3.2"
checksum: 10/c1b5aa2cee0b8929164f4a8d206d7f89256ebde0d5b9998e9cdf6da2a5fa71162f66ef30e80b98213ee2c3372514b39b756e8494980174a4907a4aa4690b4d1d
languageName: node
linkType: hard
"@types/mdurl@npm:*":
version: 2.0.0
resolution: "@types/mdurl@npm:2.0.0"
@ -16652,12 +16645,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:4.0.10":
version: 4.0.10
resolution: "marked@npm:4.0.10"
"marked@npm:15.0.11":
version: 15.0.11
resolution: "marked@npm:15.0.11"
bin:
marked: bin/marked.js
checksum: 10/764e49b246ccad0afe2eedf3c5074aafa7abd445c93cb13209b39bacdf9753ae7c333f35c7fafbcb68b20639711c34d74eb06b922ac71b82e440e72ce48d4dc0
checksum: 10/939e75f3e989ef4d72d6da9c7e80e43d49ffdc7af8ae2a38cc8c73f20a629d9659a0acda1d0cb5a5f90876cbfb1520d029f98790a934b46592dfa178fbd2838c
languageName: node
linkType: hard