chore: start building tv-recorder toolbar

This commit is contained in:
Pavel 2024-09-20 18:19:22 -07:00
parent 17ed944a84
commit c9215adc03
11 changed files with 222 additions and 102 deletions

View file

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

View file

@ -20,14 +20,6 @@
flex: auto; flex: auto;
} }
.recorder-chooser {
border: none;
background: none;
outline: none;
color: var(--vscode-sideBarTitle-foreground);
min-width: 100px;
}
.recorder .codicon { .recorder .codicon {
font-size: 16px; font-size: 16px;
} }

View file

@ -17,6 +17,7 @@
import type { CallLog, Mode, Source } from './recorderTypes'; import type { CallLog, Mode, Source } from './recorderTypes';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
import { TabbedPane } from '@web/components/tabbedPane'; import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar'; import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton'; import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
if (source) if (source)
return source; return source;
} }
const source: Source = { return emptySource();
id: 'default',
isRecorded: false,
text: '',
language: 'javascript',
label: '',
highlight: []
};
return source;
}, [sources, fileId]); }, [sources, fileId]);
const [locator, setLocator] = React.useState(''); const [locator, setLocator] = React.useState('');
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
}}></ToolbarButton> }}></ToolbarButton>
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div>Target:</div> <div>Target:</div>
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => { <SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(event.target.selectedOptions[0].value); setFileId(fileId);
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } }); window.dispatch({ event: 'fileChanged', params: { file: fileId } });
}}>{renderSourceOptions(sources)}</select> }} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => { <ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' }); window.dispatch({ event: 'clear' });
}}></ToolbarButton> }}></ToolbarButton>
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
/> />
</div>; </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

@ -21,6 +21,10 @@ import type { SourceLocation } from './modelUtil';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import type { Mode, Source } from '@recorder/recorderTypes'; import type { Mode, Source } from '@recorder/recorderTypes';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const guid = searchParams.get('ws'); const guid = searchParams.get('ws');
@ -29,33 +33,81 @@ const trace = searchParams.get('trace') + '.json';
export const RecorderView: React.FunctionComponent = () => { export const RecorderView: React.FunctionComponent = () => {
const [connection, setConnection] = React.useState<Connection | null>(null); const [connection, setConnection] = React.useState<Connection | null>(null);
const [sources, setSources] = React.useState<Source[]>([]); const [sources, setSources] = React.useState<Source[]>([]);
const [mode, setMode] = React.useState<Mode>('none');
const [fileId, setFileId] = React.useState<string | undefined>();
React.useEffect(() => {
if (!fileId && sources.length > 0)
setFileId(sources[0].id);
}, [fileId, sources]);
const source = React.useMemo(() => {
if (fileId) {
const source = sources.find(s => s.id === fileId);
if (source)
return source;
}
return emptySource();
}, [sources, fileId]);
React.useEffect(() => { React.useEffect(() => {
const wsURL = new URL(`../${guid}`, window.location.toString()); const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:'); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const webSocket = new WebSocket(wsURL.toString()); const webSocket = new WebSocket(wsURL.toString());
setConnection(new Connection(webSocket, { setSources })); setConnection(new Connection(webSocket, { setSources, setMode }));
return () => { return () => {
webSocket.close(); webSocket.close();
}; };
}, []); }, []);
React.useEffect(() => {
if (!connection)
return;
connection.setMode('recording');
}, [connection]);
return <div className='vbox workbench-loader'> return <div className='vbox workbench-loader'>
<Toolbar>
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText' || mode === 'assertingVisibility'} onClick={() => {
connection?.setMode(mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby');
}}>Record</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton icon='inspect' title='Pick locator' toggled={mode === 'inspecting' || mode === 'recording-inspecting'} onClick={() => {
const newMode = ({
'inspecting': 'standby',
'none': 'inspecting',
'standby': 'inspecting',
'recording': 'recording-inspecting',
'recording-inspecting': 'recording',
'assertingText': 'recording-inspecting',
'assertingVisibility': 'recording-inspecting',
'assertingValue': 'recording-inspecting',
} as Record<string, Mode>)[mode];
connection?.setMode(newMode);
}}></ToolbarButton>
<ToolbarButton icon='eye' title='Assert visibility' toggled={mode === 'assertingVisibility'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility');
}}></ToolbarButton>
<ToolbarButton icon='whole-word' title='Assert text' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingText' ? 'recording' : 'assertingText');
}}></ToolbarButton>
<ToolbarButton icon='symbol-constant' title='Assert value' toggled={mode === 'assertingValue'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
connection?.setMode(mode === 'assertingValue' ? 'recording' : 'assertingValue');
}}></ToolbarButton>
<ToolbarSeparator />
<div style={{ flex: 'auto' }}></div>
<div>Target:</div>
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
setFileId(fileId);
}} />
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
}}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar>
<TraceView <TraceView
traceLocation={trace} traceLocation={trace}
sources={sources} /> source={source} />
</div>; </div>;
}; };
export const TraceView: React.FC<{ export const TraceView: React.FC<{
traceLocation: string, traceLocation: string,
sources: Source[], source: Source | undefined,
}> = ({ traceLocation, sources }) => { }> = ({ traceLocation, source }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0); const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null); const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
@ -82,7 +134,7 @@ export const TraceView: React.FC<{
}, [counter, traceLocation]); }, [counter, traceLocation]);
const fallbackLocation = React.useMemo(() => { const fallbackLocation = React.useMemo(() => {
if (!sources.length) if (!source)
return undefined; return undefined;
const fallbackLocation: SourceLocation = { const fallbackLocation: SourceLocation = {
file: '', file: '',
@ -90,11 +142,11 @@ export const TraceView: React.FC<{
column: 0, column: 0,
source: { source: {
errors: [], errors: [],
content: sources[0].text content: source.text
} }
}; };
return fallbackLocation; return fallbackLocation;
}, [sources]); }, [source]);
return <Workbench return <Workbench
key='workbench' key='workbench'
@ -103,6 +155,7 @@ export const TraceView: React.FC<{
fallbackLocation={fallbackLocation} fallbackLocation={fallbackLocation}
isLive={true} isLive={true}
hideTimeline={true} hideTimeline={true}
hideMetatada={true}
/>; />;
}; };
@ -114,13 +167,19 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
return new MultiTraceModel(contextEntries); return new MultiTraceModel(contextEntries);
} }
type ConnectionOptions = {
setSources: (sources: Source[]) => void;
setMode: (mode: Mode) => void;
};
class Connection { class Connection {
private _lastId = 0; private _lastId = 0;
private _webSocket: WebSocket; private _webSocket: WebSocket;
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>(); 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._webSocket = webSocket;
this._callbacks = new Map(); this._callbacks = new Map();
this._options = options; this._options = options;
@ -157,7 +216,7 @@ class Connection {
} }
private _sendMessageNoReply(method: string, params?: any) { private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => { }); this._sendMessage(method, params);
} }
private _dispatchEvent(method: string, params?: any) { private _dispatchEvent(method: string, params?: any) {
@ -166,5 +225,10 @@ class Connection {
this._options.setSources(sources); this._options.setSources(sources);
window.playwrightSourcesEchoForTest = sources; window.playwrightSourcesEchoForTest = sources;
} }
if (method === 'setMode') {
const { mode } = params as { mode: Mode };
this._options.setMode(mode);
}
} }
} }

View file

@ -28,10 +28,6 @@
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
} }
.snapshot-tab .toolbar .pick-locator {
margin: 0 4px;
}
.snapshot-controls { .snapshot-controls {
flex: none; flex: none;
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);

View file

@ -211,7 +211,8 @@ export const SnapshotTab: React.FunctionComponent<{
iframe={iframeRef1.current} iframe={iframeRef1.current}
iteration={loadingRef.current.iteration} /> iteration={loadingRef.current.iteration} />
<Toolbar> <Toolbar>
<ToolbarButton className='pick-locator' title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} /> <ToolbarButton title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
<div style={{ width: 4 }}></div>
{['action', 'before', 'after'].map(tab => { {['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab return <TabbedPaneTab
key={tab} key={tab}

View file

@ -16,7 +16,7 @@
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import * as React from 'react'; import * as React from 'react';
import { useAsyncMemo } from '@web/uiUtils'; import { copy, useAsyncMemo } from '@web/uiUtils';
import './sourceTab.css'; import './sourceTab.css';
import { StackTraceView } from './stackTrace'; import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
@ -104,13 +104,18 @@ export const SourceTab: React.FunctionComponent<{
orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'} orientation={stackFrameLocation === 'bottom' ? 'vertical' : 'horizontal'}
sidebarHidden={!showStackFrames} sidebarHidden={!showStackFrames}
main={<div className='vbox' data-testid='source-code'> main={<div className='vbox' data-testid='source-code'>
{ fileName && <Toolbar> {fileName && <Toolbar>
<div className='source-tab-file-name' title={fileName}> <div className='source-tab-file-name' title={fileName}>
<div>{shortFileName}</div> <div>{shortFileName}</div>
</div> </div>
<CopyToClipboard description='Copy filename' value={shortFileName}/> <CopyToClipboard description='Copy filename' value={shortFileName}/>
{location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>} {location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
</Toolbar> } </Toolbar>}
{!fileName && <div style={{ position: 'absolute', right: 5, top: 5 }}>
<ToolbarButton icon='files' title='Copy' onClick={() => {
copy(source.content || '');
}} />
</div>}
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} /> <CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
</div>} </div>}
sidebar={<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />} sidebar={<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />}

View file

@ -50,7 +50,6 @@ export const Workbench: React.FunctionComponent<{
rootDir?: string, rootDir?: string,
fallbackLocation?: modelUtil.SourceLocation, fallbackLocation?: modelUtil.SourceLocation,
isLive?: boolean, isLive?: boolean,
hideTimeline?: boolean,
status?: UITestStatus, status?: UITestStatus,
annotations?: { type: string; description?: string; }[]; annotations?: { type: string; description?: string; }[];
inert?: boolean, inert?: boolean,
@ -58,7 +57,9 @@ export const Workbench: React.FunctionComponent<{
onOpenExternally?: (location: modelUtil.SourceLocation) => void, onOpenExternally?: (location: modelUtil.SourceLocation) => void,
revealSource?: boolean, revealSource?: boolean,
showSettings?: boolean, showSettings?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => { hideTimeline?: boolean,
hideMetatada?: boolean,
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, hideMetatada, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined); const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined); const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
@ -280,42 +281,46 @@ export const Workbench: React.FunctionComponent<{
else if (model && model.wallTime) else if (model && model.wallTime)
time = Date.now() - model.wallTime; time = Date.now() - model.wallTime;
const actionsView = <div className='vbox'>
{status && <div className='workbench-run-status'>
<span className={clsx('codicon', testStatusIcon(status))}></span>
<div>{testStatusText(status)}</div>
<div className='spacer'></div>
<div className='workbench-run-duration'>{time ? msToString(time) : ''}</div>
</div>}
<ActionList
sdkLanguage={sdkLanguage}
actions={filteredActions}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
onSelected={onActionSelected}
onHighlighted={setHighlightedAction}
revealConsole={() => selectPropertiesTab('console')}
isLive={isLive}
/>
</div>;
const metadataView = hideMetatada ? null : <MetadataView model={model}/>;
const settingsView = showSettings ? <SettingsView settings={[
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
]}/> : null;
const actionsTab: TabbedPaneTabModel = { const actionsTab: TabbedPaneTabModel = {
id: 'actions', id: 'actions',
title: 'Actions', title: 'Actions',
component: <div className='vbox'> component: actionsView,
{status && <div className='workbench-run-status'>
<span className={clsx('codicon', testStatusIcon(status))}></span>
<div>{testStatusText(status)}</div>
<div className='spacer'></div>
<div className='workbench-run-duration'>{time ? msToString(time) : ''}</div>
</div>}
<ActionList
sdkLanguage={sdkLanguage}
actions={filteredActions}
selectedAction={model ? selectedAction : undefined}
selectedTime={selectedTime}
setSelectedTime={setSelectedTime}
onSelected={onActionSelected}
onHighlighted={setHighlightedAction}
revealConsole={() => selectPropertiesTab('console')}
isLive={isLive}
/>
</div>
}; };
const metadataTab: TabbedPaneTabModel = { const metadataTab: TabbedPaneTabModel | null = metadataView ? {
id: 'metadata', id: 'metadata',
title: 'Metadata', title: 'Metadata',
component: <MetadataView model={model}/> component: metadataView,
}; } : null;
const settingsTab: TabbedPaneTabModel = { const settingsTab: TabbedPaneTabModel | null = settingsView ? {
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
component: <SettingsView settings={[ component: settingsView,
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' }, } : null;
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
]}/>,
};
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}> return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
{!hideTimeline && <Timeline {!hideTimeline && <Timeline
@ -348,13 +353,11 @@ export const Workbench: React.FunctionComponent<{
highlightedLocator={highlightedLocator} highlightedLocator={highlightedLocator}
setHighlightedLocator={locatorPicked} setHighlightedLocator={locatorPicked}
openPage={openPage} />} openPage={openPage} />}
sidebar={ sidebar={<TabbedPane
<TabbedPane tabs={[actionsTab, metadataTab, settingsTab].filter(Boolean) as TabbedPaneTabModel[]}
tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]} selectedTab={selectedNavigatorTab}
selectedTab={selectedNavigatorTab} setSelectedTab={setSelectedNavigatorTab}
setSelectedTab={setSelectedNavigatorTab} />}
/>
}
/>} />}
sidebar={<TabbedPane sidebar={<TabbedPane
tabs={tabs} tabs={tabs}

View file

@ -0,0 +1,23 @@
/*
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

@ -21,7 +21,7 @@
min-height: 35px; min-height: 35px;
align-items: center; align-items: center;
flex: none; flex: none;
padding-right: 4px; padding: 0 4px;
} }
.toolbar:after { .toolbar:after {