From 8d3481ea22808fbff6b8cd582a945cdc0a2851a1 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 16 Feb 2023 07:59:21 -0800 Subject: [PATCH] cherry-pick(#20937): chore: minor trace viewer UI tweaks --- packages/trace-viewer/src/index.tsx | 5 +- packages/trace-viewer/src/ui/actionList.css | 51 ------ packages/trace-viewer/src/ui/actionList.tsx | 98 ++--------- packages/trace-viewer/src/ui/callTab.css | 25 ++- packages/trace-viewer/src/ui/callTab.tsx | 6 +- .../trace-viewer/src/ui/copyToClipboard.css | 23 --- .../trace-viewer/src/ui/copyToClipboard.tsx | 1 - .../src/ui/networkResourceDetails.css | 5 +- packages/trace-viewer/src/ui/snapshotTab.css | 29 ++-- packages/trace-viewer/src/ui/timeline.tsx | 9 +- packages/trace-viewer/src/ui/workbench.css | 2 +- packages/trace-viewer/src/ui/workbench.tsx | 164 ++++++++++-------- packages/web/src/common.css | 9 + packages/web/src/components/listView.css | 65 +++++++ packages/web/src/components/listView.tsx | 146 ++++++++++++++++ packages/web/src/uiUtils.ts | 2 +- tests/config/traceViewerFixtures.ts | 4 +- tests/library/trace-viewer.spec.ts | 78 ++++----- 18 files changed, 406 insertions(+), 316 deletions(-) delete mode 100644 packages/trace-viewer/src/ui/copyToClipboard.css create mode 100644 packages/web/src/components/listView.css create mode 100644 packages/web/src/components/listView.tsx diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index bfec2f876b..2b93b3d6a6 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -15,11 +15,10 @@ */ import '@web/third_party/vscode/codicon.css'; -import { Workbench } from './ui/workbench'; -import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { applyTheme } from '@web/theme'; import '@web/common.css'; +import { WorkbenchLoader } from './ui/workbench'; (async () => { applyTheme(); @@ -37,5 +36,5 @@ import '@web/common.css'; setInterval(function() { fetch('ping'); }, 10000); } - ReactDOM.render(, document.querySelector('#root')); + ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/packages/trace-viewer/src/ui/actionList.css b/packages/trace-viewer/src/ui/actionList.css index 3e776f1639..ca4a2e81d6 100644 --- a/packages/trace-viewer/src/ui/actionList.css +++ b/packages/trace-viewer/src/ui/actionList.css @@ -14,50 +14,6 @@ limitations under the License. */ -.action-list { - background-color: var(--vscode-sideBar-background); - border-top: 1px solid var(--vscode-panel-border); -} - -.action-list-content { - display: flex; - flex-direction: column; - flex: auto; - position: relative; - user-select: none; - overflow: auto; - outline: none; -} - -.action-entry { - display: flex; - flex: none; - cursor: pointer; - align-items: center; - white-space: nowrap; - line-height: 28px; - padding-left: 5px; -} - -.action-entry.highlighted, -.action-entry.selected { - background-color: var(--vscode-list-inactiveSelectionBackground); -} - -.action-entry.highlighted { - background-color: var(--vscode-list-inactiveSelectionBackground); -} - -.action-list-content:focus .action-entry.selected { - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); - outline: 1px solid var(--vscode-focusBorder); -} - -.action-list-content:focus .action-entry.selected * { - color: var(--vscode-list-activeSelectionForeground); -} - .action-title { flex: auto; display: block; @@ -124,10 +80,3 @@ .action-entry .codicon-warning { color: darkorange; } - -.no-actions-entry { - flex: auto; - display: flex; - align-items: center; - justify-content: center; -} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 40d7138a28..29bfc60f74 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -16,6 +16,7 @@ import type { ActionTraceEvent } from '@trace/trace'; import { msToString } from '@web/uiUtils'; +import { ListView } from '@web/components/listView'; import * as React from 'react'; import './actionList.css'; import * as modelUtil from './modelUtil'; @@ -26,7 +27,6 @@ import type { Language } from '@isomorphic/locatorGenerators'; export interface ActionListProps { actions: ActionTraceEvent[], selectedAction: ActionTraceEvent | undefined, - highlightedAction: ActionTraceEvent | undefined, sdkLanguage: Language | undefined; onSelected: (action: ActionTraceEvent) => void, onHighlighted: (action: ActionTraceEvent | undefined) => void, @@ -36,92 +36,33 @@ export interface ActionListProps { export const ActionList: React.FC = ({ actions = [], selectedAction, - highlightedAction, sdkLanguage, onSelected = () => {}, onHighlighted = () => {}, setSelectedTab = () => {}, }) => { - const actionListRef = React.createRef(); - - React.useEffect(() => { - actionListRef.current?.focus(); - }, [selectedAction, actionListRef]); - - return
-
{ - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') - return; - event.stopPropagation(); - event.preventDefault(); - const index = selectedAction ? actions.indexOf(selectedAction) : -1; - let newIndex = index; - if (event.key === 'ArrowDown') { - if (index === -1) - newIndex = 0; - else - newIndex = Math.min(index + 1, actions.length - 1); - } - if (event.key === 'ArrowUp') { - if (index === -1) - newIndex = actions.length - 1; - else - newIndex = Math.max(index - 1, 0); - } - const element = actionListRef.current?.children.item(newIndex); - scrollIntoViewIfNeeded(element); - onSelected(actions[newIndex]); - }} - ref={actionListRef} - > - {actions.length === 0 &&
No actions recorded
} - {actions.map(action => )} -
-
; + return onSelected(action)} + onHighlighted={(action: ActionTraceEvent) => onHighlighted(action)} + itemKey={(action: ActionTraceEvent) => action.metadata.id} + itemRender={(action: ActionTraceEvent) => renderAction(action, sdkLanguage, setSelectedTab)} + showNoItemsMessage={true} + >; }; -const ActionListItem: React.FC<{ +const renderAction = ( action: ActionTraceEvent, - highlightedAction: ActionTraceEvent | undefined, - onSelected: (action: ActionTraceEvent) => void, - onHighlighted: (action: ActionTraceEvent | undefined) => void, - selectedAction: ActionTraceEvent | undefined, sdkLanguage: Language | undefined, - setSelectedTab: (tab: string) => void, -}> = ({ action, onSelected, onHighlighted, highlightedAction, selectedAction, sdkLanguage, setSelectedTab }) => { + setSelectedTab: (tab: string) => void +) => { const { metadata } = action; - const selectedSuffix = action === selectedAction ? ' selected' : ''; - const highlightedSuffix = action === highlightedAction ? ' highlighted' : ''; const error = metadata.error?.error?.message; const { errors, warnings } = modelUtil.stats(action); const locator = metadata.params.selector ? asLocator(sdkLanguage || 'javascript', metadata.params.selector) : undefined; - const divRef = React.useRef(null); - - React.useEffect(() => { - if (divRef.current && selectedAction === action) - scrollIntoViewIfNeeded(divRef.current); - }, [selectedAction, action]); - - return
onSelected(action)} - onMouseEnter={() => onHighlighted(action)} - onMouseLeave={() => (highlightedAction === action) && onHighlighted(undefined)} - ref={divRef} - > + return <>
{metadata.apiName} {locator &&
{locator}
} @@ -133,14 +74,5 @@ const ActionListItem: React.FC<{ {!!warnings &&
{warnings}
}
{error &&
} -
; + ; }; - -function scrollIntoViewIfNeeded(element?: Element | null) { - if (!element) - return; - if ((element as any)?.scrollIntoViewIfNeeded) - (element as any).scrollIntoViewIfNeeded(false); - else - element?.scrollIntoView(); -} diff --git a/packages/trace-viewer/src/ui/callTab.css b/packages/trace-viewer/src/ui/callTab.css index 7ed79482eb..d04c03b806 100644 --- a/packages/trace-viewer/src/ui/callTab.css +++ b/packages/trace-viewer/src/ui/callTab.css @@ -41,10 +41,8 @@ font-weight: bold; text-transform: uppercase; font-size: 10px; - border-bottom: 1px solid var(--vscode-panel-border); - background-color: var(--vscode-sideBar-background); color: var(--vscode-sideBarTitle-foreground); - line-height: 18px; + line-height: 24px; } .call-line { @@ -68,6 +66,7 @@ } .call-value { + margin-left: 2px; text-overflow: ellipsis; overflow: hidden; flex: 1; @@ -77,18 +76,18 @@ content: '\00a0'; } -.call-line .datetime, -.call-line .string, -.call-line .locator { +.call-value.datetime, +.call-value.string, +.call-value.locator { color: var(--orange); } -.call-line .number, -.call-line .bigint, -.call-line .boolean, -.call-line .symbol, -.call-line .undefined, -.call-line .function, -.call-line .object { +.call-value.number, +.call-value.bigint, +.call-value.boolean, +.call-value.symbol, +.call-value.undefined, +.call-value.function, +.call-value.object { color: var(--blue); } diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 0a6c563cdd..c604e7393e 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -46,8 +46,8 @@ export const CallTab: React.FunctionComponent<{
{action.metadata.apiName}
{<>
Time
- {action.metadata.wallTime &&
wall time: {wallTime}
} -
duration: {duration}
+ {action.metadata.wallTime &&
wall time:{wallTime}
} +
duration:{duration}
} { !!paramKeys.length &&
Parameters
} { @@ -82,7 +82,7 @@ function renderProperty(property: Property, key: string) { text = `"${text}"`; return (
- {property.name}: {text} + {property.name}:{text} { ['string', 'number', 'object', 'locator'].includes(property.type) && } diff --git a/packages/trace-viewer/src/ui/copyToClipboard.css b/packages/trace-viewer/src/ui/copyToClipboard.css deleted file mode 100644 index dc08d49410..0000000000 --- a/packages/trace-viewer/src/ui/copyToClipboard.css +++ /dev/null @@ -1,23 +0,0 @@ -/* - 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. -*/ - -.codicon-check { - color: var(--green); -} - -.codicon-close { - color: var(--red); -} diff --git a/packages/trace-viewer/src/ui/copyToClipboard.tsx b/packages/trace-viewer/src/ui/copyToClipboard.tsx index d8a61470d7..96de5f3822 100644 --- a/packages/trace-viewer/src/ui/copyToClipboard.tsx +++ b/packages/trace-viewer/src/ui/copyToClipboard.tsx @@ -15,7 +15,6 @@ */ import * as React from 'react'; -import './copyToClipboard.css'; export const CopyToClipboard: React.FunctionComponent<{ value: string, diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 082bd19fcf..89d67f2175 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -15,11 +15,10 @@ */ .network-request { - white-space: nowrap; display: flex; + white-space: nowrap; align-items: center; padding: 0 3px; - width: 100%; flex: none; outline: none; } @@ -58,6 +57,7 @@ .network-request-details { width: 100%; user-select: text; + line-height: 24px; } .network-request-details-url { @@ -84,6 +84,7 @@ background-color: var(--vscode-sideBar-background); border: black 1px solid; max-height: 500px; + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } .network-request-details-header { diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index b023ca3f37..607666cf1a 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -21,6 +21,7 @@ align-items: stretch; outline: none; --window-header-height: 40px; + overflow: hidden; } .snapshot-controls { @@ -85,18 +86,25 @@ iframe#snapshot { .popout-icon { position: absolute; - top: 10px; - right: 10px; - color: var(--gray); + top: 0; + right: 0; + color: var(--vscode-sideBarTitle-foreground); font-size: 14px; + z-index: 100; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; } .popout-icon:not(.popout-disabled):hover { - color: var(--blue); + color: var(--vscode-foreground); } .popout-icon.popout-disabled { - opacity: 0.7; + opacity: var(--vscode-disabledForeground); } .window-dot { @@ -109,11 +117,11 @@ iframe#snapshot { } .window-address-bar { - background-color: white; + background-color: var(--vscode-input-background); border-radius: 12.5px; - color: #1c1e21; + color: var(--vscode-input-foreground); flex: 1 0; - font: 400 13px Arial,sans-serif; + font: 400 16px Arial,sans-serif; margin: 0 16px 0 8px; padding: 5px 15px; overflow: hidden; @@ -121,11 +129,6 @@ iframe#snapshot { white-space: nowrap; } -body.dark-mode .window-address-bar { - background-color: #1b1b1d; - color: #e3e3e3; -} - .window-menu-bar { background-color: #aaa; display: block; diff --git a/packages/trace-viewer/src/ui/timeline.tsx b/packages/trace-viewer/src/ui/timeline.tsx index 227cb728f5..6505bd7885 100644 --- a/packages/trace-viewer/src/ui/timeline.tsx +++ b/packages/trace-viewer/src/ui/timeline.tsx @@ -41,10 +41,8 @@ export const Timeline: React.FunctionComponent<{ context: MultiTraceModel, boundaries: Boundaries, selectedAction: ActionTraceEvent | undefined, - highlightedAction: ActionTraceEvent | undefined, onSelected: (action: ActionTraceEvent) => void, - onHighlighted: (action: ActionTraceEvent | undefined) => void, -}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onHighlighted }) => { +}> = ({ context, boundaries, selectedAction, onSelected }) => { const [measure, ref] = useMeasure(); const barsRef = React.useRef(null); @@ -92,7 +90,7 @@ export const Timeline: React.FunctionComponent<{ }, [context, boundaries, measure.width]); const hoveredBar = hoveredBarIndex !== undefined ? bars[hoveredBarIndex] : undefined; - let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === (highlightedAction || selectedAction)); + let targetBar: TimelineBar | undefined = bars.find(bar => bar.action === selectedAction); targetBar = hoveredBar || targetBar; const findHoveredBarIndex = (x: number, y: number) => { @@ -132,14 +130,11 @@ export const Timeline: React.FunctionComponent<{ const index = findHoveredBarIndex(x, y); setPreviewPoint({ x, clientY: event.clientY }); setHoveredBarIndex(index); - if (typeof index === 'number') - onHighlighted(bars[index].action); }; const onMouseLeave = () => { setPreviewPoint(undefined); setHoveredBarIndex(undefined); - onHighlighted(undefined); }; const onClick = (event: React.MouseEvent) => { diff --git a/packages/trace-viewer/src/ui/workbench.css b/packages/trace-viewer/src/ui/workbench.css index 0c250d2299..bae050a2e5 100644 --- a/packages/trace-viewer/src/ui/workbench.css +++ b/packages/trace-viewer/src/ui/workbench.css @@ -75,7 +75,7 @@ contain: size; } -.workbench .header { +.header { display: flex; background-color: #000; flex: none; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 4f436cf5d1..e37994c6d3 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -33,15 +33,11 @@ import { Timeline } from './timeline'; import './workbench.css'; import { toggleTheme } from '@web/theme'; -export const Workbench: React.FunctionComponent<{ +export const WorkbenchLoader: React.FunctionComponent<{ }> = () => { const [traceURLs, setTraceURLs] = React.useState([]); const [uploadedTraceNames, setUploadedTraceNames] = React.useState([]); const [model, setModel] = React.useState(emptyModel); - const [selectedAction, setSelectedAction] = React.useState(); - const [highlightedAction, setHighlightedAction] = React.useState(); - const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); - const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState('logs'); const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 }); const [dragOver, setDragOver] = React.useState(false); const [processingErrorMessage, setProcessingErrorMessage] = React.useState(null); @@ -67,7 +63,6 @@ export const Workbench: React.FunctionComponent<{ window.history.pushState({}, '', href); setTraceURLs(blobUrls); setUploadedTraceNames(fileNames); - setSelectedAction(undefined); setDragOver(false); setProcessingErrorMessage(null); }; @@ -134,24 +129,6 @@ export const Workbench: React.FunctionComponent<{ })(); }, [traceURLs, uploadedTraceNames]); - const boundaries = { minimum: model.startTime, maximum: model.endTime }; - - - // Leave some nice free space on the right hand side. - boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; - const { errors, warnings } = selectedAction ? modelUtil.stats(selectedAction) : { errors: 0, warnings: 0 }; - const consoleCount = errors + warnings; - const networkCount = selectedAction ? modelUtil.resourcesForAction(selectedAction).length : 0; - - const tabs = [ - { id: 'logs', title: 'Call', count: 0, render: () => }, - { id: 'console', title: 'Console', count: consoleCount, render: () => }, - { id: 'network', title: 'Network', count: networkCount, render: () => }, - ]; - - if (model.hasSource) - tabs.push({ id: 'source', title: 'Source', count: 0, render: () => }); - return
{ event.preventDefault(); setDragOver(true); }}>
🎭
@@ -160,55 +137,7 @@ export const Workbench: React.FunctionComponent<{
toggleTheme()}>
-
- setSelectedAction(action)} - onHighlighted={action => setHighlightedAction(action)} - /> -
- - - - - - { - setSelectedAction(action); - }} - onHighlighted={action => setHighlightedAction(action)} - setSelectedTab={setSelectedPropertiesTab} - /> }, - { id: 'metadata', title: 'Metadata', count: 0, render: () =>
-
Time
- {model.wallTime &&
start time: {new Date(model.wallTime).toLocaleString()}
} -
duration: {msToString(model.endTime - model.startTime)}
-
Browser
-
engine: {model.browserName}
- {model.platform &&
platform: {model.platform}
} - {model.options.userAgent &&
user agent: {model.options.userAgent}
} -
Viewport
- {model.options.viewport &&
width: {model.options.viewport.width}
} - {model.options.viewport &&
height: {model.options.viewport.height}
} -
is mobile: {String(!!model.options.isMobile)}
- {model.options.deviceScaleFactor &&
device scale: {String(model.options.deviceScaleFactor)}
} -
Counts
-
pages: {model.pages.length}
-
actions: {model.actions.length}
-
events: {model.events.length}
-
}, - ] - } selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/> -
+ {!!progress.total &&
} @@ -241,4 +170,91 @@ export const Workbench: React.FunctionComponent<{
; }; -const emptyModel = new MultiTraceModel([]); +export const Workbench: React.FunctionComponent<{ + model: MultiTraceModel, + view: 'embedded' | 'standalone' +}> = ({ model, view }) => { + const [selectedAction, setSelectedAction] = React.useState(); + const [highlightedAction, setHighlightedAction] = React.useState(); + const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); + const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState('logs'); + + const activeAction = highlightedAction || selectedAction; + const boundaries = { minimum: model.startTime, maximum: model.endTime }; + + // Leave some nice free space on the right hand side. + boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + const { errors, warnings } = activeAction ? modelUtil.stats(activeAction) : { errors: 0, warnings: 0 }; + const consoleCount = errors + warnings; + const networkCount = activeAction ? modelUtil.resourcesForAction(activeAction).length : 0; + + const tabs = [ + { id: 'logs', title: 'Call', count: 0, render: () => }, + { id: 'console', title: 'Console', count: consoleCount, render: () => }, + { id: 'network', title: 'Network', count: networkCount, render: () => }, + ]; + + if (model.hasSource) + tabs.push({ id: 'source', title: 'Source', count: 0, render: () => }); + + return
+
+ setSelectedAction(action)} + /> +
+ + + + + + { + setSelectedAction(action); + }} + onHighlighted={action => { + setHighlightedAction(action); + }} + setSelectedTab={setSelectedPropertiesTab} + /> }, + { id: 'metadata', title: 'Metadata', count: 0, render: () =>
+
Time
+ {model.wallTime &&
start time:{new Date(model.wallTime).toLocaleString()}
} +
duration:{msToString(model.endTime - model.startTime)}
+
Browser
+
engine:{model.browserName}
+ {model.platform &&
platform:{model.platform}
} + {model.options.userAgent &&
user agent:{model.options.userAgent}
} +
Viewport
+ {model.options.viewport &&
width:{model.options.viewport.width}
} + {model.options.viewport &&
height:{model.options.viewport.height}
} +
is mobile:{String(!!model.options.isMobile)}
+ {model.options.deviceScaleFactor &&
device scale:{String(model.options.deviceScaleFactor)}
} +
Counts
+
pages:{model.pages.length}
+
actions:{model.actions.length}
+
events:{model.events.length}
+
}, + ] + } selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}/> +
+
; +}; + +export const emptyModel = new MultiTraceModel([]); + +export async function loadSingleTraceFile(url: string): Promise { + const params = new URLSearchParams(); + params.set('trace', url); + const response = await fetch(`context?${params.toString()}`); + const contextEntry = await response.json() as ContextEntry; + return new MultiTraceModel([contextEntry]); +} diff --git a/packages/web/src/common.css b/packages/web/src/common.css index 8885b992d9..fef540ecd3 100644 --- a/packages/web/src/common.css +++ b/packages/web/src/common.css @@ -96,3 +96,12 @@ svg { flex: auto; position: relative; } + +.codicon-check { + color: var(--green); +} + +.codicon-close, +.codicon-error { + color: var(--red); +} diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css new file mode 100644 index 0000000000..fad0fd62f6 --- /dev/null +++ b/packages/web/src/components/listView.css @@ -0,0 +1,65 @@ +/* + 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. +*/ + +.list-view { + border-top: 1px solid var(--vscode-panel-border); +} + +.list-view-content { + display: flex; + flex-direction: column; + flex: auto; + position: relative; + user-select: none; + overflow: auto; + outline: none; +} + +.list-view-entry { + display: flex; + flex: none; + cursor: pointer; + align-items: center; + white-space: nowrap; + line-height: 28px; + padding-left: 5px; +} + +.list-view-entry.highlighted, +.list-view-entry.selected { + background-color: var(--vscode-list-inactiveSelectionBackground); +} + +.list-view-entry.highlighted { + background-color: var(--vscode-list-inactiveSelectionBackground); +} + +.list-view-content:focus .list-view-entry.selected { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + outline: 1px solid var(--vscode-focusBorder); +} + +.list-view-content:focus .list-view-entry.selected * { + color: var(--vscode-list-activeSelectionForeground); +} + +.list-view-empty { + flex: auto; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx new file mode 100644 index 0000000000..1d3c6aaf0d --- /dev/null +++ b/packages/web/src/components/listView.tsx @@ -0,0 +1,146 @@ +/* + 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.css'; + +export type ListViewProps = { + items: any[], + itemKey: (item: any) => string, + itemRender: (item: any) => React.ReactNode, + itemIcon?: (item: any) => string | undefined, + itemIndent?: (item: any) => number | undefined, + selectedItem?: any, + onAccepted?: (item: any) => void, + onSelected?: (item: any) => void, + onHighlighted?: (item: any | undefined) => void, + showNoItemsMessage?: boolean, +}; + +export const ListView: React.FC = ({ + items = [], + itemKey, + itemRender, + itemIcon, + itemIndent, + selectedItem, + onAccepted, + onSelected, + onHighlighted, + showNoItemsMessage, +}) => { + const itemListRef = React.createRef(); + const [highlightedItem, setHighlightedItem] = React.useState(); + + return
+
onAccepted?.(selectedItem)} + onKeyDown={event => { + if (event.key === 'Enter') { + onAccepted?.(selectedItem); + return; + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') + return; + event.stopPropagation(); + event.preventDefault(); + const index = selectedItem ? items.indexOf(selectedItem) : -1; + let newIndex = index; + if (event.key === 'ArrowDown') { + if (index === -1) + newIndex = 0; + else + newIndex = Math.min(index + 1, items.length - 1); + } + if (event.key === 'ArrowUp') { + if (index === -1) + newIndex = items.length - 1; + else + newIndex = Math.max(index - 1, 0); + } + const element = itemListRef.current?.children.item(newIndex); + scrollIntoViewIfNeeded(element); + onSelected?.(items[newIndex]); + }} + ref={itemListRef} + > + {showNoItemsMessage && items.length === 0 &&
No items
} + {items.map(item => onSelected?.(item)} + onMouseEnter={() => { + setHighlightedItem(item); + onHighlighted?.(item); + }} + onMouseLeave={() => { + setHighlightedItem(undefined); + onHighlighted?.(undefined); + }} + > + {itemRender(item)} + )} +
+
; +}; + +const ListItemView: React.FC<{ + key: string, + icon: string | undefined, + indent: number | undefined, + isHighlighted: boolean, + isSelected: boolean, + onSelected: () => void, + onMouseEnter: () => void, + onMouseLeave: () => void, + children: React.ReactNode | React.ReactNode[], +}> = ({ key, icon, indent, onSelected, onMouseEnter, onMouseLeave, isHighlighted, isSelected, children }) => { + const selectedSuffix = isSelected ? ' selected' : ''; + const highlightedSuffix = isHighlighted ? ' highlighted' : ''; + const divRef = React.useRef(null); + + React.useEffect(() => { + if (divRef.current && isSelected) + scrollIntoViewIfNeeded(divRef.current); + }, [isSelected]); + + return
+ {indent ?
: undefined} +
+ {typeof children === 'string' ?
{children}
: children} +
; +}; + +function scrollIntoViewIfNeeded(element?: Element | null) { + if (!element) + return; + if ((element as any)?.scrollIntoViewIfNeeded) + (element as any).scrollIntoViewIfNeeded(false); + else + element?.scrollIntoView(); +} diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 0bbd672de7..9af12be9cd 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -19,7 +19,7 @@ export function msToString(ms: number): string { return '-'; if (ms === 0) - return '0ms'; + return '0'; if (ms < 1000) return ms.toFixed(0) + 'ms'; diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index bcfe79ebfc..5de26e190c 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -55,13 +55,13 @@ class TraceViewerPage { } async actionIconsText(action: string) { - const entry = await this.page.waitForSelector(`.action-entry:has-text("${action}")`); + const entry = await this.page.waitForSelector(`.list-view-entry:has-text("${action}")`); await entry.waitForSelector('.action-icon-value:visible'); return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent)); } async actionIcons(action: string) { - return await this.page.waitForSelector(`.action-entry:has-text("${action}") .action-icons`); + return await this.page.waitForSelector(`.list-view-entry:has-text("${action}") .action-icons`); } async selectAction(title: string, ordinal: number = 0) { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 3c688e199c..6b541c5bbf 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -143,29 +143,29 @@ test('should open console errors on click', async ({ showTraceViewer, browserNam expect(await traceViewer.page.waitForSelector('.console-tab')).toBeTruthy(); }); -test('should show params and return value', async ({ showTraceViewer, browserName }) => { +test('should show params and return value', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.selectAction('page.evaluate'); await expect(traceViewer.callLines).toHaveText([ /page.evaluate/, - /wall time: [0-9/:,APM ]+/, - /duration: [\d]+ms/, - /expression: "\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/, - 'isFunction: true', - 'arg: {"a":"paramA","b":4}', - 'value: "return paramA"' + /wall time:[0-9/:,APM ]+/, + /duration:[\d]+ms/, + /expression:"\({↵ a↵ }\) => {↵ console\.log\(\'Info\'\);↵ console\.warn\(\'Warning\'\);↵ console/, + 'isFunction:true', + 'arg:{"a":"paramA","b":4}', + 'value:"return paramA"' ]); await traceViewer.selectAction(`locator('button')`); await expect(traceViewer.callLines).toContainText([ /expect.toHaveText/, - /wall time: [0-9/:,APM ]+/, - /duration: [\d]+ms/, - /locator: locator\('button'\)/, - /expression: "to.have.text"/, - /timeout: 10000/, - /matches: true/, - /received: "Click"/, + /wall time:[0-9/:,APM ]+/, + /duration:[\d]+ms/, + /locator:locator\('button'\)/, + /expression:"to.have.text"/, + /timeout:10000/, + /matches:true/, + /received:"Click"/, ]); }); @@ -174,12 +174,12 @@ test('should show null as a param', async ({ showTraceViewer, browserName }) => await traceViewer.selectAction('page.evaluate', 1); await expect(traceViewer.callLines).toHaveText([ /page.evaluate/, - /wall time: [0-9/:,APM ]+/, - /duration: [\d]+ms/, - 'expression: "() => 1 + 1"', - 'isFunction: true', - 'arg: null', - 'value: 2' + /wall time:[0-9/:,APM ]+/, + /duration:[\d]+ms/, + 'expression:"() => 1 + 1"', + 'isFunction:true', + 'arg:null', + 'value:2' ]); }); @@ -604,15 +604,15 @@ test('should include metainfo', async ({ showTraceViewer, browserName }) => { const traceViewer = await showTraceViewer([traceFile]); await traceViewer.page.locator('text=Metadata').click(); const callLine = traceViewer.page.locator('.call-line'); - await expect(callLine.getByText('start time')).toHaveText(/start time: [\d/,: ]+/); - await expect(callLine.getByText('duration')).toHaveText(/duration: [\dms]+/); - await expect(callLine.getByText('engine')).toHaveText(/engine: [\w]+/); - await expect(callLine.getByText('platform')).toHaveText(/platform: [\w]+/); - await expect(callLine.getByText('width')).toHaveText(/width: [\d]+/); - await expect(callLine.getByText('height')).toHaveText(/height: [\d]+/); - await expect(callLine.getByText('pages')).toHaveText(/pages: 1/); - await expect(callLine.getByText('actions')).toHaveText(/actions: [\d]+/); - await expect(callLine.getByText('events')).toHaveText(/events: [\d]+/); + await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); + await expect(callLine.getByText('duration')).toHaveText(/duration:[\dms]+/); + await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/); + await expect(callLine.getByText('platform')).toHaveText(/platform:[\w]+/); + await expect(callLine.getByText('width')).toHaveText(/width:[\d]+/); + await expect(callLine.getByText('height')).toHaveText(/height:[\d]+/); + await expect(callLine.getByText('pages')).toHaveText(/pages:1/); + await expect(callLine.getByText('actions')).toHaveText(/actions:[\d]+/); + await expect(callLine.getByText('events')).toHaveText(/events:[\d]+/); }); test('should open two trace files', async ({ context, page, request, server, showTraceViewer }, testInfo) => { @@ -655,16 +655,16 @@ test('should open two trace files', async ({ context, page, request, server, sho await traceViewer.page.locator('text=Metadata').click(); const callLine = traceViewer.page.locator('.call-line'); // Should get metadata from the context trace - await expect(callLine.getByText('start time')).toHaveText(/start time: [\d/,: ]+/); + await expect(callLine.getByText('start time')).toHaveText(/start time:[\d/,: ]+/); // duration in the metatadata section - await expect(callLine.getByText('duration').first()).toHaveText(/duration: [\dms]+/); - await expect(callLine.getByText('engine')).toHaveText(/engine: [\w]+/); - await expect(callLine.getByText('platform')).toHaveText(/platform: [\w]+/); - await expect(callLine.getByText('width')).toHaveText(/width: [\d]+/); - await expect(callLine.getByText('height')).toHaveText(/height: [\d]+/); - await expect(callLine.getByText('pages')).toHaveText(/pages: 1/); - await expect(callLine.getByText('actions')).toHaveText(/actions: 6/); - await expect(callLine.getByText('events')).toHaveText(/events: [\d]+/); + await expect(callLine.getByText('duration').first()).toHaveText(/duration:[\dms]+/); + await expect(callLine.getByText('engine')).toHaveText(/engine:[\w]+/); + await expect(callLine.getByText('platform')).toHaveText(/platform:[\w]+/); + await expect(callLine.getByText('width')).toHaveText(/width:[\d]+/); + await expect(callLine.getByText('height')).toHaveText(/height:[\d]+/); + await expect(callLine.getByText('pages')).toHaveText(/pages:1/); + await expect(callLine.getByText('actions')).toHaveText(/actions:6/); + await expect(callLine.getByText('events')).toHaveText(/events:[\d]+/); }); test('should include requestUrl in route.fulfill', async ({ page, runAndTrace, browserName }) => { @@ -702,7 +702,7 @@ test('should include requestUrl in route.continue', async ({ page, runAndTrace, await traceViewer.page.locator('.tab-label', { hasText: 'Call' }).click(); const callLine = traceViewer.page.locator('.call-line'); await expect(callLine.getByText('requestUrl')).toContainText('http://test.com'); - await expect(callLine.getByText(/^url: .*/)).toContainText(server.EMPTY_PAGE); + await expect(callLine.getByText(/^url:.*/)).toContainText(server.EMPTY_PAGE); }); test('should include requestUrl in route.abort', async ({ page, runAndTrace, server }) => {