mirror of
https://github.com/Microsoft/vscode
synced 2024-08-28 21:39:43 +00:00
- override extension type of extensions under dev from existing extensions
- validate after updating the type
This commit is contained in:
parent
1cca784e1f
commit
05b9eddd4b
|
@ -74,7 +74,7 @@ if %errorlevel% neq 0 exit /b %errorlevel%
|
|||
|
||||
echo.
|
||||
echo ### TypeScript tests
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\typescript-language-features\test-workspace --extensionDevelopmentPath=%~dp0\..\extensions\typescript-language-features --extensionTestsPath=%~dp0\..\extensions\typescript-language-features\out\test\unit --enable-proposed-api=vscode.typescript-language-features %API_TESTS_EXTRA_ARGS%
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\typescript-language-features\test-workspace --extensionDevelopmentPath=%~dp0\..\extensions\typescript-language-features --extensionTestsPath=%~dp0\..\extensions\typescript-language-features\out\test\unit %API_TESTS_EXTRA_ARGS%
|
||||
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||
|
||||
echo.
|
||||
|
@ -92,7 +92,7 @@ echo ### Git tests
|
|||
for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set TEMPDIR=%%i
|
||||
set GITWORKSPACE=%TEMPDIR%\git-%RANDOM%
|
||||
mkdir %GITWORKSPACE%
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" %GITWORKSPACE% --extensionDevelopmentPath=%~dp0\..\extensions\git --extensionTestsPath=%~dp0\..\extensions\git\out\test --enable-proposed-api=vscode.git %API_TESTS_EXTRA_ARGS%
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" %GITWORKSPACE% --extensionDevelopmentPath=%~dp0\..\extensions\git --extensionTestsPath=%~dp0\..\extensions\git\out\test %API_TESTS_EXTRA_ARGS%
|
||||
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||
|
||||
echo.
|
||||
|
|
|
@ -93,7 +93,7 @@ kill_app
|
|||
echo
|
||||
echo "### TypeScript tests"
|
||||
echo
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/typescript-language-features/test-workspace --enable-proposed-api=vscode.typescript-language-features --extensionDevelopmentPath=$ROOT/extensions/typescript-language-features --extensionTestsPath=$ROOT/extensions/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $ROOT/extensions/typescript-language-features/test-workspace --extensionDevelopmentPath=$ROOT/extensions/typescript-language-features --extensionTestsPath=$ROOT/extensions/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS
|
||||
kill_app
|
||||
|
||||
echo
|
||||
|
@ -111,7 +111,7 @@ kill_app
|
|||
echo
|
||||
echo "### Git tests"
|
||||
echo
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $(mktemp -d 2>/dev/null) --enable-proposed-api=vscode.git --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test $API_TESTS_EXTRA_ARGS
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test $API_TESTS_EXTRA_ARGS
|
||||
kill_app
|
||||
|
||||
echo
|
||||
|
|
|
@ -64,10 +64,10 @@ if "%INTEGRATION_TEST_ELECTRON_PATH%"=="" (
|
|||
set ELECTRON_ENABLE_STACK_DUMPING=1
|
||||
|
||||
:: Tests in the extension host running from built version (both client and server)
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" --folder-uri=%REMOTE_VSCODE%/vscode-api-tests/testWorkspace --extensionDevelopmentPath=%REMOTE_VSCODE%/vscode-api-tests --extensionTestsPath=%REMOTE_VSCODE%/vscode-api-tests/out/singlefolder-tests %API_TESTS_EXTRA_ARGS% --extensions-dir=%EXT_PATH% --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests --enable-proposed-api=vscode.image-preview
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" --folder-uri=%REMOTE_VSCODE%/vscode-api-tests/testWorkspace --extensionDevelopmentPath=%REMOTE_VSCODE%/vscode-api-tests --extensionTestsPath=%REMOTE_VSCODE%/vscode-api-tests/out/singlefolder-tests %API_TESTS_EXTRA_ARGS% --extensions-dir=%EXT_PATH% --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests
|
||||
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" --file-uri=%REMOTE_VSCODE%/vscode-api-tests/testworkspace.code-workspace --extensionDevelopmentPath=%REMOTE_VSCODE%/vscode-api-tests --extensionTestsPath=%REMOTE_VSCODE%/vscode-api-tests/out/workspace-tests %API_TESTS_EXTRA_ARGS% --extensions-dir=%EXT_PATH% --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests --enable-proposed-api=vscode.image-preview
|
||||
call "%INTEGRATION_TEST_ELECTRON_PATH%" --file-uri=%REMOTE_VSCODE%/vscode-api-tests/testworkspace.code-workspace --extensionDevelopmentPath=%REMOTE_VSCODE%/vscode-api-tests --extensionTestsPath=%REMOTE_VSCODE%/vscode-api-tests/out/workspace-tests %API_TESTS_EXTRA_ARGS% --extensions-dir=%EXT_PATH% --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests
|
||||
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||
)
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ else
|
|||
export ELECTRON_ENABLE_LOGGING=1
|
||||
|
||||
# Running from a build, we need to enable the vscode-test-resolver extension
|
||||
EXTRA_INTEGRATION_TEST_ARGUMENTS="--extensions-dir=$EXT_PATH --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests --enable-proposed-api=vscode.image-preview --enable-proposed-api=vscode.git --enable-proposed-api=vscode.markdown-language-features"
|
||||
EXTRA_INTEGRATION_TEST_ARGUMENTS="--extensions-dir=$EXT_PATH --enable-proposed-api=vscode.vscode-test-resolver --enable-proposed-api=vscode.vscode-api-tests"
|
||||
|
||||
echo "Storing crash reports into '$VSCODECRASHDIR'."
|
||||
echo "Storing log files into '$VSCODELOGSDIR'."
|
||||
|
@ -105,7 +105,7 @@ kill_app
|
|||
echo
|
||||
echo "### TypeScript tests"
|
||||
echo
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/typescript-language-features/test-workspace --enable-proposed-api=vscode.typescript-language-features --extensionDevelopmentPath=$REMOTE_VSCODE/typescript-language-features --extensionTestsPath=$REMOTE_VSCODE/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS
|
||||
"$INTEGRATION_TEST_ELECTRON_PATH" $LINUX_EXTRA_ARGS --folder-uri=$REMOTE_VSCODE/typescript-language-features/test-workspace --extensionDevelopmentPath=$REMOTE_VSCODE/typescript-language-features --extensionTestsPath=$REMOTE_VSCODE/typescript-language-features/out/test/unit $API_TESTS_EXTRA_ARGS $EXTRA_INTEGRATION_TEST_ARGUMENTS
|
||||
kill_app
|
||||
|
||||
echo
|
||||
|
|
|
@ -68,5 +68,5 @@ echo ### Git tests
|
|||
for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set TEMPDIR=%%i
|
||||
set GITWORKSPACE=%TEMPDIR%\git-%RANDOM%
|
||||
mkdir %GITWORKSPACE%
|
||||
call node .\test\integration\browser\out\index.js --workspacePath=%GITWORKSPACE% --extensionDevelopmentPath=.\extensions\git --extensionTestsPath=.\extensions\git\out\test --enable-proposed-api=vscode.git %*
|
||||
call node .\test\integration\browser\out\index.js --workspacePath=%GITWORKSPACE% --extensionDevelopmentPath=.\extensions\git --extensionTestsPath=.\extensions\git\out\test %*
|
||||
if %errorlevel% neq 0 exit /b %errorlevel%
|
||||
|
|
|
@ -63,10 +63,10 @@ node test/integration/browser/out/index.js --workspacePath $ROOT/extensions/emme
|
|||
echo
|
||||
echo "### Git tests"
|
||||
echo
|
||||
node test/integration/browser/out/index.js --workspacePath $(mktemp -d 2>/dev/null) --enable-proposed-api=vscode.git --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test "$@"
|
||||
node test/integration/browser/out/index.js --workspacePath $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test "$@"
|
||||
|
||||
echo
|
||||
echo "### Ipynb tests"
|
||||
echo
|
||||
node test/integration/browser/out/index.js --workspacePath $(mktemp -d 2>/dev/null) --enable-proposed-api=vscode.ipynb --extensionDevelopmentPath=$ROOT/extensions/ipynb --extensionTestsPath=$ROOT/extensions/ipynb/out/test "$@"
|
||||
node test/integration/browser/out/index.js --workspacePath $(mktemp -d 2>/dev/null) --extensionDevelopmentPath=$ROOT/extensions/ipynb --extensionTestsPath=$ROOT/extensions/ipynb/out/test "$@"
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ import { URI } from 'vs/base/common/uri';
|
|||
import { localize } from 'vs/nls';
|
||||
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
|
||||
import { Metadata } from 'vs/platform/extensionManagement/common/extensionManagement';
|
||||
import { computeTargetPlatform, ExtensionKey, getExtensionId, getGalleryExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil';
|
||||
import { areSameExtensions, computeTargetPlatform, ExtensionKey, getExtensionId, getGalleryExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil';
|
||||
import { ExtensionType, ExtensionIdentifier, IExtensionManifest, TargetPlatform, IExtensionIdentifier, IRelaxedExtensionManifest, UNDEFINED_PUBLISHER, IExtensionDescription, BUILTIN_MANIFEST_CACHE_FILE, USER_MANIFEST_CACHE_FILE, MANIFEST_CACHE_FOLDER } from 'vs/platform/extensions/common/extensions';
|
||||
import { validateExtensionManifest } from 'vs/platform/extensions/common/extensionValidator';
|
||||
import { FileOperationResult, IFileService, toFileOperationResult } from 'vs/platform/files/common/files';
|
||||
|
@ -118,7 +118,7 @@ export interface IExtensionsScannerService {
|
|||
scanAllExtensions(scanOptions: ScanOptions): Promise<IScannedExtension[]>;
|
||||
scanSystemExtensions(scanOptions: ScanOptions): Promise<IScannedExtension[]>;
|
||||
scanUserExtensions(scanOptions: ScanOptions): Promise<IScannedExtension[]>;
|
||||
scanExtensionsUnderDevelopment(scanOptions: ScanOptions): Promise<IScannedExtension[]>;
|
||||
scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise<IScannedExtension[]>;
|
||||
scanExistingExtension(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise<IScannedExtension | null>;
|
||||
scanOneOrMultipleExtensions(extensionLocation: URI, extensionType: ExtensionType, scanOptions: ScanOptions): Promise<IScannedExtension[]>;
|
||||
|
||||
|
@ -163,11 +163,11 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
|
|||
}
|
||||
|
||||
async scanAllExtensions(scanOptions: ScanOptions): Promise<IScannedExtension[]> {
|
||||
const [system, user, development] = await Promise.all([
|
||||
const [system, user] = await Promise.all([
|
||||
this.scanSystemExtensions(scanOptions),
|
||||
this.scanUserExtensions(scanOptions),
|
||||
this.scanExtensionsUnderDevelopment(scanOptions),
|
||||
]);
|
||||
const development = await this.scanExtensionsUnderDevelopment(scanOptions, [...system, ...user]);
|
||||
return this.dedupExtensions([...system, ...user, ...development], await this.getTargetPlatform(), true);
|
||||
}
|
||||
|
||||
|
@ -189,10 +189,19 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
|
|||
return extensions;
|
||||
}
|
||||
|
||||
async scanExtensionsUnderDevelopment(scanOptions: ScanOptions): Promise<IScannedExtension[]> {
|
||||
async scanExtensionsUnderDevelopment(scanOptions: ScanOptions, existingExtensions: IScannedExtension[]): Promise<IScannedExtension[]> {
|
||||
if (this.environmentService.isExtensionDevelopment && this.environmentService.extensionDevelopmentLocationURI) {
|
||||
const extensions = (await Promise.all(this.environmentService.extensionDevelopmentLocationURI.filter(extLoc => extLoc.scheme === Schemas.file)
|
||||
.map(async extensionDevelopmentLocationURI => this.extensionsScanner.scanOneOrMultipleExtensions((await this.createExtensionScannerInput(extensionDevelopmentLocationURI, ExtensionType.User, true, scanOptions.language))))))
|
||||
.map(async extensionDevelopmentLocationURI => {
|
||||
const input = await this.createExtensionScannerInput(extensionDevelopmentLocationURI, ExtensionType.User, true, scanOptions.language, false /* do not validate */);
|
||||
const extensions = await this.extensionsScanner.scanOneOrMultipleExtensions(input);
|
||||
return extensions.map(extension => {
|
||||
// Override the extension type from the existing extensions
|
||||
extension.type = existingExtensions.find(e => areSameExtensions(e.identifier, extension.identifier))?.type ?? extension.type;
|
||||
// Validate the extension
|
||||
return this.extensionsScanner.validate(extension, input);
|
||||
});
|
||||
})))
|
||||
.flat();
|
||||
return this.applyScanOptions(extensions, scanOptions, true);
|
||||
}
|
||||
|
@ -327,7 +336,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
|
|||
}
|
||||
}
|
||||
|
||||
private async createExtensionScannerInput(location: URI, type: ExtensionType, excludeObsolete: boolean, language: string | undefined): Promise<ExtensionScannerInput> {
|
||||
private async createExtensionScannerInput(location: URI, type: ExtensionType, excludeObsolete: boolean, language: string | undefined, validate: boolean = true): Promise<ExtensionScannerInput> {
|
||||
const translations = await this.getTranslations(language ?? platform.language);
|
||||
let mtime: number | undefined;
|
||||
try {
|
||||
|
@ -343,6 +352,7 @@ export abstract class AbstractExtensionsScannerService extends Disposable implem
|
|||
mtime,
|
||||
type,
|
||||
excludeObsolete,
|
||||
validate,
|
||||
this.productService.version,
|
||||
this.productService.date,
|
||||
this.productService.commit,
|
||||
|
@ -361,6 +371,7 @@ class ExtensionScannerInput {
|
|||
public readonly mtime: number | undefined,
|
||||
public readonly type: ExtensionType,
|
||||
public readonly excludeObsolete: boolean,
|
||||
public readonly validate: boolean,
|
||||
public readonly productVersion: string,
|
||||
public readonly productDate: string | undefined,
|
||||
public readonly productCommit: string | undefined,
|
||||
|
@ -386,6 +397,7 @@ class ExtensionScannerInput {
|
|||
&& a.mtime === b.mtime
|
||||
&& a.type === b.type
|
||||
&& a.excludeObsolete === b.excludeObsolete
|
||||
&& a.validate === b.validate
|
||||
&& a.productVersion === b.productVersion
|
||||
&& a.productDate === b.productDate
|
||||
&& a.productCommit === b.productCommit
|
||||
|
@ -431,7 +443,7 @@ class ExtensionsScanner extends Disposable {
|
|||
if (input.type === ExtensionType.User && basename(c.resource).indexOf('.') === 0) {
|
||||
return null;
|
||||
}
|
||||
const extensionScannerInput = new ExtensionScannerInput(c.resource, input.mtime, input.type, input.excludeObsolete, input.productVersion, input.productDate, input.productCommit, input.devMode, input.language, input.translations);
|
||||
const extensionScannerInput = new ExtensionScannerInput(c.resource, input.mtime, input.type, input.excludeObsolete, input.validate, input.productVersion, input.productDate, input.productCommit, input.devMode, input.language, input.translations);
|
||||
const extension = await this.scanExtension(extensionScannerInput);
|
||||
return extension && !obsolete[ExtensionKey.create(extension).toString()] ? extension : null;
|
||||
}));
|
||||
|
@ -440,7 +452,7 @@ class ExtensionsScanner extends Disposable {
|
|||
return [];
|
||||
}
|
||||
|
||||
async scanOneOrMultipleExtensions(input: ExtensionScannerInput): Promise<IScannedExtension[]> {
|
||||
async scanOneOrMultipleExtensions(input: ExtensionScannerInput): Promise<IRelaxedScannedExtension[]> {
|
||||
try {
|
||||
if (await this.fileService.exists(joinPath(input.location, 'package.json'))) {
|
||||
const extension = await this.scanExtension(input);
|
||||
|
@ -468,16 +480,8 @@ class ExtensionsScanner extends Disposable {
|
|||
const identifier = metadata?.id ? { id, uuid: metadata.id } : { id };
|
||||
const type = metadata?.isSystem ? ExtensionType.System : input.type;
|
||||
const isBuiltin = type === ExtensionType.System || !!metadata?.isBuiltin;
|
||||
const validations = validateExtensionManifest(input.productVersion, input.productDate, input.location, manifest, isBuiltin);
|
||||
let isValid = true;
|
||||
for (const [severity, message] of validations) {
|
||||
if (severity === Severity.Error) {
|
||||
isValid = false;
|
||||
this.logService.error(this.formatMessage(input.location, message));
|
||||
}
|
||||
}
|
||||
manifest = await this.translateManifest(input.location, manifest, ExtensionScannerInput.createNlsConfiguration(input));
|
||||
return {
|
||||
const extension = {
|
||||
type,
|
||||
identifier,
|
||||
manifest,
|
||||
|
@ -485,9 +489,10 @@ class ExtensionsScanner extends Disposable {
|
|||
isBuiltin,
|
||||
targetPlatform: metadata?.targetPlatform ?? TargetPlatform.UNDEFINED,
|
||||
metadata,
|
||||
isValid,
|
||||
validations
|
||||
isValid: true,
|
||||
validations: []
|
||||
};
|
||||
return input.validate ? this.validate(extension, input) : extension;
|
||||
}
|
||||
} catch (e) {
|
||||
if (input.type !== ExtensionType.System) {
|
||||
|
@ -497,6 +502,20 @@ class ExtensionsScanner extends Disposable {
|
|||
return null;
|
||||
}
|
||||
|
||||
validate(extension: IRelaxedScannedExtension, input: ExtensionScannerInput): IRelaxedScannedExtension {
|
||||
let isValid = true;
|
||||
const validations = validateExtensionManifest(input.productVersion, input.productDate, input.location, extension.manifest, extension.isBuiltin);
|
||||
for (const [severity, message] of validations) {
|
||||
if (severity === Severity.Error) {
|
||||
isValid = false;
|
||||
this.logService.error(this.formatMessage(input.location, message));
|
||||
}
|
||||
}
|
||||
extension.isValid = isValid;
|
||||
extension.validations = validations;
|
||||
return extension;
|
||||
}
|
||||
|
||||
private async scanExtensionManifest(extensionLocation: URI): Promise<IScannedExtensionManifest | null> {
|
||||
const manifestLocation = joinPath(extensionLocation, 'package.json');
|
||||
let content;
|
||||
|
|
|
@ -50,21 +50,15 @@ export class CachedExtensionScanner {
|
|||
}
|
||||
|
||||
private async _scanInstalledExtensions(): Promise<{ system: IExtensionDescription[]; user: IExtensionDescription[]; development: IExtensionDescription[] }> {
|
||||
try {
|
||||
const language = platform.language;
|
||||
|
||||
const builtinExtensions = this._extensionsScannerService.scanSystemExtensions({ language, useCache: true, checkControlFile: true })
|
||||
.then(scannedExtensions => scannedExtensions.map(e => toExtensionDescription(e, false)));
|
||||
|
||||
const userExtensions = this._extensionsScannerService.scanUserExtensions({ language, useCache: true })
|
||||
.then(scannedExtensions => scannedExtensions.map(e => toExtensionDescription(e, false)));
|
||||
|
||||
const developedExtensions = this._extensionsScannerService.scanExtensionsUnderDevelopment({ language })
|
||||
.then(scannedExtensions => scannedExtensions.map(e => toExtensionDescription(e, true)));
|
||||
|
||||
return Promise.all([builtinExtensions, userExtensions, developedExtensions]).then((extensionDescriptions: IExtensionDescription[][]) => {
|
||||
const system = extensionDescriptions[0];
|
||||
const user = extensionDescriptions[1];
|
||||
const development = extensionDescriptions[2];
|
||||
const [scannedSystemExtensions, scannedUserExtensions] = await Promise.all([
|
||||
this._extensionsScannerService.scanSystemExtensions({ language, useCache: true, checkControlFile: true }),
|
||||
this._extensionsScannerService.scanUserExtensions({ language, useCache: true })]);
|
||||
const scannedDevelopedExtensions = await this._extensionsScannerService.scanExtensionsUnderDevelopment({ language }, [...scannedSystemExtensions, ...scannedUserExtensions]);
|
||||
const system = scannedSystemExtensions.map(e => toExtensionDescription(e, false));
|
||||
const user = scannedUserExtensions.map(e => toExtensionDescription(e, false));
|
||||
const development = scannedDevelopedExtensions.map(e => toExtensionDescription(e, true));
|
||||
const disposable = this._extensionsScannerService.onDidChangeCache(() => {
|
||||
disposable.dispose();
|
||||
this._notificationService.prompt(
|
||||
|
@ -78,11 +72,11 @@ export class CachedExtensionScanner {
|
|||
});
|
||||
timeout(5000).then(() => disposable.dispose());
|
||||
return { system, user, development };
|
||||
}).then(undefined, err => {
|
||||
} catch (err) {
|
||||
this._logService.error(`Error scanning installed extensions:`);
|
||||
this._logService.error(err);
|
||||
return { system: [], user: [], development: [] };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue