diff --git a/package.json b/package.json
index 06bc2427081e..c7c88de59389 100644
--- a/package.json
+++ b/package.json
@@ -149,7 +149,7 @@
"@types/reflux": "0.4.1",
"@types/scroll-to-element": "^2.0.2",
"@types/webpack-env": "1.18.8",
- "ansi-to-react": "^6.1.6",
+ "anser": "2.3.5",
"base64-arraybuffer": "^1.0.1",
"buffer": "^6.0.3",
"cbor2": "^1.12.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d80432c215e0..07dea3b616a0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -310,9 +310,9 @@ importers:
'@types/webpack-env':
specifier: 1.18.8
version: 1.18.8
- ansi-to-react:
- specifier: ^6.1.6
- version: 6.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ anser:
+ specifier: 2.3.5
+ version: 2.3.5
base64-arraybuffer:
specifier: ^1.0.1
version: 1.0.2
@@ -4289,8 +4289,8 @@ packages:
algoliasearch@4.13.1:
resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==}
- anser@1.4.10:
- resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
+ anser@2.3.5:
+ resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==}
ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@@ -4319,12 +4319,6 @@ packages:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
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'}
@@ -5253,9 +5247,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@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@@ -13148,7 +13139,7 @@ snapshots:
'@algolia/requester-node-http': 4.13.1
'@algolia/transporter': 4.13.1
- anser@1.4.10: {}
+ anser@2.3.5: {}
ansi-align@3.0.1:
dependencies:
@@ -13170,13 +13161,6 @@ snapshots:
ansi-styles@6.2.1: {}
- ansi-to-react@6.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
- dependencies:
- anser: 1.4.10
- escape-carriage: 1.3.0
- react: 19.2.3
- react-dom: 19.2.3(react@19.2.3)
-
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
@@ -14258,8 +14242,6 @@ snapshots:
escalade@3.2.0: {}
- escape-carriage@1.3.0: {}
-
escape-string-regexp@1.0.5: {}
escape-string-regexp@2.0.0: {}
diff --git a/static/app/components/ansiText/ansiToReact.tsx b/static/app/components/ansiText/ansiToReact.tsx
new file mode 100644
index 000000000000..657b9f6c9859
--- /dev/null
+++ b/static/app/components/ansiText/ansiToReact.tsx
@@ -0,0 +1,266 @@
+/**
+ * This is vendored from ansi-to-react v6.2.6
+ *
+ * The link rendering was changed to use Sentry's external-link modal.
+ *
+ * Copyright (c) 2016, nteract contributors
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. Neither the name of nteract nor the names of its contributors may be used
+ * to endorse or promote products derived from this software without specific
+ * prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+import type {CSSProperties, ReactElement, ReactNode} from 'react';
+import {Fragment, useMemo} from 'react';
+import {type Theme, useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import Anser from 'anser';
+import type {AnserJsonEntry} from 'anser';
+
+import {ExternalLink} from '@sentry/scraps/link';
+
+import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
+import {IconOpen} from 'sentry/icons';
+import {isValidUrl} from 'sentry/utils/string/isValidUrl';
+
+type AnsiToReactProps = {
+ text: string;
+};
+
+const ESCAPE_CHARACTER = '\u001B';
+
+export function AnsiToReact({text}: AnsiToReactProps): ReactElement {
+ if (!text.includes(ESCAPE_CHARACTER)) {
+ return {renderLinksInPlainText(text, 'plain')};
+ }
+
+ return ;
+}
+
+function AnsiSpans({text}: AnsiToReactProps): ReactElement {
+ const theme = useTheme();
+ const bundles = useMemo(
+ () =>
+ Anser.ansiToJson(text, {
+ json: true,
+ remove_empty: true,
+ use_classes: true,
+ }),
+ [text]
+ );
+
+ return (
+
+ {bundles.map((bundle, index) => (
+
+ {renderLinksInPlainText(bundle.content, `ansi-${index}`)}
+
+ ))}
+
+ );
+}
+
+// https?: Matches both "http" and "https"
+// :\/\/: This is a literal match for "://"
+// (?:www\.)?: Matches URLs with or without "www."
+// [-a-zA-Z0-9@:%._\+~#=]{1,256}: Matches the domain name
+// It allows for a range of characters (letters, digits, and special characters)
+// The {1,256} specifies that these characters can occur anywhere from 1 to 256 times, which covers the range of typical domain name lengths
+// \.: Matches the dot before the top-level domain (like ".com")
+// [a-zA-Z0-9]{1,6}: Matches the top-level domain (like "com" or "org"). It's limited to letters and digits and can be between 1 and 6 characters long
+// (?:[-a-zA-Z0-9@:%_\+~#?&\/=,\[\].]*[-a-zA-Z0-9@:%_\+~#?&\/=,\[\]])?: Matches the path, query parameters, or fragments that can follow the domain in a URL
+// It now includes periods within the character set to allow for file extensions (e.g., ".html") and other period-containing segments in the URL path
+// This pattern matches a wide range of characters typically found in paths, query strings, and fragments, including periods
+// The final character set ensures that the URL ends with a character typically allowed in a path, query string, or fragment, excluding special characters like a trailing period not part of the URL
+// /gi: The regex will match all occurrences in the string, not just the first one
+// The "i" modifier makes the regex match both upper and lower case characters
+// Links are rendered as React nodes so ANSI text never needs HTML injection.
+const URL_REGEX =
+ /https?:\/\/(?:www\.)?[-\w@:%.+~#=]{1,256}\.[a-z0-9]{1,6}(?:[-\w@:%+~#?&/=,[\].]*[-\w@:%+~#?&/=,[\]])?/gi;
+
+function renderLinksInPlainText(text: string, keyPrefix: string): ReactNode {
+ if (!text.includes('http://') && !text.includes('https://')) {
+ return text;
+ }
+
+ const elements: ReactNode[] = [];
+ let lastIndex = 0;
+ let hasLink = false;
+
+ for (const match of text.matchAll(URL_REGEX)) {
+ const url = match[0];
+ const index = match.index ?? 0;
+ hasLink = true;
+
+ if (index > lastIndex) {
+ elements.push(
+
+ {text.slice(lastIndex, index)}
+
+ );
+ }
+
+ if (isValidUrl(url)) {
+ elements.push(
+ {
+ event.preventDefault();
+ openNavigateToExternalLinkModal({linkText: url});
+ }}
+ >
+ {url}
+
+
+ );
+ } else {
+ elements.push({url});
+ }
+
+ lastIndex = index + url.length;
+ }
+
+ if (!hasLink) {
+ return text;
+ }
+
+ if (lastIndex < text.length) {
+ elements.push(
+ {text.slice(lastIndex)}
+ );
+ }
+
+ return elements;
+}
+
+function getAnsiStyle(bundle: AnserJsonEntry, theme: Theme): CSSProperties | undefined {
+ const style: CSSProperties = {};
+ const color = getAnsiColor(bundle.fg, bundle.fg_truecolor, theme);
+ const backgroundColor = getAnsiColor(bundle.bg, bundle.bg_truecolor, theme);
+
+ if (bundle.decoration === 'reverse') {
+ if (backgroundColor) {
+ style.color = backgroundColor;
+ }
+ if (color) {
+ style.backgroundColor = color;
+ }
+ } else {
+ if (color) {
+ style.color = color;
+ }
+ if (backgroundColor) {
+ style.backgroundColor = backgroundColor;
+ }
+ }
+
+ switch (bundle.decoration) {
+ case 'bold':
+ style.fontWeight = 'bold';
+ break;
+ case 'dim':
+ style.opacity = 0.65;
+ break;
+ case 'italic':
+ style.fontStyle = 'italic';
+ break;
+ case 'underline':
+ style.textDecorationLine = 'underline';
+ break;
+ case 'hidden':
+ style.visibility = 'hidden';
+ break;
+ case 'strikethrough':
+ style.textDecorationLine = 'line-through';
+ break;
+ case 'blink':
+ case 'reverse':
+ case null:
+ break;
+ default:
+ break;
+ }
+
+ return Object.keys(style).length > 0 ? style : undefined;
+}
+
+function getAnsiColor(
+ colorClass: string | null,
+ trueColor: string | null,
+ theme: Theme
+): string | undefined {
+ if (!colorClass) {
+ return undefined;
+ }
+
+ if (colorClass === 'ansi-truecolor' && trueColor) {
+ return `rgb(${trueColor})`;
+ }
+
+ if (colorClass.startsWith('ansi-bright-')) {
+ return getThemeAnsiColor(colorClass.replace('ansi-bright-', ''), theme, true);
+ }
+
+ if (colorClass.startsWith('ansi-')) {
+ return getThemeAnsiColor(colorClass.replace('ansi-', ''), theme);
+ }
+
+ return undefined;
+}
+
+function getThemeAnsiColor(
+ colorName: string,
+ theme: Theme,
+ bright = false
+): string | undefined {
+ if (colorName === 'black' || colorName === 'white') {
+ return theme.colors[colorName];
+ }
+
+ const themeColorName = COLOR_MAP[colorName as keyof typeof COLOR_MAP];
+ if (!themeColorName) {
+ return undefined;
+ }
+
+ const themeColorWeight = bright ? '200' : '500';
+ return theme.colors[`${themeColorName}${themeColorWeight}`];
+}
+
+/**
+ * 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 IconPlacement = styled(IconOpen)`
+ display: inline-block;
+ margin-left: 5px;
+ vertical-align: center;
+`;
diff --git a/static/app/components/ansiText/index.spec.tsx b/static/app/components/ansiText/index.spec.tsx
new file mode 100644
index 000000000000..cbe26881b25b
--- /dev/null
+++ b/static/app/components/ansiText/index.spec.tsx
@@ -0,0 +1,94 @@
+import {ThemeFixture} from 'sentry-fixture/theme';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {AnsiText} from 'sentry/components/ansiText';
+
+describe('AnsiText', () => {
+ it('renders ANSI standard and bright colors without escape codes', () => {
+ render();
+
+ expect(screen.getByText('red')).toHaveStyle({color: ThemeFixture().colors.red500});
+ expect(screen.getByText('bright')).toHaveStyle({
+ color: ThemeFixture().colors.red200,
+ });
+ expect(screen.queryByText(text => text.includes('\u001B'))).not.toBeInTheDocument();
+ });
+
+ it('renders nested ANSI foreground and background colors with reset behavior', () => {
+ render();
+
+ expect(screen.getByText('wo')).toHaveStyle({
+ color: ThemeFixture().colors.green500,
+ });
+ expect(screen.getByText('rl')).toHaveStyle({
+ backgroundColor: ThemeFixture().colors.yellow500,
+ color: ThemeFixture().colors.green500,
+ });
+ expect(screen.getByText('d')).not.toHaveStyle({
+ color: ThemeFixture().colors.green500,
+ });
+ });
+
+ it('renders ANSI text decorations', () => {
+ render();
+
+ expect(screen.getByText('world')).toHaveStyle({
+ color: ThemeFixture().colors.green500,
+ fontWeight: 'bold',
+ });
+ expect(screen.getByText('!')).not.toHaveStyle({
+ fontWeight: 'bold',
+ });
+ });
+
+ it('strips ANSI 256-color codes and renders truecolor values', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('indexed')).toBeInTheDocument();
+ expect(screen.getByText('truecolor')).toHaveStyle({color: 'rgb(1, 2, 3)'});
+ expect(screen.queryByText(text => text.includes('\u001B'))).not.toBeInTheDocument();
+ });
+
+ it('keeps URLs clickable inside ANSI colored text', () => {
+ const url = 'https://docs.sentry.io';
+
+ render();
+
+ const link = screen.getByText(url).closest('a');
+ expect(link).toBeInTheDocument();
+ expect(link?.closest('span')).toHaveStyle({color: ThemeFixture().colors.green500});
+ });
+
+ it('can normalize terminal backspaces and carriage returns', () => {
+ const {container} = render(
+
+ );
+
+ expect(container).toHaveTextContent('abde progress 20%');
+ });
+
+ it('handles terminal carriage returns across lines', () => {
+ const {container} = render(
+
+ );
+
+ expect(container).toHaveTextContent('that sentence\nwill make you pause', {
+ normalizeWhitespace: false,
+ });
+ });
+
+ it('does not linkify URL-ish text', () => {
+ render();
+
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ expect(screen.getByText(' (normalizeTerminalSequences ? normalizeTerminalText(text) : text),
+ [normalizeTerminalSequences, text]
+ );
+
+ return ;
+}
diff --git a/static/app/components/ansiText/normalizeTerminalText.ts b/static/app/components/ansiText/normalizeTerminalText.ts
new file mode 100644
index 000000000000..3841d268df1f
--- /dev/null
+++ b/static/app/components/ansiText/normalizeTerminalText.ts
@@ -0,0 +1,41 @@
+export function normalizeTerminalText(text: string): string {
+ return applyCarriageReturns(removeBackspaces(text));
+}
+
+function removeBackspaces(text: string): string {
+ const characters: string[] = [];
+
+ for (const character of text) {
+ if (character === '\b') {
+ if (characters.length > 0 && characters[characters.length - 1] !== '\n') {
+ characters.pop();
+ }
+ continue;
+ }
+
+ characters.push(character);
+ }
+
+ return characters.join('');
+}
+
+function applyCarriageReturns(text: string): string {
+ return text
+ .replace(/\r+\n/g, '\n')
+ .split('\n')
+ .map(applyLineCarriageReturns)
+ .join('\n');
+}
+
+function applyLineCarriageReturns(line: string): string {
+ if (!line.includes('\r')) {
+ return line;
+ }
+
+ const [firstSegment = '', ...segments] = line.split('\r');
+
+ return segments.reduce(
+ (currentLine, segment) => segment + currentLine.slice(segment.length),
+ firstSegment
+ );
+}
diff --git a/static/app/components/events/attachmentViewers/logFileViewer.tsx b/static/app/components/events/attachmentViewers/logFileViewer.tsx
index db52a6dc91eb..0a2f198f4b85 100644
--- a/static/app/components/events/attachmentViewers/logFileViewer.tsx
+++ b/static/app/components/events/attachmentViewers/logFileViewer.tsx
@@ -1,6 +1,6 @@
import styled from '@emotion/styled';
-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';
@@ -31,49 +31,14 @@ export function LogFileViewer(props: ViewerProps) {
return data ? (
- {data}
+
+
+
) : null;
}
-/**
- * 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]) => `
- .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%;
@@ -82,3 +47,7 @@ const CodeWrapper = styled('pre')`
content: '';
}
`;
+
+const Code = styled('code')`
+ display: block;
+`;
diff --git a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx
index 93ec63d790df..38753a80ed92 100644
--- a/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx
+++ b/static/app/components/events/breadcrumbs/breadcrumbItemContent.tsx
@@ -2,6 +2,7 @@ import {Fragment} from 'react';
import styled from '@emotion/styled';
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
+import {AnsiText} from 'sentry/components/ansiText';
import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
import {StructuredData} from 'sentry/components/structuredEventData';
import {Timeline} from 'sentry/components/timeline';
@@ -44,7 +45,11 @@ export function BreadcrumbItemContent({
const defaultMessage = defined(bc.message) ? (
-
+ {meta?.message ? (
+
+ ) : (
+
+ )}
) : null;
@@ -201,12 +206,22 @@ function ExceptionCrumbContent({
const hasValue = value !== null && value !== undefined && value !== '';
const formattedValue = hasValue ? formatValue(value) : '';
+ let renderedValue: React.ReactNode = null;
+
+ if (hasValue) {
+ renderedValue = meta?.data?.value ? (
+ formattedValue
+ ) : (
+
+ );
+ }
return (
{type ? type : null}
- {type && hasValue ? `: ${formattedValue}` : hasValue ? formattedValue : null}
+ {type && hasValue ? ': ' : null}
+ {renderedValue}
{children}
{Object.keys(otherData).length > 0 ? (
diff --git a/static/app/components/events/eventMessage.tsx b/static/app/components/events/eventMessage.tsx
index c18c4ad7a579..b207bf48ec80 100644
--- a/static/app/components/events/eventMessage.tsx
+++ b/static/app/components/events/eventMessage.tsx
@@ -4,6 +4,7 @@ import {ErrorLevel} from 'sentry/components/events/errorLevel';
import {UnhandledTag} from 'sentry/components/group/inboxBadges/unhandledTag';
import {t} from 'sentry/locale';
import type {EventOrGroupType, Level} from 'sentry/types/event';
+import {stripAnsi} from 'sentry/utils/ansiEscapeCodes';
import {eventTypeHasLogLevel} from 'sentry/utils/events';
import {Divider} from 'sentry/views/issueDetails/divider';
@@ -23,8 +24,9 @@ export function EventMessage({
showUnhandled = false,
}: Props) {
const showEventLevel = level && eventTypeHasLogLevel(type);
+ const displayMessage = typeof message === 'string' ? stripAnsi(message) : message;
const renderedMessage = message ? (
- {message}
+ {displayMessage}
) : (
({t('No error message')})
);
diff --git a/static/app/components/events/interfaces/crashContent/exception/utils.tsx b/static/app/components/events/interfaces/crashContent/exception/utils.tsx
index 163331d15074..2853a5cceec2 100644
--- a/static/app/components/events/interfaces/crashContent/exception/utils.tsx
+++ b/static/app/components/events/interfaces/crashContent/exception/utils.tsx
@@ -1,12 +1,6 @@
import type {ReactElement} from 'react';
-import {Fragment} from 'react';
-import styled from '@emotion/styled';
-import {ExternalLink} from '@sentry/scraps/link';
-
-import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
-import {IconOpen} from 'sentry/icons';
-import {isValidUrl} from 'sentry/utils/string/isValidUrl';
+import {AnsiText} from 'sentry/components/ansiText';
interface RenderLinksInTextProps {
exceptionText: string;
@@ -15,53 +9,7 @@ interface RenderLinksInTextProps {
export const renderLinksInText = ({
exceptionText,
}: RenderLinksInTextProps): ReactElement => {
- // https?: Matches both "http" and "https"
- // :\/\/: This is a literal match for "://"
- // (?:www\.)?: Matches URLs with or without "www."
- // [-a-zA-Z0-9@:%._\+~#=]{1,256}: Matches the domain name
- // It allows for a range of characters (letters, digits, and special characters)
- // The {1,256} specifies that these characters can occur anywhere from 1 to 256 times, which covers the range of typical domain name lengths
- // \.: Matches the dot before the top-level domain (like ".com")
- // [a-zA-Z0-9]{1,6}: Matches the top-level domain (like "com" or "org"). It's limited to letters and digits and can be between 1 and 6 characters long
- // (?:[-a-zA-Z0-9@:%_\+~#?&\/=,\[\].]*[-a-zA-Z0-9@:%_\+~#?&\/=,\[\]])?: Matches the path, query parameters, or fragments that can follow the domain in a URL
- // It now includes periods within the character set to allow for file extensions (e.g., ".html") and other period-containing segments in the URL path
- // This pattern matches a wide range of characters typically found in paths, query strings, and fragments, including periods
- // The final character set ensures that the URL ends with a character typically allowed in a path, query string, or fragment, excluding special characters like a trailing period not part of the URL
- // /gi: The regex will match all occurrences in the string, not just the first one
- // The "i" modifier makes the regex match both upper and lower case characters
-
- const urlRegex =
- /https?:\/\/(?:www\.)?[-\w@:%.+~#=]{1,256}\.[a-z0-9]{1,6}(?:[-\w@:%+~#?&/=,[\].]*[-\w@:%+~#?&/=,[\]])?/gi;
-
- const parts = exceptionText.split(urlRegex);
- const urls = exceptionText.match(urlRegex) || [];
-
- const elements = parts.flatMap((part, index) => {
- const url = urls[index]!;
- const linkIsValid = isValidUrl(url);
-
- let link: ReactElement | undefined;
- if (linkIsValid) {
- link = (
- {
- e.preventDefault();
- openNavigateToExternalLinkModal({linkText: url});
- }}
- >
- {url}
-
-
- );
- } else if (url) {
- link = {url};
- }
-
- return [{part}, link];
- });
-
- return {elements};
+ return ;
};
// Maps the SDK name to the url token for docs
@@ -94,9 +42,3 @@ export const sourceMapSdkDocsMap: Record = {
'sentry.javascript.react-native': 'react-native',
'sentry.javascript.astro': 'astro',
};
-
-const IconPlacement = styled(IconOpen)`
- display: inline-block;
- margin-left: 5px;
- vertical-align: center;
-`;