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/eventMessage.spec.tsx b/static/app/components/events/eventMessage.spec.tsx
index 3ecfbd938fec..1a98ea84638f 100644
--- a/static/app/components/events/eventMessage.spec.tsx
+++ b/static/app/components/events/eventMessage.spec.tsx
@@ -33,4 +33,15 @@ describe('EventMessage', () => {
);
expect(screen.getByText('Unhandled')).toBeInTheDocument();
});
+
+ it('renders colored text without escape codes when given an ANSI message', () => {
+ const message = '\x1B[31mfailed\x1B[0m to connect';
+
+ render();
+
+ expect(screen.getByText('failed').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('failed').parentElement).toHaveTextContent(
+ /^failed to connect$/
+ );
+ });
});
diff --git a/static/app/components/events/eventMessage.tsx b/static/app/components/events/eventMessage.tsx
index c18c4ad7a579..5bacf6c80180 100644
--- a/static/app/components/events/eventMessage.tsx
+++ b/static/app/components/events/eventMessage.tsx
@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
+import {AnsiText} from 'sentry/components/ansiText';
import {ErrorLevel} from 'sentry/components/events/errorLevel';
import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag';
import {t} from 'sentry/locale';
@@ -24,7 +25,9 @@ export function EventMessage({
}: Props) {
const showEventLevel = level && eventTypeHasLogLevel(type);
const renderedMessage = message ? (
- {message}
+
+ {typeof message === 'string' ? {message} : message}
+
) : (
({t('No error message')})
);
diff --git a/static/app/components/groupTitle.spec.tsx b/static/app/components/groupTitle.spec.tsx
index 8ddc8cf37c04..017a32f88a1e 100644
--- a/static/app/components/groupTitle.spec.tsx
+++ b/static/app/components/groupTitle.spec.tsx
@@ -56,6 +56,21 @@ describe('GroupTitle', () => {
expect(screen.getByText('metadata title')).toBeInTheDocument();
});
+ it('renders colored text without escape codes when given an ANSI title', () => {
+ const type = '\x1B[31mValueError\x1B[0m raised';
+
+ render(
+
+ );
+
+ expect(screen.getByText('ValueError').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('ValueError').parentElement).toHaveTextContent(
+ /^ValueError raised$/
+ );
+ });
+
it('does not render stack trace when issueCategory is performance', () => {
render(
- {titleLabel}
+
+ {titleLabel}
+
) : (
- titleLabel
+ {titleLabel}
)}
);
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;
-}
diff --git a/static/app/views/issueDetails/actions/index.tsx b/static/app/views/issueDetails/actions/index.tsx
index 9024595ae706..e4584ff04388 100644
--- a/static/app/views/issueDetails/actions/index.tsx
+++ b/static/app/views/issueDetails/actions/index.tsx
@@ -41,6 +41,7 @@ import type {Group, GroupStatusResolution, MarkReviewed} from 'sentry/types/grou
import {GroupStatus, GroupSubstatus} from 'sentry/types/group';
import type {Project} from 'sentry/types/project';
import {trackAnalytics} from 'sentry/utils/analytics';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {getUtcDateString} from 'sentry/utils/dates';
import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction';
import {getAnalyticsDataForGroup, getMessage, getTitle} from 'sentry/utils/events';
@@ -204,9 +205,8 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp
const config = useMemo(() => getConfigForIssueType(group, project), [group, project]);
const issueCommandLabel = useMemo(() => {
- const {title: rawIssueTitle} = getTitle(group);
- const title = rawIssueTitle ?? '';
- const message = getMessage(group);
+ const title = stripAnsi(getTitle(group).title ?? '');
+ const message = stripAnsi(getMessage(group) ?? '');
return message && message !== title ? `${title}: ${message}` : title;
}, [group]);
diff --git a/static/app/views/issueDetails/groupDetails.spec.tsx b/static/app/views/issueDetails/groupDetails.spec.tsx
index 47f10cec44e5..3e03966f8d56 100644
--- a/static/app/views/issueDetails/groupDetails.spec.tsx
+++ b/static/app/views/issueDetails/groupDetails.spec.tsx
@@ -15,6 +15,7 @@ import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingL
import {setWindowLocation} from 'sentry-test/utils';
import {PageFiltersStore} from 'sentry/components/pageFilters/store';
+import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager';
import {ConfigStore} from 'sentry/stores/configStore';
import {GroupStore} from 'sentry/stores/groupStore';
import {OrganizationStore} from 'sentry/stores/organizationStore';
@@ -553,4 +554,33 @@ describe('groupDetails', () => {
// Verify that the hasSeen request was NOT made
expect(hasSeenMock).not.toHaveBeenCalled();
});
+
+ it('sets the document title without escape codes when given ANSI metadata', async () => {
+ MockApiClient.addMockResponse({
+ url: `/organizations/${defaultInit.organization.slug}/issues/${group.id}/`,
+ body: {
+ ...group,
+ metadata: {type: '\x1B[31mRequestError\x1B[0m', value: '\x1B[33mfailed\x1B[0m'},
+ },
+ });
+
+ setWindowLocation(`http://localhost/?project=${group.project.id}`);
+ render(
+
+
+
+
+ ,
+ {
+ organization: defaultInit.organization,
+ initialRouterConfig,
+ }
+ );
+
+ await waitFor(() =>
+ expect(document.title).toBe(
+ `RequestError: failed — ${defaultInit.organization.slug} — ${group.project.slug}`
+ )
+ );
+ });
});
diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx
index 371bb82d7e97..0bda09bfe946 100644
--- a/static/app/views/issueDetails/groupDetails.tsx
+++ b/static/app/views/issueDetails/groupDetails.tsx
@@ -26,6 +26,7 @@ import type {Group} from 'sentry/types/group';
import {GroupStatus} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {getUtcDateString} from 'sentry/utils/dates';
import {defined} from 'sentry/utils/defined';
import {
@@ -838,8 +839,8 @@ function GroupDetails() {
return defaultTitle;
}
- const {title} = getTitle(group);
- const message = getMessage(group);
+ const title = stripAnsi(getTitle(group).title ?? '');
+ const message = stripAnsi(getMessage(group) ?? '');
const eventDetails = `${organization.slug} — ${group.project.slug}`;
diff --git a/static/app/views/issueDetails/header/header.spec.tsx b/static/app/views/issueDetails/header/header.spec.tsx
index 80a4c83ca7ba..223e16f73b00 100644
--- a/static/app/views/issueDetails/header/header.spec.tsx
+++ b/static/app/views/issueDetails/header/header.spec.tsx
@@ -119,6 +119,39 @@ describe('GroupHeader', () => {
expect(screen.getByText(group.shortId)).toBeInTheDocument();
});
+ it('renders colored title and message without escape codes when given ANSI metadata', async () => {
+ const ansiGroup = GroupFixture({
+ ...group,
+ metadata: {
+ type: '\x1B[31mRequestError\x1B[0m',
+ value: '\x1B[33mGET\x1B[0m /api failed',
+ },
+ });
+
+ render(
+
+
+ ,
+ {
+ organization,
+ }
+ );
+
+ expect(await screen.findByRole('link', {name: 'View events'})).toBeInTheDocument();
+ expect(screen.getByText('RequestError').style.color).toContain(
+ 'color-mix(in srgb,'
+ );
+ expect(screen.getByText('GET').style.color).toContain('color-mix(in srgb,');
+ expect(screen.getByText('GET').parentElement).toHaveTextContent(
+ /^GET \/api failed$/
+ );
+ });
+
it('displays share icon if issue has been shared', async () => {
render(
MAX_PICKABLE_DAYS);
const maxRetentionDays = useGetMaxRetentionDays(); // oxlint-disable-line react/hooks -- Hook comes from the override registry, which is populated before React renders.
const userCountPeriod = maxRetentionDays ? `(${maxRetentionDays}d)` : '(30d)';
- const {title: primaryTitle} = getTitle(group);
+ const {title: primaryTitle = ''} = getTitle(group);
const secondaryTitle = getMessage(group);
const isComplete = group.status === 'resolved' || group.status === 'ignored';
const groupReprocessingStatus = getGroupReprocessingStatus(group);
@@ -114,8 +116,15 @@ export function GroupHeader({event, group, project}: GroupHeaderProps) {
-
- {primaryTitle}
+
+
+ {primaryTitle}
+
{isAIDetectedIssue && }
diff --git a/static/app/views/issueList/pages/inbox/index.tsx b/static/app/views/issueList/pages/inbox/index.tsx
index a5b940605138..cc62657be15e 100644
--- a/static/app/views/issueList/pages/inbox/index.tsx
+++ b/static/app/views/issueList/pages/inbox/index.tsx
@@ -23,6 +23,7 @@ import {SegmentedControl} from '@sentry/scraps/segmentedControl';
import {StatusIndicator} from '@sentry/scraps/statusIndicator';
import {Heading, Text} from '@sentry/scraps/text';
+import {AnsiText} from 'sentry/components/ansiText';
import {NotFound} from 'sentry/components/errors/notFound';
import {EventMessage} from 'sentry/components/events/eventMessage';
import {
@@ -705,7 +706,7 @@ function InboxIssueCard({
}) {
const location = useLocation();
const organization = useOrganization();
- const {title} = getTitle(group);
+ const {title = ''} = getTitle(group);
const message = getMessage(group);
const prefetchHoverProps = useInboxPreviewPrefetch(group);
const suggestedAssignees = useIssueSuggestedAssignees(group);
@@ -751,7 +752,7 @@ function InboxIssueCard({
- {title}
+ {title}
{showPullRequests ? (
diff --git a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx
index 43eae45cbab7..b67538d35969 100644
--- a/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx
+++ b/static/app/views/issueList/pages/inbox/issuePreview/issuePreview.tsx
@@ -7,6 +7,7 @@ import {Heading} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';
import {AnalyticsArea} from 'sentry/components/analyticsArea';
+import {AnsiText} from 'sentry/components/ansiText';
import {ErrorBoundary} from 'sentry/components/errorBoundary';
import {EventMessage} from 'sentry/components/events/eventMessage';
import {
@@ -20,6 +21,7 @@ import {IconOpen} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {Group} from 'sentry/types/group';
import {trackAnalytics} from 'sentry/utils/analytics';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {getAnalyticsDataForGroup, getMessage, getTitle} from 'sentry/utils/events';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useNavigate} from 'sentry/utils/useNavigate';
@@ -154,7 +156,7 @@ function IssuePreviewContent() {
const {group, project} = useGroupData();
const previewSeer = useIssuePreviewSeer();
const linkedPullRequests = useLinkedPullRequests({group});
- const {title: primaryTitle} = getTitle(group);
+ const {title: primaryTitle = ''} = getTitle(group);
const secondaryTitle = getMessage(group);
const disableActions = [
ReprocessingStatus.REPROCESSING,
@@ -187,7 +189,12 @@ function IssuePreviewContent() {
-
+
- {primaryTitle}
+ {primaryTitle}