mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-02-16 10:58:30 -05:00
Merge from vscode 0fde6619172c9f04c41f2e816479e432cc974b8b (#5199)
This commit is contained in:
@@ -34,6 +34,7 @@ interface FileQuickPickItem extends IQuickPickItem {
|
||||
|
||||
enum UpdateResult {
|
||||
Updated,
|
||||
Updating,
|
||||
NotUpdated,
|
||||
InvalidPath
|
||||
}
|
||||
@@ -51,6 +52,7 @@ export class RemoteFileDialog {
|
||||
private allowFolderSelection: boolean;
|
||||
private remoteAuthority: string | undefined;
|
||||
private requiresTrailing: boolean;
|
||||
private trailing: string | undefined;
|
||||
private scheme: string = REMOTE_HOST_SCHEME;
|
||||
private contextKey: IContextKey<boolean>;
|
||||
private userEnteredPathSegment: string;
|
||||
@@ -149,7 +151,6 @@ export class RemoteFileDialog {
|
||||
this.allowFileSelection = !!this.options.canSelectFiles;
|
||||
this.hidden = false;
|
||||
let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri;
|
||||
let trailing: string | undefined;
|
||||
let stat: IFileStat | undefined;
|
||||
let ext: string = resources.extname(homedir);
|
||||
if (this.options.defaultUri) {
|
||||
@@ -160,14 +161,14 @@ export class RemoteFileDialog {
|
||||
}
|
||||
if (!stat || !stat.isDirectory) {
|
||||
homedir = resources.dirname(this.options.defaultUri);
|
||||
trailing = resources.basename(this.options.defaultUri);
|
||||
this.trailing = resources.basename(this.options.defaultUri);
|
||||
}
|
||||
// append extension
|
||||
if (isSave && !ext && this.options.filters) {
|
||||
for (let i = 0; i < this.options.filters.length; i++) {
|
||||
if (this.options.filters[i].extensions[0] !== '*') {
|
||||
ext = '.' + this.options.filters[i].extensions[0];
|
||||
trailing = trailing ? trailing + ext : ext;
|
||||
this.trailing = this.trailing ? this.trailing + ext : ext;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -176,6 +177,7 @@ export class RemoteFileDialog {
|
||||
|
||||
return new Promise<URI | undefined>(async (resolve) => {
|
||||
this.filePickBox = this.quickInputService.createQuickPick<FileQuickPickItem>();
|
||||
this.filePickBox.busy = true;
|
||||
this.filePickBox.matchOnLabel = false;
|
||||
this.filePickBox.autoFocusOnList = false;
|
||||
this.filePickBox.ignoreFocusOut = true;
|
||||
@@ -221,6 +223,7 @@ export class RemoteFileDialog {
|
||||
this.options.availableFileSystems.shift();
|
||||
}
|
||||
this.options.defaultUri = undefined;
|
||||
this.filePickBox.hide();
|
||||
if (this.requiresTrailing) {
|
||||
return this.fileDialogService.showSaveDialog(this.options).then(result => {
|
||||
doResolve(this, result);
|
||||
@@ -264,7 +267,7 @@ export class RemoteFileDialog {
|
||||
// onDidChangeValue can also be triggered by the auto complete, so if it looks like the auto complete, don't do anything
|
||||
if (this.isChangeFromUser()) {
|
||||
// If the user has just entered more bad path, don't change anything
|
||||
if (value !== this.constructFullUserPath() && !this.isBadSubpath(value)) {
|
||||
if (!equalsIgnoreCase(value, this.constructFullUserPath()) && !this.isBadSubpath(value)) {
|
||||
this.filePickBox.validationMessage = undefined;
|
||||
const valueUri = this.remoteUriFrom(this.trimTrailingSlash(this.filePickBox.value));
|
||||
let updated: UpdateResult = UpdateResult.NotUpdated;
|
||||
@@ -288,12 +291,13 @@ export class RemoteFileDialog {
|
||||
|
||||
this.filePickBox.show();
|
||||
this.contextKey.set(true);
|
||||
await this.updateItems(homedir, trailing);
|
||||
if (trailing) {
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length - trailing.length, this.filePickBox.value.length - ext.length];
|
||||
await this.updateItems(homedir, this.trailing);
|
||||
if (this.trailing) {
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length - this.trailing.length, this.filePickBox.value.length - ext.length];
|
||||
} else {
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length, this.filePickBox.value.length];
|
||||
}
|
||||
this.filePickBox.busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -302,7 +306,7 @@ export class RemoteFileDialog {
|
||||
}
|
||||
|
||||
private isChangeFromUser(): boolean {
|
||||
if ((this.filePickBox.value === this.pathAppend(this.currentFolder, this.userEnteredPathSegment + this.autoCompletePathSegment))
|
||||
if (equalsIgnoreCase(this.filePickBox.value, this.pathAppend(this.currentFolder, this.userEnteredPathSegment + this.autoCompletePathSegment))
|
||||
&& (this.activeItem === (this.filePickBox.activeItems ? this.filePickBox.activeItems[0] : undefined))) {
|
||||
return false;
|
||||
}
|
||||
@@ -314,6 +318,7 @@ export class RemoteFileDialog {
|
||||
}
|
||||
|
||||
private async onDidAccept(): Promise<URI | undefined> {
|
||||
this.filePickBox.busy = true;
|
||||
let resolveValue: URI | undefined;
|
||||
let navigateValue: URI | undefined;
|
||||
const trimmedPickBoxValue = ((this.filePickBox.value.length > 1) && this.endsWithSlash(this.filePickBox.value)) ? this.filePickBox.value.substr(0, this.filePickBox.value.length - 1) : this.filePickBox.value;
|
||||
@@ -351,21 +356,25 @@ export class RemoteFileDialog {
|
||||
if (resolveValue) {
|
||||
resolveValue = this.addPostfix(resolveValue);
|
||||
if (await this.validate(resolveValue)) {
|
||||
return Promise.resolve(resolveValue);
|
||||
this.filePickBox.busy = false;
|
||||
return resolveValue;
|
||||
}
|
||||
} else if (navigateValue) {
|
||||
// Try to navigate into the folder
|
||||
await this.updateItems(navigateValue);
|
||||
// Try to navigate into the folder.
|
||||
await this.updateItems(navigateValue, this.trailing);
|
||||
} else {
|
||||
// validation error. Path does not exist.
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
this.filePickBox.busy = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async tryUpdateItems(value: string, valueUri: URI): Promise<UpdateResult> {
|
||||
if (value[value.length - 1] === '~') {
|
||||
await this.updateItems(this.userHome);
|
||||
if (this.filePickBox.busy) {
|
||||
this.badPath = undefined;
|
||||
return UpdateResult.Updating;
|
||||
} else if (value[value.length - 1] === '~') {
|
||||
await this.updateItems(this.userHome);
|
||||
return UpdateResult.Updated;
|
||||
} else if (this.endsWithSlash(value) || (!resources.isEqual(this.currentFolder, resources.dirname(valueUri), true) && resources.isEqualOrParent(this.currentFolder, resources.dirname(valueUri), true))) {
|
||||
let stat: IFileStat | undefined;
|
||||
@@ -409,7 +418,7 @@ export class RemoteFileDialog {
|
||||
const inputBasename = resources.basename(this.remoteUriFrom(value));
|
||||
// Make sure that the folder whose children we are currently viewing matches the path in the input
|
||||
const userPath = this.constructFullUserPath();
|
||||
if (userPath === value.substring(0, userPath.length)) {
|
||||
if (equalsIgnoreCase(userPath, value.substring(0, userPath.length))) {
|
||||
let hasMatch = false;
|
||||
for (let i = 0; i < this.filePickBox.items.length; i++) {
|
||||
const item = <FileQuickPickItem>this.filePickBox.items[i];
|
||||
@@ -424,7 +433,7 @@ export class RemoteFileDialog {
|
||||
this.filePickBox.activeItems = [];
|
||||
}
|
||||
} else {
|
||||
if (inputBasename !== resources.basename(this.currentFolder)) {
|
||||
if (!equalsIgnoreCase(inputBasename, resources.basename(this.currentFolder))) {
|
||||
this.userEnteredPathSegment = inputBasename;
|
||||
} else {
|
||||
this.userEnteredPathSegment = '';
|
||||
@@ -442,24 +451,34 @@ export class RemoteFileDialog {
|
||||
}
|
||||
const itemBasename = quickPickItem.label;
|
||||
// Either force the autocomplete, or the old value should be one smaller than the new value and match the new value.
|
||||
if (!force && (itemBasename.length >= startingBasename.length) && equalsIgnoreCase(itemBasename.substr(0, startingBasename.length), startingBasename)) {
|
||||
if (itemBasename === '..') {
|
||||
// Don't match on the up directory item ever.
|
||||
this.userEnteredPathSegment = startingValue;
|
||||
this.autoCompletePathSegment = '';
|
||||
this.activeItem = quickPickItem;
|
||||
if (force) {
|
||||
// clear any selected text
|
||||
this.insertText(this.userEnteredPathSegment, '');
|
||||
}
|
||||
return false;
|
||||
} else if (!force && (itemBasename.length >= startingBasename.length) && equalsIgnoreCase(itemBasename.substr(0, startingBasename.length), startingBasename)) {
|
||||
this.userEnteredPathSegment = startingBasename;
|
||||
this.activeItem = quickPickItem;
|
||||
// Changing the active items will trigger the onDidActiveItemsChanged. Clear the autocomplete first, then set it after.
|
||||
this.autoCompletePathSegment = '';
|
||||
this.filePickBox.activeItems = [quickPickItem];
|
||||
this.autoCompletePathSegment = itemBasename.substr(startingBasename.length);
|
||||
this.autoCompletePathSegment = this.trimTrailingSlash(itemBasename.substr(startingBasename.length));
|
||||
this.insertText(startingValue + this.autoCompletePathSegment, this.autoCompletePathSegment);
|
||||
this.filePickBox.valueSelection = [startingValue.length, this.filePickBox.value.length];
|
||||
return true;
|
||||
} else if (force && (quickPickItem.label !== (this.userEnteredPathSegment + this.autoCompletePathSegment))) {
|
||||
} else if (force && (!equalsIgnoreCase(quickPickItem.label, (this.userEnteredPathSegment + this.autoCompletePathSegment)))) {
|
||||
this.userEnteredPathSegment = '';
|
||||
this.autoCompletePathSegment = itemBasename;
|
||||
this.autoCompletePathSegment = this.trimTrailingSlash(itemBasename);
|
||||
this.activeItem = quickPickItem;
|
||||
this.filePickBox.valueSelection = [this.pathFromUri(this.currentFolder, true).length, this.filePickBox.value.length];
|
||||
// use insert text to preserve undo buffer
|
||||
this.insertText(this.pathAppend(this.currentFolder, itemBasename), itemBasename);
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length - itemBasename.length, this.filePickBox.value.length];
|
||||
this.insertText(this.pathAppend(this.currentFolder, this.autoCompletePathSegment), this.autoCompletePathSegment);
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length - this.autoCompletePathSegment.length, this.filePickBox.value.length];
|
||||
return true;
|
||||
} else {
|
||||
this.userEnteredPathSegment = startingBasename;
|
||||
@@ -506,21 +525,24 @@ export class RemoteFileDialog {
|
||||
return ((path.length > 1) && this.endsWithSlash(path)) ? path.substr(0, path.length - 1) : path;
|
||||
}
|
||||
|
||||
private yesNoPrompt(message: string): Promise<boolean> {
|
||||
private yesNoPrompt(uri: URI, message: string): Promise<boolean> {
|
||||
interface YesNoItem extends IQuickPickItem {
|
||||
value: boolean;
|
||||
}
|
||||
const prompt = this.quickInputService.createQuickPick<YesNoItem>();
|
||||
const no = nls.localize('remoteFileDialog.no', 'No');
|
||||
prompt.items = [{ label: no, value: false }, { label: nls.localize('remoteFileDialog.yes', 'Yes'), value: true }];
|
||||
prompt.title = message;
|
||||
prompt.placeholder = no;
|
||||
prompt.ignoreFocusOut = true;
|
||||
prompt.ok = true;
|
||||
prompt.customButton = true;
|
||||
prompt.customLabel = nls.localize('remoteFileDialog.cancel', 'Cancel');
|
||||
prompt.value = this.pathFromUri(uri);
|
||||
|
||||
let isResolving = false;
|
||||
return new Promise<boolean>(resolve => {
|
||||
prompt.onDidAccept(() => {
|
||||
isResolving = true;
|
||||
prompt.hide();
|
||||
resolve(prompt.selectedItems ? prompt.selectedItems[0].value : false);
|
||||
resolve(true);
|
||||
});
|
||||
prompt.onDidHide(() => {
|
||||
if (!isResolving) {
|
||||
@@ -531,6 +553,12 @@ export class RemoteFileDialog {
|
||||
this.filePickBox.items = this.filePickBox.items;
|
||||
prompt.dispose();
|
||||
});
|
||||
prompt.onDidChangeValue(() => {
|
||||
prompt.hide();
|
||||
});
|
||||
prompt.onDidCustom(() => {
|
||||
prompt.hide();
|
||||
});
|
||||
prompt.show();
|
||||
});
|
||||
}
|
||||
@@ -554,7 +582,7 @@ export class RemoteFileDialog {
|
||||
// Replacing a file.
|
||||
// Show a yes/no prompt
|
||||
const message = nls.localize('remoteFileDialog.validateExisting', '{0} already exists. Are you sure you want to overwrite it?', resources.basename(uri));
|
||||
return this.yesNoPrompt(message);
|
||||
return this.yesNoPrompt(uri, message);
|
||||
} else if (!this.isValidBaseName(resources.basename(uri))) {
|
||||
// Filename not allowed
|
||||
this.filePickBox.validationMessage = nls.localize('remoteFileDialog.validateBadFilename', 'Please enter a valid file name.');
|
||||
@@ -587,15 +615,24 @@ export class RemoteFileDialog {
|
||||
this.userEnteredPathSegment = trailing ? trailing : '';
|
||||
this.autoCompletePathSegment = '';
|
||||
const newValue = trailing ? this.pathFromUri(resources.joinPath(newFolder, trailing)) : this.pathFromUri(newFolder, true);
|
||||
this.currentFolder = this.remoteUriFrom(this.pathFromUri(newFolder, true));
|
||||
const oldFolder = this.currentFolder;
|
||||
const newFolderPath = this.pathFromUri(newFolder, true);
|
||||
this.currentFolder = this.remoteUriFrom(newFolderPath);
|
||||
return this.createItems(this.currentFolder).then(items => {
|
||||
this.filePickBox.items = items;
|
||||
if (this.allowFolderSelection) {
|
||||
this.filePickBox.activeItems = [];
|
||||
}
|
||||
if (!equalsIgnoreCase(this.filePickBox.value, newValue)) {
|
||||
this.filePickBox.valueSelection = [0, this.filePickBox.value.length];
|
||||
this.insertText(newValue, newValue);
|
||||
// the user might have continued typing while we were updating. Only update the input box if it doesn't match the directory.
|
||||
if (!equalsIgnoreCase(this.filePickBox.value.substring(0, newValue.length), newValue)) {
|
||||
this.filePickBox.valueSelection = [0, this.filePickBox.value.length];
|
||||
this.insertText(newValue, newValue);
|
||||
} else if (equalsIgnoreCase(this.pathFromUri(resources.dirname(oldFolder), true), newFolderPath)) {
|
||||
// This is the case where the user went up one dir. We need to make sure that we remove the final dir.
|
||||
this.filePickBox.valueSelection = [newFolderPath.length, this.filePickBox.value.length];
|
||||
this.insertText(newValue, '');
|
||||
}
|
||||
}
|
||||
this.filePickBox.valueSelection = [this.filePickBox.value.length, this.filePickBox.value.length];
|
||||
this.filePickBox.busy = false;
|
||||
|
||||
@@ -530,16 +530,11 @@ export class EditorService extends Disposable implements EditorServiceImpl {
|
||||
|
||||
// Untitled file support
|
||||
const untitledInput = <IUntitledResourceInput>input;
|
||||
if (!untitledInput.resource || typeof untitledInput.filePath === 'string' || (untitledInput.resource instanceof URI && untitledInput.resource.scheme === Schemas.untitled)) {
|
||||
if (untitledInput.forceUntitled || !untitledInput.resource || (untitledInput.resource && untitledInput.resource.scheme === Schemas.untitled)) {
|
||||
// {{SQL CARBON EDIT}}
|
||||
|
||||
let modeId: string = untitledInput.language ? untitledInput.language : getFileMode(this.instantiationService, untitledInput.resource);
|
||||
return convertEditorInput(this.untitledEditorService.createOrGet(
|
||||
untitledInput.filePath ? URI.file(untitledInput.filePath) : untitledInput.resource,
|
||||
modeId,
|
||||
untitledInput.contents,
|
||||
untitledInput.encoding
|
||||
), undefined, this.instantiationService);
|
||||
return convertEditorInput(this.untitledEditorService.createOrGet(untitledInput.resource, modeId, untitledInput.contents, untitledInput.encoding), undefined, this.instantiationService);
|
||||
}
|
||||
|
||||
// Resource Editor Support
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IEditorModel } from 'vs/platform/editor/common/editor';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor';
|
||||
import { EditorInput, EditorOptions, IFileEditorInput, IEditorInput } from 'vs/workbench/common/editor';
|
||||
import { workbenchInstantiationService, TestStorageService } from 'vs/workbench/test/workbenchTestServices';
|
||||
import { workbenchInstantiationService, TestStorageService, NullFileSystemProvider } from 'vs/workbench/test/workbenchTestServices';
|
||||
import { ResourceEditorInput } from 'vs/workbench/common/editor/resourceEditorInput';
|
||||
import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService';
|
||||
import { EditorService, DelegatingEditorService } from 'vs/workbench/services/editor/browser/editorService';
|
||||
@@ -27,6 +27,8 @@ import { EditorServiceImpl } from 'vs/workbench/browser/parts/editor/editor';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
import { toResource } from 'vs/base/test/common/utils';
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
|
||||
// {{SQL CARBON EDIT}} - Disable editor tests
|
||||
/*
|
||||
@@ -66,8 +68,17 @@ export class TestEditorInput extends EditorInput implements IFileEditorInput {
|
||||
this.gotDisposed = true;
|
||||
}
|
||||
}
|
||||
*/
|
||||
suite('Editor service', () => {/*
|
||||
|
||||
class FileServiceProvider extends Disposable {
|
||||
constructor(scheme: string, @IFileService fileService: IFileService) {
|
||||
super();
|
||||
|
||||
this._register(fileService.registerProvider(scheme, new NullFileSystemProvider()));
|
||||
}
|
||||
}
|
||||
|
||||
*/suite('Editor service', () => {/*
|
||||
|
||||
function registerTestEditorInput(): void {
|
||||
Registry.as<IEditorRegistry>(Extensions.Editors).registerEditor(new EditorDescriptor(TestEditorControl, 'MyTestEditorForEditorService', 'My Test Editor For Next Editor Service'), new SyncDescriptor(TestEditorInput));
|
||||
}
|
||||
@@ -250,9 +261,23 @@ suite('Editor service', () => {/*
|
||||
assert(input instanceof UntitledEditorInput);
|
||||
|
||||
// Untyped Input (untitled with file path)
|
||||
input = service.createInput({ filePath: '/some/path.txt', options: { selection: { startLineNumber: 1, startColumn: 1 } } });
|
||||
input = service.createInput({ resource: URI.file('/some/path.txt'), forceUntitled: true, options: { selection: { startLineNumber: 1, startColumn: 1 } } });
|
||||
assert(input instanceof UntitledEditorInput);
|
||||
assert.ok((input as UntitledEditorInput).hasAssociatedFilePath);
|
||||
|
||||
// Untyped Input (untitled with untitled resource)
|
||||
input = service.createInput({ resource: URI.parse('untitled://Untitled-1'), forceUntitled: true, options: { selection: { startLineNumber: 1, startColumn: 1 } } });
|
||||
assert(input instanceof UntitledEditorInput);
|
||||
assert.ok(!(input as UntitledEditorInput).hasAssociatedFilePath);
|
||||
|
||||
// Untyped Input (untitled with custom resource)
|
||||
const provider = instantiationService.createInstance(FileServiceProvider, 'untitled-custom');
|
||||
|
||||
input = service.createInput({ resource: URI.parse('untitled-custom://some/path'), forceUntitled: true, options: { selection: { startLineNumber: 1, startColumn: 1 } } });
|
||||
assert(input instanceof UntitledEditorInput);
|
||||
assert.ok((input as UntitledEditorInput).hasAssociatedFilePath);
|
||||
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test('delegate', function (done) {
|
||||
|
||||
@@ -481,7 +481,7 @@ function readWindowsCaCertificates() {
|
||||
}
|
||||
|
||||
async function readMacCaCertificates() {
|
||||
const stdout = (await promisify(cp.execFile)('/usr/bin/security', ['find-certificate', '-a', '-p'], { encoding: 'utf8' })).stdout;
|
||||
const stdout = (await promisify(cp.execFile)('/usr/bin/security', ['find-certificate', '-a', '-p'], { encoding: 'utf8', maxBuffer: 1024 * 1024 })).stdout;
|
||||
const seen = {};
|
||||
const certs = stdout.split(/(?=-----BEGIN CERTIFICATE-----)/g)
|
||||
.filter(pem => !!pem.length && !seen[pem] && (seen[pem] = true));
|
||||
|
||||
@@ -157,7 +157,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
}
|
||||
|
||||
// Bubble up any other error as is
|
||||
throw error;
|
||||
throw this.ensureError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
isReadonly: !!(provider.capabilities & FileSystemProviderCapabilities.Readonly),
|
||||
mtime: stat.mtime,
|
||||
size: stat.size,
|
||||
etag: etag(stat.mtime, stat.size)
|
||||
etag: etag({ mtime: stat.mtime, size: stat.size })
|
||||
};
|
||||
|
||||
// check to recurse for directories
|
||||
@@ -306,7 +306,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
await this.doWriteUnbuffered(provider, resource, bufferOrReadable);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new FileOperationError(localize('err.write', "Unable to write file ({0})", error.toString()), toFileOperationResult(error), options);
|
||||
throw new FileOperationError(localize('err.write', "Unable to write file ({0})", this.ensureError(error).toString()), toFileOperationResult(error), options);
|
||||
}
|
||||
|
||||
return this.resolve(resource, { resolveMetadata: true });
|
||||
@@ -337,7 +337,10 @@ export class FileService extends Disposable implements IFileService {
|
||||
// check for size is a weaker check because it can return a false negative if the file has changed
|
||||
// but to the same length. This is a compromise we take to avoid having to produce checksums of
|
||||
// the file content for comparison which would be much slower to compute.
|
||||
if (options && typeof options.mtime === 'number' && typeof options.etag === 'string' && options.etag !== ETAG_DISABLED && options.mtime < stat.mtime && options.etag !== etag(stat.size, options.mtime)) {
|
||||
if (
|
||||
options && typeof options.mtime === 'number' && typeof options.etag === 'string' &&
|
||||
options.etag !== ETAG_DISABLED && options.mtime < stat.mtime && options.etag !== etag({ mtime: options.mtime /* not using stat.mtime for a reason, see above */, size: stat.size })
|
||||
) {
|
||||
throw new FileOperationError(localize('fileModifiedError', "File Modified Since"), FileOperationResult.FILE_MODIFIED_SINCE, options);
|
||||
}
|
||||
|
||||
@@ -398,7 +401,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
value: fileStream
|
||||
};
|
||||
} catch (error) {
|
||||
throw new FileOperationError(localize('err.read', "Unable to read file ({0})", error.toString()), toFileOperationResult(error), options);
|
||||
throw new FileOperationError(localize('err.read', "Unable to read file ({0})", this.ensureError(error).toString()), toFileOperationResult(error), options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,7 +465,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
stream.write(buffer.slice(0, lastChunkLength));
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw this.ensureError(error);
|
||||
} finally {
|
||||
await provider.close(handle);
|
||||
}
|
||||
@@ -492,8 +495,8 @@ export class FileService extends Disposable implements IFileService {
|
||||
throw new FileOperationError(localize('fileIsDirectoryError', "Expected file {0} is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options);
|
||||
}
|
||||
|
||||
// Return early if file not modified since
|
||||
if (options && options.etag === stat.etag) {
|
||||
// Return early if file not modified since (unless disabled)
|
||||
if (options && options.etag !== ETAG_DISABLED && options.etag === stat.etag) {
|
||||
throw new FileOperationError(localize('fileNotModifiedError', "File not modified since"), FileOperationResult.FILE_NOT_MODIFIED_SINCE, options);
|
||||
}
|
||||
|
||||
@@ -863,7 +866,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
posInFile += chunk.byteLength;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw this.ensureError(error);
|
||||
} finally {
|
||||
await provider.close(handle);
|
||||
}
|
||||
@@ -929,7 +932,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
}
|
||||
} while (bytesRead > 0);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw this.ensureError(error);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
typeof sourceHandle === 'number' ? sourceProvider.close(sourceHandle) : Promise.resolve(),
|
||||
@@ -960,7 +963,7 @@ export class FileService extends Disposable implements IFileService {
|
||||
const buffer = await sourceProvider.readFile(source);
|
||||
await this.doWriteBuffer(targetProvider, targetHandle, VSBuffer.wrap(buffer), buffer.byteLength, 0, 0);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
throw this.ensureError(error);
|
||||
} finally {
|
||||
await targetProvider.close(targetHandle);
|
||||
}
|
||||
@@ -991,6 +994,14 @@ export class FileService extends Disposable implements IFileService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private ensureError(error?: Error): Error {
|
||||
if (!error) {
|
||||
return new Error(localize('unknownError', "Unknown Error")); // https://github.com/Microsoft/vscode/issues/72798
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
private throwIfTooLarge(totalBytesRead: number, options?: IReadFileOptions): boolean {
|
||||
|
||||
// Return early if file is too large to load
|
||||
|
||||
@@ -355,7 +355,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('resolve - folder symbolic link', async () => {
|
||||
if (isWindows) {
|
||||
return; // not happy
|
||||
return; // not reliable on windows
|
||||
}
|
||||
|
||||
const link = URI.file(join(testDir, 'deep-link'));
|
||||
@@ -369,7 +369,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('resolve - file symbolic link', async () => {
|
||||
if (isWindows) {
|
||||
return; // not happy
|
||||
return; // not reliable on windows
|
||||
}
|
||||
|
||||
const link = URI.file(join(testDir, 'lorem.txt-linked'));
|
||||
@@ -382,7 +382,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('resolve - invalid symbolic link does not break', async () => {
|
||||
if (isWindows) {
|
||||
return; // not happy
|
||||
return; // not reliable on windows
|
||||
}
|
||||
|
||||
const link = URI.file(join(testDir, 'foo'));
|
||||
@@ -824,12 +824,24 @@ suite('Disk File Service', () => {
|
||||
return testReadFile(URI.file(join(testDir, 'small.txt')));
|
||||
});
|
||||
|
||||
test('readFile - small file - buffered / readonly', () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.Readonly);
|
||||
|
||||
return testReadFile(URI.file(join(testDir, 'small.txt')));
|
||||
});
|
||||
|
||||
test('readFile - small file - unbuffered', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite);
|
||||
|
||||
return testReadFile(URI.file(join(testDir, 'small.txt')));
|
||||
});
|
||||
|
||||
test('readFile - small file - unbuffered / readonly', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.Readonly);
|
||||
|
||||
return testReadFile(URI.file(join(testDir, 'small.txt')));
|
||||
});
|
||||
|
||||
test('readFile - large file - buffered', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose);
|
||||
|
||||
@@ -1200,6 +1212,26 @@ suite('Disk File Service', () => {
|
||||
assert.equal(readFileSync(resource.fsPath), newContent);
|
||||
});
|
||||
|
||||
test('writeFile - buffered - readonly throws', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileOpenReadWriteClose | FileSystemProviderCapabilities.Readonly);
|
||||
|
||||
const resource = URI.file(join(testDir, 'small.txt'));
|
||||
|
||||
const content = readFileSync(resource.fsPath);
|
||||
assert.equal(content, 'Small File');
|
||||
|
||||
const newContent = 'Updates to the small file';
|
||||
|
||||
let error: Error;
|
||||
try {
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContent));
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
assert.ok(error!);
|
||||
});
|
||||
|
||||
test('writeFile - unbuffered', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite);
|
||||
|
||||
@@ -1228,6 +1260,26 @@ suite('Disk File Service', () => {
|
||||
assert.equal(readFileSync(resource.fsPath), newContent);
|
||||
});
|
||||
|
||||
test('writeFile - unbuffered - readonly throws', async () => {
|
||||
setCapabilities(fileProvider, FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.Readonly);
|
||||
|
||||
const resource = URI.file(join(testDir, 'small.txt'));
|
||||
|
||||
const content = readFileSync(resource.fsPath);
|
||||
assert.equal(content, 'Small File');
|
||||
|
||||
const newContent = 'Updates to the small file';
|
||||
|
||||
let error: Error;
|
||||
try {
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContent));
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
assert.ok(error!);
|
||||
});
|
||||
|
||||
test('writeFile (large file) - multiple parallel writes queue up', async () => {
|
||||
const resource = URI.file(join(testDir, 'lorem.txt'));
|
||||
|
||||
@@ -1336,20 +1388,25 @@ suite('Disk File Service', () => {
|
||||
assert.equal(readFileSync(resource.fsPath), newContent);
|
||||
});
|
||||
|
||||
test('writeFile (error when writing to file that has been updated meanwhile)', async () => {
|
||||
test('writeFile - error when writing to file that has been updated meanwhile', async () => {
|
||||
const resource = URI.file(join(testDir, 'small.txt'));
|
||||
|
||||
const stat = await service.resolve(resource);
|
||||
|
||||
const content = readFileSync(resource.fsPath);
|
||||
const content = readFileSync(resource.fsPath).toString();
|
||||
assert.equal(content, 'Small File');
|
||||
|
||||
const newContent = 'Updates to the small file';
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContent), { etag: stat.etag, mtime: stat.mtime });
|
||||
|
||||
const newContentLeadingToError = newContent + newContent;
|
||||
|
||||
const fakeMtime = 1000;
|
||||
const fakeSize = 1000;
|
||||
|
||||
let error: FileOperationError | undefined = undefined;
|
||||
try {
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContent), { etag: etag(0, 0), mtime: 0 });
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContentLeadingToError), { etag: etag({ mtime: fakeMtime, size: fakeSize }), mtime: fakeMtime });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
@@ -1359,6 +1416,32 @@ suite('Disk File Service', () => {
|
||||
assert.equal(error!.fileOperationResult, FileOperationResult.FILE_MODIFIED_SINCE);
|
||||
});
|
||||
|
||||
test('writeFile - no error when writing to file where size is the same', async () => {
|
||||
const resource = URI.file(join(testDir, 'small.txt'));
|
||||
|
||||
const stat = await service.resolve(resource);
|
||||
|
||||
const content = readFileSync(resource.fsPath).toString();
|
||||
assert.equal(content, 'Small File');
|
||||
|
||||
const newContent = content; // same content
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContent), { etag: stat.etag, mtime: stat.mtime });
|
||||
|
||||
const newContentLeadingToNoError = newContent; // writing the same content should be OK
|
||||
|
||||
const fakeMtime = 1000;
|
||||
const actualSize = newContent.length;
|
||||
|
||||
let error: FileOperationError | undefined = undefined;
|
||||
try {
|
||||
await service.writeFile(resource, VSBuffer.fromString(newContentLeadingToNoError), { etag: etag({ mtime: fakeMtime, size: actualSize }), mtime: fakeMtime });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
assert.ok(!error);
|
||||
});
|
||||
|
||||
test('watch - file', done => {
|
||||
const toWatch = URI.file(join(testDir, 'index-watch1.html'));
|
||||
writeFileSync(toWatch.fsPath, 'Init');
|
||||
@@ -1370,7 +1453,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('watch - file symbolic link', async done => {
|
||||
if (isWindows) {
|
||||
return done(); // not happy
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const toWatch = URI.file(join(testDir, 'lorem.txt-linked'));
|
||||
@@ -1383,7 +1466,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('watch - file - multiple writes', done => {
|
||||
if (isWindows) {
|
||||
return done(); // not happy
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const toWatch = URI.file(join(testDir, 'index-watch1.html'));
|
||||
@@ -1446,6 +1529,10 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - change file', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch3'));
|
||||
mkdirSync(watchDir.fsPath);
|
||||
|
||||
@@ -1458,6 +1545,10 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - add file', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch4'));
|
||||
mkdirSync(watchDir.fsPath);
|
||||
|
||||
@@ -1469,6 +1560,10 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - delete file', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch5'));
|
||||
mkdirSync(watchDir.fsPath);
|
||||
|
||||
@@ -1481,6 +1576,10 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - add folder', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch6'));
|
||||
mkdirSync(watchDir.fsPath);
|
||||
|
||||
@@ -1492,8 +1591,8 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - delete folder', done => {
|
||||
if (isWindows) {
|
||||
return done(); // not happy
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch7'));
|
||||
@@ -1508,8 +1607,8 @@ suite('Disk File Service', () => {
|
||||
});
|
||||
|
||||
test('watch - folder (non recursive) - symbolic link - change file', async done => {
|
||||
if (isWindows) {
|
||||
return done(); // not happy
|
||||
if (!isLinux) {
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'deep-link'));
|
||||
@@ -1525,7 +1624,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('watch - folder (non recursive) - rename file', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // not happy
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch8'));
|
||||
@@ -1543,7 +1642,7 @@ suite('Disk File Service', () => {
|
||||
|
||||
test('watch - folder (non recursive) - rename file (different case)', done => {
|
||||
if (!isLinux) {
|
||||
return done(); // not happy
|
||||
return done(); // watch tests are flaky on other platforms
|
||||
}
|
||||
|
||||
const watchDir = URI.file(join(testDir, 'watch8'));
|
||||
|
||||
@@ -137,7 +137,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
}
|
||||
|
||||
if (this.defaultSettingsRawResource.toString() === uri.toString()) {
|
||||
const defaultRawSettingsEditorModel = this.instantiationService.createInstance(DefaultRawSettingsEditorModel, this.getDefaultSettings(ConfigurationTarget.USER));
|
||||
const defaultRawSettingsEditorModel = this.instantiationService.createInstance(DefaultRawSettingsEditorModel, this.getDefaultSettings(ConfigurationTarget.USER_LOCAL));
|
||||
const languageSelection = this.modeService.create('jsonc');
|
||||
const model = this._register(this.modelService.createModel(defaultRawSettingsEditorModel.content, languageSelection, uri));
|
||||
return Promise.resolve(model);
|
||||
@@ -159,7 +159,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
}
|
||||
|
||||
if (this.userSettingsResource.toString() === uri.toString()) {
|
||||
return this.createEditableSettingsEditorModel(ConfigurationTarget.USER, uri);
|
||||
return this.createEditableSettingsEditorModel(ConfigurationTarget.USER_LOCAL, uri);
|
||||
}
|
||||
|
||||
const workspaceSettingsUri = this.getEditableSettingsURI(ConfigurationTarget.WORKSPACE);
|
||||
@@ -209,8 +209,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
jsonEditor;
|
||||
|
||||
return jsonEditor ?
|
||||
this.openOrSwitchSettings(ConfigurationTarget.USER, this.userSettingsResource, options, group) :
|
||||
this.openOrSwitchSettings2(ConfigurationTarget.USER, undefined, options, group);
|
||||
this.openOrSwitchSettings(ConfigurationTarget.USER_LOCAL, this.userSettingsResource, options, group) :
|
||||
this.openOrSwitchSettings2(ConfigurationTarget.USER_LOCAL, undefined, options, group);
|
||||
}
|
||||
|
||||
async openRemoteSettings(): Promise<IEditor | null> {
|
||||
@@ -377,7 +377,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
}
|
||||
|
||||
public createSettings2EditorModel(): Settings2EditorModel {
|
||||
return this.instantiationService.createInstance(Settings2EditorModel, this.getDefaultSettings(ConfigurationTarget.USER));
|
||||
return this.instantiationService.createInstance(Settings2EditorModel, this.getDefaultSettings(ConfigurationTarget.USER_LOCAL));
|
||||
}
|
||||
|
||||
private doOpenSettings2(target: ConfigurationTarget, folderUri: URI | undefined, options?: IEditorOptions, group?: IEditorGroup): Promise<IEditor | null> {
|
||||
@@ -419,7 +419,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
|
||||
private getConfigurationTargetFromSettingsResource(resource: URI): ConfigurationTarget {
|
||||
if (this.userSettingsResource.toString() === resource.toString()) {
|
||||
return ConfigurationTarget.USER;
|
||||
return ConfigurationTarget.USER_LOCAL;
|
||||
}
|
||||
|
||||
const workspaceSettingsResource = this.workspaceSettingsResource;
|
||||
@@ -432,11 +432,15 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
||||
return ConfigurationTarget.WORKSPACE_FOLDER;
|
||||
}
|
||||
|
||||
return ConfigurationTarget.USER;
|
||||
return ConfigurationTarget.USER_LOCAL;
|
||||
}
|
||||
|
||||
private getConfigurationTargetFromDefaultSettingsResource(uri: URI) {
|
||||
return this.isDefaultWorkspaceSettingsResource(uri) ? ConfigurationTarget.WORKSPACE : this.isDefaultFolderSettingsResource(uri) ? ConfigurationTarget.WORKSPACE_FOLDER : ConfigurationTarget.USER;
|
||||
return this.isDefaultWorkspaceSettingsResource(uri) ?
|
||||
ConfigurationTarget.WORKSPACE :
|
||||
this.isDefaultFolderSettingsResource(uri) ?
|
||||
ConfigurationTarget.WORKSPACE_FOLDER :
|
||||
ConfigurationTarget.USER_LOCAL;
|
||||
}
|
||||
|
||||
private isDefaultSettingsResource(uri: URI): boolean {
|
||||
|
||||
@@ -309,7 +309,7 @@ export class ProgressService2 implements IProgressService2 {
|
||||
disposables.push(attachDialogStyler(dialog, this._themeService));
|
||||
|
||||
dialog.show().then(() => {
|
||||
if (options.cancellable && typeof onDidCancel === 'function') {
|
||||
if (typeof onDidCancel === 'function') {
|
||||
onDidCancel();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { isEqual, isEqualOrParent, extname, basename } from 'vs/base/common/resources';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
|
||||
/**
|
||||
* The text file editor model listens to changes to its underlying code editor model and saves these changes through the file service back to the disk.
|
||||
@@ -300,7 +301,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
|
||||
// Decide on etag
|
||||
let etag: string | undefined;
|
||||
if (forceReadFromDisk) {
|
||||
etag = undefined; // reset ETag if we enforce to read from disk
|
||||
etag = ETAG_DISABLED; // disable ETag if we enforce to read from disk
|
||||
} else if (this.lastResolvedDiskStat) {
|
||||
etag = this.lastResolvedDiskStat.etag; // otherwise respect etag to support caching
|
||||
}
|
||||
@@ -826,10 +827,11 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
|
||||
private getTelemetryData(reason: number | undefined): object {
|
||||
const ext = extname(this.resource);
|
||||
const fileName = basename(this.resource);
|
||||
const path = this.resource.scheme === Schemas.file ? this.resource.fsPath : this.resource.path;
|
||||
const telemetryData = {
|
||||
mimeType: guessMimeTypes(this.resource.fsPath).join(', '),
|
||||
mimeType: guessMimeTypes(path).join(', '),
|
||||
ext,
|
||||
path: hash(this.resource.fsPath),
|
||||
path: hash(path),
|
||||
reason
|
||||
};
|
||||
|
||||
|
||||
@@ -871,13 +871,20 @@ export abstract class TextFileService extends Disposable implements ITextFileSer
|
||||
return false;
|
||||
}
|
||||
|
||||
// take over encoding and model value from source model
|
||||
// take over encoding, mode and model value from source model
|
||||
targetModel.updatePreferredEncoding(sourceModel.getEncoding());
|
||||
if (targetModel.textEditorModel) {
|
||||
const snapshot = sourceModel.createSnapshot();
|
||||
if (snapshot) {
|
||||
this.modelService.updateModel(targetModel.textEditorModel, createTextBufferFactoryFromSnapshot(snapshot));
|
||||
}
|
||||
|
||||
if (sourceModel.textEditorModel) {
|
||||
const language = sourceModel.textEditorModel.getLanguageIdentifier();
|
||||
if (language.id > 1) {
|
||||
targetModel.textEditorModel.setMode(language); // only use if more specific than plain/text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// save model
|
||||
|
||||
@@ -30,6 +30,7 @@ import { IJSONSchema } from 'vs/base/common/jsonSchema';
|
||||
import { textmateColorsSchemaId, registerColorThemeSchemas, textmateColorSettingsSchemaId } from 'vs/workbench/services/themes/common/colorThemeSchema';
|
||||
import { workbenchColorsSchemaId } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { getRemoteAuthority } from 'vs/platform/remote/common/remoteHosts';
|
||||
|
||||
// implementation
|
||||
// {{SQL CARBON EDIT}}
|
||||
@@ -480,7 +481,7 @@ export class WorkbenchThemeService implements IWorkbenchThemeService {
|
||||
this.doSetFileIconTheme(newIconTheme);
|
||||
|
||||
// remember theme data for a quick restore
|
||||
if (newIconTheme.isLoaded) {
|
||||
if (newIconTheme.isLoaded && newIconTheme.location && !getRemoteAuthority(newIconTheme.location)) {
|
||||
this.storageService.store(PERSISTED_ICON_THEME_STORAGE_KEY, newIconTheme.toStorageData(), StorageScope.GLOBAL);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user