chore: start putting tv-recorder ui together (#32776)

This commit is contained in:
Pavel Feldman 2024-09-23 19:13:45 -07:00 committed by GitHub
parent 7c3dd70bf6
commit 8649b13f25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 404 additions and 98 deletions

View file

@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
super();
this._transport = transport;
this._transport.eventSink.resolve(this);
this._tracePage = tracePage;
this._traceServer = traceServer;
this.wsEndpointForTest = wsEndpointForTest;
@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea
class RecorderTransport implements Transport {
private _connected = new ManualPromise<void>();
readonly eventSink = new ManualPromise<EventEmitter>();
constructor() {
}
@ -103,6 +105,8 @@ class RecorderTransport implements Transport {
}
async dispatch(method: string, params: any): Promise<any> {
const eventSink = await this.eventSink;
eventSink.emit('event', { event: method, params });
}
onclose() {

View file

@ -19,6 +19,7 @@ import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { SplitView } from '@web/components/splitView';
import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar';
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import * as React from 'react';
import { CallLogView } from './callLog';
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
if (source)
return source;
}
const source: Source = {
id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
return source;
return emptySource();
}, [sources, fileId]);
const [locator, setLocator] = React.useState('');
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
}}></ToolbarButton>
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => {
setFileId(event.target.selectedOptions[0].value);
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } });
}}>{renderSourceOptions(sources)}</select>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
}} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' });
}}></ToolbarButton>
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
/>
</div>;
};
function renderSourceOptions(sources: Source[]): React.ReactNode {
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
const renderOption = (source: Source): React.ReactNode => (
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
);
const hasGroup = sources.some(s => s.group);
if (hasGroup) {
const groups = new Set(sources.map(s => s.group));
return [...groups].filter(Boolean).map(group => (
<optgroup label={group} key={group}>
{sources.filter(s => s.group === group).map(source => renderOption(source))}
</optgroup>
));
}
return sources.map(source => renderOption(source));
}

View file

@ -32,9 +32,9 @@ export interface ActionListProps {
selectedTime: Boundaries | undefined,
setSelectedTime: (time: Boundaries | undefined) => void,
sdkLanguage: Language | undefined;
onSelected: (action: ActionTraceEventInContext) => void,
onHighlighted: (action: ActionTraceEventInContext | undefined) => void,
revealConsole: () => void,
onSelected?: (action: ActionTraceEventInContext) => void,
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
revealConsole?: () => void,
isLive?: boolean,
}
@ -67,8 +67,8 @@ export const ActionList: React.FC<ActionListProps> = ({
treeState={treeState}
setTreeState={setTreeState}
selectedItem={selectedItem}
onSelected={item => onSelected(item.action!)}
onHighlighted={item => onHighlighted(item?.action)}
onSelected={item => onSelected?.(item.action!)}
onHighlighted={item => onHighlighted?.(item?.action)}
onAccepted={item => setSelectedTime({ minimum: item.action!.startTime, maximum: item.action!.endTime })}
isError={item => !!item.action?.error?.message}
isVisible={item => !selectedTime || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum)}

View file

@ -107,7 +107,7 @@ export const ConsoleTab: React.FunctionComponent<{
boundaries: Boundaries,
consoleModel: ConsoleTabModel,
selectedTime: Boundaries | undefined,
onEntryHovered: (entry: ConsoleEntry | undefined) => void,
onEntryHovered?: (entry: ConsoleEntry | undefined) => void,
onAccepted: (entry: ConsoleEntry) => void,
}> = ({ consoleModel, boundaries, onEntryHovered, onAccepted }) => {
if (!consoleModel.entries.length)

View file

@ -65,7 +65,7 @@ export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedT
export const NetworkTab: React.FunctionComponent<{
boundaries: Boundaries,
networkModel: NetworkTabModel,
onEntryHovered: (entry: Entry | undefined) => void,
onEntryHovered?: (entry: Entry | undefined) => void,
}> = ({ boundaries, networkModel, onEntryHovered }) => {
const [sorting, setSorting] = React.useState<Sorting | undefined>(undefined);
const [selectedEntry, setSelectedEntry] = React.useState<RenderedEntry | undefined>(undefined);
@ -95,7 +95,7 @@ export const NetworkTab: React.FunctionComponent<{
items={renderedEntries}
selectedItem={selectedEntry}
onSelected={item => setSelectedEntry(item)}
onHighlighted={item => onEntryHovered(item?.resource)}
onHighlighted={item => onEntryHovered?.(item?.resource)}
columns={visibleColumns(!!selectedEntry, renderedEntries)}
columnTitle={columnTitle}
columnWidths={columnWidths}

View file

@ -14,52 +14,52 @@
limitations under the License.
*/
import * as React from 'react';
import './recorderView.css';
import { MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench';
import type { Language } from '@isomorphic/locatorGenerators';
import type { Mode, Source } from '@recorder/recorderTypes';
import { SplitView } from '@web/components/splitView';
import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { TabbedPane } from '@web/components/tabbedPane';
import { sha1, useSetting } from '@web/uiUtils';
import * as React from 'react';
import type { ContextEntry } from '../entries';
import type { Boundaries } from '../geometry';
import { ActionList } from './actionList';
import { ConsoleTab, useConsoleTabModel } from './consoleTab';
import { InspectorTab } from './inspectorTab';
import type * as modelUtil from './modelUtil';
import type { SourceLocation } from './modelUtil';
import { MultiTraceModel } from './modelUtil';
import { NetworkTab, useNetworkTabModel } from './networkTab';
import './recorderView.css';
import { collectSnapshots, extendSnapshot, SnapshotView } from './snapshotTab';
import { SourceTab } from './sourceTab';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
import { SourceChooser } from '@web/components/sourceChooser';
const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws');
const trace = searchParams.get('trace') + '.json';
const traceLocation = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]);
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean, sha1: string } | undefined>();
const [mode, setMode] = React.useState<Mode>('none');
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources }));
setConnection(new Connection(webSocket, { setMode, setSources }));
return () => {
webSocket.close();
};
}, []);
React.useEffect(() => {
if (!connection)
return;
connection.setMode('recording');
}, [connection]);
return <div className='vbox workbench-loader'>
<TraceView
traceLocation={trace}
sources={sources} />
</div>;
};
export const TraceView: React.FC<{
traceLocation: string,
sources: Source[],
}> = ({ traceLocation, sources }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
@ -67,8 +67,9 @@ export const TraceView: React.FC<{
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
const result = await loadSingleTraceFile(traceLocation);
if (result.sha1 !== model?.sha1)
setModel({ ...result, isLive: true });
} catch {
setModel(undefined);
} finally {
@ -79,10 +80,94 @@ export const TraceView: React.FC<{
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [counter, traceLocation]);
}, [counter, model]);
return <div className='vbox workbench-loader'>
<Workbench
key='workbench'
mode={mode}
setMode={mode => connection?.setMode(mode)}
model={model?.model}
sources={sources}
/>
</div>;
};
async function loadSingleTraceFile(url: string): Promise<{ model: MultiTraceModel, sha1: string }> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
const tokens: string[] = [];
for (const entry of contextEntries) {
entry.actions.forEach(a => tokens.push(a.type + '@' + a.startTime + '-' + a.endTime));
entry.events.forEach(e => tokens.push(e.type + '@' + e.time));
}
return { model: new MultiTraceModel(contextEntries), sha1: await sha1(tokens.join('|')) };
}
export const Workbench: React.FunctionComponent<{
mode: Mode,
setMode: (mode: Mode) => void,
model?: modelUtil.MultiTraceModel,
sources: Source[],
}> = ({ mode, setMode, model, sources }) => {
const [fileId, setFileId] = React.useState<string | undefined>();
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [selectedPropertiesTab, setSelectedPropertiesTab] = useSetting<string>('recorderPropertiesTab', 'source');
const [isInspecting, setIsInspectingState] = React.useState(false);
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
const [selectedTime, setSelectedTime] = React.useState<Boundaries | undefined>();
const sourceModel = React.useRef(new Map<string, modelUtil.SourceModel>());
const setSelectedAction = React.useCallback((action: modelUtil.ActionTraceEventInContext | undefined) => {
setSelectedCallId(action?.callId);
}, []);
const selectedAction = React.useMemo(() => {
return model?.actions.find(a => a.callId === selectedCallId);
}, [model, selectedCallId]);
const onActionSelected = React.useCallback((action: modelUtil.ActionTraceEventInContext) => {
setSelectedAction(action);
}, [setSelectedAction]);
const selectPropertiesTab = React.useCallback((tab: string) => {
setSelectedPropertiesTab(tab);
if (tab !== 'inspector')
setIsInspectingState(false);
}, [setSelectedPropertiesTab]);
const setIsInspecting = React.useCallback((value: boolean) => {
if (!isInspecting && value)
selectPropertiesTab('inspector');
setIsInspectingState(value);
}, [setIsInspectingState, selectPropertiesTab, isInspecting]);
const locatorPicked = React.useCallback((locator: string) => {
setHighlightedLocator(locator);
selectPropertiesTab('inspector');
}, [selectPropertiesTab]);
const consoleModel = useConsoleTabModel(model, selectedTime);
const networkModel = useNetworkTabModel(model, selectedTime);
const sdkLanguage = model?.sdkLanguage || 'javascript';
const inspectorTab: TabbedPaneTabModel = {
id: 'inspector',
title: 'Locator',
render: () => <InspectorTab
sdkLanguage={sdkLanguage}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={setHighlightedLocator} />,
};
const source = React.useMemo(() => sources.find(s => s.id === fileId) || sources[0], [sources, fileId]);
const fallbackLocation = React.useMemo(() => {
if (!sources.length)
if (!source)
return undefined;
const fallbackLocation: SourceLocation = {
file: '',
@ -90,37 +175,178 @@ export const TraceView: React.FC<{
column: 0,
source: {
errors: [],
content: sources[0].text
content: source.text
}
};
return fallbackLocation;
}, [sources]);
}, [source]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
const sourceTab: TabbedPaneTabModel = {
id: 'source',
title: 'Source',
render: () => <SourceTab
sources={sourceModel.current}
stackFrameLocation={'right'}
fallbackLocation={fallbackLocation}
isLive={true}
hideTimeline={true}
/>;
/>
};
const consoleTab: TabbedPaneTabModel = {
id: 'console',
title: 'Console',
count: consoleModel.entries.length,
render: () => <ConsoleTab
consoleModel={consoleModel}
boundaries={boundaries}
selectedTime={selectedTime}
onAccepted={m => setSelectedTime({ minimum: m.timestamp, maximum: m.timestamp })}
/>
};
const networkTab: TabbedPaneTabModel = {
id: 'network',
title: 'Network',
count: networkModel.resources.length,
render: () => <NetworkTab boundaries={boundaries} networkModel={networkModel} />
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
const tabs: TabbedPaneTabModel[] = [
sourceTab,
inspectorTab,
consoleTab,
networkTab,
];
const { boundaries } = React.useMemo(() => {
const boundaries = { minimum: model?.startTime || 0, maximum: model?.endTime || 30000 };
if (boundaries.minimum > boundaries.maximum) {
boundaries.minimum = 0;
boundaries.maximum = 30000;
}
// Leave some nice free space on the right hand side.
boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20;
return { boundaries };
}, [model]);
const actionList = <ActionList
sdkLanguage={sdkLanguage}
actions={model?.actions || []}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
onSelected={onActionSelected}
revealConsole={() => selectPropertiesTab('console')}
isLive={true}
/>;
const actionsTab: TabbedPaneTabModel = {
id: 'actions',
title: 'Actions',
component: actionList,
};
const toolbar = <Toolbar sidebarBackground>
<div style={{ width: 4 }}></div>
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording'} onClick={() => {
setMode(mode === 'recording' ? 'standby' : 'recording');
}}>Record</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='inspect' title='Pick locator' toggled={isInspecting} onClick={() => {
setIsInspecting(!isInspecting);
}} />
<ToolbarButton icon='eye' title='Assert visibility' onClick={() => {
}} />
<ToolbarButton icon='whole-word' title='Assert text' onClick={() => {
}} />
<ToolbarButton icon='symbol-constant' title='Assert value' onClick={() => {
}} />
<ToolbarSeparator />
<ToolbarButton icon='files' title='Copy' onClick={() => {
}} />
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
}} />
<ToolbarButton icon='clear-all' title='Clear' onClick={() => {
}}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar>;
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
const propertiesTabbedPane = <TabbedPane
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab}
/>;
const snapshotView = <SnapshotContainer
sdkLanguage={sdkLanguage}
action={selectedAction}
testIdAttributeName={model?.testIdAttributeName || 'data-testid'}
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
locatorPicked={locatorPicked} />;
return <div className='vbox workbench'>
<SplitView
sidebarSize={250}
orientation={'horizontal'}
settingName='recorderActionListSidebar'
sidebarIsFirst
main={<SplitView
sidebarSize={250}
orientation='vertical'
settingName='recorderPropertiesSidebar'
main={<div className='vbox'>
{toolbar}
{snapshotView}
</div>}
sidebar={propertiesTabbedPane}
/>}
sidebar={sidebarTabbedPane}
/>
</div>;
};
const SnapshotContainer: React.FunctionComponent<{
sdkLanguage: Language,
action: modelUtil.ActionTraceEventInContext | undefined,
testIdAttributeName?: string,
isInspecting: boolean,
highlightedLocator: string,
setIsInspecting: (value: boolean) => void,
locatorPicked: (locator: string) => void,
}> = ({ sdkLanguage, action, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, locatorPicked }) => {
const snapshot = React.useMemo(() => {
const snapshot = collectSnapshots(action);
return snapshot.action || snapshot.after || snapshot.before;
}, [action]);
const snapshotUrls = React.useMemo(() => {
return snapshot ? extendSnapshot(snapshot) : undefined;
}, [snapshot]);
return <SnapshotView
sdkLanguage={sdkLanguage}
testIdAttributeName={testIdAttributeName || 'data-testid'}
isInspecting={isInspecting}
setIsInspecting={setIsInspecting}
highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked}
snapshotUrls={snapshotUrls} />;
};
type ConnectionOptions = {
setSources: (sources: Source[]) => void;
setMode: (mode: Mode) => void;
};
class Connection {
private _lastId = 0;
private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
private _options: { setSources: (sources: Source[]) => void; };
private _options: ConnectionOptions;
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
constructor(webSocket: WebSocket, options: ConnectionOptions) {
this._webSocket = webSocket;
this._callbacks = new Map();
this._options = options;
@ -166,5 +392,9 @@ class Connection {
this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources;
}
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
}
}

View file

@ -28,7 +28,7 @@ import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
export const SourceTab: React.FunctionComponent<{
stack: StackFrame[] | undefined,
stack?: StackFrame[],
stackFrameLocation: 'bottom' | 'right',
sources: Map<string, SourceModel>,
rootDir?: string,

View file

@ -60,8 +60,7 @@ export const Workbench: React.FunctionComponent<{
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<modelUtil.ActionTraceEventInContext | undefined>();
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -77,6 +76,14 @@ export const Workbench: React.FunctionComponent<{
setRevealedError(undefined);
}, []);
const highlightedAction = React.useMemo(() => {
return model?.actions.find(a => a.callId === highlightedCallId);
}, [model, highlightedCallId]);
const setHighlightedAction = React.useCallback((highlightedAction: modelUtil.ActionTraceEventInContext | undefined) => {
setHighlightedCallId(highlightedAction?.callId);
}, []);
const sources = React.useMemo(() => model?.sources || new Map<string, modelUtil.SourceModel>(), [model]);
React.useEffect(() => {

View file

@ -0,0 +1,20 @@
/*
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.
*/
.source-chooser {
border: none;
background: none;
outline: none;
color: var(--vscode-sideBarTitle-foreground);
min-width: 100px;
}

View file

@ -0,0 +1,58 @@
/**
* 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 type { Source } from '@recorder/recorderTypes';
export const SourceChooser: React.FC<{
sources: Source[],
fileId: string | undefined,
setFileId: (fileId: string) => void,
}> = ({ sources, fileId, setFileId }) => {
return <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
setFileId(event.target.selectedOptions[0].value);
}}>{renderSourceOptions(sources)}</select>;
};
function renderSourceOptions(sources: Source[]): React.ReactNode {
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
const renderOption = (source: Source): React.ReactNode => (
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
);
const hasGroup = sources.some(s => s.group);
if (hasGroup) {
const groups = new Set(sources.map(s => s.group));
return [...groups].filter(Boolean).map(group => (
<optgroup label={group} key={group}>
{sources.filter(s => s.group === group).map(source => renderOption(source))}
</optgroup>
));
}
return sources.map(source => renderOption(source));
}
export function emptySource(): Source {
return {
id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
}

View file

@ -32,11 +32,13 @@ export const TabbedPane: React.FunctionComponent<{
tabs: TabbedPaneTabModel[],
leftToolbar?: React.ReactElement[],
rightToolbar?: React.ReactElement[],
selectedTab: string,
setSelectedTab: (tab: string) => void,
selectedTab?: string,
setSelectedTab?: (tab: string) => void,
dataTestId?: string,
mode?: 'default' | 'select',
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
if (!selectedTab)
selectedTab = tabs[0].id;
if (!mode)
mode = 'default';
return <div className='tabbed-pane' data-testid={dataTestId}>
@ -60,7 +62,7 @@ export const TabbedPane: React.FunctionComponent<{
</div>}
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
setSelectedTab(tabs[e.currentTarget.selectedIndex].id);
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
}}>
{tabs.map(tab => {
let suffix = '';
@ -95,10 +97,10 @@ export const TabbedPaneTab: React.FunctionComponent<{
count?: number,
errorCount?: number,
selected?: boolean,
onSelect: (id: string) => void
onSelect?: (id: string) => void
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
onClick={() => onSelect(id)}
onClick={() => onSelect?.(id)}
title={title}
key={id}>
<div className='tabbed-pane-tab-label'>{title}</div>

View file

@ -24,6 +24,10 @@
padding-right: 4px;
}
.toolbar.toolbar-sidebar-background {
background-color: var(--vscode-sideBar-background);
}
.toolbar:after {
content: '';
display: block;

View file

@ -21,6 +21,7 @@ import * as React from 'react';
type ToolbarProps = {
noShadow?: boolean;
noMinHeight?: boolean;
sidebarBackground?: boolean;
className?: string;
onClick?: (e: React.MouseEvent) => void;
};
@ -30,7 +31,8 @@ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
children,
noMinHeight,
className,
sidebarBackground,
onClick,
}) => {
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} onClick={onClick}>{children}</div>;
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className, sidebarBackground && 'toolbar-sidebar-background')} onClick={onClick}>{children}</div>;
};

View file

@ -203,5 +203,10 @@ export function clsx(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(' ');
}
export async function sha1(str: string): Promise<string> {
const buffer = new TextEncoder().encode(str);
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-1', buffer))).map(b => b.toString(16).padStart(2, '0')).join('');
}
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');