Skip to content

Commit

Permalink
feat(ui mode): linkify attachment names and content
Browse files Browse the repository at this point in the history
- Pass `contentType` to the CodeMirror.
- Support `text/markdown` mode.
- Custom mode for non-supported types that linkifies urls.
  • Loading branch information
dgozman committed Aug 1, 2024
1 parent 47714d6 commit 09d685e
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 17 deletions.
8 changes: 6 additions & 2 deletions packages/trace-viewer/src/ui/attachmentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { isTextualMimeType } from '@isomorphic/mimeType';
import { Expandable } from '@web/components/expandable';
import { linkifyText } from '@web/renderUtils';

type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };

Expand All @@ -36,6 +37,7 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
const [placeholder, setPlaceholder] = React.useState<string | null>(null);

const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path;

React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) {
Expand All @@ -50,10 +52,10 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
}, [expanded, attachmentText, placeholder, attachment]);

const title = <span style={{ marginLeft: 5 }}>
{attachment.name} <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>
{linkifyText(attachment.name)} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>;

if (!isTextAttachment)
if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>;

return <>
Expand All @@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
{expanded && attachmentText !== null && <CodeMirrorWrapper
text={attachmentText}
readOnly
mimeType={attachment.contentType}
linkify={true}
lineNumbers={true}
wrapLines={false}>
</CodeMirrorWrapper>}
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/components/codeMirrorModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed';
import 'codemirror-shadow-1/mode/javascript/javascript';
import 'codemirror-shadow-1/mode/python/python';
import 'codemirror-shadow-1/mode/clike/clike';
import 'codemirror-shadow-1/mode/markdown/markdown';
import 'codemirror-shadow-1/addon/mode/simple';

export type CodeMirror = typeof codemirrorType;
export default codemirror;
6 changes: 6 additions & 0 deletions packages/web/src/components/codeMirrorWrapper.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,9 @@ body.dark-mode .CodeMirror span.cm-type {
margin: 3px 10px;
padding: 5px;
}

.CodeMirror span.cm-link, span.cm-linkified {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
cursor: pointer;
}
59 changes: 49 additions & 10 deletions packages/web/src/components/codeMirrorWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ import './codeMirrorWrapper.css';
import * as React from 'react';
import type { CodeMirror } from './codeMirrorModule';
import { ansi2html } from '../ansi2html';
import { useMeasure } from '../uiUtils';
import { useMeasure, kWebLinkRe } from '../uiUtils';

export type SourceHighlight = {
line: number;
type: 'running' | 'paused' | 'error';
message?: string;
};

export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css';
export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown';

export interface SourceProps {
text: string;
language?: Language;
mimeType?: string;
linkify?: boolean;
readOnly?: boolean;
// 1-based
highlight?: SourceHighlight[];
Expand All @@ -45,6 +47,8 @@ export interface SourceProps {
export const CodeMirrorWrapper: React.FC<SourceProps> = ({
text,
language,
mimeType,
linkify,
readOnly,
highlight,
revealLine,
Expand All @@ -63,24 +67,29 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
(async () => {
// Always load the module first.
const CodeMirror = await modulePromise;
defineCustomMode(CodeMirror);

const element = codemirrorElement.current;
if (!element)
return;

let mode = '';
if (language === 'javascript')
if (language === 'javascript' || mimeType?.includes('javascript'))
mode = 'javascript';
if (language === 'python')
else if (language === 'python' || mimeType?.includes('python'))
mode = 'python';
if (language === 'java')
else if (language === 'java' || mimeType?.includes('java'))
mode = 'text/x-java';
if (language === 'csharp')
else if (language === 'csharp' || mimeType?.includes('csharp'))
mode = 'text/x-csharp';
if (language === 'html')
else if (language === 'html' || mimeType?.includes('html') || mimeType?.includes('svg'))
mode = 'htmlmixed';
if (language === 'css')
else if (language === 'css' || mimeType?.includes('css'))
mode = 'css';
else if (language === 'markdown' || mimeType?.includes('markdown'))
mode = 'markdown';
else if (linkify)
mode = 'text/linkified';

if (codemirrorRef.current
&& mode === codemirrorRef.current.cm.getOption('mode')
Expand All @@ -106,7 +115,7 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
setCodemirror(cm);
return cm;
})();
}, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]);
}, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]);

React.useEffect(() => {
if (codemirrorRef.current)
Expand Down Expand Up @@ -175,5 +184,35 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
};
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);

return <div className='cm-wrapper' ref={codemirrorElement}></div>;
return <div className='cm-wrapper' ref={codemirrorElement} onClick={onCodeMirrorClick}></div>;
};

function onCodeMirrorClick(event: React.MouseEvent) {
if (!(event.target instanceof HTMLElement))
return;
let url: string | undefined;
if (event.target.classList.contains('cm-linkified')) {
// 'text/linkified' custom mode
url = event.target.textContent!;
} else if (event.target.classList.contains('cm-link') && event.target.nextElementSibling?.classList.contains('cm-url')) {
// 'markdown' mode
url = event.target.nextElementSibling.textContent!.slice(1, -1);
}
if (url) {
event.preventDefault();
event.stopPropagation();
window.open(url, '_blank');
}
}

let customModeDefined = false;
function defineCustomMode(cm: CodeMirror) {
if (customModeDefined)
return;
customModeDefined = true;
(cm as any).defineSimpleMode('text/linkified', {
start: [
{ regex: kWebLinkRe, token: 'linkified' },
],
});
}
7 changes: 3 additions & 4 deletions packages/web/src/renderUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@
* limitations under the License.
*/

export function linkifyText(description: string) {
const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';
const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');
import { kWebLinkRe } from './uiUtils';

export function linkifyText(description: string) {
const result = [];
let currentIndex = 0;
let match;

while ((match = WEB_LINK_REGEX.exec(description)) !== null) {
while ((match = kWebLinkRe.exec(description)) !== null) {
const stringBeforeMatch = description.substring(currentIndex, match.index);
if (stringBeforeMatch)
result.push(stringBeforeMatch);
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/uiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,7 @@ export const settings = new Settings();
// inspired by https://www.npmjs.com/package/clsx
export function clsx(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(' ');
}
}

const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
49 changes: 49 additions & 0 deletions tests/playwright-test/ui-mode-test-attachments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,55 @@ test('should contain string attachment', async ({ runUITest }) => {
expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42');
});

test('should linkify string attachments', async ({ runUITest, server }) => {
server.setRoute('/one.html', (req, res) => res.end());
server.setRoute('/two.html', (req, res) => res.end());
server.setRoute('/three.html', (req, res) => res.end());

const { page } = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
test('attach test', async () => {
await test.info().attach('Inline url: ${server.PREFIX + '/one.html'}');
await test.info().attach('Second', { body: 'Inline link ${server.PREFIX + '/two.html'} to be highlighted.' });
await test.info().attach('Third', { body: '[markdown link](${server.PREFIX + '/three.html'})', contentType: 'text/markdown' });
});
`,
});
await page.getByText('attach test').click();
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByText('Attachments').click();

const attachmentsPane = page.locator('.attachments-tab');

{
const url = server.PREFIX + '/one.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText(url).click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}

{
await attachmentsPane.getByText('Second download').click();
const url = server.PREFIX + '/two.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText(url).click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}

{
await attachmentsPane.getByText('Third download').click();
const url = server.PREFIX + '/three.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText('[markdown link]').click();
const popup = await promise;
await expect(popup).toHaveURL(url);
}
});

function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];
Expand Down

0 comments on commit 09d685e

Please sign in to comment.