mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-19 00:21:38 -05:00
improvements to settings sync
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" focusable="false" width="16" height="16" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4 3H3V2h1v1zM3 5h1V4H3v1zm4 0L4 9h2v7h2V9h2L7 5zm4-5H1C.45 0 0 .45 0 1v12c0 .55.45 1 1 1h4v-1H1v-2h4v-1H2V1h9.02L11 10H9v1h2v2H9v1h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1z" fill="#C5C5C5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" focusable="false" width="16" height="16" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);" preserveAspectRatio="xMidYMid meet" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4 3H3V2h1v1zM3 5h1V4H3v1zm4 0L4 9h2v7h2V9h2L7 5zm4-5H1C.45 0 0 .45 0 1v12c0 .55.45 1 1 1h4v-1H1v-2h4v-1H2V1h9.02L11 10H9v1h2v2H9v1h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1z" fill="#626262"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { IUserDataSyncService, SyncStatus } from 'vs/workbench/services/userData/common/userData';
|
||||
import { IUserDataSyncService, SyncStatus, USER_DATA_PREVIEW_SCHEME } from 'vs/workbench/services/userData/common/userData';
|
||||
import { localize } from 'vs/nls';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
|
||||
@@ -18,9 +18,12 @@ import { FalseContext } from 'vs/platform/contextkey/common/contextkeys';
|
||||
import { IActivityService, IBadge, NumberBadge, ProgressBadge } from 'vs/workbench/services/activity/common/activity';
|
||||
import { GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
import { registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { AcceptChangesController } from 'vs/workbench/contrib/userData/browser/userDataPreviewEditorContribution';
|
||||
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { registerAndGetAmdImageURL } from 'vs/base/common/amd';
|
||||
import { ResourceContextKey } from 'vs/workbench/common/resources';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
|
||||
const CONTEXT_SYNC_STATE = new RawContextKey<string>('syncStatus', SyncStatus.Uninitialized);
|
||||
|
||||
@@ -71,6 +74,8 @@ class AutoSyncUserData extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
}
|
||||
|
||||
const SYNC_PUSH_LIGHT_ICON_URI = URI.parse(registerAndGetAmdImageURL(`vs/workbench/contrib/userData/browser/media/sync-push-light.svg`));
|
||||
const SYNC_PUSH_DARK_ICON_URI = URI.parse(registerAndGetAmdImageURL(`vs/workbench/contrib/userData/browser/media/sync-push-dark.svg`));
|
||||
class SyncContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
private readonly syncEnablementContext: IContextKey<string>;
|
||||
@@ -82,7 +87,9 @@ class SyncContribution extends Disposable implements IWorkbenchContribution {
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IActivityService private readonly activityService: IActivityService,
|
||||
@INotificationService private readonly notificationService: INotificationService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@ITextFileService private readonly textFileService: ITextFileService,
|
||||
) {
|
||||
super();
|
||||
this.syncEnablementContext = CONTEXT_SYNC_STATE.bindTo(contextKeyService);
|
||||
@@ -139,6 +146,30 @@ class SyncContribution extends Disposable implements IWorkbenchContribution {
|
||||
this.configurationService.updateValue('userConfiguration.autoSync', false);
|
||||
return this.userDataSyncService.stopSync();
|
||||
}
|
||||
|
||||
private async continueSync(): Promise<void> {
|
||||
// Get the preview editor
|
||||
const editorInput = this.editorService.editors.filter(input => {
|
||||
const resource = input.getResource();
|
||||
return resource && resource.scheme === USER_DATA_PREVIEW_SCHEME;
|
||||
})[0];
|
||||
// Save the preview
|
||||
if (editorInput && editorInput.isDirty()) {
|
||||
await this.textFileService.save(editorInput.getResource()!);
|
||||
}
|
||||
try {
|
||||
// Continue Sync
|
||||
await this.userDataSyncService.continueSync();
|
||||
} catch (error) {
|
||||
this.notificationService.error(error);
|
||||
return;
|
||||
}
|
||||
// Close the preview editor
|
||||
if (editorInput) {
|
||||
editorInput.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private registerActions(): void {
|
||||
|
||||
const startSyncMenuItem: IMenuItem = {
|
||||
@@ -187,6 +218,29 @@ class SyncContribution extends Disposable implements IWorkbenchContribution {
|
||||
MenuRegistry.appendMenuItem(MenuId.GlobalActivity, resolveConflictsMenuItem);
|
||||
MenuRegistry.appendMenuItem(MenuId.CommandPalette, resolveConflictsMenuItem);
|
||||
|
||||
const continueSyncCommandId = 'workbench.userData.actions.continueSync';
|
||||
CommandsRegistry.registerCommand(continueSyncCommandId, () => this.continueSync());
|
||||
MenuRegistry.appendMenuItem(MenuId.CommandPalette, {
|
||||
command: {
|
||||
id: continueSyncCommandId,
|
||||
title: localize('continue sync', "Sync: Continue")
|
||||
},
|
||||
when: ContextKeyExpr.and(CONTEXT_SYNC_STATE.isEqualTo(SyncStatus.HasConflicts)),
|
||||
});
|
||||
MenuRegistry.appendMenuItem(MenuId.EditorTitle, {
|
||||
command: {
|
||||
id: continueSyncCommandId,
|
||||
title: localize('continue sync', "Sync: Continue"),
|
||||
iconLocation: {
|
||||
light: SYNC_PUSH_LIGHT_ICON_URI,
|
||||
dark: SYNC_PUSH_DARK_ICON_URI
|
||||
}
|
||||
},
|
||||
group: 'navigation',
|
||||
order: 1,
|
||||
when: ContextKeyExpr.and(CONTEXT_SYNC_STATE.isEqualTo(SyncStatus.HasConflicts), ResourceContextKey.Scheme.isEqualTo(USER_DATA_PREVIEW_SCHEME)),
|
||||
});
|
||||
|
||||
CommandsRegistry.registerCommand('sync.synchronising', () => { });
|
||||
MenuRegistry.appendMenuItem(MenuId.GlobalActivity, {
|
||||
group: '5_sync',
|
||||
@@ -213,5 +267,3 @@ class SyncContribution extends Disposable implements IWorkbenchContribution {
|
||||
const workbenchRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench);
|
||||
workbenchRegistry.registerWorkbenchContribution(SyncContribution, LifecyclePhase.Starting);
|
||||
workbenchRegistry.registerWorkbenchContribution(AutoSyncUserData, LifecyclePhase.Eventually);
|
||||
|
||||
registerEditorContribution(AcceptChangesController);
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import * as editorCommon from 'vs/editor/common/editorCommon';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IUserDataSyncService, SETTINGS_PREVIEW_RESOURCE } from 'vs/workbench/services/userData/common/userData';
|
||||
import { isEqual } from 'vs/base/common/resources';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
|
||||
export class AcceptChangesController extends Disposable implements editorCommon.IEditorContribution {
|
||||
|
||||
private static readonly ID = 'editor.contrib.sync.acceptChanges';
|
||||
|
||||
static get(editor: ICodeEditor): AcceptChangesController {
|
||||
return editor.getContribution<AcceptChangesController>(AcceptChangesController.ID);
|
||||
}
|
||||
|
||||
private readonly acceptChangesWidgetRenderer: MutableDisposable<AcceptChangesWidgetRenderer>;
|
||||
|
||||
constructor(
|
||||
private editor: ICodeEditor,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService
|
||||
) {
|
||||
super();
|
||||
|
||||
this.acceptChangesWidgetRenderer = this._register(new MutableDisposable<AcceptChangesWidgetRenderer>());
|
||||
this._register(this.editor.onDidChangeModel(() => this.update()));
|
||||
this.update();
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return AcceptChangesController.ID;
|
||||
}
|
||||
|
||||
private update(): void {
|
||||
if (this.isInterestingEditorModel()) {
|
||||
if (!this.acceptChangesWidgetRenderer.value) {
|
||||
this.acceptChangesWidgetRenderer.value = this.instantiationService.createInstance(AcceptChangesWidgetRenderer, this.editor);
|
||||
}
|
||||
} else {
|
||||
this.acceptChangesWidgetRenderer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private isInterestingEditorModel(): boolean {
|
||||
const model = this.editor.getModel();
|
||||
if (!model) {
|
||||
return false;
|
||||
}
|
||||
return isEqual(model.uri, SETTINGS_PREVIEW_RESOURCE, false);
|
||||
}
|
||||
}
|
||||
|
||||
export class AcceptChangesWidgetRenderer extends Disposable {
|
||||
|
||||
constructor(
|
||||
private readonly editor: ICodeEditor,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IUserDataSyncService private readonly userDataSyncService: IUserDataSyncService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@ITextFileService private readonly textFileService: ITextFileService,
|
||||
@INotificationService private readonly notificationService: INotificationService
|
||||
) {
|
||||
super();
|
||||
|
||||
const floatingClickWidget = this._register(instantiationService.createInstance(FloatingClickWidget, editor, localize('Accept', "Accept & Sync"), null));
|
||||
this._register(floatingClickWidget.onClick(() => this.acceptChanges()));
|
||||
floatingClickWidget.render();
|
||||
}
|
||||
|
||||
private async acceptChanges(): Promise<void> {
|
||||
// Do not accept if editor is readonly
|
||||
if (this.editor.getOption(EditorOption.readOnly)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.editor.getModel();
|
||||
if (model) {
|
||||
// Disable updating
|
||||
this.editor.updateOptions({ readOnly: true });
|
||||
// Save the preview
|
||||
await this.textFileService.save(model.uri);
|
||||
|
||||
try {
|
||||
// Apply Preview
|
||||
await this.userDataSyncService.apply(model.uri);
|
||||
} catch (error) {
|
||||
this.notificationService.error(error);
|
||||
// Enable updating
|
||||
this.editor.updateOptions({ readOnly: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Close all preview editors
|
||||
const editorInputs = this.editorService.editors.filter(input => isEqual(input.getResource(), model.uri));
|
||||
for (const input of editorInputs) {
|
||||
input.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class TextResourcePropertiesService implements ITextResourcePropertiesSer
|
||||
remoteAgentService.getEnvironment().then(remoteEnv => this.remoteEnvironment = remoteEnv);
|
||||
}
|
||||
|
||||
getEOL(resource: URI, language?: string): string {
|
||||
getEOL(resource?: URI, language?: string): string {
|
||||
const filesConfiguration = this.configurationService.getValue<{ eol: string }>('files', { overrideIdentifier: language, resource });
|
||||
if (filesConfiguration && filesConfiguration.eol && filesConfiguration.eol !== 'auto') {
|
||||
return filesConfiguration.eol;
|
||||
@@ -38,12 +38,12 @@ export class TextResourcePropertiesService implements ITextResourcePropertiesSer
|
||||
return os === OperatingSystem.Linux || os === OperatingSystem.Macintosh ? '\n' : '\r\n';
|
||||
}
|
||||
|
||||
private getOS(resource: URI): OperatingSystem {
|
||||
private getOS(resource?: URI): OperatingSystem {
|
||||
let os = OS;
|
||||
|
||||
const remoteAuthority = this.environmentService.configuration.remoteAuthority;
|
||||
if (remoteAuthority) {
|
||||
if (resource.scheme !== Schemas.file) {
|
||||
if (resource && resource.scheme !== Schemas.file) {
|
||||
const osCacheKey = `resource.authority.os.${remoteAuthority}`;
|
||||
os = this.remoteEnvironment ? this.remoteEnvironment.os : /* Get it from cache */ this.storageService.getNumber(osCacheKey, StorageScope.WORKSPACE, OS);
|
||||
this.storageService.store(osCacheKey, os, StorageScope.WORKSPACE);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IStorageService, StorageScope } from 'vs/platform/storage/common/storag
|
||||
import { IRemoteUserDataService, IUserData, RemoteUserDataError, RemoteUserDataErrorCode, ISynchroniser, SyncStatus, SETTINGS_PREVIEW_RESOURCE } from 'vs/workbench/services/userData/common/userData';
|
||||
import { VSBuffer } from 'vs/base/common/buffer';
|
||||
import { parse, findNodeAtLocation, parseTree, ParseError } from 'vs/base/common/json';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { IModeService } from 'vs/editor/common/services/modeService';
|
||||
@@ -25,7 +24,6 @@ import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { IHistoryService } from 'vs/workbench/services/history/common/history';
|
||||
import { isEqual } from 'vs/base/common/resources';
|
||||
import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async';
|
||||
|
||||
interface ISyncPreviewResult {
|
||||
@@ -83,7 +81,7 @@ export class SettingsSynchroniser extends Disposable implements ISynchroniser {
|
||||
this.setStatus(SyncStatus.HasConflicts);
|
||||
return false;
|
||||
}
|
||||
await this.apply(SETTINGS_PREVIEW_RESOURCE);
|
||||
await this.apply();
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.syncPreviewResultPromise = null;
|
||||
@@ -129,35 +127,40 @@ export class SettingsSynchroniser extends Disposable implements ISynchroniser {
|
||||
return true;
|
||||
}
|
||||
|
||||
async apply(previewResource: URI): Promise<boolean> {
|
||||
if (!isEqual(previewResource, SETTINGS_PREVIEW_RESOURCE, false)) {
|
||||
async continueSync(): Promise<boolean> {
|
||||
if (this.status !== SyncStatus.HasConflicts) {
|
||||
return false;
|
||||
}
|
||||
if (this.syncPreviewResultPromise) {
|
||||
const result = await this.syncPreviewResultPromise;
|
||||
let remoteUserData = result.remoteUserData;
|
||||
const settingsPreivew = await this.fileService.readFile(SETTINGS_PREVIEW_RESOURCE);
|
||||
const content = settingsPreivew.value.toString();
|
||||
await this.apply();
|
||||
return true;
|
||||
}
|
||||
|
||||
const parseErrors: ParseError[] = [];
|
||||
parse(content, parseErrors);
|
||||
if (parseErrors.length > 0) {
|
||||
return Promise.reject(localize('errorInvalidSettings', "Unable to sync settings. Please fix errors/warnings in it and try again."));
|
||||
}
|
||||
if (result.hasRemoteChanged) {
|
||||
const ref = await this.writeToRemote(content, remoteUserData ? remoteUserData.ref : null);
|
||||
remoteUserData = { ref, content };
|
||||
}
|
||||
if (result.hasLocalChanged) {
|
||||
await this.writeToLocal(content, result.fileContent);
|
||||
}
|
||||
if (remoteUserData) {
|
||||
this.updateLastSyncValue(remoteUserData);
|
||||
}
|
||||
private async apply(): Promise<void> {
|
||||
if (!this.syncPreviewResultPromise) {
|
||||
return;
|
||||
}
|
||||
const result = await this.syncPreviewResultPromise;
|
||||
let remoteUserData = result.remoteUserData;
|
||||
const settingsPreivew = await this.fileService.readFile(SETTINGS_PREVIEW_RESOURCE);
|
||||
const content = settingsPreivew.value.toString();
|
||||
|
||||
const parseErrors: ParseError[] = [];
|
||||
parse(content, parseErrors);
|
||||
if (parseErrors.length > 0) {
|
||||
return Promise.reject(localize('errorInvalidSettings', "Unable to sync settings. Please resolve conflicts without any errors/warnings and try again."));
|
||||
}
|
||||
if (result.hasRemoteChanged) {
|
||||
const ref = await this.writeToRemote(content, remoteUserData ? remoteUserData.ref : null);
|
||||
remoteUserData = { ref, content };
|
||||
}
|
||||
if (result.hasLocalChanged) {
|
||||
await this.writeToLocal(content, result.fileContent);
|
||||
}
|
||||
if (remoteUserData) {
|
||||
this.updateLastSyncValue(remoteUserData);
|
||||
}
|
||||
this.syncPreviewResultPromise = null;
|
||||
this.setStatus(SyncStatus.Idle);
|
||||
return true;
|
||||
}
|
||||
|
||||
private getPreview(): Promise<ISyncPreviewResult> {
|
||||
@@ -225,8 +228,8 @@ export class SettingsSynchroniser extends Disposable implements ISynchroniser {
|
||||
// Remote has moved forward
|
||||
if (remoteUserData.ref !== lastSyncData.ref) {
|
||||
this.logService.trace('Settings Sync: Remote contents have changed. Merge and Sync.');
|
||||
hasRemoteChanged = true;
|
||||
hasLocalChanged = lastSyncData.content !== localContent;
|
||||
hasLocalChanged = true;
|
||||
hasRemoteChanged = lastSyncData.content !== localContent;
|
||||
const mergeResult = await this.mergeContents(localContent, remoteContent, lastSyncData.content);
|
||||
return { settingsPreview: mergeResult.settingsPreview, hasLocalChanged, hasRemoteChanged, hasConflicts: mergeResult.hasConflicts };
|
||||
}
|
||||
@@ -278,8 +281,8 @@ export class SettingsSynchroniser extends Disposable implements ISynchroniser {
|
||||
const base = lastSyncedContent ? parse(lastSyncedContent) : null;
|
||||
const settingsPreviewModel = this.modelService.createModel(localContent, this.modeService.create('jsonc'));
|
||||
|
||||
const baseToLocal = base ? this.compare(base, local) : { added: new Set<string>(), removed: new Set<string>(), updated: new Set<string>() };
|
||||
const baseToRemote = base ? this.compare(base, remote) : { added: new Set<string>(), removed: new Set<string>(), updated: new Set<string>() };
|
||||
const baseToLocal = base ? this.compare(base, local) : { added: Object.keys(local).reduce((r, k) => { r.add(k); return r; }, new Set<string>()), removed: new Set<string>(), updated: new Set<string>() };
|
||||
const baseToRemote = base ? this.compare(base, remote) : { added: Object.keys(remote).reduce((r, k) => { r.add(k); return r; }, new Set<string>()), removed: new Set<string>(), updated: new Set<string>() };
|
||||
const localToRemote = this.compare(local, remote);
|
||||
|
||||
const conflicts: Set<string> = new Set<string>();
|
||||
|
||||
@@ -101,9 +101,9 @@ export interface ISynchroniser {
|
||||
readonly status: SyncStatus;
|
||||
readonly onDidChangeStatus: Event<SyncStatus>;
|
||||
sync(): Promise<boolean>;
|
||||
continueSync(): Promise<boolean>;
|
||||
stopSync(): Promise<void>;
|
||||
handleConflicts(): boolean;
|
||||
apply(previewResource: URI): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const IUserDataSyncService = createDecorator<IUserDataSyncService>('IUserDataSyncService');
|
||||
|
||||
@@ -9,7 +9,6 @@ import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { SettingsSynchroniser } from 'vs/workbench/services/userData/common/settingsSync';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { InMemoryFileSystemProvider } from 'vs/workbench/services/userData/common/inMemoryUserDataProvider';
|
||||
|
||||
@@ -59,12 +58,12 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ
|
||||
}
|
||||
}
|
||||
|
||||
async apply(previewResource: URI): Promise<boolean> {
|
||||
async continueSync(): Promise<boolean> {
|
||||
if (!this.remoteUserDataService.isEnabled()) {
|
||||
throw new Error('Not enabled');
|
||||
}
|
||||
for (const synchroniser of this.synchronisers) {
|
||||
if (await synchroniser.apply(previewResource)) {
|
||||
if (await synchroniser.continueSync()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user