cherry-pick(#34353): chore: move attachment link back to tree item, make it flash yellow (#34432)

Co-authored-by: Simon Knott <info@simonknott.de>
This commit is contained in:
Adam Gastineau 2025-01-22 10:06:07 -08:00 committed by GitHub
parent 2811a1d4f5
commit 7bbcc3c624
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 79 additions and 63 deletions

View file

@ -60,11 +60,6 @@
color: var(--color-scale-orange-6); color: var(--color-scale-orange-6);
border: 1px solid var(--color-scale-orange-4); border: 1px solid var(--color-scale-orange-4);
} }
.label-color-gray {
background-color: var(--color-scale-gray-0);
color: var(--color-scale-gray-6);
border: 1px solid var(--color-scale-gray-4);
}
} }
@media(prefers-color-scheme: dark) { @media(prefers-color-scheme: dark) {
@ -98,11 +93,6 @@
color: var(--color-scale-orange-2); color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4); border: 1px solid var(--color-scale-orange-4);
} }
.label-color-gray {
background-color: var(--color-scale-gray-9);
color: var(--color-scale-gray-2);
border: 1px solid var(--color-scale-gray-4);
}
} }
.attachment-body { .attachment-body {

View file

@ -21,7 +21,7 @@ import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard'; import { CopyToClipboard } from './copyToClipboard';
import './links.css'; import './links.css';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx, useFlash } from '@web/uiUtils';
export function navigate(href: string | URL) { export function navigate(href: string | URL) {
window.history.pushState({}, '', href); window.history.pushState({}, '', href);
@ -73,7 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string, linkName?: string,
openInNewTab?: boolean, openInNewTab?: boolean,
}> = ({ attachment, result, href, linkName, openInNewTab }) => { }> = ({ attachment, result, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); const [flash, triggerFlash] = useFlash();
useAnchor('attachment-' + result.attachments.indexOf(attachment), triggerFlash);
return <TreeItem title={<span> return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>} {attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
@ -84,7 +85,7 @@ export const AttachmentLink: React.FunctionComponent<{
)} )}
</span>} loadChildren={attachment.body ? () => { </span>} loadChildren={attachment.body ? () => {
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>]; return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>; } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={flash}></TreeItem>;
}; };
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1))); export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
@ -118,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing';
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;
export function useAnchor(id: AnchorID, onReveal: () => void) { export function useAnchor(id: AnchorID, onReveal: React.EffectCallback) {
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id); const isAnchored = useIsAnchored(id);
React.useEffect(() => { React.useEffect(() => {
if (isAnchored) if (isAnchored)
onReveal(); return onReveal();
}, [isAnchored, onReveal, searchParams]); }, [isAnchored, onReveal, searchParams]);
} }

View file

@ -176,6 +176,7 @@ const StepTreeItem: React.FC<{
}> = ({ test, step, result, depth }) => { }> = ({ test, step, result, depth }) => {
return <TreeItem title={<span aria-label={step.title}> return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span> <span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{step.attachments.length > 0 && <a style={{ float: 'right' }} title={`reveal attachment`} href={testResultHref({ test, result, anchor: `attachment-${step.attachments[0]}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))} {statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))}
<span>{step.title}</span> <span>{step.title}</span>
{step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>} {step.count > 1 && <> <span className='test-result-counter'>{step.count}</span></>}
@ -183,20 +184,6 @@ const StepTreeItem: React.FC<{
</span>} loadChildren={step.steps.length || step.snippet ? () => { </span>} loadChildren={step.steps.length || step.snippet ? () => {
const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : []; const snippet = step.snippet ? [<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>] : [];
const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />); const steps = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
const attachments = step.attachments.map(attachmentIndex => ( return snippet.concat(steps);
<a key={'' + attachmentIndex}
href={testResultHref({ test, result, anchor: `attachment-${attachmentIndex}` })}
style={{ paddingLeft: depth * 22 + 4, textDecoration: 'none' }}
>
<span
style={{ margin: '8px 0 0 8px', padding: '2px 10px', cursor: 'pointer' }}
className='label label-color-gray'
title={`see "${result.attachments[attachmentIndex].name}"`}
>
{icons.attachment()}{result.attachments[attachmentIndex].name}
</span>
</a>
));
return snippet.concat(steps, attachments);
} : undefined} depth={depth}/>; } : undefined} depth={depth}/>;
}; };

View file

@ -25,11 +25,14 @@
cursor: pointer; cursor: pointer;
} }
.tree-item-title.selected {
text-decoration: underline var(--color-underlinenav-icon);
text-decoration-thickness: 1.5px;
}
.tree-item-body { .tree-item-body {
min-height: 18px; min-height: 18px;
} }
.yellow-flash {
animation: yellowflash-bg 2s;
}
@keyframes yellowflash-bg {
from { background: var(--color-attention-subtle); }
to { background: transparent; }
}

View file

@ -25,12 +25,12 @@ export const TreeItem: React.FunctionComponent<{
onClick?: () => void, onClick?: () => void,
expandByDefault?: boolean, expandByDefault?: boolean,
depth: number, depth: number,
selected?: boolean,
style?: React.CSSProperties, style?: React.CSSProperties,
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { flash?: boolean
}> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => {
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
return <div className={'tree-item'} style={style}> return <div className={clsx('tree-item', flash && 'yellow-flash')} style={style}>
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <span className='tree-item-title' style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()} {loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()} {loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>} {!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}

View file

@ -55,3 +55,11 @@
a.codicon-cloud-download:hover{ a.codicon-cloud-download:hover{
background-color: var(--vscode-list-inactiveSelectionBackground) background-color: var(--vscode-list-inactiveSelectionBackground)
} }
.yellow-flash {
animation: yellowflash-bg 2s;
}
@keyframes yellowflash-bg {
from { background: var(--vscode-peekViewEditor-matchHighlightBackground); }
to { background: transparent; }
}

View file

@ -17,36 +17,38 @@
import * as React from 'react'; import * as React from 'react';
import './attachmentsTab.css'; import './attachmentsTab.css';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; import type { MultiTraceModel } from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel'; import { PlaceholderPanel } from './placeholderPanel';
import type { AfterActionTraceEventAttachment } from '@trace/trace'; import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper';
import { isTextualMimeType } from '@isomorphic/mimeType'; import { isTextualMimeType } from '@isomorphic/mimeType';
import { Expandable } from '@web/components/expandable'; import { Expandable } from '@web/components/expandable';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils'; import { clsx, useFlash } from '@web/uiUtils';
type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };
type ExpandableAttachmentProps = { type ExpandableAttachmentProps = {
attachment: Attachment; attachment: Attachment;
reveal: boolean; reveal?: any;
highlight: boolean;
}; };
const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, reveal, highlight }) => { const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, reveal }) => {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [attachmentText, setAttachmentText] = React.useState<string | null>(null); const [attachmentText, setAttachmentText] = React.useState<string | null>(null);
const [placeholder, setPlaceholder] = React.useState<string | null>(null); const [placeholder, setPlaceholder] = React.useState<string | null>(null);
const [flash, triggerFlash] = useFlash();
const ref = React.useRef<HTMLSpanElement>(null); const ref = React.useRef<HTMLSpanElement>(null);
const isTextAttachment = isTextualMimeType(attachment.contentType); const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path; const hasContent = !!attachment.sha1 || !!attachment.path;
React.useEffect(() => { React.useEffect(() => {
if (reveal) if (reveal) {
ref.current?.scrollIntoView({ behavior: 'smooth' }); ref.current?.scrollIntoView({ behavior: 'smooth' });
}, [reveal]); return triggerFlash();
}
}, [reveal, triggerFlash]);
React.useEffect(() => { React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) { if (expanded && attachmentText === null && placeholder === null) {
@ -66,14 +68,14 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
}, [attachmentText]); }, [attachmentText]);
const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}> const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}>
<span className={clsx(highlight && 'attachment-title-highlight')}>{linkifyText(attachment.name)}</span> <span>{linkifyText(attachment.name)}</span>
{hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>; </span>;
if (!isTextAttachment || !hasContent) if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>; return <div style={{ marginLeft: 20 }}>{title}</div>;
return <> return <div className={clsx(flash && 'yellow-flash')}>
<Expandable title={title} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}> <Expandable title={title} expanded={expanded} setExpanded={setExpanded} expandOnTitleClick={true}>
{placeholder && <i>{placeholder}</i>} {placeholder && <i>{placeholder}</i>}
</Expandable> </Expandable>
@ -87,14 +89,13 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
wrapLines={false}> wrapLines={false}>
</CodeMirrorWrapper> </CodeMirrorWrapper>
</div>} </div>}
</>; </div>;
}; };
export const AttachmentsTab: React.FunctionComponent<{ export const AttachmentsTab: React.FunctionComponent<{
model: MultiTraceModel | undefined, model: MultiTraceModel | undefined,
selectedAction: ActionTraceEventInContext | undefined, revealedAttachment?: [AfterActionTraceEventAttachment, number],
revealedAttachment?: AfterActionTraceEventAttachment, }> = ({ model, revealedAttachment }) => {
}> = ({ model, selectedAction, revealedAttachment }) => {
const { diffMap, screenshots, attachments } = React.useMemo(() => { const { diffMap, screenshots, attachments } = React.useMemo(() => {
const attachments = new Set<Attachment>(); const attachments = new Set<Attachment>();
const screenshots = new Set<Attachment>(); const screenshots = new Set<Attachment>();
@ -153,8 +154,7 @@ export const AttachmentsTab: React.FunctionComponent<{
return <div className='attachment-item' key={attachmentKey(a, i)}> return <div className='attachment-item' key={attachmentKey(a, i)}>
<ExpandableAttachment <ExpandableAttachment
attachment={a} attachment={a}
highlight={selectedAction?.attachments?.some(selected => isEqualAttachment(a, selected)) ?? false} reveal={(!!revealedAttachment && isEqualAttachment(a, revealedAttachment[0])) ? revealedAttachment : undefined}
reveal={!!revealedAttachment && isEqualAttachment(a, revealedAttachment)}
/> />
</div>; </div>;
})} })}

View file

@ -59,7 +59,7 @@ export const Workbench: React.FunctionComponent<{
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => { }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined); const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [revealedAttachment, setRevealedAttachment] = React.useState<AfterActionTraceEventAttachment | undefined>(undefined); const [revealedAttachment, setRevealedAttachment] = React.useState<[attachment: AfterActionTraceEventAttachment, renderCounter: number] | undefined>(undefined);
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>(); const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>(); const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
@ -148,7 +148,12 @@ export const Workbench: React.FunctionComponent<{
const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => { const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => {
selectPropertiesTab('attachments'); selectPropertiesTab('attachments');
setRevealedAttachment(attachment); setRevealedAttachment(currentValue => {
if (!currentValue)
return [attachment, 0];
const revealCounter = currentValue[1];
return [attachment, revealCounter + 1];
});
}, [selectPropertiesTab]); }, [selectPropertiesTab]);
React.useEffect(() => { React.useEffect(() => {
@ -238,7 +243,7 @@ export const Workbench: React.FunctionComponent<{
id: 'attachments', id: 'attachments',
title: 'Attachments', title: 'Attachments',
count: attachments.length, count: attachments.length,
render: () => <AttachmentsTab model={model} selectedAction={selectedAction} revealedAttachment={revealedAttachment} /> render: () => <AttachmentsTab model={model} revealedAttachment={revealedAttachment} />
}; };
const tabs: TabbedPaneTabModel[] = [ const tabs: TabbedPaneTabModel[] = [

View file

@ -14,6 +14,7 @@
limitations under the License. limitations under the License.
*/ */
import type { EffectCallback } from 'react';
import React from 'react'; import React from 'react';
// Recalculates the value when dependencies change. // Recalculates the value when dependencies change.
@ -224,3 +225,26 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) {
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; 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'); export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
/**
* Manages flash animation state.
* Calling `trigger` will turn `flash` to true for a second, and then back to false.
* If `trigger` is called while a flash is ongoing, the ongoing flash will be cancelled and after 50ms a new flash is started.
* @returns [flash, trigger]
*/
export function useFlash(): [boolean, EffectCallback] {
const [flash, setFlash] = React.useState(false);
const trigger = React.useCallback<React.EffectCallback>(() => {
const timeouts: any[] = [];
setFlash(currentlyFlashing => {
timeouts.push(setTimeout(() => setFlash(false), 1000));
if (!currentlyFlashing)
return true;
timeouts.push(setTimeout(() => setFlash(true), 50));
return false;
});
return () => timeouts.forEach(clearTimeout);
}, [setFlash]);
return [flash, trigger];
}

View file

@ -959,10 +959,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
await page.getByRole('link', { name: 'passing' }).click(); await page.getByRole('link', { name: 'passing' }).click();
const attachment = page.getByTestId('attachments').getByText('foo-2', { exact: true }); const attachment = page.getByText('foo-2', { exact: true });
await expect(attachment).not.toBeInViewport(); await expect(attachment).not.toBeInViewport();
await page.getByLabel('attach "foo-2"').click(); await page.getByLabel(`attach "foo-2"`).getByTitle('reveal attachment').click();
await page.getByTitle('see "foo-2"').click();
await expect(attachment).toBeInViewport(); await expect(attachment).toBeInViewport();
await page.reload(); await page.reload();
@ -989,10 +988,9 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
await page.getByRole('link', { name: 'passing' }).click(); await page.getByRole('link', { name: 'passing' }).click();
const attachment = page.getByTestId('attachments').getByText('attachment', { exact: true }); const attachment = page.getByText('attachment', { exact: true });
await expect(attachment).not.toBeInViewport(); await expect(attachment).not.toBeInViewport();
await page.getByLabel('step').click(); await page.getByLabel('step').getByTitle('reveal attachment').click();
await page.getByTitle('see "attachment"').click();
await expect(attachment).toBeInViewport(); await expect(attachment).toBeInViewport();
}); });