chore: split code mirror and xterm modules (#21415)
This commit is contained in:
parent
99e736afc8
commit
b6ff3bad98
|
|
@ -26,10 +26,10 @@ import { createPlaywright } from '../../playwright';
|
||||||
import { ProgressController } from '../../progress';
|
import { ProgressController } from '../../progress';
|
||||||
import type { Page } from '../../page';
|
import type { Page } from '../../page';
|
||||||
|
|
||||||
type Options = { headless?: boolean, host?: string, port?: number, watchMode?: boolean };
|
type Options = { app?: string, headless?: boolean, host?: string, port?: number };
|
||||||
|
|
||||||
export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<Page> {
|
export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise<Page> {
|
||||||
const { headless = false, host, port, watchMode } = options || {};
|
const { headless = false, host, port, app } = options || {};
|
||||||
for (const traceUrl of traceUrls) {
|
for (const traceUrl of traceUrls) {
|
||||||
if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) {
|
if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|
@ -89,8 +89,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
|
||||||
await syncLocalStorageWithSettings(page, 'traceviewer');
|
await syncLocalStorageWithSettings(page, 'traceviewer');
|
||||||
|
|
||||||
const params = traceUrls.map(t => `trace=${t}`);
|
const params = traceUrls.map(t => `trace=${t}`);
|
||||||
if (watchMode)
|
|
||||||
params.push('watchMode=true');
|
|
||||||
if (isUnderTest()) {
|
if (isUnderTest()) {
|
||||||
params.push('isUnderTest=true');
|
params.push('isUnderTest=true');
|
||||||
page.on('close', () => context.close(serverSideCallMetadata()).catch(() => {}));
|
page.on('close', () => context.close(serverSideCallMetadata()).catch(() => {}));
|
||||||
|
|
@ -99,6 +97,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchQuery = params.length ? '?' + params.join('&') : '';
|
const searchQuery = params.length ? '?' + params.join('&') : '';
|
||||||
await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/index.html${searchQuery}`);
|
await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/${app || 'index.html'}${searchQuery}`);
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -281,12 +281,18 @@ class HtmlBuilder {
|
||||||
if (this._hasTraces) {
|
if (this._hasTraces) {
|
||||||
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer');
|
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer');
|
||||||
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
|
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
|
||||||
fs.mkdirSync(traceViewerTargetFolder, { recursive: true });
|
const traceViewerAssetsTargetFolder = path.join(traceViewerTargetFolder, 'assets');
|
||||||
|
fs.mkdirSync(traceViewerAssetsTargetFolder, { recursive: true });
|
||||||
for (const file of fs.readdirSync(traceViewerFolder)) {
|
for (const file of fs.readdirSync(traceViewerFolder)) {
|
||||||
if (file.endsWith('.map'))
|
if (file.endsWith('.map') || file.includes('watch') || file.includes('assets'))
|
||||||
continue;
|
continue;
|
||||||
await copyFileAndMakeWritable(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
|
await copyFileAndMakeWritable(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
|
||||||
}
|
}
|
||||||
|
for (const file of fs.readdirSync(path.join(traceViewerFolder, 'assets'))) {
|
||||||
|
if (file.endsWith('.map') || file.includes('xTermModule'))
|
||||||
|
continue;
|
||||||
|
await copyFileAndMakeWritable(path.join(traceViewerFolder, 'assets', file), path.join(traceViewerAssetsTargetFolder, file));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline report data.
|
// Inline report data.
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class UIMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
async showUI() {
|
async showUI() {
|
||||||
this._page = await showTraceViewer([], 'chromium', { watchMode: true });
|
this._page = await showTraceViewer([], 'chromium', { app: 'watch.html' });
|
||||||
const exitPromise = new ManualPromise();
|
const exitPromise = new ManualPromise();
|
||||||
this._page.on('close', () => exitPromise.resolve());
|
this._page.on('close', () => exitPromise.resolve());
|
||||||
this._page.exposeBinding('sendMessage', false, async (source, data) => {
|
this._page.exposeBinding('sendMessage', false, async (source, data) => {
|
||||||
|
|
|
||||||
30
packages/trace-viewer/public/watch.webmanifest
Normal file
30
packages/trace-viewer/public/watch.webmanifest
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"theme_color": "#000",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"display": "browser",
|
||||||
|
"start_url": "watch.html",
|
||||||
|
"name": "Playwright Test",
|
||||||
|
"short_name": "Trace Viewer",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -19,17 +19,8 @@ import { applyTheme } from '@web/theme';
|
||||||
import '@web/third_party/vscode/codicon.css';
|
import '@web/third_party/vscode/codicon.css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import { WatchModeView } from './ui/watchMode';
|
|
||||||
import { WorkbenchLoader } from './ui/workbench';
|
import { WorkbenchLoader } from './ui/workbench';
|
||||||
|
|
||||||
export const RootView: React.FC<{}> = ({
|
|
||||||
}) => {
|
|
||||||
if (window.location.href.includes('watchMode=true'))
|
|
||||||
return <WatchModeView />;
|
|
||||||
else
|
|
||||||
return <WorkbenchLoader/>;
|
|
||||||
};
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
if (window.location.protocol !== 'file:') {
|
if (window.location.protocol !== 'file:') {
|
||||||
|
|
@ -46,5 +37,5 @@ export const RootView: React.FC<{}> = ({
|
||||||
setInterval(function() { fetch('ping'); }, 10000);
|
setInterval(function() { fetch('ping'); }, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
ReactDOM.render(<RootView></RootView>, document.querySelector('#root'));
|
ReactDOM.render(<WorkbenchLoader/>, document.querySelector('#root'));
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
41
packages/trace-viewer/src/watch.tsx
Normal file
41
packages/trace-viewer/src/watch.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* 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 '@web/common.css';
|
||||||
|
import { applyTheme } from '@web/theme';
|
||||||
|
import '@web/third_party/vscode/codicon.css';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
import { WatchModeView } from './ui/watchMode';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
applyTheme();
|
||||||
|
if (window.location.protocol !== 'file:') {
|
||||||
|
if (window.location.href.includes('isUnderTest=true'))
|
||||||
|
await new Promise(f => setTimeout(f, 1000));
|
||||||
|
navigator.serviceWorker.register('sw.bundle.js');
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
await new Promise<void>(f => {
|
||||||
|
navigator.serviceWorker.oncontrollerchange = () => f();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep SW running.
|
||||||
|
setInterval(function() { fetch('ping'); }, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<WatchModeView></WatchModeView>, document.querySelector('#root'));
|
||||||
|
})();
|
||||||
|
|
@ -43,6 +43,7 @@ export default defineConfig({
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: path.resolve(__dirname, 'index.html'),
|
index: path.resolve(__dirname, 'index.html'),
|
||||||
|
watch: path.resolve(__dirname, 'watch.html'),
|
||||||
popout: path.resolve(__dirname, 'popout.html'),
|
popout: path.resolve(__dirname, 'popout.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
|
||||||
30
packages/trace-viewer/watch.html
Normal file
30
packages/trace-viewer/watch.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/icon-16x16.png">
|
||||||
|
<link rel="manifest" href="/watch.webmanifest">
|
||||||
|
<title>Playwright Test</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/watch.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
packages/web/src/components/codeMirrorModule.tsx
Normal file
24
packages/web/src/components/codeMirrorModule.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
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 codemirror from 'codemirror';
|
||||||
|
import 'codemirror/lib/codemirror.css';
|
||||||
|
import 'codemirror/mode/javascript/javascript';
|
||||||
|
import 'codemirror/mode/python/python';
|
||||||
|
import 'codemirror/mode/clike/clike';
|
||||||
|
|
||||||
|
export type CodeMirror = typeof codemirror;
|
||||||
|
export default codemirror;
|
||||||
|
|
@ -16,11 +16,7 @@
|
||||||
|
|
||||||
import './source.css';
|
import './source.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import CodeMirror from 'codemirror';
|
import type { CodeMirror } from './codeMirrorModule';
|
||||||
import 'codemirror/mode/javascript/javascript';
|
|
||||||
import 'codemirror/mode/python/python';
|
|
||||||
import 'codemirror/mode/clike/clike';
|
|
||||||
import 'codemirror/lib/codemirror.css';
|
|
||||||
|
|
||||||
export type SourceHighlight = {
|
export type SourceHighlight = {
|
||||||
line: number;
|
line: number;
|
||||||
|
|
@ -54,42 +50,53 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const codemirrorElement = React.createRef<HTMLDivElement>();
|
const codemirrorElement = React.createRef<HTMLDivElement>();
|
||||||
|
const [modulePromise] = React.useState<Promise<CodeMirror>>(import('./codeMirrorModule').then(m => m.default));
|
||||||
const [codemirror, setCodemirror] = React.useState<CodeMirror.Editor>();
|
const [codemirror, setCodemirror] = React.useState<CodeMirror.Editor>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let mode;
|
(async () => {
|
||||||
if (language === 'javascript')
|
// Always load the module first.
|
||||||
mode = 'javascript';
|
const CodeMirror = await modulePromise;
|
||||||
if (language === 'python')
|
|
||||||
mode = 'python';
|
|
||||||
if (language === 'java')
|
|
||||||
mode = 'text/x-java';
|
|
||||||
if (language === 'csharp')
|
|
||||||
mode = 'text/x-csharp';
|
|
||||||
|
|
||||||
if (codemirror && codemirror.getOption('mode') === mode && codemirror.isReadOnly() === readOnly)
|
const element = codemirrorElement.current;
|
||||||
return;
|
if (!element)
|
||||||
|
return;
|
||||||
|
|
||||||
if (!codemirrorElement.current)
|
let mode = 'javascript';
|
||||||
return;
|
if (language === 'python')
|
||||||
if (codemirror)
|
mode = 'python';
|
||||||
codemirror.getWrapperElement().remove();
|
if (language === 'java')
|
||||||
|
mode = 'text/x-java';
|
||||||
|
if (language === 'csharp')
|
||||||
|
mode = 'text/x-csharp';
|
||||||
|
|
||||||
const cm = CodeMirror(codemirrorElement.current, {
|
if (codemirror
|
||||||
value: '',
|
&& mode === codemirror.getOption('mode')
|
||||||
mode,
|
&& readOnly === codemirror.getOption('readOnly')
|
||||||
readOnly,
|
&& lineNumbers === codemirror.getOption('lineNumbers')
|
||||||
lineNumbers,
|
&& wrapLines === codemirror.getOption('lineWrapping')) {
|
||||||
lineWrapping: wrapLines,
|
updateEditor(codemirror, text, highlight, revealLine, focusOnChange);
|
||||||
});
|
return;
|
||||||
if (onChange)
|
}
|
||||||
cm.on('change', () => onChange(cm.getValue()));
|
|
||||||
setCodemirror(cm);
|
|
||||||
updateEditor(cm, text, highlight, revealLine, focusOnChange);
|
|
||||||
}, [codemirror, codemirrorElement, text, language, highlight, revealLine, focusOnChange, lineNumbers, wrapLines, readOnly, onChange]);
|
|
||||||
|
|
||||||
if (codemirror)
|
// Either configuration is different or we don't have a codemirror yet.
|
||||||
updateEditor(codemirror, text, highlight, revealLine, focusOnChange);
|
if (codemirror)
|
||||||
|
codemirror.getWrapperElement().remove();
|
||||||
|
|
||||||
|
const cm = CodeMirror(element, {
|
||||||
|
value: '',
|
||||||
|
mode,
|
||||||
|
readOnly,
|
||||||
|
lineNumbers,
|
||||||
|
lineWrapping: wrapLines,
|
||||||
|
});
|
||||||
|
setCodemirror(cm);
|
||||||
|
if (onChange)
|
||||||
|
cm.on('change', () => onChange(cm.getValue()));
|
||||||
|
updateEditor(cm, text, highlight, revealLine, focusOnChange);
|
||||||
|
return cm;
|
||||||
|
})();
|
||||||
|
}, [modulePromise, codemirror, codemirrorElement, text, language, highlight, revealLine, focusOnChange, lineNumbers, wrapLines, readOnly, onChange]);
|
||||||
|
|
||||||
return <div className='cm-wrapper' ref={codemirrorElement}></div>;
|
return <div className='cm-wrapper' ref={codemirrorElement}></div>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
27
packages/web/src/components/xTermModule.tsx
Normal file
27
packages/web/src/components/xTermModule.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
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 'xterm/css/xterm.css';
|
||||||
|
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
|
||||||
|
export type XTermModule = {
|
||||||
|
Terminal: typeof Terminal;
|
||||||
|
FitAddon: typeof FitAddon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { Terminal, FitAddon };
|
||||||
|
|
@ -15,10 +15,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Terminal } from 'xterm';
|
|
||||||
import { FitAddon } from 'xterm-addon-fit';
|
|
||||||
import 'xterm/css/xterm.css';
|
|
||||||
import './xtermWrapper.css';
|
import './xtermWrapper.css';
|
||||||
|
import type { Terminal } from 'xterm';
|
||||||
|
import type { XTermModule } from './xtermModule';
|
||||||
|
|
||||||
export type XTermDataSource = {
|
export type XTermDataSource = {
|
||||||
pending: (string | Uint8Array)[];
|
pending: (string | Uint8Array)[];
|
||||||
|
|
@ -30,29 +29,37 @@ export const XTermWrapper: React.FC<{ source: XTermDataSource }> = ({
|
||||||
source
|
source
|
||||||
}) => {
|
}) => {
|
||||||
const xtermElement = React.createRef<HTMLDivElement>();
|
const xtermElement = React.createRef<HTMLDivElement>();
|
||||||
|
const [modulePromise] = React.useState<Promise<XTermModule>>(import('./xTermModule').then(m => m.default));
|
||||||
const [terminal, setTerminal] = React.useState<Terminal>();
|
const [terminal, setTerminal] = React.useState<Terminal>();
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (terminal)
|
(async () => {
|
||||||
return;
|
// Always load the module first.
|
||||||
if (!xtermElement.current)
|
const { Terminal, FitAddon } = await modulePromise;
|
||||||
return;
|
const element = xtermElement.current;
|
||||||
const newTerminal = new Terminal({ convertEol: true });
|
if (!element)
|
||||||
const fitAddon = new FitAddon();
|
return;
|
||||||
newTerminal.loadAddon(fitAddon);
|
|
||||||
for (const p of source.pending)
|
if (terminal)
|
||||||
newTerminal.write(p);
|
return;
|
||||||
source.write = (data => {
|
|
||||||
newTerminal.write(data);
|
const newTerminal = new Terminal({ convertEol: true });
|
||||||
});
|
const fitAddon = new FitAddon();
|
||||||
newTerminal.open(xtermElement.current);
|
newTerminal.loadAddon(fitAddon);
|
||||||
setTerminal(newTerminal);
|
for (const p of source.pending)
|
||||||
fitAddon.fit();
|
newTerminal.write(p);
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
source.write = (data => {
|
||||||
source.resize(newTerminal.cols, newTerminal.rows);
|
newTerminal.write(data);
|
||||||
|
});
|
||||||
|
newTerminal.open(element);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
});
|
setTerminal(newTerminal);
|
||||||
resizeObserver.observe(xtermElement.current);
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
}, [terminal, xtermElement, source]);
|
source.resize(newTerminal.cols, newTerminal.rows);
|
||||||
|
fitAddon.fit();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(element);
|
||||||
|
})();
|
||||||
|
}, [modulePromise, terminal, xtermElement, source]);
|
||||||
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
|
return <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue