chore: network panel polish (#28924)
This commit is contained in:
parent
3851d9b897
commit
a0750b7854
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') }>
|
||||
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
../third_party/vscode/codicon.css
|
||||
../uiUtils.ts
|
||||
../ansi2html.ts
|
||||
../shared
|
||||
|
||||
[expandable.spec.tsx]
|
||||
***
|
||||
|
|
|
|||
69
packages/web/src/components/gridView.css
Normal file
69
packages/web/src/components/gridView.css
Normal 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;
|
||||
}
|
||||
107
packages/web/src/components/gridView.tsx
Normal file
107
packages/web/src/components/gridView.tsx
Normal 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') : '';
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
flex: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabbed-pane-tab {
|
||||
|
|
|
|||
|
|
@ -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 <></>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
99
packages/web/src/shared/resizeView.tsx
Normal file
99
packages/web/src/shared/resizeView.tsx
Normal 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>;
|
||||
};
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue