diff --git a/package.json b/package.json index f1e81ed9fccf..ed346fbada7c 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "@types/reflux": "0.4.1", "@types/trusted-types": "^2.0.7", "ansi-sequence-parser": "^1.1.3", - "ansi-to-react": "^6.1.6", "base64-arraybuffer": "^1.0.1", "cbor2": "^1.12.0", "classnames": "2.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0634b405d57..6345fcfa8d29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,9 +317,6 @@ importers: ansi-sequence-parser: specifier: ^1.1.3 version: 1.1.3 - ansi-to-react: - specifier: ^6.1.6 - version: 6.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) base64-arraybuffer: specifier: ^1.0.1 version: 1.0.2 @@ -4766,9 +4763,6 @@ packages: algoliasearch@4.13.1: resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==} - anser@1.4.10: - resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} - ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -4799,12 +4793,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansi-to-react@6.1.6: - resolution: {integrity: sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==} - peerDependencies: - react: ^16.3.2 || ^17.0.0 - react-dom: ^16.3.2 || ^17.0.0 - anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -5775,9 +5763,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-carriage@1.3.0: - resolution: {integrity: sha512-ATWi5MD8QlAGQOeMgI8zTp671BG8aKvAC0M7yenlxU4CRLGO/sKthxVUyjiOFKjHdIo+6dZZUNFgHFeVEaKfGQ==} - escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -13143,8 +13128,6 @@ snapshots: '@algolia/requester-node-http': 4.13.1 '@algolia/transporter': 4.13.1 - anser@1.4.10: {} - ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -13167,13 +13150,6 @@ snapshots: ansi-styles@6.2.3: {} - ansi-to-react@6.1.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - anser: 1.4.10 - escape-carriage: 1.3.0 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -14308,8 +14284,6 @@ snapshots: escalade@3.2.0: {} - escape-carriage@1.3.0: {} - escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} diff --git a/static/app/components/ansiText.spec.tsx b/static/app/components/ansiText.spec.tsx new file mode 100644 index 000000000000..1e63195d4bb8 --- /dev/null +++ b/static/app/components/ansiText.spec.tsx @@ -0,0 +1,122 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {AnsiText} from 'sentry/components/ansiText'; + +function renderSegments(text: string) { + render({text}); + + const wrapper = screen.getByText('FATAL').parentElement; + + return { + segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({ + text: span.textContent, + colored: span.style.color !== '', + })), + wrapper, + }; +} + +describe('AnsiText', () => { + it('renders text without escape codes when given ANSI colors', () => { + const {container} = render({'\x1B[31mfailed\x1B[0m to connect'}); + + expect(container).toHaveTextContent(/^failed to connect$/); + }); + + it('blends ANSI colors into the text and background colors when given colored segments', () => { + render({'\x1B[31;44mcolored'}); + + const segment = screen.getByText('colored'); + expect(segment.style.color).toContain('color-mix(in srgb,'); + expect(segment.style.backgroundColor).toContain('color-mix(in srgb,'); + }); + + it('resolves 256-color and truecolor codes to their RGB values', () => { + render( + {'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'} + ); + + expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)'); + expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)'); + }); + + it('applies every decoration when given combined decoration codes', () => { + render({'\x1B[1;3;4;9mdecorated'}); + + expect(screen.getByText('decorated')).toHaveStyle({ + fontWeight: 'bold', + fontStyle: 'italic', + textDecorationLine: 'underline line-through', + }); + }); + + it('removes non-color escape codes when given cursor control sequences', () => { + const {container} = render({'\x1B[2Kdone\x1B[?25h'}); + + expect(container).toHaveTextContent(/^done$/); + }); + + it('colors only the escaped text when given uncolored spaces around a colored word', () => { + const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors a trailing space when given the space inside the escape codes', () => { + const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL ', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors a leading space when given the space inside the escape codes', () => { + const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: ' FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors only the escaped word when given uncolored text before it and a space after it', () => { + const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m '); + + expect(segments).toEqual([ + {text: 'ABC', colored: false}, + {text: 'FATAL', colored: true}, + {text: ' ', colored: false}, + ]); + }); + + it('colors only the escaped word when given a space before it and uncolored text after it', () => { + const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC'); + + expect(segments).toEqual([ + {text: ' ', colored: false}, + {text: 'FATAL', colored: true}, + {text: 'ABC', colored: false}, + ]); + }); + + it('collapses whitespace by default when given escape codes', () => { + const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); + + expect(wrapper).not.toHaveStyle({whiteSpaceCollapse: 'preserve'}); + }); + + it('preserves whitespace when given escape codes and preserveWhitespace', () => { + render({' \x1B[41m FATAL \x1B[0m '}); + + expect(screen.getByText('FATAL').parentElement).toHaveStyle({ + whiteSpaceCollapse: 'preserve', + }); + }); +}); diff --git a/static/app/components/ansiText.tsx b/static/app/components/ansiText.tsx new file mode 100644 index 000000000000..531e1c1578f4 --- /dev/null +++ b/static/app/components/ansiText.tsx @@ -0,0 +1,92 @@ +import {useMemo, type CSSProperties, type ReactNode} from 'react'; +import {useTheme} from '@emotion/react'; +import { + createColorPalette, + parseAnsiSequences, + type ParseToken, +} from 'ansi-sequence-parser'; + +import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; + +const ANSI_BACKGROUND_STRENGTH = '15%'; +const ANSI_TEXT_STRENGTH = '50%'; + +const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'}; + +type ColorPalette = ReturnType; + +interface AnsiTextProps { + children: string; + preserveWhitespace?: boolean; + renderText?: (text: string) => ReactNode; +} + +export function AnsiText({ + children, + preserveWhitespace, + renderText = text => text, +}: AnsiTextProps) { + const theme = useTheme(); + const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]); + const tokens = useMemo( + () => + hasAnsi(children) + ? parseAnsiSequences(children) + .map(token => ({...token, value: stripAnsi(token.value)})) + .filter(token => token.value) + : undefined, + [children] + ); + + if (!tokens) { + return renderText(children); + } + + return ( + + {tokens.map((token, index) => ( + + {renderText(token.value)} + + ))} + + ); +} + +function getTokenStyle( + {background, decorations, foreground}: ParseToken, + palette: ColorPalette +): CSSProperties { + const style: CSSProperties = {}; + + if (foreground) { + style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`; + } + + if (background) { + style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`; + } + + if (decorations.has('bold')) { + style.fontWeight = 'bold'; + } + + if (decorations.has('dim')) { + style.opacity = 0.7; + } + + if (decorations.has('italic')) { + style.fontStyle = 'italic'; + } + + const lines = [ + decorations.has('underline') && 'underline', + decorations.has('strikethrough') && 'line-through', + decorations.has('overline') && 'overline', + ].filter(Boolean); + if (lines.length) { + style.textDecorationLine = lines.join(' '); + } + + return style; +} diff --git a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx index 753f4ff9b9b0..2e187e288d36 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx @@ -58,6 +58,31 @@ describe('LogFileViewer', () => { ); }); + it('renders colored text without escape codes when given ANSI colors', async () => { + fetchMock.route( + attachmentUrl, + fetchMock.Response(new TextEncoder().encode('\x1B[31mfailed\x1B[0m to connect')) + ); + + render(); + + expect((await screen.findByText('failed')).style.color).toContain( + 'color-mix(in srgb,' + ); + expect(screen.getByText('to connect')).toBeInTheDocument(); + }); + + it('renders only the final write when given carriage-return progress updates', async () => { + fetchMock.route( + attachmentUrl, + fetchMock.Response(new TextEncoder().encode('progress 10%\rprogress 100%')) + ); + + render(); + + expect(await screen.findByText('progress 100%')).toBeInTheDocument(); + }); + it('renders an error when the attachment cannot be downloaded', async () => { fetchMock.route(attachmentUrl, '', {status: 404}); diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx index e697b8ee35bc..6cf43cbe008f 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx @@ -1,14 +1,14 @@ -import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; -import Ansi from 'ansi-to-react'; +import {AnsiText} from 'sentry/components/ansiText'; import {PreviewPanelItem} from 'sentry/components/events/attachmentViewers/previewPanelItem'; import type {ViewerProps} from 'sentry/components/events/attachmentViewers/utils'; import {getAttachmentUrl} from 'sentry/components/events/attachmentViewers/utils'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; +import {applyCarriageReturns} from 'sentry/utils/ansiEscapeCodes'; import {resolveHostname} from 'sentry/utils/api/resolveHostname'; import {RequestError} from 'sentry/utils/requestError/requestError'; @@ -39,7 +39,7 @@ export function LogFileViewer(props: ViewerProps) { ); } - return decodeTextAttachment(await response.arrayBuffer()); + return applyCarriageReturns(decodeTextAttachment(await response.arrayBuffer())); }, retry: false, staleTime: Infinity, @@ -56,7 +56,7 @@ export function LogFileViewer(props: ViewerProps) { return data ? ( - {data} + {data} ) : null; @@ -74,44 +74,6 @@ function decodeTextAttachment(buffer: ArrayBuffer): string { return new TextDecoder(encoding).decode(buffer); } -/** - * Maps ANSI color names -> theme.tsx color names - */ -const COLOR_MAP = { - red: 'red', - green: 'green', - blue: 'blue', - yellow: 'yellow', - magenta: 'pink', - cyan: 'blue', -} as const; - -const SentryStyleAnsi = styled(Ansi)` - ${p => - Object.entries(COLOR_MAP).map( - ([ansiColor, themeColor]) => css` - .ansi-${ansiColor}-bg { - background-color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}500`]}; - } - .ansi-bright-${ansiColor}-fg { - color: ${p.theme.colors[`${themeColor}200`]}; - } - ` - )} - - .ansi-black-fg, - .ansi-bright-black-fg { - color: ${p => p.theme.colors.black}; - } - .ansi-white-fg, - .ansi-bright-white-fg { - color: ${p => p.theme.colors.white}; - } -`; - const CodeWrapper = styled('pre')` padding: ${p => p.theme.space.md} ${p => p.theme.space.xl}; width: 100%; diff --git a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx index d513a39faef1..e46bec80a9bf 100644 --- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.spec.tsx @@ -76,6 +76,21 @@ describe('ExceptionGroupContext', () => { ).toBeInTheDocument(); }); + it('renders colored text without escape codes when given ANSI exception values', () => { + const allExceptions = defaultProps.allExceptions.map(exception => + exception.type === 'TypeError' + ? {...exception, value: '\x1B[31mnested\x1B[0m failure'} + : exception + ); + + render(); + + expect( + screen.getByRole('button', {name: 'TypeError: nested failure'}) + ).toBeInTheDocument(); + expect(screen.getByText('nested').style.color).toContain('color-mix(in srgb,'); + }); + it('does not render for sub-exception', () => { const {container} = render( diff --git a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx index fbbaf4bad3ee..30f991300b7d 100644 --- a/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/relatedExceptions.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; +import {AnsiText} from 'sentry/components/ansiText'; import {t} from 'sentry/locale'; import type {ExceptionValue} from 'sentry/types/event'; import type {StackTraceMechanism} from 'sentry/types/stacktrace'; @@ -38,10 +39,20 @@ type ExceptionTreeItemProps = { function getExceptionName(exception: ExceptionValue) { if (exception.type) { - return exception.value ? `${exception.type}: ${exception.value}` : exception.type; + return exception.value ? ( + + {exception.type}: {exception.value} + + ) : ( + exception.type + ); } - return exception.value ?? t('Exception'); + return defined(exception.value) ? ( + {exception.value} + ) : ( + t('Exception') + ); } function ExceptionLink({exception, link, onExceptionClick}: ExceptionLinkProps) { diff --git a/static/app/components/stackTrace/exceptionGroup.spec.tsx b/static/app/components/stackTrace/exceptionGroup.spec.tsx index d6abc9b8d9f3..aa7196cbcd9e 100644 --- a/static/app/components/stackTrace/exceptionGroup.spec.tsx +++ b/static/app/components/stackTrace/exceptionGroup.spec.tsx @@ -137,4 +137,34 @@ describe('exceptionGroup', () => { expect(screen.getByTestId('exc-3')).toBeInTheDocument(); expect(screen.getByTestId('exc-4')).toBeInTheDocument(); }); + + it('renders colored text without escape codes when given ANSI exception values', () => { + const values = makeValues().map(exception => { + if (exception.type === 'ValueError') { + return {...exception, value: '\x1B[31mbad\x1B[0m value'}; + } + if (exception.mechanism?.exception_id === 0) { + return {...exception, value: '\x1B[32mroot\x1B[0m group'}; + } + return exception; + }); + + render( + + ); + + expect( + screen.getByRole('button', {name: 'ValueError: bad value'}) + ).toBeInTheDocument(); + expect(screen.getByText('bad').style.color).toContain('color-mix(in srgb,'); + expect(screen.getByText('root').closest('div')).toHaveTextContent( + /^ExceptionGroup: root group$/ + ); + expect(screen.getByText('root').style.color).toContain('color-mix(in srgb,'); + }); }); diff --git a/static/app/components/stackTrace/exceptionGroup.tsx b/static/app/components/stackTrace/exceptionGroup.tsx index aa80b52d0c6b..7b8439abd016 100644 --- a/static/app/components/stackTrace/exceptionGroup.tsx +++ b/static/app/components/stackTrace/exceptionGroup.tsx @@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button'; import {Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; +import {AnsiText} from 'sentry/components/ansiText'; import {t, tn} from 'sentry/locale'; import type {ExceptionValue} from 'sentry/types/event'; import {defined} from 'sentry/utils/defined'; @@ -59,9 +60,19 @@ export function useHiddenExceptions(values: ExceptionValue[]) { function getExceptionName(exception: ExceptionValue) { if (exception.type) { - return exception.value ? `${exception.type}: ${exception.value}` : exception.type; + return exception.value ? ( + + {exception.type}: {exception.value} + + ) : ( + exception.type + ); } - return exception.value ?? t('Exception'); + return defined(exception.value) ? ( + {exception.value} + ) : ( + t('Exception') + ); } interface ToggleRelatedExceptionsButtonProps { diff --git a/static/app/utils/ansiEscapeCodes.spec.tsx b/static/app/utils/ansiEscapeCodes.spec.tsx index 79bbad6f5b6f..6b4795b59b3f 100644 --- a/static/app/utils/ansiEscapeCodes.spec.tsx +++ b/static/app/utils/ansiEscapeCodes.spec.tsx @@ -1,4 +1,4 @@ -import {stripAnsi} from 'sentry/utils/ansiEscapeCodes'; +import {applyCarriageReturns, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; describe('ansiEscapeCodes', () => { it('removes ANSI color codes', () => { @@ -25,3 +25,13 @@ describe('ansiEscapeCodes', () => { expect(stripAnsi(input)).toBe('Hello World!'); }); }); + +describe('applyCarriageReturns', () => { + it('keeps only the final write when given carriage-return progress updates', () => { + expect(applyCarriageReturns('10%\r50%\r100%\ndone')).toBe('100%\ndone'); + }); + + it('keeps line endings when given CRLF text', () => { + expect(applyCarriageReturns('first\r\nsecond\r\n')).toBe('first\r\nsecond\r\n'); + }); +}); diff --git a/static/app/utils/ansiEscapeCodes.tsx b/static/app/utils/ansiEscapeCodes.tsx index 6484577a8e48..201dc788564d 100644 --- a/static/app/utils/ansiEscapeCodes.tsx +++ b/static/app/utils/ansiEscapeCodes.tsx @@ -12,3 +12,7 @@ export function stripAnsi(input: string): string { export function hasAnsi(input: string): boolean { return input.includes('\x1B'); } + +export function applyCarriageReturns(input: string): string { + return input.replace(/^.*\r(?!$)/gm, ''); +} diff --git a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx index 1e9fe7f30e07..1acdad51579f 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.spec.tsx @@ -2,35 +2,11 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {LogsAnsiHighlight} from 'sentry/views/explore/logs/logsAnsiHighlight'; -function renderSegments(text: string) { - render({text}); - - const wrapper = screen.getByText('FATAL').parentElement; - - return { - segments: Array.from(wrapper?.querySelectorAll('span') ?? [], span => ({ - text: span.textContent, - colored: span.style.color !== '', - })), - wrapper, - }; -} - describe('LogsAnsiHighlight', () => { - it('renders text without escape codes when given ANSI colors', () => { - const {container} = render( - {'\x1B[31mfailed\x1B[0m to connect'} - ); + it('highlights search terms when given plain text', () => { + render(failed to connect); - expect(container).toHaveTextContent(/^failed to connect$/); - }); - - it('blends ANSI colors into the text and background colors when given colored segments', () => { - render({'\x1B[31;44mcolored'}); - - const segment = screen.getByText('colored'); - expect(segment.style.color).toContain('color-mix(in srgb,'); - expect(segment.style.backgroundColor).toContain('color-mix(in srgb,'); + expect(screen.getByText('connect').tagName).toBe('SPAN'); }); it('highlights search terms inside colored segments', () => { @@ -43,88 +19,13 @@ describe('LogsAnsiHighlight', () => { expect(screen.getByText('connect').tagName).toBe('SPAN'); }); - it('resolves 256-color and truecolor codes to their RGB values', () => { + it('preserves whitespace when given colored segments', () => { render( - - {'\x1B[38;5;208mpalette\x1B[0m \x1B[38;2;1;2;3mtruecolor'} - + {'\x1B[31m failed\x1B[0m to connect'} ); - expect(screen.getByText('palette').style.color).toContain('rgb(255, 135, 0)'); - expect(screen.getByText('truecolor').style.color).toContain('rgb(1, 2, 3)'); - }); - - it('applies every decoration when given combined decoration codes', () => { - render({'\x1B[1;3;4;9mdecorated'}); - - expect(screen.getByText('decorated')).toHaveStyle({ - fontWeight: 'bold', - fontStyle: 'italic', - textDecorationLine: 'underline line-through', + expect(screen.getByText('failed').parentElement).toHaveStyle({ + whiteSpaceCollapse: 'preserve', }); }); - - it('removes non-color escape codes when given cursor control sequences', () => { - const {container} = render( - {'\x1B[2Kdone\x1B[?25h'} - ); - - expect(container).toHaveTextContent(/^done$/); - }); - - it('colors only the escaped text when given uncolored spaces around a colored word', () => { - const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors a trailing space when given the space inside the escape codes', () => { - const {segments} = renderSegments(' \x1B[31mFATAL \x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL ', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors a leading space when given the space inside the escape codes', () => { - const {segments} = renderSegments(' \x1B[31m FATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: ' FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors only the escaped word when given uncolored text before it and a space after it', () => { - const {segments} = renderSegments('ABC\x1B[31mFATAL\x1B[0m '); - - expect(segments).toEqual([ - {text: 'ABC', colored: false}, - {text: 'FATAL', colored: true}, - {text: ' ', colored: false}, - ]); - }); - - it('colors only the escaped word when given a space before it and uncolored text after it', () => { - const {segments} = renderSegments(' \x1B[31mFATAL\x1B[0mABC'); - - expect(segments).toEqual([ - {text: ' ', colored: false}, - {text: 'FATAL', colored: true}, - {text: 'ABC', colored: false}, - ]); - }); - - it('preserves whitespace when given escape codes', () => { - const {wrapper} = renderSegments(' \x1B[41m FATAL \x1B[0m '); - - expect(wrapper).toHaveStyle({whiteSpaceCollapse: 'preserve'}); - }); }); diff --git a/static/app/views/explore/logs/logsAnsiHighlight.tsx b/static/app/views/explore/logs/logsAnsiHighlight.tsx index 0fe7b790a4b3..144145ddf7af 100644 --- a/static/app/views/explore/logs/logsAnsiHighlight.tsx +++ b/static/app/views/explore/logs/logsAnsiHighlight.tsx @@ -1,21 +1,6 @@ -import {useMemo, type CSSProperties} from 'react'; -import {useTheme} from '@emotion/react'; -import { - createColorPalette, - parseAnsiSequences, - type ParseToken, -} from 'ansi-sequence-parser'; - -import {hasAnsi, stripAnsi} from 'sentry/utils/ansiEscapeCodes'; +import {AnsiText} from 'sentry/components/ansiText'; import {LogsHighlight} from 'sentry/views/explore/logs/styles'; -const ANSI_BACKGROUND_STRENGTH = '15%'; -const ANSI_TEXT_STRENGTH = '50%'; - -const PRESERVE_WHITESPACE_STYLE: CSSProperties = {whiteSpaceCollapse: 'preserve'}; - -type ColorPalette = ReturnType; - interface LogsAnsiHighlightProps { children: string; caseSensitive?: boolean; @@ -27,73 +12,16 @@ export function LogsAnsiHighlight({ children, terms = [], }: LogsAnsiHighlightProps) { - const theme = useTheme(); - const palette = useMemo(() => createColorPalette(theme.tokens.syntax.ansi), [theme]); - const tokens = useMemo( - () => - hasAnsi(children) - ? parseAnsiSequences(children) - .map(token => ({...token, value: stripAnsi(token.value)})) - .filter(token => token.value) - : undefined, - [children] - ); - - if (!tokens) { - return ( - - {children} - - ); - } - return ( - - {tokens.map((token, index) => ( - - - {token.value} - - - ))} - + ( + + {text} + + )} + > + {children} + ); } - -function getTokenStyle( - {background, decorations, foreground}: ParseToken, - palette: ColorPalette -): CSSProperties { - const style: CSSProperties = {}; - - if (foreground) { - style.color = `color-mix(in srgb, ${palette.value(foreground)} ${ANSI_TEXT_STRENGTH}, currentColor)`; - } - - if (background) { - style.backgroundColor = `color-mix(in srgb, ${palette.value(background)} ${ANSI_BACKGROUND_STRENGTH}, transparent)`; - } - - if (decorations.has('bold')) { - style.fontWeight = 'bold'; - } - - if (decorations.has('dim')) { - style.opacity = 0.7; - } - - if (decorations.has('italic')) { - style.fontStyle = 'italic'; - } - - const lines = [ - decorations.has('underline') && 'underline', - decorations.has('strikethrough') && 'line-through', - decorations.has('overline') && 'overline', - ].filter(Boolean); - if (lines.length) { - style.textDecorationLine = lines.join(' '); - } - - return style; -}