chore: network panel polish (#28924)

This commit is contained in:
Pavel Feldman 2024-01-10 15:28:33 -08:00 committed by GitHub
parent 3851d9b897
commit a0750b7854
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 451 additions and 230 deletions

View file

@ -44,7 +44,6 @@
}
.tab-network .toolbar {
margin-top: 3px !important;
min-height: 30px !important;
background-color: initial !important;
border-bottom: 1px solid var(--vscode-panel-border);

View file

@ -30,11 +30,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{
return <TabbedPane
dataTestId='network-request-details'
leftToolbar={[
<ToolbarButton icon='arrow-left' title='Back' onClick={onClose}></ToolbarButton>,
<div style={{ width: 30 }}></div>
]}
rightToolbar={[<ToolbarButton icon='close' title='Close' onClick={onClose}></ToolbarButton>]}
leftToolbar={[<ToolbarButton icon='close' title='Close' onClick={onClose}></ToolbarButton>]}
tabs={[
{
id: 'request',

View file

@ -29,6 +29,14 @@
background-color: var(--vscode-statusBarItem-remoteBackground);
}
.network-request-column {
overflow: hidden;
text-overflow: ellipsis;
flex: 0.5;
padding: 0 5px;
display: flex;
}
.network-request-start {
flex: 0 0 65px;
justify-content: right;
@ -36,7 +44,7 @@
}
.network-request-status {
flex: 0 0 65px;
flex: 0 0 75px;
}
.network-request-method {
@ -54,21 +62,15 @@
flex: 1;
}
.network-request-content-type,
.network-request-duration,
.network-request-route,
.network-request-size {
overflow: hidden;
text-overflow: ellipsis;
flex: 0.5;
}
.network-request-route {
flex: 0.25;
.network-request-body .network-request-start,
.network-request-body .network-request-status,
.network-request-body .network-request-duration,
.network-request-body .network-request-size {
justify-content: end;
}
.network-request-header {
margin-top: 3px;
margin: 3px 14px 0 5px;
height: 30px;
border-bottom: 1px solid var(--vscode-panel-border);
flex: none;
@ -90,53 +92,18 @@
white-space: nowrap;
height: 100%;
overflow: hidden;
padding: 0 5px;
justify-content: space-between;
}
.network-request-header.filter-start.positive .network-request-start .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-start.negative .network-request-start .codicon-triangle-up {
.network-request-header > .filter-positive .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-status.positive .network-request-status .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-status.negative .network-request-status .codicon-triangle-up {
.network-request-header > .filter-negative .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-method.positive .network-request-method .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-method.negative .network-request-method .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-file.positive .network-request-file .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-file.negative .network-request-file .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-content-type.positive .network-request-content-type .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-content-type.negative .network-request-content-type .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-duration.positive .network-request-duration .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-duration.negative .network-request-duration .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-size.positive .network-request-size .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-size.negative .network-request-size .codicon-triangle-up {
display: initial !important;
.network-request-header .network-request-column {
border-right: 1px solid var(--vscode-panel-border);
}

View file

@ -15,7 +15,6 @@
*/
import type { Entry } from '@trace/har';
import { ListView } from '@web/components/listView';
import * as React from 'react';
import type { Boundaries } from '../geometry';
import './networkTab.css';
@ -23,15 +22,28 @@ import { NetworkResourceDetails } from './networkResourceDetails';
import { bytesToString, msToString } from '@web/uiUtils';
import { PlaceholderPanel } from './placeholderPanel';
import type { MultiTraceModel } from './modelUtil';
import { GridView } from '@web/components/gridView';
import { SplitView } from '@web/components/splitView';
const NetworkListView = ListView<Entry>;
type SortBy = 'start' | 'status' | 'method' | 'file' | 'duration' | 'size' | 'content-type';
type Sorting = { by: SortBy, negate: boolean};
type NetworkTabModel = {
resources: Entry[],
};
type RenderedEntry = {
name: { name: string, url: string },
method: string,
status: { code: number, text: string, className: string },
contentType: string,
duration: number,
size: number,
start: number,
route: string,
resource: Entry,
};
type ColumnName = keyof RenderedEntry;
type Sorting = { by: ColumnName, negate: boolean};
const NetworkGridView = GridView<RenderedEntry>;
export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel {
const resources = React.useMemo(() => {
const resources = model?.resources || [];
@ -50,122 +62,110 @@ export const NetworkTab: React.FunctionComponent<{
networkModel: NetworkTabModel,
onEntryHovered: (entry: Entry | undefined) => void,
}> = ({ boundaries, networkModel, onEntryHovered }) => {
const [resource, setResource] = React.useState<Entry | undefined>();
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
React.useMemo(() => {
const { renderedEntries } = React.useMemo(() => {
const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries));
if (sorting)
sort(networkModel.resources, sorting);
}, [networkModel.resources, sorting]);
const toggleSorting = React.useCallback((f: SortBy) => {
setSorting({ by: f, negate: sorting?.by === f ? !sorting.negate : false });
}, [sorting]);
sort(renderedEntries, sorting);
return { renderedEntries };
}, [networkModel.resources, sorting, boundaries]);
if (!networkModel.resources.length)
return <PlaceholderPanel text='No network calls' />;
const grid = <NetworkGridView
name='network'
items={renderedEntries}
selectedItem={selectedEntry}
onSelected={item => setSelectedEntry(item)}
onHighlighted={item => onEntryHovered(item?.resource)}
columns={selectedEntry ? ['name'] : ['name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route']}
columnTitle={columnTitle}
columnWidth={column => column === 'name' ? 200 : 100}
render={(item, column) => renderCell(item, column)}
sorting={sorting}
setSorting={setSorting}
/>;
return <>
{!resource && <div className='vbox'>
<NetworkHeader sorting={sorting} toggleSorting={toggleSorting} />
<NetworkListView
name='network'
items={networkModel.resources}
render={entry => <NetworkResource boundaries={boundaries} resource={entry}></NetworkResource>}
onSelected={setResource}
onHighlighted={onEntryHovered}
/>
</div>}
{resource && <NetworkResourceDetails resource={resource} onClose={() => setResource(undefined)} />}
{!selectedEntry && grid}
{selectedEntry && <SplitView sidebarSize={200} sidebarIsFirst={true} orientation='horizontal'>
<NetworkResourceDetails resource={selectedEntry.resource} onClose={() => setSelectedEntry(undefined)} />
{grid}
</SplitView>}
</>;
};
const NetworkHeader: React.FunctionComponent<{
sorting: Sorting | undefined,
toggleSorting: (sortBy: SortBy) => void,
}> = ({ toggleSorting: toggleSortBy, sorting }) => {
return <div className={'hbox network-request-header' + (sorting ? ' filter-' + sorting.by + (sorting.negate ? ' negative' : ' positive') : '')}>
<div className='network-request-start' onClick={() => toggleSortBy('start') }>
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-status' onClick={() => toggleSortBy('status') }>
&nbsp;Status
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-method' onClick={() => toggleSortBy('method') }>
Method
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-file' onClick={() => toggleSortBy('file') }>
Request
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-content-type' onClick={() => toggleSortBy('content-type') }>
Content Type
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-duration' onClick={() => toggleSortBy('duration') }>
Duration
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-size' onClick={() => toggleSortBy('size') }>
Size
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-route'>Route</div>
</div>;
const columnTitle = (column: ColumnName) => {
if (column === 'name')
return 'Name';
if (column === 'method')
return 'Method';
if (column === 'status')
return 'Status';
if (column === 'contentType')
return 'Content Type';
if (column === 'duration')
return 'Duration';
if (column === 'size')
return 'Size';
if (column === 'start')
return 'Start';
if (column === 'route')
return 'Route';
return '';
};
const NetworkResource: React.FunctionComponent<{
resource: Entry,
boundaries: Boundaries,
}> = ({ resource, boundaries }) => {
const { routeStatus, resourceName, contentType } = React.useMemo(() => {
const routeStatus = formatRouteStatus(resource);
let resourceName: string;
try {
const url = new URL(resource.request.url);
resourceName = url.pathname;
} catch {
resourceName = resource.request.url;
}
let contentType = resource.response.content.mimeType;
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
contentType = charset[1];
return { routeStatus, resourceName, contentType };
}, [resource]);
return <div className='hbox'>
<div className='hbox network-request-start'>
<div>{msToString(resource._monotonicTime! - boundaries.minimum)}</div>
</div>
<div className='hbox network-request-status'>
<div className={formatStatus(resource.response.status)} title={resource.response.statusText}>{resource.response.status}</div>
</div>
<div className='hbox network-request-method'>
<div>{resource.request.method}</div>
</div>
<div className='network-request-file'>
<div className='network-request-file-url' title={resource.request.url}>{resourceName}</div>
</div>
<div className='network-request-content-type' title={contentType}>{contentType}</div>
<div className='network-request-duration'>{msToString(resource.time)}</div>
<div className='network-request-size'>{bytesToString(resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize)}</div>
<div className='network-request-route'>
{routeStatus && <div className={`status-route ${routeStatus}`}>{routeStatus}</div>}
</div>
</div>;
const renderCell = (entry: RenderedEntry, column: ColumnName) => {
if (column === 'name')
return <span title={entry.name.url}>{entry.name.name}</span>;
if (column === 'method')
return <span>{entry.method}</span>;
if (column === 'status')
return <span className={entry.status.className} title={entry.status.text}>{entry.status.code > 0 ? entry.status.code : ''}</span>;
if (column === 'contentType')
return <span>{entry.contentType}</span>;
if (column === 'duration')
return <span>{msToString(entry.duration)}</span>;
if (column === 'size')
return <span>{bytesToString(entry.size)}</span>;
if (column === 'start')
return <span>{msToString(entry.start)}</span>;
if (column === 'route')
return entry.route && <span className={`status-route ${entry.route}`}>{entry.route}</span>;
};
function formatStatus(status: number): string {
const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => {
const routeStatus = formatRouteStatus(resource);
let resourceName: string;
try {
const url = new URL(resource.request.url);
resourceName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1);
if (!resourceName)
resourceName = url.host;
} catch {
resourceName = resource.request.url;
}
let contentType = resource.response.content.mimeType;
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
contentType = charset[1];
return {
name: { name: resourceName, url: resource.request.url },
method: resource.request.method,
status: { code: resource.response.status, text: resource.response.statusText, className: statusClassName(resource.response.status) },
contentType: contentType,
duration: resource.time,
size: resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize,
start: resource._monotonicTime! - boundaries.minimum,
route: routeStatus,
resource
};
};
function statusClassName(status: number): string {
if (status >= 200 && status < 400)
return 'status-success';
if (status >= 400)
@ -185,7 +185,7 @@ function formatRouteStatus(request: Entry): string {
return '';
}
function sort(resources: Entry[], sorting: Sorting) {
function sort(resources: RenderedEntry[], sorting: Sorting) {
const c = comparator(sorting?.by);
if (c)
resources.sort(c);
@ -193,45 +193,45 @@ function sort(resources: Entry[], sorting: Sorting) {
resources.reverse();
}
function comparator(sortBy: SortBy) {
function comparator(sortBy: ColumnName) {
if (sortBy === 'start')
return (a: Entry, b: Entry) => a._monotonicTime! - b._monotonicTime!;
return (a: RenderedEntry, b: RenderedEntry) => a.start - b.start;
if (sortBy === 'duration')
return (a: Entry, b: Entry) => a.time - b.time;
return (a: RenderedEntry, b: RenderedEntry) => a.duration - b.duration;
if (sortBy === 'status')
return (a: Entry, b: Entry) => a.response.status - b.response.status;
return (a: RenderedEntry, b: RenderedEntry) => a.status.code - b.status.code;
if (sortBy === 'method') {
return (a: Entry, b: Entry) => {
const valueA = a.request.method;
const valueB = b.request.method;
return (a: RenderedEntry, b: RenderedEntry) => {
const valueA = a.method;
const valueB = b.method;
return valueA.localeCompare(valueB);
};
}
if (sortBy === 'size') {
return (a: Entry, b: Entry) => {
const sizeA = a.response._transferSize! > 0 ? a.response._transferSize! : a.response.bodySize;
const sizeB = b.response._transferSize! > 0 ? b.response._transferSize! : b.response.bodySize;
return sizeA - sizeB;
return (a: RenderedEntry, b: RenderedEntry) => {
return a.size - b.size;
};
}
if (sortBy === 'content-type') {
return (a: Entry, b: Entry) => {
const valueA = a.response.content.mimeType;
const valueB = b.response.content.mimeType;
return valueA.localeCompare(valueB);
if (sortBy === 'contentType') {
return (a: RenderedEntry, b: RenderedEntry) => {
return a.contentType.localeCompare(b.contentType);
};
}
if (sortBy === 'file') {
return (a: Entry, b: Entry) => {
const nameA = a.request.url.substring(a.request.url.lastIndexOf('/'));
const nameB = b.request.url.substring(b.request.url.lastIndexOf('/'));
return nameA.localeCompare(nameB);
if (sortBy === 'name') {
return (a: RenderedEntry, b: RenderedEntry) => {
return a.name.name.localeCompare(b.name.name);
};
}
if (sortBy === 'route') {
return (a: RenderedEntry, b: RenderedEntry) => {
return a.route.localeCompare(b.route);
};
}
}

View file

@ -205,12 +205,11 @@ export const Timeline: React.FunctionComponent<{
}, [setSelectedTime]);
return <div style={{ flex: 'none', borderBottom: '1px solid var(--vscode-panel-border)' }}>
<GlassPane
enabled={!!dragWindow}
{!!dragWindow && <GlassPane
cursor={dragWindow?.type === 'resize' ? 'ew-resize' : 'grab'}
onPaneMouseUp={onGlassPaneMouseUp}
onPaneMouseMove={onGlassPaneMouseMove}
onPaneDoubleClick={onPaneDoubleClick} />
onPaneDoubleClick={onPaneDoubleClick} />}
<div ref={ref}
className='timeline-view'
onMouseDown={onMouseDown}

View file

@ -3,6 +3,7 @@
../third_party/vscode/codicon.css
../uiUtils.ts
../ansi2html.ts
../shared
[expandable.spec.tsx]
***

View file

@ -0,0 +1,69 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.grid-view {
display: flex;
position: relative;
flex: auto;
}
.grid-view-cell {
overflow: hidden;
text-overflow: ellipsis;
padding: 0 5px;
flex: none;
}
.grid-view-header {
user-select: none;
display: flex;
flex: 0 0 30px;
align-items: center;
flex-direction: row;
border-bottom: 1px solid var(--vscode-panel-border);
}
.grid-view-header .codicon-triangle-up {
display: none;
}
.grid-view-header .codicon-triangle-down {
display: none;
}
.grid-view-header > .filter-positive .codicon-triangle-down {
display: initial !important;
}
.grid-view-header > .filter-negative .codicon-triangle-up {
display: initial !important;
}
.grid-view-header-cell {
flex: none;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 10px;
cursor: pointer;
display: flex;
white-space: nowrap;
}
.grid-view-header-cell-title {
overflow: hidden;
text-overflow: ellipsis;
flex: auto;
}

View file

@ -0,0 +1,107 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the 'License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import { ListView } from './listView';
import type { ListViewProps } from './listView';
import './gridView.css';
import { ResizeView } from '@web/shared/resizeView';
export type Sorting<T> = { by: keyof T, negate: boolean };
export type GridViewProps<T> = Omit<ListViewProps<T>, 'render'> & {
columns: (keyof T)[],
columnTitle: (column: keyof T) => string,
columnWidth: (column: keyof T) => number,
render: (item: T, column: keyof T, index: number) => React.ReactNode,
sorting?: Sorting<T>,
setSorting?: (sorting: Sorting<T> | undefined) => void,
};
export function GridView<T>(model: GridViewProps<T>) {
const initialOffsets: number[] = [];
for (let i = 0; i < model.columns.length - 1; ++i) {
const column = model.columns[i];
initialOffsets[i] = (initialOffsets[i - 1] || 0) + model.columnWidth(column);
}
const [offsets, setOffsets] = React.useState<number[]>(initialOffsets);
const toggleSorting = React.useCallback((f: keyof T) => {
model.setSorting?.({ by: f, negate: model.sorting?.by === f ? !model.sorting.negate : false });
}, [model]);
return <div className='grid-view'>
<ResizeView
orientation={'horizontal'}
offsets={offsets}
setOffsets={setOffsets}
resizerColor='var(--vscode-panel-border)'
resizerWidth={1}
minColumnWidth={25}>
</ResizeView>
<div className='vbox'>
<div className='grid-view-header'>
{model.columns.map((column, i) => {
return <div
className={'grid-view-header-cell ' + sortingHeader(column, model.sorting)}
style={{
width: offsets[i] - (offsets[i - 1] || 0),
}}
onClick={() => model.setSorting && toggleSorting(column)}
>
<span className='grid-view-header-cell-title'>{model.columnTitle(column)}</span>
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>;
})}
</div>
<ListView
name={model.name}
items={model.items}
id={model.id}
render={(item, index) => {
return <>
{model.columns.map((column, i) => {
return <div
className='grid-view-cell'
style={{ width: offsets[i] - (offsets[i - 1] || 0) }}>
{model.render(item, column, index)}
</div>;
})}
</>;
}}
icon={model.icon}
indent={model.indent}
isError={model.isError}
isWarning={model.isWarning}
selectedItem={model.selectedItem}
onAccepted={model.onAccepted}
onSelected={model.onSelected}
onLeftArrow={model.onLeftArrow}
onRightArrow={model.onRightArrow}
onHighlighted={model.onHighlighted}
onIconClicked={model.onIconClicked}
noItemsMessage={model.noItemsMessage}
dataTestId={model.dataTestId}
noHighlightOnHover={model.noHighlightOnHover}
></ListView>
</div>
</div>;
}
function sortingHeader<T>(column: keyof T, sorting: Sorting<T> | undefined) {
return column === sorting?.by ? ' filter-' + (sorting.negate ? 'negative' : 'positive') : '';
}

View file

@ -25,12 +25,11 @@ export type SplitViewProps = {
orientation?: 'vertical' | 'horizontal';
minSidebarSize?: number;
settingName?: string;
children: JSX.Element | JSX.Element[] | string;
};
const kMinSize = 50;
export const SplitView: React.FC<SplitViewProps> = ({
export const SplitView: React.FC<React.PropsWithChildren<SplitViewProps>> = ({
sidebarSize,
sidebarHidden = false,
sidebarIsFirst = false,

View file

@ -29,6 +29,7 @@
flex: auto;
overflow: hidden;
position: relative;
flex-direction: column;
}
.tabbed-pane-tab {

View file

@ -17,16 +17,12 @@
import React from 'react';
export const GlassPane: React.FC<{
enabled: boolean;
cursor: string;
onPaneMouseMove?: (e: MouseEvent) => void;
onPaneMouseUp?: (e: MouseEvent) => void;
onPaneDoubleClick?: (e: MouseEvent) => void;
}> = ({ enabled, cursor, onPaneMouseMove, onPaneMouseUp, onPaneDoubleClick }) => {
}> = ({ cursor, onPaneMouseMove, onPaneMouseUp, onPaneDoubleClick }) => {
React.useEffect(() => {
if (!enabled)
return;
const glassPaneDiv = document.createElement('div');
glassPaneDiv.style.position = 'fixed';
glassPaneDiv.style.top = '0';
@ -54,7 +50,7 @@ export const GlassPane: React.FC<{
document.body.removeEventListener('dblclick', onPaneDoubleClick);
document.body.removeChild(glassPaneDiv);
};
}, [enabled, cursor, onPaneMouseMove, onPaneMouseUp, onPaneDoubleClick]);
}, [cursor, onPaneMouseMove, onPaneMouseUp, onPaneDoubleClick]);
return <></>;
};

View file

@ -15,8 +15,8 @@
*/
import * as React from 'react';
import { GlassPane } from './glassPane';
import { useMeasure } from '../uiUtils';
import { ResizeView } from './resizeView';
type TestAttachment = {
name: string;
@ -141,25 +141,8 @@ export const ImageDiffSlider: React.FC<{
const [slider, setSlider] = React.useState<number>(canvasWidth / 2);
const sameSize = expectedImage.naturalWidth === actualImage.naturalWidth && expectedImage.naturalHeight === actualImage.naturalHeight;
const [resizing, setResizing] = React.useState<{ offset: number, slider: number } | null>(null);
return <div style={{ flex: 'none', display: 'flex', alignItems: 'center', flexDirection: 'column', userSelect: 'none' }}>
<GlassPane
enabled={!!resizing}
cursor={'ew-resize'}
onPaneMouseUp={() => setResizing(null)}
onPaneMouseMove={event => {
if (!event.buttons) {
setResizing(null);
} else if (resizing) {
const offset = event.clientX;
const delta = offset - resizing.offset;
const newSlider = resizing.slider + delta;
const slider = Math.min(Math.max(0, newSlider), canvasWidth);
setSlider(slider);
}
}}
/>
<div style={{ margin: 5 }}>
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>Expected </span>}
<span>{expectedImage.naturalWidth}</span>
@ -170,8 +153,13 @@ export const ImageDiffSlider: React.FC<{
{!sameSize && <span style={{ flex: 'none', margin: '0 5px' }}>x</span>}
{!sameSize && <span>{actualImage.naturalHeight}</span>}
</div>
<div style={{ position: 'relative', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}
onMouseDown={event => setResizing({ offset: event.clientX, slider: slider })}>
<div style={{ position: 'relative', width: canvasWidth, height: canvasHeight, margin: 15, ...checkerboardStyle }}>
<ResizeView
orientation={'horizontal'}
offsets={[slider]}
setOffsets={offsets => setSlider(offsets[0])}
resizerColor={'#57606a80'}
resizerWidth={6}></ResizeView>
<img alt='Expected' style={{
width: expectedImage.naturalWidth * scale,
height: expectedImage.naturalHeight * scale,
@ -182,9 +170,6 @@ export const ImageDiffSlider: React.FC<{
height: actualImage.naturalHeight * scale,
}} draggable='false' src={actualImage.src} />
</div>
<div style={{ position: 'absolute', top: 0, bottom: 0, left: slider, width: 6, background: '#57606a80', cursor: 'ew-resize', overflow: 'visible', display: 'flex', alignItems: 'center' }}>
<svg style={{ fill: '#57606a80', width: 30, flex: 'none', marginLeft: -12, pointerEvents: 'none' }} viewBox="0 0 27 20"><path d="M9.6 0L0 9.6l9.6 9.6z"></path><path d="M17 19.2l9.5-9.6L16.9 0z"></path></svg>
</div>
</div>
</div>;
};

View file

@ -0,0 +1,99 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { GlassPane } from './glassPane';
import { useMeasure } from '../uiUtils';
const fillStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
};
export const ResizeView: React.FC<{
orientation: 'horizontal' | 'vertical',
offsets: number[],
setOffsets: (offsets: number[]) => void,
resizerColor: string,
resizerWidth: number,
minColumnWidth?: number,
}> = ({ orientation, offsets, setOffsets, resizerColor, resizerWidth, minColumnWidth }) => {
const minGap = minColumnWidth || 0;
const [resizing, setResizing] = React.useState<{ clientX: number, clientY: number, offset: number, index: number } | null>(null);
const [measure, ref] = useMeasure<HTMLDivElement>();
const dividerStyle: React.CSSProperties = {
position: 'absolute',
right: orientation === 'horizontal' ? undefined : 0,
bottom: orientation === 'horizontal' ? 0 : undefined,
width: orientation === 'horizontal' ? 7 : undefined,
height: orientation === 'horizontal' ? undefined : 7,
borderTopWidth: orientation === 'horizontal' ? undefined : (7 - resizerWidth) / 2,
borderRightWidth: orientation === 'horizontal' ? (7 - resizerWidth) / 2 : undefined,
borderBottomWidth: orientation === 'horizontal' ? undefined : (7 - resizerWidth) / 2,
borderLeftWidth: orientation === 'horizontal' ? (7 - resizerWidth) / 2 : undefined,
borderColor: 'transparent',
borderStyle: 'solid',
cursor: orientation === 'horizontal' ? 'ew-resize' : 'ns-resize',
};
return <div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
pointerEvents: 'none',
}}
ref={ref}>
{!!resizing && <GlassPane
cursor={orientation === 'horizontal' ? 'ew-resize' : 'ns-resize'}
onPaneMouseUp={() => setResizing(null)}
onPaneMouseMove={event => {
if (!event.buttons) {
setResizing(null);
} else if (resizing) {
const delta = orientation === 'horizontal' ? event.clientX - resizing.clientX : event.clientY - resizing.clientY;
const newOffset = resizing.offset + delta;
const previous = resizing.index > 0 ? offsets[resizing.index - 1] : 0;
const next = orientation === 'horizontal' ? measure.width : measure.height;
const constrainedDelta = Math.min(Math.max(previous + minGap, newOffset), next - minGap) - offsets[resizing.index];
for (let i = resizing.index; i < offsets.length; ++i)
offsets[i] = offsets[i] + constrainedDelta;
setOffsets([...offsets]);
}
}}
/>}
{offsets.map((offset, index) => {
return <div
style={{
...dividerStyle,
top: orientation === 'horizontal' ? 0 : offset,
left: orientation === 'horizontal' ? offset : 0,
pointerEvents: 'initial',
}}
onMouseDown={event => setResizing({ clientX: event.clientX, clientY: event.clientY, offset, index })}>
<div style={{
...fillStyle,
background: resizerColor,
}}></div>
</div>;
})}
</div>;
};

View file

@ -241,9 +241,9 @@ test('should have network requests', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toContainText([/200GET\/frames\/frame.htmltext\/html/]);
await expect(traceViewer.networkRequests).toContainText([/200GET\/frames\/style.csstext\/css/]);
await expect(traceViewer.networkRequests).toContainText([/200GET\/frames\/script.jsapplication\/javascript/]);
await expect(traceViewer.networkRequests).toContainText([/frame.htmlGET200text\/html/]);
await expect(traceViewer.networkRequests).toContainText([/style.cssGET200text\/css/]);
await expect(traceViewer.networkRequests).toContainText([/script.jsGET200application\/javascript/]);
});
test('should have network request overrides', async ({ page, server, runAndTrace }) => {
@ -253,8 +253,8 @@ test('should have network request overrides', async ({ page, server, runAndTrace
});
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toContainText([/200GET\/frames\/frame.htmltext\/html/]);
await expect(traceViewer.networkRequests).toContainText([/GET\/frames\/style.cssx-unknown.*aborted/]);
await expect(traceViewer.networkRequests).toContainText([/frame.htmlGET200text\/html/]);
await expect(traceViewer.networkRequests).toContainText([/style.cssGETx-unknown.*aborted/]);
await expect(traceViewer.networkRequests).not.toContainText([/continued/]);
});
@ -265,8 +265,8 @@ test('should have network request overrides 2', async ({ page, server, runAndTra
});
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect.soft(traceViewer.networkRequests).toContainText([/200GET\/frames\/frame.htmltext\/html.*/]);
await expect.soft(traceViewer.networkRequests).toContainText([/200GET\/frames\/script.jsapplication\/javascript.*continued/]);
await expect.soft(traceViewer.networkRequests).toContainText([/frame.htmlGET200text\/html.*/]);
await expect.soft(traceViewer.networkRequests).toContainText([/script.jsGET200application\/javascript.*continued/]);
});
test('should show snapshot URL', async ({ page, runAndTrace, server }) => {
@ -937,7 +937,10 @@ test('should open trace-1.37', async ({ showTraceViewer }) => {
await expect(traceViewer.consoleLineMessages).toHaveText(['hello {foo: bar}']);
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toContainText([/200GET\/index.htmltext\/html/, /200GET\/style.cssx-unknown/]);
await expect(traceViewer.networkRequests).toContainText([
/index.htmlGET200text\/html/,
/style.cssGET200x-unknown/
]);
});
test('should prefer later resource request with the same method', async ({ page, server, runAndTrace }) => {