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;
-}