mirror of
https://github.com/coder/code-server.git
synced 2026-05-28 16:09:35 +00:00
Merge commit 'be3e8236086165e5e45a5a10783823874b3f3ebd' as 'lib/vscode'
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { BrowserBackupTracker } from 'vs/workbench/contrib/backup/browser/backupTracker';
|
||||
|
||||
// Register Backup Tracker
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BrowserBackupTracker, LifecyclePhase.Starting);
|
||||
@@ -0,0 +1,83 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { AutoSaveMode, IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
|
||||
import { IWorkingCopy, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
||||
import { ILifecycleService, ShutdownReason } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { BackupTracker } from 'vs/workbench/contrib/backup/common/backupTracker';
|
||||
|
||||
export class BrowserBackupTracker extends BackupTracker implements IWorkbenchContribution {
|
||||
|
||||
// Delay creation of backups when content changes to avoid too much
|
||||
// load on the backup service when the user is typing into the editor
|
||||
// Since we always schedule a backup, even when auto save is on (web
|
||||
// only), we have different scheduling delays based on auto save. This
|
||||
// helps to avoid a race between saving (after 1s per default) and making
|
||||
// a backup of the working copy.
|
||||
private static readonly BACKUP_SCHEDULE_DELAYS = {
|
||||
[AutoSaveMode.OFF]: 1000,
|
||||
[AutoSaveMode.ON_FOCUS_CHANGE]: 1000,
|
||||
[AutoSaveMode.ON_WINDOW_CHANGE]: 1000,
|
||||
[AutoSaveMode.AFTER_SHORT_DELAY]: 2000, // explicitly higher to prevent races
|
||||
[AutoSaveMode.AFTER_LONG_DELAY]: 1000
|
||||
};
|
||||
|
||||
constructor(
|
||||
@IBackupFileService backupFileService: IBackupFileService,
|
||||
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
|
||||
@IWorkingCopyService workingCopyService: IWorkingCopyService,
|
||||
@ILifecycleService lifecycleService: ILifecycleService,
|
||||
@ILogService logService: ILogService
|
||||
) {
|
||||
super(backupFileService, workingCopyService, logService, lifecycleService);
|
||||
}
|
||||
|
||||
protected shouldScheduleBackup(workingCopy: IWorkingCopy): boolean {
|
||||
// Web: we always want to schedule a backup, even if auto save
|
||||
// is enabled because in web there is no handler on shutdown
|
||||
// to trigger saving so there is a higher chance of dataloss.
|
||||
// See https://github.com/microsoft/vscode/issues/108789
|
||||
return true;
|
||||
}
|
||||
|
||||
protected getBackupScheduleDelay(workingCopy: IWorkingCopy): number {
|
||||
let autoSaveMode = this.filesConfigurationService.getAutoSaveMode();
|
||||
if (workingCopy.capabilities & WorkingCopyCapabilities.Untitled) {
|
||||
autoSaveMode = AutoSaveMode.OFF; // auto-save is never on for untitled working copies
|
||||
}
|
||||
|
||||
return BrowserBackupTracker.BACKUP_SCHEDULE_DELAYS[autoSaveMode];
|
||||
}
|
||||
|
||||
protected onBeforeShutdown(reason: ShutdownReason): boolean | Promise<boolean> {
|
||||
|
||||
// Web: we cannot perform long running in the shutdown phase
|
||||
// As such we need to check sync if there are any dirty working
|
||||
// copies that have not been backed up yet and then prevent the
|
||||
// shutdown if that is the case.
|
||||
|
||||
const dirtyWorkingCopies = this.workingCopyService.dirtyWorkingCopies;
|
||||
if (!dirtyWorkingCopies.length) {
|
||||
return false; // no dirty: no veto
|
||||
}
|
||||
|
||||
if (!this.filesConfigurationService.isHotExitEnabled) {
|
||||
return true; // dirty without backup: veto
|
||||
}
|
||||
|
||||
for (const dirtyWorkingCopy of dirtyWorkingCopies) {
|
||||
if (!this.backupFileService.hasBackupSync(dirtyWorkingCopy.resource, this.getContentVersion(dirtyWorkingCopy))) {
|
||||
this.logService.warn('Unload veto: pending backups');
|
||||
|
||||
return true; // dirty without backup: veto
|
||||
}
|
||||
}
|
||||
|
||||
return false; // dirty with backups: no veto
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
|
||||
import { BackupRestorer } from 'vs/workbench/contrib/backup/common/backupRestorer';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
|
||||
// Register Backup Restorer
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BackupRestorer, LifecyclePhase.Starting);
|
||||
@@ -0,0 +1,116 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { IResourceEditorInput } from 'vs/platform/editor/common/editor';
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
import { ILifecycleService, LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { IUntitledTextResourceEditorInput, IEditorInput, IEditorInputFactoryRegistry, Extensions as EditorExtensions, IEditorInputWithOptions } from 'vs/workbench/common/editor';
|
||||
import { toLocalResource, isEqual } from 'vs/base/common/resources';
|
||||
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IPathService } from 'vs/workbench/services/path/common/pathService';
|
||||
|
||||
export class BackupRestorer implements IWorkbenchContribution {
|
||||
|
||||
private static readonly UNTITLED_REGEX = /Untitled-\d+/;
|
||||
|
||||
constructor(
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IBackupFileService private readonly backupFileService: IBackupFileService,
|
||||
@ILifecycleService private readonly lifecycleService: ILifecycleService,
|
||||
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IPathService private readonly pathService: IPathService
|
||||
) {
|
||||
this.restoreBackups();
|
||||
}
|
||||
|
||||
private restoreBackups(): void {
|
||||
this.lifecycleService.when(LifecyclePhase.Restored).then(() => this.doRestoreBackups());
|
||||
}
|
||||
|
||||
protected async doRestoreBackups(): Promise<URI[] | undefined> {
|
||||
|
||||
// Find all files and untitled with backups
|
||||
const backups = await this.backupFileService.getBackups();
|
||||
const unresolvedBackups = await this.doResolveOpenedBackups(backups);
|
||||
|
||||
// Some failed to restore or were not opened at all so we open and resolve them manually
|
||||
if (unresolvedBackups.length > 0) {
|
||||
await this.doOpenEditors(unresolvedBackups);
|
||||
|
||||
return this.doResolveOpenedBackups(unresolvedBackups);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async doResolveOpenedBackups(backups: URI[]): Promise<URI[]> {
|
||||
const unresolvedBackups: URI[] = [];
|
||||
|
||||
await Promise.all(backups.map(async backup => {
|
||||
const openedEditor = this.findEditorByResource(backup);
|
||||
if (openedEditor) {
|
||||
try {
|
||||
await openedEditor.resolve(); // trigger load
|
||||
} catch (error) {
|
||||
unresolvedBackups.push(backup); // ignore error and remember as unresolved
|
||||
}
|
||||
} else {
|
||||
unresolvedBackups.push(backup);
|
||||
}
|
||||
}));
|
||||
|
||||
return unresolvedBackups;
|
||||
}
|
||||
|
||||
private findEditorByResource(resource: URI): IEditorInput | undefined {
|
||||
for (const editor of this.editorService.editors) {
|
||||
const customFactory = Registry.as<IEditorInputFactoryRegistry>(EditorExtensions.EditorInputFactories).getCustomEditorInputFactory(resource.scheme);
|
||||
if (customFactory && customFactory.canResolveBackup(editor, resource)) {
|
||||
return editor;
|
||||
} else if (isEqual(editor.resource, resource)) {
|
||||
return editor;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async doOpenEditors(resources: URI[]): Promise<void> {
|
||||
const hasOpenedEditors = this.editorService.visibleEditors.length > 0;
|
||||
const inputs = await Promise.all(resources.map((resource, index) => this.resolveInput(resource, index, hasOpenedEditors)));
|
||||
|
||||
// Open all remaining backups as editors and resolve them to load their backups
|
||||
await this.editorService.openEditors(inputs);
|
||||
}
|
||||
|
||||
private async resolveInput(resource: URI, index: number, hasOpenedEditors: boolean): Promise<IResourceEditorInput | IUntitledTextResourceEditorInput | IEditorInputWithOptions> {
|
||||
const options = { pinned: true, preserveFocus: true, inactive: index > 0 || hasOpenedEditors };
|
||||
|
||||
// this is a (weak) strategy to find out if the untitled input had
|
||||
// an associated file path or not by just looking at the path. and
|
||||
// if so, we must ensure to restore the local resource it had.
|
||||
if (resource.scheme === Schemas.untitled && !BackupRestorer.UNTITLED_REGEX.test(resource.path)) {
|
||||
return { resource: toLocalResource(resource, this.environmentService.remoteAuthority, this.pathService.defaultUriScheme), options, forceUntitled: true };
|
||||
}
|
||||
|
||||
// handle custom editors by asking the custom editor input factory
|
||||
// to create the input.
|
||||
const customFactory = Registry.as<IEditorInputFactoryRegistry>(EditorExtensions.EditorInputFactories).getCustomEditorInputFactory(resource.scheme);
|
||||
|
||||
if (customFactory) {
|
||||
const editor = await customFactory.createCustomEditorInput(resource, this.instantiationService);
|
||||
return { editor, options };
|
||||
}
|
||||
|
||||
return { resource, options };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { Disposable, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { ShutdownReason, ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
|
||||
export abstract class BackupTracker extends Disposable {
|
||||
|
||||
// A map from working copy to a version ID we compute on each content
|
||||
// change. This version ID allows to e.g. ask if a backup for a specific
|
||||
// content has been made before closing.
|
||||
private readonly mapWorkingCopyToContentVersion = new Map<IWorkingCopy, number>();
|
||||
|
||||
// A map of scheduled pending backups for working copies
|
||||
private readonly pendingBackups = new Map<IWorkingCopy, IDisposable>();
|
||||
|
||||
constructor(
|
||||
protected readonly backupFileService: IBackupFileService,
|
||||
protected readonly workingCopyService: IWorkingCopyService,
|
||||
protected readonly logService: ILogService,
|
||||
protected readonly lifecycleService: ILifecycleService
|
||||
) {
|
||||
super();
|
||||
|
||||
// Fill in initial dirty working copies
|
||||
this.workingCopyService.dirtyWorkingCopies.forEach(workingCopy => this.onDidRegister(workingCopy));
|
||||
|
||||
this.registerListeners();
|
||||
}
|
||||
|
||||
private registerListeners() {
|
||||
|
||||
// Working Copy events
|
||||
this._register(this.workingCopyService.onDidRegister(workingCopy => this.onDidRegister(workingCopy)));
|
||||
this._register(this.workingCopyService.onDidUnregister(workingCopy => this.onDidUnregister(workingCopy)));
|
||||
this._register(this.workingCopyService.onDidChangeDirty(workingCopy => this.onDidChangeDirty(workingCopy)));
|
||||
this._register(this.workingCopyService.onDidChangeContent(workingCopy => this.onDidChangeContent(workingCopy)));
|
||||
|
||||
// Lifecycle (handled in subclasses)
|
||||
this.lifecycleService.onBeforeShutdown(event => event.veto(this.onBeforeShutdown(event.reason)));
|
||||
}
|
||||
|
||||
private onDidRegister(workingCopy: IWorkingCopy): void {
|
||||
if (workingCopy.isDirty()) {
|
||||
this.scheduleBackup(workingCopy);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidUnregister(workingCopy: IWorkingCopy): void {
|
||||
|
||||
// Remove from content version map
|
||||
this.mapWorkingCopyToContentVersion.delete(workingCopy);
|
||||
|
||||
// Discard backup
|
||||
this.discardBackup(workingCopy);
|
||||
}
|
||||
|
||||
private onDidChangeDirty(workingCopy: IWorkingCopy): void {
|
||||
if (workingCopy.isDirty()) {
|
||||
this.scheduleBackup(workingCopy);
|
||||
} else {
|
||||
this.discardBackup(workingCopy);
|
||||
}
|
||||
}
|
||||
|
||||
private onDidChangeContent(workingCopy: IWorkingCopy): void {
|
||||
|
||||
// Increment content version ID
|
||||
const contentVersionId = this.getContentVersion(workingCopy);
|
||||
this.mapWorkingCopyToContentVersion.set(workingCopy, contentVersionId + 1);
|
||||
|
||||
// Schedule backup if dirty
|
||||
if (workingCopy.isDirty()) {
|
||||
// this listener will make sure that the backup is
|
||||
// pushed out for as long as the user is still changing
|
||||
// the content of the working copy.
|
||||
this.scheduleBackup(workingCopy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows subclasses to conditionally opt-out of doing a backup, e.g. if
|
||||
* auto save is enabled.
|
||||
*/
|
||||
protected abstract shouldScheduleBackup(workingCopy: IWorkingCopy): boolean;
|
||||
|
||||
/**
|
||||
* Allows subclasses to control the delay before performing a backup from
|
||||
* working copy content changes.
|
||||
*/
|
||||
protected abstract getBackupScheduleDelay(workingCopy: IWorkingCopy): number;
|
||||
|
||||
private scheduleBackup(workingCopy: IWorkingCopy): void {
|
||||
|
||||
// Clear any running backup operation
|
||||
this.cancelBackup(workingCopy);
|
||||
|
||||
// subclass prevented backup for working copy
|
||||
if (!this.shouldScheduleBackup(workingCopy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logService.trace(`[backup tracker] scheduling backup`, workingCopy.resource.toString());
|
||||
|
||||
// Schedule new backup
|
||||
const cts = new CancellationTokenSource();
|
||||
const handle = setTimeout(async () => {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backup if dirty
|
||||
if (workingCopy.isDirty()) {
|
||||
this.logService.trace(`[backup tracker] creating backup`, workingCopy.resource.toString());
|
||||
|
||||
try {
|
||||
const backup = await workingCopy.backup(cts.token);
|
||||
if (cts.token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workingCopy.isDirty()) {
|
||||
this.logService.trace(`[backup tracker] storing backup`, workingCopy.resource.toString());
|
||||
|
||||
await this.backupFileService.backup(workingCopy.resource, backup.content, this.getContentVersion(workingCopy), backup.meta, cts.token);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (cts.token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear disposable
|
||||
this.pendingBackups.delete(workingCopy);
|
||||
|
||||
}, this.getBackupScheduleDelay(workingCopy));
|
||||
|
||||
// Keep in map for disposal as needed
|
||||
this.pendingBackups.set(workingCopy, toDisposable(() => {
|
||||
this.logService.trace(`[backup tracker] clearing pending backup`, workingCopy.resource.toString());
|
||||
|
||||
cts.dispose(true);
|
||||
clearTimeout(handle);
|
||||
}));
|
||||
}
|
||||
|
||||
protected getContentVersion(workingCopy: IWorkingCopy): number {
|
||||
return this.mapWorkingCopyToContentVersion.get(workingCopy) || 0;
|
||||
}
|
||||
|
||||
private discardBackup(workingCopy: IWorkingCopy): void {
|
||||
this.logService.trace(`[backup tracker] discarding backup`, workingCopy.resource.toString());
|
||||
|
||||
// Clear any running backup operation
|
||||
this.cancelBackup(workingCopy);
|
||||
|
||||
// Forward to backup file service
|
||||
this.backupFileService.discardBackup(workingCopy.resource);
|
||||
}
|
||||
|
||||
private cancelBackup(workingCopy: IWorkingCopy): void {
|
||||
dispose(this.pendingBackups.get(workingCopy));
|
||||
this.pendingBackups.delete(workingCopy);
|
||||
}
|
||||
|
||||
protected abstract onBeforeShutdown(reason: ShutdownReason): boolean | Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { NativeBackupTracker } from 'vs/workbench/contrib/backup/electron-sandbox/backupTracker';
|
||||
|
||||
// Register Backup Tracker
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(NativeBackupTracker, LifecyclePhase.Starting);
|
||||
@@ -0,0 +1,304 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from 'vs/nls';
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
|
||||
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
||||
import { ILifecycleService, LifecyclePhase, ShutdownReason } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { ConfirmResult, IFileDialogService, IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import Severity from 'vs/base/common/severity';
|
||||
import { WorkbenchState, IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
|
||||
import { isMacintosh } from 'vs/base/common/platform';
|
||||
import { HotExitConfiguration } from 'vs/platform/files/common/files';
|
||||
import { INativeHostService } from 'vs/platform/native/electron-sandbox/native';
|
||||
import { BackupTracker } from 'vs/workbench/contrib/backup/common/backupTracker';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
|
||||
export class NativeBackupTracker extends BackupTracker implements IWorkbenchContribution {
|
||||
|
||||
// Delay creation of backups when working copy changes to avoid too much
|
||||
// load on the backup service when the user is typing into the editor
|
||||
private static readonly BACKUP_SCHEDULE_DELAY = 1000;
|
||||
|
||||
// Disable backup for when a short auto-save delay is configured with
|
||||
// the rationale that the auto save will trigger a save periodically
|
||||
// anway and thus creating frequent backups is not useful
|
||||
//
|
||||
// This will only apply to working copies that are not untitled where
|
||||
// auto save is actually saving.
|
||||
private static readonly DISABLE_BACKUP_AUTO_SAVE_THRESHOLD = 1500;
|
||||
|
||||
constructor(
|
||||
@IBackupFileService backupFileService: IBackupFileService,
|
||||
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
|
||||
@IWorkingCopyService workingCopyService: IWorkingCopyService,
|
||||
@ILifecycleService lifecycleService: ILifecycleService,
|
||||
@IFileDialogService private readonly fileDialogService: IFileDialogService,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
|
||||
@INativeHostService private readonly nativeHostService: INativeHostService,
|
||||
@ILogService logService: ILogService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IEnvironmentService private readonly environmentService: IEnvironmentService
|
||||
) {
|
||||
super(backupFileService, workingCopyService, logService, lifecycleService);
|
||||
}
|
||||
|
||||
protected shouldScheduleBackup(workingCopy: IWorkingCopy): boolean {
|
||||
if (workingCopy.capabilities & WorkingCopyCapabilities.Untitled) {
|
||||
return true; // always backup untitled
|
||||
}
|
||||
|
||||
const autoSaveConfiguration = this.filesConfigurationService.getAutoSaveConfiguration();
|
||||
if (typeof autoSaveConfiguration.autoSaveDelay === 'number' && autoSaveConfiguration.autoSaveDelay < NativeBackupTracker.DISABLE_BACKUP_AUTO_SAVE_THRESHOLD) {
|
||||
return false; // skip backup when auto save is already enabled with a low delay
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected getBackupScheduleDelay(): number {
|
||||
return NativeBackupTracker.BACKUP_SCHEDULE_DELAY;
|
||||
}
|
||||
|
||||
protected onBeforeShutdown(reason: ShutdownReason): boolean | Promise<boolean> {
|
||||
|
||||
// Dirty working copies need treatment on shutdown
|
||||
const dirtyWorkingCopies = this.workingCopyService.dirtyWorkingCopies;
|
||||
if (dirtyWorkingCopies.length) {
|
||||
return this.onBeforeShutdownWithDirty(reason, dirtyWorkingCopies);
|
||||
}
|
||||
|
||||
// No dirty working copies
|
||||
return this.onBeforeShutdownWithoutDirty();
|
||||
}
|
||||
|
||||
protected async onBeforeShutdownWithDirty(reason: ShutdownReason, workingCopies: IWorkingCopy[]): Promise<boolean> {
|
||||
|
||||
// If auto save is enabled, save all non-untitled working copies
|
||||
// and then check again for dirty copies
|
||||
if (this.filesConfigurationService.getAutoSaveMode() !== AutoSaveMode.OFF) {
|
||||
|
||||
// Save all files
|
||||
await this.doSaveAllBeforeShutdown(false /* not untitled */, SaveReason.AUTO);
|
||||
|
||||
// If we still have dirty working copies, we either have untitled ones or working copies that cannot be saved
|
||||
const remainingDirtyWorkingCopies = this.workingCopyService.dirtyWorkingCopies;
|
||||
if (remainingDirtyWorkingCopies.length) {
|
||||
return this.handleDirtyBeforeShutdown(remainingDirtyWorkingCopies, reason);
|
||||
}
|
||||
|
||||
return false; // no veto (there are no remaining dirty working copies)
|
||||
}
|
||||
|
||||
// Auto save is not enabled
|
||||
return this.handleDirtyBeforeShutdown(workingCopies, reason);
|
||||
}
|
||||
|
||||
private async handleDirtyBeforeShutdown(workingCopies: IWorkingCopy[], reason: ShutdownReason): Promise<boolean> {
|
||||
|
||||
// Trigger backup if configured
|
||||
let backups: IWorkingCopy[] = [];
|
||||
let backupError: Error | undefined = undefined;
|
||||
if (this.filesConfigurationService.isHotExitEnabled) {
|
||||
try {
|
||||
backups = await this.backupBeforeShutdown(workingCopies, reason);
|
||||
if (backups.length === workingCopies.length) {
|
||||
return false; // no veto (backup was successful for all working copies)
|
||||
}
|
||||
} catch (error) {
|
||||
backupError = error;
|
||||
}
|
||||
}
|
||||
|
||||
// we ran a backup but received an error that we show to the user
|
||||
if (backupError) {
|
||||
this.showErrorDialog(localize('backupTrackerBackupFailed', "One or more dirty editors could not be saved to the back up location."), backupError);
|
||||
|
||||
return true; // veto (the backup failed)
|
||||
}
|
||||
|
||||
// since a backup did not happen, we have to confirm for
|
||||
// the working copies that did not successfully backup
|
||||
try {
|
||||
return await this.confirmBeforeShutdown(workingCopies.filter(workingCopy => !backups.includes(workingCopy)));
|
||||
} catch (error) {
|
||||
this.showErrorDialog(localize('backupTrackerConfirmFailed', "One or more dirty editors could not be saved or reverted."), error);
|
||||
|
||||
return true; // veto (save or revert failed)
|
||||
}
|
||||
}
|
||||
|
||||
private showErrorDialog(msg: string, error?: Error): void {
|
||||
this.dialogService.show(Severity.Error, msg, [localize('ok', 'OK')], { detail: localize('backupErrorDetails', "Try saving or reverting the dirty editors first and then try again.") });
|
||||
|
||||
this.logService.error(error ? `[backup tracker] ${msg}: ${error}` : `[backup tracker] ${msg}`);
|
||||
}
|
||||
|
||||
private async backupBeforeShutdown(workingCopies: IWorkingCopy[], reason: ShutdownReason): Promise<IWorkingCopy[]> {
|
||||
|
||||
// When quit is requested skip the confirm callback and attempt to backup all workspaces.
|
||||
// When quit is not requested the confirm callback should be shown when the window being
|
||||
// closed is the only VS Code window open, except for on Mac where hot exit is only
|
||||
// ever activated when quit is requested.
|
||||
|
||||
let doBackup: boolean | undefined;
|
||||
if (this.environmentService.isExtensionDevelopment) {
|
||||
doBackup = true; // always backup closing extension development window without asking to speed up debugging
|
||||
} else {
|
||||
switch (reason) {
|
||||
case ShutdownReason.CLOSE:
|
||||
if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.filesConfigurationService.hotExitConfiguration === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE) {
|
||||
doBackup = true; // backup if a folder is open and onExitAndWindowClose is configured
|
||||
} else if (await this.nativeHostService.getWindowCount() > 1 || isMacintosh) {
|
||||
doBackup = false; // do not backup if a window is closed that does not cause quitting of the application
|
||||
} else {
|
||||
doBackup = true; // backup if last window is closed on win/linux where the application quits right after
|
||||
}
|
||||
break;
|
||||
|
||||
case ShutdownReason.QUIT:
|
||||
doBackup = true; // backup because next start we restore all backups
|
||||
break;
|
||||
|
||||
case ShutdownReason.RELOAD:
|
||||
doBackup = true; // backup because after window reload, backups restore
|
||||
break;
|
||||
|
||||
case ShutdownReason.LOAD:
|
||||
if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.filesConfigurationService.hotExitConfiguration === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE) {
|
||||
doBackup = true; // backup if a folder is open and onExitAndWindowClose is configured
|
||||
} else {
|
||||
doBackup = false; // do not backup because we are switching contexts
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a backup of all dirty working copies unless a backup already exists
|
||||
const backups: IWorkingCopy[] = [];
|
||||
if (doBackup) {
|
||||
await Promise.all(workingCopies.map(async workingCopy => {
|
||||
const contentVersion = this.getContentVersion(workingCopy);
|
||||
|
||||
// Backup exists
|
||||
if (this.backupFileService.hasBackupSync(workingCopy.resource, contentVersion)) {
|
||||
backups.push(workingCopy);
|
||||
}
|
||||
|
||||
// Backup does not exist
|
||||
else {
|
||||
const backup = await workingCopy.backup(CancellationToken.None);
|
||||
await this.backupFileService.backup(workingCopy.resource, backup.content, contentVersion, backup.meta);
|
||||
|
||||
backups.push(workingCopy);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return backups;
|
||||
}
|
||||
|
||||
private async confirmBeforeShutdown(workingCopies: IWorkingCopy[]): Promise<boolean> {
|
||||
|
||||
// Save
|
||||
const confirm = await this.fileDialogService.showSaveConfirm(workingCopies.map(workingCopy => workingCopy.name));
|
||||
if (confirm === ConfirmResult.SAVE) {
|
||||
const dirtyCountBeforeSave = this.workingCopyService.dirtyCount;
|
||||
await this.doSaveAllBeforeShutdown(workingCopies, SaveReason.EXPLICIT);
|
||||
|
||||
const savedWorkingCopies = dirtyCountBeforeSave - this.workingCopyService.dirtyCount;
|
||||
if (savedWorkingCopies < workingCopies.length) {
|
||||
return true; // veto (save failed or was canceled)
|
||||
}
|
||||
|
||||
return this.noVeto(workingCopies); // no veto (dirty saved)
|
||||
}
|
||||
|
||||
// Don't Save
|
||||
else if (confirm === ConfirmResult.DONT_SAVE) {
|
||||
await this.doRevertAllBeforeShutdown(workingCopies);
|
||||
|
||||
return this.noVeto(workingCopies); // no veto (dirty reverted)
|
||||
}
|
||||
|
||||
// Cancel
|
||||
return true; // veto (user canceled)
|
||||
}
|
||||
|
||||
private async doSaveAllBeforeShutdown(workingCopies: IWorkingCopy[], reason: SaveReason): Promise<void>;
|
||||
private async doSaveAllBeforeShutdown(includeUntitled: boolean, reason: SaveReason): Promise<void>;
|
||||
private async doSaveAllBeforeShutdown(arg1: IWorkingCopy[] | boolean, reason: SaveReason): Promise<void> {
|
||||
const workingCopies = Array.isArray(arg1) ? arg1 : this.workingCopyService.dirtyWorkingCopies.filter(workingCopy => {
|
||||
if (arg1 === false && (workingCopy.capabilities & WorkingCopyCapabilities.Untitled)) {
|
||||
return false; // skip untitled unless explicitly included
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Skip save participants on shutdown for performance reasons
|
||||
const saveOptions = { skipSaveParticipants: true, reason };
|
||||
|
||||
// First save through the editor service if we save all to benefit
|
||||
// from some extras like switching to untitled dirty editors before saving.
|
||||
let result: boolean | undefined = undefined;
|
||||
if (typeof arg1 === 'boolean' || workingCopies.length === this.workingCopyService.dirtyCount) {
|
||||
result = await this.editorService.saveAll({ includeUntitled: typeof arg1 === 'boolean' ? arg1 : true, ...saveOptions });
|
||||
}
|
||||
|
||||
// If we still have dirty working copies, save those directly
|
||||
// unless the save was not successful (e.g. cancelled)
|
||||
if (result !== false) {
|
||||
await Promise.all(workingCopies.map(workingCopy => workingCopy.isDirty() ? workingCopy.save(saveOptions) : true));
|
||||
}
|
||||
}
|
||||
|
||||
private async doRevertAllBeforeShutdown(workingCopies: IWorkingCopy[]): Promise<void> {
|
||||
|
||||
// Soft revert is good enough on shutdown
|
||||
const revertOptions = { soft: true };
|
||||
|
||||
// First revert through the editor service if we revert all
|
||||
if (workingCopies.length === this.workingCopyService.dirtyCount) {
|
||||
await this.editorService.revertAll(revertOptions);
|
||||
}
|
||||
|
||||
// If we still have dirty working copies, revert those directly
|
||||
// unless the revert operation was not successful (e.g. cancelled)
|
||||
await Promise.all(workingCopies.map(workingCopy => workingCopy.isDirty() ? workingCopy.revert(revertOptions) : undefined));
|
||||
}
|
||||
|
||||
private noVeto(backupsToDiscard: IWorkingCopy[]): boolean | Promise<boolean> {
|
||||
if (this.lifecycleService.phase < LifecyclePhase.Restored) {
|
||||
return false; // if editors have not restored, we are not up to speed with backups and thus should not discard them
|
||||
}
|
||||
|
||||
return Promise.all(backupsToDiscard.map(workingCopy => this.backupFileService.discardBackup(workingCopy.resource))).then(() => false, () => false);
|
||||
}
|
||||
|
||||
private async onBeforeShutdownWithoutDirty(): Promise<boolean> {
|
||||
// If we have proceeded enough that editors and dirty state
|
||||
// has restored, we make sure that no backups lure around
|
||||
// given we have no known dirty working copy. This helps
|
||||
// to clean up stale backups as for example reported in
|
||||
// https://github.com/microsoft/vscode/issues/92962
|
||||
if (this.lifecycleService.phase >= LifecyclePhase.Restored) {
|
||||
try {
|
||||
await this.backupFileService.discardBackups();
|
||||
} catch (error) {
|
||||
this.logService.error(`[backup tracker] error discarding backups: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return false; // no veto (no dirty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import * as os from 'os';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import * as pfs from 'vs/base/node/pfs';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { createTextBufferFactory } from 'vs/editor/common/model/textModel';
|
||||
import { getRandomTestPath } from 'vs/base/test/node/testUtils';
|
||||
import { DefaultEndOfLine } from 'vs/editor/common/model';
|
||||
import { hashPath } from 'vs/workbench/services/backup/node/backupFileService';
|
||||
import { NativeBackupTracker } from 'vs/workbench/contrib/backup/electron-sandbox/backupTracker';
|
||||
import { workbenchInstantiationService } from 'vs/workbench/test/electron-browser/workbenchTestServices';
|
||||
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart';
|
||||
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
|
||||
import { EditorService } from 'vs/workbench/services/editor/browser/editorService';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { EditorInput } from 'vs/workbench/common/editor';
|
||||
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
|
||||
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
|
||||
import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor';
|
||||
import { TextFileEditor } from 'vs/workbench/contrib/files/browser/editors/textFileEditor';
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { NodeTestBackupFileService } from 'vs/workbench/services/backup/test/electron-browser/backupFileService.test';
|
||||
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
import { isEqual } from 'vs/base/common/resources';
|
||||
import { TestServiceAccessor } from 'vs/workbench/test/browser/workbenchTestServices';
|
||||
import { BackupRestorer } from 'vs/workbench/contrib/backup/common/backupRestorer';
|
||||
|
||||
const userdataDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backuprestorer');
|
||||
const backupHome = path.join(userdataDir, 'Backups');
|
||||
const workspacesJsonPath = path.join(backupHome, 'workspaces.json');
|
||||
|
||||
const workspaceResource = URI.file(platform.isWindows ? 'c:\\workspace' : '/workspace');
|
||||
const workspaceBackupPath = path.join(backupHome, hashPath(workspaceResource));
|
||||
const fooFile = URI.file(platform.isWindows ? 'c:\\Foo' : '/Foo');
|
||||
const barFile = URI.file(platform.isWindows ? 'c:\\Bar' : '/Bar');
|
||||
const untitledFile1 = URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' });
|
||||
const untitledFile2 = URI.from({ scheme: Schemas.untitled, path: 'Untitled-2' });
|
||||
|
||||
class TestBackupRestorer extends BackupRestorer {
|
||||
async doRestoreBackups(): Promise<URI[] | undefined> {
|
||||
return super.doRestoreBackups();
|
||||
}
|
||||
}
|
||||
|
||||
suite('BackupRestorer', () => {
|
||||
let accessor: TestServiceAccessor;
|
||||
|
||||
let disposables: IDisposable[] = [];
|
||||
|
||||
setup(async () => {
|
||||
disposables.push(Registry.as<IEditorRegistry>(EditorExtensions.Editors).registerEditor(
|
||||
EditorDescriptor.create(
|
||||
TextFileEditor,
|
||||
TextFileEditor.ID,
|
||||
'Text File Editor'
|
||||
),
|
||||
[new SyncDescriptor<EditorInput>(FileEditorInput)]
|
||||
));
|
||||
|
||||
// Delete any existing backups completely and then re-create it.
|
||||
await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE);
|
||||
await pfs.mkdirp(backupHome);
|
||||
|
||||
return pfs.writeFile(workspacesJsonPath, '');
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
dispose(disposables);
|
||||
disposables = [];
|
||||
|
||||
(<TextFileEditorModelManager>accessor.textFileService.files).dispose();
|
||||
|
||||
return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE);
|
||||
});
|
||||
|
||||
test('Restore backups', async function () {
|
||||
this.timeout(20000);
|
||||
|
||||
const backupFileService = new NodeTestBackupFileService(workspaceBackupPath);
|
||||
const instantiationService = workbenchInstantiationService();
|
||||
instantiationService.stub(IBackupFileService, backupFileService);
|
||||
|
||||
const part = instantiationService.createInstance(EditorPart);
|
||||
part.create(document.createElement('div'));
|
||||
part.layout(400, 300);
|
||||
|
||||
instantiationService.stub(IEditorGroupsService, part);
|
||||
|
||||
const editorService: EditorService = instantiationService.createInstance(EditorService);
|
||||
instantiationService.stub(IEditorService, editorService);
|
||||
|
||||
accessor = instantiationService.createInstance(TestServiceAccessor);
|
||||
|
||||
await part.whenRestored;
|
||||
|
||||
const tracker = instantiationService.createInstance(NativeBackupTracker);
|
||||
const restorer = instantiationService.createInstance(TestBackupRestorer);
|
||||
|
||||
// Backup 2 normal files and 2 untitled file
|
||||
await backupFileService.backup(untitledFile1, createTextBufferFactory('untitled-1').create(DefaultEndOfLine.LF).createSnapshot(false));
|
||||
await backupFileService.backup(untitledFile2, createTextBufferFactory('untitled-2').create(DefaultEndOfLine.LF).createSnapshot(false));
|
||||
await backupFileService.backup(fooFile, createTextBufferFactory('fooFile').create(DefaultEndOfLine.LF).createSnapshot(false));
|
||||
await backupFileService.backup(barFile, createTextBufferFactory('barFile').create(DefaultEndOfLine.LF).createSnapshot(false));
|
||||
|
||||
// Verify backups restored and opened as dirty
|
||||
await restorer.doRestoreBackups();
|
||||
assert.equal(editorService.count, 4);
|
||||
assert.ok(editorService.editors.every(editor => editor.isDirty()));
|
||||
|
||||
let counter = 0;
|
||||
for (const editor of editorService.editors) {
|
||||
const resource = editor.resource;
|
||||
if (isEqual(resource, untitledFile1)) {
|
||||
const model = await accessor.textFileService.untitled.resolve({ untitledResource: resource });
|
||||
if (model.textEditorModel.getValue() !== 'untitled-1') {
|
||||
const backupContents = await backupFileService.getBackupContents(untitledFile1);
|
||||
assert.fail(`Unable to restore backup for resource ${untitledFile1.toString()}. Backup contents: ${backupContents}`);
|
||||
}
|
||||
model.dispose();
|
||||
counter++;
|
||||
} else if (isEqual(resource, untitledFile2)) {
|
||||
const model = await accessor.textFileService.untitled.resolve({ untitledResource: resource });
|
||||
if (model.textEditorModel.getValue() !== 'untitled-2') {
|
||||
const backupContents = await backupFileService.getBackupContents(untitledFile2);
|
||||
assert.fail(`Unable to restore backup for resource ${untitledFile2.toString()}. Backup contents: ${backupContents}`);
|
||||
}
|
||||
model.dispose();
|
||||
counter++;
|
||||
} else if (isEqual(resource, fooFile)) {
|
||||
const model = await accessor.textFileService.files.get(fooFile!)?.load();
|
||||
if (model?.textEditorModel?.getValue() !== 'fooFile') {
|
||||
const backupContents = await backupFileService.getBackupContents(fooFile);
|
||||
assert.fail(`Unable to restore backup for resource ${fooFile.toString()}. Backup contents: ${backupContents}`);
|
||||
}
|
||||
counter++;
|
||||
} else {
|
||||
const model = await accessor.textFileService.files.get(barFile!)?.load();
|
||||
if (model?.textEditorModel?.getValue() !== 'barFile') {
|
||||
const backupContents = await backupFileService.getBackupContents(barFile);
|
||||
assert.fail(`Unable to restore backup for resource ${barFile.toString()}. Backup contents: ${backupContents}`);
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(counter, 4);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import * as os from 'os';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import * as pfs from 'vs/base/node/pfs';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { getRandomTestPath } from 'vs/base/test/node/testUtils';
|
||||
import { hashPath } from 'vs/workbench/services/backup/node/backupFileService';
|
||||
import { NativeBackupTracker } from 'vs/workbench/contrib/backup/electron-sandbox/backupTracker';
|
||||
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart';
|
||||
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
|
||||
import { EditorService } from 'vs/workbench/services/editor/browser/editorService';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { EditorInput, IUntitledTextResourceEditorInput } from 'vs/workbench/common/editor';
|
||||
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
|
||||
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
|
||||
import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor';
|
||||
import { TextFileEditor } from 'vs/workbench/contrib/files/browser/editors/textFileEditor';
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
import { NodeTestBackupFileService } from 'vs/workbench/services/backup/test/electron-browser/backupFileService.test';
|
||||
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { toResource } from 'vs/base/test/common/utils';
|
||||
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
|
||||
import { IWorkingCopyBackup, IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { HotExitConfiguration } from 'vs/platform/files/common/files';
|
||||
import { ShutdownReason, ILifecycleService, BeforeShutdownEvent } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { IFileDialogService, ConfirmResult, IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import { IWorkspaceContextService, Workspace } from 'vs/platform/workspace/common/workspace';
|
||||
import { INativeHostService } from 'vs/platform/native/electron-sandbox/native';
|
||||
import { BackupTracker } from 'vs/workbench/contrib/backup/common/backupTracker';
|
||||
import { workbenchInstantiationService, TestServiceAccessor } from 'vs/workbench/test/electron-browser/workbenchTestServices';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { UntitledTextEditorInput } from 'vs/workbench/services/untitled/common/untitledTextEditorInput';
|
||||
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { TestFilesConfigurationService } from 'vs/workbench/test/browser/workbenchTestServices';
|
||||
import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
|
||||
import { TestWorkingCopy } from 'vs/workbench/test/common/workbenchTestServices';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
|
||||
const userdataDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backuprestorer');
|
||||
const backupHome = path.join(userdataDir, 'Backups');
|
||||
const workspacesJsonPath = path.join(backupHome, 'workspaces.json');
|
||||
|
||||
const workspaceResource = URI.file(platform.isWindows ? 'c:\\workspace' : '/workspace');
|
||||
const workspaceBackupPath = path.join(backupHome, hashPath(workspaceResource));
|
||||
|
||||
class TestBackupTracker extends NativeBackupTracker {
|
||||
|
||||
constructor(
|
||||
@IBackupFileService backupFileService: IBackupFileService,
|
||||
@IFilesConfigurationService filesConfigurationService: IFilesConfigurationService,
|
||||
@IWorkingCopyService workingCopyService: IWorkingCopyService,
|
||||
@ILifecycleService lifecycleService: ILifecycleService,
|
||||
@IFileDialogService fileDialogService: IFileDialogService,
|
||||
@IDialogService dialogService: IDialogService,
|
||||
@IWorkspaceContextService contextService: IWorkspaceContextService,
|
||||
@INativeHostService nativeHostService: INativeHostService,
|
||||
@ILogService logService: ILogService,
|
||||
@IEditorService editorService: IEditorService,
|
||||
@IEnvironmentService environmentService: IEnvironmentService
|
||||
) {
|
||||
super(backupFileService, filesConfigurationService, workingCopyService, lifecycleService, fileDialogService, dialogService, contextService, nativeHostService, logService, editorService, environmentService);
|
||||
}
|
||||
|
||||
protected getBackupScheduleDelay(): number {
|
||||
return 10; // Reduce timeout for tests
|
||||
}
|
||||
}
|
||||
|
||||
class BeforeShutdownEventImpl implements BeforeShutdownEvent {
|
||||
|
||||
value: boolean | Promise<boolean> | undefined;
|
||||
reason = ShutdownReason.CLOSE;
|
||||
|
||||
veto(value: boolean | Promise<boolean>): void {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
suite('BackupTracker', () => {
|
||||
let accessor: TestServiceAccessor;
|
||||
let disposables: IDisposable[] = [];
|
||||
|
||||
setup(async () => {
|
||||
const instantiationService = workbenchInstantiationService();
|
||||
accessor = instantiationService.createInstance(TestServiceAccessor);
|
||||
|
||||
disposables.push(Registry.as<IEditorRegistry>(EditorExtensions.Editors).registerEditor(
|
||||
EditorDescriptor.create(
|
||||
TextFileEditor,
|
||||
TextFileEditor.ID,
|
||||
'Text File Editor'
|
||||
),
|
||||
[new SyncDescriptor<EditorInput>(FileEditorInput)]
|
||||
));
|
||||
|
||||
// Delete any existing backups completely and then re-create it.
|
||||
await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE);
|
||||
await pfs.mkdirp(backupHome);
|
||||
await pfs.mkdirp(workspaceBackupPath);
|
||||
|
||||
return pfs.writeFile(workspacesJsonPath, '');
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
dispose(disposables);
|
||||
disposables = [];
|
||||
|
||||
(<TextFileEditorModelManager>accessor.textFileService.files).dispose();
|
||||
|
||||
return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE);
|
||||
});
|
||||
|
||||
async function createTracker(autoSaveEnabled = false): Promise<[TestServiceAccessor, EditorPart, BackupTracker, IInstantiationService]> {
|
||||
const backupFileService = new NodeTestBackupFileService(workspaceBackupPath);
|
||||
const instantiationService = workbenchInstantiationService();
|
||||
instantiationService.stub(IBackupFileService, backupFileService);
|
||||
|
||||
const configurationService = new TestConfigurationService();
|
||||
if (autoSaveEnabled) {
|
||||
configurationService.setUserConfiguration('files', { autoSave: 'afterDelay', autoSaveDelay: 1 });
|
||||
}
|
||||
instantiationService.stub(IConfigurationService, configurationService);
|
||||
|
||||
instantiationService.stub(IFilesConfigurationService, new TestFilesConfigurationService(
|
||||
<IContextKeyService>instantiationService.createInstance(MockContextKeyService),
|
||||
configurationService
|
||||
));
|
||||
|
||||
const part = instantiationService.createInstance(EditorPart);
|
||||
part.create(document.createElement('div'));
|
||||
part.layout(400, 300);
|
||||
|
||||
instantiationService.stub(IEditorGroupsService, part);
|
||||
|
||||
const editorService: EditorService = instantiationService.createInstance(EditorService);
|
||||
instantiationService.stub(IEditorService, editorService);
|
||||
|
||||
accessor = instantiationService.createInstance(TestServiceAccessor);
|
||||
|
||||
await part.whenRestored;
|
||||
|
||||
const tracker = instantiationService.createInstance(TestBackupTracker);
|
||||
|
||||
return [accessor, part, tracker, instantiationService];
|
||||
}
|
||||
|
||||
async function untitledBackupTest(untitled: IUntitledTextResourceEditorInput = {}): Promise<void> {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const untitledEditor = (await accessor.editorService.openEditor(untitled))?.input as UntitledTextEditorInput;
|
||||
|
||||
const untitledModel = await untitledEditor.resolve();
|
||||
|
||||
if (!untitled?.contents) {
|
||||
untitledModel.textEditorModel.setValue('Super Good');
|
||||
}
|
||||
|
||||
await accessor.backupFileService.joinBackupResource();
|
||||
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(untitledEditor.resource), true);
|
||||
|
||||
untitledModel.dispose();
|
||||
|
||||
await accessor.backupFileService.joinDiscardBackup();
|
||||
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(untitledEditor.resource), false);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
}
|
||||
|
||||
test('Track backups (untitled)', function () {
|
||||
this.timeout(20000);
|
||||
|
||||
return untitledBackupTest();
|
||||
});
|
||||
|
||||
test('Track backups (untitled with initial contents)', function () {
|
||||
this.timeout(20000);
|
||||
|
||||
return untitledBackupTest({ contents: 'Foo Bar' });
|
||||
});
|
||||
|
||||
test('Track backups (file)', async function () {
|
||||
this.timeout(20000);
|
||||
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const fileModel = accessor.textFileService.files.get(resource);
|
||||
fileModel?.textEditorModel?.setValue('Super Good');
|
||||
|
||||
await accessor.backupFileService.joinBackupResource();
|
||||
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), true);
|
||||
|
||||
fileModel?.dispose();
|
||||
|
||||
await accessor.backupFileService.joinDiscardBackup();
|
||||
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), false);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('Track backups (custom)', async function () {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
class TestBackupWorkingCopy extends TestWorkingCopy {
|
||||
|
||||
backupDelay = 0;
|
||||
|
||||
constructor(resource: URI) {
|
||||
super(resource);
|
||||
|
||||
accessor.workingCopyService.registerWorkingCopy(this);
|
||||
}
|
||||
|
||||
async backup(token: CancellationToken): Promise<IWorkingCopyBackup> {
|
||||
await timeout(this.backupDelay);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const resource = toResource.call(this, '/path/custom.txt');
|
||||
const customWorkingCopy = new TestBackupWorkingCopy(resource);
|
||||
|
||||
// Normal
|
||||
customWorkingCopy.setDirty(true);
|
||||
await accessor.backupFileService.joinBackupResource();
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), true);
|
||||
|
||||
customWorkingCopy.setDirty(false);
|
||||
customWorkingCopy.setDirty(true);
|
||||
await accessor.backupFileService.joinBackupResource();
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), true);
|
||||
|
||||
customWorkingCopy.setDirty(false);
|
||||
await accessor.backupFileService.joinDiscardBackup();
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), false);
|
||||
|
||||
// Cancellation
|
||||
customWorkingCopy.setDirty(true);
|
||||
await timeout(0);
|
||||
customWorkingCopy.setDirty(false);
|
||||
await accessor.backupFileService.joinDiscardBackup();
|
||||
assert.equal(accessor.backupFileService.hasBackupSync(resource), false);
|
||||
|
||||
customWorkingCopy.dispose();
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('onWillShutdown - no veto if no dirty files', async function () {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.ok(!veto);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('onWillShutdown - veto if user cancels (hot.exit: off)', async function () {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const model = accessor.textFileService.files.get(resource);
|
||||
|
||||
accessor.fileDialogService.setConfirmResult(ConfirmResult.CANCEL);
|
||||
accessor.filesConfigurationService.onFilesConfigurationChange({ files: { hotExit: 'off' } });
|
||||
|
||||
await model?.load();
|
||||
model?.textEditorModel?.setValue('foo');
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 1);
|
||||
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.ok(veto);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('onWillShutdown - no veto if auto save is on', async function () {
|
||||
const [accessor, part, tracker] = await createTracker(true /* auto save enabled */);
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const model = accessor.textFileService.files.get(resource);
|
||||
|
||||
await model?.load();
|
||||
model?.textEditorModel?.setValue('foo');
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 1);
|
||||
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.ok(!veto);
|
||||
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 0);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('onWillShutdown - no veto and backups cleaned up if user does not want to save (hot.exit: off)', async function () {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const model = accessor.textFileService.files.get(resource);
|
||||
|
||||
accessor.fileDialogService.setConfirmResult(ConfirmResult.DONT_SAVE);
|
||||
accessor.filesConfigurationService.onFilesConfigurationChange({ files: { hotExit: 'off' } });
|
||||
|
||||
await model?.load();
|
||||
model?.textEditorModel?.setValue('foo');
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 1);
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.ok(!veto);
|
||||
assert.ok(accessor.backupFileService.discardedBackups.length > 0);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test('onWillShutdown - save (hot.exit: off)', async function () {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const model = accessor.textFileService.files.get(resource);
|
||||
|
||||
accessor.fileDialogService.setConfirmResult(ConfirmResult.SAVE);
|
||||
accessor.filesConfigurationService.onFilesConfigurationChange({ files: { hotExit: 'off' } });
|
||||
|
||||
await model?.load();
|
||||
model?.textEditorModel?.setValue('foo');
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 1);
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.ok(!veto);
|
||||
assert.ok(!model?.isDirty());
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
suite('Hot Exit', () => {
|
||||
suite('"onExit" setting', () => {
|
||||
test('should hot exit on non-Mac (reason: CLOSE, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.CLOSE, false, true, !!platform.isMacintosh);
|
||||
});
|
||||
test('should hot exit on non-Mac (reason: CLOSE, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.CLOSE, false, false, !!platform.isMacintosh);
|
||||
});
|
||||
test('should NOT hot exit (reason: CLOSE, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.CLOSE, true, true, true);
|
||||
});
|
||||
test('should NOT hot exit (reason: CLOSE, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.CLOSE, true, false, true);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.QUIT, false, true, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.QUIT, false, false, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.QUIT, true, true, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.QUIT, true, false, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.RELOAD, false, true, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.RELOAD, false, false, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.RELOAD, true, true, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.RELOAD, true, false, false);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.LOAD, false, true, true);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.LOAD, false, false, true);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.LOAD, true, true, true);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT, ShutdownReason.LOAD, true, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
suite('"onExitAndWindowClose" setting', () => {
|
||||
test('should hot exit (reason: CLOSE, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.CLOSE, false, true, false);
|
||||
});
|
||||
test('should hot exit (reason: CLOSE, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.CLOSE, false, false, !!platform.isMacintosh);
|
||||
});
|
||||
test('should hot exit (reason: CLOSE, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.CLOSE, true, true, false);
|
||||
});
|
||||
test('should NOT hot exit (reason: CLOSE, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.CLOSE, true, false, true);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.QUIT, false, true, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.QUIT, false, false, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.QUIT, true, true, false);
|
||||
});
|
||||
test('should hot exit (reason: QUIT, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.QUIT, true, false, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.RELOAD, false, true, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.RELOAD, false, false, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.RELOAD, true, true, false);
|
||||
});
|
||||
test('should hot exit (reason: RELOAD, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.RELOAD, true, false, false);
|
||||
});
|
||||
test('should hot exit (reason: LOAD, windows: single, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.LOAD, false, true, false);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: single, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.LOAD, false, false, true);
|
||||
});
|
||||
test('should hot exit (reason: LOAD, windows: multiple, workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.LOAD, true, true, false);
|
||||
});
|
||||
test('should NOT hot exit (reason: LOAD, windows: multiple, empty workspace)', function () {
|
||||
return hotExitTest.call(this, HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE, ShutdownReason.LOAD, true, false, true);
|
||||
});
|
||||
});
|
||||
|
||||
async function hotExitTest(this: any, setting: string, shutdownReason: ShutdownReason, multipleWindows: boolean, workspace: boolean, shouldVeto: boolean): Promise<void> {
|
||||
const [accessor, part, tracker] = await createTracker();
|
||||
|
||||
const resource = toResource.call(this, '/path/index.txt');
|
||||
await accessor.editorService.openEditor({ resource, options: { pinned: true } });
|
||||
|
||||
const model = accessor.textFileService.files.get(resource);
|
||||
|
||||
// Set hot exit config
|
||||
accessor.filesConfigurationService.onFilesConfigurationChange({ files: { hotExit: setting } });
|
||||
|
||||
// Set empty workspace if required
|
||||
if (!workspace) {
|
||||
accessor.contextService.setWorkspace(new Workspace('empty:1508317022751'));
|
||||
}
|
||||
|
||||
// Set multiple windows if required
|
||||
if (multipleWindows) {
|
||||
accessor.nativeHostService.windowCount = Promise.resolve(2);
|
||||
}
|
||||
|
||||
// Set cancel to force a veto if hot exit does not trigger
|
||||
accessor.fileDialogService.setConfirmResult(ConfirmResult.CANCEL);
|
||||
|
||||
await model?.load();
|
||||
model?.textEditorModel?.setValue('foo');
|
||||
assert.equal(accessor.workingCopyService.dirtyCount, 1);
|
||||
|
||||
const event = new BeforeShutdownEventImpl();
|
||||
event.reason = shutdownReason;
|
||||
accessor.lifecycleService.fireWillShutdown(event);
|
||||
|
||||
const veto = await event.value;
|
||||
assert.equal(accessor.backupFileService.discardedBackups.length, 0); // When hot exit is set, backups should never be cleaned since the confirm result is cancel
|
||||
assert.equal(veto, shouldVeto);
|
||||
|
||||
part.dispose();
|
||||
tracker.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { groupBy } from 'vs/base/common/arrays';
|
||||
import { compare } from 'vs/base/common/strings';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ResourceEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
import { WorkspaceEditMetadata } from 'vs/editor/common/modes';
|
||||
import { IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { UndoRedoGroup } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
import { ICellEditOperation } from 'vs/workbench/contrib/notebook/common/notebookCommon';
|
||||
import { INotebookEditorModelResolverService } from 'vs/workbench/contrib/notebook/common/notebookEditorModelResolverService';
|
||||
import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService';
|
||||
|
||||
export class ResourceNotebookCellEdit extends ResourceEdit {
|
||||
|
||||
constructor(
|
||||
readonly resource: URI,
|
||||
readonly cellEdit: ICellEditOperation,
|
||||
readonly versionId?: number,
|
||||
readonly metadata?: WorkspaceEditMetadata
|
||||
) {
|
||||
super(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkCellEdits {
|
||||
|
||||
constructor(
|
||||
private _undoRedoGroup: UndoRedoGroup,
|
||||
private readonly _progress: IProgress<void>,
|
||||
private readonly _edits: ResourceNotebookCellEdit[],
|
||||
@INotebookService private readonly _notebookService: INotebookService,
|
||||
@INotebookEditorModelResolverService private readonly _notebookModelService: INotebookEditorModelResolverService,
|
||||
) { }
|
||||
|
||||
async apply(): Promise<void> {
|
||||
|
||||
const editsByNotebook = groupBy(this._edits, (a, b) => compare(a.resource.toString(), b.resource.toString()));
|
||||
|
||||
for (let group of editsByNotebook) {
|
||||
const [first] = group;
|
||||
const ref = await this._notebookModelService.resolve(first.resource);
|
||||
|
||||
// check state
|
||||
// if (typeof first.versionId === 'number' && ref.object.notebook.versionId !== first.versionId) {
|
||||
// ref.dispose();
|
||||
// throw new Error(`Notebook '${first.resource}' has changed in the meantime`);
|
||||
// }
|
||||
|
||||
// apply edits
|
||||
const edits = group.map(entry => entry.cellEdit);
|
||||
this._notebookService.transformEditsOutputs(ref.object.notebook, edits);
|
||||
ref.object.notebook.applyEdits(ref.object.notebook.versionId, edits, true, undefined, () => undefined, this._undoRedoGroup);
|
||||
ref.dispose();
|
||||
|
||||
this._progress.report(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from 'vs/nls';
|
||||
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IBulkEditOptions, IBulkEditResult, IBulkEditService, IBulkEditPreviewHandler, ResourceEdit, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { IProgress, IProgressStep, Progress } from 'vs/platform/progress/common/progress';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { BulkTextEdits } from 'vs/workbench/contrib/bulkEdit/browser/bulkTextEdits';
|
||||
import { BulkFileEdits } from 'vs/workbench/contrib/bulkEdit/browser/bulkFileEdits';
|
||||
import { BulkCellEdits, ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits';
|
||||
import { UndoRedoGroup } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
|
||||
class BulkEdit {
|
||||
|
||||
constructor(
|
||||
private readonly _label: string | undefined,
|
||||
private readonly _editor: ICodeEditor | undefined,
|
||||
private readonly _progress: IProgress<IProgressStep>,
|
||||
private readonly _edits: ResourceEdit[],
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
ariaMessage(): string {
|
||||
const editCount = this._edits.length;
|
||||
const resourceCount = this._edits.length;
|
||||
if (editCount === 0) {
|
||||
return localize('summary.0', "Made no edits");
|
||||
} else if (editCount > 1 && resourceCount > 1) {
|
||||
return localize('summary.nm', "Made {0} text edits in {1} files", editCount, resourceCount);
|
||||
} else {
|
||||
return localize('summary.n0', "Made {0} text edits in one file", editCount, resourceCount);
|
||||
}
|
||||
}
|
||||
|
||||
async perform(): Promise<void> {
|
||||
|
||||
if (this._edits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges: number[] = [1];
|
||||
for (let i = 1; i < this._edits.length; i++) {
|
||||
if (Object.getPrototypeOf(this._edits[i - 1]) === Object.getPrototypeOf(this._edits[i])) {
|
||||
ranges[ranges.length - 1]++;
|
||||
} else {
|
||||
ranges.push(1);
|
||||
}
|
||||
}
|
||||
|
||||
this._progress.report({ total: this._edits.length });
|
||||
const progress: IProgress<void> = { report: _ => this._progress.report({ increment: 1 }) };
|
||||
|
||||
const undoRedoGroup = new UndoRedoGroup();
|
||||
|
||||
let index = 0;
|
||||
for (let range of ranges) {
|
||||
const group = this._edits.slice(index, index + range);
|
||||
if (group[0] instanceof ResourceFileEdit) {
|
||||
await this._performFileEdits(<ResourceFileEdit[]>group, undoRedoGroup, progress);
|
||||
} else if (group[0] instanceof ResourceTextEdit) {
|
||||
await this._performTextEdits(<ResourceTextEdit[]>group, undoRedoGroup, progress);
|
||||
} else if (group[0] instanceof ResourceNotebookCellEdit) {
|
||||
await this._performCellEdits(<ResourceNotebookCellEdit[]>group, undoRedoGroup, progress);
|
||||
} else {
|
||||
console.log('UNKNOWN EDIT');
|
||||
}
|
||||
index = index + range;
|
||||
}
|
||||
}
|
||||
|
||||
private async _performFileEdits(edits: ResourceFileEdit[], undoRedoGroup: UndoRedoGroup, progress: IProgress<void>) {
|
||||
this._logService.debug('_performFileEdits', JSON.stringify(edits));
|
||||
const model = this._instaService.createInstance(BulkFileEdits, this._label || localize('workspaceEdit', "Workspace Edit"), undoRedoGroup, progress, edits);
|
||||
await model.apply();
|
||||
}
|
||||
|
||||
private async _performTextEdits(edits: ResourceTextEdit[], undoRedoGroup: UndoRedoGroup, progress: IProgress<void>): Promise<void> {
|
||||
this._logService.debug('_performTextEdits', JSON.stringify(edits));
|
||||
const model = this._instaService.createInstance(BulkTextEdits, this._label || localize('workspaceEdit', "Workspace Edit"), this._editor, undoRedoGroup, progress, edits);
|
||||
await model.apply();
|
||||
}
|
||||
|
||||
private async _performCellEdits(edits: ResourceNotebookCellEdit[], undoRedoGroup: UndoRedoGroup, progress: IProgress<void>): Promise<void> {
|
||||
this._logService.debug('_performCellEdits', JSON.stringify(edits));
|
||||
const model = this._instaService.createInstance(BulkCellEdits, undoRedoGroup, progress, edits);
|
||||
await model.apply();
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkEditService implements IBulkEditService {
|
||||
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private _previewHandler?: IBulkEditPreviewHandler;
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
) { }
|
||||
|
||||
setPreviewHandler(handler: IBulkEditPreviewHandler): IDisposable {
|
||||
this._previewHandler = handler;
|
||||
return toDisposable(() => {
|
||||
if (this._previewHandler === handler) {
|
||||
this._previewHandler = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hasPreviewHandler(): boolean {
|
||||
return Boolean(this._previewHandler);
|
||||
}
|
||||
|
||||
async apply(edits: ResourceEdit[], options?: IBulkEditOptions): Promise<IBulkEditResult> {
|
||||
|
||||
if (edits.length === 0) {
|
||||
return { ariaSummary: localize('nothing', "Made no edits") };
|
||||
}
|
||||
|
||||
if (this._previewHandler && (options?.showPreview || edits.some(value => value.metadata?.needsConfirmation))) {
|
||||
edits = await this._previewHandler(edits, options);
|
||||
}
|
||||
|
||||
let codeEditor = options?.editor;
|
||||
// try to find code editor
|
||||
if (!codeEditor) {
|
||||
let candidate = this._editorService.activeTextEditorControl;
|
||||
if (isCodeEditor(candidate)) {
|
||||
codeEditor = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeEditor && codeEditor.getOption(EditorOption.readOnly)) {
|
||||
// If the code editor is readonly still allow bulk edits to be applied #68549
|
||||
codeEditor = undefined;
|
||||
}
|
||||
|
||||
const bulkEdit = this._instaService.createInstance(
|
||||
BulkEdit,
|
||||
options?.quotableLabel || options?.label,
|
||||
codeEditor, options?.progress ?? Progress.None,
|
||||
edits
|
||||
);
|
||||
|
||||
try {
|
||||
await bulkEdit.perform();
|
||||
return { ariaSummary: bulkEdit.ariaMessage() };
|
||||
} catch (err) {
|
||||
// console.log('apply FAILED');
|
||||
// console.log(err);
|
||||
this._logService.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerSingleton(IBulkEditService, BulkEditService, true);
|
||||
@@ -0,0 +1,211 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
|
||||
import { WorkspaceFileEditOptions } from 'vs/editor/common/modes';
|
||||
import { IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files';
|
||||
import { IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService';
|
||||
import { IWorkspaceUndoRedoElement, UndoRedoElementType, IUndoRedoService, UndoRedoGroup } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { VSBuffer } from 'vs/base/common/buffer';
|
||||
import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
import * as resources from 'vs/base/common/resources';
|
||||
|
||||
interface IFileOperation {
|
||||
uris: URI[];
|
||||
perform(): Promise<IFileOperation>;
|
||||
}
|
||||
|
||||
class Noop implements IFileOperation {
|
||||
readonly uris = [];
|
||||
async perform() { return this; }
|
||||
toString(): string {
|
||||
return '(noop)';
|
||||
}
|
||||
}
|
||||
|
||||
class RenameOperation implements IFileOperation {
|
||||
|
||||
constructor(
|
||||
readonly newUri: URI,
|
||||
readonly oldUri: URI,
|
||||
readonly options: WorkspaceFileEditOptions,
|
||||
@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
) { }
|
||||
|
||||
get uris() {
|
||||
return [this.newUri, this.oldUri];
|
||||
}
|
||||
|
||||
async perform(): Promise<IFileOperation> {
|
||||
// rename
|
||||
if (this.options.overwrite === undefined && this.options.ignoreIfExists && await this._fileService.exists(this.newUri)) {
|
||||
return new Noop(); // not overwriting, but ignoring, and the target file exists
|
||||
}
|
||||
|
||||
await this._workingCopyFileService.move([{ source: this.oldUri, target: this.newUri }], { overwrite: this.options.overwrite });
|
||||
return new RenameOperation(this.oldUri, this.newUri, this.options, this._workingCopyFileService, this._fileService);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const oldBasename = resources.basename(this.oldUri);
|
||||
const newBasename = resources.basename(this.newUri);
|
||||
if (oldBasename !== newBasename) {
|
||||
return `(rename ${oldBasename} to ${newBasename})`;
|
||||
}
|
||||
return `(rename ${this.oldUri} to ${this.newUri})`;
|
||||
}
|
||||
}
|
||||
|
||||
class CreateOperation implements IFileOperation {
|
||||
|
||||
constructor(
|
||||
readonly newUri: URI,
|
||||
readonly options: WorkspaceFileEditOptions,
|
||||
readonly contents: VSBuffer | undefined,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
) { }
|
||||
|
||||
get uris() {
|
||||
return [this.newUri];
|
||||
}
|
||||
|
||||
async perform(): Promise<IFileOperation> {
|
||||
// create file
|
||||
if (this.options.overwrite === undefined && this.options.ignoreIfExists && await this._fileService.exists(this.newUri)) {
|
||||
return new Noop(); // not overwriting, but ignoring, and the target file exists
|
||||
}
|
||||
await this._workingCopyFileService.create(this.newUri, this.contents, { overwrite: this.options.overwrite });
|
||||
return this._instaService.createInstance(DeleteOperation, this.newUri, this.options, true);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `(create ${resources.basename(this.newUri)} with ${this.contents?.byteLength || 0} bytes)`;
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteOperation implements IFileOperation {
|
||||
|
||||
constructor(
|
||||
readonly oldUri: URI,
|
||||
readonly options: WorkspaceFileEditOptions,
|
||||
private readonly _undoesCreateOperation: boolean,
|
||||
@IWorkingCopyFileService private readonly _workingCopyFileService: IWorkingCopyFileService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
@ILogService private readonly _logService: ILogService
|
||||
) { }
|
||||
|
||||
get uris() {
|
||||
return [this.oldUri];
|
||||
}
|
||||
|
||||
async perform(): Promise<IFileOperation> {
|
||||
// delete file
|
||||
if (!await this._fileService.exists(this.oldUri)) {
|
||||
if (!this.options.ignoreIfNotExists) {
|
||||
throw new Error(`${this.oldUri} does not exist and can not be deleted`);
|
||||
}
|
||||
return new Noop();
|
||||
}
|
||||
|
||||
let contents: VSBuffer | undefined;
|
||||
if (!this._undoesCreateOperation) {
|
||||
try {
|
||||
contents = (await this._fileService.readFile(this.oldUri)).value;
|
||||
} catch (err) {
|
||||
this._logService.critical(err);
|
||||
}
|
||||
}
|
||||
|
||||
const useTrash = this._fileService.hasCapability(this.oldUri, FileSystemProviderCapabilities.Trash) && this._configurationService.getValue<boolean>('files.enableTrash');
|
||||
await this._workingCopyFileService.delete([this.oldUri], { useTrash, recursive: this.options.recursive });
|
||||
return this._instaService.createInstance(CreateOperation, this.oldUri, this.options, contents);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `(delete ${resources.basename(this.oldUri)})`;
|
||||
}
|
||||
}
|
||||
|
||||
class FileUndoRedoElement implements IWorkspaceUndoRedoElement {
|
||||
|
||||
readonly type = UndoRedoElementType.Workspace;
|
||||
|
||||
readonly resources: readonly URI[];
|
||||
|
||||
constructor(
|
||||
readonly label: string,
|
||||
readonly operations: IFileOperation[]
|
||||
) {
|
||||
this.resources = (<URI[]>[]).concat(...operations.map(op => op.uris));
|
||||
}
|
||||
|
||||
async undo(): Promise<void> {
|
||||
await this._reverse();
|
||||
}
|
||||
|
||||
async redo(): Promise<void> {
|
||||
await this._reverse();
|
||||
}
|
||||
|
||||
private async _reverse() {
|
||||
for (let i = 0; i < this.operations.length; i++) {
|
||||
const op = this.operations[i];
|
||||
const undo = await op.perform();
|
||||
this.operations[i] = undo;
|
||||
}
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this.operations.map(op => String(op)).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkFileEdits {
|
||||
|
||||
constructor(
|
||||
private readonly _label: string,
|
||||
private readonly _undoRedoGroup: UndoRedoGroup,
|
||||
private readonly _progress: IProgress<void>,
|
||||
private readonly _edits: ResourceFileEdit[],
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
|
||||
) { }
|
||||
|
||||
async apply(): Promise<void> {
|
||||
const undoOperations: IFileOperation[] = [];
|
||||
for (const edit of this._edits) {
|
||||
this._progress.report(undefined);
|
||||
|
||||
const options = edit.options || {};
|
||||
let op: IFileOperation | undefined;
|
||||
if (edit.newResource && edit.oldResource) {
|
||||
// rename
|
||||
op = this._instaService.createInstance(RenameOperation, edit.newResource, edit.oldResource, options);
|
||||
} else if (!edit.newResource && edit.oldResource) {
|
||||
// delete file
|
||||
op = this._instaService.createInstance(DeleteOperation, edit.oldResource, options, false);
|
||||
} else if (edit.newResource && !edit.oldResource) {
|
||||
// create file
|
||||
op = this._instaService.createInstance(CreateOperation, edit.newResource, options, undefined);
|
||||
}
|
||||
if (op) {
|
||||
const undoOp = await op.perform();
|
||||
undoOperations.push(undoOp);
|
||||
}
|
||||
}
|
||||
|
||||
this._undoRedoService.pushElement(new FileUndoRedoElement(this._label, undoOperations), this._undoRedoGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { mergeSort } from 'vs/base/common/arrays';
|
||||
import { dispose, IDisposable, IReference } from 'vs/base/common/lifecycle';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { EndOfLineSequence, IIdentifiedSingleEditOperation, ITextModel } from 'vs/editor/common/model';
|
||||
import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService';
|
||||
import { IProgress } from 'vs/platform/progress/common/progress';
|
||||
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
|
||||
import { IUndoRedoService, UndoRedoGroup } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
import { SingleModelEditStackElement, MultiModelEditStackElement } from 'vs/editor/common/model/editStack';
|
||||
import { ResourceMap } from 'vs/base/common/map';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
|
||||
type ValidationResult = { canApply: true } | { canApply: false, reason: URI };
|
||||
|
||||
class ModelEditTask implements IDisposable {
|
||||
|
||||
readonly model: ITextModel;
|
||||
|
||||
private _expectedModelVersionId: number | undefined;
|
||||
protected _edits: IIdentifiedSingleEditOperation[];
|
||||
protected _newEol: EndOfLineSequence | undefined;
|
||||
|
||||
constructor(private readonly _modelReference: IReference<IResolvedTextEditorModel>) {
|
||||
this.model = this._modelReference.object.textEditorModel;
|
||||
this._edits = [];
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._modelReference.dispose();
|
||||
}
|
||||
|
||||
addEdit(resourceEdit: ResourceTextEdit): void {
|
||||
this._expectedModelVersionId = resourceEdit.versionId;
|
||||
const { textEdit } = resourceEdit;
|
||||
|
||||
if (typeof textEdit.eol === 'number') {
|
||||
// honor eol-change
|
||||
this._newEol = textEdit.eol;
|
||||
}
|
||||
if (!textEdit.range && !textEdit.text) {
|
||||
// lacks both a range and the text
|
||||
return;
|
||||
}
|
||||
if (Range.isEmpty(textEdit.range) && !textEdit.text) {
|
||||
// no-op edit (replace empty range with empty text)
|
||||
return;
|
||||
}
|
||||
|
||||
// create edit operation
|
||||
let range: Range;
|
||||
if (!textEdit.range) {
|
||||
range = this.model.getFullModelRange();
|
||||
} else {
|
||||
range = Range.lift(textEdit.range);
|
||||
}
|
||||
this._edits.push(EditOperation.replaceMove(range, textEdit.text));
|
||||
}
|
||||
|
||||
validate(): ValidationResult {
|
||||
if (typeof this._expectedModelVersionId === 'undefined' || this.model.getVersionId() === this._expectedModelVersionId) {
|
||||
return { canApply: true };
|
||||
}
|
||||
return { canApply: false, reason: this.model.uri };
|
||||
}
|
||||
|
||||
getBeforeCursorState(): Selection[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this._edits.length > 0) {
|
||||
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
|
||||
this.model.pushEditOperations(null, this._edits, () => null);
|
||||
}
|
||||
if (this._newEol !== undefined) {
|
||||
this.model.pushEOL(this._newEol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EditorEditTask extends ModelEditTask {
|
||||
|
||||
private _editor: ICodeEditor;
|
||||
|
||||
constructor(modelReference: IReference<IResolvedTextEditorModel>, editor: ICodeEditor) {
|
||||
super(modelReference);
|
||||
this._editor = editor;
|
||||
}
|
||||
|
||||
getBeforeCursorState(): Selection[] | null {
|
||||
return this._editor.getSelections();
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this._edits.length > 0) {
|
||||
this._edits = mergeSort(this._edits, (a, b) => Range.compareRangesUsingStarts(a.range, b.range));
|
||||
this._editor.executeEdits('', this._edits);
|
||||
}
|
||||
if (this._newEol !== undefined) {
|
||||
if (this._editor.hasModel()) {
|
||||
this._editor.getModel().pushEOL(this._newEol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkTextEdits {
|
||||
|
||||
private readonly _edits = new ResourceMap<ResourceTextEdit[]>();
|
||||
|
||||
constructor(
|
||||
private readonly _label: string,
|
||||
private readonly _editor: ICodeEditor | undefined,
|
||||
private readonly _undoRedoGroup: UndoRedoGroup,
|
||||
private readonly _progress: IProgress<void>,
|
||||
edits: ResourceTextEdit[],
|
||||
@IEditorWorkerService private readonly _editorWorker: IEditorWorkerService,
|
||||
@IModelService private readonly _modelService: IModelService,
|
||||
@ITextModelService private readonly _textModelResolverService: ITextModelService,
|
||||
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService
|
||||
) {
|
||||
|
||||
for (const edit of edits) {
|
||||
let array = this._edits.get(edit.resource);
|
||||
if (!array) {
|
||||
array = [];
|
||||
this._edits.set(edit.resource, array);
|
||||
}
|
||||
array.push(edit);
|
||||
}
|
||||
}
|
||||
|
||||
private _validateBeforePrepare(): void {
|
||||
// First check if loaded models were not changed in the meantime
|
||||
for (const array of this._edits.values()) {
|
||||
for (let edit of array) {
|
||||
if (typeof edit.versionId === 'number') {
|
||||
let model = this._modelService.getModel(edit.resource);
|
||||
if (model && model.getVersionId() !== edit.versionId) {
|
||||
// model changed in the meantime
|
||||
throw new Error(`${model.uri.toString()} has changed in the meantime`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _createEditsTasks(): Promise<ModelEditTask[]> {
|
||||
|
||||
const tasks: ModelEditTask[] = [];
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
for (let [key, value] of this._edits) {
|
||||
const promise = this._textModelResolverService.createModelReference(key).then(async ref => {
|
||||
let task: ModelEditTask;
|
||||
let makeMinimal = false;
|
||||
if (this._editor?.getModel()?.uri.toString() === ref.object.textEditorModel.uri.toString()) {
|
||||
task = new EditorEditTask(ref, this._editor);
|
||||
makeMinimal = true;
|
||||
} else {
|
||||
task = new ModelEditTask(ref);
|
||||
}
|
||||
|
||||
for (const edit of value) {
|
||||
if (makeMinimal) {
|
||||
const newEdits = await this._editorWorker.computeMoreMinimalEdits(edit.resource, [edit.textEdit]);
|
||||
if (!newEdits) {
|
||||
task.addEdit(edit);
|
||||
} else {
|
||||
for (let moreMinialEdit of newEdits) {
|
||||
task.addEdit(new ResourceTextEdit(edit.resource, moreMinialEdit, edit.versionId, edit.metadata));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
task.addEdit(edit);
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push(task);
|
||||
});
|
||||
promises.push(promise);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
private _validateTasks(tasks: ModelEditTask[]): ValidationResult {
|
||||
for (const task of tasks) {
|
||||
const result = task.validate();
|
||||
if (!result.canApply) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return { canApply: true };
|
||||
}
|
||||
|
||||
async apply(): Promise<void> {
|
||||
|
||||
this._validateBeforePrepare();
|
||||
const tasks = await this._createEditsTasks();
|
||||
|
||||
try {
|
||||
|
||||
const validation = this._validateTasks(tasks);
|
||||
if (!validation.canApply) {
|
||||
throw new Error(`${validation.reason.toString()} has changed in the meantime`);
|
||||
}
|
||||
if (tasks.length === 1) {
|
||||
// This edit touches a single model => keep things simple
|
||||
const task = tasks[0];
|
||||
const singleModelEditStackElement = new SingleModelEditStackElement(task.model, task.getBeforeCursorState());
|
||||
this._undoRedoService.pushElement(singleModelEditStackElement, this._undoRedoGroup);
|
||||
task.apply();
|
||||
singleModelEditStackElement.close();
|
||||
this._progress.report(undefined);
|
||||
} else {
|
||||
// prepare multi model undo element
|
||||
const multiModelEditStackElement = new MultiModelEditStackElement(
|
||||
this._label,
|
||||
tasks.map(t => new SingleModelEditStackElement(t.model, t.getBeforeCursorState()))
|
||||
);
|
||||
this._undoRedoService.pushElement(multiModelEditStackElement, this._undoRedoGroup);
|
||||
for (const task of tasks) {
|
||||
task.apply();
|
||||
this._progress.report(undefined);
|
||||
}
|
||||
multiModelEditStackElement.close();
|
||||
}
|
||||
|
||||
} finally {
|
||||
dispose(tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { ResourceMap } from 'vs/base/common/map';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
import { ResourceNotebookCellEdit } from 'vs/workbench/contrib/bulkEdit/browser/bulkCellEdits';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
|
||||
export class ConflictDetector {
|
||||
|
||||
private readonly _conflicts = new ResourceMap<boolean>();
|
||||
private readonly _disposables = new DisposableStore();
|
||||
|
||||
private readonly _onDidConflict = new Emitter<this>();
|
||||
readonly onDidConflict: Event<this> = this._onDidConflict.event;
|
||||
|
||||
constructor(
|
||||
edits: ResourceEdit[],
|
||||
@IFileService fileService: IFileService,
|
||||
@IModelService modelService: IModelService,
|
||||
@ILogService logService: ILogService,
|
||||
) {
|
||||
|
||||
const _workspaceEditResources = new ResourceMap<boolean>();
|
||||
|
||||
for (let edit of edits) {
|
||||
if (edit instanceof ResourceTextEdit) {
|
||||
_workspaceEditResources.set(edit.resource, true);
|
||||
if (typeof edit.versionId === 'number') {
|
||||
const model = modelService.getModel(edit.resource);
|
||||
if (model && model.getVersionId() !== edit.versionId) {
|
||||
this._conflicts.set(edit.resource, true);
|
||||
this._onDidConflict.fire(this);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (edit instanceof ResourceFileEdit) {
|
||||
if (edit.newResource) {
|
||||
_workspaceEditResources.set(edit.newResource, true);
|
||||
|
||||
} else if (edit.oldResource) {
|
||||
_workspaceEditResources.set(edit.oldResource, true);
|
||||
}
|
||||
} else if (edit instanceof ResourceNotebookCellEdit) {
|
||||
_workspaceEditResources.set(edit.resource, true);
|
||||
|
||||
} else {
|
||||
logService.warn('UNKNOWN edit type', edit);
|
||||
}
|
||||
}
|
||||
|
||||
// listen to file changes
|
||||
this._disposables.add(fileService.onDidFilesChange(e => {
|
||||
|
||||
for (const uri of _workspaceEditResources.keys()) {
|
||||
// conflict happens when a file that we are working
|
||||
// on changes on disk. ignore changes for which a model
|
||||
// exists because we have a better check for models
|
||||
if (!modelService.getModel(uri) && e.contains(uri)) {
|
||||
this._conflicts.set(uri, true);
|
||||
this._onDidConflict.fire(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// listen to model changes...?
|
||||
const onDidChangeModel = (model: ITextModel) => {
|
||||
|
||||
// conflict
|
||||
if (_workspaceEditResources.has(model.uri)) {
|
||||
this._conflicts.set(model.uri, true);
|
||||
this._onDidConflict.fire(this);
|
||||
}
|
||||
};
|
||||
for (let model of modelService.getModels()) {
|
||||
this._disposables.add(model.onDidChangeContent(() => onDidChangeModel(model)));
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposables.dispose();
|
||||
this._onDidConflict.dispose();
|
||||
}
|
||||
|
||||
list(): URI[] {
|
||||
return [...this._conflicts.keys()];
|
||||
}
|
||||
|
||||
hasConflicts(): boolean {
|
||||
return this._conflicts.size > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { IPanelService } from 'vs/workbench/services/panel/common/panelService';
|
||||
import { IBulkEditService, ResourceEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
import { BulkEditPane } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane';
|
||||
import { IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry, FocusedViewContext, IViewsService } from 'vs/workbench/common/views';
|
||||
import { localize } from 'vs/nls';
|
||||
import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer';
|
||||
import { RawContextKey, IContextKeyService, IContextKey, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
|
||||
import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput';
|
||||
import { BulkEditPreviewProvider } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { KeyMod, KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { WorkbenchListFocusContextKey } from 'vs/platform/list/browser/listService';
|
||||
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { MenuId, registerAction2, Action2 } from 'vs/platform/actions/common/actions';
|
||||
import { IEditorInput } from 'vs/workbench/common/editor';
|
||||
import type { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import Severity from 'vs/base/common/severity';
|
||||
import { Codicon } from 'vs/base/common/codicons';
|
||||
|
||||
async function getBulkEditPane(viewsService: IViewsService): Promise<BulkEditPane | undefined> {
|
||||
const view = await viewsService.openView(BulkEditPane.ID, true);
|
||||
if (view instanceof BulkEditPane) {
|
||||
return view;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class UXState {
|
||||
|
||||
private readonly _activePanel: string | undefined;
|
||||
|
||||
constructor(
|
||||
@IPanelService private readonly _panelService: IPanelService,
|
||||
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
|
||||
) {
|
||||
this._activePanel = _panelService.getActivePanel()?.getId();
|
||||
}
|
||||
|
||||
async restore(): Promise<void> {
|
||||
|
||||
// (1) restore previous panel
|
||||
if (typeof this._activePanel === 'string') {
|
||||
await this._panelService.openPanel(this._activePanel);
|
||||
} else {
|
||||
this._panelService.hideActivePanel();
|
||||
}
|
||||
|
||||
// (2) close preview editors
|
||||
for (let group of this._editorGroupsService.groups) {
|
||||
let previewEditors: IEditorInput[] = [];
|
||||
for (let input of group.editors) {
|
||||
|
||||
let resource: URI | undefined;
|
||||
if (input instanceof DiffEditorInput) {
|
||||
resource = input.modifiedInput.resource;
|
||||
} else {
|
||||
resource = input.resource;
|
||||
}
|
||||
|
||||
if (resource?.scheme === BulkEditPreviewProvider.Schema) {
|
||||
previewEditors.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
if (previewEditors.length) {
|
||||
group.closeEditors(previewEditors, { preserveFocus: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewSession {
|
||||
constructor(
|
||||
readonly uxState: UXState,
|
||||
readonly cts: CancellationTokenSource = new CancellationTokenSource(),
|
||||
) { }
|
||||
}
|
||||
|
||||
class BulkEditPreviewContribution {
|
||||
|
||||
static readonly ctxEnabled = new RawContextKey('refactorPreview.enabled', false);
|
||||
|
||||
private readonly _ctxEnabled: IContextKey<boolean>;
|
||||
|
||||
private _activeSession: PreviewSession | undefined;
|
||||
|
||||
constructor(
|
||||
@IPanelService private readonly _panelService: IPanelService,
|
||||
@IViewsService private readonly _viewsService: IViewsService,
|
||||
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
|
||||
@IDialogService private readonly _dialogService: IDialogService,
|
||||
@IBulkEditService bulkEditService: IBulkEditService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
) {
|
||||
bulkEditService.setPreviewHandler(edits => this._previewEdit(edits));
|
||||
this._ctxEnabled = BulkEditPreviewContribution.ctxEnabled.bindTo(contextKeyService);
|
||||
}
|
||||
|
||||
private async _previewEdit(edits: ResourceEdit[]): Promise<ResourceEdit[]> {
|
||||
this._ctxEnabled.set(true);
|
||||
|
||||
const uxState = this._activeSession?.uxState ?? new UXState(this._panelService, this._editorGroupsService);
|
||||
const view = await getBulkEditPane(this._viewsService);
|
||||
if (!view) {
|
||||
this._ctxEnabled.set(false);
|
||||
return edits;
|
||||
}
|
||||
|
||||
// check for active preview session and let the user decide
|
||||
if (view.hasInput()) {
|
||||
const choice = await this._dialogService.show(
|
||||
Severity.Info,
|
||||
localize('overlap', "Another refactoring is being previewed."),
|
||||
[localize('cancel', "Cancel"), localize('continue', "Continue")],
|
||||
{ detail: localize('detail', "Press 'Continue' to discard the previous refactoring and continue with the current refactoring.") }
|
||||
);
|
||||
|
||||
if (choice.choice === 0) {
|
||||
// this refactoring is being cancelled
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// session
|
||||
let session: PreviewSession;
|
||||
if (this._activeSession) {
|
||||
this._activeSession.cts.dispose(true);
|
||||
session = new PreviewSession(uxState);
|
||||
} else {
|
||||
session = new PreviewSession(uxState);
|
||||
}
|
||||
this._activeSession = session;
|
||||
|
||||
// the actual work...
|
||||
try {
|
||||
|
||||
return await view.setInput(edits, session.cts.token) ?? [];
|
||||
|
||||
} finally {
|
||||
// restore UX state
|
||||
if (this._activeSession === session) {
|
||||
await this._activeSession.uxState.restore();
|
||||
this._activeSession.cts.dispose();
|
||||
this._ctxEnabled.set(false);
|
||||
this._activeSession = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CMD: accept
|
||||
registerAction2(class ApplyAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.apply',
|
||||
title: { value: localize('apply', "Apply Refactoring"), original: 'Apply Refactoring' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
icon: { id: 'codicon/check' },
|
||||
precondition: ContextKeyExpr.and(BulkEditPreviewContribution.ctxEnabled, BulkEditPane.ctxHasCheckedChanges),
|
||||
menu: [{
|
||||
id: MenuId.BulkEditTitle,
|
||||
group: 'navigation'
|
||||
}, {
|
||||
id: MenuId.BulkEditContext,
|
||||
order: 1
|
||||
}],
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.EditorContrib - 10,
|
||||
when: ContextKeyExpr.and(BulkEditPreviewContribution.ctxEnabled, FocusedViewContext.isEqualTo(BulkEditPane.ID)),
|
||||
primary: KeyMod.Shift + KeyCode.Enter,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<any> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.accept();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CMD: discard
|
||||
registerAction2(class DiscardAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.discard',
|
||||
title: { value: localize('Discard', "Discard Refactoring"), original: 'Discard Refactoring' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
icon: { id: 'codicon/clear-all' },
|
||||
precondition: BulkEditPreviewContribution.ctxEnabled,
|
||||
menu: [{
|
||||
id: MenuId.BulkEditTitle,
|
||||
group: 'navigation'
|
||||
}, {
|
||||
id: MenuId.BulkEditContext,
|
||||
order: 2
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.discard();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// CMD: toggle change
|
||||
registerAction2(class ToggleAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.toggleCheckedState',
|
||||
title: { value: localize('toogleSelection', "Toggle Change"), original: 'Toggle Change' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
precondition: BulkEditPreviewContribution.ctxEnabled,
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
when: WorkbenchListFocusContextKey,
|
||||
primary: KeyCode.Space,
|
||||
},
|
||||
menu: {
|
||||
id: MenuId.BulkEditContext,
|
||||
group: 'navigation'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.toggleChecked();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// CMD: toggle category
|
||||
registerAction2(class GroupByFile extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.groupByFile',
|
||||
title: { value: localize('groupByFile', "Group Changes By File"), original: 'Group Changes By File' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
icon: { id: 'codicon/ungroup-by-ref-type' },
|
||||
precondition: ContextKeyExpr.and(BulkEditPane.ctxHasCategories, BulkEditPane.ctxGroupByFile.negate(), BulkEditPreviewContribution.ctxEnabled),
|
||||
menu: [{
|
||||
id: MenuId.BulkEditTitle,
|
||||
when: ContextKeyExpr.and(BulkEditPane.ctxHasCategories, BulkEditPane.ctxGroupByFile.negate()),
|
||||
group: 'navigation',
|
||||
order: 3,
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.groupByFile();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class GroupByType extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.groupByType',
|
||||
title: { value: localize('groupByType', "Group Changes By Type"), original: 'Group Changes By Type' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
icon: { id: 'codicon/group-by-ref-type' },
|
||||
precondition: ContextKeyExpr.and(BulkEditPane.ctxHasCategories, BulkEditPane.ctxGroupByFile, BulkEditPreviewContribution.ctxEnabled),
|
||||
menu: [{
|
||||
id: MenuId.BulkEditTitle,
|
||||
when: ContextKeyExpr.and(BulkEditPane.ctxHasCategories, BulkEditPane.ctxGroupByFile),
|
||||
group: 'navigation',
|
||||
order: 3
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.groupByType();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class ToggleGrouping extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'refactorPreview.toggleGrouping',
|
||||
title: { value: localize('groupByType', "Group Changes By Type"), original: 'Group Changes By Type' },
|
||||
category: { value: localize('cat', "Refactor Preview"), original: 'Refactor Preview' },
|
||||
icon: { id: 'codicon/list-tree' },
|
||||
toggled: BulkEditPane.ctxGroupByFile.negate(),
|
||||
precondition: ContextKeyExpr.and(BulkEditPane.ctxHasCategories, BulkEditPreviewContribution.ctxEnabled),
|
||||
menu: [{
|
||||
id: MenuId.BulkEditContext,
|
||||
order: 3
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = await getBulkEditPane(viewsService);
|
||||
if (view) {
|
||||
view.toggleGrouping();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(
|
||||
BulkEditPreviewContribution, LifecyclePhase.Ready
|
||||
);
|
||||
|
||||
const container = Registry.as<IViewContainersRegistry>(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({
|
||||
id: BulkEditPane.ID,
|
||||
name: localize('panel', "Refactor Preview"),
|
||||
hideIfEmpty: true,
|
||||
ctorDescriptor: new SyncDescriptor(
|
||||
ViewPaneContainer,
|
||||
[BulkEditPane.ID, { mergeViewWithContainerWhenSingleView: true, donotShowContainerTitleWhenMergedWithContainer: true }]
|
||||
),
|
||||
icon: Codicon.lightbulb.classNames,
|
||||
storageId: BulkEditPane.ID
|
||||
}, ViewContainerLocation.Panel);
|
||||
|
||||
Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry).registerViews([{
|
||||
id: BulkEditPane.ID,
|
||||
name: localize('panel', "Refactor Preview"),
|
||||
when: BulkEditPreviewContribution.ctxEnabled,
|
||||
ctorDescriptor: new SyncDescriptor(BulkEditPane),
|
||||
containerIcon: Codicon.lightbulb.classNames,
|
||||
}], container);
|
||||
@@ -0,0 +1,84 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .highlight.remove {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .message {
|
||||
padding: 10px 20px
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel [data-state="message"] .message,
|
||||
.monaco-workbench .bulk-edit-panel [data-state="data"] .tree
|
||||
{
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel [data-state="data"] .message,
|
||||
.monaco-workbench .bulk-edit-panel [data-state="message"] .tree
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents .edit-checkbox {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents .edit-checkbox.disabled {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents .monaco-icon-label.delete .monaco-icon-label-container {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents .details {
|
||||
margin-left: .5em;
|
||||
opacity: .7;
|
||||
font-size: 0.9em;
|
||||
white-space: pre
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.category {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.category .theme-icon,
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.textedit .theme-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.category .uri-icon,
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.textedit .uri-icon {
|
||||
background-repeat: no-repeat;
|
||||
background-image: var(--background-light);
|
||||
background-position: left center;
|
||||
background-size: contain;
|
||||
margin-right: 4px;
|
||||
height: 100%;
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
.monaco-workbench.vs-dark .bulk-edit-panel .monaco-tl-contents.category .uri-icon,
|
||||
.monaco-workbench.hc-black .bulk-edit-panel .monaco-tl-contents.category .uri-icon,
|
||||
.monaco-workbench.vs-dark .bulk-edit-panel .monaco-tl-contents.textedit .uri-icon,
|
||||
.monaco-workbench.hc-black .bulk-edit-panel .monaco-tl-contents.textedit .uri-icon
|
||||
{
|
||||
background-image: var(--background-dark);
|
||||
}
|
||||
|
||||
.monaco-workbench .bulk-edit-panel .monaco-tl-contents.textedit .monaco-highlighted-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./bulkEdit';
|
||||
import { WorkbenchAsyncDataTree, IOpenEvent } from 'vs/platform/list/browser/listService';
|
||||
import { BulkEditElement, BulkEditDelegate, TextEditElementRenderer, FileElementRenderer, BulkEditDataSource, BulkEditIdentityProvider, FileElement, TextEditElement, BulkEditAccessibilityProvider, CategoryElementRenderer, BulkEditNaviLabelProvider, CategoryElement, BulkEditSorter } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree';
|
||||
import { FuzzyScore } from 'vs/base/common/filters';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { registerThemingParticipant, IColorTheme, ICssStyleCollector, IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { diffInserted, diffRemoved } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { localize } from 'vs/nls';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { BulkEditPreviewProvider, BulkFileOperations, BulkFileOperationType } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
import { ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ViewPane } from 'vs/workbench/browser/parts/views/viewPaneContainer';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKeyService, RawContextKey, IContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet';
|
||||
import { ResourceLabels, IResourceLabelsContainer } from 'vs/workbench/browser/labels';
|
||||
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import Severity from 'vs/base/common/severity';
|
||||
import { basename } from 'vs/base/common/resources';
|
||||
import { IMenuService, MenuId } from 'vs/platform/actions/common/actions';
|
||||
import { IAction } from 'vs/base/common/actions';
|
||||
import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
|
||||
import { ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { ITextEditorOptions } from 'vs/platform/editor/common/editor';
|
||||
import type { IAsyncDataTreeViewState } from 'vs/base/browser/ui/tree/asyncDataTree';
|
||||
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
|
||||
import { IViewDescriptorService } from 'vs/workbench/common/views';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
||||
import { ResourceEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
|
||||
const enum State {
|
||||
Data = 'data',
|
||||
Message = 'message'
|
||||
}
|
||||
|
||||
export class BulkEditPane extends ViewPane {
|
||||
|
||||
static readonly ID = 'refactorPreview';
|
||||
|
||||
static readonly ctxHasCategories = new RawContextKey('refactorPreview.hasCategories', false);
|
||||
static readonly ctxGroupByFile = new RawContextKey('refactorPreview.groupByFile', true);
|
||||
static readonly ctxHasCheckedChanges = new RawContextKey('refactorPreview.hasCheckedChanges', true);
|
||||
|
||||
private static readonly _memGroupByFile = `${BulkEditPane.ID}.groupByFile`;
|
||||
|
||||
private _tree!: WorkbenchAsyncDataTree<BulkFileOperations, BulkEditElement, FuzzyScore>;
|
||||
private _treeDataSource!: BulkEditDataSource;
|
||||
private _treeViewStates = new Map<boolean, IAsyncDataTreeViewState>();
|
||||
private _message!: HTMLSpanElement;
|
||||
|
||||
private readonly _ctxHasCategories: IContextKey<boolean>;
|
||||
private readonly _ctxGroupByFile: IContextKey<boolean>;
|
||||
private readonly _ctxHasCheckedChanges: IContextKey<boolean>;
|
||||
|
||||
private readonly _disposables = new DisposableStore();
|
||||
private readonly _sessionDisposables = new DisposableStore();
|
||||
private _currentResolve?: (edit?: ResourceEdit[]) => void;
|
||||
private _currentInput?: BulkFileOperations;
|
||||
|
||||
|
||||
constructor(
|
||||
options: IViewletViewOptions,
|
||||
@IInstantiationService private readonly _instaService: IInstantiationService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@ILabelService private readonly _labelService: ILabelService,
|
||||
@ITextModelService private readonly _textModelService: ITextModelService,
|
||||
@IDialogService private readonly _dialogService: IDialogService,
|
||||
@IMenuService private readonly _menuService: IMenuService,
|
||||
@IContextMenuService private readonly _contextMenuService: IContextMenuService,
|
||||
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
) {
|
||||
super(
|
||||
{ ...options, titleMenuId: MenuId.BulkEditTitle },
|
||||
keybindingService, contextMenuService, configurationService, _contextKeyService, viewDescriptorService, _instaService, openerService, themeService, telemetryService
|
||||
);
|
||||
|
||||
this.element.classList.add('bulk-edit-panel', 'show-file-icons');
|
||||
this._ctxHasCategories = BulkEditPane.ctxHasCategories.bindTo(_contextKeyService);
|
||||
this._ctxGroupByFile = BulkEditPane.ctxGroupByFile.bindTo(_contextKeyService);
|
||||
this._ctxHasCheckedChanges = BulkEditPane.ctxHasCheckedChanges.bindTo(_contextKeyService);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._tree.dispose();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
protected renderBody(parent: HTMLElement): void {
|
||||
super.renderBody(parent);
|
||||
|
||||
const resourceLabels = this._instaService.createInstance(
|
||||
ResourceLabels,
|
||||
<IResourceLabelsContainer>{ onDidChangeVisibility: this.onDidChangeBodyVisibility }
|
||||
);
|
||||
this._disposables.add(resourceLabels);
|
||||
|
||||
// tree
|
||||
const treeContainer = document.createElement('div');
|
||||
treeContainer.className = 'tree';
|
||||
treeContainer.style.width = '100%';
|
||||
treeContainer.style.height = '100%';
|
||||
parent.appendChild(treeContainer);
|
||||
|
||||
this._treeDataSource = this._instaService.createInstance(BulkEditDataSource);
|
||||
this._treeDataSource.groupByFile = this._storageService.getBoolean(BulkEditPane._memGroupByFile, StorageScope.GLOBAL, true);
|
||||
this._ctxGroupByFile.set(this._treeDataSource.groupByFile);
|
||||
|
||||
this._tree = <WorkbenchAsyncDataTree<BulkFileOperations, BulkEditElement, FuzzyScore>>this._instaService.createInstance(
|
||||
WorkbenchAsyncDataTree, this.id, treeContainer,
|
||||
new BulkEditDelegate(),
|
||||
[new TextEditElementRenderer(), this._instaService.createInstance(FileElementRenderer, resourceLabels), new CategoryElementRenderer()],
|
||||
this._treeDataSource,
|
||||
{
|
||||
accessibilityProvider: this._instaService.createInstance(BulkEditAccessibilityProvider),
|
||||
identityProvider: new BulkEditIdentityProvider(),
|
||||
expandOnlyOnTwistieClick: true,
|
||||
multipleSelectionSupport: false,
|
||||
keyboardNavigationLabelProvider: new BulkEditNaviLabelProvider(),
|
||||
sorter: new BulkEditSorter(),
|
||||
openOnFocus: true
|
||||
}
|
||||
);
|
||||
|
||||
this._disposables.add(this._tree.onContextMenu(this._onContextMenu, this));
|
||||
this._disposables.add(this._tree.onDidOpen(e => this._openElementAsEditor(e)));
|
||||
|
||||
// message
|
||||
this._message = document.createElement('span');
|
||||
this._message.className = 'message';
|
||||
this._message.innerText = localize('empty.msg', "Invoke a code action, like rename, to see a preview of its changes here.");
|
||||
parent.appendChild(this._message);
|
||||
|
||||
//
|
||||
this._setState(State.Message);
|
||||
}
|
||||
|
||||
protected layoutBody(height: number, width: number): void {
|
||||
super.layoutBody(height, width);
|
||||
this._tree.layout(height, width);
|
||||
}
|
||||
|
||||
private _setState(state: State): void {
|
||||
this.element.dataset['state'] = state;
|
||||
}
|
||||
|
||||
async setInput(edit: ResourceEdit[], token: CancellationToken): Promise<ResourceEdit[] | undefined> {
|
||||
this._setState(State.Data);
|
||||
this._sessionDisposables.clear();
|
||||
this._treeViewStates.clear();
|
||||
|
||||
if (this._currentResolve) {
|
||||
this._currentResolve(undefined);
|
||||
this._currentResolve = undefined;
|
||||
}
|
||||
|
||||
const input = await this._instaService.invokeFunction(BulkFileOperations.create, edit);
|
||||
const provider = this._instaService.createInstance(BulkEditPreviewProvider, input);
|
||||
this._sessionDisposables.add(provider);
|
||||
this._sessionDisposables.add(input);
|
||||
|
||||
//
|
||||
const hasCategories = input.categories.length > 1;
|
||||
this._ctxHasCategories.set(hasCategories);
|
||||
this._treeDataSource.groupByFile = !hasCategories || this._treeDataSource.groupByFile;
|
||||
this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);
|
||||
|
||||
this._currentInput = input;
|
||||
|
||||
return new Promise<ResourceEdit[] | undefined>(async resolve => {
|
||||
|
||||
token.onCancellationRequested(() => resolve(undefined));
|
||||
|
||||
this._currentResolve = resolve;
|
||||
this._setTreeInput(input);
|
||||
|
||||
// refresh when check state changes
|
||||
this._sessionDisposables.add(input.checked.onDidChange(() => {
|
||||
this._tree.updateChildren();
|
||||
this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
hasInput(): boolean {
|
||||
return Boolean(this._currentInput);
|
||||
}
|
||||
|
||||
private async _setTreeInput(input: BulkFileOperations) {
|
||||
|
||||
const viewState = this._treeViewStates.get(this._treeDataSource.groupByFile);
|
||||
await this._tree.setInput(input, viewState);
|
||||
this._tree.domFocus();
|
||||
|
||||
if (viewState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// async expandAll (max=10) is the default when no view state is given
|
||||
const expand = [...this._tree.getNode(input).children].slice(0, 10);
|
||||
while (expand.length > 0) {
|
||||
const { element } = expand.shift()!;
|
||||
if (element instanceof FileElement) {
|
||||
await this._tree.expand(element, true);
|
||||
}
|
||||
if (element instanceof CategoryElement) {
|
||||
await this._tree.expand(element, true);
|
||||
expand.push(...this._tree.getNode(element).children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
|
||||
const conflicts = this._currentInput?.conflicts.list();
|
||||
|
||||
if (!conflicts || conflicts.length === 0) {
|
||||
this._done(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let message: string;
|
||||
if (conflicts.length === 1) {
|
||||
message = localize('conflict.1', "Cannot apply refactoring because '{0}' has changed in the meantime.", this._labelService.getUriLabel(conflicts[0], { relative: true }));
|
||||
} else {
|
||||
message = localize('conflict.N', "Cannot apply refactoring because {0} other files have changed in the meantime.", conflicts.length);
|
||||
}
|
||||
|
||||
this._dialogService.show(Severity.Warning, message, []).finally(() => this._done(false));
|
||||
}
|
||||
|
||||
discard() {
|
||||
this._done(false);
|
||||
}
|
||||
|
||||
private _done(accept: boolean): void {
|
||||
if (this._currentResolve) {
|
||||
this._currentResolve(accept ? this._currentInput?.getWorkspaceEdit() : undefined);
|
||||
}
|
||||
this._currentInput = undefined;
|
||||
this._setState(State.Message);
|
||||
this._sessionDisposables.clear();
|
||||
}
|
||||
|
||||
toggleChecked() {
|
||||
const [first] = this._tree.getFocus();
|
||||
if ((first instanceof FileElement || first instanceof TextEditElement) && !first.isDisabled()) {
|
||||
first.setChecked(!first.isChecked());
|
||||
}
|
||||
}
|
||||
|
||||
groupByFile(): void {
|
||||
if (!this._treeDataSource.groupByFile) {
|
||||
this.toggleGrouping();
|
||||
}
|
||||
}
|
||||
|
||||
groupByType(): void {
|
||||
if (this._treeDataSource.groupByFile) {
|
||||
this.toggleGrouping();
|
||||
}
|
||||
}
|
||||
|
||||
toggleGrouping() {
|
||||
const input = this._tree.getInput();
|
||||
if (input) {
|
||||
|
||||
// (1) capture view state
|
||||
let oldViewState = this._tree.getViewState();
|
||||
this._treeViewStates.set(this._treeDataSource.groupByFile, oldViewState);
|
||||
|
||||
// (2) toggle and update
|
||||
this._treeDataSource.groupByFile = !this._treeDataSource.groupByFile;
|
||||
this._setTreeInput(input);
|
||||
|
||||
// (3) remember preference
|
||||
this._storageService.store(BulkEditPane._memGroupByFile, this._treeDataSource.groupByFile, StorageScope.GLOBAL);
|
||||
this._ctxGroupByFile.set(this._treeDataSource.groupByFile);
|
||||
}
|
||||
}
|
||||
|
||||
private async _openElementAsEditor(e: IOpenEvent<BulkEditElement | null>): Promise<void> {
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P]
|
||||
};
|
||||
|
||||
let options: Mutable<ITextEditorOptions> = { ...e.editorOptions };
|
||||
let fileElement: FileElement;
|
||||
if (e.element instanceof TextEditElement) {
|
||||
fileElement = e.element.parent;
|
||||
options.selection = e.element.edit.textEdit.textEdit.range;
|
||||
|
||||
} else if (e.element instanceof FileElement) {
|
||||
fileElement = e.element;
|
||||
options.selection = e.element.edit.textEdits[0]?.textEdit.textEdit.range;
|
||||
|
||||
} else {
|
||||
// invalid event
|
||||
return;
|
||||
}
|
||||
|
||||
const previewUri = BulkEditPreviewProvider.asPreviewUri(fileElement.edit.uri);
|
||||
|
||||
if (fileElement.edit.type & BulkFileOperationType.Delete) {
|
||||
// delete -> show single editor
|
||||
this._editorService.openEditor({
|
||||
label: localize('edt.title.del', "{0} (delete, refactor preview)", basename(fileElement.edit.uri)),
|
||||
resource: previewUri,
|
||||
options
|
||||
});
|
||||
|
||||
} else {
|
||||
// rename, create, edits -> show diff editr
|
||||
let leftResource: URI | undefined;
|
||||
try {
|
||||
(await this._textModelService.createModelReference(fileElement.edit.uri)).dispose();
|
||||
leftResource = fileElement.edit.uri;
|
||||
} catch {
|
||||
leftResource = BulkEditPreviewProvider.emptyPreview;
|
||||
}
|
||||
|
||||
let typeLabel: string | undefined;
|
||||
if (fileElement.edit.type & BulkFileOperationType.Rename) {
|
||||
typeLabel = localize('rename', "rename");
|
||||
} else if (fileElement.edit.type & BulkFileOperationType.Create) {
|
||||
typeLabel = localize('create', "create");
|
||||
}
|
||||
|
||||
let label: string;
|
||||
if (typeLabel) {
|
||||
label = localize('edt.title.2', "{0} ({1}, refactor preview)", basename(fileElement.edit.uri), typeLabel);
|
||||
} else {
|
||||
label = localize('edt.title.1', "{0} (refactor preview)", basename(fileElement.edit.uri));
|
||||
}
|
||||
|
||||
this._editorService.openEditor({
|
||||
leftResource,
|
||||
rightResource: previewUri,
|
||||
label,
|
||||
options
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onContextMenu(e: ITreeContextMenuEvent<any>): void {
|
||||
const menu = this._menuService.createMenu(MenuId.BulkEditContext, this._contextKeyService);
|
||||
const actions: IAction[] = [];
|
||||
const disposable = createAndFillInContextMenuActions(menu, undefined, actions);
|
||||
|
||||
this._contextMenuService.showContextMenu({
|
||||
getActions: () => actions,
|
||||
getAnchor: () => e.anchor,
|
||||
onHide: () => {
|
||||
disposable.dispose();
|
||||
menu.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
|
||||
|
||||
const diffInsertedColor = theme.getColor(diffInserted);
|
||||
if (diffInsertedColor) {
|
||||
collector.addRule(`.monaco-workbench .bulk-edit-panel .highlight.insert { background-color: ${diffInsertedColor}; }`);
|
||||
}
|
||||
const diffRemovedColor = theme.getColor(diffRemoved);
|
||||
if (diffRemovedColor) {
|
||||
collector.addRule(`.monaco-workbench .bulk-edit-panel .highlight.remove { background-color: ${diffRemovedColor}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IModeService } from 'vs/editor/common/services/modeService';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { createTextBufferFactoryFromSnapshot } from 'vs/editor/common/model/textModel';
|
||||
import { WorkspaceEditMetadata } from 'vs/editor/common/modes';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { mergeSort, coalesceInPlace } from 'vs/base/common/arrays';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { ServicesAccessor, IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { IIdentifiedSingleEditOperation } from 'vs/editor/common/model';
|
||||
import { ConflictDetector } from 'vs/workbench/contrib/bulkEdit/browser/conflicts';
|
||||
import { ResourceMap } from 'vs/base/common/map';
|
||||
import { localize } from 'vs/nls';
|
||||
import { extUri } from 'vs/base/common/resources';
|
||||
import { ResourceEdit, ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
|
||||
export class CheckedStates<T extends object> {
|
||||
|
||||
private readonly _states = new WeakMap<T, boolean>();
|
||||
private _checkedCount: number = 0;
|
||||
|
||||
private readonly _onDidChange = new Emitter<T>();
|
||||
readonly onDidChange: Event<T> = this._onDidChange.event;
|
||||
|
||||
dispose(): void {
|
||||
this._onDidChange.dispose();
|
||||
}
|
||||
|
||||
get checkedCount() {
|
||||
return this._checkedCount;
|
||||
}
|
||||
|
||||
isChecked(obj: T): boolean {
|
||||
return this._states.get(obj) ?? false;
|
||||
}
|
||||
|
||||
updateChecked(obj: T, value: boolean): void {
|
||||
const valueNow = this._states.get(obj);
|
||||
if (valueNow === value) {
|
||||
return;
|
||||
}
|
||||
if (valueNow === undefined) {
|
||||
if (value) {
|
||||
this._checkedCount += 1;
|
||||
}
|
||||
} else {
|
||||
if (value) {
|
||||
this._checkedCount += 1;
|
||||
} else {
|
||||
this._checkedCount -= 1;
|
||||
}
|
||||
}
|
||||
this._states.set(obj, value);
|
||||
this._onDidChange.fire(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkTextEdit {
|
||||
|
||||
constructor(
|
||||
readonly parent: BulkFileOperation,
|
||||
readonly textEdit: ResourceTextEdit
|
||||
) { }
|
||||
}
|
||||
|
||||
export const enum BulkFileOperationType {
|
||||
TextEdit = 1,
|
||||
Create = 2,
|
||||
Delete = 4,
|
||||
Rename = 8,
|
||||
}
|
||||
|
||||
export class BulkFileOperation {
|
||||
|
||||
type: BulkFileOperationType = 0;
|
||||
textEdits: BulkTextEdit[] = [];
|
||||
originalEdits = new Map<number, ResourceTextEdit | ResourceFileEdit>();
|
||||
newUri?: URI;
|
||||
|
||||
constructor(
|
||||
readonly uri: URI,
|
||||
readonly parent: BulkFileOperations
|
||||
) { }
|
||||
|
||||
addEdit(index: number, type: BulkFileOperationType, edit: ResourceTextEdit | ResourceFileEdit) {
|
||||
this.type |= type;
|
||||
this.originalEdits.set(index, edit);
|
||||
if (edit instanceof ResourceTextEdit) {
|
||||
this.textEdits.push(new BulkTextEdit(this, edit));
|
||||
|
||||
} else if (type === BulkFileOperationType.Rename) {
|
||||
this.newUri = edit.newResource;
|
||||
}
|
||||
}
|
||||
|
||||
needsConfirmation(): boolean {
|
||||
for (let [, edit] of this.originalEdits) {
|
||||
if (!this.parent.checked.isChecked(edit)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkCategory {
|
||||
|
||||
private static readonly _defaultMetadata = Object.freeze({
|
||||
label: localize('default', "Other"),
|
||||
icon: { id: 'codicon/symbol-file' },
|
||||
needsConfirmation: false
|
||||
});
|
||||
|
||||
static keyOf(metadata?: WorkspaceEditMetadata) {
|
||||
return metadata?.label || '<default>';
|
||||
}
|
||||
|
||||
readonly operationByResource = new Map<string, BulkFileOperation>();
|
||||
|
||||
constructor(readonly metadata: WorkspaceEditMetadata = BulkCategory._defaultMetadata) { }
|
||||
|
||||
get fileOperations(): IterableIterator<BulkFileOperation> {
|
||||
return this.operationByResource.values();
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkFileOperations {
|
||||
|
||||
static async create(accessor: ServicesAccessor, bulkEdit: ResourceEdit[]): Promise<BulkFileOperations> {
|
||||
const result = accessor.get(IInstantiationService).createInstance(BulkFileOperations, bulkEdit);
|
||||
return await result._init();
|
||||
}
|
||||
|
||||
readonly checked = new CheckedStates<ResourceEdit>();
|
||||
|
||||
readonly fileOperations: BulkFileOperation[] = [];
|
||||
readonly categories: BulkCategory[] = [];
|
||||
readonly conflicts: ConflictDetector;
|
||||
|
||||
constructor(
|
||||
private readonly _bulkEdit: ResourceEdit[],
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IInstantiationService instaService: IInstantiationService,
|
||||
) {
|
||||
this.conflicts = instaService.createInstance(ConflictDetector, _bulkEdit);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.checked.dispose();
|
||||
this.conflicts.dispose();
|
||||
}
|
||||
|
||||
async _init() {
|
||||
const operationByResource = new Map<string, BulkFileOperation>();
|
||||
const operationByCategory = new Map<string, BulkCategory>();
|
||||
|
||||
const newToOldUri = new ResourceMap<URI>();
|
||||
|
||||
for (let idx = 0; idx < this._bulkEdit.length; idx++) {
|
||||
const edit = this._bulkEdit[idx];
|
||||
|
||||
let uri: URI;
|
||||
let type: BulkFileOperationType;
|
||||
|
||||
// store inital checked state
|
||||
this.checked.updateChecked(edit, !edit.metadata?.needsConfirmation);
|
||||
|
||||
if (edit instanceof ResourceTextEdit) {
|
||||
type = BulkFileOperationType.TextEdit;
|
||||
uri = edit.resource;
|
||||
|
||||
} else if (edit instanceof ResourceFileEdit) {
|
||||
if (edit.newResource && edit.oldResource) {
|
||||
type = BulkFileOperationType.Rename;
|
||||
uri = edit.oldResource;
|
||||
if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {
|
||||
// noop -> "soft" rename to something that already exists
|
||||
continue;
|
||||
}
|
||||
// map newResource onto oldResource so that text-edit appear for
|
||||
// the same file element
|
||||
newToOldUri.set(edit.newResource, uri);
|
||||
|
||||
} else if (edit.oldResource) {
|
||||
type = BulkFileOperationType.Delete;
|
||||
uri = edit.oldResource;
|
||||
if (edit.options?.ignoreIfNotExists && !await this._fileService.exists(uri)) {
|
||||
// noop -> "soft" delete something that doesn't exist
|
||||
continue;
|
||||
}
|
||||
|
||||
} else if (edit.newResource) {
|
||||
type = BulkFileOperationType.Create;
|
||||
uri = edit.newResource;
|
||||
if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) {
|
||||
// noop -> "soft" create something that already exists
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
// invalid edit -> skip
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
// unsupported edit
|
||||
continue;
|
||||
}
|
||||
|
||||
const insert = (uri: URI, map: Map<string, BulkFileOperation>) => {
|
||||
let key = extUri.getComparisonKey(uri, true);
|
||||
let operation = map.get(key);
|
||||
|
||||
// rename
|
||||
if (!operation && newToOldUri.has(uri)) {
|
||||
uri = newToOldUri.get(uri)!;
|
||||
key = extUri.getComparisonKey(uri, true);
|
||||
operation = map.get(key);
|
||||
}
|
||||
|
||||
if (!operation) {
|
||||
operation = new BulkFileOperation(uri, this);
|
||||
map.set(key, operation);
|
||||
}
|
||||
operation.addEdit(idx, type, edit);
|
||||
};
|
||||
|
||||
insert(uri, operationByResource);
|
||||
|
||||
// insert into "this" category
|
||||
let key = BulkCategory.keyOf(edit.metadata);
|
||||
let category = operationByCategory.get(key);
|
||||
if (!category) {
|
||||
category = new BulkCategory(edit.metadata);
|
||||
operationByCategory.set(key, category);
|
||||
}
|
||||
insert(uri, category.operationByResource);
|
||||
}
|
||||
|
||||
operationByResource.forEach(value => this.fileOperations.push(value));
|
||||
operationByCategory.forEach(value => this.categories.push(value));
|
||||
|
||||
// "correct" invalid parent-check child states that is
|
||||
// unchecked file edits (rename, create, delete) uncheck
|
||||
// all edits for a file, e.g no text change without rename
|
||||
for (let file of this.fileOperations) {
|
||||
if (file.type !== BulkFileOperationType.TextEdit) {
|
||||
let checked = true;
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
if (edit instanceof ResourceFileEdit) {
|
||||
checked = checked && this.checked.isChecked(edit);
|
||||
}
|
||||
}
|
||||
if (!checked) {
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
this.checked.updateChecked(edit, checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort (once) categories atop which have unconfirmed edits
|
||||
this.categories.sort((a, b) => {
|
||||
if (a.metadata.needsConfirmation === b.metadata.needsConfirmation) {
|
||||
return a.metadata.label.localeCompare(b.metadata.label);
|
||||
} else if (a.metadata.needsConfirmation) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getWorkspaceEdit(): ResourceEdit[] {
|
||||
const result: ResourceEdit[] = [];
|
||||
let allAccepted = true;
|
||||
|
||||
for (let i = 0; i < this._bulkEdit.length; i++) {
|
||||
const edit = this._bulkEdit[i];
|
||||
if (this.checked.isChecked(edit)) {
|
||||
result[i] = edit;
|
||||
continue;
|
||||
}
|
||||
allAccepted = false;
|
||||
}
|
||||
|
||||
if (allAccepted) {
|
||||
return this._bulkEdit;
|
||||
}
|
||||
|
||||
// not all edits have been accepted
|
||||
coalesceInPlace(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
getFileEdits(uri: URI): IIdentifiedSingleEditOperation[] {
|
||||
|
||||
for (let file of this.fileOperations) {
|
||||
if (file.uri.toString() === uri.toString()) {
|
||||
|
||||
const result: IIdentifiedSingleEditOperation[] = [];
|
||||
let ignoreAll = false;
|
||||
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
if (edit instanceof ResourceTextEdit) {
|
||||
if (this.checked.isChecked(edit)) {
|
||||
result.push(EditOperation.replaceMove(Range.lift(edit.textEdit.range), edit.textEdit.text));
|
||||
}
|
||||
|
||||
} else if (!this.checked.isChecked(edit)) {
|
||||
// UNCHECKED WorkspaceFileEdit disables all text edits
|
||||
ignoreAll = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (ignoreAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mergeSort(
|
||||
result,
|
||||
(a, b) => Range.compareRangesUsingStarts(a.range, b.range)
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
getUriOfEdit(edit: ResourceEdit): URI {
|
||||
for (let file of this.fileOperations) {
|
||||
for (const value of file.originalEdits.values()) {
|
||||
if (value === edit) {
|
||||
return file.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('invalid edit');
|
||||
}
|
||||
}
|
||||
|
||||
export class BulkEditPreviewProvider implements ITextModelContentProvider {
|
||||
|
||||
static readonly Schema = 'vscode-bulkeditpreview';
|
||||
|
||||
static emptyPreview = URI.from({ scheme: BulkEditPreviewProvider.Schema, fragment: 'empty' });
|
||||
|
||||
static asPreviewUri(uri: URI): URI {
|
||||
return URI.from({ scheme: BulkEditPreviewProvider.Schema, path: uri.path, query: uri.toString() });
|
||||
}
|
||||
|
||||
static fromPreviewUri(uri: URI): URI {
|
||||
return URI.parse(uri.query);
|
||||
}
|
||||
|
||||
private readonly _disposables = new DisposableStore();
|
||||
private readonly _ready: Promise<any>;
|
||||
private readonly _modelPreviewEdits = new Map<string, IIdentifiedSingleEditOperation[]>();
|
||||
|
||||
constructor(
|
||||
private readonly _operations: BulkFileOperations,
|
||||
@IModeService private readonly _modeService: IModeService,
|
||||
@IModelService private readonly _modelService: IModelService,
|
||||
@ITextModelService private readonly _textModelResolverService: ITextModelService
|
||||
) {
|
||||
this._disposables.add(this._textModelResolverService.registerTextModelContentProvider(BulkEditPreviewProvider.Schema, this));
|
||||
this._ready = this._init();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
private async _init() {
|
||||
for (let operation of this._operations.fileOperations) {
|
||||
await this._applyTextEditsToPreviewModel(operation.uri);
|
||||
}
|
||||
this._disposables.add(this._operations.checked.onDidChange(e => {
|
||||
const uri = this._operations.getUriOfEdit(e);
|
||||
this._applyTextEditsToPreviewModel(uri);
|
||||
}));
|
||||
}
|
||||
|
||||
private async _applyTextEditsToPreviewModel(uri: URI) {
|
||||
const model = await this._getOrCreatePreviewModel(uri);
|
||||
|
||||
// undo edits that have been done before
|
||||
let undoEdits = this._modelPreviewEdits.get(model.id);
|
||||
if (undoEdits) {
|
||||
model.applyEdits(undoEdits);
|
||||
}
|
||||
// apply new edits and keep (future) undo edits
|
||||
const newEdits = this._operations.getFileEdits(uri);
|
||||
const newUndoEdits = model.applyEdits(newEdits, true);
|
||||
this._modelPreviewEdits.set(model.id, newUndoEdits);
|
||||
}
|
||||
|
||||
private async _getOrCreatePreviewModel(uri: URI) {
|
||||
const previewUri = BulkEditPreviewProvider.asPreviewUri(uri);
|
||||
let model = this._modelService.getModel(previewUri);
|
||||
if (!model) {
|
||||
try {
|
||||
// try: copy existing
|
||||
const ref = await this._textModelResolverService.createModelReference(uri);
|
||||
const sourceModel = ref.object.textEditorModel;
|
||||
model = this._modelService.createModel(
|
||||
createTextBufferFactoryFromSnapshot(sourceModel.createSnapshot()),
|
||||
this._modeService.create(sourceModel.getLanguageIdentifier().language),
|
||||
previewUri
|
||||
);
|
||||
ref.dispose();
|
||||
|
||||
} catch {
|
||||
// create NEW model
|
||||
model = this._modelService.createModel(
|
||||
'',
|
||||
this._modeService.createByFilepathOrFirstLine(previewUri),
|
||||
previewUri
|
||||
);
|
||||
}
|
||||
// this is a little weird but otherwise editors and other cusomers
|
||||
// will dispose my models before they should be disposed...
|
||||
// And all of this is off the eventloop to prevent endless recursion
|
||||
new Promise(async () => this._disposables.add(await this._textModelResolverService.createModelReference(model!.uri)));
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async provideTextContent(previewUri: URI) {
|
||||
if (previewUri.toString() === BulkEditPreviewProvider.emptyPreview.toString()) {
|
||||
return this._modelService.createModel('', null, previewUri);
|
||||
}
|
||||
await this._ready;
|
||||
return this._modelService.getModel(previewUri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAsyncDataSource, ITreeRenderer, ITreeNode, ITreeSorter } from 'vs/base/browser/ui/tree/tree';
|
||||
import { ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
|
||||
import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels';
|
||||
import { HighlightedLabel, IHighlight } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel';
|
||||
import { IIdentityProvider, IListVirtualDelegate, IKeyboardNavigationLabelProvider } from 'vs/base/browser/ui/list/list';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { TextModel } from 'vs/editor/common/model/textModel';
|
||||
import { BulkFileOperations, BulkFileOperation, BulkFileOperationType, BulkTextEdit, BulkCategory } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview';
|
||||
import { FileKind } from 'vs/platform/files/common/files';
|
||||
import { localize } from 'vs/nls';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
import type { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget';
|
||||
import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel';
|
||||
import { basename } from 'vs/base/common/resources';
|
||||
import { ThemeIcon } from 'vs/platform/theme/common/themeService';
|
||||
import { compare } from 'vs/base/common/strings';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
import { Iterable } from 'vs/base/common/iterator';
|
||||
import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
|
||||
// --- VIEW MODEL
|
||||
|
||||
export interface ICheckable {
|
||||
isChecked(): boolean;
|
||||
setChecked(value: boolean): void;
|
||||
}
|
||||
|
||||
export class CategoryElement {
|
||||
|
||||
constructor(
|
||||
readonly parent: BulkFileOperations,
|
||||
readonly category: BulkCategory
|
||||
) { }
|
||||
}
|
||||
|
||||
export class FileElement implements ICheckable {
|
||||
|
||||
constructor(
|
||||
readonly parent: CategoryElement | BulkFileOperations,
|
||||
readonly edit: BulkFileOperation
|
||||
) { }
|
||||
|
||||
isChecked(): boolean {
|
||||
let model = this.parent instanceof CategoryElement ? this.parent.parent : this.parent;
|
||||
|
||||
let checked = true;
|
||||
|
||||
// only text edit children -> reflect children state
|
||||
if (this.edit.type === BulkFileOperationType.TextEdit) {
|
||||
checked = !this.edit.textEdits.every(edit => !model.checked.isChecked(edit.textEdit));
|
||||
}
|
||||
|
||||
// multiple file edits -> reflect single state
|
||||
for (let edit of this.edit.originalEdits.values()) {
|
||||
if (edit instanceof ResourceFileEdit) {
|
||||
checked = checked && model.checked.isChecked(edit);
|
||||
}
|
||||
}
|
||||
|
||||
// multiple categories and text change -> read all elements
|
||||
if (this.parent instanceof CategoryElement && this.edit.type === BulkFileOperationType.TextEdit) {
|
||||
for (let category of model.categories) {
|
||||
for (let file of category.fileOperations) {
|
||||
if (file.uri.toString() === this.edit.uri.toString()) {
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
if (edit instanceof ResourceFileEdit) {
|
||||
checked = checked && model.checked.isChecked(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checked;
|
||||
}
|
||||
|
||||
setChecked(value: boolean): void {
|
||||
let model = this.parent instanceof CategoryElement ? this.parent.parent : this.parent;
|
||||
for (const edit of this.edit.originalEdits.values()) {
|
||||
model.checked.updateChecked(edit, value);
|
||||
}
|
||||
|
||||
// multiple categories and file change -> update all elements
|
||||
if (this.parent instanceof CategoryElement && this.edit.type !== BulkFileOperationType.TextEdit) {
|
||||
for (let category of model.categories) {
|
||||
for (let file of category.fileOperations) {
|
||||
if (file.uri.toString() === this.edit.uri.toString()) {
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
model.checked.updateChecked(edit, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
if (this.parent instanceof CategoryElement && this.edit.type === BulkFileOperationType.TextEdit) {
|
||||
let model = this.parent.parent;
|
||||
let checked = true;
|
||||
for (let category of model.categories) {
|
||||
for (let file of category.fileOperations) {
|
||||
if (file.uri.toString() === this.edit.uri.toString()) {
|
||||
for (const edit of file.originalEdits.values()) {
|
||||
if (edit instanceof ResourceFileEdit) {
|
||||
checked = checked && model.checked.isChecked(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return !checked;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class TextEditElement implements ICheckable {
|
||||
|
||||
constructor(
|
||||
readonly parent: FileElement,
|
||||
readonly idx: number,
|
||||
readonly edit: BulkTextEdit,
|
||||
readonly prefix: string, readonly selecting: string, readonly inserting: string, readonly suffix: string
|
||||
) { }
|
||||
|
||||
isChecked(): boolean {
|
||||
let model = this.parent.parent;
|
||||
if (model instanceof CategoryElement) {
|
||||
model = model.parent;
|
||||
}
|
||||
return model.checked.isChecked(this.edit.textEdit);
|
||||
}
|
||||
|
||||
setChecked(value: boolean): void {
|
||||
let model = this.parent.parent;
|
||||
if (model instanceof CategoryElement) {
|
||||
model = model.parent;
|
||||
}
|
||||
|
||||
// check/uncheck this element
|
||||
model.checked.updateChecked(this.edit.textEdit, value);
|
||||
|
||||
// make sure parent is checked when this element is checked...
|
||||
if (value) {
|
||||
for (const edit of this.parent.edit.originalEdits.values()) {
|
||||
if (edit instanceof ResourceFileEdit) {
|
||||
(<BulkFileOperations>model).checked.updateChecked(edit, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
return this.parent.isDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
export type BulkEditElement = CategoryElement | FileElement | TextEditElement;
|
||||
|
||||
// --- DATA SOURCE
|
||||
|
||||
export class BulkEditDataSource implements IAsyncDataSource<BulkFileOperations, BulkEditElement> {
|
||||
|
||||
public groupByFile: boolean = true;
|
||||
|
||||
constructor(
|
||||
@ITextModelService private readonly _textModelService: ITextModelService,
|
||||
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
|
||||
) { }
|
||||
|
||||
hasChildren(element: BulkFileOperations | BulkEditElement): boolean {
|
||||
if (element instanceof FileElement) {
|
||||
return element.edit.textEdits.length > 0;
|
||||
}
|
||||
if (element instanceof TextEditElement) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getChildren(element: BulkFileOperations | BulkEditElement): Promise<BulkEditElement[]> {
|
||||
|
||||
// root -> file/text edits
|
||||
if (element instanceof BulkFileOperations) {
|
||||
return this.groupByFile
|
||||
? element.fileOperations.map(op => new FileElement(element, op))
|
||||
: element.categories.map(cat => new CategoryElement(element, cat));
|
||||
}
|
||||
|
||||
// category
|
||||
if (element instanceof CategoryElement) {
|
||||
return [...Iterable.map(element.category.fileOperations, op => new FileElement(element, op))];
|
||||
}
|
||||
|
||||
// file: text edit
|
||||
if (element instanceof FileElement && element.edit.textEdits.length > 0) {
|
||||
// const previewUri = BulkEditPreviewProvider.asPreviewUri(element.edit.resource);
|
||||
let textModel: ITextModel;
|
||||
let textModelDisposable: IDisposable;
|
||||
try {
|
||||
const ref = await this._textModelService.createModelReference(element.edit.uri);
|
||||
textModel = ref.object.textEditorModel;
|
||||
textModelDisposable = ref;
|
||||
} catch {
|
||||
textModel = new TextModel('', TextModel.DEFAULT_CREATION_OPTIONS, null, null, this._undoRedoService);
|
||||
textModelDisposable = textModel;
|
||||
}
|
||||
|
||||
const result = element.edit.textEdits.map((edit, idx) => {
|
||||
const range = Range.lift(edit.textEdit.textEdit.range);
|
||||
|
||||
//prefix-math
|
||||
let startTokens = textModel.getLineTokens(range.startLineNumber);
|
||||
let prefixLen = 23; // default value for the no tokens/grammar case
|
||||
for (let idx = startTokens.findTokenIndexAtOffset(range.startColumn) - 1; prefixLen < 50 && idx >= 0; idx--) {
|
||||
prefixLen = range.startColumn - startTokens.getStartOffset(idx);
|
||||
}
|
||||
|
||||
//suffix-math
|
||||
let endTokens = textModel.getLineTokens(range.endLineNumber);
|
||||
let suffixLen = 0;
|
||||
for (let idx = endTokens.findTokenIndexAtOffset(range.endColumn); suffixLen < 50 && idx < endTokens.getCount(); idx++) {
|
||||
suffixLen += endTokens.getEndOffset(idx) - endTokens.getStartOffset(idx);
|
||||
}
|
||||
|
||||
return new TextEditElement(
|
||||
element,
|
||||
idx,
|
||||
edit,
|
||||
textModel.getValueInRange(new Range(range.startLineNumber, range.startColumn - prefixLen, range.startLineNumber, range.startColumn)),
|
||||
textModel.getValueInRange(range),
|
||||
edit.textEdit.textEdit.text,
|
||||
textModel.getValueInRange(new Range(range.endLineNumber, range.endColumn, range.endLineNumber, range.endColumn + suffixLen))
|
||||
);
|
||||
});
|
||||
|
||||
textModelDisposable.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class BulkEditSorter implements ITreeSorter<BulkEditElement> {
|
||||
|
||||
compare(a: BulkEditElement, b: BulkEditElement): number {
|
||||
if (a instanceof FileElement && b instanceof FileElement) {
|
||||
return compare(a.edit.uri.toString(), b.edit.uri.toString());
|
||||
}
|
||||
|
||||
if (a instanceof TextEditElement && b instanceof TextEditElement) {
|
||||
return Range.compareRangesUsingStarts(a.edit.textEdit.textEdit.range, b.edit.textEdit.textEdit.range);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACCESSI
|
||||
|
||||
export class BulkEditAccessibilityProvider implements IListAccessibilityProvider<BulkEditElement> {
|
||||
|
||||
constructor(@ILabelService private readonly _labelService: ILabelService) { }
|
||||
|
||||
getWidgetAriaLabel(): string {
|
||||
return localize('bulkEdit', "Bulk Edit");
|
||||
}
|
||||
|
||||
getRole(_element: BulkEditElement): string {
|
||||
return 'checkbox';
|
||||
}
|
||||
|
||||
getAriaLabel(element: BulkEditElement): string | null {
|
||||
if (element instanceof FileElement) {
|
||||
if (element.edit.textEdits.length > 0) {
|
||||
if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {
|
||||
return localize(
|
||||
'aria.renameAndEdit', "Renaming {0} to {1}, also making text edits",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })
|
||||
);
|
||||
|
||||
} else if (element.edit.type & BulkFileOperationType.Create) {
|
||||
return localize(
|
||||
'aria.createAndEdit', "Creating {0}, also making text edits",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true })
|
||||
);
|
||||
|
||||
} else if (element.edit.type & BulkFileOperationType.Delete) {
|
||||
return localize(
|
||||
'aria.deleteAndEdit', "Deleting {0}, also making text edits",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true }),
|
||||
);
|
||||
} else {
|
||||
return localize(
|
||||
'aria.editOnly', "{0}, making text edits",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true }),
|
||||
);
|
||||
}
|
||||
|
||||
} else {
|
||||
if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {
|
||||
return localize(
|
||||
'aria.rename', "Renaming {0} to {1}",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })
|
||||
);
|
||||
|
||||
} else if (element.edit.type & BulkFileOperationType.Create) {
|
||||
return localize(
|
||||
'aria.create', "Creating {0}",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true })
|
||||
);
|
||||
|
||||
} else if (element.edit.type & BulkFileOperationType.Delete) {
|
||||
return localize(
|
||||
'aria.delete', "Deleting {0}",
|
||||
this._labelService.getUriLabel(element.edit.uri, { relative: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element instanceof TextEditElement) {
|
||||
if (element.selecting.length > 0 && element.inserting.length > 0) {
|
||||
// edit: replace
|
||||
return localize('aria.replace', "line {0}, replacing {1} with {2}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting, element.inserting);
|
||||
} else if (element.selecting.length > 0 && element.inserting.length === 0) {
|
||||
// edit: delete
|
||||
return localize('aria.del', "line {0}, removing {1}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting);
|
||||
} else if (element.selecting.length === 0 && element.inserting.length > 0) {
|
||||
// edit: insert
|
||||
return localize('aria.insert', "line {0}, inserting {1}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- IDENT
|
||||
|
||||
export class BulkEditIdentityProvider implements IIdentityProvider<BulkEditElement> {
|
||||
|
||||
getId(element: BulkEditElement): { toString(): string; } {
|
||||
if (element instanceof FileElement) {
|
||||
return element.edit.uri + (element.parent instanceof CategoryElement ? JSON.stringify(element.parent.category.metadata) : '');
|
||||
} else if (element instanceof TextEditElement) {
|
||||
return element.parent.edit.uri.toString() + element.idx;
|
||||
} else {
|
||||
return JSON.stringify(element.category.metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- RENDERER
|
||||
|
||||
class CategoryElementTemplate {
|
||||
|
||||
readonly icon: HTMLDivElement;
|
||||
readonly label: IconLabel;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
container.classList.add('category');
|
||||
this.icon = document.createElement('div');
|
||||
container.appendChild(this.icon);
|
||||
this.label = new IconLabel(container);
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryElementRenderer implements ITreeRenderer<CategoryElement, FuzzyScore, CategoryElementTemplate> {
|
||||
|
||||
static readonly id: string = 'CategoryElementRenderer';
|
||||
|
||||
readonly templateId: string = CategoryElementRenderer.id;
|
||||
|
||||
renderTemplate(container: HTMLElement): CategoryElementTemplate {
|
||||
return new CategoryElementTemplate(container);
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<CategoryElement, FuzzyScore>, _index: number, template: CategoryElementTemplate): void {
|
||||
|
||||
template.icon.style.setProperty('--background-dark', null);
|
||||
template.icon.style.setProperty('--background-light', null);
|
||||
|
||||
const { metadata } = node.element.category;
|
||||
if (ThemeIcon.isThemeIcon(metadata.iconPath)) {
|
||||
// css
|
||||
const className = ThemeIcon.asClassName(metadata.iconPath);
|
||||
template.icon.className = className ? `theme-icon ${className}` : '';
|
||||
|
||||
} else if (URI.isUri(metadata.iconPath)) {
|
||||
// background-image
|
||||
template.icon.className = 'uri-icon';
|
||||
template.icon.style.setProperty('--background-dark', `url("${metadata.iconPath.toString(true)}")`);
|
||||
template.icon.style.setProperty('--background-light', `url("${metadata.iconPath.toString(true)}")`);
|
||||
|
||||
} else if (metadata.iconPath) {
|
||||
// background-image
|
||||
template.icon.className = 'uri-icon';
|
||||
template.icon.style.setProperty('--background-dark', `url("${metadata.iconPath.dark.toString(true)}")`);
|
||||
template.icon.style.setProperty('--background-light', `url("${metadata.iconPath.light.toString(true)}")`);
|
||||
}
|
||||
|
||||
template.label.setLabel(metadata.label, metadata.description, {
|
||||
descriptionMatches: createMatches(node.filterData),
|
||||
});
|
||||
}
|
||||
|
||||
disposeTemplate(template: CategoryElementTemplate): void {
|
||||
template.label.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class FileElementTemplate {
|
||||
|
||||
private readonly _disposables = new DisposableStore();
|
||||
private readonly _localDisposables = new DisposableStore();
|
||||
|
||||
private readonly _checkbox: HTMLInputElement;
|
||||
private readonly _label: IResourceLabel;
|
||||
private readonly _details: HTMLSpanElement;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
resourceLabels: ResourceLabels,
|
||||
@ILabelService private readonly _labelService: ILabelService,
|
||||
) {
|
||||
|
||||
this._checkbox = document.createElement('input');
|
||||
this._checkbox.className = 'edit-checkbox';
|
||||
this._checkbox.type = 'checkbox';
|
||||
this._checkbox.setAttribute('role', 'checkbox');
|
||||
container.appendChild(this._checkbox);
|
||||
|
||||
this._label = resourceLabels.create(container, { supportHighlights: true });
|
||||
|
||||
this._details = document.createElement('span');
|
||||
this._details.className = 'details';
|
||||
container.appendChild(this._details);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._localDisposables.dispose();
|
||||
this._disposables.dispose();
|
||||
this._label.dispose();
|
||||
}
|
||||
|
||||
set(element: FileElement, score: FuzzyScore | undefined) {
|
||||
this._localDisposables.clear();
|
||||
|
||||
this._checkbox.checked = element.isChecked();
|
||||
this._checkbox.disabled = element.isDisabled();
|
||||
this._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', () => {
|
||||
element.setChecked(this._checkbox.checked);
|
||||
}));
|
||||
|
||||
if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {
|
||||
// rename: oldName → newName
|
||||
this._label.setResource({
|
||||
resource: element.edit.uri,
|
||||
name: localize('rename.label', "{0} → {1}", this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })),
|
||||
}, {
|
||||
fileDecorations: { colors: true, badges: false }
|
||||
});
|
||||
|
||||
this._details.innerText = localize('detail.rename', "(renaming)");
|
||||
|
||||
} else {
|
||||
// create, delete, edit: NAME
|
||||
const options = {
|
||||
matches: createMatches(score),
|
||||
fileKind: FileKind.FILE,
|
||||
fileDecorations: { colors: true, badges: false },
|
||||
extraClasses: <string[]>[]
|
||||
};
|
||||
if (element.edit.type & BulkFileOperationType.Create) {
|
||||
this._details.innerText = localize('detail.create', "(creating)");
|
||||
} else if (element.edit.type & BulkFileOperationType.Delete) {
|
||||
this._details.innerText = localize('detail.del', "(deleting)");
|
||||
options.extraClasses.push('delete');
|
||||
} else {
|
||||
this._details.innerText = '';
|
||||
}
|
||||
this._label.setFile(element.edit.uri, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FileElementRenderer implements ITreeRenderer<FileElement, FuzzyScore, FileElementTemplate> {
|
||||
|
||||
static readonly id: string = 'FileElementRenderer';
|
||||
|
||||
readonly templateId: string = FileElementRenderer.id;
|
||||
|
||||
constructor(
|
||||
private readonly _resourceLabels: ResourceLabels,
|
||||
@ILabelService private readonly _labelService: ILabelService,
|
||||
) { }
|
||||
|
||||
renderTemplate(container: HTMLElement): FileElementTemplate {
|
||||
return new FileElementTemplate(container, this._resourceLabels, this._labelService);
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<FileElement, FuzzyScore>, _index: number, template: FileElementTemplate): void {
|
||||
template.set(node.element, node.filterData);
|
||||
}
|
||||
|
||||
disposeTemplate(template: FileElementTemplate): void {
|
||||
template.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class TextEditElementTemplate {
|
||||
|
||||
private readonly _disposables = new DisposableStore();
|
||||
private readonly _localDisposables = new DisposableStore();
|
||||
|
||||
private readonly _checkbox: HTMLInputElement;
|
||||
private readonly _icon: HTMLDivElement;
|
||||
private readonly _label: HighlightedLabel;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
container.classList.add('textedit');
|
||||
|
||||
this._checkbox = document.createElement('input');
|
||||
this._checkbox.className = 'edit-checkbox';
|
||||
this._checkbox.type = 'checkbox';
|
||||
this._checkbox.setAttribute('role', 'checkbox');
|
||||
container.appendChild(this._checkbox);
|
||||
|
||||
this._icon = document.createElement('div');
|
||||
container.appendChild(this._icon);
|
||||
|
||||
this._label = new HighlightedLabel(container, false);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._localDisposables.dispose();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
set(element: TextEditElement) {
|
||||
this._localDisposables.clear();
|
||||
|
||||
this._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', e => {
|
||||
element.setChecked(this._checkbox.checked);
|
||||
e.preventDefault();
|
||||
}));
|
||||
if (element.parent.isChecked()) {
|
||||
this._checkbox.checked = element.isChecked();
|
||||
this._checkbox.disabled = element.isDisabled();
|
||||
} else {
|
||||
this._checkbox.checked = element.isChecked();
|
||||
this._checkbox.disabled = element.isDisabled();
|
||||
}
|
||||
|
||||
let value = '';
|
||||
value += element.prefix;
|
||||
value += element.selecting;
|
||||
value += element.inserting;
|
||||
value += element.suffix;
|
||||
|
||||
let selectHighlight: IHighlight = { start: element.prefix.length, end: element.prefix.length + element.selecting.length, extraClasses: 'remove' };
|
||||
let insertHighlight: IHighlight = { start: selectHighlight.end, end: selectHighlight.end + element.inserting.length, extraClasses: 'insert' };
|
||||
|
||||
let title: string | undefined;
|
||||
let { metadata } = element.edit.textEdit;
|
||||
if (metadata && metadata.description) {
|
||||
title = localize('title', "{0} - {1}", metadata.label, metadata.description);
|
||||
} else if (metadata) {
|
||||
title = metadata.label;
|
||||
}
|
||||
|
||||
const iconPath = metadata?.iconPath;
|
||||
if (!iconPath) {
|
||||
this._icon.style.display = 'none';
|
||||
} else {
|
||||
this._icon.style.display = 'block';
|
||||
|
||||
this._icon.style.setProperty('--background-dark', null);
|
||||
this._icon.style.setProperty('--background-light', null);
|
||||
|
||||
if (ThemeIcon.isThemeIcon(iconPath)) {
|
||||
// css
|
||||
const className = ThemeIcon.asClassName(iconPath);
|
||||
this._icon.className = className ? `theme-icon ${className}` : '';
|
||||
|
||||
} else if (URI.isUri(iconPath)) {
|
||||
// background-image
|
||||
this._icon.className = 'uri-icon';
|
||||
this._icon.style.setProperty('--background-dark', `url("${iconPath.toString(true)}")`);
|
||||
this._icon.style.setProperty('--background-light', `url("${iconPath.toString(true)}")`);
|
||||
|
||||
} else {
|
||||
// background-image
|
||||
this._icon.className = 'uri-icon';
|
||||
this._icon.style.setProperty('--background-dark', `url("${iconPath.dark.toString(true)}")`);
|
||||
this._icon.style.setProperty('--background-light', `url("${iconPath.light.toString(true)}")`);
|
||||
}
|
||||
}
|
||||
|
||||
this._label.set(value, [selectHighlight, insertHighlight], title, true);
|
||||
this._icon.title = title || '';
|
||||
}
|
||||
}
|
||||
|
||||
export class TextEditElementRenderer implements ITreeRenderer<TextEditElement, FuzzyScore, TextEditElementTemplate> {
|
||||
|
||||
static readonly id = 'TextEditElementRenderer';
|
||||
|
||||
readonly templateId: string = TextEditElementRenderer.id;
|
||||
|
||||
renderTemplate(container: HTMLElement): TextEditElementTemplate {
|
||||
return new TextEditElementTemplate(container);
|
||||
}
|
||||
|
||||
renderElement({ element }: ITreeNode<TextEditElement, FuzzyScore>, _index: number, template: TextEditElementTemplate): void {
|
||||
template.set(element);
|
||||
}
|
||||
|
||||
disposeTemplate(_template: TextEditElementTemplate): void { }
|
||||
}
|
||||
|
||||
export class BulkEditDelegate implements IListVirtualDelegate<BulkEditElement> {
|
||||
|
||||
getHeight(): number {
|
||||
return 23;
|
||||
}
|
||||
|
||||
getTemplateId(element: BulkEditElement): string {
|
||||
|
||||
if (element instanceof FileElement) {
|
||||
return FileElementRenderer.id;
|
||||
} else if (element instanceof TextEditElement) {
|
||||
return TextEditElementRenderer.id;
|
||||
} else {
|
||||
return CategoryElementRenderer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class BulkEditNaviLabelProvider implements IKeyboardNavigationLabelProvider<BulkEditElement> {
|
||||
|
||||
getKeyboardNavigationLabel(element: BulkEditElement) {
|
||||
if (element instanceof FileElement) {
|
||||
return basename(element.edit.uri);
|
||||
} else if (element instanceof CategoryElement) {
|
||||
return element.category.metadata.label;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { mock } from 'vs/workbench/test/common/workbenchTestServices';
|
||||
import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService';
|
||||
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { BulkFileOperations } from 'vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPreview';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { ResourceFileEdit, ResourceTextEdit } from 'vs/editor/browser/services/bulkEditService';
|
||||
|
||||
suite('BulkEditPreview', function () {
|
||||
|
||||
|
||||
let instaService: IInstantiationService;
|
||||
|
||||
setup(function () {
|
||||
|
||||
const fileService: IFileService = new class extends mock<IFileService>() {
|
||||
onDidFilesChange = Event.None;
|
||||
async exists() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const modelService: IModelService = new class extends mock<IModelService>() {
|
||||
getModel() {
|
||||
return null;
|
||||
}
|
||||
getModels() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
instaService = new InstantiationService(new ServiceCollection(
|
||||
[IFileService, fileService],
|
||||
[IModelService, modelService],
|
||||
));
|
||||
});
|
||||
|
||||
test('one needsConfirmation unchecks all of file', async function () {
|
||||
|
||||
const edits = [
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri1'), undefined, { label: 'cat1', needsConfirmation: true }),
|
||||
new ResourceFileEdit(URI.parse('some:///uri1'), URI.parse('some:///uri2'), undefined, { label: 'cat2', needsConfirmation: false }),
|
||||
];
|
||||
|
||||
const ops = await instaService.invokeFunction(BulkFileOperations.create, edits);
|
||||
assert.equal(ops.fileOperations.length, 1);
|
||||
assert.equal(ops.checked.isChecked(edits[0]), false);
|
||||
});
|
||||
|
||||
test('has categories', async function () {
|
||||
|
||||
const edits = [
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri1'), undefined, { label: 'uri1', needsConfirmation: true }),
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri2'), undefined, { label: 'uri2', needsConfirmation: false }),
|
||||
];
|
||||
|
||||
|
||||
const ops = await instaService.invokeFunction(BulkFileOperations.create, edits);
|
||||
assert.equal(ops.categories.length, 2);
|
||||
assert.equal(ops.categories[0].metadata.label, 'uri1'); // unconfirmed!
|
||||
assert.equal(ops.categories[1].metadata.label, 'uri2');
|
||||
});
|
||||
|
||||
test('has not categories', async function () {
|
||||
|
||||
const edits = [
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri1'), undefined, { label: 'uri1', needsConfirmation: true }),
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri2'), undefined, { label: 'uri1', needsConfirmation: false }),
|
||||
];
|
||||
|
||||
const ops = await instaService.invokeFunction(BulkFileOperations.create, edits);
|
||||
assert.equal(ops.categories.length, 1);
|
||||
assert.equal(ops.categories[0].metadata.label, 'uri1'); // unconfirmed!
|
||||
assert.equal(ops.categories[0].metadata.label, 'uri1');
|
||||
});
|
||||
|
||||
test('category selection', async function () {
|
||||
|
||||
const edits = [
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri1'), undefined, { label: 'C1', needsConfirmation: false }),
|
||||
new ResourceTextEdit(URI.parse('some:///uri2'), { text: 'foo', range: new Range(1, 1, 1, 1) }, undefined, { label: 'C2', needsConfirmation: false }),
|
||||
];
|
||||
|
||||
|
||||
const ops = await instaService.invokeFunction(BulkFileOperations.create, edits);
|
||||
|
||||
assert.equal(ops.checked.isChecked(edits[0]), true);
|
||||
assert.equal(ops.checked.isChecked(edits[1]), true);
|
||||
|
||||
assert.ok(edits === ops.getWorkspaceEdit());
|
||||
|
||||
// NOT taking to create, but the invalid text edit will
|
||||
// go through
|
||||
ops.checked.updateChecked(edits[0], false);
|
||||
const newEdits = ops.getWorkspaceEdit();
|
||||
assert.ok(edits !== newEdits);
|
||||
|
||||
assert.equal(edits.length, 2);
|
||||
assert.equal(newEdits.length, 1);
|
||||
});
|
||||
|
||||
test('fix bad metadata', async function () {
|
||||
|
||||
// bogous edit that wants creation to be confirmed, but not it's textedit-child...
|
||||
|
||||
const edits = [
|
||||
new ResourceFileEdit(undefined, URI.parse('some:///uri1'), undefined, { label: 'C1', needsConfirmation: true }),
|
||||
new ResourceTextEdit(URI.parse('some:///uri1'), { text: 'foo', range: new Range(1, 1, 1, 1) }, undefined, { label: 'C2', needsConfirmation: false })
|
||||
];
|
||||
|
||||
const ops = await instaService.invokeFunction(BulkFileOperations.create, edits);
|
||||
|
||||
assert.equal(ops.checked.isChecked(edits[0]), false);
|
||||
assert.equal(ops.checked.isChecked(edits[1]), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from 'vs/nls';
|
||||
import { CallHierarchyProviderRegistry, CallHierarchyDirection, CallHierarchyModel } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy';
|
||||
import { CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { CallHierarchyTreePeekWidget } from 'vs/workbench/contrib/callHierarchy/browser/callHierarchyPeek';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { registerEditorContribution, EditorAction2 } from 'vs/editor/browser/editorExtensions';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IContextKeyService, RawContextKey, IContextKey, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
|
||||
import { PeekContext } from 'vs/editor/contrib/peekView/peekView';
|
||||
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { IPosition } from 'vs/editor/common/core/position';
|
||||
import { MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
|
||||
import { registerIcon, Codicon } from 'vs/base/common/codicons';
|
||||
|
||||
const _ctxHasCallHierarchyProvider = new RawContextKey<boolean>('editorHasCallHierarchyProvider', false);
|
||||
const _ctxCallHierarchyVisible = new RawContextKey<boolean>('callHierarchyVisible', false);
|
||||
const _ctxCallHierarchyDirection = new RawContextKey<string>('callHierarchyDirection', undefined);
|
||||
|
||||
function sanitizedDirection(candidate: string): CallHierarchyDirection {
|
||||
return candidate === CallHierarchyDirection.CallsFrom || candidate === CallHierarchyDirection.CallsTo
|
||||
? candidate
|
||||
: CallHierarchyDirection.CallsTo;
|
||||
}
|
||||
|
||||
class CallHierarchyController implements IEditorContribution {
|
||||
|
||||
static readonly Id = 'callHierarchy';
|
||||
|
||||
static get(editor: ICodeEditor): CallHierarchyController {
|
||||
return editor.getContribution<CallHierarchyController>(CallHierarchyController.Id);
|
||||
}
|
||||
|
||||
private static readonly _StorageDirection = 'callHierarchy/defaultDirection';
|
||||
|
||||
private readonly _ctxHasProvider: IContextKey<boolean>;
|
||||
private readonly _ctxIsVisible: IContextKey<boolean>;
|
||||
private readonly _ctxDirection: IContextKey<string>;
|
||||
private readonly _dispoables = new DisposableStore();
|
||||
private readonly _sessionDisposables = new DisposableStore();
|
||||
|
||||
private _widget?: CallHierarchyTreePeekWidget;
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
@ICodeEditorService private readonly _editorService: ICodeEditorService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) {
|
||||
this._ctxIsVisible = _ctxCallHierarchyVisible.bindTo(this._contextKeyService);
|
||||
this._ctxHasProvider = _ctxHasCallHierarchyProvider.bindTo(this._contextKeyService);
|
||||
this._ctxDirection = _ctxCallHierarchyDirection.bindTo(this._contextKeyService);
|
||||
this._dispoables.add(Event.any<any>(_editor.onDidChangeModel, _editor.onDidChangeModelLanguage, CallHierarchyProviderRegistry.onDidChange)(() => {
|
||||
this._ctxHasProvider.set(_editor.hasModel() && CallHierarchyProviderRegistry.has(_editor.getModel()));
|
||||
}));
|
||||
this._dispoables.add(this._sessionDisposables);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._ctxHasProvider.reset();
|
||||
this._ctxIsVisible.reset();
|
||||
this._dispoables.dispose();
|
||||
}
|
||||
|
||||
async startCallHierarchyFromEditor(): Promise<void> {
|
||||
this._sessionDisposables.clear();
|
||||
|
||||
if (!this._editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = this._editor.getModel();
|
||||
const position = this._editor.getPosition();
|
||||
if (!CallHierarchyProviderRegistry.has(document)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cts = new CancellationTokenSource();
|
||||
const model = CallHierarchyModel.create(document, position, cts.token);
|
||||
const direction = sanitizedDirection(this._storageService.get(CallHierarchyController._StorageDirection, StorageScope.GLOBAL, CallHierarchyDirection.CallsTo));
|
||||
|
||||
this._showCallHierarchyWidget(position, direction, model, cts);
|
||||
}
|
||||
|
||||
async startCallHierarchyFromCallHierarchy(): Promise<void> {
|
||||
if (!this._widget) {
|
||||
return;
|
||||
}
|
||||
const model = this._widget.getModel();
|
||||
const call = this._widget.getFocused();
|
||||
if (!call || !model) {
|
||||
return;
|
||||
}
|
||||
const newEditor = await this._editorService.openCodeEditor({ resource: call.item.uri }, this._editor);
|
||||
if (!newEditor) {
|
||||
return;
|
||||
}
|
||||
const newModel = model.fork(call.item);
|
||||
this._sessionDisposables.clear();
|
||||
|
||||
CallHierarchyController.get(newEditor)._showCallHierarchyWidget(
|
||||
Range.lift(newModel.root.selectionRange).getStartPosition(),
|
||||
this._widget.direction,
|
||||
Promise.resolve(newModel),
|
||||
new CancellationTokenSource()
|
||||
);
|
||||
}
|
||||
|
||||
private _showCallHierarchyWidget(position: IPosition, direction: CallHierarchyDirection, model: Promise<CallHierarchyModel | undefined>, cts: CancellationTokenSource) {
|
||||
|
||||
this._ctxIsVisible.set(true);
|
||||
this._ctxDirection.set(direction);
|
||||
Event.any<any>(this._editor.onDidChangeModel, this._editor.onDidChangeModelLanguage)(this.endCallHierarchy, this, this._sessionDisposables);
|
||||
this._widget = this._instantiationService.createInstance(CallHierarchyTreePeekWidget, this._editor, position, direction);
|
||||
this._widget.showLoading();
|
||||
this._sessionDisposables.add(this._widget.onDidClose(() => {
|
||||
this.endCallHierarchy();
|
||||
this._storageService.store(CallHierarchyController._StorageDirection, this._widget!.direction, StorageScope.GLOBAL);
|
||||
}));
|
||||
this._sessionDisposables.add({ dispose() { cts.dispose(true); } });
|
||||
this._sessionDisposables.add(this._widget);
|
||||
|
||||
model.then(model => {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
return; // nothing
|
||||
}
|
||||
if (model) {
|
||||
this._sessionDisposables.add(model);
|
||||
this._widget!.showModel(model);
|
||||
}
|
||||
else {
|
||||
this._widget!.showMessage(localize('no.item', "No results"));
|
||||
}
|
||||
}).catch(e => {
|
||||
this._widget!.showMessage(localize('error', "Failed to show call hierarchy"));
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
showOutgoingCalls(): void {
|
||||
this._widget?.updateDirection(CallHierarchyDirection.CallsFrom);
|
||||
this._ctxDirection.set(CallHierarchyDirection.CallsFrom);
|
||||
}
|
||||
|
||||
showIncomingCalls(): void {
|
||||
this._widget?.updateDirection(CallHierarchyDirection.CallsTo);
|
||||
this._ctxDirection.set(CallHierarchyDirection.CallsTo);
|
||||
}
|
||||
|
||||
endCallHierarchy(): void {
|
||||
this._sessionDisposables.clear();
|
||||
this._ctxIsVisible.set(false);
|
||||
this._editor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(CallHierarchyController.Id, CallHierarchyController);
|
||||
|
||||
registerAction2(class extends EditorAction2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.showCallHierarchy',
|
||||
title: { value: localize('title', "Peek Call Hierarchy"), original: 'Peek Call Hierarchy' },
|
||||
menu: {
|
||||
id: MenuId.EditorContextPeek,
|
||||
group: 'navigation',
|
||||
order: 1000,
|
||||
when: ContextKeyExpr.and(
|
||||
_ctxHasCallHierarchyProvider,
|
||||
PeekContext.notInPeekEditor
|
||||
),
|
||||
},
|
||||
keybinding: {
|
||||
when: EditorContextKeys.editorTextFocus,
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.Shift + KeyMod.Alt + KeyCode.KEY_H
|
||||
},
|
||||
precondition: ContextKeyExpr.and(
|
||||
_ctxHasCallHierarchyProvider,
|
||||
PeekContext.notInPeekEditor
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
async runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {
|
||||
return CallHierarchyController.get(editor).startCallHierarchyFromEditor();
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class extends EditorAction2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.showIncomingCalls',
|
||||
title: { value: localize('title.incoming', "Show Incoming Calls"), original: 'Show Incoming Calls' },
|
||||
icon: registerIcon('callhierarchy-incoming', Codicon.callIncoming),
|
||||
precondition: ContextKeyExpr.and(_ctxCallHierarchyVisible, _ctxCallHierarchyDirection.isEqualTo(CallHierarchyDirection.CallsFrom)),
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.Shift + KeyMod.Alt + KeyCode.KEY_H,
|
||||
},
|
||||
menu: {
|
||||
id: CallHierarchyTreePeekWidget.TitleMenu,
|
||||
when: _ctxCallHierarchyDirection.isEqualTo(CallHierarchyDirection.CallsFrom),
|
||||
order: 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor) {
|
||||
return CallHierarchyController.get(editor).showIncomingCalls();
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class extends EditorAction2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.showOutgoingCalls',
|
||||
title: { value: localize('title.outgoing', "Show Outgoing Calls"), original: 'Show Outgoing Calls' },
|
||||
icon: registerIcon('callhierarchy-outgoing', Codicon.callOutgoing),
|
||||
precondition: ContextKeyExpr.and(_ctxCallHierarchyVisible, _ctxCallHierarchyDirection.isEqualTo(CallHierarchyDirection.CallsTo)),
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.Shift + KeyMod.Alt + KeyCode.KEY_H,
|
||||
},
|
||||
menu: {
|
||||
id: CallHierarchyTreePeekWidget.TitleMenu,
|
||||
when: _ctxCallHierarchyDirection.isEqualTo(CallHierarchyDirection.CallsTo),
|
||||
order: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor) {
|
||||
return CallHierarchyController.get(editor).showOutgoingCalls();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
registerAction2(class extends EditorAction2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.refocusCallHierarchy',
|
||||
title: { value: localize('title.refocus', "Refocus Call Hierarchy"), original: 'Refocus Call Hierarchy' },
|
||||
precondition: _ctxCallHierarchyVisible,
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.Shift + KeyCode.Enter
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {
|
||||
return CallHierarchyController.get(editor).startCallHierarchyFromCallHierarchy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
registerAction2(class extends EditorAction2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.closeCallHierarchy',
|
||||
title: localize('close', 'Close'),
|
||||
icon: Codicon.close,
|
||||
precondition: ContextKeyExpr.and(
|
||||
_ctxCallHierarchyVisible,
|
||||
ContextKeyExpr.not('config.editor.stablePeek')
|
||||
),
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib + 10,
|
||||
primary: KeyCode.Escape
|
||||
},
|
||||
menu: {
|
||||
id: CallHierarchyTreePeekWidget.TitleMenu,
|
||||
order: 1000
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
return CallHierarchyController.get(editor).endCallHierarchy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./media/callHierarchy';
|
||||
import * as peekView from 'vs/editor/contrib/peekView/peekView';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { CallHierarchyDirection, CallHierarchyModel } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy';
|
||||
import { WorkbenchAsyncDataTree, IWorkbenchAsyncDataTreeOptions } from 'vs/platform/list/browser/listService';
|
||||
import { FuzzyScore } from 'vs/base/common/filters';
|
||||
import * as callHTree from 'vs/workbench/contrib/callHierarchy/browser/callHierarchyTree';
|
||||
import { IAsyncDataTreeViewState } from 'vs/base/browser/ui/tree/asyncDataTree';
|
||||
import { localize } from 'vs/nls';
|
||||
import { ScrollType } from 'vs/editor/common/editorCommon';
|
||||
import { IRange, Range } from 'vs/editor/common/core/range';
|
||||
import { SplitView, Orientation, Sizing } from 'vs/base/browser/ui/splitview/splitview';
|
||||
import { Dimension } from 'vs/base/browser/dom';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
|
||||
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
|
||||
import { ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { toDisposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { TrackedRangeStickiness, IModelDeltaDecoration, IModelDecorationOptions, OverviewRulerLane } from 'vs/editor/common/model';
|
||||
import { registerThemingParticipant, themeColorFromId, IThemeService, IColorTheme } from 'vs/platform/theme/common/themeService';
|
||||
import { IPosition } from 'vs/editor/common/core/position';
|
||||
import { IAction } from 'vs/base/common/actions';
|
||||
import { IActionBarOptions, ActionsOrientation } from 'vs/base/browser/ui/actionbar/actionbar';
|
||||
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
|
||||
import { Color } from 'vs/base/common/color';
|
||||
import { TreeMouseEventTarget, ITreeNode } from 'vs/base/browser/ui/tree/tree';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { MenuId, IMenuService } from 'vs/platform/actions/common/actions';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
|
||||
|
||||
const enum State {
|
||||
Loading = 'loading',
|
||||
Message = 'message',
|
||||
Data = 'data'
|
||||
}
|
||||
|
||||
class LayoutInfo {
|
||||
|
||||
static store(info: LayoutInfo, storageService: IStorageService): void {
|
||||
storageService.store('callHierarchyPeekLayout', JSON.stringify(info), StorageScope.GLOBAL);
|
||||
}
|
||||
|
||||
static retrieve(storageService: IStorageService): LayoutInfo {
|
||||
const value = storageService.get('callHierarchyPeekLayout', StorageScope.GLOBAL, '{}');
|
||||
const defaultInfo: LayoutInfo = { ratio: 0.7, height: 17 };
|
||||
try {
|
||||
return { ...defaultInfo, ...JSON.parse(value) };
|
||||
} catch {
|
||||
return defaultInfo;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
public ratio: number,
|
||||
public height: number
|
||||
) { }
|
||||
}
|
||||
|
||||
class CallHierarchyTree extends WorkbenchAsyncDataTree<CallHierarchyModel, callHTree.Call, FuzzyScore>{ }
|
||||
|
||||
export class CallHierarchyTreePeekWidget extends peekView.PeekViewWidget {
|
||||
|
||||
static readonly TitleMenu = new MenuId('callhierarchy/title');
|
||||
|
||||
private _parent!: HTMLElement;
|
||||
private _message!: HTMLElement;
|
||||
private _splitView!: SplitView;
|
||||
private _tree!: CallHierarchyTree;
|
||||
private _treeViewStates = new Map<CallHierarchyDirection, IAsyncDataTreeViewState>();
|
||||
private _editor!: EmbeddedCodeEditorWidget;
|
||||
private _dim!: Dimension;
|
||||
private _layoutInfo!: LayoutInfo;
|
||||
|
||||
private readonly _previewDisposable = new DisposableStore();
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
private readonly _where: IPosition,
|
||||
private _direction: CallHierarchyDirection,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@peekView.IPeekViewService private readonly _peekViewService: peekView.IPeekViewService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@ITextModelService private readonly _textModelService: ITextModelService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
@IMenuService private readonly _menuService: IMenuService,
|
||||
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) {
|
||||
super(editor, { showFrame: true, showArrow: true, isResizeable: true, isAccessible: true }, _instantiationService);
|
||||
this.create();
|
||||
this._peekViewService.addExclusiveWidget(editor, this);
|
||||
this._applyTheme(themeService.getColorTheme());
|
||||
this._disposables.add(themeService.onDidColorThemeChange(this._applyTheme, this));
|
||||
this._disposables.add(this._previewDisposable);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
LayoutInfo.store(this._layoutInfo, this._storageService);
|
||||
this._splitView.dispose();
|
||||
this._tree.dispose();
|
||||
this._editor.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
get direction(): CallHierarchyDirection {
|
||||
return this._direction;
|
||||
}
|
||||
|
||||
private _applyTheme(theme: IColorTheme) {
|
||||
const borderColor = theme.getColor(peekView.peekViewBorder) || Color.transparent;
|
||||
this.style({
|
||||
arrowColor: borderColor,
|
||||
frameColor: borderColor,
|
||||
headerBackgroundColor: theme.getColor(peekView.peekViewTitleBackground) || Color.transparent,
|
||||
primaryHeadingColor: theme.getColor(peekView.peekViewTitleForeground),
|
||||
secondaryHeadingColor: theme.getColor(peekView.peekViewTitleInfoForeground)
|
||||
});
|
||||
}
|
||||
|
||||
protected _fillHead(container: HTMLElement): void {
|
||||
super._fillHead(container, true);
|
||||
|
||||
const menu = this._menuService.createMenu(CallHierarchyTreePeekWidget.TitleMenu, this._contextKeyService);
|
||||
const updateToolbar = () => {
|
||||
const actions: IAction[] = [];
|
||||
createAndFillInActionBarActions(menu, undefined, actions);
|
||||
this._actionbarWidget!.clear();
|
||||
this._actionbarWidget!.push(actions, { label: false, icon: true });
|
||||
};
|
||||
this._disposables.add(menu);
|
||||
this._disposables.add(menu.onDidChange(updateToolbar));
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
protected _getActionBarOptions(): IActionBarOptions {
|
||||
return {
|
||||
...super._getActionBarOptions(),
|
||||
orientation: ActionsOrientation.HORIZONTAL
|
||||
};
|
||||
}
|
||||
|
||||
protected _fillBody(parent: HTMLElement): void {
|
||||
|
||||
this._layoutInfo = LayoutInfo.retrieve(this._storageService);
|
||||
this._dim = new Dimension(0, 0);
|
||||
|
||||
this._parent = parent;
|
||||
parent.classList.add('call-hierarchy');
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.classList.add('message');
|
||||
parent.appendChild(message);
|
||||
this._message = message;
|
||||
this._message.tabIndex = 0;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.classList.add('results');
|
||||
parent.appendChild(container);
|
||||
|
||||
this._splitView = new SplitView(container, { orientation: Orientation.HORIZONTAL });
|
||||
|
||||
// editor stuff
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.classList.add('editor');
|
||||
container.appendChild(editorContainer);
|
||||
let editorOptions: IEditorOptions = {
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 14,
|
||||
horizontal: 'auto',
|
||||
useShadows: true,
|
||||
verticalHasArrows: false,
|
||||
horizontalHasArrows: false,
|
||||
alwaysConsumeMouseWheel: false
|
||||
},
|
||||
overviewRulerLanes: 2,
|
||||
fixedOverflowWidgets: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
}
|
||||
};
|
||||
this._editor = this._instantiationService.createInstance(
|
||||
EmbeddedCodeEditorWidget,
|
||||
editorContainer,
|
||||
editorOptions,
|
||||
this.editor
|
||||
);
|
||||
|
||||
// tree stuff
|
||||
const treeContainer = document.createElement('div');
|
||||
treeContainer.classList.add('tree');
|
||||
container.appendChild(treeContainer);
|
||||
const options: IWorkbenchAsyncDataTreeOptions<callHTree.Call, FuzzyScore> = {
|
||||
sorter: new callHTree.Sorter(),
|
||||
accessibilityProvider: new callHTree.AccessibilityProvider(() => this._direction),
|
||||
identityProvider: new callHTree.IdentityProvider(() => this._direction),
|
||||
expandOnlyOnTwistieClick: true,
|
||||
overrideStyles: {
|
||||
listBackground: peekView.peekViewResultsBackground
|
||||
}
|
||||
};
|
||||
this._tree = this._instantiationService.createInstance(
|
||||
CallHierarchyTree,
|
||||
'CallHierarchyPeek',
|
||||
treeContainer,
|
||||
new callHTree.VirtualDelegate(),
|
||||
[this._instantiationService.createInstance(callHTree.CallRenderer)],
|
||||
this._instantiationService.createInstance(callHTree.DataSource, () => this._direction),
|
||||
options
|
||||
);
|
||||
|
||||
// split stuff
|
||||
this._splitView.addView({
|
||||
onDidChange: Event.None,
|
||||
element: editorContainer,
|
||||
minimumSize: 200,
|
||||
maximumSize: Number.MAX_VALUE,
|
||||
layout: (width) => {
|
||||
if (this._dim.height) {
|
||||
this._editor.layout({ height: this._dim.height, width });
|
||||
}
|
||||
}
|
||||
}, Sizing.Distribute);
|
||||
|
||||
this._splitView.addView({
|
||||
onDidChange: Event.None,
|
||||
element: treeContainer,
|
||||
minimumSize: 100,
|
||||
maximumSize: Number.MAX_VALUE,
|
||||
layout: (width) => {
|
||||
if (this._dim.height) {
|
||||
this._tree.layout(this._dim.height, width);
|
||||
}
|
||||
}
|
||||
}, Sizing.Distribute);
|
||||
|
||||
this._disposables.add(this._splitView.onDidSashChange(() => {
|
||||
if (this._dim.width) {
|
||||
this._layoutInfo.ratio = this._splitView.getViewSize(0) / this._dim.width;
|
||||
}
|
||||
}));
|
||||
|
||||
// update editor
|
||||
this._disposables.add(this._tree.onDidChangeFocus(this._updatePreview, this));
|
||||
|
||||
this._disposables.add(this._editor.onMouseDown(e => {
|
||||
const { event, target } = e;
|
||||
if (event.detail !== 2) {
|
||||
return;
|
||||
}
|
||||
const [focus] = this._tree.getFocus();
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
this.dispose();
|
||||
this._editorService.openEditor({
|
||||
resource: focus.item.uri,
|
||||
options: { selection: target.range! }
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
this._disposables.add(this._tree.onMouseDblClick(e => {
|
||||
if (e.target === TreeMouseEventTarget.Twistie) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.element) {
|
||||
this.dispose();
|
||||
this._editorService.openEditor({
|
||||
resource: e.element.item.uri,
|
||||
options: { selection: e.element.item.selectionRange }
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
this._disposables.add(this._tree.onDidChangeSelection(e => {
|
||||
const [element] = e.elements;
|
||||
// don't close on click
|
||||
if (element && e.browserEvent instanceof KeyboardEvent) {
|
||||
this.dispose();
|
||||
this._editorService.openEditor({
|
||||
resource: element.item.uri,
|
||||
options: { selection: element.item.selectionRange }
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private async _updatePreview() {
|
||||
const [element] = this._tree.getFocus();
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._previewDisposable.clear();
|
||||
|
||||
// update: editor and editor highlights
|
||||
const options: IModelDecorationOptions = {
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
className: 'call-decoration',
|
||||
overviewRuler: {
|
||||
color: themeColorFromId(peekView.peekViewEditorMatchHighlight),
|
||||
position: OverviewRulerLane.Center
|
||||
},
|
||||
};
|
||||
|
||||
let previewUri: URI;
|
||||
if (this._direction === CallHierarchyDirection.CallsFrom) {
|
||||
// outgoing calls: show caller and highlight focused calls
|
||||
previewUri = element.parent ? element.parent.item.uri : element.model.root.uri;
|
||||
|
||||
} else {
|
||||
// incoming calls: show caller and highlight focused calls
|
||||
previewUri = element.item.uri;
|
||||
}
|
||||
|
||||
const value = await this._textModelService.createModelReference(previewUri);
|
||||
this._editor.setModel(value.object.textEditorModel);
|
||||
|
||||
// set decorations for caller ranges (if in the same file)
|
||||
let decorations: IModelDeltaDecoration[] = [];
|
||||
let fullRange: IRange | undefined;
|
||||
let locations = element.locations;
|
||||
if (!locations) {
|
||||
locations = [{ uri: element.item.uri, range: element.item.selectionRange }];
|
||||
}
|
||||
for (const loc of locations) {
|
||||
if (loc.uri.toString() === previewUri.toString()) {
|
||||
decorations.push({ range: loc.range, options });
|
||||
fullRange = !fullRange ? loc.range : Range.plusRange(loc.range, fullRange);
|
||||
}
|
||||
}
|
||||
if (fullRange) {
|
||||
this._editor.revealRangeInCenter(fullRange, ScrollType.Immediate);
|
||||
const ids = this._editor.deltaDecorations([], decorations);
|
||||
this._previewDisposable.add(toDisposable(() => this._editor.deltaDecorations(ids, [])));
|
||||
}
|
||||
this._previewDisposable.add(value);
|
||||
|
||||
// update: title
|
||||
const title = this._direction === CallHierarchyDirection.CallsFrom
|
||||
? localize('callFrom', "Calls from '{0}'", element.model.root.name)
|
||||
: localize('callsTo', "Callers of '{0}'", element.model.root.name);
|
||||
this.setTitle(title);
|
||||
}
|
||||
|
||||
showLoading(): void {
|
||||
this._parent.dataset['state'] = State.Loading;
|
||||
this.setTitle(localize('title.loading', "Loading..."));
|
||||
this._show();
|
||||
}
|
||||
|
||||
showMessage(message: string): void {
|
||||
this._parent.dataset['state'] = State.Message;
|
||||
this.setTitle('');
|
||||
this.setMetaTitle('');
|
||||
this._message.innerText = message;
|
||||
this._show();
|
||||
this._message.focus();
|
||||
}
|
||||
|
||||
async showModel(model: CallHierarchyModel): Promise<void> {
|
||||
|
||||
this._show();
|
||||
const viewState = this._treeViewStates.get(this._direction);
|
||||
|
||||
await this._tree.setInput(model, viewState);
|
||||
|
||||
const root = <ITreeNode<callHTree.Call>>this._tree.getNode(model).children[0];
|
||||
await this._tree.expand(root.element);
|
||||
|
||||
if (root.children.length === 0) {
|
||||
//
|
||||
this.showMessage(this._direction === CallHierarchyDirection.CallsFrom
|
||||
? localize('empt.callsFrom', "No calls from '{0}'", model.root.name)
|
||||
: localize('empt.callsTo', "No callers of '{0}'", model.root.name));
|
||||
|
||||
} else {
|
||||
this._parent.dataset['state'] = State.Data;
|
||||
if (!viewState || this._tree.getFocus().length === 0) {
|
||||
this._tree.setFocus([root.children[0].element]);
|
||||
}
|
||||
this._tree.domFocus();
|
||||
this._updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): CallHierarchyModel | undefined {
|
||||
return this._tree.getInput();
|
||||
}
|
||||
|
||||
getFocused(): callHTree.Call | undefined {
|
||||
return this._tree.getFocus()[0];
|
||||
}
|
||||
|
||||
async updateDirection(newDirection: CallHierarchyDirection): Promise<void> {
|
||||
const model = this._tree.getInput();
|
||||
if (model && newDirection !== this._direction) {
|
||||
this._treeViewStates.set(this._direction, this._tree.getViewState());
|
||||
this._direction = newDirection;
|
||||
await this.showModel(model);
|
||||
}
|
||||
}
|
||||
|
||||
private _show() {
|
||||
if (!this._isShowing) {
|
||||
this.editor.revealLineInCenterIfOutsideViewport(this._where.lineNumber, ScrollType.Smooth);
|
||||
super.show(Range.fromPositions(this._where), this._layoutInfo.height);
|
||||
}
|
||||
}
|
||||
|
||||
protected _onWidth(width: number) {
|
||||
if (this._dim) {
|
||||
this._doLayoutBody(this._dim.height, width);
|
||||
}
|
||||
}
|
||||
|
||||
protected _doLayoutBody(height: number, width: number): void {
|
||||
if (this._dim.height !== height || this._dim.width !== width) {
|
||||
super._doLayoutBody(height, width);
|
||||
this._dim = new Dimension(width, height);
|
||||
this._layoutInfo.height = this._viewZone ? this._viewZone.heightInLines : this._layoutInfo.height;
|
||||
this._splitView.layout(width);
|
||||
this._splitView.resizeView(0, width * this._layoutInfo.ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const referenceHighlightColor = theme.getColor(peekView.peekViewEditorMatchHighlight);
|
||||
if (referenceHighlightColor) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .call-decoration { background-color: ${referenceHighlightColor}; }`);
|
||||
}
|
||||
const referenceHighlightBorder = theme.getColor(peekView.peekViewEditorMatchHighlightBorder);
|
||||
if (referenceHighlightBorder) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .call-decoration { border: 2px solid ${referenceHighlightBorder}; box-sizing: border-box; }`);
|
||||
}
|
||||
const resultsBackground = theme.getColor(peekView.peekViewResultsBackground);
|
||||
if (resultsBackground) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .tree { background-color: ${resultsBackground}; }`);
|
||||
}
|
||||
const resultsMatchForeground = theme.getColor(peekView.peekViewResultsFileForeground);
|
||||
if (resultsMatchForeground) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .tree { color: ${resultsMatchForeground}; }`);
|
||||
}
|
||||
const resultsSelectedBackground = theme.getColor(peekView.peekViewResultsSelectionBackground);
|
||||
if (resultsSelectedBackground) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .tree .monaco-list:focus .monaco-list-rows > .monaco-list-row.selected:not(.highlighted) { background-color: ${resultsSelectedBackground}; }`);
|
||||
}
|
||||
const resultsSelectedForeground = theme.getColor(peekView.peekViewResultsSelectionForeground);
|
||||
if (resultsSelectedForeground) {
|
||||
collector.addRule(`.monaco-editor .call-hierarchy .tree .monaco-list:focus .monaco-list-rows > .monaco-list-row.selected:not(.highlighted) { color: ${resultsSelectedForeground} !important; }`);
|
||||
}
|
||||
const editorBackground = theme.getColor(peekView.peekViewEditorBackground);
|
||||
if (editorBackground) {
|
||||
collector.addRule(
|
||||
`.monaco-editor .call-hierarchy .editor .monaco-editor .monaco-editor-background,` +
|
||||
`.monaco-editor .call-hierarchy .editor .monaco-editor .inputarea.ime-input {` +
|
||||
` background-color: ${editorBackground};` +
|
||||
`}`
|
||||
);
|
||||
}
|
||||
const editorGutterBackground = theme.getColor(peekView.peekViewEditorGutterBackground);
|
||||
if (editorGutterBackground) {
|
||||
collector.addRule(
|
||||
`.monaco-editor .call-hierarchy .editor .monaco-editor .margin {` +
|
||||
` background-color: ${editorGutterBackground};` +
|
||||
`}`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAsyncDataSource, ITreeRenderer, ITreeNode, ITreeSorter } from 'vs/base/browser/ui/tree/tree';
|
||||
import { CallHierarchyItem, CallHierarchyDirection, CallHierarchyModel, } from 'vs/workbench/contrib/callHierarchy/common/callHierarchy';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
|
||||
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
|
||||
import { IconLabel } from 'vs/base/browser/ui/iconLabel/iconLabel';
|
||||
import { SymbolKinds, Location, SymbolTag } from 'vs/editor/common/modes';
|
||||
import { compare } from 'vs/base/common/strings';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget';
|
||||
import { localize } from 'vs/nls';
|
||||
|
||||
export class Call {
|
||||
constructor(
|
||||
readonly item: CallHierarchyItem,
|
||||
readonly locations: Location[] | undefined,
|
||||
readonly model: CallHierarchyModel,
|
||||
readonly parent: Call | undefined
|
||||
) { }
|
||||
|
||||
static compare(a: Call, b: Call): number {
|
||||
let res = compare(a.item.uri.toString(), b.item.uri.toString());
|
||||
if (res === 0) {
|
||||
res = Range.compareRangesUsingStarts(a.item.range, b.item.range);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataSource implements IAsyncDataSource<CallHierarchyModel, Call> {
|
||||
|
||||
constructor(
|
||||
public getDirection: () => CallHierarchyDirection,
|
||||
) { }
|
||||
|
||||
hasChildren(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getChildren(element: CallHierarchyModel | Call): Promise<Call[]> {
|
||||
if (element instanceof CallHierarchyModel) {
|
||||
return element.roots.map(root => new Call(root, undefined, element, undefined));
|
||||
}
|
||||
|
||||
const { model, item } = element;
|
||||
|
||||
if (this.getDirection() === CallHierarchyDirection.CallsFrom) {
|
||||
return (await model.resolveOutgoingCalls(item, CancellationToken.None)).map(call => {
|
||||
return new Call(
|
||||
call.to,
|
||||
call.fromRanges.map(range => ({ range, uri: item.uri })),
|
||||
model,
|
||||
element
|
||||
);
|
||||
});
|
||||
|
||||
} else {
|
||||
return (await model.resolveIncomingCalls(item, CancellationToken.None)).map(call => {
|
||||
return new Call(
|
||||
call.from,
|
||||
call.fromRanges.map(range => ({ range, uri: call.from.uri })),
|
||||
model,
|
||||
element
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Sorter implements ITreeSorter<Call> {
|
||||
|
||||
compare(element: Call, otherElement: Call): number {
|
||||
return Call.compare(element, otherElement);
|
||||
}
|
||||
}
|
||||
|
||||
export class IdentityProvider implements IIdentityProvider<Call> {
|
||||
|
||||
constructor(
|
||||
public getDirection: () => CallHierarchyDirection
|
||||
) { }
|
||||
|
||||
getId(element: Call): { toString(): string; } {
|
||||
let res = this.getDirection() + JSON.stringify(element.item.uri) + JSON.stringify(element.item.range);
|
||||
if (element.parent) {
|
||||
res += this.getId(element.parent);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
class CallRenderingTemplate {
|
||||
constructor(
|
||||
readonly icon: HTMLDivElement,
|
||||
readonly label: IconLabel
|
||||
) { }
|
||||
}
|
||||
|
||||
export class CallRenderer implements ITreeRenderer<Call, FuzzyScore, CallRenderingTemplate> {
|
||||
|
||||
static readonly id = 'CallRenderer';
|
||||
|
||||
templateId: string = CallRenderer.id;
|
||||
|
||||
renderTemplate(container: HTMLElement): CallRenderingTemplate {
|
||||
container.classList.add('callhierarchy-element');
|
||||
let icon = document.createElement('div');
|
||||
container.appendChild(icon);
|
||||
const label = new IconLabel(container, { supportHighlights: true });
|
||||
return new CallRenderingTemplate(icon, label);
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<Call, FuzzyScore>, _index: number, template: CallRenderingTemplate): void {
|
||||
const { element, filterData } = node;
|
||||
const deprecated = element.item.tags?.includes(SymbolTag.Deprecated);
|
||||
template.icon.className = SymbolKinds.toCssClassName(element.item.kind, true);
|
||||
template.label.setLabel(
|
||||
element.item.name,
|
||||
element.item.detail,
|
||||
{ labelEscapeNewLines: true, matches: createMatches(filterData), strikethrough: deprecated }
|
||||
);
|
||||
}
|
||||
disposeTemplate(template: CallRenderingTemplate): void {
|
||||
template.label.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class VirtualDelegate implements IListVirtualDelegate<Call> {
|
||||
|
||||
getHeight(_element: Call): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
getTemplateId(_element: Call): string {
|
||||
return CallRenderer.id;
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessibilityProvider implements IListAccessibilityProvider<Call> {
|
||||
|
||||
constructor(
|
||||
public getDirection: () => CallHierarchyDirection
|
||||
) { }
|
||||
|
||||
getWidgetAriaLabel(): string {
|
||||
return localize('tree.aria', "Call Hierarchy");
|
||||
}
|
||||
|
||||
getAriaLabel(element: Call): string | null {
|
||||
if (this.getDirection() === CallHierarchyDirection.CallsFrom) {
|
||||
return localize('from', "calls from {0}", element.item.name);
|
||||
} else {
|
||||
return localize('to', "callers of {0}", element.item.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-workbench .call-hierarchy .results,
|
||||
.monaco-workbench .call-hierarchy .message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.monaco-workbench .call-hierarchy[data-state="data"] .results {
|
||||
display: inherit;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monaco-workbench .call-hierarchy[data-state="message"] .message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monaco-workbench .call-hierarchy .editor,
|
||||
.monaco-workbench .call-hierarchy .tree {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monaco-workbench .call-hierarchy .tree .callhierarchy-element {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monaco-workbench .call-hierarchy .tree .callhierarchy-element .monaco-icon-label {
|
||||
padding-left: 4px;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IRange } from 'vs/editor/common/core/range';
|
||||
import { SymbolKind, ProviderResult, SymbolTag } from 'vs/editor/common/modes';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { LanguageFeatureRegistry } from 'vs/editor/common/modes/languageFeatureRegistry';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IPosition, Position } from 'vs/editor/common/core/position';
|
||||
import { isNonEmptyArray } from 'vs/base/common/arrays';
|
||||
import { onUnexpectedExternalError } from 'vs/base/common/errors';
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
|
||||
import { assertType } from 'vs/base/common/types';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
|
||||
export const enum CallHierarchyDirection {
|
||||
CallsTo = 'incomingCalls',
|
||||
CallsFrom = 'outgoingCalls'
|
||||
}
|
||||
|
||||
export interface CallHierarchyItem {
|
||||
_sessionId: string;
|
||||
_itemId: string;
|
||||
kind: SymbolKind;
|
||||
name: string;
|
||||
detail?: string;
|
||||
uri: URI;
|
||||
range: IRange;
|
||||
selectionRange: IRange;
|
||||
tags?: SymbolTag[]
|
||||
}
|
||||
|
||||
export interface IncomingCall {
|
||||
from: CallHierarchyItem;
|
||||
fromRanges: IRange[];
|
||||
}
|
||||
|
||||
export interface OutgoingCall {
|
||||
fromRanges: IRange[];
|
||||
to: CallHierarchyItem;
|
||||
}
|
||||
|
||||
export interface CallHierarchySession {
|
||||
roots: CallHierarchyItem[];
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface CallHierarchyProvider {
|
||||
|
||||
prepareCallHierarchy(document: ITextModel, position: IPosition, token: CancellationToken): ProviderResult<CallHierarchySession>;
|
||||
|
||||
provideIncomingCalls(item: CallHierarchyItem, token: CancellationToken): ProviderResult<IncomingCall[]>;
|
||||
|
||||
provideOutgoingCalls(item: CallHierarchyItem, token: CancellationToken): ProviderResult<OutgoingCall[]>;
|
||||
}
|
||||
|
||||
export const CallHierarchyProviderRegistry = new LanguageFeatureRegistry<CallHierarchyProvider>();
|
||||
|
||||
|
||||
class RefCountedDisposabled {
|
||||
|
||||
constructor(
|
||||
private readonly _disposable: IDisposable,
|
||||
private _counter = 1
|
||||
) { }
|
||||
|
||||
acquire() {
|
||||
this._counter++;
|
||||
return this;
|
||||
}
|
||||
|
||||
release() {
|
||||
if (--this._counter === 0) {
|
||||
this._disposable.dispose();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class CallHierarchyModel {
|
||||
|
||||
static async create(model: ITextModel, position: IPosition, token: CancellationToken): Promise<CallHierarchyModel | undefined> {
|
||||
const [provider] = CallHierarchyProviderRegistry.ordered(model);
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const session = await provider.prepareCallHierarchy(model, position, token);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
return new CallHierarchyModel(session.roots.reduce((p, c) => p + c._sessionId, ''), provider, session.roots, new RefCountedDisposabled(session));
|
||||
}
|
||||
|
||||
readonly root: CallHierarchyItem;
|
||||
|
||||
private constructor(
|
||||
readonly id: string,
|
||||
readonly provider: CallHierarchyProvider,
|
||||
readonly roots: CallHierarchyItem[],
|
||||
readonly ref: RefCountedDisposabled,
|
||||
) {
|
||||
this.root = roots[0];
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.ref.release();
|
||||
}
|
||||
|
||||
fork(item: CallHierarchyItem): CallHierarchyModel {
|
||||
const that = this;
|
||||
return new class extends CallHierarchyModel {
|
||||
constructor() {
|
||||
super(that.id, that.provider, [item], that.ref.acquire());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async resolveIncomingCalls(item: CallHierarchyItem, token: CancellationToken): Promise<IncomingCall[]> {
|
||||
try {
|
||||
const result = await this.provider.provideIncomingCalls(item, token);
|
||||
if (isNonEmptyArray(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
onUnexpectedExternalError(e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async resolveOutgoingCalls(item: CallHierarchyItem, token: CancellationToken): Promise<OutgoingCall[]> {
|
||||
try {
|
||||
const result = await this.provider.provideOutgoingCalls(item, token);
|
||||
if (isNonEmptyArray(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
onUnexpectedExternalError(e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- API command support
|
||||
|
||||
const _models = new Map<string, CallHierarchyModel>();
|
||||
|
||||
CommandsRegistry.registerCommand('_executePrepareCallHierarchy', async (accessor, ...args) => {
|
||||
const [resource, position] = args;
|
||||
assertType(URI.isUri(resource));
|
||||
assertType(Position.isIPosition(position));
|
||||
|
||||
const modelService = accessor.get(IModelService);
|
||||
let textModel = modelService.getModel(resource);
|
||||
let textModelReference: IDisposable | undefined;
|
||||
if (!textModel) {
|
||||
const textModelService = accessor.get(ITextModelService);
|
||||
const result = await textModelService.createModelReference(resource);
|
||||
textModel = result.object.textEditorModel;
|
||||
textModelReference = result;
|
||||
}
|
||||
|
||||
try {
|
||||
const model = await CallHierarchyModel.create(textModel, position, CancellationToken.None);
|
||||
if (!model) {
|
||||
return [];
|
||||
}
|
||||
//
|
||||
_models.set(model.id, model);
|
||||
_models.forEach((value, key, map) => {
|
||||
if (map.size > 10) {
|
||||
value.dispose();
|
||||
_models.delete(key);
|
||||
}
|
||||
});
|
||||
return [model.root];
|
||||
|
||||
} finally {
|
||||
textModelReference?.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
function isCallHierarchyItemDto(obj: any): obj is CallHierarchyItem {
|
||||
return true;
|
||||
}
|
||||
|
||||
CommandsRegistry.registerCommand('_executeProvideIncomingCalls', async (_accessor, ...args) => {
|
||||
const [item] = args;
|
||||
assertType(isCallHierarchyItemDto(item));
|
||||
|
||||
// find model
|
||||
const model = _models.get(item._sessionId);
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return model.resolveIncomingCalls(item, CancellationToken.None);
|
||||
});
|
||||
|
||||
CommandsRegistry.registerCommand('_executeProvideOutgoingCalls', async (_accessor, ...args) => {
|
||||
const [item] = args;
|
||||
assertType(isCallHierarchyItemDto(item));
|
||||
|
||||
// find model
|
||||
const model = _models.get(item._sessionId);
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return model.resolveOutgoingCalls(item, CancellationToken.None);
|
||||
});
|
||||
197
lib/vscode/src/vs/workbench/contrib/cli/node/cli.contribution.ts
Normal file
197
lib/vscode/src/vs/workbench/contrib/cli/node/cli.contribution.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import * as cp from 'child_process';
|
||||
import * as pfs from 'vs/base/node/pfs';
|
||||
import * as extpath from 'vs/base/node/extpath';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { promisify } from 'util';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/workbench/common/actions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import product from 'vs/platform/product/common/product';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import Severity from 'vs/base/common/severity';
|
||||
import { ILogService } from 'vs/platform/log/common/log';
|
||||
import { FileAccess } from 'vs/base/common/network';
|
||||
import { IProductService } from 'vs/platform/product/common/productService';
|
||||
|
||||
function ignore<T>(code: string, value: T): (err: any) => Promise<T> {
|
||||
return err => err.code === code ? Promise.resolve<T>(value) : Promise.reject<T>(err);
|
||||
}
|
||||
|
||||
let _source: string | null = null;
|
||||
function getSource(): string {
|
||||
if (!_source) {
|
||||
const root = FileAccess.asFileUri('', require).fsPath;
|
||||
_source = path.resolve(root, '..', 'bin', 'code');
|
||||
}
|
||||
return _source;
|
||||
}
|
||||
|
||||
function isAvailable(): Promise<boolean> {
|
||||
return Promise.resolve(pfs.exists(getSource()));
|
||||
}
|
||||
|
||||
class InstallAction extends Action {
|
||||
|
||||
static readonly ID = 'workbench.action.installCommandLine';
|
||||
static readonly LABEL = nls.localize('install', "Install '{0}' command in PATH", product.applicationName);
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@INotificationService private readonly notificationService: INotificationService,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IProductService private readonly productService: IProductService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
private get target(): string {
|
||||
return `/usr/local/bin/${this.productService.applicationName}`;
|
||||
}
|
||||
|
||||
run(): Promise<void> {
|
||||
return isAvailable().then(isAvailable => {
|
||||
if (!isAvailable) {
|
||||
const message = nls.localize('not available', "This command is not available");
|
||||
this.notificationService.info(message);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.isInstalled()
|
||||
.then(isInstalled => {
|
||||
if (!isAvailable || isInstalled) {
|
||||
return Promise.resolve(null);
|
||||
} else {
|
||||
return pfs.unlink(this.target)
|
||||
.then(undefined, ignore('ENOENT', null))
|
||||
.then(() => pfs.symlink(getSource(), this.target))
|
||||
.then(undefined, err => {
|
||||
if (err.code === 'EACCES' || err.code === 'ENOENT') {
|
||||
return this.createBinFolderAndSymlinkAsAdmin();
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.logService.trace('cli#install', this.target);
|
||||
this.notificationService.info(nls.localize('successIn', "Shell command '{0}' successfully installed in PATH.", this.productService.applicationName));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isInstalled(): Promise<boolean> {
|
||||
return pfs.lstat(this.target)
|
||||
.then(stat => stat.isSymbolicLink())
|
||||
.then(() => extpath.realpath(this.target))
|
||||
.then(link => link === getSource())
|
||||
.then(undefined, ignore('ENOENT', false));
|
||||
}
|
||||
|
||||
private createBinFolderAndSymlinkAsAdmin(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const buttons = [nls.localize('ok', "OK"), nls.localize('cancel2', "Cancel")];
|
||||
|
||||
this.dialogService.show(Severity.Info, nls.localize('warnEscalation', "Code will now prompt with 'osascript' for Administrator privileges to install the shell command."), buttons, { cancelId: 1 }).then(result => {
|
||||
switch (result.choice) {
|
||||
case 0 /* OK */:
|
||||
const command = 'osascript -e "do shell script \\"mkdir -p /usr/local/bin && ln -sf \'' + getSource() + '\' \'' + this.target + '\'\\" with administrator privileges"';
|
||||
|
||||
promisify(cp.exec)(command, {})
|
||||
.then(undefined, _ => Promise.reject(new Error(nls.localize('cantCreateBinFolder', "Unable to create '/usr/local/bin'."))))
|
||||
.then(() => resolve(), reject);
|
||||
break;
|
||||
case 1 /* Cancel */:
|
||||
reject(new Error(nls.localize('aborted', "Aborted")));
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class UninstallAction extends Action {
|
||||
|
||||
static readonly ID = 'workbench.action.uninstallCommandLine';
|
||||
static readonly LABEL = nls.localize('uninstall', "Uninstall '{0}' command from PATH", product.applicationName);
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@INotificationService private readonly notificationService: INotificationService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@IProductService private readonly productService: IProductService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
private get target(): string {
|
||||
return `/usr/local/bin/${this.productService.applicationName}`;
|
||||
}
|
||||
|
||||
run(): Promise<void> {
|
||||
return isAvailable().then(isAvailable => {
|
||||
if (!isAvailable) {
|
||||
const message = nls.localize('not available', "This command is not available");
|
||||
this.notificationService.info(message);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uninstall = () => {
|
||||
return pfs.unlink(this.target)
|
||||
.then(undefined, ignore('ENOENT', null));
|
||||
};
|
||||
|
||||
return uninstall().then(undefined, err => {
|
||||
if (err.code === 'EACCES') {
|
||||
return this.deleteSymlinkAsAdmin();
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}).then(() => {
|
||||
this.logService.trace('cli#uninstall', this.target);
|
||||
this.notificationService.info(nls.localize('successFrom', "Shell command '{0}' successfully uninstalled from PATH.", this.productService.applicationName));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private deleteSymlinkAsAdmin(): Promise<void> {
|
||||
return new Promise<void>(async (resolve, reject) => {
|
||||
const buttons = [nls.localize('ok', "OK"), nls.localize('cancel2', "Cancel")];
|
||||
|
||||
const { choice } = await this.dialogService.show(Severity.Info, nls.localize('warnEscalationUninstall', "Code will now prompt with 'osascript' for Administrator privileges to uninstall the shell command."), buttons, { cancelId: 1 });
|
||||
switch (choice) {
|
||||
case 0 /* OK */:
|
||||
const command = 'osascript -e "do shell script \\"rm \'' + this.target + '\'\\" with administrator privileges"';
|
||||
|
||||
promisify(cp.exec)(command, {})
|
||||
.then(undefined, _ => Promise.reject(new Error(nls.localize('cantUninstall', "Unable to uninstall the shell command '{0}'.", this.target))))
|
||||
.then(() => resolve(), reject);
|
||||
break;
|
||||
case 1 /* Cancel */:
|
||||
reject(new Error(nls.localize('aborted', "Aborted")));
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.isMacintosh) {
|
||||
const category = nls.localize('shellCommand', "Shell Command");
|
||||
|
||||
const workbenchActionsRegistry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
workbenchActionsRegistry.registerWorkbenchAction(SyncActionDescriptor.from(InstallAction), `Shell Command: Install \'${product.applicationName}\' command in PATH`, category);
|
||||
workbenchActionsRegistry.registerWorkbenchAction(SyncActionDescriptor.from(UninstallAction), `Shell Command: Uninstall \'${product.applicationName}\' command from PATH`, category);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { CodeActionsContribution, editorConfiguration } from 'vs/workbench/contrib/codeActions/common/codeActionsContribution';
|
||||
import { CodeActionsExtensionPoint, codeActionsExtensionPointDescriptor } from 'vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint';
|
||||
import { CodeActionDocumentationContribution } from 'vs/workbench/contrib/codeActions/common/documentationContribution';
|
||||
import { DocumentationExtensionPoint, documentationExtensionPointDescriptor } from 'vs/workbench/contrib/codeActions/common/documentationExtensionPoint';
|
||||
import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||
|
||||
const codeActionsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<CodeActionsExtensionPoint[]>(codeActionsExtensionPointDescriptor);
|
||||
const documentationExtensionPoint = ExtensionsRegistry.registerExtensionPoint<DocumentationExtensionPoint>(documentationExtensionPointDescriptor);
|
||||
|
||||
Registry.as<IConfigurationRegistry>(Extensions.Configuration)
|
||||
.registerConfiguration(editorConfiguration);
|
||||
|
||||
class WorkbenchConfigurationContribution {
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
) {
|
||||
instantiationService.createInstance(CodeActionsContribution, codeActionsExtensionPoint);
|
||||
instantiationService.createInstance(CodeActionDocumentationContribution, documentationExtensionPoint);
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench)
|
||||
.registerWorkbenchContribution(WorkbenchConfigurationContribution, LifecyclePhase.Eventually);
|
||||
@@ -0,0 +1,155 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { flatten } from 'vs/base/common/arrays';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { IJSONSchema, IJSONSchemaMap } from 'vs/base/common/jsonSchema';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { codeActionCommandId, refactorCommandId, sourceActionCommandId } from 'vs/editor/contrib/codeAction/codeAction';
|
||||
import { CodeActionKind } from 'vs/editor/contrib/codeAction/types';
|
||||
import * as nls from 'vs/nls';
|
||||
import { Extensions, IConfigurationNode, IConfigurationRegistry, ConfigurationScope, IConfigurationPropertySchema } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { CodeActionsExtensionPoint, ContributedCodeAction } from 'vs/workbench/contrib/codeActions/common/codeActionsExtensionPoint';
|
||||
import { IExtensionPoint } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||
import { editorConfigurationBaseNode } from 'vs/editor/common/config/commonEditorConfig';
|
||||
|
||||
const codeActionsOnSaveDefaultProperties = Object.freeze<IJSONSchemaMap>({
|
||||
'source.fixAll': {
|
||||
type: 'boolean',
|
||||
description: nls.localize('codeActionsOnSave.fixAll', "Controls whether auto fix action should be run on file save.")
|
||||
}
|
||||
});
|
||||
|
||||
const codeActionsOnSaveSchema: IConfigurationPropertySchema = {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: codeActionsOnSaveDefaultProperties,
|
||||
additionalProperties: {
|
||||
type: 'boolean'
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' }
|
||||
}
|
||||
],
|
||||
default: {},
|
||||
description: nls.localize('codeActionsOnSave', "Code action kinds to be run on save."),
|
||||
scope: ConfigurationScope.LANGUAGE_OVERRIDABLE,
|
||||
};
|
||||
|
||||
export const editorConfiguration = Object.freeze<IConfigurationNode>({
|
||||
...editorConfigurationBaseNode,
|
||||
properties: {
|
||||
'editor.codeActionsOnSave': codeActionsOnSaveSchema
|
||||
}
|
||||
});
|
||||
|
||||
export class CodeActionsContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
private _contributedCodeActions: CodeActionsExtensionPoint[] = [];
|
||||
|
||||
private readonly _onDidChangeContributions = this._register(new Emitter<void>());
|
||||
|
||||
constructor(
|
||||
codeActionsExtensionPoint: IExtensionPoint<CodeActionsExtensionPoint[]>,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
) {
|
||||
super();
|
||||
|
||||
codeActionsExtensionPoint.setHandler(extensionPoints => {
|
||||
this._contributedCodeActions = flatten(extensionPoints.map(x => x.value));
|
||||
this.updateConfigurationSchema(this._contributedCodeActions);
|
||||
this._onDidChangeContributions.fire();
|
||||
});
|
||||
|
||||
keybindingService.registerSchemaContribution({
|
||||
getSchemaAdditions: () => this.getSchemaAdditions(),
|
||||
onDidChange: this._onDidChangeContributions.event,
|
||||
});
|
||||
}
|
||||
|
||||
private updateConfigurationSchema(codeActionContributions: readonly CodeActionsExtensionPoint[]) {
|
||||
const newProperties: IJSONSchemaMap = { ...codeActionsOnSaveDefaultProperties };
|
||||
for (const [sourceAction, props] of this.getSourceActions(codeActionContributions)) {
|
||||
newProperties[sourceAction] = {
|
||||
type: 'boolean',
|
||||
description: nls.localize('codeActionsOnSave.generic', "Controls whether '{0}' actions should be run on file save.", props.title)
|
||||
};
|
||||
}
|
||||
codeActionsOnSaveSchema.properties = newProperties;
|
||||
Registry.as<IConfigurationRegistry>(Extensions.Configuration)
|
||||
.notifyConfigurationSchemaUpdated(editorConfiguration);
|
||||
}
|
||||
|
||||
private getSourceActions(contributions: readonly CodeActionsExtensionPoint[]) {
|
||||
const defaultKinds = Object.keys(codeActionsOnSaveDefaultProperties).map(value => new CodeActionKind(value));
|
||||
const sourceActions = new Map<string, { readonly title: string }>();
|
||||
for (const contribution of contributions) {
|
||||
for (const action of contribution.actions) {
|
||||
const kind = new CodeActionKind(action.kind);
|
||||
if (CodeActionKind.Source.contains(kind)
|
||||
// Exclude any we already included by default
|
||||
&& !defaultKinds.some(defaultKind => defaultKind.contains(kind))
|
||||
) {
|
||||
sourceActions.set(kind.value, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
return sourceActions;
|
||||
}
|
||||
|
||||
private getSchemaAdditions(): IJSONSchema[] {
|
||||
const conditionalSchema = (command: string, actions: readonly ContributedCodeAction[]): IJSONSchema => {
|
||||
return {
|
||||
if: {
|
||||
properties: {
|
||||
'command': { const: command }
|
||||
}
|
||||
},
|
||||
then: {
|
||||
properties: {
|
||||
'args': {
|
||||
required: ['kind'],
|
||||
properties: {
|
||||
'kind': {
|
||||
anyOf: [
|
||||
{
|
||||
enum: actions.map(action => action.kind),
|
||||
enumDescriptions: actions.map(action => action.description ?? action.title),
|
||||
},
|
||||
{ type: 'string' },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getActions = (ofKind: CodeActionKind): ContributedCodeAction[] => {
|
||||
const allActions = flatten(this._contributedCodeActions.map(desc => desc.actions.slice()));
|
||||
|
||||
const out = new Map<string, ContributedCodeAction>();
|
||||
for (const action of allActions) {
|
||||
if (!out.has(action.kind) && ofKind.contains(new CodeActionKind(action.kind))) {
|
||||
out.set(action.kind, action);
|
||||
}
|
||||
}
|
||||
return Array.from(out.values());
|
||||
};
|
||||
|
||||
return [
|
||||
conditionalSchema(codeActionCommandId, getActions(CodeActionKind.Empty)),
|
||||
conditionalSchema(refactorCommandId, getActions(CodeActionKind.Refactor)),
|
||||
conditionalSchema(sourceActionCommandId, getActions(CodeActionKind.Source)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { IConfigurationPropertySchema } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
import { languagesExtPoint } from 'vs/workbench/services/mode/common/workbenchModeService';
|
||||
|
||||
export enum CodeActionExtensionPointFields {
|
||||
languages = 'languages',
|
||||
actions = 'actions',
|
||||
kind = 'kind',
|
||||
title = 'title',
|
||||
description = 'description'
|
||||
}
|
||||
|
||||
export interface ContributedCodeAction {
|
||||
readonly [CodeActionExtensionPointFields.kind]: string;
|
||||
readonly [CodeActionExtensionPointFields.title]: string;
|
||||
readonly [CodeActionExtensionPointFields.description]?: string;
|
||||
}
|
||||
|
||||
export interface CodeActionsExtensionPoint {
|
||||
readonly [CodeActionExtensionPointFields.languages]: readonly string[];
|
||||
readonly [CodeActionExtensionPointFields.actions]: readonly ContributedCodeAction[];
|
||||
}
|
||||
|
||||
const codeActionsExtensionPointSchema = Object.freeze<IConfigurationPropertySchema>({
|
||||
type: 'array',
|
||||
markdownDescription: nls.localize('contributes.codeActions', "Configure which editor to use for a resource."),
|
||||
items: {
|
||||
type: 'object',
|
||||
required: [CodeActionExtensionPointFields.languages, CodeActionExtensionPointFields.actions],
|
||||
properties: {
|
||||
[CodeActionExtensionPointFields.languages]: {
|
||||
type: 'array',
|
||||
description: nls.localize('contributes.codeActions.languages', "Language modes that the code actions are enabled for."),
|
||||
items: { type: 'string' }
|
||||
},
|
||||
[CodeActionExtensionPointFields.actions]: {
|
||||
type: 'object',
|
||||
required: [CodeActionExtensionPointFields.kind, CodeActionExtensionPointFields.title],
|
||||
properties: {
|
||||
[CodeActionExtensionPointFields.kind]: {
|
||||
type: 'string',
|
||||
markdownDescription: nls.localize('contributes.codeActions.kind', "`CodeActionKind` of the contributed code action."),
|
||||
},
|
||||
[CodeActionExtensionPointFields.title]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.codeActions.title', "Label for the code action used in the UI."),
|
||||
},
|
||||
[CodeActionExtensionPointFields.description]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.codeActions.description', "Description of what the code action does."),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const codeActionsExtensionPointDescriptor = {
|
||||
extensionPoint: 'codeActions',
|
||||
deps: [languagesExtPoint],
|
||||
jsonSchema: codeActionsExtensionPointSchema
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import * as modes from 'vs/editor/common/modes';
|
||||
import { CodeActionKind } from 'vs/editor/contrib/codeAction/types';
|
||||
import { ContextKeyExpr, IContextKeyService, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { IExtensionPoint } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||
import { DocumentationExtensionPoint } from './documentationExtensionPoint';
|
||||
|
||||
|
||||
export class CodeActionDocumentationContribution extends Disposable implements IWorkbenchContribution, modes.CodeActionProvider {
|
||||
|
||||
private contributions: {
|
||||
title: string;
|
||||
when: ContextKeyExpression;
|
||||
command: string;
|
||||
}[] = [];
|
||||
|
||||
private readonly emptyCodeActionsList = {
|
||||
actions: [],
|
||||
dispose: () => { }
|
||||
};
|
||||
|
||||
constructor(
|
||||
extensionPoint: IExtensionPoint<DocumentationExtensionPoint>,
|
||||
@IContextKeyService private readonly contextKeyService: IContextKeyService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(modes.CodeActionProviderRegistry.register('*', this));
|
||||
|
||||
extensionPoint.setHandler(points => {
|
||||
this.contributions = [];
|
||||
for (const documentation of points) {
|
||||
if (!documentation.value.refactoring) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const contribution of documentation.value.refactoring) {
|
||||
const precondition = ContextKeyExpr.deserialize(contribution.when);
|
||||
if (!precondition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.contributions.push({
|
||||
title: contribution.title,
|
||||
when: precondition,
|
||||
command: contribution.command
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async provideCodeActions(_model: ITextModel, _range: Range | Selection, context: modes.CodeActionContext, _token: CancellationToken): Promise<modes.CodeActionList> {
|
||||
return this.emptyCodeActionsList;
|
||||
}
|
||||
|
||||
public _getAdditionalMenuItems(context: modes.CodeActionContext, actions: readonly modes.CodeAction[]): modes.Command[] {
|
||||
if (context.only !== CodeActionKind.Refactor.value) {
|
||||
if (!actions.some(action => action.kind && CodeActionKind.Refactor.contains(new CodeActionKind(action.kind)))) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return this.contributions
|
||||
.filter(contribution => this.contextKeyService.contextMatchesRules(contribution.when))
|
||||
.map(contribution => {
|
||||
return {
|
||||
id: contribution.command,
|
||||
title: contribution.title
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { IConfigurationPropertySchema } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
import { languagesExtPoint } from 'vs/workbench/services/mode/common/workbenchModeService';
|
||||
|
||||
export enum DocumentationExtensionPointFields {
|
||||
when = 'when',
|
||||
title = 'title',
|
||||
command = 'command',
|
||||
}
|
||||
|
||||
export interface RefactoringDocumentationExtensionPoint {
|
||||
readonly [DocumentationExtensionPointFields.title]: string;
|
||||
readonly [DocumentationExtensionPointFields.when]: string;
|
||||
readonly [DocumentationExtensionPointFields.command]: string;
|
||||
}
|
||||
|
||||
export interface DocumentationExtensionPoint {
|
||||
readonly refactoring?: readonly RefactoringDocumentationExtensionPoint[];
|
||||
}
|
||||
|
||||
const documentationExtensionPointSchema = Object.freeze<IConfigurationPropertySchema>({
|
||||
type: 'object',
|
||||
description: nls.localize('contributes.documentation', "Contributed documentation."),
|
||||
properties: {
|
||||
'refactoring': {
|
||||
type: 'array',
|
||||
description: nls.localize('contributes.documentation.refactorings', "Contributed documentation for refactorings."),
|
||||
items: {
|
||||
type: 'object',
|
||||
description: nls.localize('contributes.documentation.refactoring', "Contributed documentation for refactoring."),
|
||||
required: [
|
||||
DocumentationExtensionPointFields.title,
|
||||
DocumentationExtensionPointFields.when,
|
||||
DocumentationExtensionPointFields.command
|
||||
],
|
||||
properties: {
|
||||
[DocumentationExtensionPointFields.title]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.documentation.refactoring.title', "Label for the documentation used in the UI."),
|
||||
},
|
||||
[DocumentationExtensionPointFields.when]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.documentation.refactoring.when', "When clause."),
|
||||
},
|
||||
[DocumentationExtensionPointFields.command]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.documentation.refactoring.command', "Command executed."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const documentationExtensionPointDescriptor = {
|
||||
extensionPoint: 'documentation',
|
||||
deps: [languagesExtPoint],
|
||||
jsonSchema: documentationExtensionPointSchema
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-editor .accessibilityHelpWidget {
|
||||
padding: 10px;
|
||||
vertical-align: middle;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./accessibility';
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode';
|
||||
import { renderFormattedText } from 'vs/base/browser/formattedTextRenderer';
|
||||
import { alert } from 'vs/base/browser/ui/aria/aria';
|
||||
import { Widget } from 'vs/base/browser/ui/widget';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorCommand, registerEditorContribution, registerEditorCommand } from 'vs/editor/browser/editorExtensions';
|
||||
import { IEditorOptions, EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
|
||||
import { ToggleTabFocusModeAction } from 'vs/editor/contrib/toggleTabFocusMode/toggleTabFocusMode';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { contrastBorder, editorWidgetBackground, widgetShadow, editorWidgetForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { AccessibilitySupport } from 'vs/platform/accessibility/common/accessibility';
|
||||
import { Action2, registerAction2 } from 'vs/platform/actions/common/actions';
|
||||
import { ICommandService } from 'vs/platform/commands/common/commands';
|
||||
import { NEW_UNTITLED_FILE_COMMAND_ID } from 'vs/workbench/contrib/files/browser/fileCommands';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
|
||||
const CONTEXT_ACCESSIBILITY_WIDGET_VISIBLE = new RawContextKey<boolean>('accessibilityHelpWidgetVisible', false);
|
||||
|
||||
class AccessibilityHelpController extends Disposable implements IEditorContribution {
|
||||
|
||||
public static readonly ID = 'editor.contrib.accessibilityHelpController';
|
||||
|
||||
public static get(editor: ICodeEditor): AccessibilityHelpController {
|
||||
return editor.getContribution<AccessibilityHelpController>(AccessibilityHelpController.ID);
|
||||
}
|
||||
|
||||
private _editor: ICodeEditor;
|
||||
private _widget: AccessibilityHelpWidget;
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
@IInstantiationService instantiationService: IInstantiationService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._editor = editor;
|
||||
this._widget = this._register(instantiationService.createInstance(AccessibilityHelpWidget, this._editor));
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
this._widget.show();
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this._widget.hide();
|
||||
}
|
||||
}
|
||||
|
||||
class AccessibilityHelpWidget extends Widget implements IOverlayWidget {
|
||||
|
||||
private static readonly ID = 'editor.contrib.accessibilityHelpWidget';
|
||||
private static readonly WIDTH = 500;
|
||||
private static readonly HEIGHT = 300;
|
||||
|
||||
private _editor: ICodeEditor;
|
||||
private _domNode: FastDomNode<HTMLElement>;
|
||||
private _contentDomNode: FastDomNode<HTMLElement>;
|
||||
private _isVisible: boolean;
|
||||
private _isVisibleKey: IContextKey<boolean>;
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
|
||||
@IKeybindingService private readonly _keybindingService: IKeybindingService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IOpenerService private readonly _openerService: IOpenerService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._editor = editor;
|
||||
this._isVisibleKey = CONTEXT_ACCESSIBILITY_WIDGET_VISIBLE.bindTo(this._contextKeyService);
|
||||
|
||||
this._domNode = createFastDomNode(document.createElement('div'));
|
||||
this._domNode.setClassName('accessibilityHelpWidget');
|
||||
this._domNode.setWidth(AccessibilityHelpWidget.WIDTH);
|
||||
this._domNode.setHeight(AccessibilityHelpWidget.HEIGHT);
|
||||
this._domNode.setDisplay('none');
|
||||
this._domNode.setAttribute('role', 'dialog');
|
||||
this._domNode.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this._contentDomNode = createFastDomNode(document.createElement('div'));
|
||||
this._contentDomNode.setAttribute('role', 'document');
|
||||
this._domNode.appendChild(this._contentDomNode);
|
||||
|
||||
this._isVisible = false;
|
||||
|
||||
this._register(this._editor.onDidLayoutChange(() => {
|
||||
if (this._isVisible) {
|
||||
this._layout();
|
||||
}
|
||||
}));
|
||||
|
||||
// Intentionally not configurable!
|
||||
this._register(dom.addStandardDisposableListener(this._contentDomNode.domNode, 'keydown', (e) => {
|
||||
if (!this._isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.equals(KeyMod.CtrlCmd | KeyCode.KEY_E)) {
|
||||
alert(nls.localize('emergencyConfOn', "Now changing the setting `editor.accessibilitySupport` to 'on'."));
|
||||
|
||||
this._configurationService.updateValue('editor.accessibilitySupport', 'on', ConfigurationTarget.USER);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (e.equals(KeyMod.CtrlCmd | KeyCode.KEY_H)) {
|
||||
alert(nls.localize('openingDocs', "Now opening the VS Code Accessibility documentation page."));
|
||||
|
||||
this._openerService.open(URI.parse('https://go.microsoft.com/fwlink/?linkid=851010'));
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}));
|
||||
|
||||
this.onblur(this._contentDomNode.domNode, () => {
|
||||
this.hide();
|
||||
});
|
||||
|
||||
this._editor.addOverlayWidget(this);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._editor.removeOverlayWidget(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public getId(): string {
|
||||
return AccessibilityHelpWidget.ID;
|
||||
}
|
||||
|
||||
public getDomNode(): HTMLElement {
|
||||
return this._domNode.domNode;
|
||||
}
|
||||
|
||||
public getPosition(): IOverlayWidgetPosition {
|
||||
return {
|
||||
preference: null
|
||||
};
|
||||
}
|
||||
|
||||
public show(): void {
|
||||
if (this._isVisible) {
|
||||
return;
|
||||
}
|
||||
this._isVisible = true;
|
||||
this._isVisibleKey.set(true);
|
||||
this._layout();
|
||||
this._domNode.setDisplay('block');
|
||||
this._domNode.setAttribute('aria-hidden', 'false');
|
||||
this._contentDomNode.domNode.tabIndex = 0;
|
||||
this._buildContent();
|
||||
this._contentDomNode.domNode.focus();
|
||||
}
|
||||
|
||||
private _descriptionForCommand(commandId: string, msg: string, noKbMsg: string): string {
|
||||
let kb = this._keybindingService.lookupKeybinding(commandId);
|
||||
if (kb) {
|
||||
return strings.format(msg, kb.getAriaLabel());
|
||||
}
|
||||
return strings.format(noKbMsg, commandId);
|
||||
}
|
||||
|
||||
private _buildContent() {
|
||||
const options = this._editor.getOptions();
|
||||
let text = nls.localize('introMsg', "Thank you for trying out VS Code's accessibility options.");
|
||||
|
||||
text += '\n\n' + nls.localize('status', "Status:");
|
||||
|
||||
const configuredValue = this._configurationService.getValue<IEditorOptions>('editor').accessibilitySupport;
|
||||
const actualValue = options.get(EditorOption.accessibilitySupport);
|
||||
|
||||
const emergencyTurnOnMessage = (
|
||||
platform.isMacintosh
|
||||
? nls.localize('changeConfigToOnMac', "To configure the editor to be permanently optimized for usage with a Screen Reader press Command+E now.")
|
||||
: nls.localize('changeConfigToOnWinLinux', "To configure the editor to be permanently optimized for usage with a Screen Reader press Control+E now.")
|
||||
);
|
||||
|
||||
switch (configuredValue) {
|
||||
case 'auto':
|
||||
switch (actualValue) {
|
||||
case AccessibilitySupport.Unknown:
|
||||
// Should never happen in VS Code
|
||||
text += '\n\n - ' + nls.localize('auto_unknown', "The editor is configured to use platform APIs to detect when a Screen Reader is attached, but the current runtime does not support this.");
|
||||
break;
|
||||
case AccessibilitySupport.Enabled:
|
||||
text += '\n\n - ' + nls.localize('auto_on', "The editor has automatically detected a Screen Reader is attached.");
|
||||
break;
|
||||
case AccessibilitySupport.Disabled:
|
||||
text += '\n\n - ' + nls.localize('auto_off', "The editor is configured to automatically detect when a Screen Reader is attached, which is not the case at this time.");
|
||||
text += ' ' + emergencyTurnOnMessage;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'on':
|
||||
text += '\n\n - ' + nls.localize('configuredOn', "The editor is configured to be permanently optimized for usage with a Screen Reader - you can change this by editing the setting `editor.accessibilitySupport`.");
|
||||
break;
|
||||
case 'off':
|
||||
text += '\n\n - ' + nls.localize('configuredOff', "The editor is configured to never be optimized for usage with a Screen Reader.");
|
||||
text += ' ' + emergencyTurnOnMessage;
|
||||
break;
|
||||
}
|
||||
|
||||
const NLS_TAB_FOCUS_MODE_ON = nls.localize('tabFocusModeOnMsg', "Pressing Tab in the current editor will move focus to the next focusable element. Toggle this behavior by pressing {0}.");
|
||||
const NLS_TAB_FOCUS_MODE_ON_NO_KB = nls.localize('tabFocusModeOnMsgNoKb', "Pressing Tab in the current editor will move focus to the next focusable element. The command {0} is currently not triggerable by a keybinding.");
|
||||
const NLS_TAB_FOCUS_MODE_OFF = nls.localize('tabFocusModeOffMsg', "Pressing Tab in the current editor will insert the tab character. Toggle this behavior by pressing {0}.");
|
||||
const NLS_TAB_FOCUS_MODE_OFF_NO_KB = nls.localize('tabFocusModeOffMsgNoKb', "Pressing Tab in the current editor will insert the tab character. The command {0} is currently not triggerable by a keybinding.");
|
||||
|
||||
if (options.get(EditorOption.tabFocusMode)) {
|
||||
text += '\n\n - ' + this._descriptionForCommand(ToggleTabFocusModeAction.ID, NLS_TAB_FOCUS_MODE_ON, NLS_TAB_FOCUS_MODE_ON_NO_KB);
|
||||
} else {
|
||||
text += '\n\n - ' + this._descriptionForCommand(ToggleTabFocusModeAction.ID, NLS_TAB_FOCUS_MODE_OFF, NLS_TAB_FOCUS_MODE_OFF_NO_KB);
|
||||
}
|
||||
|
||||
const openDocMessage = (
|
||||
platform.isMacintosh
|
||||
? nls.localize('openDocMac', "Press Command+H now to open a browser window with more VS Code information related to Accessibility.")
|
||||
: nls.localize('openDocWinLinux', "Press Control+H now to open a browser window with more VS Code information related to Accessibility.")
|
||||
);
|
||||
|
||||
text += '\n\n' + openDocMessage;
|
||||
|
||||
text += '\n\n' + nls.localize('outroMsg', "You can dismiss this tooltip and return to the editor by pressing Escape or Shift+Escape.");
|
||||
|
||||
this._contentDomNode.domNode.appendChild(renderFormattedText(text));
|
||||
// Per https://www.w3.org/TR/wai-aria/roles#document, Authors SHOULD provide a title or label for documents
|
||||
this._contentDomNode.domNode.setAttribute('aria-label', text);
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
if (!this._isVisible) {
|
||||
return;
|
||||
}
|
||||
this._isVisible = false;
|
||||
this._isVisibleKey.reset();
|
||||
this._domNode.setDisplay('none');
|
||||
this._domNode.setAttribute('aria-hidden', 'true');
|
||||
this._contentDomNode.domNode.tabIndex = -1;
|
||||
dom.clearNode(this._contentDomNode.domNode);
|
||||
|
||||
this._editor.focus();
|
||||
}
|
||||
|
||||
private _layout(): void {
|
||||
let editorLayout = this._editor.getLayoutInfo();
|
||||
|
||||
const width = Math.min(editorLayout.width - 40, AccessibilityHelpWidget.WIDTH);
|
||||
const height = Math.min(editorLayout.height - 40, AccessibilityHelpWidget.HEIGHT);
|
||||
|
||||
this._domNode.setTop(Math.round((editorLayout.height - height) / 2));
|
||||
this._domNode.setLeft(Math.round((editorLayout.width - width) / 2));
|
||||
this._domNode.setWidth(width);
|
||||
this._domNode.setHeight(height);
|
||||
}
|
||||
}
|
||||
|
||||
// Show Accessibility Help is a workench command so it can also be shown when there is no editor open #108850
|
||||
class ShowAccessibilityHelpAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.action.showAccessibilityHelp',
|
||||
title: { value: nls.localize('ShowAccessibilityHelpAction', "Show Accessibility Help"), original: 'Show Accessibility Help' },
|
||||
f1: true,
|
||||
keybinding: {
|
||||
primary: KeyMod.Alt | KeyCode.F1,
|
||||
weight: KeybindingWeight.EditorContrib,
|
||||
linux: {
|
||||
primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F1,
|
||||
secondary: [KeyMod.Alt | KeyCode.F1]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const editorService = accessor.get(ICodeEditorService);
|
||||
let activeEditor = editorService.getActiveCodeEditor();
|
||||
if (!activeEditor) {
|
||||
await commandService.executeCommand(NEW_UNTITLED_FILE_COMMAND_ID);
|
||||
}
|
||||
activeEditor = editorService.getActiveCodeEditor();
|
||||
|
||||
if (activeEditor) {
|
||||
const controller = AccessibilityHelpController.get(activeEditor);
|
||||
if (controller) {
|
||||
controller.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(AccessibilityHelpController.ID, AccessibilityHelpController);
|
||||
registerAction2(ShowAccessibilityHelpAction);
|
||||
|
||||
const AccessibilityHelpCommand = EditorCommand.bindToContribution<AccessibilityHelpController>(AccessibilityHelpController.get);
|
||||
|
||||
registerEditorCommand(new AccessibilityHelpCommand({
|
||||
id: 'closeAccessibilityHelp',
|
||||
precondition: CONTEXT_ACCESSIBILITY_WIDGET_VISIBLE,
|
||||
handler: x => x.hide(),
|
||||
kbOpts: {
|
||||
weight: KeybindingWeight.EditorContrib + 100,
|
||||
kbExpr: EditorContextKeys.focus,
|
||||
primary: KeyCode.Escape, secondary: [KeyMod.Shift | KeyCode.Escape]
|
||||
}
|
||||
}));
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const widgetBackground = theme.getColor(editorWidgetBackground);
|
||||
if (widgetBackground) {
|
||||
collector.addRule(`.monaco-editor .accessibilityHelpWidget { background-color: ${widgetBackground}; }`);
|
||||
}
|
||||
|
||||
const widgetForeground = theme.getColor(editorWidgetForeground);
|
||||
if (widgetBackground) {
|
||||
collector.addRule(`.monaco-editor .accessibilityHelpWidget { color: ${widgetForeground}; }`);
|
||||
}
|
||||
|
||||
const widgetShadowColor = theme.getColor(widgetShadow);
|
||||
if (widgetShadowColor) {
|
||||
collector.addRule(`.monaco-editor .accessibilityHelpWidget { box-shadow: 0 2px 8px ${widgetShadowColor}; }`);
|
||||
}
|
||||
|
||||
const hcBorder = theme.getColor(contrastBorder);
|
||||
if (hcBorder) {
|
||||
collector.addRule(`.monaco-editor .accessibilityHelpWidget { border: 2px solid ${hcBorder}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './menuPreventer';
|
||||
import './accessibility/accessibility';
|
||||
import './diffEditorHelper';
|
||||
import './inspectKeybindings';
|
||||
import './largeFileOptimizations';
|
||||
import './inspectEditorTokens/inspectEditorTokens';
|
||||
import './quickaccess/gotoLineQuickAccess';
|
||||
import './quickaccess/gotoSymbolQuickAccess';
|
||||
import './saveParticipants';
|
||||
import './toggleColumnSelection';
|
||||
import './toggleMinimap';
|
||||
import './toggleMultiCursorModifier';
|
||||
import './toggleRenderControlCharacter';
|
||||
import './toggleRenderWhitespace';
|
||||
import './toggleWordWrap';
|
||||
import './workbenchReferenceSearch';
|
||||
@@ -0,0 +1,106 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { IDiffEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { registerDiffEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { IDiffEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets';
|
||||
import { IDiffComputationResult } from 'vs/editor/common/services/editorWorkerService';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration';
|
||||
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
|
||||
|
||||
const enum WidgetState {
|
||||
Hidden,
|
||||
HintWhitespace
|
||||
}
|
||||
|
||||
class DiffEditorHelperContribution extends Disposable implements IDiffEditorContribution {
|
||||
|
||||
public static ID = 'editor.contrib.diffEditorHelper';
|
||||
|
||||
private _helperWidget: FloatingClickWidget | null;
|
||||
private _helperWidgetListener: IDisposable | null;
|
||||
private _state: WidgetState;
|
||||
|
||||
constructor(
|
||||
private readonly _diffEditor: IDiffEditor,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
) {
|
||||
super();
|
||||
this._helperWidget = null;
|
||||
this._helperWidgetListener = null;
|
||||
this._state = WidgetState.Hidden;
|
||||
|
||||
|
||||
this._register(this._diffEditor.onDidUpdateDiff(() => {
|
||||
const diffComputationResult = this._diffEditor.getDiffComputationResult();
|
||||
this._setState(this._deduceState(diffComputationResult));
|
||||
|
||||
if (diffComputationResult && diffComputationResult.quitEarly) {
|
||||
this._notificationService.prompt(
|
||||
Severity.Warning,
|
||||
nls.localize('hintTimeout', "The diff algorithm was stopped early (after {0} ms.)", this._diffEditor.maxComputationTime),
|
||||
[{
|
||||
label: nls.localize('removeTimeout', "Remove limit"),
|
||||
run: () => {
|
||||
this._configurationService.updateValue('diffEditor.maxComputationTime', 0, ConfigurationTarget.USER);
|
||||
}
|
||||
}],
|
||||
{}
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private _deduceState(diffComputationResult: IDiffComputationResult | null): WidgetState {
|
||||
if (!diffComputationResult) {
|
||||
return WidgetState.Hidden;
|
||||
}
|
||||
if (this._diffEditor.ignoreTrimWhitespace && diffComputationResult.changes.length === 0 && !diffComputationResult.identical) {
|
||||
return WidgetState.HintWhitespace;
|
||||
}
|
||||
return WidgetState.Hidden;
|
||||
}
|
||||
|
||||
private _setState(newState: WidgetState) {
|
||||
if (this._state === newState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._state = newState;
|
||||
|
||||
if (this._helperWidgetListener) {
|
||||
this._helperWidgetListener.dispose();
|
||||
this._helperWidgetListener = null;
|
||||
}
|
||||
if (this._helperWidget) {
|
||||
this._helperWidget.dispose();
|
||||
this._helperWidget = null;
|
||||
}
|
||||
|
||||
if (this._state === WidgetState.HintWhitespace) {
|
||||
this._helperWidget = this._instantiationService.createInstance(FloatingClickWidget, this._diffEditor.getModifiedEditor(), nls.localize('hintWhitespace', "Show Whitespace Differences"), null);
|
||||
this._helperWidgetListener = this._helperWidget.onClick(() => this._onDidClickHelperWidget());
|
||||
this._helperWidget.render();
|
||||
}
|
||||
}
|
||||
|
||||
private _onDidClickHelperWidget(): void {
|
||||
if (this._state === WidgetState.HintWhitespace) {
|
||||
this._configurationService.updateValue('diffEditor.ignoreTrimWhitespace', false, ConfigurationTarget.USER);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
registerDiffEditorContribution(DiffEditorHelperContribution.ID, DiffEditorHelperContribution);
|
||||
@@ -0,0 +1,120 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper {
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: -45px;
|
||||
right: 18px;
|
||||
width: 318px;
|
||||
max-width: calc(100% - 28px - 28px - 8px);
|
||||
pointer-events: none;
|
||||
transition: top 200ms linear;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part {
|
||||
/* visibility: hidden; Use visibility to maintain flex layout while hidden otherwise interferes with transition */
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
top: 0px;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
margin: 0 0 0 17px;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-replace-part {
|
||||
/* visibility: hidden; Use visibility to maintain flex layout while hidden otherwise interferes with transition */
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
top: 0px;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
margin: 0 0 0 17px;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress .monaco-progress-container {
|
||||
height: 2px;
|
||||
top: 0px !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .find-replace-progress .monaco-progress-container .progress-bit {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .monaco-findInput {
|
||||
width: 224px;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: initial;
|
||||
margin-left: 3px;
|
||||
background-position: 50%;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper.visible .simple-fr-find-part {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper .toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 18px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part-wrapper.visible-transition {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part .monaco-findInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part .button {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
flex: initial;
|
||||
margin-left: 3px;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-fr-find-part .button.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./simpleFindReplaceWidget';
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { FindInput, IFindInputStyles } from 'vs/base/browser/ui/findinput/findInput';
|
||||
import { Widget } from 'vs/base/browser/ui/widget';
|
||||
import { Delayer } from 'vs/base/common/async';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { FindReplaceState, FindReplaceStateChangedEvent } from 'vs/editor/contrib/find/findState';
|
||||
import { IMessage as InputBoxMessage } from 'vs/base/browser/ui/inputbox/inputBox';
|
||||
import { SimpleButton, findCloseIcon, findNextMatchIcon, findPreviousMatchIcon, findReplaceIcon, findReplaceAllIcon } from 'vs/editor/contrib/find/findWidget';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { editorWidgetBackground, inputActiveOptionBorder, inputActiveOptionBackground, inputActiveOptionForeground, inputBackground, inputBorder, inputForeground, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, inputValidationInfoBackground, inputValidationInfoBorder, inputValidationInfoForeground, inputValidationWarningBackground, inputValidationWarningBorder, inputValidationWarningForeground, widgetShadow, editorWidgetForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { IColorTheme, registerThemingParticipant, IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { ContextScopedFindInput, ContextScopedReplaceInput } from 'vs/platform/browser/contextScopedHistoryWidget';
|
||||
import { ReplaceInput, IReplaceInputStyles } from 'vs/base/browser/ui/findinput/replaceInput';
|
||||
import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar';
|
||||
import { attachProgressBarStyler } from 'vs/platform/theme/common/styler';
|
||||
|
||||
const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find");
|
||||
const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find");
|
||||
const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous match");
|
||||
const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next match");
|
||||
const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close");
|
||||
const NLS_TOGGLE_REPLACE_MODE_BTN_LABEL = nls.localize('label.toggleReplaceButton', "Toggle Replace mode");
|
||||
const NLS_REPLACE_INPUT_LABEL = nls.localize('label.replace', "Replace");
|
||||
const NLS_REPLACE_INPUT_PLACEHOLDER = nls.localize('placeholder.replace', "Replace");
|
||||
const NLS_REPLACE_BTN_LABEL = nls.localize('label.replaceButton', "Replace");
|
||||
const NLS_REPLACE_ALL_BTN_LABEL = nls.localize('label.replaceAllButton', "Replace All");
|
||||
|
||||
export abstract class SimpleFindReplaceWidget extends Widget {
|
||||
protected readonly _findInput: FindInput;
|
||||
private readonly _domNode: HTMLElement;
|
||||
private readonly _innerFindDomNode: HTMLElement;
|
||||
private readonly _focusTracker: dom.IFocusTracker;
|
||||
private readonly _findInputFocusTracker: dom.IFocusTracker;
|
||||
private readonly _updateHistoryDelayer: Delayer<void>;
|
||||
private readonly prevBtn: SimpleButton;
|
||||
private readonly nextBtn: SimpleButton;
|
||||
|
||||
protected readonly _replaceInput!: ReplaceInput;
|
||||
private readonly _innerReplaceDomNode!: HTMLElement;
|
||||
private _toggleReplaceBtn!: SimpleButton;
|
||||
private readonly _replaceInputFocusTracker!: dom.IFocusTracker;
|
||||
private _replaceBtn!: SimpleButton;
|
||||
private _replaceAllBtn!: SimpleButton;
|
||||
|
||||
|
||||
private _isVisible: boolean = false;
|
||||
private _isReplaceVisible: boolean = false;
|
||||
private foundMatch: boolean = false;
|
||||
|
||||
protected _progressBar!: ProgressBar;
|
||||
|
||||
|
||||
constructor(
|
||||
@IContextViewService private readonly _contextViewService: IContextViewService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IThemeService private readonly _themeService: IThemeService,
|
||||
protected readonly _state: FindReplaceState = new FindReplaceState(),
|
||||
showOptionButtons?: boolean
|
||||
) {
|
||||
super();
|
||||
|
||||
this._domNode = document.createElement('div');
|
||||
this._domNode.classList.add('simple-fr-find-part-wrapper');
|
||||
this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e)));
|
||||
|
||||
let progressContainer = dom.$('.find-replace-progress');
|
||||
this._progressBar = new ProgressBar(progressContainer);
|
||||
this._register(attachProgressBarStyler(this._progressBar, this._themeService));
|
||||
this._domNode.appendChild(progressContainer);
|
||||
|
||||
// Toggle replace button
|
||||
this._toggleReplaceBtn = this._register(new SimpleButton({
|
||||
label: NLS_TOGGLE_REPLACE_MODE_BTN_LABEL,
|
||||
className: 'codicon toggle left',
|
||||
onTrigger: () => {
|
||||
this._isReplaceVisible = !this._isReplaceVisible;
|
||||
this._state.change({ isReplaceRevealed: this._isReplaceVisible }, false);
|
||||
if (this._isReplaceVisible) {
|
||||
this._innerReplaceDomNode.style.display = 'flex';
|
||||
} else {
|
||||
this._innerReplaceDomNode.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}));
|
||||
this._toggleReplaceBtn.setExpanded(this._isReplaceVisible);
|
||||
this._domNode.appendChild(this._toggleReplaceBtn.domNode);
|
||||
|
||||
|
||||
this._innerFindDomNode = document.createElement('div');
|
||||
this._innerFindDomNode.classList.add('simple-fr-find-part');
|
||||
|
||||
this._findInput = this._register(new ContextScopedFindInput(null, this._contextViewService, {
|
||||
label: NLS_FIND_INPUT_LABEL,
|
||||
placeholder: NLS_FIND_INPUT_PLACEHOLDER,
|
||||
validation: (value: string): InputBoxMessage | null => {
|
||||
if (value.length === 0 || !this._findInput.getRegex()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
new RegExp(value);
|
||||
return null;
|
||||
} catch (e) {
|
||||
this.foundMatch = false;
|
||||
this.updateButtons(this.foundMatch);
|
||||
return { content: e.message };
|
||||
}
|
||||
}
|
||||
}, contextKeyService, showOptionButtons));
|
||||
|
||||
// Find History with update delayer
|
||||
this._updateHistoryDelayer = new Delayer<void>(500);
|
||||
|
||||
this.oninput(this._findInput.domNode, (e) => {
|
||||
this.foundMatch = this.onInputChanged();
|
||||
this.updateButtons(this.foundMatch);
|
||||
this._delayedUpdateHistory();
|
||||
});
|
||||
|
||||
this._findInput.setRegex(!!this._state.isRegex);
|
||||
this._findInput.setCaseSensitive(!!this._state.matchCase);
|
||||
this._findInput.setWholeWords(!!this._state.wholeWord);
|
||||
|
||||
this._register(this._findInput.onDidOptionChange(() => {
|
||||
this._state.change({
|
||||
isRegex: this._findInput.getRegex(),
|
||||
wholeWord: this._findInput.getWholeWords(),
|
||||
matchCase: this._findInput.getCaseSensitive()
|
||||
}, true);
|
||||
}));
|
||||
|
||||
this._register(this._state.onFindReplaceStateChange(() => {
|
||||
this._findInput.setRegex(this._state.isRegex);
|
||||
this._findInput.setWholeWords(this._state.wholeWord);
|
||||
this._findInput.setCaseSensitive(this._state.matchCase);
|
||||
this._replaceInput.setPreserveCase(this._state.preserveCase);
|
||||
this.findFirst();
|
||||
}));
|
||||
|
||||
this.prevBtn = this._register(new SimpleButton({
|
||||
label: NLS_PREVIOUS_MATCH_BTN_LABEL,
|
||||
className: findPreviousMatchIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.find(true);
|
||||
}
|
||||
}));
|
||||
|
||||
this.nextBtn = this._register(new SimpleButton({
|
||||
label: NLS_NEXT_MATCH_BTN_LABEL,
|
||||
className: findNextMatchIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.find(false);
|
||||
}
|
||||
}));
|
||||
|
||||
const closeBtn = this._register(new SimpleButton({
|
||||
label: NLS_CLOSE_BTN_LABEL,
|
||||
className: findCloseIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.hide();
|
||||
}
|
||||
}));
|
||||
|
||||
this._innerFindDomNode.appendChild(this._findInput.domNode);
|
||||
this._innerFindDomNode.appendChild(this.prevBtn.domNode);
|
||||
this._innerFindDomNode.appendChild(this.nextBtn.domNode);
|
||||
this._innerFindDomNode.appendChild(closeBtn.domNode);
|
||||
|
||||
// _domNode wraps _innerDomNode, ensuring that
|
||||
this._domNode.appendChild(this._innerFindDomNode);
|
||||
|
||||
this.onkeyup(this._innerFindDomNode, e => {
|
||||
if (e.equals(KeyCode.Escape)) {
|
||||
this.hide();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this._focusTracker = this._register(dom.trackFocus(this._innerFindDomNode));
|
||||
this._register(this._focusTracker.onDidFocus(this.onFocusTrackerFocus.bind(this)));
|
||||
this._register(this._focusTracker.onDidBlur(this.onFocusTrackerBlur.bind(this)));
|
||||
|
||||
this._findInputFocusTracker = this._register(dom.trackFocus(this._findInput.domNode));
|
||||
this._register(this._findInputFocusTracker.onDidFocus(this.onFindInputFocusTrackerFocus.bind(this)));
|
||||
this._register(this._findInputFocusTracker.onDidBlur(this.onFindInputFocusTrackerBlur.bind(this)));
|
||||
|
||||
this._register(dom.addDisposableListener(this._innerFindDomNode, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
}));
|
||||
|
||||
// Replace
|
||||
this._innerReplaceDomNode = document.createElement('div');
|
||||
this._innerReplaceDomNode.classList.add('simple-fr-replace-part');
|
||||
|
||||
this._replaceInput = this._register(new ContextScopedReplaceInput(null, undefined, {
|
||||
label: NLS_REPLACE_INPUT_LABEL,
|
||||
placeholder: NLS_REPLACE_INPUT_PLACEHOLDER,
|
||||
history: []
|
||||
}, contextKeyService, false));
|
||||
this._innerReplaceDomNode.appendChild(this._replaceInput.domNode);
|
||||
this._replaceInputFocusTracker = this._register(dom.trackFocus(this._replaceInput.domNode));
|
||||
this._register(this._replaceInputFocusTracker.onDidFocus(this.onReplaceInputFocusTrackerFocus.bind(this)));
|
||||
this._register(this._replaceInputFocusTracker.onDidBlur(this.onReplaceInputFocusTrackerBlur.bind(this)));
|
||||
|
||||
this._domNode.appendChild(this._innerReplaceDomNode);
|
||||
|
||||
if (this._isReplaceVisible) {
|
||||
this._innerReplaceDomNode.style.display = 'flex';
|
||||
} else {
|
||||
this._innerReplaceDomNode.style.display = 'none';
|
||||
}
|
||||
|
||||
this._replaceBtn = this._register(new SimpleButton({
|
||||
label: NLS_REPLACE_BTN_LABEL,
|
||||
className: findReplaceIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.replaceOne();
|
||||
}
|
||||
}));
|
||||
|
||||
// Replace all button
|
||||
this._replaceAllBtn = this._register(new SimpleButton({
|
||||
label: NLS_REPLACE_ALL_BTN_LABEL,
|
||||
className: findReplaceAllIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.replaceAll();
|
||||
}
|
||||
}));
|
||||
|
||||
this._innerReplaceDomNode.appendChild(this._replaceBtn.domNode);
|
||||
this._innerReplaceDomNode.appendChild(this._replaceAllBtn.domNode);
|
||||
|
||||
|
||||
}
|
||||
|
||||
protected abstract onInputChanged(): boolean;
|
||||
protected abstract find(previous: boolean): void;
|
||||
protected abstract findFirst(): void;
|
||||
protected abstract replaceOne(): void;
|
||||
protected abstract replaceAll(): void;
|
||||
protected abstract onFocusTrackerFocus(): void;
|
||||
protected abstract onFocusTrackerBlur(): void;
|
||||
protected abstract onFindInputFocusTrackerFocus(): void;
|
||||
protected abstract onFindInputFocusTrackerBlur(): void;
|
||||
protected abstract onReplaceInputFocusTrackerFocus(): void;
|
||||
protected abstract onReplaceInputFocusTrackerBlur(): void;
|
||||
|
||||
protected get inputValue() {
|
||||
return this._findInput.getValue();
|
||||
}
|
||||
|
||||
protected get replaceValue() {
|
||||
return this._replaceInput.getValue();
|
||||
}
|
||||
|
||||
public get focusTracker(): dom.IFocusTracker {
|
||||
return this._focusTracker;
|
||||
}
|
||||
|
||||
public updateTheme(theme: IColorTheme): void {
|
||||
const inputStyles: IFindInputStyles = {
|
||||
inputActiveOptionBorder: theme.getColor(inputActiveOptionBorder),
|
||||
inputActiveOptionForeground: theme.getColor(inputActiveOptionForeground),
|
||||
inputActiveOptionBackground: theme.getColor(inputActiveOptionBackground),
|
||||
inputBackground: theme.getColor(inputBackground),
|
||||
inputForeground: theme.getColor(inputForeground),
|
||||
inputBorder: theme.getColor(inputBorder),
|
||||
inputValidationInfoBackground: theme.getColor(inputValidationInfoBackground),
|
||||
inputValidationInfoForeground: theme.getColor(inputValidationInfoForeground),
|
||||
inputValidationInfoBorder: theme.getColor(inputValidationInfoBorder),
|
||||
inputValidationWarningBackground: theme.getColor(inputValidationWarningBackground),
|
||||
inputValidationWarningForeground: theme.getColor(inputValidationWarningForeground),
|
||||
inputValidationWarningBorder: theme.getColor(inputValidationWarningBorder),
|
||||
inputValidationErrorBackground: theme.getColor(inputValidationErrorBackground),
|
||||
inputValidationErrorForeground: theme.getColor(inputValidationErrorForeground),
|
||||
inputValidationErrorBorder: theme.getColor(inputValidationErrorBorder),
|
||||
};
|
||||
this._findInput.style(inputStyles);
|
||||
const replaceStyles: IReplaceInputStyles = {
|
||||
inputActiveOptionBorder: theme.getColor(inputActiveOptionBorder),
|
||||
inputActiveOptionForeground: theme.getColor(inputActiveOptionForeground),
|
||||
inputActiveOptionBackground: theme.getColor(inputActiveOptionBackground),
|
||||
inputBackground: theme.getColor(inputBackground),
|
||||
inputForeground: theme.getColor(inputForeground),
|
||||
inputBorder: theme.getColor(inputBorder),
|
||||
inputValidationInfoBackground: theme.getColor(inputValidationInfoBackground),
|
||||
inputValidationInfoForeground: theme.getColor(inputValidationInfoForeground),
|
||||
inputValidationInfoBorder: theme.getColor(inputValidationInfoBorder),
|
||||
inputValidationWarningBackground: theme.getColor(inputValidationWarningBackground),
|
||||
inputValidationWarningForeground: theme.getColor(inputValidationWarningForeground),
|
||||
inputValidationWarningBorder: theme.getColor(inputValidationWarningBorder),
|
||||
inputValidationErrorBackground: theme.getColor(inputValidationErrorBackground),
|
||||
inputValidationErrorForeground: theme.getColor(inputValidationErrorForeground),
|
||||
inputValidationErrorBorder: theme.getColor(inputValidationErrorBorder),
|
||||
};
|
||||
this._replaceInput.style(replaceStyles);
|
||||
}
|
||||
|
||||
private _onStateChanged(e: FindReplaceStateChangedEvent): void {
|
||||
this._updateButtons();
|
||||
}
|
||||
|
||||
private _updateButtons(): void {
|
||||
this._findInput.setEnabled(this._isVisible);
|
||||
this._replaceInput.setEnabled(this._isVisible && this._isReplaceVisible);
|
||||
let findInputIsNonEmpty = (this._state.searchString.length > 0);
|
||||
this._replaceBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty);
|
||||
this._replaceAllBtn.setEnabled(this._isVisible && this._isReplaceVisible && findInputIsNonEmpty);
|
||||
|
||||
this._domNode.classList.toggle('replaceToggled', this._isReplaceVisible);
|
||||
this._toggleReplaceBtn.setExpanded(this._isReplaceVisible);
|
||||
}
|
||||
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
|
||||
if (this._domNode && this._domNode.parentElement) {
|
||||
this._domNode.parentElement.removeChild(this._domNode);
|
||||
}
|
||||
}
|
||||
|
||||
public getDomNode() {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
public reveal(initialInput?: string): void {
|
||||
if (initialInput) {
|
||||
this._findInput.setValue(initialInput);
|
||||
}
|
||||
|
||||
if (this._isVisible) {
|
||||
this._findInput.select();
|
||||
return;
|
||||
}
|
||||
|
||||
this._isVisible = true;
|
||||
this.updateButtons(this.foundMatch);
|
||||
|
||||
setTimeout(() => {
|
||||
this._domNode.classList.add('visible', 'visible-transition');
|
||||
this._domNode.setAttribute('aria-hidden', 'false');
|
||||
this._findInput.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
this._findInput.focus();
|
||||
}
|
||||
|
||||
public show(initialInput?: string): void {
|
||||
if (initialInput && !this._isVisible) {
|
||||
this._findInput.setValue(initialInput);
|
||||
}
|
||||
|
||||
this._isVisible = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this._domNode.classList.add('visible', 'visible-transition');
|
||||
this._domNode.setAttribute('aria-hidden', 'false');
|
||||
|
||||
this.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public showWithReplace(initialInput?: string, replaceInput?: string): void {
|
||||
if (initialInput && !this._isVisible) {
|
||||
this._findInput.setValue(initialInput);
|
||||
}
|
||||
|
||||
if (replaceInput && !this._isVisible) {
|
||||
this._replaceInput.setValue(replaceInput);
|
||||
}
|
||||
|
||||
this._isVisible = true;
|
||||
this._isReplaceVisible = true;
|
||||
this._state.change({ isReplaceRevealed: this._isReplaceVisible }, false);
|
||||
if (this._isReplaceVisible) {
|
||||
this._innerReplaceDomNode.style.display = 'flex';
|
||||
} else {
|
||||
this._innerReplaceDomNode.style.display = 'none';
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this._domNode.classList.add('visible', 'visible-transition');
|
||||
this._domNode.setAttribute('aria-hidden', 'false');
|
||||
this._updateButtons();
|
||||
|
||||
this._replaceInput.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
if (this._isVisible) {
|
||||
this._domNode.classList.remove('visible-transition');
|
||||
this._domNode.setAttribute('aria-hidden', 'true');
|
||||
// Need to delay toggling visibility until after Transition, then visibility hidden - removes from tabIndex list
|
||||
setTimeout(() => {
|
||||
this._isVisible = false;
|
||||
this.updateButtons(this.foundMatch);
|
||||
this._domNode.classList.remove('visible');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
protected _delayedUpdateHistory() {
|
||||
this._updateHistoryDelayer.trigger(this._updateHistory.bind(this));
|
||||
}
|
||||
|
||||
protected _updateHistory() {
|
||||
this._findInput.inputBox.addToHistory();
|
||||
}
|
||||
|
||||
protected _getRegexValue(): boolean {
|
||||
return this._findInput.getRegex();
|
||||
}
|
||||
|
||||
protected _getWholeWordValue(): boolean {
|
||||
return this._findInput.getWholeWords();
|
||||
}
|
||||
|
||||
protected _getCaseSensitiveValue(): boolean {
|
||||
return this._findInput.getCaseSensitive();
|
||||
}
|
||||
|
||||
protected updateButtons(foundMatch: boolean) {
|
||||
const hasInput = this.inputValue.length > 0;
|
||||
this.prevBtn.setEnabled(this._isVisible && hasInput && foundMatch);
|
||||
this.nextBtn.setEnabled(this._isVisible && hasInput && foundMatch);
|
||||
}
|
||||
}
|
||||
|
||||
// theming
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const findWidgetBGColor = theme.getColor(editorWidgetBackground);
|
||||
if (findWidgetBGColor) {
|
||||
collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { background-color: ${findWidgetBGColor} !important; }`);
|
||||
}
|
||||
|
||||
const widgetForeground = theme.getColor(editorWidgetForeground);
|
||||
if (widgetForeground) {
|
||||
collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { color: ${widgetForeground}; }`);
|
||||
}
|
||||
|
||||
const widgetShadowColor = theme.getColor(widgetShadow);
|
||||
if (widgetShadowColor) {
|
||||
collector.addRule(`.monaco-workbench .simple-fr-find-part-wrapper { box-shadow: 0 2px 8px ${widgetShadowColor}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-workbench .simple-find-part-wrapper {
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 18px;
|
||||
width: 220px;
|
||||
max-width: calc(100% - 28px - 28px - 8px);
|
||||
pointer-events: none;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part {
|
||||
visibility: hidden; /* Use visibility to maintain flex layout while hidden otherwise interferes with transition */
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
top: -45px;
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
align-items: center;
|
||||
pointer-events: all;
|
||||
transition: top 200ms linear;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part.visible-transition {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part .monaco-findInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part .button {
|
||||
min-width: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
flex: initial;
|
||||
justify-content: center;
|
||||
margin-left: 3px;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.monaco-workbench .simple-find-part .button.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./simpleFindWidget';
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { FindInput, IFindInputStyles } from 'vs/base/browser/ui/findinput/findInput';
|
||||
import { Widget } from 'vs/base/browser/ui/widget';
|
||||
import { Delayer } from 'vs/base/common/async';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { FindReplaceState } from 'vs/editor/contrib/find/findState';
|
||||
import { IMessage as InputBoxMessage } from 'vs/base/browser/ui/inputbox/inputBox';
|
||||
import { SimpleButton, findPreviousMatchIcon, findNextMatchIcon, findCloseIcon } from 'vs/editor/contrib/find/findWidget';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { editorWidgetBackground, inputActiveOptionBorder, inputActiveOptionBackground, inputActiveOptionForeground, inputBackground, inputBorder, inputForeground, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, inputValidationInfoBackground, inputValidationInfoBorder, inputValidationInfoForeground, inputValidationWarningBackground, inputValidationWarningBorder, inputValidationWarningForeground, widgetShadow, editorWidgetForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { IColorTheme, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { ContextScopedFindInput } from 'vs/platform/browser/contextScopedHistoryWidget';
|
||||
|
||||
const NLS_FIND_INPUT_LABEL = nls.localize('label.find', "Find");
|
||||
const NLS_FIND_INPUT_PLACEHOLDER = nls.localize('placeholder.find', "Find");
|
||||
const NLS_PREVIOUS_MATCH_BTN_LABEL = nls.localize('label.previousMatchButton', "Previous match");
|
||||
const NLS_NEXT_MATCH_BTN_LABEL = nls.localize('label.nextMatchButton', "Next match");
|
||||
const NLS_CLOSE_BTN_LABEL = nls.localize('label.closeButton', "Close");
|
||||
|
||||
export abstract class SimpleFindWidget extends Widget {
|
||||
private readonly _findInput: FindInput;
|
||||
private readonly _domNode: HTMLElement;
|
||||
private readonly _innerDomNode: HTMLElement;
|
||||
private readonly _focusTracker: dom.IFocusTracker;
|
||||
private readonly _findInputFocusTracker: dom.IFocusTracker;
|
||||
private readonly _updateHistoryDelayer: Delayer<void>;
|
||||
private readonly prevBtn: SimpleButton;
|
||||
private readonly nextBtn: SimpleButton;
|
||||
|
||||
private _isVisible: boolean = false;
|
||||
private foundMatch: boolean = false;
|
||||
|
||||
constructor(
|
||||
@IContextViewService private readonly _contextViewService: IContextViewService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
private readonly _state: FindReplaceState = new FindReplaceState(),
|
||||
showOptionButtons?: boolean
|
||||
) {
|
||||
super();
|
||||
|
||||
this._findInput = this._register(new ContextScopedFindInput(null, this._contextViewService, {
|
||||
label: NLS_FIND_INPUT_LABEL,
|
||||
placeholder: NLS_FIND_INPUT_PLACEHOLDER,
|
||||
validation: (value: string): InputBoxMessage | null => {
|
||||
if (value.length === 0 || !this._findInput.getRegex()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
new RegExp(value);
|
||||
return null;
|
||||
} catch (e) {
|
||||
this.foundMatch = false;
|
||||
this.updateButtons(this.foundMatch);
|
||||
return { content: e.message };
|
||||
}
|
||||
}
|
||||
}, contextKeyService, showOptionButtons));
|
||||
|
||||
// Find History with update delayer
|
||||
this._updateHistoryDelayer = new Delayer<void>(500);
|
||||
|
||||
this.oninput(this._findInput.domNode, (e) => {
|
||||
this.foundMatch = this.onInputChanged();
|
||||
this.updateButtons(this.foundMatch);
|
||||
this._delayedUpdateHistory();
|
||||
});
|
||||
|
||||
this._findInput.setRegex(!!this._state.isRegex);
|
||||
this._findInput.setCaseSensitive(!!this._state.matchCase);
|
||||
this._findInput.setWholeWords(!!this._state.wholeWord);
|
||||
|
||||
this._register(this._findInput.onDidOptionChange(() => {
|
||||
this._state.change({
|
||||
isRegex: this._findInput.getRegex(),
|
||||
wholeWord: this._findInput.getWholeWords(),
|
||||
matchCase: this._findInput.getCaseSensitive()
|
||||
}, true);
|
||||
}));
|
||||
|
||||
this._register(this._state.onFindReplaceStateChange(() => {
|
||||
this._findInput.setRegex(this._state.isRegex);
|
||||
this._findInput.setWholeWords(this._state.wholeWord);
|
||||
this._findInput.setCaseSensitive(this._state.matchCase);
|
||||
this.findFirst();
|
||||
}));
|
||||
|
||||
this.prevBtn = this._register(new SimpleButton({
|
||||
label: NLS_PREVIOUS_MATCH_BTN_LABEL,
|
||||
className: findPreviousMatchIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.find(true);
|
||||
}
|
||||
}));
|
||||
|
||||
this.nextBtn = this._register(new SimpleButton({
|
||||
label: NLS_NEXT_MATCH_BTN_LABEL,
|
||||
className: findNextMatchIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.find(false);
|
||||
}
|
||||
}));
|
||||
|
||||
const closeBtn = this._register(new SimpleButton({
|
||||
label: NLS_CLOSE_BTN_LABEL,
|
||||
className: findCloseIcon.classNames,
|
||||
onTrigger: () => {
|
||||
this.hide();
|
||||
}
|
||||
}));
|
||||
|
||||
this._innerDomNode = document.createElement('div');
|
||||
this._innerDomNode.classList.add('simple-find-part');
|
||||
this._innerDomNode.appendChild(this._findInput.domNode);
|
||||
this._innerDomNode.appendChild(this.prevBtn.domNode);
|
||||
this._innerDomNode.appendChild(this.nextBtn.domNode);
|
||||
this._innerDomNode.appendChild(closeBtn.domNode);
|
||||
|
||||
// _domNode wraps _innerDomNode, ensuring that
|
||||
this._domNode = document.createElement('div');
|
||||
this._domNode.classList.add('simple-find-part-wrapper');
|
||||
this._domNode.appendChild(this._innerDomNode);
|
||||
|
||||
this.onkeyup(this._innerDomNode, e => {
|
||||
if (e.equals(KeyCode.Escape)) {
|
||||
this.hide();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this._focusTracker = this._register(dom.trackFocus(this._innerDomNode));
|
||||
this._register(this._focusTracker.onDidFocus(this.onFocusTrackerFocus.bind(this)));
|
||||
this._register(this._focusTracker.onDidBlur(this.onFocusTrackerBlur.bind(this)));
|
||||
|
||||
this._findInputFocusTracker = this._register(dom.trackFocus(this._findInput.domNode));
|
||||
this._register(this._findInputFocusTracker.onDidFocus(this.onFindInputFocusTrackerFocus.bind(this)));
|
||||
this._register(this._findInputFocusTracker.onDidBlur(this.onFindInputFocusTrackerBlur.bind(this)));
|
||||
|
||||
this._register(dom.addDisposableListener(this._innerDomNode, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
}));
|
||||
}
|
||||
|
||||
protected abstract onInputChanged(): boolean;
|
||||
protected abstract find(previous: boolean): void;
|
||||
protected abstract findFirst(): void;
|
||||
protected abstract onFocusTrackerFocus(): void;
|
||||
protected abstract onFocusTrackerBlur(): void;
|
||||
protected abstract onFindInputFocusTrackerFocus(): void;
|
||||
protected abstract onFindInputFocusTrackerBlur(): void;
|
||||
|
||||
protected get inputValue() {
|
||||
return this._findInput.getValue();
|
||||
}
|
||||
|
||||
public get focusTracker(): dom.IFocusTracker {
|
||||
return this._focusTracker;
|
||||
}
|
||||
|
||||
public updateTheme(theme: IColorTheme): void {
|
||||
const inputStyles: IFindInputStyles = {
|
||||
inputActiveOptionBorder: theme.getColor(inputActiveOptionBorder),
|
||||
inputActiveOptionForeground: theme.getColor(inputActiveOptionForeground),
|
||||
inputActiveOptionBackground: theme.getColor(inputActiveOptionBackground),
|
||||
inputBackground: theme.getColor(inputBackground),
|
||||
inputForeground: theme.getColor(inputForeground),
|
||||
inputBorder: theme.getColor(inputBorder),
|
||||
inputValidationInfoBackground: theme.getColor(inputValidationInfoBackground),
|
||||
inputValidationInfoForeground: theme.getColor(inputValidationInfoForeground),
|
||||
inputValidationInfoBorder: theme.getColor(inputValidationInfoBorder),
|
||||
inputValidationWarningBackground: theme.getColor(inputValidationWarningBackground),
|
||||
inputValidationWarningForeground: theme.getColor(inputValidationWarningForeground),
|
||||
inputValidationWarningBorder: theme.getColor(inputValidationWarningBorder),
|
||||
inputValidationErrorBackground: theme.getColor(inputValidationErrorBackground),
|
||||
inputValidationErrorForeground: theme.getColor(inputValidationErrorForeground),
|
||||
inputValidationErrorBorder: theme.getColor(inputValidationErrorBorder)
|
||||
};
|
||||
this._findInput.style(inputStyles);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
|
||||
if (this._domNode && this._domNode.parentElement) {
|
||||
this._domNode.parentElement.removeChild(this._domNode);
|
||||
}
|
||||
}
|
||||
|
||||
public getDomNode() {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
public reveal(initialInput?: string): void {
|
||||
if (initialInput) {
|
||||
this._findInput.setValue(initialInput);
|
||||
}
|
||||
|
||||
if (this._isVisible) {
|
||||
this._findInput.select();
|
||||
return;
|
||||
}
|
||||
|
||||
this._isVisible = true;
|
||||
this.updateButtons(this.foundMatch);
|
||||
|
||||
setTimeout(() => {
|
||||
this._innerDomNode.classList.add('visible', 'visible-transition');
|
||||
this._innerDomNode.setAttribute('aria-hidden', 'false');
|
||||
this._findInput.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public show(initialInput?: string): void {
|
||||
if (initialInput && !this._isVisible) {
|
||||
this._findInput.setValue(initialInput);
|
||||
}
|
||||
|
||||
this._isVisible = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this._innerDomNode.classList.add('visible', 'visible-transition');
|
||||
this._innerDomNode.setAttribute('aria-hidden', 'false');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
if (this._isVisible) {
|
||||
this._innerDomNode.classList.remove('visible-transition');
|
||||
this._innerDomNode.setAttribute('aria-hidden', 'true');
|
||||
// Need to delay toggling visibility until after Transition, then visibility hidden - removes from tabIndex list
|
||||
setTimeout(() => {
|
||||
this._isVisible = false;
|
||||
this.updateButtons(this.foundMatch);
|
||||
this._innerDomNode.classList.remove('visible');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
protected _delayedUpdateHistory() {
|
||||
this._updateHistoryDelayer.trigger(this._updateHistory.bind(this));
|
||||
}
|
||||
|
||||
protected _updateHistory() {
|
||||
this._findInput.inputBox.addToHistory();
|
||||
}
|
||||
|
||||
protected _getRegexValue(): boolean {
|
||||
return this._findInput.getRegex();
|
||||
}
|
||||
|
||||
protected _getWholeWordValue(): boolean {
|
||||
return this._findInput.getWholeWords();
|
||||
}
|
||||
|
||||
protected _getCaseSensitiveValue(): boolean {
|
||||
return this._findInput.getCaseSensitive();
|
||||
}
|
||||
|
||||
protected updateButtons(foundMatch: boolean) {
|
||||
const hasInput = this.inputValue.length > 0;
|
||||
this.prevBtn.setEnabled(this._isVisible && hasInput && foundMatch);
|
||||
this.nextBtn.setEnabled(this._isVisible && hasInput && foundMatch);
|
||||
}
|
||||
}
|
||||
|
||||
// theming
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const findWidgetBGColor = theme.getColor(editorWidgetBackground);
|
||||
if (findWidgetBGColor) {
|
||||
collector.addRule(`.monaco-workbench .simple-find-part { background-color: ${findWidgetBGColor} !important; }`);
|
||||
}
|
||||
|
||||
const widgetForeground = theme.getColor(editorWidgetForeground);
|
||||
if (widgetForeground) {
|
||||
collector.addRule(`.monaco-workbench .simple-find-part { color: ${widgetForeground}; }`);
|
||||
}
|
||||
|
||||
const widgetShadowColor = theme.getColor(widgetShadow);
|
||||
if (widgetShadowColor) {
|
||||
collector.addRule(`.monaco-workbench .simple-find-part { box-shadow: 0 2px 8px ${widgetShadowColor}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.token-inspect-widget {
|
||||
z-index: 50;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tiw-token {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
}
|
||||
|
||||
.tiw-metadata-separator {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tiw-token-length {
|
||||
font-weight: normal;
|
||||
font-size: 60%;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.tiw-metadata-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tiw-metadata-value {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tiw-metadata-values {
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-right: -10px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tiw-metadata-values > .tiw-metadata-value {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.tiw-metadata-key {
|
||||
width: 1px;
|
||||
min-width: 150px;
|
||||
padding-right: 10px;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tiw-metadata-semantic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiw-metadata-scopes {
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.tiw-theme-selector {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./inspectEditorTokens';
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { CharCode } from 'vs/base/common/charCode';
|
||||
import { Color } from 'vs/base/common/color';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { ContentWidgetPositionPreference, IActiveCodeEditor, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorAction, ServicesAccessor, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { FontStyle, LanguageIdentifier, StandardTokenType, TokenMetadata, DocumentSemanticTokensProviderRegistry, SemanticTokensLegend, SemanticTokens, LanguageId, ColorId, DocumentRangeSemanticTokensProviderRegistry } from 'vs/editor/common/modes';
|
||||
import { IModeService } from 'vs/editor/common/services/modeService';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { editorHoverBackground, editorHoverBorder } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { findMatchingThemeRule } from 'vs/workbench/services/textMate/common/TMHelper';
|
||||
import { ITextMateService, IGrammar, IToken, StackElement } from 'vs/workbench/services/textMate/common/textMateService';
|
||||
import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService';
|
||||
import { CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
import { ColorThemeData, TokenStyleDefinitions, TokenStyleDefinition, TextMateThemingRuleDefinitions } from 'vs/workbench/services/themes/common/colorThemeData';
|
||||
import { SemanticTokenRule, TokenStyleData, TokenStyle } from 'vs/platform/theme/common/tokenClassificationRegistry';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { SEMANTIC_HIGHLIGHTING_SETTING_ID, IEditorSemanticHighlightingOptions } from 'vs/editor/common/services/modelServiceImpl';
|
||||
import { ColorScheme } from 'vs/platform/theme/common/theme';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
class InspectEditorTokensController extends Disposable implements IEditorContribution {
|
||||
|
||||
public static readonly ID = 'editor.contrib.inspectEditorTokens';
|
||||
|
||||
public static get(editor: ICodeEditor): InspectEditorTokensController {
|
||||
return editor.getContribution<InspectEditorTokensController>(InspectEditorTokensController.ID);
|
||||
}
|
||||
|
||||
private _editor: ICodeEditor;
|
||||
private _textMateService: ITextMateService;
|
||||
private _themeService: IWorkbenchThemeService;
|
||||
private _modeService: IModeService;
|
||||
private _notificationService: INotificationService;
|
||||
private _configurationService: IConfigurationService;
|
||||
private _widget: InspectEditorTokensWidget | null;
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
@ITextMateService textMateService: ITextMateService,
|
||||
@IModeService modeService: IModeService,
|
||||
@IWorkbenchThemeService themeService: IWorkbenchThemeService,
|
||||
@INotificationService notificationService: INotificationService,
|
||||
@IConfigurationService configurationService: IConfigurationService
|
||||
) {
|
||||
super();
|
||||
this._editor = editor;
|
||||
this._textMateService = textMateService;
|
||||
this._themeService = themeService;
|
||||
this._modeService = modeService;
|
||||
this._notificationService = notificationService;
|
||||
this._configurationService = configurationService;
|
||||
this._widget = null;
|
||||
|
||||
this._register(this._editor.onDidChangeModel((e) => this.stop()));
|
||||
this._register(this._editor.onDidChangeModelLanguage((e) => this.stop()));
|
||||
this._register(this._editor.onKeyUp((e) => e.keyCode === KeyCode.Escape && this.stop()));
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public launch(): void {
|
||||
if (this._widget) {
|
||||
return;
|
||||
}
|
||||
if (!this._editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
this._widget = new InspectEditorTokensWidget(this._editor, this._textMateService, this._modeService, this._themeService, this._notificationService, this._configurationService);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this._widget) {
|
||||
this._widget.dispose();
|
||||
this._widget = null;
|
||||
}
|
||||
}
|
||||
|
||||
public toggle(): void {
|
||||
if (!this._widget) {
|
||||
this.launch();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InspectEditorTokens extends EditorAction {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.action.inspectTMScopes',
|
||||
label: nls.localize('inspectEditorTokens', "Developer: Inspect Editor Tokens and Scopes"),
|
||||
alias: 'Developer: Inspect Editor Tokens and Scopes',
|
||||
precondition: undefined
|
||||
});
|
||||
}
|
||||
|
||||
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
let controller = InspectEditorTokensController.get(editor);
|
||||
if (controller) {
|
||||
controller.toggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ITextMateTokenInfo {
|
||||
token: IToken;
|
||||
metadata: IDecodedMetadata;
|
||||
}
|
||||
|
||||
interface ISemanticTokenInfo {
|
||||
type: string;
|
||||
modifiers: string[];
|
||||
range: Range;
|
||||
metadata?: IDecodedMetadata,
|
||||
definitions: TokenStyleDefinitions
|
||||
}
|
||||
|
||||
interface IDecodedMetadata {
|
||||
languageIdentifier: LanguageIdentifier;
|
||||
tokenType: StandardTokenType;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
foreground?: string;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
function renderTokenText(tokenText: string): string {
|
||||
if (tokenText.length > 40) {
|
||||
tokenText = tokenText.substr(0, 20) + '…' + tokenText.substr(tokenText.length - 20);
|
||||
}
|
||||
let result: string = '';
|
||||
for (let charIndex = 0, len = tokenText.length; charIndex < len; charIndex++) {
|
||||
let charCode = tokenText.charCodeAt(charIndex);
|
||||
switch (charCode) {
|
||||
case CharCode.Tab:
|
||||
result += '\u2192'; // →
|
||||
break;
|
||||
|
||||
case CharCode.Space:
|
||||
result += '\u00B7'; // ·
|
||||
break;
|
||||
|
||||
default:
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
type SemanticTokensResult = { tokens: SemanticTokens, legend: SemanticTokensLegend };
|
||||
|
||||
class InspectEditorTokensWidget extends Disposable implements IContentWidget {
|
||||
|
||||
private static readonly _ID = 'editor.contrib.inspectEditorTokensWidget';
|
||||
|
||||
// Editor.IContentWidget.allowEditorOverflow
|
||||
public readonly allowEditorOverflow = true;
|
||||
|
||||
private _isDisposed: boolean;
|
||||
private readonly _editor: IActiveCodeEditor;
|
||||
private readonly _modeService: IModeService;
|
||||
private readonly _themeService: IWorkbenchThemeService;
|
||||
private readonly _textMateService: ITextMateService;
|
||||
private readonly _notificationService: INotificationService;
|
||||
private readonly _configurationService: IConfigurationService;
|
||||
private readonly _model: ITextModel;
|
||||
private readonly _domNode: HTMLElement;
|
||||
private readonly _currentRequestCancellationTokenSource: CancellationTokenSource;
|
||||
|
||||
constructor(
|
||||
editor: IActiveCodeEditor,
|
||||
textMateService: ITextMateService,
|
||||
modeService: IModeService,
|
||||
themeService: IWorkbenchThemeService,
|
||||
notificationService: INotificationService,
|
||||
configurationService: IConfigurationService
|
||||
) {
|
||||
super();
|
||||
this._isDisposed = false;
|
||||
this._editor = editor;
|
||||
this._modeService = modeService;
|
||||
this._themeService = themeService;
|
||||
this._textMateService = textMateService;
|
||||
this._notificationService = notificationService;
|
||||
this._configurationService = configurationService;
|
||||
this._model = this._editor.getModel();
|
||||
this._domNode = document.createElement('div');
|
||||
this._domNode.className = 'token-inspect-widget';
|
||||
this._currentRequestCancellationTokenSource = new CancellationTokenSource();
|
||||
this._beginCompute(this._editor.getPosition());
|
||||
this._register(this._editor.onDidChangeCursorPosition((e) => this._beginCompute(this._editor.getPosition())));
|
||||
this._register(themeService.onDidColorThemeChange(_ => this._beginCompute(this._editor.getPosition())));
|
||||
this._register(configurationService.onDidChangeConfiguration(e => e.affectsConfiguration('editor.semanticHighlighting.enabled') && this._beginCompute(this._editor.getPosition())));
|
||||
this._editor.addContentWidget(this);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._isDisposed = true;
|
||||
this._editor.removeContentWidget(this);
|
||||
this._currentRequestCancellationTokenSource.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public getId(): string {
|
||||
return InspectEditorTokensWidget._ID;
|
||||
}
|
||||
|
||||
private _beginCompute(position: Position): void {
|
||||
const grammar = this._textMateService.createGrammar(this._model.getLanguageIdentifier().language);
|
||||
const semanticTokens = this._computeSemanticTokens(position);
|
||||
|
||||
dom.clearNode(this._domNode);
|
||||
this._domNode.appendChild(document.createTextNode(nls.localize('inspectTMScopesWidget.loading', "Loading...")));
|
||||
|
||||
Promise.all([grammar, semanticTokens]).then(([grammar, semanticTokens]) => {
|
||||
if (this._isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._compute(grammar, semanticTokens, position);
|
||||
this._domNode.style.maxWidth = `${Math.max(this._editor.getLayoutInfo().width * 0.66, 500)}px`;
|
||||
this._editor.layoutContentWidget(this);
|
||||
}, (err) => {
|
||||
this._notificationService.warn(err);
|
||||
|
||||
setTimeout(() => {
|
||||
InspectEditorTokensController.get(this._editor).stop();
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private _isSemanticColoringEnabled() {
|
||||
const setting = this._configurationService.getValue<IEditorSemanticHighlightingOptions>(SEMANTIC_HIGHLIGHTING_SETTING_ID, { overrideIdentifier: this._model.getLanguageIdentifier().language, resource: this._model.uri })?.enabled;
|
||||
if (typeof setting === 'boolean') {
|
||||
return setting;
|
||||
}
|
||||
return this._themeService.getColorTheme().semanticHighlighting;
|
||||
}
|
||||
|
||||
private _compute(grammar: IGrammar | null, semanticTokens: SemanticTokensResult | null, position: Position) {
|
||||
const textMateTokenInfo = grammar && this._getTokensAtPosition(grammar, position);
|
||||
const semanticTokenInfo = semanticTokens && this._getSemanticTokenAtPosition(semanticTokens, position);
|
||||
if (!textMateTokenInfo && !semanticTokenInfo) {
|
||||
dom.reset(this._domNode, 'No grammar or semantic tokens available.');
|
||||
return;
|
||||
}
|
||||
|
||||
let tmMetadata = textMateTokenInfo?.metadata;
|
||||
let semMetadata = semanticTokenInfo?.metadata;
|
||||
|
||||
const semTokenText = semanticTokenInfo && renderTokenText(this._model.getValueInRange(semanticTokenInfo.range));
|
||||
const tmTokenText = textMateTokenInfo && renderTokenText(this._model.getLineContent(position.lineNumber).substring(textMateTokenInfo.token.startIndex, textMateTokenInfo.token.endIndex));
|
||||
|
||||
const tokenText = semTokenText || tmTokenText || '';
|
||||
|
||||
dom.reset(this._domNode,
|
||||
$('h2.tiw-token', undefined,
|
||||
tokenText,
|
||||
$('span.tiw-token-length', undefined, `${tokenText.length} ${tokenText.length === 1 ? 'char' : 'chars'}`)));
|
||||
dom.append(this._domNode, $('hr.tiw-metadata-separator', { 'style': 'clear:both' }));
|
||||
dom.append(this._domNode, $('table.tiw-metadata-table', undefined,
|
||||
$('tbody', undefined,
|
||||
$('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'language'),
|
||||
$('td.tiw-metadata-value', undefined, tmMetadata?.languageIdentifier.language || '')
|
||||
),
|
||||
$('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'standard token type' as string),
|
||||
$('td.tiw-metadata-value', undefined, this._tokenTypeToString(tmMetadata?.tokenType || StandardTokenType.Other))
|
||||
),
|
||||
...this._formatMetadata(semMetadata, tmMetadata)
|
||||
)
|
||||
));
|
||||
|
||||
if (semanticTokenInfo) {
|
||||
dom.append(this._domNode, $('hr.tiw-metadata-separator'));
|
||||
const table = dom.append(this._domNode, $('table.tiw-metadata-table', undefined));
|
||||
const tbody = dom.append(table, $('tbody', undefined,
|
||||
$('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'semantic token type' as string),
|
||||
$('td.tiw-metadata-value', undefined, semanticTokenInfo.type)
|
||||
)
|
||||
));
|
||||
if (semanticTokenInfo.modifiers.length) {
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'modifiers'),
|
||||
$('td.tiw-metadata-value', undefined, semanticTokenInfo.modifiers.join(' ')),
|
||||
));
|
||||
}
|
||||
if (semanticTokenInfo.metadata) {
|
||||
const properties: (keyof TokenStyleData)[] = ['foreground', 'bold', 'italic', 'underline'];
|
||||
const propertiesByDefValue: { [rule: string]: string[] } = {};
|
||||
const allDefValues = new Array<[Array<HTMLElement | string>, string]>(); // remember the order
|
||||
// first collect to detect when the same rule is used for multiple properties
|
||||
for (let property of properties) {
|
||||
if (semanticTokenInfo.metadata[property] !== undefined) {
|
||||
const definition = semanticTokenInfo.definitions[property];
|
||||
const defValue = this._renderTokenStyleDefinition(definition, property);
|
||||
const defValueStr = defValue.map(el => el instanceof HTMLElement ? el.outerHTML : el).join();
|
||||
let properties = propertiesByDefValue[defValueStr];
|
||||
if (!properties) {
|
||||
propertiesByDefValue[defValueStr] = properties = [];
|
||||
allDefValues.push([defValue, defValueStr]);
|
||||
}
|
||||
properties.push(property);
|
||||
}
|
||||
}
|
||||
for (const [defValue, defValueStr] of allDefValues) {
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, propertiesByDefValue[defValueStr].join(', ')),
|
||||
$('td.tiw-metadata-value', undefined, ...defValue)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (textMateTokenInfo) {
|
||||
let theme = this._themeService.getColorTheme();
|
||||
dom.append(this._domNode, $('hr.tiw-metadata-separator'));
|
||||
const table = dom.append(this._domNode, $('table.tiw-metadata-table'));
|
||||
const tbody = dom.append(table, $('tbody'));
|
||||
|
||||
if (tmTokenText && tmTokenText !== tokenText) {
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'textmate token' as string),
|
||||
$('td.tiw-metadata-value', undefined, `${tmTokenText} (${tmTokenText.length})`)
|
||||
));
|
||||
}
|
||||
const scopes = new Array<HTMLElement | string>();
|
||||
for (let i = textMateTokenInfo.token.scopes.length - 1; i >= 0; i--) {
|
||||
scopes.push(textMateTokenInfo.token.scopes[i]);
|
||||
if (i > 0) {
|
||||
scopes.push($('br'));
|
||||
}
|
||||
}
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'textmate scopes' as string),
|
||||
$('td.tiw-metadata-value.tiw-metadata-scopes', undefined, ...scopes),
|
||||
));
|
||||
|
||||
let matchingRule = findMatchingThemeRule(theme, textMateTokenInfo.token.scopes, false);
|
||||
const semForeground = semanticTokenInfo?.metadata?.foreground;
|
||||
if (matchingRule) {
|
||||
if (semForeground !== textMateTokenInfo.metadata.foreground) {
|
||||
let defValue = $('code.tiw-theme-selector', undefined,
|
||||
matchingRule.rawSelector, $('br'), JSON.stringify(matchingRule.settings, null, '\t'));
|
||||
if (semForeground) {
|
||||
defValue = $('s', undefined, defValue);
|
||||
}
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'foreground'),
|
||||
$('td.tiw-metadata-value', undefined, defValue),
|
||||
));
|
||||
}
|
||||
} else if (!semForeground) {
|
||||
dom.append(tbody, $('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'foreground'),
|
||||
$('td.tiw-metadata-value', undefined, 'No theme selector' as string),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _formatMetadata(semantic?: IDecodedMetadata, tm?: IDecodedMetadata): Array<HTMLElement | string> {
|
||||
const elements = new Array<HTMLElement | string>();
|
||||
|
||||
function render(property: 'foreground' | 'background') {
|
||||
let value = semantic?.[property] || tm?.[property];
|
||||
if (value !== undefined) {
|
||||
const semanticStyle = semantic?.[property] ? 'tiw-metadata-semantic' : '';
|
||||
elements.push($('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, property),
|
||||
$(`td.tiw-metadata-value.${semanticStyle}`, undefined, value)
|
||||
));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const foreground = render('foreground');
|
||||
const background = render('background');
|
||||
if (foreground && background) {
|
||||
const backgroundColor = Color.fromHex(background), foregroundColor = Color.fromHex(foreground);
|
||||
if (backgroundColor.isOpaque()) {
|
||||
elements.push($('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'contrast ratio' as string),
|
||||
$('td.tiw-metadata-value', undefined, backgroundColor.getContrastRatio(foregroundColor.makeOpaque(backgroundColor)).toFixed(2))
|
||||
));
|
||||
} else {
|
||||
elements.push($('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'Contrast ratio cannot be precise for background colors that use transparency' as string),
|
||||
$('td.tiw-metadata-value')
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const fontStyleLabels = new Array<HTMLElement | string>();
|
||||
|
||||
function addStyle(key: 'bold' | 'italic' | 'underline') {
|
||||
if (semantic && semantic[key]) {
|
||||
fontStyleLabels.push($('span.tiw-metadata-semantic', undefined, key));
|
||||
} else if (tm && tm[key]) {
|
||||
fontStyleLabels.push(key);
|
||||
}
|
||||
}
|
||||
addStyle('bold');
|
||||
addStyle('italic');
|
||||
addStyle('underline');
|
||||
if (fontStyleLabels.length) {
|
||||
elements.push($('tr', undefined,
|
||||
$('td.tiw-metadata-key', undefined, 'font style' as string),
|
||||
$('td.tiw-metadata-value', undefined, fontStyleLabels.join(' '))
|
||||
));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
private _decodeMetadata(metadata: number): IDecodedMetadata {
|
||||
let colorMap = this._themeService.getColorTheme().tokenColorMap;
|
||||
let languageId = TokenMetadata.getLanguageId(metadata);
|
||||
let tokenType = TokenMetadata.getTokenType(metadata);
|
||||
let fontStyle = TokenMetadata.getFontStyle(metadata);
|
||||
let foreground = TokenMetadata.getForeground(metadata);
|
||||
let background = TokenMetadata.getBackground(metadata);
|
||||
return {
|
||||
languageIdentifier: this._modeService.getLanguageIdentifier(languageId)!,
|
||||
tokenType: tokenType,
|
||||
bold: (fontStyle & FontStyle.Bold) ? true : undefined,
|
||||
italic: (fontStyle & FontStyle.Italic) ? true : undefined,
|
||||
underline: (fontStyle & FontStyle.Underline) ? true : undefined,
|
||||
foreground: colorMap[foreground],
|
||||
background: colorMap[background]
|
||||
};
|
||||
}
|
||||
|
||||
private _tokenTypeToString(tokenType: StandardTokenType): string {
|
||||
switch (tokenType) {
|
||||
case StandardTokenType.Other: return 'Other';
|
||||
case StandardTokenType.Comment: return 'Comment';
|
||||
case StandardTokenType.String: return 'String';
|
||||
case StandardTokenType.RegEx: return 'RegEx';
|
||||
default: return '??';
|
||||
}
|
||||
}
|
||||
|
||||
private _getTokensAtPosition(grammar: IGrammar, position: Position): ITextMateTokenInfo {
|
||||
const lineNumber = position.lineNumber;
|
||||
let stateBeforeLine = this._getStateBeforeLine(grammar, lineNumber);
|
||||
|
||||
let tokenizationResult1 = grammar.tokenizeLine(this._model.getLineContent(lineNumber), stateBeforeLine);
|
||||
let tokenizationResult2 = grammar.tokenizeLine2(this._model.getLineContent(lineNumber), stateBeforeLine);
|
||||
|
||||
let token1Index = 0;
|
||||
for (let i = tokenizationResult1.tokens.length - 1; i >= 0; i--) {
|
||||
let t = tokenizationResult1.tokens[i];
|
||||
if (position.column - 1 >= t.startIndex) {
|
||||
token1Index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let token2Index = 0;
|
||||
for (let i = (tokenizationResult2.tokens.length >>> 1); i >= 0; i--) {
|
||||
if (position.column - 1 >= tokenizationResult2.tokens[(i << 1)]) {
|
||||
token2Index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: tokenizationResult1.tokens[token1Index],
|
||||
metadata: this._decodeMetadata(tokenizationResult2.tokens[(token2Index << 1) + 1])
|
||||
};
|
||||
}
|
||||
|
||||
private _getStateBeforeLine(grammar: IGrammar, lineNumber: number): StackElement | null {
|
||||
let state: StackElement | null = null;
|
||||
|
||||
for (let i = 1; i < lineNumber; i++) {
|
||||
let tokenizationResult = grammar.tokenizeLine(this._model.getLineContent(i), state);
|
||||
state = tokenizationResult.ruleStack;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private isSemanticTokens(token: any): token is SemanticTokens {
|
||||
return token && token.data;
|
||||
}
|
||||
|
||||
private async _computeSemanticTokens(position: Position): Promise<SemanticTokensResult | null> {
|
||||
if (!this._isSemanticColoringEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenProviders = DocumentSemanticTokensProviderRegistry.ordered(this._model);
|
||||
if (tokenProviders.length) {
|
||||
const provider = tokenProviders[0];
|
||||
const tokens = await Promise.resolve(provider.provideDocumentSemanticTokens(this._model, null, this._currentRequestCancellationTokenSource.token));
|
||||
if (this.isSemanticTokens(tokens)) {
|
||||
return { tokens, legend: provider.getLegend() };
|
||||
}
|
||||
}
|
||||
const rangeTokenProviders = DocumentRangeSemanticTokensProviderRegistry.ordered(this._model);
|
||||
if (rangeTokenProviders.length) {
|
||||
const provider = rangeTokenProviders[0];
|
||||
const lineNumber = position.lineNumber;
|
||||
const range = new Range(lineNumber, 1, lineNumber, this._model.getLineMaxColumn(lineNumber));
|
||||
const tokens = await Promise.resolve(provider.provideDocumentRangeSemanticTokens(this._model, range, this._currentRequestCancellationTokenSource.token));
|
||||
if (this.isSemanticTokens(tokens)) {
|
||||
return { tokens, legend: provider.getLegend() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _getSemanticTokenAtPosition(semanticTokens: SemanticTokensResult, pos: Position): ISemanticTokenInfo | null {
|
||||
const tokenData = semanticTokens.tokens.data;
|
||||
const defaultLanguage = this._model.getLanguageIdentifier().language;
|
||||
let lastLine = 0;
|
||||
let lastCharacter = 0;
|
||||
const posLine = pos.lineNumber - 1, posCharacter = pos.column - 1; // to 0-based position
|
||||
for (let i = 0; i < tokenData.length; i += 5) {
|
||||
const lineDelta = tokenData[i], charDelta = tokenData[i + 1], len = tokenData[i + 2], typeIdx = tokenData[i + 3], modSet = tokenData[i + 4];
|
||||
const line = lastLine + lineDelta; // 0-based
|
||||
const character = lineDelta === 0 ? lastCharacter + charDelta : charDelta; // 0-based
|
||||
if (posLine === line && character <= posCharacter && posCharacter < character + len) {
|
||||
const type = semanticTokens.legend.tokenTypes[typeIdx] || 'not in legend (ignored)';
|
||||
const modifiers = [];
|
||||
let modifierSet = modSet;
|
||||
for (let modifierIndex = 0; modifierSet > 0 && modifierIndex < semanticTokens.legend.tokenModifiers.length; modifierIndex++) {
|
||||
if (modifierSet & 1) {
|
||||
modifiers.push(semanticTokens.legend.tokenModifiers[modifierIndex]);
|
||||
}
|
||||
modifierSet = modifierSet >> 1;
|
||||
}
|
||||
if (modifierSet > 0) {
|
||||
modifiers.push('not in legend (ignored)');
|
||||
}
|
||||
const range = new Range(line + 1, character + 1, line + 1, character + 1 + len);
|
||||
const definitions = {};
|
||||
const colorMap = this._themeService.getColorTheme().tokenColorMap;
|
||||
const theme = this._themeService.getColorTheme() as ColorThemeData;
|
||||
const tokenStyle = theme.getTokenStyleMetadata(type, modifiers, defaultLanguage, true, definitions);
|
||||
|
||||
let metadata: IDecodedMetadata | undefined = undefined;
|
||||
if (tokenStyle) {
|
||||
metadata = {
|
||||
languageIdentifier: this._modeService.getLanguageIdentifier(LanguageId.Null)!,
|
||||
tokenType: StandardTokenType.Other,
|
||||
bold: tokenStyle?.bold,
|
||||
italic: tokenStyle?.italic,
|
||||
underline: tokenStyle?.underline,
|
||||
foreground: colorMap[tokenStyle?.foreground || ColorId.None]
|
||||
};
|
||||
}
|
||||
|
||||
return { type, modifiers, range, metadata, definitions };
|
||||
}
|
||||
lastLine = line;
|
||||
lastCharacter = character;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _renderTokenStyleDefinition(definition: TokenStyleDefinition | undefined, property: keyof TokenStyleData): Array<HTMLElement | string> {
|
||||
const elements = new Array<HTMLElement | string>();
|
||||
if (definition === undefined) {
|
||||
return elements;
|
||||
}
|
||||
const theme = this._themeService.getColorTheme() as ColorThemeData;
|
||||
|
||||
if (Array.isArray(definition)) {
|
||||
const scopesDefinition: TextMateThemingRuleDefinitions = {};
|
||||
theme.resolveScopes(definition, scopesDefinition);
|
||||
const matchingRule = scopesDefinition[property];
|
||||
if (matchingRule && scopesDefinition.scope) {
|
||||
const scopes = $('ul.tiw-metadata-values');
|
||||
const strScopes = Array.isArray(matchingRule.scope) ? matchingRule.scope : [String(matchingRule.scope)];
|
||||
|
||||
for (let strScope of strScopes) {
|
||||
scopes.appendChild($('li.tiw-metadata-value.tiw-metadata-scopes', undefined, strScope));
|
||||
}
|
||||
|
||||
elements.push(
|
||||
scopesDefinition.scope.join(' '),
|
||||
scopes,
|
||||
$('code.tiw-theme-selector', undefined, JSON.stringify(matchingRule.settings, null, '\t')));
|
||||
return elements;
|
||||
}
|
||||
return elements;
|
||||
} else if (SemanticTokenRule.is(definition)) {
|
||||
const scope = theme.getTokenStylingRuleScope(definition);
|
||||
if (scope === 'setting') {
|
||||
elements.push(`User settings: ${definition.selector.id} - ${this._renderStyleProperty(definition.style, property)}`);
|
||||
return elements;
|
||||
} else if (scope === 'theme') {
|
||||
elements.push(`Color theme: ${definition.selector.id} - ${this._renderStyleProperty(definition.style, property)}`);
|
||||
return elements;
|
||||
}
|
||||
return elements;
|
||||
} else {
|
||||
const style = theme.resolveTokenStyleValue(definition);
|
||||
elements.push(`Default: ${style ? this._renderStyleProperty(style, property) : ''}`);
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
private _renderStyleProperty(style: TokenStyle, property: keyof TokenStyleData) {
|
||||
switch (property) {
|
||||
case 'foreground': return style.foreground ? Color.Format.CSS.formatHexA(style.foreground, true) : '';
|
||||
default: return style[property] !== undefined ? String(style[property]) : '';
|
||||
}
|
||||
}
|
||||
|
||||
public getDomNode(): HTMLElement {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
public getPosition(): IContentWidgetPosition {
|
||||
return {
|
||||
position: this._editor.getPosition(),
|
||||
preference: [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(InspectEditorTokensController.ID, InspectEditorTokensController);
|
||||
registerEditorAction(InspectEditorTokens);
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const border = theme.getColor(editorHoverBorder);
|
||||
if (border) {
|
||||
let borderWidth = theme.type === ColorScheme.HIGH_CONTRAST ? 2 : 1;
|
||||
collector.addRule(`.monaco-editor .token-inspect-widget { border: ${borderWidth}px solid ${border}; }`);
|
||||
collector.addRule(`.monaco-editor .token-inspect-widget .tiw-metadata-separator { background-color: ${border}; }`);
|
||||
}
|
||||
const background = theme.getColor(editorHoverBackground);
|
||||
if (background) {
|
||||
collector.addRule(`.monaco-editor .token-inspect-widget { background-color: ${background}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorAction, ServicesAccessor, registerEditorAction } from 'vs/editor/browser/editorExtensions';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { CATEGORIES, Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
import { SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
|
||||
class InspectKeyMap extends EditorAction {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.inspectKeyMappings',
|
||||
label: nls.localize('workbench.action.inspectKeyMap', "Developer: Inspect Key Mappings"),
|
||||
alias: 'Developer: Inspect Key Mappings',
|
||||
precondition: undefined
|
||||
});
|
||||
}
|
||||
|
||||
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
const keybindingService = accessor.get(IKeybindingService);
|
||||
const editorService = accessor.get(IEditorService);
|
||||
|
||||
editorService.openEditor({ contents: keybindingService._dumpDebugInfo(), options: { pinned: true } });
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorAction(InspectKeyMap);
|
||||
|
||||
class InspectKeyMapJSON extends Action {
|
||||
public static readonly ID = 'workbench.action.inspectKeyMappingsJSON';
|
||||
public static readonly LABEL = nls.localize('workbench.action.inspectKeyMapJSON', "Inspect Key Mappings (JSON)");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IKeybindingService private readonly _keybindingService: IKeybindingService,
|
||||
@IEditorService private readonly _editorService: IEditorService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
public run(): Promise<any> {
|
||||
return this._editorService.openEditor({ contents: this._keybindingService._dumpDebugInfoJSON(), options: { pinned: true } });
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(InspectKeyMapJSON), 'Developer: Inspect Key Mappings (JSON)', CATEGORIES.Developer.value);
|
||||
@@ -0,0 +1,609 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { ParseError, parse, getNodeType } from 'vs/base/common/json';
|
||||
import { IJSONSchema } from 'vs/base/common/jsonSchema';
|
||||
import * as types from 'vs/base/common/types';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { LanguageIdentifier } from 'vs/editor/common/modes';
|
||||
import { CharacterPair, CommentRule, FoldingRules, IAutoClosingPair, IAutoClosingPairConditional, IndentationRule, LanguageConfiguration } from 'vs/editor/common/modes/languageConfiguration';
|
||||
import { LanguageConfigurationRegistry } from 'vs/editor/common/modes/languageConfigurationRegistry';
|
||||
import { IModeService } from 'vs/editor/common/services/modeService';
|
||||
import { Extensions, IJSONContributionRegistry } from 'vs/platform/jsonschemas/common/jsonContributionRegistry';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
|
||||
import { ITextMateService } from 'vs/workbench/services/textMate/common/textMateService';
|
||||
import { getParseErrorMessage } from 'vs/base/common/jsonErrorMessages';
|
||||
import { IExtensionResourceLoaderService } from 'vs/workbench/services/extensionResourceLoader/common/extensionResourceLoader';
|
||||
|
||||
interface IRegExp {
|
||||
pattern: string;
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
interface IIndentationRules {
|
||||
decreaseIndentPattern: string | IRegExp;
|
||||
increaseIndentPattern: string | IRegExp;
|
||||
indentNextLinePattern?: string | IRegExp;
|
||||
unIndentedLinePattern?: string | IRegExp;
|
||||
}
|
||||
|
||||
interface ILanguageConfiguration {
|
||||
comments?: CommentRule;
|
||||
brackets?: CharacterPair[];
|
||||
autoClosingPairs?: Array<CharacterPair | IAutoClosingPairConditional>;
|
||||
surroundingPairs?: Array<CharacterPair | IAutoClosingPair>;
|
||||
wordPattern?: string | IRegExp;
|
||||
indentationRules?: IIndentationRules;
|
||||
folding?: FoldingRules;
|
||||
autoCloseBefore?: string;
|
||||
}
|
||||
|
||||
function isStringArr(something: string[] | null): something is string[] {
|
||||
if (!Array.isArray(something)) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0, len = something.length; i < len; i++) {
|
||||
if (typeof something[i] !== 'string') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function isCharacterPair(something: CharacterPair | null): boolean {
|
||||
return (
|
||||
isStringArr(something)
|
||||
&& something.length === 2
|
||||
);
|
||||
}
|
||||
|
||||
export class LanguageConfigurationFileHandler {
|
||||
|
||||
private _done: boolean[];
|
||||
|
||||
constructor(
|
||||
@ITextMateService textMateService: ITextMateService,
|
||||
@IModeService private readonly _modeService: IModeService,
|
||||
@IExtensionResourceLoaderService private readonly _extensionResourceLoaderService: IExtensionResourceLoaderService,
|
||||
@IExtensionService private readonly _extensionService: IExtensionService
|
||||
) {
|
||||
this._done = [];
|
||||
|
||||
// Listen for hints that a language configuration is needed/usefull and then load it once
|
||||
this._modeService.onDidCreateMode((mode) => {
|
||||
const languageIdentifier = mode.getLanguageIdentifier();
|
||||
// Modes can be instantiated before the extension points have finished registering
|
||||
this._extensionService.whenInstalledExtensionsRegistered().then(() => {
|
||||
this._loadConfigurationsForMode(languageIdentifier);
|
||||
});
|
||||
});
|
||||
textMateService.onDidEncounterLanguage((languageId) => {
|
||||
this._loadConfigurationsForMode(this._modeService.getLanguageIdentifier(languageId)!);
|
||||
});
|
||||
}
|
||||
|
||||
private _loadConfigurationsForMode(languageIdentifier: LanguageIdentifier): void {
|
||||
if (this._done[languageIdentifier.id]) {
|
||||
return;
|
||||
}
|
||||
this._done[languageIdentifier.id] = true;
|
||||
|
||||
let configurationFiles = this._modeService.getConfigurationFiles(languageIdentifier.language);
|
||||
configurationFiles.forEach((configFileLocation) => this._handleConfigFile(languageIdentifier, configFileLocation));
|
||||
}
|
||||
|
||||
private _handleConfigFile(languageIdentifier: LanguageIdentifier, configFileLocation: URI): void {
|
||||
this._extensionResourceLoaderService.readExtensionResource(configFileLocation).then((contents) => {
|
||||
const errors: ParseError[] = [];
|
||||
let configuration = <ILanguageConfiguration>parse(contents, errors);
|
||||
if (errors.length) {
|
||||
console.error(nls.localize('parseErrors', "Errors parsing {0}: {1}", configFileLocation.toString(), errors.map(e => (`[${e.offset}, ${e.length}] ${getParseErrorMessage(e.error)}`)).join('\n')));
|
||||
}
|
||||
if (getNodeType(configuration) !== 'object') {
|
||||
console.error(nls.localize('formatError', "{0}: Invalid format, JSON object expected.", configFileLocation.toString()));
|
||||
configuration = {};
|
||||
}
|
||||
this._handleConfig(languageIdentifier, configuration);
|
||||
}, (err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
private _extractValidCommentRule(languageIdentifier: LanguageIdentifier, configuration: ILanguageConfiguration): CommentRule | null {
|
||||
const source = configuration.comments;
|
||||
if (typeof source === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (!types.isObject(source)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`comments\` to be an object.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: CommentRule | null = null;
|
||||
if (typeof source.lineComment !== 'undefined') {
|
||||
if (typeof source.lineComment !== 'string') {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`comments.lineComment\` to be a string.`);
|
||||
} else {
|
||||
result = result || {};
|
||||
result.lineComment = source.lineComment;
|
||||
}
|
||||
}
|
||||
if (typeof source.blockComment !== 'undefined') {
|
||||
if (!isCharacterPair(source.blockComment)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`comments.blockComment\` to be an array of two strings.`);
|
||||
} else {
|
||||
result = result || {};
|
||||
result.blockComment = source.blockComment;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _extractValidBrackets(languageIdentifier: LanguageIdentifier, configuration: ILanguageConfiguration): CharacterPair[] | null {
|
||||
const source = configuration.brackets;
|
||||
if (typeof source === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(source)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`brackets\` to be an array.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: CharacterPair[] | null = null;
|
||||
for (let i = 0, len = source.length; i < len; i++) {
|
||||
const pair = source[i];
|
||||
if (!isCharacterPair(pair)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`brackets[${i}]\` to be an array of two strings.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
result = result || [];
|
||||
result.push(pair);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _extractValidAutoClosingPairs(languageIdentifier: LanguageIdentifier, configuration: ILanguageConfiguration): IAutoClosingPairConditional[] | null {
|
||||
const source = configuration.autoClosingPairs;
|
||||
if (typeof source === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(source)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs\` to be an array.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: IAutoClosingPairConditional[] | null = null;
|
||||
for (let i = 0, len = source.length; i < len; i++) {
|
||||
const pair = source[i];
|
||||
if (Array.isArray(pair)) {
|
||||
if (!isCharacterPair(pair)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs[${i}]\` to be an array of two strings or an object.`);
|
||||
continue;
|
||||
}
|
||||
result = result || [];
|
||||
result.push({ open: pair[0], close: pair[1] });
|
||||
} else {
|
||||
if (!types.isObject(pair)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs[${i}]\` to be an array of two strings or an object.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof pair.open !== 'string') {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs[${i}].open\` to be a string.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof pair.close !== 'string') {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs[${i}].close\` to be a string.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof pair.notIn !== 'undefined') {
|
||||
if (!isStringArr(pair.notIn)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`autoClosingPairs[${i}].notIn\` to be a string array.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = result || [];
|
||||
result.push({ open: pair.open, close: pair.close, notIn: pair.notIn });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _extractValidSurroundingPairs(languageIdentifier: LanguageIdentifier, configuration: ILanguageConfiguration): IAutoClosingPair[] | null {
|
||||
const source = configuration.surroundingPairs;
|
||||
if (typeof source === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(source)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`surroundingPairs\` to be an array.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: IAutoClosingPair[] | null = null;
|
||||
for (let i = 0, len = source.length; i < len; i++) {
|
||||
const pair = source[i];
|
||||
if (Array.isArray(pair)) {
|
||||
if (!isCharacterPair(pair)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`surroundingPairs[${i}]\` to be an array of two strings or an object.`);
|
||||
continue;
|
||||
}
|
||||
result = result || [];
|
||||
result.push({ open: pair[0], close: pair[1] });
|
||||
} else {
|
||||
if (!types.isObject(pair)) {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`surroundingPairs[${i}]\` to be an array of two strings or an object.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof pair.open !== 'string') {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`surroundingPairs[${i}].open\` to be a string.`);
|
||||
continue;
|
||||
}
|
||||
if (typeof pair.close !== 'string') {
|
||||
console.warn(`[${languageIdentifier.language}]: language configuration: expected \`surroundingPairs[${i}].close\` to be a string.`);
|
||||
continue;
|
||||
}
|
||||
result = result || [];
|
||||
result.push({ open: pair.open, close: pair.close });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// private _mapCharacterPairs(pairs: Array<CharacterPair | IAutoClosingPairConditional>): IAutoClosingPairConditional[] {
|
||||
// return pairs.map(pair => {
|
||||
// if (Array.isArray(pair)) {
|
||||
// return { open: pair[0], close: pair[1] };
|
||||
// }
|
||||
// return <IAutoClosingPairConditional>pair;
|
||||
// });
|
||||
// }
|
||||
|
||||
private _handleConfig(languageIdentifier: LanguageIdentifier, configuration: ILanguageConfiguration): void {
|
||||
|
||||
let richEditConfig: LanguageConfiguration = {};
|
||||
|
||||
const comments = this._extractValidCommentRule(languageIdentifier, configuration);
|
||||
if (comments) {
|
||||
richEditConfig.comments = comments;
|
||||
}
|
||||
|
||||
const brackets = this._extractValidBrackets(languageIdentifier, configuration);
|
||||
if (brackets) {
|
||||
richEditConfig.brackets = brackets;
|
||||
}
|
||||
|
||||
const autoClosingPairs = this._extractValidAutoClosingPairs(languageIdentifier, configuration);
|
||||
if (autoClosingPairs) {
|
||||
richEditConfig.autoClosingPairs = autoClosingPairs;
|
||||
}
|
||||
|
||||
const surroundingPairs = this._extractValidSurroundingPairs(languageIdentifier, configuration);
|
||||
if (surroundingPairs) {
|
||||
richEditConfig.surroundingPairs = surroundingPairs;
|
||||
}
|
||||
|
||||
const autoCloseBefore = configuration.autoCloseBefore;
|
||||
if (typeof autoCloseBefore === 'string') {
|
||||
richEditConfig.autoCloseBefore = autoCloseBefore;
|
||||
}
|
||||
|
||||
if (configuration.wordPattern) {
|
||||
try {
|
||||
let wordPattern = this._parseRegex(configuration.wordPattern);
|
||||
if (wordPattern) {
|
||||
richEditConfig.wordPattern = wordPattern;
|
||||
}
|
||||
} catch (error) {
|
||||
// Malformed regexes are ignored
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.indentationRules) {
|
||||
let indentationRules = this._mapIndentationRules(configuration.indentationRules);
|
||||
if (indentationRules) {
|
||||
richEditConfig.indentationRules = indentationRules;
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.folding) {
|
||||
let markers = configuration.folding.markers;
|
||||
|
||||
richEditConfig.folding = {
|
||||
offSide: configuration.folding.offSide,
|
||||
markers: markers ? { start: new RegExp(markers.start), end: new RegExp(markers.end) } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
LanguageConfigurationRegistry.register(languageIdentifier, richEditConfig);
|
||||
}
|
||||
|
||||
private _parseRegex(value: string | IRegExp) {
|
||||
if (typeof value === 'string') {
|
||||
return new RegExp(value, '');
|
||||
} else if (typeof value === 'object') {
|
||||
return new RegExp(value.pattern, value.flags);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _mapIndentationRules(indentationRules: IIndentationRules): IndentationRule | null {
|
||||
try {
|
||||
let increaseIndentPattern = this._parseRegex(indentationRules.increaseIndentPattern);
|
||||
let decreaseIndentPattern = this._parseRegex(indentationRules.decreaseIndentPattern);
|
||||
|
||||
if (increaseIndentPattern && decreaseIndentPattern) {
|
||||
let result: IndentationRule = {
|
||||
increaseIndentPattern: increaseIndentPattern,
|
||||
decreaseIndentPattern: decreaseIndentPattern
|
||||
};
|
||||
|
||||
if (indentationRules.indentNextLinePattern) {
|
||||
result.indentNextLinePattern = this._parseRegex(indentationRules.indentNextLinePattern);
|
||||
}
|
||||
if (indentationRules.unIndentedLinePattern) {
|
||||
result.unIndentedLinePattern = this._parseRegex(indentationRules.unIndentedLinePattern);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Malformed regexes are ignored
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const schemaId = 'vscode://schemas/language-configuration';
|
||||
const schema: IJSONSchema = {
|
||||
allowComments: true,
|
||||
allowTrailingCommas: true,
|
||||
default: {
|
||||
comments: {
|
||||
blockComment: ['/*', '*/'],
|
||||
lineComment: '//'
|
||||
},
|
||||
brackets: [['(', ')'], ['[', ']'], ['{', '}']],
|
||||
autoClosingPairs: [['(', ')'], ['[', ']'], ['{', '}']],
|
||||
surroundingPairs: [['(', ')'], ['[', ']'], ['{', '}']]
|
||||
},
|
||||
definitions: {
|
||||
openBracket: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.openBracket', 'The opening bracket character or string sequence.')
|
||||
},
|
||||
closeBracket: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.closeBracket', 'The closing bracket character or string sequence.')
|
||||
},
|
||||
bracketPair: {
|
||||
type: 'array',
|
||||
items: [{
|
||||
$ref: '#definitions/openBracket'
|
||||
}, {
|
||||
$ref: '#definitions/closeBracket'
|
||||
}]
|
||||
}
|
||||
},
|
||||
properties: {
|
||||
comments: {
|
||||
default: {
|
||||
blockComment: ['/*', '*/'],
|
||||
lineComment: '//'
|
||||
},
|
||||
description: nls.localize('schema.comments', 'Defines the comment symbols'),
|
||||
type: 'object',
|
||||
properties: {
|
||||
blockComment: {
|
||||
type: 'array',
|
||||
description: nls.localize('schema.blockComments', 'Defines how block comments are marked.'),
|
||||
items: [{
|
||||
type: 'string',
|
||||
description: nls.localize('schema.blockComment.begin', 'The character sequence that starts a block comment.')
|
||||
}, {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.blockComment.end', 'The character sequence that ends a block comment.')
|
||||
}]
|
||||
},
|
||||
lineComment: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.lineComment', 'The character sequence that starts a line comment.')
|
||||
}
|
||||
}
|
||||
},
|
||||
brackets: {
|
||||
default: [['(', ')'], ['[', ']'], ['{', '}']],
|
||||
description: nls.localize('schema.brackets', 'Defines the bracket symbols that increase or decrease the indentation.'),
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#definitions/bracketPair'
|
||||
}
|
||||
},
|
||||
autoClosingPairs: {
|
||||
default: [['(', ')'], ['[', ']'], ['{', '}']],
|
||||
description: nls.localize('schema.autoClosingPairs', 'Defines the bracket pairs. When a opening bracket is entered, the closing bracket is inserted automatically.'),
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [{
|
||||
$ref: '#definitions/bracketPair'
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
open: {
|
||||
$ref: '#definitions/openBracket'
|
||||
},
|
||||
close: {
|
||||
$ref: '#definitions/closeBracket'
|
||||
},
|
||||
notIn: {
|
||||
type: 'array',
|
||||
description: nls.localize('schema.autoClosingPairs.notIn', 'Defines a list of scopes where the auto pairs are disabled.'),
|
||||
items: {
|
||||
enum: ['string', 'comment']
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
autoCloseBefore: {
|
||||
default: ';:.,=}])> \n\t',
|
||||
description: nls.localize('schema.autoCloseBefore', 'Defines what characters must be after the cursor in order for bracket or quote autoclosing to occur when using the \'languageDefined\' autoclosing setting. This is typically the set of characters which can not start an expression.'),
|
||||
type: 'string',
|
||||
},
|
||||
surroundingPairs: {
|
||||
default: [['(', ')'], ['[', ']'], ['{', '}']],
|
||||
description: nls.localize('schema.surroundingPairs', 'Defines the bracket pairs that can be used to surround a selected string.'),
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [{
|
||||
$ref: '#definitions/bracketPair'
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
open: {
|
||||
$ref: '#definitions/openBracket'
|
||||
},
|
||||
close: {
|
||||
$ref: '#definitions/closeBracket'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
wordPattern: {
|
||||
default: '',
|
||||
description: nls.localize('schema.wordPattern', 'Defines what is considered to be a word in the programming language.'),
|
||||
type: ['string', 'object'],
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.wordPattern.pattern', 'The RegExp pattern used to match words.'),
|
||||
default: '',
|
||||
},
|
||||
flags: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.wordPattern.flags', 'The RegExp flags used to match words.'),
|
||||
default: 'g',
|
||||
pattern: '^([gimuy]+)$',
|
||||
patternErrorMessage: nls.localize('schema.wordPattern.flags.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.')
|
||||
}
|
||||
}
|
||||
},
|
||||
indentationRules: {
|
||||
default: {
|
||||
increaseIndentPattern: '',
|
||||
decreaseIndentPattern: ''
|
||||
},
|
||||
description: nls.localize('schema.indentationRules', 'The language\'s indentation settings.'),
|
||||
type: 'object',
|
||||
properties: {
|
||||
increaseIndentPattern: {
|
||||
type: ['string', 'object'],
|
||||
description: nls.localize('schema.indentationRules.increaseIndentPattern', 'If a line matches this pattern, then all the lines after it should be indented once (until another rule matches).'),
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.increaseIndentPattern.pattern', 'The RegExp pattern for increaseIndentPattern.'),
|
||||
default: '',
|
||||
},
|
||||
flags: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.increaseIndentPattern.flags', 'The RegExp flags for increaseIndentPattern.'),
|
||||
default: '',
|
||||
pattern: '^([gimuy]+)$',
|
||||
patternErrorMessage: nls.localize('schema.indentationRules.increaseIndentPattern.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.')
|
||||
}
|
||||
}
|
||||
},
|
||||
decreaseIndentPattern: {
|
||||
type: ['string', 'object'],
|
||||
description: nls.localize('schema.indentationRules.decreaseIndentPattern', 'If a line matches this pattern, then all the lines after it should be unindented once (until another rule matches).'),
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.decreaseIndentPattern.pattern', 'The RegExp pattern for decreaseIndentPattern.'),
|
||||
default: '',
|
||||
},
|
||||
flags: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.decreaseIndentPattern.flags', 'The RegExp flags for decreaseIndentPattern.'),
|
||||
default: '',
|
||||
pattern: '^([gimuy]+)$',
|
||||
patternErrorMessage: nls.localize('schema.indentationRules.decreaseIndentPattern.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.')
|
||||
}
|
||||
}
|
||||
},
|
||||
indentNextLinePattern: {
|
||||
type: ['string', 'object'],
|
||||
description: nls.localize('schema.indentationRules.indentNextLinePattern', 'If a line matches this pattern, then **only the next line** after it should be indented once.'),
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.indentNextLinePattern.pattern', 'The RegExp pattern for indentNextLinePattern.'),
|
||||
default: '',
|
||||
},
|
||||
flags: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.indentNextLinePattern.flags', 'The RegExp flags for indentNextLinePattern.'),
|
||||
default: '',
|
||||
pattern: '^([gimuy]+)$',
|
||||
patternErrorMessage: nls.localize('schema.indentationRules.indentNextLinePattern.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.')
|
||||
}
|
||||
}
|
||||
},
|
||||
unIndentedLinePattern: {
|
||||
type: ['string', 'object'],
|
||||
description: nls.localize('schema.indentationRules.unIndentedLinePattern', 'If a line matches this pattern, then its indentation should not be changed and it should not be evaluated against the other rules.'),
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.unIndentedLinePattern.pattern', 'The RegExp pattern for unIndentedLinePattern.'),
|
||||
default: '',
|
||||
},
|
||||
flags: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.indentationRules.unIndentedLinePattern.flags', 'The RegExp flags for unIndentedLinePattern.'),
|
||||
default: '',
|
||||
pattern: '^([gimuy]+)$',
|
||||
patternErrorMessage: nls.localize('schema.indentationRules.unIndentedLinePattern.errorMessage', 'Must match the pattern `/^([gimuy]+)$/`.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
folding: {
|
||||
type: 'object',
|
||||
description: nls.localize('schema.folding', 'The language\'s folding settings.'),
|
||||
properties: {
|
||||
offSide: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('schema.folding.offSide', 'A language adheres to the off-side rule if blocks in that language are expressed by their indentation. If set, empty lines belong to the subsequent block.'),
|
||||
},
|
||||
markers: {
|
||||
type: 'object',
|
||||
description: nls.localize('schema.folding.markers', 'Language specific folding markers such as \'#region\' and \'#endregion\'. The start and end regexes will be tested against the contents of all lines and must be designed efficiently'),
|
||||
properties: {
|
||||
start: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.folding.markers.start', 'The RegExp pattern for the start marker. The regexp must start with \'^\'.')
|
||||
},
|
||||
end: {
|
||||
type: 'string',
|
||||
description: nls.localize('schema.folding.markers.end', 'The RegExp pattern for the end marker. The regexp must start with \'^\'.')
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
let schemaRegistry = Registry.as<IJSONContributionRegistry>(Extensions.JSONContribution);
|
||||
schemaRegistry.registerSchema(schemaId, schema);
|
||||
@@ -0,0 +1,70 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as path from 'vs/base/common/path';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
|
||||
import { IStorageKeysSyncRegistryService } from 'vs/platform/userDataSync/common/storageKeys';
|
||||
|
||||
/**
|
||||
* Shows a message when opening a large file which has been memory optimized (and features disabled).
|
||||
*/
|
||||
export class LargeFileOptimizationsWarner extends Disposable implements IEditorContribution {
|
||||
|
||||
public static readonly ID = 'editor.contrib.largeFileOptimizationsWarner';
|
||||
|
||||
constructor(
|
||||
private readonly _editor: ICodeEditor,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IStorageKeysSyncRegistryService storageKeysSyncRegistryService: IStorageKeysSyncRegistryService
|
||||
) {
|
||||
super();
|
||||
|
||||
// opt-in to syncing
|
||||
const neverShowAgainId = 'editor.contrib.largeFileOptimizationsWarner';
|
||||
storageKeysSyncRegistryService.registerStorageKey({ key: neverShowAgainId, version: 1 });
|
||||
|
||||
this._register(this._editor.onDidChangeModel((e) => {
|
||||
const model = this._editor.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.isTooLargeForTokenization()) {
|
||||
const message = nls.localize(
|
||||
{
|
||||
key: 'largeFile',
|
||||
comment: [
|
||||
'Variable 0 will be a file name.'
|
||||
]
|
||||
},
|
||||
"{0}: tokenization, wrapping and folding have been turned off for this large file in order to reduce memory usage and avoid freezing or crashing.",
|
||||
path.basename(model.uri.path)
|
||||
);
|
||||
|
||||
this._notificationService.prompt(Severity.Info, message, [
|
||||
{
|
||||
label: nls.localize('removeOptimizations', "Forcefully enable features"),
|
||||
run: () => {
|
||||
this._configurationService.updateValue(`editor.largeFileOptimizations`, false).then(() => {
|
||||
this._notificationService.info(nls.localize('reopenFilePrompt', "Please reopen file in order for this setting to take effect."));
|
||||
}, (err) => {
|
||||
this._notificationService.error(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
], { neverShowAgain: { id: neverShowAgainId } });
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(LargeFileOptimizationsWarner.ID, LargeFileOptimizationsWarner);
|
||||
@@ -0,0 +1,60 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
|
||||
/**
|
||||
* Prevents the top-level menu from showing up when doing Alt + Click in the editor
|
||||
*/
|
||||
export class MenuPreventer extends Disposable implements IEditorContribution {
|
||||
|
||||
public static readonly ID = 'editor.contrib.menuPreventer';
|
||||
|
||||
private _editor: ICodeEditor;
|
||||
private _altListeningMouse: boolean;
|
||||
private _altMouseTriggered: boolean;
|
||||
|
||||
constructor(editor: ICodeEditor) {
|
||||
super();
|
||||
this._editor = editor;
|
||||
this._altListeningMouse = false;
|
||||
this._altMouseTriggered = false;
|
||||
|
||||
// A global crossover handler to prevent menu bar from showing up
|
||||
// When <alt> is hold, we will listen to mouse events and prevent
|
||||
// the release event up <alt> if the mouse is triggered.
|
||||
|
||||
this._register(this._editor.onMouseDown((e) => {
|
||||
if (this._altListeningMouse) {
|
||||
this._altMouseTriggered = true;
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._editor.onKeyDown((e) => {
|
||||
if (e.equals(KeyMod.Alt)) {
|
||||
if (!this._altListeningMouse) {
|
||||
this._altMouseTriggered = false;
|
||||
}
|
||||
this._altListeningMouse = true;
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._editor.onKeyUp((e) => {
|
||||
if (e.equals(KeyMod.Alt)) {
|
||||
if (this._altMouseTriggered) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this._altListeningMouse = false;
|
||||
this._altMouseTriggered = false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(MenuPreventer.ID, MenuPreventer);
|
||||
@@ -0,0 +1,90 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from 'vs/nls';
|
||||
import { IKeyMods, IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
|
||||
import { IEditor } from 'vs/editor/common/editorCommon';
|
||||
import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { IRange } from 'vs/editor/common/core/range';
|
||||
import { AbstractGotoLineQuickAccessProvider } from 'vs/editor/contrib/quickAccess/gotoLineQuickAccess';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IQuickAccessRegistry, Extensions as QuickaccesExtensions } from 'vs/platform/quickinput/common/quickAccess';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IWorkbenchEditorConfiguration } from 'vs/workbench/common/editor';
|
||||
import { Action2, registerAction2 } from 'vs/platform/actions/common/actions';
|
||||
import { KeyMod, KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
|
||||
export class GotoLineQuickAccessProvider extends AbstractGotoLineQuickAccessProvider {
|
||||
|
||||
protected readonly onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange;
|
||||
|
||||
constructor(
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private get configuration() {
|
||||
const editorConfig = this.configurationService.getValue<IWorkbenchEditorConfiguration>().workbench.editor;
|
||||
|
||||
return {
|
||||
openEditorPinned: !editorConfig.enablePreviewFromQuickOpen,
|
||||
};
|
||||
}
|
||||
|
||||
protected get activeTextEditorControl() {
|
||||
return this.editorService.activeTextEditorControl;
|
||||
}
|
||||
|
||||
protected gotoLocation(editor: IEditor, options: { range: IRange, keyMods: IKeyMods, forceSideBySide?: boolean, preserveFocus?: boolean }): void {
|
||||
|
||||
// Check for sideBySide use
|
||||
if ((options.keyMods.ctrlCmd || options.forceSideBySide) && this.editorService.activeEditor) {
|
||||
this.editorService.openEditor(this.editorService.activeEditor, {
|
||||
selection: options.range,
|
||||
pinned: options.keyMods.alt || this.configuration.openEditorPinned,
|
||||
preserveFocus: options.preserveFocus
|
||||
}, SIDE_GROUP);
|
||||
}
|
||||
|
||||
// Otherwise let parent handle it
|
||||
else {
|
||||
super.gotoLocation(editor, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IQuickAccessRegistry>(QuickaccesExtensions.Quickaccess).registerQuickAccessProvider({
|
||||
ctor: GotoLineQuickAccessProvider,
|
||||
prefix: AbstractGotoLineQuickAccessProvider.PREFIX,
|
||||
placeholder: localize('gotoLineQuickAccessPlaceholder', "Type the line number and optional column to go to (e.g. 42:5 for line 42 and column 5)."),
|
||||
helpEntries: [{ description: localize('gotoLineQuickAccess', "Go to Line/Column"), needsEditor: true }]
|
||||
});
|
||||
|
||||
class GotoLineAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.gotoLine',
|
||||
title: { value: localize('gotoLine', "Go to Line/Column..."), original: 'Go to Line/Column...' },
|
||||
f1: true,
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
when: null,
|
||||
primary: KeyMod.CtrlCmd | KeyCode.KEY_G,
|
||||
mac: { primary: KeyMod.WinCtrl | KeyCode.KEY_G }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor): Promise<void> {
|
||||
accessor.get(IQuickInputService).quickAccess.show(GotoLineQuickAccessProvider.PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
registerAction2(GotoLineAction);
|
||||
@@ -0,0 +1,283 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { localize } from 'vs/nls';
|
||||
import { IKeyMods, IQuickPickSeparator, IQuickInputService, IQuickPick } from 'vs/platform/quickinput/common/quickInput';
|
||||
import { IEditor } from 'vs/editor/common/editorCommon';
|
||||
import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { IRange } from 'vs/editor/common/core/range';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IQuickAccessRegistry, Extensions as QuickaccessExtensions } from 'vs/platform/quickinput/common/quickAccess';
|
||||
import { AbstractGotoSymbolQuickAccessProvider, IGotoSymbolQuickPickItem } from 'vs/editor/contrib/quickAccess/gotoSymbolQuickAccess';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IWorkbenchEditorConfiguration, IEditorPane } from 'vs/workbench/common/editor';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { DisposableStore, IDisposable, toDisposable, Disposable } from 'vs/base/common/lifecycle';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
import { registerAction2, Action2 } from 'vs/platform/actions/common/actions';
|
||||
import { KeyMod, KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { prepareQuery } from 'vs/base/common/fuzzyScorer';
|
||||
import { SymbolKind } from 'vs/editor/common/modes';
|
||||
import { fuzzyScore, createMatches } from 'vs/base/common/filters';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { ThemeIcon } from 'vs/platform/theme/common/themeService';
|
||||
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
|
||||
export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccessProvider {
|
||||
|
||||
protected readonly onDidActiveTextEditorControlChange = this.editorService.onDidActiveEditorChange;
|
||||
|
||||
constructor(
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService
|
||||
) {
|
||||
super({
|
||||
openSideBySideDirection: () => this.configuration.openSideBySideDirection
|
||||
});
|
||||
}
|
||||
|
||||
//#region DocumentSymbols (text editor required)
|
||||
|
||||
protected provideWithTextEditor(editor: IEditor, picker: IQuickPick<IGotoSymbolQuickPickItem>, token: CancellationToken): IDisposable {
|
||||
if (this.canPickFromTableOfContents()) {
|
||||
return this.doGetTableOfContentsPicks(picker);
|
||||
}
|
||||
return super.provideWithTextEditor(editor, picker, token);
|
||||
}
|
||||
|
||||
private get configuration() {
|
||||
const editorConfig = this.configurationService.getValue<IWorkbenchEditorConfiguration>().workbench.editor;
|
||||
|
||||
return {
|
||||
openEditorPinned: !editorConfig.enablePreviewFromQuickOpen,
|
||||
openSideBySideDirection: editorConfig.openSideBySideDirection
|
||||
};
|
||||
}
|
||||
|
||||
protected get activeTextEditorControl() {
|
||||
return this.editorService.activeTextEditorControl;
|
||||
}
|
||||
|
||||
protected gotoLocation(editor: IEditor, options: { range: IRange, keyMods: IKeyMods, forceSideBySide?: boolean, preserveFocus?: boolean }): void {
|
||||
|
||||
// Check for sideBySide use
|
||||
if ((options.keyMods.ctrlCmd || options.forceSideBySide) && this.editorService.activeEditor) {
|
||||
this.editorService.openEditor(this.editorService.activeEditor, {
|
||||
selection: options.range,
|
||||
pinned: options.keyMods.alt || this.configuration.openEditorPinned,
|
||||
preserveFocus: options.preserveFocus
|
||||
}, SIDE_GROUP);
|
||||
}
|
||||
|
||||
// Otherwise let parent handle it
|
||||
else {
|
||||
super.gotoLocation(editor, options);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region public methods to use this picker from other pickers
|
||||
|
||||
private static readonly SYMBOL_PICKS_TIMEOUT = 8000;
|
||||
|
||||
async getSymbolPicks(model: ITextModel, filter: string, options: { extraContainerLabel?: string }, disposables: DisposableStore, token: CancellationToken): Promise<Array<IGotoSymbolQuickPickItem | IQuickPickSeparator>> {
|
||||
|
||||
// If the registry does not know the model, we wait for as long as
|
||||
// the registry knows it. This helps in cases where a language
|
||||
// registry was not activated yet for providing any symbols.
|
||||
// To not wait forever, we eventually timeout though.
|
||||
const result = await Promise.race([
|
||||
this.waitForLanguageSymbolRegistry(model, disposables),
|
||||
timeout(GotoSymbolQuickAccessProvider.SYMBOL_PICKS_TIMEOUT)
|
||||
]);
|
||||
|
||||
if (!result || token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.doGetSymbolPicks(this.getDocumentSymbols(model, true, token), prepareQuery(filter), options, token);
|
||||
}
|
||||
|
||||
addDecorations(editor: IEditor, range: IRange): void {
|
||||
super.addDecorations(editor, range);
|
||||
}
|
||||
|
||||
clearDecorations(editor: IEditor): void {
|
||||
super.clearDecorations(editor);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
protected provideWithoutTextEditor(picker: IQuickPick<IGotoSymbolQuickPickItem>): IDisposable {
|
||||
if (this.canPickFromTableOfContents()) {
|
||||
return this.doGetTableOfContentsPicks(picker);
|
||||
}
|
||||
return super.provideWithoutTextEditor(picker);
|
||||
}
|
||||
|
||||
private canPickFromTableOfContents(): boolean {
|
||||
return this.editorService.activeEditorPane ? TableOfContentsProviderRegistry.has(this.editorService.activeEditorPane.getId()) : false;
|
||||
}
|
||||
|
||||
private doGetTableOfContentsPicks(picker: IQuickPick<IGotoSymbolQuickPickItem>): IDisposable {
|
||||
const pane = this.editorService.activeEditorPane;
|
||||
if (!pane) {
|
||||
return Disposable.None;
|
||||
}
|
||||
const provider = TableOfContentsProviderRegistry.get(pane.getId())!;
|
||||
const cts = new CancellationTokenSource();
|
||||
|
||||
const disposables = new DisposableStore();
|
||||
disposables.add(toDisposable(() => cts.dispose(true)));
|
||||
|
||||
picker.busy = true;
|
||||
|
||||
provider.provideTableOfContents(pane, { disposables }, cts.token).then(entries => {
|
||||
|
||||
picker.busy = false;
|
||||
|
||||
if (cts.token.isCancellationRequested || !entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items: IGotoSymbolQuickPickItem[] = entries.map((entry, idx) => {
|
||||
return {
|
||||
kind: SymbolKind.File,
|
||||
index: idx,
|
||||
score: 0,
|
||||
label: entry.icon ? `$(${entry.icon.id}) ${entry.label}` : entry.label,
|
||||
ariaLabel: entry.detail ? `${entry.label}, ${entry.detail}` : entry.label,
|
||||
detail: entry.detail,
|
||||
description: entry.description,
|
||||
};
|
||||
});
|
||||
|
||||
disposables.add(picker.onDidAccept(() => {
|
||||
picker.hide();
|
||||
const [entry] = picker.selectedItems;
|
||||
entries[entry.index]?.pick();
|
||||
}));
|
||||
|
||||
const updatePickerItems = () => {
|
||||
const filteredItems = items.filter(item => {
|
||||
if (picker.value === '@') {
|
||||
// default, no filtering, scoring...
|
||||
item.score = 0;
|
||||
item.highlights = undefined;
|
||||
return true;
|
||||
}
|
||||
const score = fuzzyScore(picker.value, picker.value.toLowerCase(), 1 /*@-character*/, item.label, item.label.toLowerCase(), 0, true);
|
||||
if (!score) {
|
||||
return false;
|
||||
}
|
||||
item.score = score[1];
|
||||
item.highlights = { label: createMatches(score) };
|
||||
return true;
|
||||
});
|
||||
if (filteredItems.length === 0) {
|
||||
const label = localize('empty', 'No matching entries');
|
||||
picker.items = [{ label, index: -1, kind: SymbolKind.String }];
|
||||
picker.ariaLabel = label;
|
||||
} else {
|
||||
picker.items = filteredItems;
|
||||
}
|
||||
};
|
||||
updatePickerItems();
|
||||
disposables.add(picker.onDidChangeValue(updatePickerItems));
|
||||
|
||||
disposables.add(picker.onDidChangeActive(() => {
|
||||
const [entry] = picker.activeItems;
|
||||
if (entry) {
|
||||
entries[entry.index]?.preview();
|
||||
}
|
||||
}));
|
||||
|
||||
}).catch(err => {
|
||||
onUnexpectedError(err);
|
||||
picker.hide();
|
||||
});
|
||||
|
||||
return disposables;
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IQuickAccessRegistry>(QuickaccessExtensions.Quickaccess).registerQuickAccessProvider({
|
||||
ctor: GotoSymbolQuickAccessProvider,
|
||||
prefix: AbstractGotoSymbolQuickAccessProvider.PREFIX,
|
||||
contextKey: 'inFileSymbolsPicker',
|
||||
placeholder: localize('gotoSymbolQuickAccessPlaceholder', "Type the name of a symbol to go to."),
|
||||
helpEntries: [
|
||||
{ description: localize('gotoSymbolQuickAccess', "Go to Symbol in Editor"), prefix: AbstractGotoSymbolQuickAccessProvider.PREFIX, needsEditor: true },
|
||||
{ description: localize('gotoSymbolByCategoryQuickAccess', "Go to Symbol in Editor by Category"), prefix: AbstractGotoSymbolQuickAccessProvider.PREFIX_BY_CATEGORY, needsEditor: true }
|
||||
]
|
||||
});
|
||||
|
||||
registerAction2(class GotoSymbolAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.gotoSymbol',
|
||||
title: {
|
||||
value: localize('gotoSymbol', "Go to Symbol in Editor..."),
|
||||
original: 'Go to Symbol in Editor...'
|
||||
},
|
||||
f1: true,
|
||||
keybinding: {
|
||||
when: undefined,
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_O
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
run(accessor: ServicesAccessor) {
|
||||
accessor.get(IQuickInputService).quickAccess.show(GotoSymbolQuickAccessProvider.PREFIX);
|
||||
}
|
||||
});
|
||||
|
||||
//#region toc definition and logic
|
||||
|
||||
export interface ITableOfContentsEntry {
|
||||
icon?: ThemeIcon;
|
||||
label: string;
|
||||
detail?: string;
|
||||
description?: string;
|
||||
pick(): any;
|
||||
preview(): any;
|
||||
}
|
||||
|
||||
export interface ITableOfContentsProvider<T extends IEditorPane = IEditorPane> {
|
||||
|
||||
provideTableOfContents(editor: T, context: { disposables: DisposableStore }, token: CancellationToken): Promise<ITableOfContentsEntry[] | undefined | null>;
|
||||
}
|
||||
|
||||
class ProviderRegistry {
|
||||
|
||||
private readonly _provider = new Map<string, ITableOfContentsProvider>();
|
||||
|
||||
register(type: string, provider: ITableOfContentsProvider): IDisposable {
|
||||
this._provider.set(type, provider);
|
||||
return toDisposable(() => {
|
||||
if (this._provider.get(type) === provider) {
|
||||
this._provider.delete(type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get(type: string): ITableOfContentsProvider | undefined {
|
||||
return this._provider.get(type);
|
||||
}
|
||||
|
||||
has(type: string): boolean {
|
||||
return this._provider.has(type);
|
||||
}
|
||||
}
|
||||
|
||||
export const TableOfContentsProviderRegistry = new ProviderRegistry();
|
||||
|
||||
//#endregion
|
||||
@@ -0,0 +1,387 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { IActiveCodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { trimTrailingWhitespace } from 'vs/editor/common/commands/trimTrailingWhitespaceCommand';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { CodeActionTriggerType, CodeActionProvider } from 'vs/editor/common/modes';
|
||||
import { getCodeActions } from 'vs/editor/contrib/codeAction/codeAction';
|
||||
import { applyCodeAction } from 'vs/editor/contrib/codeAction/codeActionCommands';
|
||||
import { CodeActionKind } from 'vs/editor/contrib/codeAction/types';
|
||||
import { formatDocumentRangesWithSelectedProvider, formatDocumentWithSelectedProvider, FormattingMode } from 'vs/editor/contrib/format/format';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { localize } from 'vs/nls';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IProgressStep, IProgress, Progress } from 'vs/platform/progress/common/progress';
|
||||
import { ITextFileService, ITextFileSaveParticipant, ITextFileEditorModel } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { IWorkbenchContribution, Extensions as WorkbenchContributionsExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { getModifiedRanges } from 'vs/workbench/contrib/format/browser/formatModified';
|
||||
import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions';
|
||||
|
||||
export class TrimWhitespaceParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: ITextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (!model.textEditorModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.configurationService.getValue('files.trimTrailingWhitespace', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimTrailingWhitespace(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
private doTrimTrailingWhitespace(model: ITextModel, isAutoSaved: boolean): void {
|
||||
let prevSelection: Selection[] = [];
|
||||
let cursors: Position[] = [];
|
||||
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
// Find `prevSelection` in any case do ensure a good undo stack when pushing the edit
|
||||
// Collect active cursors in `cursors` only if `isAutoSaved` to avoid having the cursors jump
|
||||
prevSelection = editor.getSelections();
|
||||
if (isAutoSaved) {
|
||||
cursors = prevSelection.map(s => s.getPosition());
|
||||
const snippetsRange = SnippetController2.get(editor).getSessionEnclosingRange();
|
||||
if (snippetsRange) {
|
||||
for (let lineNumber = snippetsRange.startLineNumber; lineNumber <= snippetsRange.endLineNumber; lineNumber++) {
|
||||
cursors.push(new Position(lineNumber, model.getLineMaxColumn(lineNumber)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ops = trimTrailingWhitespace(model, cursors);
|
||||
if (!ops.length) {
|
||||
return; // Nothing to do
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, ops, (_edits) => prevSelection);
|
||||
}
|
||||
}
|
||||
|
||||
function findEditor(model: ITextModel, codeEditorService: ICodeEditorService): IActiveCodeEditor | null {
|
||||
let candidate: IActiveCodeEditor | null = null;
|
||||
|
||||
if (model.isAttachedToEditor()) {
|
||||
for (const editor of codeEditorService.listCodeEditors()) {
|
||||
if (editor.hasModel() && editor.getModel() === model) {
|
||||
if (editor.hasTextFocus()) {
|
||||
return editor; // favour focused editor if there are multiple
|
||||
}
|
||||
|
||||
candidate = editor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export class FinalNewLineParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: ITextFileEditorModel, _env: { reason: SaveReason; }): Promise<void> {
|
||||
if (!model.textEditorModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.configurationService.getValue('files.insertFinalNewline', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doInsertFinalNewLine(model.textEditorModel);
|
||||
}
|
||||
}
|
||||
|
||||
private doInsertFinalNewLine(model: ITextModel): void {
|
||||
const lineCount = model.getLineCount();
|
||||
const lastLine = model.getLineContent(lineCount);
|
||||
const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;
|
||||
|
||||
if (!lineCount || lastLineIsEmptyOrWhitespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = [EditOperation.insert(new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL())];
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
editor.executeEdits('insertFinalNewLine', edits, editor.getSelections());
|
||||
} else {
|
||||
model.pushEditOperations([], edits, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TrimFinalNewLinesParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: ITextFileEditorModel, env: { reason: SaveReason; }): Promise<void> {
|
||||
if (!model.textEditorModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.configurationService.getValue('files.trimFinalNewlines', { overrideIdentifier: model.textEditorModel.getLanguageIdentifier().language, resource: model.resource })) {
|
||||
this.doTrimFinalNewLines(model.textEditorModel, env.reason === SaveReason.AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns 0 if the entire file is empty or whitespace only
|
||||
*/
|
||||
private findLastLineWithContent(model: ITextModel): number {
|
||||
for (let lineNumber = model.getLineCount(); lineNumber >= 1; lineNumber--) {
|
||||
const lineContent = model.getLineContent(lineNumber);
|
||||
if (strings.lastNonWhitespaceIndex(lineContent) !== -1) {
|
||||
// this line has content
|
||||
return lineNumber;
|
||||
}
|
||||
}
|
||||
// no line has content
|
||||
return 0;
|
||||
}
|
||||
|
||||
private doTrimFinalNewLines(model: ITextModel, isAutoSaved: boolean): void {
|
||||
const lineCount = model.getLineCount();
|
||||
|
||||
// Do not insert new line if file does not end with new line
|
||||
if (lineCount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevSelection: Selection[] = [];
|
||||
let cannotTouchLineNumber = 0;
|
||||
const editor = findEditor(model, this.codeEditorService);
|
||||
if (editor) {
|
||||
prevSelection = editor.getSelections();
|
||||
if (isAutoSaved) {
|
||||
for (let i = 0, len = prevSelection.length; i < len; i++) {
|
||||
const positionLineNumber = prevSelection[i].positionLineNumber;
|
||||
if (positionLineNumber > cannotTouchLineNumber) {
|
||||
cannotTouchLineNumber = positionLineNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastLineNumberWithContent = this.findLastLineWithContent(model);
|
||||
const deleteFromLineNumber = Math.max(lastLineNumberWithContent + 1, cannotTouchLineNumber + 1);
|
||||
const deletionRange = model.validateRange(new Range(deleteFromLineNumber, 1, lineCount, model.getLineMaxColumn(lineCount)));
|
||||
|
||||
if (deletionRange.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.pushEditOperations(prevSelection, [EditOperation.delete(deletionRange)], _edits => prevSelection);
|
||||
|
||||
if (editor) {
|
||||
editor.setSelections(prevSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FormatOnSaveParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) {
|
||||
// Nothing
|
||||
}
|
||||
|
||||
async participate(model: ITextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
if (!model.textEditorModel) {
|
||||
return;
|
||||
}
|
||||
if (env.reason === SaveReason.AUTO) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textEditorModel = model.textEditorModel;
|
||||
const overrides = { overrideIdentifier: textEditorModel.getLanguageIdentifier().language, resource: textEditorModel.uri };
|
||||
|
||||
const nestedProgress = new Progress<{ displayName?: string, extensionId?: ExtensionIdentifier }>(provider => {
|
||||
progress.report({
|
||||
message: localize(
|
||||
'formatting',
|
||||
"Running '{0}' Formatter ([configure](command:workbench.action.openSettings?%5B%22editor.formatOnSave%22%5D)).",
|
||||
provider.displayName || provider.extensionId && provider.extensionId.value || '???'
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
const enabled = this.configurationService.getValue<boolean>('editor.formatOnSave', overrides);
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const editorOrModel = findEditor(textEditorModel, this.codeEditorService) || textEditorModel;
|
||||
const mode = this.configurationService.getValue<'file' | 'modifications'>('editor.formatOnSaveMode', overrides);
|
||||
if (mode === 'modifications') {
|
||||
// format modifications
|
||||
const ranges = await this.instantiationService.invokeFunction(getModifiedRanges, isCodeEditor(editorOrModel) ? editorOrModel.getModel() : editorOrModel);
|
||||
if (ranges) {
|
||||
await this.instantiationService.invokeFunction(formatDocumentRangesWithSelectedProvider, editorOrModel, ranges, FormattingMode.Silent, nestedProgress, token);
|
||||
}
|
||||
} else {
|
||||
// format the whole file
|
||||
await this.instantiationService.invokeFunction(formatDocumentWithSelectedProvider, editorOrModel, FormattingMode.Silent, nestedProgress, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CodeActionOnSaveParticipant implements ITextFileSaveParticipant {
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
) { }
|
||||
|
||||
async participate(model: ITextFileEditorModel, env: { reason: SaveReason; }, progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
if (!model.textEditorModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.reason === SaveReason.AUTO) {
|
||||
return undefined;
|
||||
}
|
||||
const textEditorModel = model.textEditorModel;
|
||||
|
||||
const settingsOverrides = { overrideIdentifier: textEditorModel.getLanguageIdentifier().language, resource: model.resource };
|
||||
const setting = this.configurationService.getValue<{ [kind: string]: boolean } | string[]>('editor.codeActionsOnSave', settingsOverrides);
|
||||
if (!setting) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settingItems: string[] = Array.isArray(setting)
|
||||
? setting
|
||||
: Object.keys(setting).filter(x => setting[x]);
|
||||
|
||||
const codeActionsOnSave = settingItems
|
||||
.map(x => new CodeActionKind(x));
|
||||
|
||||
if (!Array.isArray(setting)) {
|
||||
codeActionsOnSave.sort((a, b) => {
|
||||
if (CodeActionKind.SourceFixAll.contains(a)) {
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (CodeActionKind.SourceFixAll.contains(b)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (!codeActionsOnSave.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const excludedActions = Array.isArray(setting)
|
||||
? []
|
||||
: Object.keys(setting)
|
||||
.filter(x => setting[x] === false)
|
||||
.map(x => new CodeActionKind(x));
|
||||
|
||||
progress.report({ message: localize('codeaction', "Quick Fixes") });
|
||||
await this.applyOnSaveActions(textEditorModel, codeActionsOnSave, excludedActions, progress, token);
|
||||
}
|
||||
|
||||
private async applyOnSaveActions(model: ITextModel, codeActionsOnSave: readonly CodeActionKind[], excludes: readonly CodeActionKind[], progress: IProgress<IProgressStep>, token: CancellationToken): Promise<void> {
|
||||
|
||||
const getActionProgress = new class implements IProgress<CodeActionProvider> {
|
||||
private _names = new Set<string>();
|
||||
private _report(): void {
|
||||
progress.report({
|
||||
message: localize(
|
||||
'codeaction.get',
|
||||
"Getting code actions from '{0}' ([configure](command:workbench.action.openSettings?%5B%22editor.codeActionsOnSave%22%5D)).",
|
||||
[...this._names].map(name => `'${name}'`).join(', ')
|
||||
)
|
||||
});
|
||||
}
|
||||
report(provider: CodeActionProvider) {
|
||||
if (provider.displayName && !this._names.has(provider.displayName)) {
|
||||
this._names.add(provider.displayName);
|
||||
this._report();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const codeActionKind of codeActionsOnSave) {
|
||||
const actionsToRun = await this.getActionsToRun(model, codeActionKind, excludes, getActionProgress, token);
|
||||
try {
|
||||
for (const action of actionsToRun.validActions) {
|
||||
progress.report({ message: localize('codeAction.apply', "Applying code action '{0}'.", action.action.title) });
|
||||
await this.instantiationService.invokeFunction(applyCodeAction, action);
|
||||
}
|
||||
} catch {
|
||||
// Failure to apply a code action should not block other on save actions
|
||||
} finally {
|
||||
actionsToRun.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getActionsToRun(model: ITextModel, codeActionKind: CodeActionKind, excludes: readonly CodeActionKind[], progress: IProgress<CodeActionProvider>, token: CancellationToken) {
|
||||
return getCodeActions(model, model.getFullModelRange(), {
|
||||
type: CodeActionTriggerType.Auto,
|
||||
filter: { include: codeActionKind, excludes: excludes, includeSourceActions: true },
|
||||
}, progress, token);
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveParticipantsContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ITextFileService private readonly textFileService: ITextFileService
|
||||
) {
|
||||
super();
|
||||
|
||||
this.registerSaveParticipants();
|
||||
}
|
||||
|
||||
private registerSaveParticipants(): void {
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimWhitespaceParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(CodeActionOnSaveParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FormatOnSaveParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(FinalNewLineParticipant)));
|
||||
this._register(this.textFileService.files.addSaveParticipant(this.instantiationService.createInstance(TrimFinalNewLinesParticipant)));
|
||||
}
|
||||
}
|
||||
|
||||
const workbenchContributionsRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchContributionsExtensions.Workbench);
|
||||
workbenchContributionsRegistry.registerWorkbenchContribution(SaveParticipantsContribution, LifecyclePhase.Restored);
|
||||
@@ -0,0 +1,6 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export const SelectionClipboardContributionID = 'editor.contrib.selectionClipboard';
|
||||
@@ -0,0 +1,54 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
|
||||
import { ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget';
|
||||
import { ContextMenuController } from 'vs/editor/contrib/contextmenu/contextmenu';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
|
||||
import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer';
|
||||
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
|
||||
import { TabCompletionController } from 'vs/workbench/contrib/snippets/browser/tabCompletion';
|
||||
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
|
||||
|
||||
export function getSimpleEditorOptions(): IEditorOptions {
|
||||
return {
|
||||
wordWrap: 'on',
|
||||
overviewRulerLanes: 0,
|
||||
glyphMargin: false,
|
||||
lineNumbers: 'off',
|
||||
folding: false,
|
||||
selectOnLineNumbers: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
selectionHighlight: false,
|
||||
scrollbar: {
|
||||
horizontal: 'hidden'
|
||||
},
|
||||
lineDecorationsWidth: 0,
|
||||
overviewRulerBorder: false,
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'none',
|
||||
fixedOverflowWidgets: true,
|
||||
acceptSuggestionOnEnter: 'smart',
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
renderIndentGuides: false
|
||||
};
|
||||
}
|
||||
|
||||
export function getSimpleCodeEditorWidgetOptions(): ICodeEditorWidgetOptions {
|
||||
return {
|
||||
isSimpleWidget: true,
|
||||
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
|
||||
MenuPreventer.ID,
|
||||
SelectionClipboardContributionID,
|
||||
ContextMenuController.ID,
|
||||
SuggestController.ID,
|
||||
SnippetController2.ID,
|
||||
TabCompletionController.ID,
|
||||
])
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.suggest-input-container {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.suggest-input-container .monaco-editor-background,
|
||||
.suggest-input-container .monaco-editor,
|
||||
.suggest-input-container .mtk1 {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.suggest-input-container .suggest-input-placeholder {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggest-input-container .monaco-editor,
|
||||
.suggest-input-container .monaco-editor .lines-content {
|
||||
background: none;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./suggestEnabledInput';
|
||||
import { $, Dimension, append } from 'vs/base/browser/dom';
|
||||
import { Widget } from 'vs/base/browser/ui/widget';
|
||||
import { Color } from 'vs/base/common/color';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { mixin } from 'vs/base/common/objects';
|
||||
import { isMacintosh } from 'vs/base/common/platform';
|
||||
import { URI as uri } from 'vs/base/common/uri';
|
||||
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
|
||||
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
|
||||
import { EditOperation } from 'vs/editor/common/core/editOperation';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import * as modes from 'vs/editor/common/modes';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { ContextMenuController } from 'vs/editor/contrib/contextmenu/contextmenu';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
|
||||
import { IContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ColorIdentifier, editorSelectionBackground, inputBackground, inputBorder, inputForeground, inputPlaceholderForeground, selectionBackground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { IStyleOverrides, attachStyler } from 'vs/platform/theme/common/styler';
|
||||
import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer';
|
||||
import { getSimpleEditorOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
|
||||
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
|
||||
import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions';
|
||||
import { IThemable } from 'vs/base/common/styler';
|
||||
import { DEFAULT_FONT_FAMILY } from 'vs/workbench/browser/style';
|
||||
|
||||
interface SuggestResultsProvider {
|
||||
/**
|
||||
* Provider function for suggestion results.
|
||||
*
|
||||
* @param query the full text of the input.
|
||||
*/
|
||||
provideResults: (query: string) => string[];
|
||||
|
||||
/**
|
||||
* Trigger characters for this input. Suggestions will appear when one of these is typed,
|
||||
* or upon `ctrl+space` triggering at a word boundary.
|
||||
*
|
||||
* Defaults to the empty array.
|
||||
*/
|
||||
triggerCharacters?: string[];
|
||||
|
||||
/**
|
||||
* Defines the sorting function used when showing results.
|
||||
*
|
||||
* Defaults to the identity function.
|
||||
*/
|
||||
sortKey?: (result: string) => string;
|
||||
}
|
||||
|
||||
interface SuggestEnabledInputOptions {
|
||||
/**
|
||||
* The text to show when no input is present.
|
||||
*
|
||||
* Defaults to the empty string.
|
||||
*/
|
||||
placeholderText?: string;
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Context key tracking the focus state of this element
|
||||
*/
|
||||
focusContextKey?: IContextKey<boolean>;
|
||||
}
|
||||
|
||||
export interface ISuggestEnabledInputStyleOverrides extends IStyleOverrides {
|
||||
inputBackground?: ColorIdentifier;
|
||||
inputForeground?: ColorIdentifier;
|
||||
inputBorder?: ColorIdentifier;
|
||||
inputPlaceholderForeground?: ColorIdentifier;
|
||||
}
|
||||
|
||||
type ISuggestEnabledInputStyles = {
|
||||
[P in keyof ISuggestEnabledInputStyleOverrides]: Color | undefined;
|
||||
};
|
||||
|
||||
export function attachSuggestEnabledInputBoxStyler(widget: IThemable, themeService: IThemeService, style?: ISuggestEnabledInputStyleOverrides): IDisposable {
|
||||
return attachStyler(themeService, {
|
||||
inputBackground: (style && style.inputBackground) || inputBackground,
|
||||
inputForeground: (style && style.inputForeground) || inputForeground,
|
||||
inputBorder: (style && style.inputBorder) || inputBorder,
|
||||
inputPlaceholderForeground: (style && style.inputPlaceholderForeground) || inputPlaceholderForeground,
|
||||
} as ISuggestEnabledInputStyleOverrides, widget);
|
||||
}
|
||||
|
||||
export class SuggestEnabledInput extends Widget implements IThemable {
|
||||
|
||||
private readonly _onShouldFocusResults = new Emitter<void>();
|
||||
readonly onShouldFocusResults: Event<void> = this._onShouldFocusResults.event;
|
||||
|
||||
private readonly _onEnter = new Emitter<void>();
|
||||
readonly onEnter: Event<void> = this._onEnter.event;
|
||||
|
||||
private readonly _onInputDidChange = new Emitter<string | undefined>();
|
||||
readonly onInputDidChange: Event<string | undefined> = this._onInputDidChange.event;
|
||||
|
||||
private readonly inputWidget: CodeEditorWidget;
|
||||
private readonly inputModel: ITextModel;
|
||||
private stylingContainer: HTMLDivElement;
|
||||
private placeholderText: HTMLDivElement;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
parent: HTMLElement,
|
||||
suggestionProvider: SuggestResultsProvider,
|
||||
ariaLabel: string,
|
||||
resourceHandle: string,
|
||||
options: SuggestEnabledInputOptions,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IModelService modelService: IModelService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.stylingContainer = append(parent, $('.suggest-input-container'));
|
||||
this.placeholderText = append(this.stylingContainer, $('.suggest-input-placeholder', undefined, options.placeholderText || ''));
|
||||
|
||||
const editorOptions: IEditorOptions = mixin(
|
||||
getSimpleEditorOptions(),
|
||||
getSuggestEnabledInputOptions(ariaLabel));
|
||||
|
||||
this.inputWidget = instantiationService.createInstance(CodeEditorWidget, this.stylingContainer,
|
||||
editorOptions,
|
||||
{
|
||||
contributions: EditorExtensionsRegistry.getSomeEditorContributions([
|
||||
SuggestController.ID,
|
||||
SnippetController2.ID,
|
||||
ContextMenuController.ID,
|
||||
MenuPreventer.ID,
|
||||
SelectionClipboardContributionID,
|
||||
]),
|
||||
isSimpleWidget: true,
|
||||
});
|
||||
this._register(this.inputWidget);
|
||||
|
||||
let scopeHandle = uri.parse(resourceHandle);
|
||||
this.inputModel = modelService.createModel('', null, scopeHandle, true);
|
||||
this.inputWidget.setModel(this.inputModel);
|
||||
|
||||
this._register(this.inputWidget.onDidPaste(() => this.setValue(this.getValue()))); // setter cleanses
|
||||
|
||||
this._register((this.inputWidget.onDidFocusEditorText(() => {
|
||||
if (options.focusContextKey) { options.focusContextKey.set(true); }
|
||||
this.stylingContainer.classList.add('synthetic-focus');
|
||||
})));
|
||||
this._register((this.inputWidget.onDidBlurEditorText(() => {
|
||||
if (options.focusContextKey) { options.focusContextKey.set(false); }
|
||||
this.stylingContainer.classList.remove('synthetic-focus');
|
||||
})));
|
||||
|
||||
const onKeyDownMonaco = Event.chain(this.inputWidget.onKeyDown);
|
||||
this._register(onKeyDownMonaco.filter(e => e.keyCode === KeyCode.Enter).on(e => { e.preventDefault(); this._onEnter.fire(); }, this));
|
||||
this._register(onKeyDownMonaco.filter(e => e.keyCode === KeyCode.DownArrow && (isMacintosh ? e.metaKey : e.ctrlKey)).on(() => this._onShouldFocusResults.fire(), this));
|
||||
|
||||
let preexistingContent = this.getValue();
|
||||
const inputWidgetModel = this.inputWidget.getModel();
|
||||
if (inputWidgetModel) {
|
||||
this._register(inputWidgetModel.onDidChangeContent(() => {
|
||||
let content = this.getValue();
|
||||
this.placeholderText.style.visibility = content ? 'hidden' : 'visible';
|
||||
if (preexistingContent.trim() === content.trim()) { return; }
|
||||
this._onInputDidChange.fire(undefined);
|
||||
preexistingContent = content;
|
||||
}));
|
||||
}
|
||||
|
||||
let validatedSuggestProvider = {
|
||||
provideResults: suggestionProvider.provideResults,
|
||||
sortKey: suggestionProvider.sortKey || (a => a),
|
||||
triggerCharacters: suggestionProvider.triggerCharacters || []
|
||||
};
|
||||
|
||||
this.setValue(options.value || '');
|
||||
|
||||
this._register(modes.CompletionProviderRegistry.register({ scheme: scopeHandle.scheme, pattern: '**/' + scopeHandle.path, hasAccessToAllModels: true }, {
|
||||
triggerCharacters: validatedSuggestProvider.triggerCharacters,
|
||||
provideCompletionItems: (model: ITextModel, position: Position, _context: modes.CompletionContext) => {
|
||||
let query = model.getValue();
|
||||
|
||||
const zeroIndexedColumn = position.column - 1;
|
||||
|
||||
let zeroIndexedWordStart = query.lastIndexOf(' ', zeroIndexedColumn - 1) + 1;
|
||||
let alreadyTypedCount = zeroIndexedColumn - zeroIndexedWordStart;
|
||||
|
||||
// dont show suggestions if the user has typed something, but hasn't used the trigger character
|
||||
if (alreadyTypedCount > 0 && validatedSuggestProvider.triggerCharacters.indexOf(query[zeroIndexedWordStart]) === -1) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: suggestionProvider.provideResults(query).map(result => {
|
||||
return <modes.CompletionItem>{
|
||||
label: result,
|
||||
insertText: result,
|
||||
range: Range.fromPositions(position.delta(0, -alreadyTypedCount), position),
|
||||
sortText: validatedSuggestProvider.sortKey(result),
|
||||
kind: modes.CompletionItemKind.Keyword
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public updateAriaLabel(label: string): void {
|
||||
this.inputWidget.updateOptions({ ariaLabel: label });
|
||||
}
|
||||
|
||||
public get onFocus(): Event<void> { return this.inputWidget.onDidFocusEditorText; }
|
||||
|
||||
public setValue(val: string) {
|
||||
val = val.replace(/\s/g, ' ');
|
||||
const fullRange = this.inputModel.getFullModelRange();
|
||||
this.inputWidget.executeEdits('suggestEnabledInput.setValue', [EditOperation.replace(fullRange, val)]);
|
||||
this.inputWidget.setScrollTop(0);
|
||||
this.inputWidget.setPosition(new Position(1, val.length + 1));
|
||||
}
|
||||
|
||||
public getValue(): string {
|
||||
return this.inputWidget.getValue();
|
||||
}
|
||||
|
||||
|
||||
public style(colors: ISuggestEnabledInputStyles): void {
|
||||
this.stylingContainer.style.backgroundColor = colors.inputBackground ? colors.inputBackground.toString() : '';
|
||||
this.stylingContainer.style.color = colors.inputForeground ? colors.inputForeground.toString() : '';
|
||||
this.placeholderText.style.color = colors.inputPlaceholderForeground ? colors.inputPlaceholderForeground.toString() : '';
|
||||
|
||||
this.stylingContainer.style.borderWidth = '1px';
|
||||
this.stylingContainer.style.borderStyle = 'solid';
|
||||
this.stylingContainer.style.borderColor = colors.inputBorder ?
|
||||
colors.inputBorder.toString() :
|
||||
'transparent';
|
||||
|
||||
const cursor = this.stylingContainer.getElementsByClassName('cursor')[0] as HTMLDivElement;
|
||||
if (cursor) {
|
||||
cursor.style.backgroundColor = colors.inputForeground ? colors.inputForeground.toString() : '';
|
||||
}
|
||||
}
|
||||
|
||||
public focus(selectAll?: boolean): void {
|
||||
this.inputWidget.focus();
|
||||
|
||||
if (selectAll && this.inputWidget.getValue()) {
|
||||
this.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
public onHide(): void {
|
||||
this.inputWidget.onHide();
|
||||
}
|
||||
|
||||
public layout(dimension: Dimension): void {
|
||||
this.inputWidget.layout(dimension);
|
||||
this.placeholderText.style.width = `${dimension.width - 2}px`;
|
||||
}
|
||||
|
||||
private selectAll(): void {
|
||||
this.inputWidget.setSelection(new Range(1, 1, 1, this.getValue().length + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Override styles in selections.ts
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
let selectionColor = theme.getColor(selectionBackground);
|
||||
if (selectionColor) {
|
||||
selectionColor = selectionColor.transparent(0.4);
|
||||
} else {
|
||||
selectionColor = theme.getColor(editorSelectionBackground);
|
||||
}
|
||||
|
||||
if (selectionColor) {
|
||||
collector.addRule(`.suggest-input-container .monaco-editor .focused .selected-text { background-color: ${selectionColor}; }`);
|
||||
}
|
||||
|
||||
// Override inactive selection bg
|
||||
const inputBackgroundColor = theme.getColor(inputBackground);
|
||||
if (inputBackgroundColor) {
|
||||
collector.addRule(`.suggest-input-container .monaco-editor .selected-text { background-color: ${inputBackgroundColor.transparent(0.4)}; }`);
|
||||
}
|
||||
|
||||
// Override selected fg
|
||||
const inputForegroundColor = theme.getColor(inputForeground);
|
||||
if (inputForegroundColor) {
|
||||
collector.addRule(`.suggest-input-container .monaco-editor .view-line span.inline-selected-text { color: ${inputForegroundColor}; }`);
|
||||
}
|
||||
|
||||
const backgroundColor = theme.getColor(inputBackground);
|
||||
if (backgroundColor) {
|
||||
collector.addRule(`.suggest-input-container .monaco-editor-background { background-color: ${backgroundColor}; } `);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function getSuggestEnabledInputOptions(ariaLabel?: string): IEditorOptions {
|
||||
return {
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
wordWrap: 'off',
|
||||
scrollbar: { vertical: 'hidden', },
|
||||
roundedSelection: false,
|
||||
renderIndentGuides: false,
|
||||
cursorWidth: 1,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
ariaLabel: ariaLabel || '',
|
||||
snippetSuggestions: 'none',
|
||||
suggest: { filterGraceful: false, showIcons: false },
|
||||
autoClosingBrackets: 'never'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { MenuId, MenuRegistry, SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { CoreNavigationCommands } from 'vs/editor/browser/controller/coreCommands';
|
||||
import { Position } from 'vs/editor/common/core/position';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { CursorColumns } from 'vs/editor/common/controller/cursorCommon';
|
||||
|
||||
export class ToggleColumnSelectionAction extends Action {
|
||||
public static readonly ID = 'editor.action.toggleColumnSelection';
|
||||
public static readonly LABEL = nls.localize('toggleColumnSelection', "Toggle Column Selection Mode");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
private _getCodeEditor(): ICodeEditor | null {
|
||||
const codeEditor = this._codeEditorService.getFocusedCodeEditor();
|
||||
if (codeEditor) {
|
||||
return codeEditor;
|
||||
}
|
||||
return this._codeEditorService.getActiveCodeEditor();
|
||||
}
|
||||
|
||||
public async run(): Promise<any> {
|
||||
const oldValue = this._configurationService.getValue<boolean>('editor.columnSelection');
|
||||
const codeEditor = this._getCodeEditor();
|
||||
await this._configurationService.updateValue('editor.columnSelection', !oldValue, ConfigurationTarget.USER);
|
||||
const newValue = this._configurationService.getValue<boolean>('editor.columnSelection');
|
||||
if (!codeEditor || codeEditor !== this._getCodeEditor() || oldValue === newValue || !codeEditor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
const viewModel = codeEditor._getViewModel();
|
||||
if (codeEditor.getOption(EditorOption.columnSelection)) {
|
||||
const selection = codeEditor.getSelection();
|
||||
const modelSelectionStart = new Position(selection.selectionStartLineNumber, selection.selectionStartColumn);
|
||||
const viewSelectionStart = viewModel.coordinatesConverter.convertModelPositionToViewPosition(modelSelectionStart);
|
||||
const modelPosition = new Position(selection.positionLineNumber, selection.positionColumn);
|
||||
const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(modelPosition);
|
||||
|
||||
CoreNavigationCommands.MoveTo.runCoreEditorCommand(viewModel, {
|
||||
position: modelSelectionStart,
|
||||
viewPosition: viewSelectionStart
|
||||
});
|
||||
const visibleColumn = CursorColumns.visibleColumnFromColumn2(viewModel.cursorConfig, viewModel, viewPosition);
|
||||
CoreNavigationCommands.ColumnSelect.runCoreEditorCommand(viewModel, {
|
||||
position: modelPosition,
|
||||
viewPosition: viewPosition,
|
||||
doColumnSelect: true,
|
||||
mouseColumn: visibleColumn + 1
|
||||
});
|
||||
} else {
|
||||
const columnSelectData = viewModel.getCursorColumnSelectData();
|
||||
const fromViewColumn = CursorColumns.columnFromVisibleColumn2(viewModel.cursorConfig, viewModel, columnSelectData.fromViewLineNumber, columnSelectData.fromViewVisualColumn);
|
||||
const fromPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(new Position(columnSelectData.fromViewLineNumber, fromViewColumn));
|
||||
const toViewColumn = CursorColumns.columnFromVisibleColumn2(viewModel.cursorConfig, viewModel, columnSelectData.toViewLineNumber, columnSelectData.toViewVisualColumn);
|
||||
const toPosition = viewModel.coordinatesConverter.convertViewPositionToModelPosition(new Position(columnSelectData.toViewLineNumber, toViewColumn));
|
||||
|
||||
codeEditor.setSelection(new Selection(fromPosition.lineNumber, fromPosition.column, toPosition.lineNumber, toPosition.column));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(ToggleColumnSelectionAction), 'Toggle Column Selection Mode');
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarSelectionMenu, {
|
||||
group: '4_config',
|
||||
command: {
|
||||
id: ToggleColumnSelectionAction.ID,
|
||||
title: nls.localize({ key: 'miColumnSelection', comment: ['&& denotes a mnemonic'] }, "Column &&Selection Mode"),
|
||||
toggled: ContextKeyExpr.equals('config.editor.columnSelection', true)
|
||||
},
|
||||
order: 2
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { MenuId, MenuRegistry, SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { CATEGORIES, Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
|
||||
export class ToggleMinimapAction extends Action {
|
||||
public static readonly ID = 'editor.action.toggleMinimap';
|
||||
public static readonly LABEL = nls.localize('toggleMinimap', "Toggle Minimap");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
public run(): Promise<any> {
|
||||
const newValue = !this._configurationService.getValue<boolean>('editor.minimap.enabled');
|
||||
return this._configurationService.updateValue('editor.minimap.enabled', newValue, ConfigurationTarget.USER);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(ToggleMinimapAction), 'View: Toggle Minimap', CATEGORIES.View.value);
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
|
||||
group: '5_editor',
|
||||
command: {
|
||||
id: ToggleMinimapAction.ID,
|
||||
title: nls.localize({ key: 'miShowMinimap', comment: ['&& denotes a mnemonic'] }, "Show &&Minimap"),
|
||||
toggled: ContextKeyExpr.equals('config.editor.minimap.enabled', true)
|
||||
},
|
||||
order: 2
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { MenuId, MenuRegistry, SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
|
||||
export class ToggleMultiCursorModifierAction extends Action {
|
||||
|
||||
public static readonly ID = 'workbench.action.toggleMultiCursorModifier';
|
||||
public static readonly LABEL = nls.localize('toggleLocation', "Toggle Multi-Cursor Modifier");
|
||||
|
||||
private static readonly multiCursorModifierConfigurationKey = 'editor.multiCursorModifier';
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
public run(): Promise<any> {
|
||||
const editorConf = this.configurationService.getValue<{ multiCursorModifier: 'ctrlCmd' | 'alt' }>('editor');
|
||||
const newValue: 'ctrlCmd' | 'alt' = (editorConf.multiCursorModifier === 'ctrlCmd' ? 'alt' : 'ctrlCmd');
|
||||
|
||||
return this.configurationService.updateValue(ToggleMultiCursorModifierAction.multiCursorModifierConfigurationKey, newValue, ConfigurationTarget.USER);
|
||||
}
|
||||
}
|
||||
|
||||
const multiCursorModifier = new RawContextKey<string>('multiCursorModifier', 'altKey');
|
||||
|
||||
class MultiCursorModifierContextKeyController implements IWorkbenchContribution {
|
||||
|
||||
private readonly _multiCursorModifier: IContextKey<string>;
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService
|
||||
) {
|
||||
this._multiCursorModifier = multiCursorModifier.bindTo(contextKeyService);
|
||||
|
||||
this._update();
|
||||
configurationService.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('editor.multiCursorModifier')) {
|
||||
this._update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _update(): void {
|
||||
const editorConf = this.configurationService.getValue<{ multiCursorModifier: 'ctrlCmd' | 'alt' }>('editor');
|
||||
const value = (editorConf.multiCursorModifier === 'ctrlCmd' ? 'ctrlCmd' : 'altKey');
|
||||
this._multiCursorModifier.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(MultiCursorModifierContextKeyController, LifecyclePhase.Restored);
|
||||
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(Extensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(ToggleMultiCursorModifierAction), 'Toggle Multi-Cursor Modifier');
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarSelectionMenu, {
|
||||
group: '4_config',
|
||||
command: {
|
||||
id: ToggleMultiCursorModifierAction.ID,
|
||||
title: nls.localize('miMultiCursorAlt', "Switch to Alt+Click for Multi-Cursor")
|
||||
},
|
||||
when: multiCursorModifier.isEqualTo('ctrlCmd'),
|
||||
order: 1
|
||||
});
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarSelectionMenu, {
|
||||
group: '4_config',
|
||||
command: {
|
||||
id: ToggleMultiCursorModifierAction.ID,
|
||||
title: (
|
||||
platform.isMacintosh
|
||||
? nls.localize('miMultiCursorCmd', "Switch to Cmd+Click for Multi-Cursor")
|
||||
: nls.localize('miMultiCursorCtrl', "Switch to Ctrl+Click for Multi-Cursor")
|
||||
)
|
||||
},
|
||||
when: multiCursorModifier.isEqualTo('altKey'),
|
||||
order: 1
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { MenuId, MenuRegistry, SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { CATEGORIES, Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
|
||||
export class ToggleRenderControlCharacterAction extends Action {
|
||||
|
||||
public static readonly ID = 'editor.action.toggleRenderControlCharacter';
|
||||
public static readonly LABEL = nls.localize('toggleRenderControlCharacters', "Toggle Control Characters");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
public run(): Promise<any> {
|
||||
let newRenderControlCharacters = !this._configurationService.getValue<boolean>('editor.renderControlCharacters');
|
||||
return this._configurationService.updateValue('editor.renderControlCharacters', newRenderControlCharacters, ConfigurationTarget.USER);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(ToggleRenderControlCharacterAction), 'View: Toggle Control Characters', CATEGORIES.View.value);
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
|
||||
group: '5_editor',
|
||||
command: {
|
||||
id: ToggleRenderControlCharacterAction.ID,
|
||||
title: nls.localize({ key: 'miToggleRenderControlCharacters', comment: ['&& denotes a mnemonic'] }, "Render &&Control Characters"),
|
||||
toggled: ContextKeyExpr.equals('config.editor.renderControlCharacters', true)
|
||||
},
|
||||
order: 5
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { MenuId, MenuRegistry, SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { CATEGORIES, Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
|
||||
export class ToggleRenderWhitespaceAction extends Action {
|
||||
|
||||
public static readonly ID = 'editor.action.toggleRenderWhitespace';
|
||||
public static readonly LABEL = nls.localize('toggleRenderWhitespace', "Toggle Render Whitespace");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
public run(): Promise<any> {
|
||||
const renderWhitespace = this._configurationService.getValue<string>('editor.renderWhitespace');
|
||||
|
||||
let newRenderWhitespace: string;
|
||||
if (renderWhitespace === 'none') {
|
||||
newRenderWhitespace = 'all';
|
||||
} else {
|
||||
newRenderWhitespace = 'none';
|
||||
}
|
||||
|
||||
return this._configurationService.updateValue('editor.renderWhitespace', newRenderWhitespace, ConfigurationTarget.USER);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(ToggleRenderWhitespaceAction), 'View: Toggle Render Whitespace', CATEGORIES.View.value);
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
|
||||
group: '5_editor',
|
||||
command: {
|
||||
id: ToggleRenderWhitespaceAction.ID,
|
||||
title: nls.localize({ key: 'miToggleRenderWhitespace', comment: ['&& denotes a mnemonic'] }, "&&Render Whitespace"),
|
||||
toggled: ContextKeyExpr.notEquals('config.editor.renderWhitespace', 'none')
|
||||
},
|
||||
order: 4
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorAction, ServicesAccessor, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { EditorOption, EditorOptions } from 'vs/editor/common/config/editorOptions';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService';
|
||||
import { MenuId, MenuRegistry } from 'vs/platform/actions/common/actions';
|
||||
import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { DefaultSettingsEditorContribution } from 'vs/workbench/contrib/preferences/browser/preferencesEditor';
|
||||
|
||||
const transientWordWrapState = 'transientWordWrapState';
|
||||
const isWordWrapMinifiedKey = 'isWordWrapMinified';
|
||||
const isDominatedByLongLinesKey = 'isDominatedByLongLines';
|
||||
const inDiffEditorKey = 'inDiffEditor';
|
||||
|
||||
/**
|
||||
* State written/read by the toggle word wrap action and associated with a particular model.
|
||||
*/
|
||||
interface IWordWrapTransientState {
|
||||
readonly forceWordWrap: 'on' | 'off' | 'wordWrapColumn' | 'bounded';
|
||||
readonly forceWordWrapMinified: boolean;
|
||||
}
|
||||
|
||||
interface IWordWrapState {
|
||||
readonly configuredWordWrap: 'on' | 'off' | 'wordWrapColumn' | 'bounded' | undefined;
|
||||
readonly configuredWordWrapMinified: boolean;
|
||||
readonly transientState: IWordWrapTransientState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (in memory) the word wrap state for a particular model.
|
||||
*/
|
||||
export function writeTransientState(model: ITextModel, state: IWordWrapTransientState | null, codeEditorService: ICodeEditorService): void {
|
||||
codeEditorService.setTransientModelProperty(model, transientWordWrapState, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read (in memory) the word wrap state for a particular model.
|
||||
*/
|
||||
function readTransientState(model: ITextModel, codeEditorService: ICodeEditorService): IWordWrapTransientState {
|
||||
return codeEditorService.getTransientModelProperty(model, transientWordWrapState);
|
||||
}
|
||||
|
||||
function readWordWrapState(model: ITextModel, configurationService: ITextResourceConfigurationService, codeEditorService: ICodeEditorService): IWordWrapState {
|
||||
const editorConfig = configurationService.getValue(model.uri, 'editor') as { wordWrap: 'on' | 'off' | 'wordWrapColumn' | 'bounded'; wordWrapMinified: boolean };
|
||||
let _configuredWordWrap = editorConfig && (typeof editorConfig.wordWrap === 'string' || typeof editorConfig.wordWrap === 'boolean') ? editorConfig.wordWrap : undefined;
|
||||
|
||||
// Compatibility with old true or false values
|
||||
if (<any>_configuredWordWrap === true) {
|
||||
_configuredWordWrap = 'on';
|
||||
} else if (<any>_configuredWordWrap === false) {
|
||||
_configuredWordWrap = 'off';
|
||||
}
|
||||
|
||||
const _configuredWordWrapMinified = editorConfig && typeof editorConfig.wordWrapMinified === 'boolean' ? editorConfig.wordWrapMinified : undefined;
|
||||
const _transientState = readTransientState(model, codeEditorService);
|
||||
return {
|
||||
configuredWordWrap: _configuredWordWrap,
|
||||
configuredWordWrapMinified: (typeof _configuredWordWrapMinified === 'boolean' ? _configuredWordWrapMinified : EditorOptions.wordWrapMinified.defaultValue),
|
||||
transientState: _transientState
|
||||
};
|
||||
}
|
||||
|
||||
function toggleWordWrap(editor: ICodeEditor, state: IWordWrapState): IWordWrapState {
|
||||
if (state.transientState) {
|
||||
// toggle off => go to null
|
||||
return {
|
||||
configuredWordWrap: state.configuredWordWrap,
|
||||
configuredWordWrapMinified: state.configuredWordWrapMinified,
|
||||
transientState: null
|
||||
};
|
||||
}
|
||||
|
||||
let transientState: IWordWrapTransientState;
|
||||
|
||||
const actualWrappingInfo = editor.getOption(EditorOption.wrappingInfo);
|
||||
if (actualWrappingInfo.isWordWrapMinified) {
|
||||
// => wrapping due to minified file
|
||||
transientState = {
|
||||
forceWordWrap: 'off',
|
||||
forceWordWrapMinified: false
|
||||
};
|
||||
} else if (state.configuredWordWrap !== 'off') {
|
||||
// => wrapping is configured to be on (or some variant)
|
||||
transientState = {
|
||||
forceWordWrap: 'off',
|
||||
forceWordWrapMinified: false
|
||||
};
|
||||
} else {
|
||||
// => wrapping is configured to be off
|
||||
transientState = {
|
||||
forceWordWrap: 'on',
|
||||
forceWordWrapMinified: state.configuredWordWrapMinified
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configuredWordWrap: state.configuredWordWrap,
|
||||
configuredWordWrapMinified: state.configuredWordWrapMinified,
|
||||
transientState: transientState
|
||||
};
|
||||
}
|
||||
|
||||
const TOGGLE_WORD_WRAP_ID = 'editor.action.toggleWordWrap';
|
||||
class ToggleWordWrapAction extends EditorAction {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: TOGGLE_WORD_WRAP_ID,
|
||||
label: nls.localize('toggle.wordwrap', "View: Toggle Word Wrap"),
|
||||
alias: 'View: Toggle Word Wrap',
|
||||
precondition: undefined,
|
||||
kbOpts: {
|
||||
kbExpr: null,
|
||||
primary: KeyMod.Alt | KeyCode.KEY_Z,
|
||||
weight: KeybindingWeight.EditorContrib
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
if (editor.getContribution(DefaultSettingsEditorContribution.ID)) {
|
||||
// in the settings editor...
|
||||
return;
|
||||
}
|
||||
if (!editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
if (editor.getOption(EditorOption.inDiffEditor)) {
|
||||
// Cannot change wrapping settings inside the diff editor
|
||||
const notificationService = accessor.get(INotificationService);
|
||||
notificationService.info(nls.localize('wordWrap.notInDiffEditor', "Cannot toggle word wrap in a diff editor."));
|
||||
return;
|
||||
}
|
||||
|
||||
const textResourceConfigurationService = accessor.get(ITextResourceConfigurationService);
|
||||
const codeEditorService = accessor.get(ICodeEditorService);
|
||||
const model = editor.getModel();
|
||||
|
||||
if (!canToggleWordWrap(model.uri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the current state
|
||||
const currentState = readWordWrapState(model, textResourceConfigurationService, codeEditorService);
|
||||
// Compute the new state
|
||||
const newState = toggleWordWrap(editor, currentState);
|
||||
// Write the new state
|
||||
// (this will cause an event and the controller will apply the state)
|
||||
writeTransientState(model, newState.transientState, codeEditorService);
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleWordWrapController extends Disposable implements IEditorContribution {
|
||||
|
||||
public static readonly ID = 'editor.contrib.toggleWordWrapController';
|
||||
|
||||
constructor(
|
||||
private readonly editor: ICodeEditor,
|
||||
@IContextKeyService readonly contextKeyService: IContextKeyService,
|
||||
@ITextResourceConfigurationService readonly configurationService: ITextResourceConfigurationService,
|
||||
@ICodeEditorService readonly codeEditorService: ICodeEditorService
|
||||
) {
|
||||
super();
|
||||
|
||||
const options = this.editor.getOptions();
|
||||
const wrappingInfo = options.get(EditorOption.wrappingInfo);
|
||||
const isWordWrapMinified = this.contextKeyService.createKey(isWordWrapMinifiedKey, wrappingInfo.isWordWrapMinified);
|
||||
const isDominatedByLongLines = this.contextKeyService.createKey(isDominatedByLongLinesKey, wrappingInfo.isDominatedByLongLines);
|
||||
const inDiffEditor = this.contextKeyService.createKey(inDiffEditorKey, options.get(EditorOption.inDiffEditor));
|
||||
let currentlyApplyingEditorConfig = false;
|
||||
|
||||
this._register(editor.onDidChangeConfiguration((e) => {
|
||||
if (!e.hasChanged(EditorOption.wrappingInfo) && !e.hasChanged(EditorOption.inDiffEditor)) {
|
||||
return;
|
||||
}
|
||||
const options = this.editor.getOptions();
|
||||
const wrappingInfo = options.get(EditorOption.wrappingInfo);
|
||||
isWordWrapMinified.set(wrappingInfo.isWordWrapMinified);
|
||||
isDominatedByLongLines.set(wrappingInfo.isDominatedByLongLines);
|
||||
inDiffEditor.set(options.get(EditorOption.inDiffEditor));
|
||||
if (!currentlyApplyingEditorConfig) {
|
||||
// I am not the cause of the word wrap getting changed
|
||||
ensureWordWrapSettings();
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(editor.onDidChangeModel((e) => {
|
||||
ensureWordWrapSettings();
|
||||
}));
|
||||
|
||||
this._register(codeEditorService.onDidChangeTransientModelProperty(() => {
|
||||
ensureWordWrapSettings();
|
||||
}));
|
||||
|
||||
const ensureWordWrapSettings = () => {
|
||||
if (this.editor.getContribution(DefaultSettingsEditorContribution.ID)) {
|
||||
// in the settings editor...
|
||||
return;
|
||||
}
|
||||
if (this.editor.isSimpleWidget) {
|
||||
// in a simple widget...
|
||||
return;
|
||||
}
|
||||
// Ensure correct word wrap settings
|
||||
const newModel = this.editor.getModel();
|
||||
if (!newModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editor.getOption(EditorOption.inDiffEditor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canToggleWordWrap(newModel.uri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current configured values and toggle state
|
||||
const desiredState = readWordWrapState(newModel, this.configurationService, this.codeEditorService);
|
||||
|
||||
// Apply the state
|
||||
try {
|
||||
currentlyApplyingEditorConfig = true;
|
||||
this._applyWordWrapState(desiredState);
|
||||
} finally {
|
||||
currentlyApplyingEditorConfig = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _applyWordWrapState(state: IWordWrapState): void {
|
||||
if (state.transientState) {
|
||||
// toggle is on
|
||||
this.editor.updateOptions({
|
||||
wordWrap: state.transientState.forceWordWrap,
|
||||
wordWrapMinified: state.transientState.forceWordWrapMinified
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// toggle is off
|
||||
this.editor.updateOptions({
|
||||
wordWrap: state.configuredWordWrap,
|
||||
wordWrapMinified: state.configuredWordWrapMinified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canToggleWordWrap(uri: URI): boolean {
|
||||
if (!uri) {
|
||||
return false;
|
||||
}
|
||||
return (uri.scheme !== 'output');
|
||||
}
|
||||
|
||||
|
||||
registerEditorContribution(ToggleWordWrapController.ID, ToggleWordWrapController);
|
||||
|
||||
registerEditorAction(ToggleWordWrapAction);
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.EditorTitle, {
|
||||
command: {
|
||||
id: TOGGLE_WORD_WRAP_ID,
|
||||
title: nls.localize('unwrapMinified', "Disable wrapping for this file"),
|
||||
icon: {
|
||||
id: 'codicon/word-wrap'
|
||||
}
|
||||
},
|
||||
group: 'navigation',
|
||||
order: 1,
|
||||
when: ContextKeyExpr.and(
|
||||
ContextKeyExpr.not(inDiffEditorKey),
|
||||
ContextKeyExpr.has(isDominatedByLongLinesKey),
|
||||
ContextKeyExpr.has(isWordWrapMinifiedKey)
|
||||
)
|
||||
});
|
||||
MenuRegistry.appendMenuItem(MenuId.EditorTitle, {
|
||||
command: {
|
||||
id: TOGGLE_WORD_WRAP_ID,
|
||||
title: nls.localize('wrapMinified', "Enable wrapping for this file"),
|
||||
icon: {
|
||||
id: 'codicon/word-wrap'
|
||||
}
|
||||
},
|
||||
group: 'navigation',
|
||||
order: 1,
|
||||
when: ContextKeyExpr.and(
|
||||
ContextKeyExpr.not(inDiffEditorKey),
|
||||
ContextKeyExpr.has(isDominatedByLongLinesKey),
|
||||
ContextKeyExpr.not(isWordWrapMinifiedKey)
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
// View menu
|
||||
MenuRegistry.appendMenuItem(MenuId.MenubarViewMenu, {
|
||||
group: '5_editor',
|
||||
command: {
|
||||
id: TOGGLE_WORD_WRAP_ID,
|
||||
title: nls.localize({ key: 'miToggleWordWrap', comment: ['&& denotes a mnemonic'] }, "Toggle &&Word Wrap")
|
||||
},
|
||||
order: 1
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { ReferencesController } from 'vs/editor/contrib/gotoSymbol/peek/referencesController';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { IStorageService } from 'vs/platform/storage/common/storage';
|
||||
|
||||
export class WorkbenchReferencesController extends ReferencesController {
|
||||
|
||||
public constructor(
|
||||
editor: ICodeEditor,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@ICodeEditorService editorService: ICodeEditorService,
|
||||
@INotificationService notificationService: INotificationService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IStorageService storageService: IStorageService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
) {
|
||||
super(
|
||||
false,
|
||||
editor,
|
||||
contextKeyService,
|
||||
editorService,
|
||||
notificationService,
|
||||
instantiationService,
|
||||
storageService,
|
||||
configurationService
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(ReferencesController.ID, WorkbenchReferencesController);
|
||||
@@ -0,0 +1,6 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './startDebugTextMate';
|
||||
@@ -0,0 +1,107 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Action } from 'vs/base/common/actions';
|
||||
import { SyncActionDescriptor } from 'vs/platform/actions/common/actions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { CATEGORIES, Extensions as ActionExtensions, IWorkbenchActionRegistry } from 'vs/workbench/common/actions';
|
||||
import { ITextMateService } from 'vs/workbench/services/textMate/common/textMateService';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { createRotatingLogger } from 'vs/platform/log/node/spdlogService';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { Constants } from 'vs/base/common/uint';
|
||||
import { IHostService } from 'vs/workbench/services/host/browser/host';
|
||||
import { join } from 'vs/base/common/path';
|
||||
import { INativeWorkbenchEnvironmentService } from 'vs/workbench/services/environment/electron-sandbox/environmentService';
|
||||
|
||||
class StartDebugTextMate extends Action {
|
||||
|
||||
private static resource = URI.parse(`inmemory:///tm-log.txt`);
|
||||
|
||||
public static readonly ID = 'editor.action.startDebugTextMate';
|
||||
public static readonly LABEL = nls.localize('startDebugTextMate', "Start Text Mate Syntax Grammar Logging");
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
label: string,
|
||||
@ITextMateService private readonly _textMateService: ITextMateService,
|
||||
@IModelService private readonly _modelService: IModelService,
|
||||
@IEditorService private readonly _editorService: IEditorService,
|
||||
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
|
||||
@IHostService private readonly _hostService: IHostService,
|
||||
@INativeWorkbenchEnvironmentService private readonly _environmentService: INativeWorkbenchEnvironmentService
|
||||
) {
|
||||
super(id, label);
|
||||
}
|
||||
|
||||
private _getOrCreateModel(): ITextModel {
|
||||
const model = this._modelService.getModel(StartDebugTextMate.resource);
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
return this._modelService.createModel('', null, StartDebugTextMate.resource);
|
||||
}
|
||||
|
||||
private _append(model: ITextModel, str: string) {
|
||||
const lineCount = model.getLineCount();
|
||||
model.applyEdits([{
|
||||
range: new Range(lineCount, Constants.MAX_SAFE_SMALL_INTEGER, lineCount, Constants.MAX_SAFE_SMALL_INTEGER),
|
||||
text: str
|
||||
}]);
|
||||
}
|
||||
|
||||
public async run(): Promise<any> {
|
||||
const pathInTemp = join(this._environmentService.tmpDir.fsPath, `vcode-tm-log-${generateUuid()}.txt`);
|
||||
const logger = createRotatingLogger(`tm-log`, pathInTemp, 1024 * 1024 * 30, 1);
|
||||
const model = this._getOrCreateModel();
|
||||
const append = (str: string) => {
|
||||
this._append(model, str + '\n');
|
||||
scrollEditor();
|
||||
logger.info(str);
|
||||
logger.flush();
|
||||
};
|
||||
await this._hostService.openWindow([{ fileUri: URI.file(pathInTemp) }], { forceNewWindow: true });
|
||||
const textEditorPane = await this._editorService.openEditor({
|
||||
resource: model.uri
|
||||
});
|
||||
if (!textEditorPane) {
|
||||
return;
|
||||
}
|
||||
const scrollEditor = () => {
|
||||
const editors = this._codeEditorService.listCodeEditors();
|
||||
for (const editor of editors) {
|
||||
if (editor.hasModel()) {
|
||||
if (editor.getModel().uri.toString() === StartDebugTextMate.resource.toString()) {
|
||||
editor.revealLine(editor.getModel().getLineCount());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
append(`// Open the file you want to test to the side and watch here`);
|
||||
append(`// Output mirrored at ${pathInTemp}`);
|
||||
|
||||
this._textMateService.startDebugMode(
|
||||
(str) => {
|
||||
this._append(model, str + '\n');
|
||||
scrollEditor();
|
||||
logger.info(str);
|
||||
logger.flush();
|
||||
},
|
||||
() => {
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const registry = Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions);
|
||||
registry.registerWorkbenchAction(SyncActionDescriptor.from(StartDebugTextMate), 'Start Text Mate Syntax Grammar Logging', CATEGORIES.Developer.value);
|
||||
@@ -0,0 +1,8 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './inputClipboardActions';
|
||||
import './selectionClipboard';
|
||||
import './sleepResumeRepaintMinimap';
|
||||
@@ -0,0 +1,43 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { KeybindingsRegistry } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
|
||||
if (platform.isMacintosh) {
|
||||
|
||||
// On the mac, cmd+x, cmd+c and cmd+v do not result in cut / copy / paste
|
||||
// We therefore add a basic keybinding rule that invokes document.execCommand
|
||||
// This is to cover <input>s...
|
||||
|
||||
KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'execCut',
|
||||
primary: KeyMod.CtrlCmd | KeyCode.KEY_X,
|
||||
handler: bindExecuteCommand('cut'),
|
||||
weight: 0,
|
||||
when: undefined,
|
||||
});
|
||||
KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'execCopy',
|
||||
primary: KeyMod.CtrlCmd | KeyCode.KEY_C,
|
||||
handler: bindExecuteCommand('copy'),
|
||||
weight: 0,
|
||||
when: undefined,
|
||||
});
|
||||
KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'execPaste',
|
||||
primary: KeyMod.CtrlCmd | KeyCode.KEY_V,
|
||||
handler: bindExecuteCommand('paste'),
|
||||
weight: 0,
|
||||
when: undefined,
|
||||
});
|
||||
|
||||
function bindExecuteCommand(command: 'cut' | 'copy' | 'paste') {
|
||||
return () => {
|
||||
document.execCommand(command);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import * as platform from 'vs/base/common/platform';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { registerEditorContribution, EditorAction, ServicesAccessor, registerEditorAction } from 'vs/editor/browser/editorExtensions';
|
||||
import { ConfigurationChangedEvent, EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { ICursorSelectionChangedEvent } from 'vs/editor/common/controller/cursorEvents';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { IEditorContribution, Handler } from 'vs/editor/common/editorCommon';
|
||||
import { EndOfLinePreference } from 'vs/editor/common/model';
|
||||
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
|
||||
import { SelectionClipboardContributionID } from 'vs/workbench/contrib/codeEditor/browser/selectionClipboard';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
|
||||
|
||||
export class SelectionClipboard extends Disposable implements IEditorContribution {
|
||||
private static readonly SELECTION_LENGTH_LIMIT = 65536;
|
||||
|
||||
constructor(editor: ICodeEditor, @IClipboardService clipboardService: IClipboardService) {
|
||||
super();
|
||||
|
||||
if (platform.isLinux) {
|
||||
let isEnabled = editor.getOption(EditorOption.selectionClipboard);
|
||||
|
||||
this._register(editor.onDidChangeConfiguration((e: ConfigurationChangedEvent) => {
|
||||
if (e.hasChanged(EditorOption.selectionClipboard)) {
|
||||
isEnabled = editor.getOption(EditorOption.selectionClipboard);
|
||||
}
|
||||
}));
|
||||
|
||||
let setSelectionToClipboard = this._register(new RunOnceScheduler(() => {
|
||||
if (!editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
let model = editor.getModel();
|
||||
let selections = editor.getSelections();
|
||||
selections = selections.slice(0);
|
||||
selections.sort(Range.compareRangesUsingStarts);
|
||||
|
||||
let resultLength = 0;
|
||||
for (const sel of selections) {
|
||||
if (sel.isEmpty()) {
|
||||
// Only write if all cursors have selection
|
||||
return;
|
||||
}
|
||||
resultLength += model.getValueLengthInRange(sel);
|
||||
}
|
||||
|
||||
if (resultLength > SelectionClipboard.SELECTION_LENGTH_LIMIT) {
|
||||
// This is a large selection!
|
||||
// => do not write it to the selection clipboard
|
||||
return;
|
||||
}
|
||||
|
||||
let result: string[] = [];
|
||||
for (const sel of selections) {
|
||||
result.push(model.getValueInRange(sel, EndOfLinePreference.TextDefined));
|
||||
}
|
||||
|
||||
let textToCopy = result.join(model.getEOL());
|
||||
clipboardService.writeText(textToCopy, 'selection');
|
||||
}, 100));
|
||||
|
||||
this._register(editor.onDidChangeCursorSelection((e: ICursorSelectionChangedEvent) => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (e.source === 'restoreState') {
|
||||
// do not set selection to clipboard if this selection change
|
||||
// was caused by restoring editors...
|
||||
return;
|
||||
}
|
||||
setSelectionToClipboard.schedule();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class SelectionClipboardPastePreventer implements IWorkbenchContribution {
|
||||
constructor(
|
||||
@IConfigurationService configurationService: IConfigurationService
|
||||
) {
|
||||
if (platform.isLinux) {
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (e.button === 1) {
|
||||
// middle button
|
||||
const config = configurationService.getValue<{ selectionClipboard: boolean; }>('editor');
|
||||
if (!config.selectionClipboard) {
|
||||
// selection clipboard is disabled
|
||||
// try to stop the upcoming paste
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasteSelectionClipboardAction extends EditorAction {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.action.selectionClipboardPaste',
|
||||
label: nls.localize('actions.pasteSelectionClipboard', "Paste Selection Clipboard"),
|
||||
alias: 'Paste Selection Clipboard',
|
||||
precondition: EditorContextKeys.writable
|
||||
});
|
||||
}
|
||||
|
||||
public async run(accessor: ServicesAccessor, editor: ICodeEditor, args: any): Promise<void> {
|
||||
const clipboardService = accessor.get(IClipboardService);
|
||||
|
||||
// read selection clipboard
|
||||
const text = await clipboardService.readText('selection');
|
||||
|
||||
editor.trigger('keyboard', Handler.Paste, {
|
||||
text: text,
|
||||
pasteOnNewLine: false,
|
||||
multicursorText: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorContribution(SelectionClipboardContributionID, SelectionClipboard);
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SelectionClipboardPastePreventer, LifecyclePhase.Ready);
|
||||
if (platform.isLinux) {
|
||||
registerEditorAction(PasteSelectionClipboardAction);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { INativeHostService } from 'vs/platform/native/electron-sandbox/native';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
|
||||
class SleepResumeRepaintMinimap extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
constructor(
|
||||
@ICodeEditorService codeEditorService: ICodeEditorService,
|
||||
@INativeHostService nativeHostService: INativeHostService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(nativeHostService.onDidResumeOS(() => {
|
||||
codeEditorService.listCodeEditors().forEach(editor => editor.render(true));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(SleepResumeRepaintMinimap, LifecyclePhase.Eventually);
|
||||
@@ -0,0 +1,174 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { FinalNewLineParticipant, TrimFinalNewLinesParticipant, TrimWhitespaceParticipant } from 'vs/workbench/contrib/codeEditor/browser/saveParticipants';
|
||||
import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService';
|
||||
import { workbenchInstantiationService, TestServiceAccessor } from 'vs/workbench/test/browser/workbenchTestServices';
|
||||
import { toResource } from 'vs/base/test/common/utils';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { TextFileEditorModel } from 'vs/workbench/services/textfile/common/textFileEditorModel';
|
||||
import { IResolvedTextFileEditorModel, snapshotToString } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { SaveReason } from 'vs/workbench/common/editor';
|
||||
import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager';
|
||||
|
||||
suite('Save Participants', function () {
|
||||
|
||||
let instantiationService: IInstantiationService;
|
||||
let accessor: TestServiceAccessor;
|
||||
|
||||
setup(() => {
|
||||
instantiationService = workbenchInstantiationService();
|
||||
accessor = instantiationService.createInstance(TestServiceAccessor);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
(<TextFileEditorModelManager>accessor.textFileService.files).dispose();
|
||||
});
|
||||
|
||||
test('insert final new line', async function () {
|
||||
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel;
|
||||
|
||||
await model.load();
|
||||
const configService = new TestConfigurationService();
|
||||
configService.setUserConfiguration('files', { 'insertFinalNewline': true });
|
||||
const participant = new FinalNewLineParticipant(configService, undefined!);
|
||||
|
||||
// No new line for empty lines
|
||||
let lineContent = '';
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), lineContent);
|
||||
|
||||
// No new line if last line already empty
|
||||
lineContent = `Hello New Line${model.textEditorModel.getEOL()}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), lineContent);
|
||||
|
||||
// New empty line added (single line)
|
||||
lineContent = 'Hello New Line';
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${lineContent}${model.textEditorModel.getEOL()}`);
|
||||
|
||||
// New empty line added (multi line)
|
||||
lineContent = `Hello New Line${model.textEditorModel.getEOL()}Hello New Line${model.textEditorModel.getEOL()}Hello New Line`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${lineContent}${model.textEditorModel.getEOL()}`);
|
||||
});
|
||||
|
||||
test('trim final new lines', async function () {
|
||||
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel;
|
||||
|
||||
await model.load();
|
||||
const configService = new TestConfigurationService();
|
||||
configService.setUserConfiguration('files', { 'trimFinalNewlines': true });
|
||||
const participant = new TrimFinalNewLinesParticipant(configService, undefined!);
|
||||
const textContent = 'Trim New Line';
|
||||
const eol = `${model.textEditorModel.getEOL()}`;
|
||||
|
||||
// No new line removal if last line is not new line
|
||||
let lineContent = `${textContent}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), lineContent);
|
||||
|
||||
// No new line removal if last line is single new line
|
||||
lineContent = `${textContent}${eol}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), lineContent);
|
||||
|
||||
// Remove new line (single line with two new lines)
|
||||
lineContent = `${textContent}${eol}${eol}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}${eol}`);
|
||||
|
||||
// Remove new lines (multiple lines with multiple new lines)
|
||||
lineContent = `${textContent}${eol}${textContent}${eol}${eol}${eol}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}${eol}${textContent}${eol}`);
|
||||
});
|
||||
|
||||
test('trim final new lines bug#39750', async function () {
|
||||
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel;
|
||||
|
||||
await model.load();
|
||||
const configService = new TestConfigurationService();
|
||||
configService.setUserConfiguration('files', { 'trimFinalNewlines': true });
|
||||
const participant = new TrimFinalNewLinesParticipant(configService, undefined!);
|
||||
const textContent = 'Trim New Line';
|
||||
|
||||
// single line
|
||||
let lineContent = `${textContent}`;
|
||||
model.textEditorModel.setValue(lineContent);
|
||||
|
||||
// apply edits and push to undo stack.
|
||||
let textEdits = [{ range: new Range(1, 14, 1, 14), text: '.', forceMoveMarkers: false }];
|
||||
model.textEditorModel.pushEditOperations([new Selection(1, 14, 1, 14)], textEdits, () => { return [new Selection(1, 15, 1, 15)]; });
|
||||
|
||||
// undo
|
||||
await model.textEditorModel.undo();
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}`);
|
||||
|
||||
// trim final new lines should not mess the undo stack
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
await model.textEditorModel.redo();
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}.`);
|
||||
});
|
||||
|
||||
test('trim final new lines bug#46075', async function () {
|
||||
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel;
|
||||
|
||||
await model.load();
|
||||
const configService = new TestConfigurationService();
|
||||
configService.setUserConfiguration('files', { 'trimFinalNewlines': true });
|
||||
const participant = new TrimFinalNewLinesParticipant(configService, undefined!);
|
||||
const textContent = 'Test';
|
||||
const eol = `${model.textEditorModel.getEOL()}`;
|
||||
let content = `${textContent}${eol}${eol}`;
|
||||
model.textEditorModel.setValue(content);
|
||||
|
||||
// save many times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
}
|
||||
|
||||
// confirm trimming
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}${eol}`);
|
||||
|
||||
// undo should go back to previous content immediately
|
||||
await model.textEditorModel.undo();
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}${eol}${eol}`);
|
||||
await model.textEditorModel.redo();
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}${eol}`);
|
||||
});
|
||||
|
||||
test('trim whitespace', async function () {
|
||||
const model = instantiationService.createInstance(TextFileEditorModel, toResource.call(this, '/path/trim_final_new_line.txt'), 'utf8', undefined) as IResolvedTextFileEditorModel;
|
||||
|
||||
await model.load();
|
||||
const configService = new TestConfigurationService();
|
||||
configService.setUserConfiguration('files', { 'trimTrailingWhitespace': true });
|
||||
const participant = new TrimWhitespaceParticipant(configService, undefined!);
|
||||
const textContent = 'Test';
|
||||
let content = `${textContent} `;
|
||||
model.textEditorModel.setValue(content);
|
||||
|
||||
// save many times
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await participant.participate(model, { reason: SaveReason.EXPLICIT });
|
||||
}
|
||||
|
||||
// confirm trimming
|
||||
assert.equal(snapshotToString(model.createSnapshot()!), `${textContent}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Button } from 'vs/base/browser/ui/button/button';
|
||||
import { IAction } from 'vs/base/common/actions';
|
||||
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IMenu } from 'vs/platform/actions/common/actions';
|
||||
import { attachButtonStyler } from 'vs/platform/theme/common/styler';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
|
||||
export class CommentFormActions implements IDisposable {
|
||||
private _buttonElements: HTMLElement[] = [];
|
||||
private readonly _toDispose = new DisposableStore();
|
||||
private _actions: IAction[] = [];
|
||||
|
||||
constructor(
|
||||
private container: HTMLElement,
|
||||
private actionHandler: (action: IAction) => void,
|
||||
private themeService: IThemeService
|
||||
) { }
|
||||
|
||||
setActions(menu: IMenu) {
|
||||
this._toDispose.clear();
|
||||
|
||||
this._buttonElements.forEach(b => b.remove());
|
||||
|
||||
const groups = menu.getActions({ shouldForwardArgs: true });
|
||||
for (const group of groups) {
|
||||
const [, actions] = group;
|
||||
|
||||
this._actions = actions;
|
||||
actions.forEach(action => {
|
||||
const button = new Button(this.container);
|
||||
this._buttonElements.push(button.element);
|
||||
|
||||
this._toDispose.add(button);
|
||||
this._toDispose.add(attachButtonStyler(button, this.themeService));
|
||||
this._toDispose.add(button.onDidClick(() => this.actionHandler(action)));
|
||||
|
||||
button.enabled = action.enabled;
|
||||
button.label = action.label;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
triggerDefaultAction() {
|
||||
if (this._actions.length) {
|
||||
let lastAction = this._actions[0];
|
||||
|
||||
if (lastAction.enabled) {
|
||||
this.actionHandler(lastAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._toDispose.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { Color, RGBA } from 'vs/base/common/color';
|
||||
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser';
|
||||
import { IModelDecorationOptions, OverviewRulerLane } from 'vs/editor/common/model';
|
||||
import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
|
||||
import { registerColor } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { themeColorFromId } from 'vs/platform/theme/common/themeService';
|
||||
|
||||
const overviewRulerDefault = new Color(new RGBA(197, 197, 197, 1));
|
||||
|
||||
export const overviewRulerCommentingRangeForeground = registerColor('editorGutter.commentRangeForeground', { dark: overviewRulerDefault, light: overviewRulerDefault, hc: overviewRulerDefault }, nls.localize('editorGutterCommentRangeForeground', 'Editor gutter decoration color for commenting ranges.'));
|
||||
|
||||
export class CommentGlyphWidget {
|
||||
private _lineNumber!: number;
|
||||
private _editor: ICodeEditor;
|
||||
private commentsDecorations: string[] = [];
|
||||
private _commentsOptions: ModelDecorationOptions;
|
||||
|
||||
constructor(editor: ICodeEditor, lineNumber: number) {
|
||||
this._commentsOptions = this.createDecorationOptions();
|
||||
this._editor = editor;
|
||||
this.setLineNumber(lineNumber);
|
||||
}
|
||||
|
||||
private createDecorationOptions(): ModelDecorationOptions {
|
||||
const decorationOptions: IModelDecorationOptions = {
|
||||
isWholeLine: true,
|
||||
overviewRuler: {
|
||||
color: themeColorFromId(overviewRulerCommentingRangeForeground),
|
||||
position: OverviewRulerLane.Center
|
||||
},
|
||||
linesDecorationsClassName: `comment-range-glyph comment-thread`
|
||||
};
|
||||
|
||||
return ModelDecorationOptions.createDynamic(decorationOptions);
|
||||
}
|
||||
|
||||
setLineNumber(lineNumber: number): void {
|
||||
this._lineNumber = lineNumber;
|
||||
let commentsDecorations = [{
|
||||
range: {
|
||||
startLineNumber: lineNumber, startColumn: 1,
|
||||
endLineNumber: lineNumber, endColumn: 1
|
||||
},
|
||||
options: this._commentsOptions
|
||||
}];
|
||||
|
||||
this.commentsDecorations = this._editor.deltaDecorations(this.commentsDecorations, commentsDecorations);
|
||||
}
|
||||
|
||||
getPosition(): IContentWidgetPosition {
|
||||
const range = this._editor.hasModel() && this.commentsDecorations && this.commentsDecorations.length
|
||||
? this._editor.getModel().getDecorationRange(this.commentsDecorations[0])
|
||||
: null;
|
||||
|
||||
return {
|
||||
position: {
|
||||
lineNumber: range ? range.startLineNumber : this._lineNumber,
|
||||
column: 1
|
||||
},
|
||||
preference: [ContentWidgetPositionPreference.EXACT]
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.commentsDecorations) {
|
||||
this._editor.deltaDecorations(this.commentsDecorations, []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IMenuService, MenuId, IMenu } from 'vs/platform/actions/common/actions';
|
||||
import { IAction } from 'vs/base/common/actions';
|
||||
import { Comment, CommentThread } from 'vs/editor/common/modes';
|
||||
import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem';
|
||||
|
||||
export class CommentMenus implements IDisposable {
|
||||
constructor(
|
||||
@IMenuService private readonly menuService: IMenuService
|
||||
) { }
|
||||
|
||||
getCommentThreadTitleActions(commentThread: CommentThread, contextKeyService: IContextKeyService): IMenu {
|
||||
return this.getMenu(MenuId.CommentThreadTitle, contextKeyService);
|
||||
}
|
||||
|
||||
getCommentThreadActions(commentThread: CommentThread, contextKeyService: IContextKeyService): IMenu {
|
||||
return this.getMenu(MenuId.CommentThreadActions, contextKeyService);
|
||||
}
|
||||
|
||||
getCommentTitleActions(comment: Comment, contextKeyService: IContextKeyService): IMenu {
|
||||
return this.getMenu(MenuId.CommentTitle, contextKeyService);
|
||||
}
|
||||
|
||||
getCommentActions(comment: Comment, contextKeyService: IContextKeyService): IMenu {
|
||||
return this.getMenu(MenuId.CommentActions, contextKeyService);
|
||||
}
|
||||
|
||||
private getMenu(menuId: MenuId, contextKeyService: IContextKeyService): IMenu {
|
||||
const menu = this.menuService.createMenu(menuId, contextKeyService);
|
||||
|
||||
const primary: IAction[] = [];
|
||||
const secondary: IAction[] = [];
|
||||
const result = { primary, secondary };
|
||||
|
||||
createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, g => /^inline/.test(g));
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import * as modes from 'vs/editor/common/modes';
|
||||
import { ActionsOrientation, ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
|
||||
import { Action, IActionRunner, IAction, Separator } from 'vs/base/common/actions';
|
||||
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
|
||||
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';
|
||||
import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { ICommentService } from 'vs/workbench/contrib/comments/browser/commentService';
|
||||
import { SimpleCommentEditor } from 'vs/workbench/contrib/comments/browser/simpleCommentEditor';
|
||||
import { Selection } from 'vs/editor/common/core/selection';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { ToolBar } from 'vs/base/browser/ui/toolbar/toolbar';
|
||||
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { AnchorAlignment } from 'vs/base/browser/ui/contextview/contextview';
|
||||
import { ToggleReactionsAction, ReactionAction, ReactionActionViewItem } from './reactionsAction';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ICommentThreadWidget } from 'vs/workbench/contrib/comments/common/commentThreadWidget';
|
||||
import { MenuItemAction, SubmenuItemAction, IMenu } from 'vs/platform/actions/common/actions';
|
||||
import { MenuEntryActionViewItem, SubmenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem';
|
||||
import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { CommentFormActions } from 'vs/workbench/contrib/comments/browser/commentFormActions';
|
||||
import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from 'vs/base/browser/ui/mouseCursor/mouseCursor';
|
||||
import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
|
||||
import { DropdownMenuActionViewItem } from 'vs/base/browser/ui/dropdown/dropdownActionViewItem';
|
||||
|
||||
export class CommentNode extends Disposable {
|
||||
private _domNode: HTMLElement;
|
||||
private _body: HTMLElement;
|
||||
private _md: HTMLElement;
|
||||
private _clearTimeout: any;
|
||||
|
||||
private _editAction: Action | null = null;
|
||||
private _commentEditContainer: HTMLElement | null = null;
|
||||
private _commentDetailsContainer: HTMLElement;
|
||||
private _actionsToolbarContainer!: HTMLElement;
|
||||
private _reactionsActionBar?: ActionBar;
|
||||
private _reactionActionsContainer?: HTMLElement;
|
||||
private _commentEditor: SimpleCommentEditor | null = null;
|
||||
private _commentEditorDisposables: IDisposable[] = [];
|
||||
private _commentEditorModel: ITextModel | null = null;
|
||||
private _isPendingLabel!: HTMLElement;
|
||||
private _contextKeyService: IContextKeyService;
|
||||
private _commentContextValue: IContextKey<string>;
|
||||
|
||||
protected actionRunner?: IActionRunner;
|
||||
protected toolbar: ToolBar | undefined;
|
||||
private _commentFormActions: CommentFormActions | null = null;
|
||||
|
||||
private readonly _onDidClick = new Emitter<CommentNode>();
|
||||
|
||||
public get domNode(): HTMLElement {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
public isEditing: boolean = false;
|
||||
|
||||
constructor(
|
||||
private commentThread: modes.CommentThread,
|
||||
public comment: modes.Comment,
|
||||
private owner: string,
|
||||
private resource: URI,
|
||||
private parentEditor: ICodeEditor,
|
||||
private parentThread: ICommentThreadWidget,
|
||||
private markdownRenderer: MarkdownRenderer,
|
||||
@IThemeService private themeService: IThemeService,
|
||||
@IInstantiationService private instantiationService: IInstantiationService,
|
||||
@ICommentService private commentService: ICommentService,
|
||||
@IModelService private modelService: IModelService,
|
||||
@IModeService private modeService: IModeService,
|
||||
@INotificationService private notificationService: INotificationService,
|
||||
@IContextMenuService private contextMenuService: IContextMenuService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._domNode = dom.$('div.review-comment');
|
||||
this._contextKeyService = contextKeyService.createScoped(this._domNode);
|
||||
this._commentContextValue = this._contextKeyService.createKey('comment', comment.contextValue);
|
||||
|
||||
this._domNode.tabIndex = -1;
|
||||
const avatar = dom.append(this._domNode, dom.$('div.avatar-container'));
|
||||
if (comment.userIconPath) {
|
||||
const img = <HTMLImageElement>dom.append(avatar, dom.$('img.avatar'));
|
||||
img.src = comment.userIconPath.toString();
|
||||
img.onerror = _ => img.remove();
|
||||
}
|
||||
this._commentDetailsContainer = dom.append(this._domNode, dom.$('.review-comment-contents'));
|
||||
|
||||
this.createHeader(this._commentDetailsContainer);
|
||||
|
||||
this._body = dom.append(this._commentDetailsContainer, dom.$(`div.comment-body.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`));
|
||||
this._md = this.markdownRenderer.render(comment.body).element;
|
||||
this._body.appendChild(this._md);
|
||||
|
||||
if (this.comment.commentReactions && this.comment.commentReactions.length && this.comment.commentReactions.filter(reaction => !!reaction.count).length) {
|
||||
this.createReactionsContainer(this._commentDetailsContainer);
|
||||
}
|
||||
|
||||
this._domNode.setAttribute('aria-label', `${comment.userName}, ${comment.body.value}`);
|
||||
this._domNode.setAttribute('role', 'treeitem');
|
||||
this._clearTimeout = null;
|
||||
|
||||
this._register(dom.addDisposableListener(this._domNode, dom.EventType.CLICK, () => this.isEditing || this._onDidClick.fire(this)));
|
||||
}
|
||||
|
||||
public get onDidClick(): Event<CommentNode> {
|
||||
return this._onDidClick.event;
|
||||
}
|
||||
|
||||
private createHeader(commentDetailsContainer: HTMLElement): void {
|
||||
const header = dom.append(commentDetailsContainer, dom.$(`div.comment-title.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`));
|
||||
const author = dom.append(header, dom.$('strong.author'));
|
||||
author.innerText = this.comment.userName;
|
||||
|
||||
this._isPendingLabel = dom.append(header, dom.$('span.isPending'));
|
||||
|
||||
if (this.comment.label) {
|
||||
this._isPendingLabel.innerText = this.comment.label;
|
||||
} else {
|
||||
this._isPendingLabel.innerText = '';
|
||||
}
|
||||
|
||||
this._actionsToolbarContainer = dom.append(header, dom.$('.comment-actions.hidden'));
|
||||
this.createActionsToolbar();
|
||||
}
|
||||
|
||||
private getToolbarActions(menu: IMenu): { primary: IAction[], secondary: IAction[] } {
|
||||
const contributedActions = menu.getActions({ shouldForwardArgs: true });
|
||||
const primary: IAction[] = [];
|
||||
const secondary: IAction[] = [];
|
||||
const result = { primary, secondary };
|
||||
fillInActions(contributedActions, result, false, g => /^inline/.test(g));
|
||||
return result;
|
||||
}
|
||||
|
||||
private createToolbar() {
|
||||
this.toolbar = new ToolBar(this._actionsToolbarContainer, this.contextMenuService, {
|
||||
actionViewItemProvider: action => {
|
||||
if (action.id === ToggleReactionsAction.ID) {
|
||||
return new DropdownMenuActionViewItem(
|
||||
action,
|
||||
(<ToggleReactionsAction>action).menuActions,
|
||||
this.contextMenuService,
|
||||
{
|
||||
actionViewItemProvider: action => this.actionViewItemProvider(action as Action),
|
||||
actionRunner: this.actionRunner,
|
||||
classNames: ['toolbar-toggle-pickReactions', 'codicon', 'codicon-reactions'],
|
||||
anchorAlignmentProvider: () => AnchorAlignment.RIGHT
|
||||
}
|
||||
);
|
||||
}
|
||||
return this.actionViewItemProvider(action as Action);
|
||||
},
|
||||
orientation: ActionsOrientation.HORIZONTAL
|
||||
});
|
||||
|
||||
this.toolbar.context = {
|
||||
thread: this.commentThread,
|
||||
commentUniqueId: this.comment.uniqueIdInThread,
|
||||
$mid: 9
|
||||
};
|
||||
|
||||
this.registerActionBarListeners(this._actionsToolbarContainer);
|
||||
this._register(this.toolbar);
|
||||
}
|
||||
|
||||
private createActionsToolbar() {
|
||||
const actions: IAction[] = [];
|
||||
|
||||
let hasReactionHandler = this.commentService.hasReactionHandler(this.owner);
|
||||
|
||||
if (hasReactionHandler) {
|
||||
let toggleReactionAction = this.createReactionPicker(this.comment.commentReactions || []);
|
||||
actions.push(toggleReactionAction);
|
||||
}
|
||||
|
||||
let commentMenus = this.commentService.getCommentMenus(this.owner);
|
||||
const menu = commentMenus.getCommentTitleActions(this.comment, this._contextKeyService);
|
||||
this._register(menu);
|
||||
this._register(menu.onDidChange(e => {
|
||||
const { primary, secondary } = this.getToolbarActions(menu);
|
||||
if (!this.toolbar && (primary.length || secondary.length)) {
|
||||
this.createToolbar();
|
||||
}
|
||||
|
||||
this.toolbar!.setActions(primary, secondary);
|
||||
}));
|
||||
|
||||
const { primary, secondary } = this.getToolbarActions(menu);
|
||||
actions.push(...primary);
|
||||
|
||||
if (actions.length || secondary.length) {
|
||||
this.createToolbar();
|
||||
this.toolbar!.setActions(actions, secondary);
|
||||
}
|
||||
}
|
||||
|
||||
actionViewItemProvider(action: Action) {
|
||||
let options = {};
|
||||
if (action.id === ToggleReactionsAction.ID) {
|
||||
options = { label: false, icon: true };
|
||||
} else {
|
||||
options = { label: false, icon: true };
|
||||
}
|
||||
|
||||
if (action.id === ReactionAction.ID) {
|
||||
let item = new ReactionActionViewItem(action);
|
||||
return item;
|
||||
} else if (action instanceof MenuItemAction) {
|
||||
return this.instantiationService.createInstance(MenuEntryActionViewItem, action);
|
||||
} else if (action instanceof SubmenuItemAction) {
|
||||
return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action);
|
||||
} else {
|
||||
let item = new ActionViewItem({}, action, options);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private createReactionPicker(reactionGroup: modes.CommentReaction[]): ToggleReactionsAction {
|
||||
let toggleReactionActionViewItem: DropdownMenuActionViewItem;
|
||||
let toggleReactionAction = this._register(new ToggleReactionsAction(() => {
|
||||
if (toggleReactionActionViewItem) {
|
||||
toggleReactionActionViewItem.show();
|
||||
}
|
||||
}, nls.localize('commentToggleReaction', "Toggle Reaction")));
|
||||
|
||||
let reactionMenuActions: Action[] = [];
|
||||
if (reactionGroup && reactionGroup.length) {
|
||||
reactionMenuActions = reactionGroup.map((reaction) => {
|
||||
return new Action(`reaction.command.${reaction.label}`, `${reaction.label}`, '', true, async () => {
|
||||
try {
|
||||
await this.commentService.toggleReaction(this.owner, this.resource, this.commentThread, this.comment, reaction);
|
||||
} catch (e) {
|
||||
const error = e.message
|
||||
? nls.localize('commentToggleReactionError', "Toggling the comment reaction failed: {0}.", e.message)
|
||||
: nls.localize('commentToggleReactionDefaultError', "Toggling the comment reaction failed");
|
||||
this.notificationService.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleReactionAction.menuActions = reactionMenuActions;
|
||||
|
||||
toggleReactionActionViewItem = new DropdownMenuActionViewItem(
|
||||
toggleReactionAction,
|
||||
(<ToggleReactionsAction>toggleReactionAction).menuActions,
|
||||
this.contextMenuService,
|
||||
{
|
||||
actionViewItemProvider: action => {
|
||||
if (action.id === ToggleReactionsAction.ID) {
|
||||
return toggleReactionActionViewItem;
|
||||
}
|
||||
return this.actionViewItemProvider(action as Action);
|
||||
},
|
||||
actionRunner: this.actionRunner,
|
||||
classNames: 'toolbar-toggle-pickReactions',
|
||||
anchorAlignmentProvider: () => AnchorAlignment.RIGHT
|
||||
}
|
||||
);
|
||||
|
||||
return toggleReactionAction;
|
||||
}
|
||||
|
||||
private createReactionsContainer(commentDetailsContainer: HTMLElement): void {
|
||||
this._reactionActionsContainer = dom.append(commentDetailsContainer, dom.$('div.comment-reactions'));
|
||||
this._reactionsActionBar = new ActionBar(this._reactionActionsContainer, {
|
||||
actionViewItemProvider: action => {
|
||||
if (action.id === ToggleReactionsAction.ID) {
|
||||
return new DropdownMenuActionViewItem(
|
||||
action,
|
||||
(<ToggleReactionsAction>action).menuActions,
|
||||
this.contextMenuService,
|
||||
{
|
||||
actionViewItemProvider: action => this.actionViewItemProvider(action as Action),
|
||||
actionRunner: this.actionRunner,
|
||||
classNames: 'toolbar-toggle-pickReactions',
|
||||
anchorAlignmentProvider: () => AnchorAlignment.RIGHT
|
||||
}
|
||||
);
|
||||
}
|
||||
return this.actionViewItemProvider(action as Action);
|
||||
}
|
||||
});
|
||||
this._register(this._reactionsActionBar);
|
||||
|
||||
let hasReactionHandler = this.commentService.hasReactionHandler(this.owner);
|
||||
this.comment.commentReactions!.filter(reaction => !!reaction.count).map(reaction => {
|
||||
let action = new ReactionAction(`reaction.${reaction.label}`, `${reaction.label}`, reaction.hasReacted && (reaction.canEdit || hasReactionHandler) ? 'active' : '', (reaction.canEdit || hasReactionHandler), async () => {
|
||||
try {
|
||||
await this.commentService.toggleReaction(this.owner, this.resource, this.commentThread, this.comment, reaction);
|
||||
} catch (e) {
|
||||
let error: string;
|
||||
|
||||
if (reaction.hasReacted) {
|
||||
error = e.message
|
||||
? nls.localize('commentDeleteReactionError', "Deleting the comment reaction failed: {0}.", e.message)
|
||||
: nls.localize('commentDeleteReactionDefaultError', "Deleting the comment reaction failed");
|
||||
} else {
|
||||
error = e.message
|
||||
? nls.localize('commentAddReactionError', "Deleting the comment reaction failed: {0}.", e.message)
|
||||
: nls.localize('commentAddReactionDefaultError', "Deleting the comment reaction failed");
|
||||
}
|
||||
this.notificationService.error(error);
|
||||
}
|
||||
}, reaction.iconPath, reaction.count);
|
||||
|
||||
if (this._reactionsActionBar) {
|
||||
this._reactionsActionBar.push(action, { label: true, icon: true });
|
||||
}
|
||||
});
|
||||
|
||||
if (hasReactionHandler) {
|
||||
let toggleReactionAction = this.createReactionPicker(this.comment.commentReactions || []);
|
||||
this._reactionsActionBar.push(toggleReactionAction, { label: false, icon: true });
|
||||
}
|
||||
}
|
||||
|
||||
private createCommentEditor(editContainer: HTMLElement): void {
|
||||
const container = dom.append(editContainer, dom.$('.edit-textarea'));
|
||||
this._commentEditor = this.instantiationService.createInstance(SimpleCommentEditor, container, SimpleCommentEditor.getEditorOptions(), this.parentEditor, this.parentThread);
|
||||
const resource = URI.parse(`comment:commentinput-${this.comment.uniqueIdInThread}-${Date.now()}.md`);
|
||||
this._commentEditorModel = this.modelService.createModel('', this.modeService.createByFilepathOrFirstLine(resource), resource, false);
|
||||
|
||||
this._commentEditor.setModel(this._commentEditorModel);
|
||||
this._commentEditor.setValue(this.comment.body.value);
|
||||
this._commentEditor.layout({ width: container.clientWidth - 14, height: 90 });
|
||||
this._commentEditor.focus();
|
||||
|
||||
dom.scheduleAtNextAnimationFrame(() => {
|
||||
this._commentEditor!.layout({ width: container.clientWidth - 14, height: 90 });
|
||||
this._commentEditor!.focus();
|
||||
});
|
||||
|
||||
const lastLine = this._commentEditorModel.getLineCount();
|
||||
const lastColumn = this._commentEditorModel.getLineContent(lastLine).length + 1;
|
||||
this._commentEditor.setSelection(new Selection(lastLine, lastColumn, lastLine, lastColumn));
|
||||
|
||||
let commentThread = this.commentThread;
|
||||
commentThread.input = {
|
||||
uri: this._commentEditor.getModel()!.uri,
|
||||
value: this.comment.body.value
|
||||
};
|
||||
this.commentService.setActiveCommentThread(commentThread);
|
||||
|
||||
this._commentEditorDisposables.push(this._commentEditor.onDidFocusEditorWidget(() => {
|
||||
commentThread.input = {
|
||||
uri: this._commentEditor!.getModel()!.uri,
|
||||
value: this.comment.body.value
|
||||
};
|
||||
this.commentService.setActiveCommentThread(commentThread);
|
||||
}));
|
||||
|
||||
this._commentEditorDisposables.push(this._commentEditor.onDidChangeModelContent(e => {
|
||||
if (commentThread.input && this._commentEditor && this._commentEditor.getModel()!.uri === commentThread.input.uri) {
|
||||
let newVal = this._commentEditor.getValue();
|
||||
if (newVal !== commentThread.input.value) {
|
||||
let input = commentThread.input;
|
||||
input.value = newVal;
|
||||
commentThread.input = input;
|
||||
this.commentService.setActiveCommentThread(commentThread);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this._commentEditor);
|
||||
this._register(this._commentEditorModel);
|
||||
}
|
||||
|
||||
private removeCommentEditor() {
|
||||
this.isEditing = false;
|
||||
if (this._editAction) {
|
||||
this._editAction.enabled = true;
|
||||
}
|
||||
this._body.classList.remove('hidden');
|
||||
|
||||
if (this._commentEditorModel) {
|
||||
this._commentEditorModel.dispose();
|
||||
}
|
||||
|
||||
this._commentEditorDisposables.forEach(dispose => dispose.dispose());
|
||||
this._commentEditorDisposables = [];
|
||||
if (this._commentEditor) {
|
||||
this._commentEditor.dispose();
|
||||
this._commentEditor = null;
|
||||
}
|
||||
|
||||
this._commentEditContainer!.remove();
|
||||
}
|
||||
|
||||
layout() {
|
||||
this._commentEditor?.layout();
|
||||
}
|
||||
|
||||
public switchToEditMode() {
|
||||
if (this.isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEditing = true;
|
||||
this._body.classList.add('hidden');
|
||||
this._commentEditContainer = dom.append(this._commentDetailsContainer, dom.$('.edit-container'));
|
||||
this.createCommentEditor(this._commentEditContainer);
|
||||
const formActions = dom.append(this._commentEditContainer, dom.$('.form-actions'));
|
||||
|
||||
const menus = this.commentService.getCommentMenus(this.owner);
|
||||
const menu = menus.getCommentActions(this.comment, this._contextKeyService);
|
||||
|
||||
this._register(menu);
|
||||
this._register(menu.onDidChange(() => {
|
||||
if (this._commentFormActions) {
|
||||
this._commentFormActions.setActions(menu);
|
||||
}
|
||||
}));
|
||||
|
||||
this._commentFormActions = new CommentFormActions(formActions, (action: IAction): void => {
|
||||
let text = this._commentEditor!.getValue();
|
||||
|
||||
action.run({
|
||||
thread: this.commentThread,
|
||||
commentUniqueId: this.comment.uniqueIdInThread,
|
||||
text: text,
|
||||
$mid: 10
|
||||
});
|
||||
|
||||
this.removeCommentEditor();
|
||||
}, this.themeService);
|
||||
|
||||
this._commentFormActions.setActions(menu);
|
||||
}
|
||||
|
||||
setFocus(focused: boolean, visible: boolean = false) {
|
||||
if (focused) {
|
||||
this._domNode.focus();
|
||||
this._actionsToolbarContainer.classList.remove('hidden');
|
||||
this._actionsToolbarContainer.classList.add('tabfocused');
|
||||
this._domNode.tabIndex = 0;
|
||||
if (this.comment.mode === modes.CommentMode.Editing) {
|
||||
this._commentEditor?.focus();
|
||||
}
|
||||
} else {
|
||||
if (this._actionsToolbarContainer.classList.contains('tabfocused') && !this._actionsToolbarContainer.classList.contains('mouseover')) {
|
||||
this._actionsToolbarContainer.classList.add('hidden');
|
||||
this._domNode.tabIndex = -1;
|
||||
}
|
||||
this._actionsToolbarContainer.classList.remove('tabfocused');
|
||||
}
|
||||
}
|
||||
|
||||
private registerActionBarListeners(actionsContainer: HTMLElement): void {
|
||||
this._register(dom.addDisposableListener(this._domNode, 'mouseenter', () => {
|
||||
actionsContainer.classList.remove('hidden');
|
||||
actionsContainer.classList.add('mouseover');
|
||||
}));
|
||||
this._register(dom.addDisposableListener(this._domNode, 'mouseleave', () => {
|
||||
if (actionsContainer.classList.contains('mouseover') && !actionsContainer.classList.contains('tabfocused')) {
|
||||
actionsContainer.classList.add('hidden');
|
||||
}
|
||||
actionsContainer.classList.remove('mouseover');
|
||||
}));
|
||||
}
|
||||
|
||||
update(newComment: modes.Comment) {
|
||||
|
||||
if (newComment.body !== this.comment.body) {
|
||||
this._body.removeChild(this._md);
|
||||
this._md = this.markdownRenderer.render(newComment.body).element;
|
||||
this._body.appendChild(this._md);
|
||||
}
|
||||
|
||||
if (newComment.mode !== undefined && newComment.mode !== this.comment.mode) {
|
||||
if (newComment.mode === modes.CommentMode.Editing) {
|
||||
this.switchToEditMode();
|
||||
} else {
|
||||
this.removeCommentEditor();
|
||||
}
|
||||
}
|
||||
|
||||
this.comment = newComment;
|
||||
|
||||
if (newComment.label) {
|
||||
this._isPendingLabel.innerText = newComment.label;
|
||||
} else {
|
||||
this._isPendingLabel.innerText = '';
|
||||
}
|
||||
|
||||
// update comment reactions
|
||||
if (this._reactionActionsContainer) {
|
||||
this._reactionActionsContainer.remove();
|
||||
}
|
||||
|
||||
if (this._reactionsActionBar) {
|
||||
this._reactionsActionBar.clear();
|
||||
}
|
||||
|
||||
if (this.comment.commentReactions && this.comment.commentReactions.some(reaction => !!reaction.count)) {
|
||||
this.createReactionsContainer(this._commentDetailsContainer);
|
||||
}
|
||||
|
||||
if (this.comment.contextValue) {
|
||||
this._commentContextValue.set(this.comment.contextValue);
|
||||
} else {
|
||||
this._commentContextValue.reset();
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.domNode.focus();
|
||||
if (!this._clearTimeout) {
|
||||
this.domNode.classList.add('focus');
|
||||
this._clearTimeout = setTimeout(() => {
|
||||
this.domNode.classList.remove('focus');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillInActions(groups: [string, Array<MenuItemAction | SubmenuItemAction>][], target: IAction[] | { primary: IAction[]; secondary: IAction[]; }, useAlternativeActions: boolean, isPrimaryGroup: (group: string) => boolean = group => group === 'navigation'): void {
|
||||
for (let tuple of groups) {
|
||||
let [group, actions] = tuple;
|
||||
if (useAlternativeActions) {
|
||||
actions = actions.map(a => (a instanceof MenuItemAction) && !!a.alt ? a.alt : a);
|
||||
}
|
||||
|
||||
if (isPrimaryGroup(group)) {
|
||||
const to = Array.isArray(target) ? target : target.primary;
|
||||
|
||||
to.unshift(...actions);
|
||||
} else {
|
||||
const to = Array.isArray(target) ? target : target.secondary;
|
||||
|
||||
if (to.length > 0) {
|
||||
to.push(new Separator());
|
||||
}
|
||||
|
||||
to.push(...actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CommentThreadChangedEvent, CommentInfo, Comment, CommentReaction, CommentingRanges, CommentThread } from 'vs/editor/common/modes';
|
||||
import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { Event, Emitter } from 'vs/base/common/event';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { Range, IRange } from 'vs/editor/common/core/range';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { ICommentThreadChangedEvent } from 'vs/workbench/contrib/comments/common/commentModel';
|
||||
import { MainThreadCommentController } from 'vs/workbench/api/browser/mainThreadComments';
|
||||
import { CommentMenus } from 'vs/workbench/contrib/comments/browser/commentMenus';
|
||||
|
||||
export const ICommentService = createDecorator<ICommentService>('commentService');
|
||||
|
||||
export interface IResourceCommentThreadEvent {
|
||||
resource: URI;
|
||||
commentInfos: ICommentInfo[];
|
||||
}
|
||||
|
||||
export interface ICommentInfo extends CommentInfo {
|
||||
owner: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface IWorkspaceCommentThreadsEvent {
|
||||
ownerId: string;
|
||||
commentThreads: CommentThread[];
|
||||
}
|
||||
|
||||
export interface ICommentService {
|
||||
readonly _serviceBrand: undefined;
|
||||
readonly onDidSetResourceCommentInfos: Event<IResourceCommentThreadEvent>;
|
||||
readonly onDidSetAllCommentThreads: Event<IWorkspaceCommentThreadsEvent>;
|
||||
readonly onDidUpdateCommentThreads: Event<ICommentThreadChangedEvent>;
|
||||
readonly onDidChangeActiveCommentThread: Event<CommentThread | null>;
|
||||
readonly onDidChangeActiveCommentingRange: Event<{ range: Range, commentingRangesInfo: CommentingRanges }>;
|
||||
readonly onDidSetDataProvider: Event<void>;
|
||||
readonly onDidDeleteDataProvider: Event<string>;
|
||||
setDocumentComments(resource: URI, commentInfos: ICommentInfo[]): void;
|
||||
setWorkspaceComments(owner: string, commentsByResource: CommentThread[]): void;
|
||||
removeWorkspaceComments(owner: string): void;
|
||||
registerCommentController(owner: string, commentControl: MainThreadCommentController): void;
|
||||
unregisterCommentController(owner: string): void;
|
||||
getCommentController(owner: string): MainThreadCommentController | undefined;
|
||||
createCommentThreadTemplate(owner: string, resource: URI, range: Range): void;
|
||||
updateCommentThreadTemplate(owner: string, threadHandle: number, range: Range): Promise<void>;
|
||||
getCommentMenus(owner: string): CommentMenus;
|
||||
updateComments(ownerId: string, event: CommentThreadChangedEvent): void;
|
||||
disposeCommentThread(ownerId: string, threadId: string): void;
|
||||
getComments(resource: URI): Promise<(ICommentInfo | null)[]>;
|
||||
getCommentingRanges(resource: URI): Promise<IRange[]>;
|
||||
hasReactionHandler(owner: string): boolean;
|
||||
toggleReaction(owner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise<void>;
|
||||
setActiveCommentThread(commentThread: CommentThread | null): void;
|
||||
}
|
||||
|
||||
export class CommentService extends Disposable implements ICommentService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _onDidSetDataProvider: Emitter<void> = this._register(new Emitter<void>());
|
||||
readonly onDidSetDataProvider: Event<void> = this._onDidSetDataProvider.event;
|
||||
|
||||
private readonly _onDidDeleteDataProvider: Emitter<string> = this._register(new Emitter<string>());
|
||||
readonly onDidDeleteDataProvider: Event<string> = this._onDidDeleteDataProvider.event;
|
||||
|
||||
private readonly _onDidSetResourceCommentInfos: Emitter<IResourceCommentThreadEvent> = this._register(new Emitter<IResourceCommentThreadEvent>());
|
||||
readonly onDidSetResourceCommentInfos: Event<IResourceCommentThreadEvent> = this._onDidSetResourceCommentInfos.event;
|
||||
|
||||
private readonly _onDidSetAllCommentThreads: Emitter<IWorkspaceCommentThreadsEvent> = this._register(new Emitter<IWorkspaceCommentThreadsEvent>());
|
||||
readonly onDidSetAllCommentThreads: Event<IWorkspaceCommentThreadsEvent> = this._onDidSetAllCommentThreads.event;
|
||||
|
||||
private readonly _onDidUpdateCommentThreads: Emitter<ICommentThreadChangedEvent> = this._register(new Emitter<ICommentThreadChangedEvent>());
|
||||
readonly onDidUpdateCommentThreads: Event<ICommentThreadChangedEvent> = this._onDidUpdateCommentThreads.event;
|
||||
|
||||
private readonly _onDidChangeActiveCommentThread = this._register(new Emitter<CommentThread | null>());
|
||||
readonly onDidChangeActiveCommentThread = this._onDidChangeActiveCommentThread.event;
|
||||
|
||||
private readonly _onDidChangeActiveCommentingRange: Emitter<{
|
||||
range: Range, commentingRangesInfo:
|
||||
CommentingRanges
|
||||
}> = this._register(new Emitter<{
|
||||
range: Range, commentingRangesInfo:
|
||||
CommentingRanges
|
||||
}>());
|
||||
readonly onDidChangeActiveCommentingRange: Event<{ range: Range, commentingRangesInfo: CommentingRanges }> = this._onDidChangeActiveCommentingRange.event;
|
||||
|
||||
private _commentControls = new Map<string, MainThreadCommentController>();
|
||||
private _commentMenus = new Map<string, CommentMenus>();
|
||||
|
||||
constructor(
|
||||
@IInstantiationService protected instantiationService: IInstantiationService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
setActiveCommentThread(commentThread: CommentThread | null) {
|
||||
this._onDidChangeActiveCommentThread.fire(commentThread);
|
||||
}
|
||||
|
||||
setDocumentComments(resource: URI, commentInfos: ICommentInfo[]): void {
|
||||
this._onDidSetResourceCommentInfos.fire({ resource, commentInfos });
|
||||
}
|
||||
|
||||
setWorkspaceComments(owner: string, commentsByResource: CommentThread[]): void {
|
||||
this._onDidSetAllCommentThreads.fire({ ownerId: owner, commentThreads: commentsByResource });
|
||||
}
|
||||
|
||||
removeWorkspaceComments(owner: string): void {
|
||||
this._onDidSetAllCommentThreads.fire({ ownerId: owner, commentThreads: [] });
|
||||
}
|
||||
|
||||
registerCommentController(owner: string, commentControl: MainThreadCommentController): void {
|
||||
this._commentControls.set(owner, commentControl);
|
||||
this._onDidSetDataProvider.fire();
|
||||
}
|
||||
|
||||
unregisterCommentController(owner: string): void {
|
||||
this._commentControls.delete(owner);
|
||||
this._onDidDeleteDataProvider.fire(owner);
|
||||
}
|
||||
|
||||
getCommentController(owner: string): MainThreadCommentController | undefined {
|
||||
return this._commentControls.get(owner);
|
||||
}
|
||||
|
||||
createCommentThreadTemplate(owner: string, resource: URI, range: Range): void {
|
||||
const commentController = this._commentControls.get(owner);
|
||||
|
||||
if (!commentController) {
|
||||
return;
|
||||
}
|
||||
|
||||
commentController.createCommentThreadTemplate(resource, range);
|
||||
}
|
||||
|
||||
async updateCommentThreadTemplate(owner: string, threadHandle: number, range: Range) {
|
||||
const commentController = this._commentControls.get(owner);
|
||||
|
||||
if (!commentController) {
|
||||
return;
|
||||
}
|
||||
|
||||
await commentController.updateCommentThreadTemplate(threadHandle, range);
|
||||
}
|
||||
|
||||
disposeCommentThread(owner: string, threadId: string) {
|
||||
let controller = this.getCommentController(owner);
|
||||
if (controller) {
|
||||
controller.deleteCommentThreadMain(threadId);
|
||||
}
|
||||
}
|
||||
|
||||
getCommentMenus(owner: string): CommentMenus {
|
||||
if (this._commentMenus.get(owner)) {
|
||||
return this._commentMenus.get(owner)!;
|
||||
}
|
||||
|
||||
let menu = this.instantiationService.createInstance(CommentMenus);
|
||||
this._commentMenus.set(owner, menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
updateComments(ownerId: string, event: CommentThreadChangedEvent): void {
|
||||
const evt: ICommentThreadChangedEvent = Object.assign({}, event, { owner: ownerId });
|
||||
this._onDidUpdateCommentThreads.fire(evt);
|
||||
}
|
||||
|
||||
async toggleReaction(owner: string, resource: URI, thread: CommentThread, comment: Comment, reaction: CommentReaction): Promise<void> {
|
||||
const commentController = this._commentControls.get(owner);
|
||||
|
||||
if (commentController) {
|
||||
return commentController.toggleReaction(resource, thread, comment, reaction, CancellationToken.None);
|
||||
} else {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
}
|
||||
|
||||
hasReactionHandler(owner: string): boolean {
|
||||
const commentProvider = this._commentControls.get(owner);
|
||||
|
||||
if (commentProvider) {
|
||||
return !!commentProvider.features.reactionHandler;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getComments(resource: URI): Promise<(ICommentInfo | null)[]> {
|
||||
let commentControlResult: Promise<ICommentInfo | null>[] = [];
|
||||
|
||||
this._commentControls.forEach(control => {
|
||||
commentControlResult.push(control.getDocumentComments(resource, CancellationToken.None)
|
||||
.catch(e => {
|
||||
console.log(e);
|
||||
return null;
|
||||
}));
|
||||
});
|
||||
|
||||
return Promise.all(commentControlResult);
|
||||
}
|
||||
|
||||
async getCommentingRanges(resource: URI): Promise<IRange[]> {
|
||||
let commentControlResult: Promise<IRange[]>[] = [];
|
||||
|
||||
this._commentControls.forEach(control => {
|
||||
commentControlResult.push(control.getCommentingRanges(resource, CancellationToken.None));
|
||||
});
|
||||
|
||||
let ret = await Promise.all(commentControlResult);
|
||||
return ret.reduce((prev, curr) => { prev.push(...curr); return prev; }, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
|
||||
import { Action, IAction } from 'vs/base/common/actions';
|
||||
import * as arrays from 'vs/base/common/arrays';
|
||||
import { Color } from 'vs/base/common/color';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { withNullAsUndefined } from 'vs/base/common/types';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
import { IMarginData } from 'vs/editor/browser/controller/mouseTarget';
|
||||
import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser';
|
||||
import { IPosition } from 'vs/editor/common/core/position';
|
||||
import { IRange, Range } from 'vs/editor/common/core/range';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import * as modes from 'vs/editor/common/modes';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { IModeService } from 'vs/editor/common/services/modeService';
|
||||
import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer';
|
||||
import { peekViewBorder } from 'vs/editor/contrib/peekView/peekView';
|
||||
import { ZoneWidget } from 'vs/editor/contrib/zoneWidget/zoneWidget';
|
||||
import * as nls from 'vs/nls';
|
||||
import { MenuEntryActionViewItem, SubmenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem';
|
||||
import { IMenu, MenuItemAction, SubmenuItemAction } from 'vs/platform/actions/common/actions';
|
||||
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { contrastBorder, editorForeground, focusBorder, inputValidationErrorBackground, inputValidationErrorBorder, inputValidationErrorForeground, textBlockQuoteBackground, textBlockQuoteBorder, textLinkActiveForeground, textLinkForeground, transparent } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { CommentFormActions } from 'vs/workbench/contrib/comments/browser/commentFormActions';
|
||||
import { CommentGlyphWidget } from 'vs/workbench/contrib/comments/browser/commentGlyphWidget';
|
||||
import { CommentMenus } from 'vs/workbench/contrib/comments/browser/commentMenus';
|
||||
import { CommentNode } from 'vs/workbench/contrib/comments/browser/commentNode';
|
||||
import { ICommentService } from 'vs/workbench/contrib/comments/browser/commentService';
|
||||
import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/commentContextKeys';
|
||||
import { ICommentThreadWidget } from 'vs/workbench/contrib/comments/common/commentThreadWidget';
|
||||
import { SimpleCommentEditor } from './simpleCommentEditor';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
|
||||
import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from 'vs/base/browser/ui/mouseCursor/mouseCursor';
|
||||
import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
|
||||
import { PANEL_BORDER } from 'vs/workbench/common/theme';
|
||||
|
||||
export const COMMENTEDITOR_DECORATION_KEY = 'commenteditordecoration';
|
||||
const COLLAPSE_ACTION_CLASS = 'expand-review-action codicon-chevron-up';
|
||||
const COMMENT_SCHEME = 'comment';
|
||||
|
||||
|
||||
let INMEM_MODEL_ID = 0;
|
||||
|
||||
export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget {
|
||||
private _headElement!: HTMLElement;
|
||||
protected _headingLabel!: HTMLElement;
|
||||
protected _actionbarWidget!: ActionBar;
|
||||
private _bodyElement!: HTMLElement;
|
||||
private _parentEditor: ICodeEditor;
|
||||
private _commentsElement!: HTMLElement;
|
||||
private _commentElements: CommentNode[] = [];
|
||||
private _commentReplyComponent?: {
|
||||
editor: ICodeEditor;
|
||||
form: HTMLElement;
|
||||
commentEditorIsEmpty: IContextKey<boolean>;
|
||||
};
|
||||
private _reviewThreadReplyButton!: HTMLElement;
|
||||
private _resizeObserver: any;
|
||||
private readonly _onDidClose = new Emitter<ReviewZoneWidget | undefined>();
|
||||
private readonly _onDidCreateThread = new Emitter<ReviewZoneWidget>();
|
||||
private _isExpanded?: boolean;
|
||||
private _collapseAction!: Action;
|
||||
private _commentGlyph?: CommentGlyphWidget;
|
||||
private _submitActionsDisposables: IDisposable[];
|
||||
private readonly _globalToDispose = new DisposableStore();
|
||||
private _commentThreadDisposables: IDisposable[] = [];
|
||||
private _markdownRenderer: MarkdownRenderer;
|
||||
private _styleElement: HTMLStyleElement;
|
||||
private _formActions: HTMLElement | null;
|
||||
private _error!: HTMLElement;
|
||||
private _contextKeyService: IContextKeyService;
|
||||
private _threadIsEmpty: IContextKey<boolean>;
|
||||
private _commentThreadContextValue: IContextKey<string>;
|
||||
private _commentFormActions!: CommentFormActions;
|
||||
private _scopedInstatiationService: IInstantiationService;
|
||||
private _focusedComment: number | undefined = undefined;
|
||||
|
||||
public get owner(): string {
|
||||
return this._owner;
|
||||
}
|
||||
public get commentThread(): modes.CommentThread {
|
||||
return this._commentThread;
|
||||
}
|
||||
|
||||
public get extensionId(): string | undefined {
|
||||
return this._commentThread.extensionId;
|
||||
}
|
||||
|
||||
private _commentMenus: CommentMenus;
|
||||
|
||||
private _commentOptions: modes.CommentOptions | undefined;
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
private _owner: string,
|
||||
private _commentThread: modes.CommentThread,
|
||||
private _pendingComment: string | null,
|
||||
@IInstantiationService private instantiationService: IInstantiationService,
|
||||
@IModeService private modeService: IModeService,
|
||||
@IModelService private modelService: IModelService,
|
||||
@IThemeService private themeService: IThemeService,
|
||||
@ICommentService private commentService: ICommentService,
|
||||
@IOpenerService private openerService: IOpenerService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService
|
||||
) {
|
||||
super(editor, { keepEditorSelection: true });
|
||||
this._contextKeyService = contextKeyService.createScoped(this.domNode);
|
||||
|
||||
this._scopedInstatiationService = instantiationService.createChild(new ServiceCollection(
|
||||
[IContextKeyService, this._contextKeyService]
|
||||
));
|
||||
|
||||
this._threadIsEmpty = CommentContextKeys.commentThreadIsEmpty.bindTo(this._contextKeyService);
|
||||
this._threadIsEmpty.set(!_commentThread.comments || !_commentThread.comments.length);
|
||||
this._commentThreadContextValue = this._contextKeyService.createKey('commentThread', _commentThread.contextValue);
|
||||
|
||||
const commentControllerKey = this._contextKeyService.createKey<string | undefined>('commentController', undefined);
|
||||
const controller = this.commentService.getCommentController(this._owner);
|
||||
|
||||
if (controller) {
|
||||
commentControllerKey.set(controller.contextValue);
|
||||
this._commentOptions = controller.options;
|
||||
}
|
||||
|
||||
this._resizeObserver = null;
|
||||
this._isExpanded = _commentThread.collapsibleState === modes.CommentThreadCollapsibleState.Expanded;
|
||||
this._commentThreadDisposables = [];
|
||||
this._submitActionsDisposables = [];
|
||||
this._formActions = null;
|
||||
this._commentMenus = this.commentService.getCommentMenus(this._owner);
|
||||
this.create();
|
||||
|
||||
this._styleElement = dom.createStyleSheet(this.domNode);
|
||||
this._globalToDispose.add(this.themeService.onDidColorThemeChange(this._applyTheme, this));
|
||||
this._globalToDispose.add(this.editor.onDidChangeConfiguration(e => {
|
||||
if (e.hasChanged(EditorOption.fontInfo)) {
|
||||
this._applyTheme(this.themeService.getColorTheme());
|
||||
}
|
||||
}));
|
||||
this._applyTheme(this.themeService.getColorTheme());
|
||||
|
||||
this._markdownRenderer = this._globalToDispose.add(new MarkdownRenderer({ editor }, this.modeService, this.openerService));
|
||||
this._parentEditor = editor;
|
||||
}
|
||||
|
||||
public get onDidClose(): Event<ReviewZoneWidget | undefined> {
|
||||
return this._onDidClose.event;
|
||||
}
|
||||
|
||||
public get onDidCreateThread(): Event<ReviewZoneWidget> {
|
||||
return this._onDidCreateThread.event;
|
||||
}
|
||||
|
||||
public getPosition(): IPosition | undefined {
|
||||
if (this.position) {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
if (this._commentGlyph) {
|
||||
return withNullAsUndefined(this._commentGlyph.getPosition().position);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected revealLine(lineNumber: number) {
|
||||
// we don't do anything here as we always do the reveal ourselves.
|
||||
}
|
||||
|
||||
public reveal(commentUniqueId?: number) {
|
||||
if (!this._isExpanded) {
|
||||
this.show({ lineNumber: this._commentThread.range.startLineNumber, column: 1 }, 2);
|
||||
}
|
||||
|
||||
if (commentUniqueId !== undefined) {
|
||||
let height = this.editor.getLayoutInfo().height;
|
||||
let matchedNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === commentUniqueId);
|
||||
if (matchedNode && matchedNode.length) {
|
||||
const commentThreadCoords = dom.getDomNodePagePosition(this._commentElements[0].domNode);
|
||||
const commentCoords = dom.getDomNodePagePosition(matchedNode[0].domNode);
|
||||
|
||||
this.editor.setScrollTop(this.editor.getTopForLineNumber(this._commentThread.range.startLineNumber) - height / 2 + commentCoords.top - commentThreadCoords.top);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.revealRangeInCenter(this._commentThread.range);
|
||||
}
|
||||
|
||||
public getPendingComment(): string | null {
|
||||
if (this._commentReplyComponent) {
|
||||
let model = this._commentReplyComponent.editor.getModel();
|
||||
|
||||
if (model && model.getValueLength() > 0) { // checking length is cheap
|
||||
return model.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _fillContainer(container: HTMLElement): void {
|
||||
this.setCssClass('review-widget');
|
||||
this._headElement = <HTMLDivElement>dom.$('.head');
|
||||
container.appendChild(this._headElement);
|
||||
this._fillHead(this._headElement);
|
||||
|
||||
this._bodyElement = <HTMLDivElement>dom.$('.body');
|
||||
container.appendChild(this._bodyElement);
|
||||
|
||||
dom.addDisposableListener(this._bodyElement, dom.EventType.FOCUS_IN, e => {
|
||||
this.commentService.setActiveCommentThread(this._commentThread);
|
||||
});
|
||||
}
|
||||
|
||||
protected _fillHead(container: HTMLElement): void {
|
||||
let titleElement = dom.append(this._headElement, dom.$('.review-title'));
|
||||
|
||||
this._headingLabel = dom.append(titleElement, dom.$('span.filename'));
|
||||
this.createThreadLabel();
|
||||
|
||||
const actionsContainer = dom.append(this._headElement, dom.$('.review-actions'));
|
||||
this._actionbarWidget = new ActionBar(actionsContainer, {
|
||||
actionViewItemProvider: (action: IAction) => {
|
||||
if (action instanceof MenuItemAction) {
|
||||
return this.instantiationService.createInstance(MenuEntryActionViewItem, action);
|
||||
} else if (action instanceof SubmenuItemAction) {
|
||||
return this.instantiationService.createInstance(SubmenuEntryActionViewItem, action);
|
||||
} else {
|
||||
return new ActionViewItem({}, action, { label: false, icon: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._disposables.add(this._actionbarWidget);
|
||||
|
||||
this._collapseAction = new Action('review.expand', nls.localize('label.collapse', "Collapse"), COLLAPSE_ACTION_CLASS, true, () => this.collapse());
|
||||
|
||||
const menu = this._commentMenus.getCommentThreadTitleActions(this._commentThread, this._contextKeyService);
|
||||
this.setActionBarActions(menu);
|
||||
|
||||
this._disposables.add(menu);
|
||||
this._disposables.add(menu.onDidChange(e => {
|
||||
this.setActionBarActions(menu);
|
||||
}));
|
||||
|
||||
this._actionbarWidget.context = this._commentThread;
|
||||
}
|
||||
|
||||
private setActionBarActions(menu: IMenu): void {
|
||||
const groups = menu.getActions({ shouldForwardArgs: true }).reduce((r, [, actions]) => [...r, ...actions], <(MenuItemAction | SubmenuItemAction)[]>[]);
|
||||
this._actionbarWidget.clear();
|
||||
this._actionbarWidget.push([...groups, this._collapseAction], { label: false, icon: true });
|
||||
}
|
||||
|
||||
private deleteCommentThread(): void {
|
||||
this.dispose();
|
||||
this.commentService.disposeCommentThread(this.owner, this._commentThread.threadId);
|
||||
}
|
||||
|
||||
public collapse(): Promise<void> {
|
||||
this._commentThread.collapsibleState = modes.CommentThreadCollapsibleState.Collapsed;
|
||||
if (this._commentThread.comments && this._commentThread.comments.length === 0) {
|
||||
this.deleteCommentThread();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.hide();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public getGlyphPosition(): number {
|
||||
if (this._commentGlyph) {
|
||||
return this._commentGlyph.getPosition().position!.lineNumber;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
toggleExpand(lineNumber: number) {
|
||||
if (this._isExpanded) {
|
||||
this._commentThread.collapsibleState = modes.CommentThreadCollapsibleState.Collapsed;
|
||||
this.hide();
|
||||
if (!this._commentThread.comments || !this._commentThread.comments.length) {
|
||||
this.deleteCommentThread();
|
||||
}
|
||||
} else {
|
||||
this._commentThread.collapsibleState = modes.CommentThreadCollapsibleState.Expanded;
|
||||
this.show({ lineNumber: lineNumber, column: 1 }, 2);
|
||||
}
|
||||
}
|
||||
|
||||
async update(commentThread: modes.CommentThread) {
|
||||
const oldCommentsLen = this._commentElements.length;
|
||||
const newCommentsLen = commentThread.comments ? commentThread.comments.length : 0;
|
||||
this._threadIsEmpty.set(!newCommentsLen);
|
||||
|
||||
let commentElementsToDel: CommentNode[] = [];
|
||||
let commentElementsToDelIndex: number[] = [];
|
||||
for (let i = 0; i < oldCommentsLen; i++) {
|
||||
let comment = this._commentElements[i].comment;
|
||||
let newComment = commentThread.comments ? commentThread.comments.filter(c => c.uniqueIdInThread === comment.uniqueIdInThread) : [];
|
||||
|
||||
if (newComment.length) {
|
||||
this._commentElements[i].update(newComment[0]);
|
||||
} else {
|
||||
commentElementsToDelIndex.push(i);
|
||||
commentElementsToDel.push(this._commentElements[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// del removed elements
|
||||
for (let i = commentElementsToDel.length - 1; i >= 0; i--) {
|
||||
this._commentElements.splice(commentElementsToDelIndex[i], 1);
|
||||
this._commentsElement.removeChild(commentElementsToDel[i].domNode);
|
||||
}
|
||||
|
||||
let lastCommentElement: HTMLElement | null = null;
|
||||
let newCommentNodeList: CommentNode[] = [];
|
||||
let newCommentsInEditMode: CommentNode[] = [];
|
||||
for (let i = newCommentsLen - 1; i >= 0; i--) {
|
||||
let currentComment = commentThread.comments![i];
|
||||
let oldCommentNode = this._commentElements.filter(commentNode => commentNode.comment.uniqueIdInThread === currentComment.uniqueIdInThread);
|
||||
if (oldCommentNode.length) {
|
||||
lastCommentElement = oldCommentNode[0].domNode;
|
||||
newCommentNodeList.unshift(oldCommentNode[0]);
|
||||
} else {
|
||||
const newElement = this.createNewCommentNode(currentComment);
|
||||
|
||||
newCommentNodeList.unshift(newElement);
|
||||
if (lastCommentElement) {
|
||||
this._commentsElement.insertBefore(newElement.domNode, lastCommentElement);
|
||||
lastCommentElement = newElement.domNode;
|
||||
} else {
|
||||
this._commentsElement.appendChild(newElement.domNode);
|
||||
lastCommentElement = newElement.domNode;
|
||||
}
|
||||
|
||||
if (currentComment.mode === modes.CommentMode.Editing) {
|
||||
newElement.switchToEditMode();
|
||||
newCommentsInEditMode.push(newElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._commentThread = commentThread;
|
||||
this._commentElements = newCommentNodeList;
|
||||
this.createThreadLabel();
|
||||
|
||||
// Move comment glyph widget and show position if the line has changed.
|
||||
const lineNumber = this._commentThread.range.startLineNumber;
|
||||
let shouldMoveWidget = false;
|
||||
if (this._commentGlyph) {
|
||||
if (this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {
|
||||
shouldMoveWidget = true;
|
||||
this._commentGlyph.setLineNumber(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._reviewThreadReplyButton && this._commentReplyComponent) {
|
||||
this.createReplyButton(this._commentReplyComponent.editor, this._commentReplyComponent.form);
|
||||
}
|
||||
|
||||
if (this._commentThread.comments && this._commentThread.comments.length === 0) {
|
||||
this.expandReplyArea();
|
||||
}
|
||||
|
||||
if (shouldMoveWidget && this._isExpanded) {
|
||||
this.show({ lineNumber, column: 1 }, 2);
|
||||
}
|
||||
|
||||
if (this._commentThread.collapsibleState === modes.CommentThreadCollapsibleState.Expanded) {
|
||||
this.show({ lineNumber, column: 1 }, 2);
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (this._commentThread.contextValue) {
|
||||
this._commentThreadContextValue.set(this._commentThread.contextValue);
|
||||
} else {
|
||||
this._commentThreadContextValue.reset();
|
||||
}
|
||||
|
||||
if (newCommentsInEditMode.length) {
|
||||
const lastIndex = this._commentElements.indexOf(newCommentsInEditMode[newCommentsInEditMode.length - 1]);
|
||||
this._focusedComment = lastIndex;
|
||||
}
|
||||
|
||||
this.setFocusedComment(this._focusedComment);
|
||||
}
|
||||
|
||||
protected _onWidth(widthInPixel: number): void {
|
||||
this._commentReplyComponent?.editor.layout({ height: 5 * 18, width: widthInPixel - 54 /* margin 20px * 10 + scrollbar 14px*/ });
|
||||
}
|
||||
|
||||
protected _doLayout(heightInPixel: number, widthInPixel: number): void {
|
||||
this._commentReplyComponent?.editor.layout({ height: 5 * 18, width: widthInPixel - 54 /* margin 20px * 10 + scrollbar 14px*/ });
|
||||
}
|
||||
|
||||
display(lineNumber: number) {
|
||||
this._commentGlyph = new CommentGlyphWidget(this.editor, lineNumber);
|
||||
|
||||
this._disposables.add(this.editor.onMouseDown(e => this.onEditorMouseDown(e)));
|
||||
this._disposables.add(this.editor.onMouseUp(e => this.onEditorMouseUp(e)));
|
||||
|
||||
let headHeight = Math.ceil(this.editor.getOption(EditorOption.lineHeight) * 1.2);
|
||||
this._headElement.style.height = `${headHeight}px`;
|
||||
this._headElement.style.lineHeight = this._headElement.style.height;
|
||||
|
||||
this._commentsElement = dom.append(this._bodyElement, dom.$('div.comments-container'));
|
||||
this._commentsElement.setAttribute('role', 'presentation');
|
||||
this._commentsElement.tabIndex = 0;
|
||||
|
||||
this._disposables.add(dom.addDisposableListener(this._commentsElement, dom.EventType.KEY_DOWN, (e) => {
|
||||
let event = new StandardKeyboardEvent(e as KeyboardEvent);
|
||||
if (event.equals(KeyCode.UpArrow) || event.equals(KeyCode.DownArrow)) {
|
||||
const moveFocusWithinBounds = (change: number): number => {
|
||||
if (this._focusedComment === undefined && change >= 0) { return 0; }
|
||||
if (this._focusedComment === undefined && change < 0) { return this._commentElements.length - 1; }
|
||||
let newIndex = this._focusedComment! + change;
|
||||
return Math.min(Math.max(0, newIndex), this._commentElements.length - 1);
|
||||
};
|
||||
|
||||
this.setFocusedComment(event.equals(KeyCode.UpArrow) ? moveFocusWithinBounds(-1) : moveFocusWithinBounds(1));
|
||||
}
|
||||
}));
|
||||
|
||||
this._commentElements = [];
|
||||
if (this._commentThread.comments) {
|
||||
for (const comment of this._commentThread.comments) {
|
||||
const newCommentNode = this.createNewCommentNode(comment);
|
||||
|
||||
this._commentElements.push(newCommentNode);
|
||||
this._commentsElement.appendChild(newCommentNode.domNode);
|
||||
if (comment.mode === modes.CommentMode.Editing) {
|
||||
newCommentNode.switchToEditMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create comment thread only when it supports reply
|
||||
if (this._commentThread.canReply) {
|
||||
this.createCommentForm();
|
||||
}
|
||||
|
||||
this._resizeObserver = new MutationObserver(this._refresh.bind(this));
|
||||
|
||||
this._resizeObserver.observe(this._bodyElement, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
if (this._commentThread.collapsibleState === modes.CommentThreadCollapsibleState.Expanded) {
|
||||
this.show({ lineNumber: lineNumber, column: 1 }, 2);
|
||||
}
|
||||
|
||||
// If there are no existing comments, place focus on the text area. This must be done after show, which also moves focus.
|
||||
// if this._commentThread.comments is undefined, it doesn't finish initialization yet, so we don't focus the editor immediately.
|
||||
if (this._commentThread.canReply && this._commentReplyComponent) {
|
||||
if (!this._commentThread.comments || !this._commentThread.comments.length) {
|
||||
this._commentReplyComponent.editor.focus();
|
||||
} else if (this._commentReplyComponent.editor.getModel()!.getValueLength() > 0) {
|
||||
this.expandReplyArea();
|
||||
}
|
||||
}
|
||||
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeCanReply(() => {
|
||||
if (this._commentReplyComponent) {
|
||||
if (!this._commentThread.canReply) {
|
||||
this._commentReplyComponent.form.style.display = 'none';
|
||||
} else {
|
||||
this._commentReplyComponent.form.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
if (this._commentThread.canReply) {
|
||||
this.createCommentForm();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private createCommentForm() {
|
||||
const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;
|
||||
const commentForm = dom.append(this._bodyElement, dom.$('.comment-form'));
|
||||
const commentEditor = this._scopedInstatiationService.createInstance(SimpleCommentEditor, commentForm, SimpleCommentEditor.getEditorOptions(), this._parentEditor, this);
|
||||
const commentEditorIsEmpty = CommentContextKeys.commentIsEmpty.bindTo(this._contextKeyService);
|
||||
commentEditorIsEmpty.set(!this._pendingComment);
|
||||
|
||||
this._commentReplyComponent = {
|
||||
form: commentForm,
|
||||
editor: commentEditor,
|
||||
commentEditorIsEmpty
|
||||
};
|
||||
|
||||
const modeId = generateUuid() + '-' + (hasExistingComments ? this._commentThread.threadId : ++INMEM_MODEL_ID);
|
||||
const params = JSON.stringify({
|
||||
extensionId: this.extensionId,
|
||||
commentThreadId: this.commentThread.threadId
|
||||
});
|
||||
|
||||
let resource = URI.parse(`${COMMENT_SCHEME}://${this.extensionId}/commentinput-${modeId}.md?${params}`); // TODO. Remove params once extensions adopt authority.
|
||||
let commentController = this.commentService.getCommentController(this.owner);
|
||||
if (commentController) {
|
||||
resource = resource.with({ authority: commentController.id });
|
||||
}
|
||||
|
||||
const model = this.modelService.createModel(this._pendingComment || '', this.modeService.createByFilepathOrFirstLine(resource), resource, false);
|
||||
this._disposables.add(model);
|
||||
commentEditor.setModel(model);
|
||||
this._disposables.add(commentEditor);
|
||||
this._disposables.add(commentEditor.getModel()!.onDidChangeContent(() => {
|
||||
this.setCommentEditorDecorations();
|
||||
commentEditorIsEmpty?.set(!commentEditor.getValue());
|
||||
}));
|
||||
|
||||
this.createTextModelListener(commentEditor, commentForm);
|
||||
|
||||
this.setCommentEditorDecorations();
|
||||
|
||||
// Only add the additional step of clicking a reply button to expand the textarea when there are existing comments
|
||||
if (hasExistingComments) {
|
||||
this.createReplyButton(commentEditor, commentForm);
|
||||
} else {
|
||||
if (this._commentThread.comments && this._commentThread.comments.length === 0) {
|
||||
this.expandReplyArea();
|
||||
}
|
||||
}
|
||||
this._error = dom.append(commentForm, dom.$('.validation-error.hidden'));
|
||||
|
||||
this._formActions = dom.append(commentForm, dom.$('.form-actions'));
|
||||
this.createCommentWidgetActions(this._formActions, model);
|
||||
this.createCommentWidgetActionsListener();
|
||||
}
|
||||
|
||||
private createTextModelListener(commentEditor: ICodeEditor, commentForm: HTMLElement) {
|
||||
this._commentThreadDisposables.push(commentEditor.onDidFocusEditorWidget(() => {
|
||||
this._commentThread.input = {
|
||||
uri: commentEditor.getModel()!.uri,
|
||||
value: commentEditor.getValue()
|
||||
};
|
||||
this.commentService.setActiveCommentThread(this._commentThread);
|
||||
}));
|
||||
|
||||
this._commentThreadDisposables.push(commentEditor.getModel()!.onDidChangeContent(() => {
|
||||
let modelContent = commentEditor.getValue();
|
||||
if (this._commentThread.input && this._commentThread.input.uri === commentEditor.getModel()!.uri && this._commentThread.input.value !== modelContent) {
|
||||
let newInput: modes.CommentInput = this._commentThread.input;
|
||||
newInput.value = modelContent;
|
||||
this._commentThread.input = newInput;
|
||||
}
|
||||
this.commentService.setActiveCommentThread(this._commentThread);
|
||||
}));
|
||||
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeInput(input => {
|
||||
let thread = this._commentThread;
|
||||
|
||||
if (thread.input && thread.input.uri !== commentEditor.getModel()!.uri) {
|
||||
return;
|
||||
}
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commentEditor.getValue() !== input.value) {
|
||||
commentEditor.setValue(input.value);
|
||||
|
||||
if (input.value === '') {
|
||||
this._pendingComment = '';
|
||||
commentForm.classList.remove('expand');
|
||||
commentEditor.getDomNode()!.style.outline = '';
|
||||
this._error.textContent = '';
|
||||
this._error.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeComments(async _ => {
|
||||
await this.update(this._commentThread);
|
||||
}));
|
||||
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeLabel(_ => {
|
||||
this.createThreadLabel();
|
||||
}));
|
||||
}
|
||||
|
||||
private createCommentWidgetActionsListener() {
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeRange(range => {
|
||||
// Move comment glyph widget and show position if the line has changed.
|
||||
const lineNumber = this._commentThread.range.startLineNumber;
|
||||
let shouldMoveWidget = false;
|
||||
if (this._commentGlyph) {
|
||||
if (this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {
|
||||
shouldMoveWidget = true;
|
||||
this._commentGlyph.setLineNumber(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldMoveWidget && this._isExpanded) {
|
||||
this.show({ lineNumber, column: 1 }, 2);
|
||||
}
|
||||
}));
|
||||
|
||||
this._commentThreadDisposables.push(this._commentThread.onDidChangeCollasibleState(state => {
|
||||
if (state === modes.CommentThreadCollapsibleState.Expanded && !this._isExpanded) {
|
||||
const lineNumber = this._commentThread.range.startLineNumber;
|
||||
|
||||
this.show({ lineNumber, column: 1 }, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === modes.CommentThreadCollapsibleState.Collapsed && this._isExpanded) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private setFocusedComment(value: number | undefined) {
|
||||
if (this._focusedComment !== undefined) {
|
||||
this._commentElements[this._focusedComment]?.setFocus(false);
|
||||
}
|
||||
|
||||
if (this._commentElements.length === 0 || value === undefined) {
|
||||
this._focusedComment = undefined;
|
||||
} else {
|
||||
this._focusedComment = Math.min(value, this._commentElements.length - 1);
|
||||
this._commentElements[this._focusedComment].setFocus(true);
|
||||
}
|
||||
}
|
||||
|
||||
private getActiveComment(): CommentNode | ReviewZoneWidget {
|
||||
return this._commentElements.filter(node => node.isEditing)[0] || this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command based actions.
|
||||
*/
|
||||
private createCommentWidgetActions(container: HTMLElement, model: ITextModel) {
|
||||
const commentThread = this._commentThread;
|
||||
|
||||
const menu = this._commentMenus.getCommentThreadActions(commentThread, this._contextKeyService);
|
||||
|
||||
this._disposables.add(menu);
|
||||
this._disposables.add(menu.onDidChange(() => {
|
||||
this._commentFormActions.setActions(menu);
|
||||
}));
|
||||
|
||||
this._commentFormActions = new CommentFormActions(container, async (action: IAction) => {
|
||||
if (!commentThread.comments || !commentThread.comments.length) {
|
||||
let newPosition = this.getPosition();
|
||||
|
||||
if (newPosition) {
|
||||
this.commentService.updateCommentThreadTemplate(this.owner, commentThread.commentThreadHandle, new Range(newPosition.lineNumber, 1, newPosition.lineNumber, 1));
|
||||
}
|
||||
}
|
||||
action.run({
|
||||
thread: this._commentThread,
|
||||
text: this._commentReplyComponent?.editor.getValue(),
|
||||
$mid: 8
|
||||
});
|
||||
|
||||
this.hideReplyArea();
|
||||
}, this.themeService);
|
||||
|
||||
this._commentFormActions.setActions(menu);
|
||||
}
|
||||
|
||||
private createNewCommentNode(comment: modes.Comment): CommentNode {
|
||||
let newCommentNode = this._scopedInstatiationService.createInstance(CommentNode,
|
||||
this._commentThread,
|
||||
comment,
|
||||
this.owner,
|
||||
this.editor.getModel()!.uri,
|
||||
this._parentEditor,
|
||||
this,
|
||||
this._markdownRenderer);
|
||||
|
||||
this._disposables.add(newCommentNode);
|
||||
this._disposables.add(newCommentNode.onDidClick(clickedNode =>
|
||||
this.setFocusedComment(this._commentElements.findIndex(commentNode => commentNode.comment.uniqueIdInThread === clickedNode.comment.uniqueIdInThread))
|
||||
));
|
||||
|
||||
return newCommentNode;
|
||||
}
|
||||
|
||||
async submitComment(): Promise<void> {
|
||||
const activeComment = this.getActiveComment();
|
||||
if (activeComment instanceof ReviewZoneWidget) {
|
||||
if (this._commentFormActions) {
|
||||
this._commentFormActions.triggerDefaultAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createThreadLabel() {
|
||||
let label: string | undefined;
|
||||
label = this._commentThread.label;
|
||||
|
||||
if (label === undefined) {
|
||||
if (this._commentThread.comments && this._commentThread.comments.length) {
|
||||
const participantsList = this._commentThread.comments.filter(arrays.uniqueFilter(comment => comment.userName)).map(comment => `@${comment.userName}`).join(', ');
|
||||
label = nls.localize('commentThreadParticipants', "Participants: {0}", participantsList);
|
||||
} else {
|
||||
label = nls.localize('startThread', "Start discussion");
|
||||
}
|
||||
}
|
||||
|
||||
if (label) {
|
||||
this._headingLabel.textContent = strings.escape(label);
|
||||
this._headingLabel.setAttribute('aria-label', label);
|
||||
}
|
||||
}
|
||||
|
||||
private expandReplyArea() {
|
||||
if (!this._commentReplyComponent?.form.classList.contains('expand')) {
|
||||
this._commentReplyComponent?.form.classList.add('expand');
|
||||
this._commentReplyComponent?.editor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private hideReplyArea() {
|
||||
if (this._commentReplyComponent) {
|
||||
this._commentReplyComponent.editor.setValue('');
|
||||
this._commentReplyComponent.editor.getDomNode()!.style.outline = '';
|
||||
}
|
||||
this._pendingComment = '';
|
||||
this._commentReplyComponent?.form.classList.remove('expand');
|
||||
this._error.textContent = '';
|
||||
this._error.classList.add('hidden');
|
||||
}
|
||||
|
||||
private createReplyButton(commentEditor: ICodeEditor, commentForm: HTMLElement) {
|
||||
this._reviewThreadReplyButton = <HTMLButtonElement>dom.append(commentForm, dom.$(`button.review-thread-reply-button.${MOUSE_CURSOR_TEXT_CSS_CLASS_NAME}`));
|
||||
this._reviewThreadReplyButton.title = this._commentOptions?.prompt || nls.localize('reply', "Reply...");
|
||||
|
||||
this._reviewThreadReplyButton.textContent = this._commentOptions?.prompt || nls.localize('reply', "Reply...");
|
||||
// bind click/escape actions for reviewThreadReplyButton and textArea
|
||||
this._disposables.add(dom.addDisposableListener(this._reviewThreadReplyButton, 'click', _ => this.expandReplyArea()));
|
||||
this._disposables.add(dom.addDisposableListener(this._reviewThreadReplyButton, 'focus', _ => this.expandReplyArea()));
|
||||
|
||||
commentEditor.onDidBlurEditorWidget(() => {
|
||||
if (commentEditor.getModel()!.getValueLength() === 0 && commentForm.classList.contains('expand')) {
|
||||
commentForm.classList.remove('expand');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_refresh() {
|
||||
if (this._isExpanded && this._bodyElement) {
|
||||
let dimensions = dom.getClientArea(this._bodyElement);
|
||||
|
||||
this._commentElements.forEach(element => {
|
||||
element.layout();
|
||||
});
|
||||
|
||||
const headHeight = Math.ceil(this.editor.getOption(EditorOption.lineHeight) * 1.2);
|
||||
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
|
||||
const arrowHeight = Math.round(lineHeight / 3);
|
||||
const frameThickness = Math.round(lineHeight / 9) * 2;
|
||||
|
||||
const computedLinesNumber = Math.ceil((headHeight + dimensions.height + arrowHeight + frameThickness + 8 /** margin bottom to avoid margin collapse */) / lineHeight);
|
||||
|
||||
if (this._viewZone?.heightInLines === computedLinesNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentPosition = this.getPosition();
|
||||
|
||||
if (this._viewZone && currentPosition && currentPosition.lineNumber !== this._viewZone.afterLineNumber) {
|
||||
this._viewZone.afterLineNumber = currentPosition.lineNumber;
|
||||
}
|
||||
|
||||
if (!this._commentThread.comments || !this._commentThread.comments.length) {
|
||||
this._commentReplyComponent?.editor.focus();
|
||||
}
|
||||
|
||||
this._relayout(computedLinesNumber);
|
||||
}
|
||||
}
|
||||
|
||||
private setCommentEditorDecorations() {
|
||||
const model = this._commentReplyComponent && this._commentReplyComponent.editor.getModel();
|
||||
if (model) {
|
||||
const valueLength = model.getValueLength();
|
||||
const hasExistingComments = this._commentThread.comments && this._commentThread.comments.length > 0;
|
||||
const placeholder = valueLength > 0
|
||||
? ''
|
||||
: hasExistingComments
|
||||
? (this._commentOptions?.placeHolder || nls.localize('reply', "Reply..."))
|
||||
: (this._commentOptions?.placeHolder || nls.localize('newComment', "Type a new comment"));
|
||||
const decorations = [{
|
||||
range: {
|
||||
startLineNumber: 0,
|
||||
endLineNumber: 0,
|
||||
startColumn: 0,
|
||||
endColumn: 1
|
||||
},
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: placeholder,
|
||||
color: `${transparent(editorForeground, 0.4)(this.themeService.getColorTheme())}`
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
this._commentReplyComponent?.editor.setDecorations(COMMENTEDITOR_DECORATION_KEY, decorations);
|
||||
}
|
||||
}
|
||||
|
||||
private mouseDownInfo: { lineNumber: number } | null = null;
|
||||
|
||||
private onEditorMouseDown(e: IEditorMouseEvent): void {
|
||||
this.mouseDownInfo = null;
|
||||
|
||||
const range = e.target.range;
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.event.leftButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.target.detail as IMarginData;
|
||||
const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft;
|
||||
|
||||
// don't collide with folding and git decorations
|
||||
if (gutterOffsetX > 14) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouseDownInfo = { lineNumber: range.startLineNumber };
|
||||
}
|
||||
|
||||
private onEditorMouseUp(e: IEditorMouseEvent): void {
|
||||
if (!this.mouseDownInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lineNumber } = this.mouseDownInfo;
|
||||
this.mouseDownInfo = null;
|
||||
|
||||
const range = e.target.range;
|
||||
|
||||
if (!range || range.startLineNumber !== lineNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.target.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._commentGlyph && this._commentGlyph.getPosition().position!.lineNumber !== lineNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.element.className.indexOf('comment-thread') >= 0) {
|
||||
this.toggleExpand(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
private _applyTheme(theme: IColorTheme) {
|
||||
const borderColor = theme.getColor(peekViewBorder) || Color.transparent;
|
||||
this.style({
|
||||
arrowColor: borderColor,
|
||||
frameColor: borderColor
|
||||
});
|
||||
|
||||
const content: string[] = [];
|
||||
const linkColor = theme.getColor(textLinkForeground);
|
||||
if (linkColor) {
|
||||
content.push(`.monaco-editor .review-widget .body .comment-body a { color: ${linkColor} }`);
|
||||
}
|
||||
|
||||
const linkActiveColor = theme.getColor(textLinkActiveForeground);
|
||||
if (linkActiveColor) {
|
||||
content.push(`.monaco-editor .review-widget .body .comment-body a:hover, a:active { color: ${linkActiveColor} }`);
|
||||
}
|
||||
|
||||
const focusColor = theme.getColor(focusBorder);
|
||||
if (focusColor) {
|
||||
content.push(`.monaco-editor .review-widget .body .comment-body a:focus { outline: 1px solid ${focusColor}; }`);
|
||||
content.push(`.monaco-editor .review-widget .body .monaco-editor.focused { outline: 1px solid ${focusColor}; }`);
|
||||
}
|
||||
|
||||
const blockQuoteBackground = theme.getColor(textBlockQuoteBackground);
|
||||
if (blockQuoteBackground) {
|
||||
content.push(`.monaco-editor .review-widget .body .review-comment blockquote { background: ${blockQuoteBackground}; }`);
|
||||
}
|
||||
|
||||
const blockQuoteBOrder = theme.getColor(textBlockQuoteBorder);
|
||||
if (blockQuoteBOrder) {
|
||||
content.push(`.monaco-editor .review-widget .body .review-comment blockquote { border-color: ${blockQuoteBOrder}; }`);
|
||||
}
|
||||
|
||||
const border = theme.getColor(PANEL_BORDER);
|
||||
if (border) {
|
||||
content.push(`.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label { border-color: ${border}; }`);
|
||||
}
|
||||
|
||||
const hcBorder = theme.getColor(contrastBorder);
|
||||
if (hcBorder) {
|
||||
content.push(`.monaco-editor .review-widget .body .comment-form .review-thread-reply-button { outline-color: ${hcBorder}; }`);
|
||||
content.push(`.monaco-editor .review-widget .body .monaco-editor { outline: 1px solid ${hcBorder}; }`);
|
||||
}
|
||||
|
||||
const errorBorder = theme.getColor(inputValidationErrorBorder);
|
||||
if (errorBorder) {
|
||||
content.push(`.monaco-editor .review-widget .validation-error { border: 1px solid ${errorBorder}; }`);
|
||||
}
|
||||
|
||||
const errorBackground = theme.getColor(inputValidationErrorBackground);
|
||||
if (errorBackground) {
|
||||
content.push(`.monaco-editor .review-widget .validation-error { background: ${errorBackground}; }`);
|
||||
}
|
||||
|
||||
const errorForeground = theme.getColor(inputValidationErrorForeground);
|
||||
if (errorForeground) {
|
||||
content.push(`.monaco-editor .review-widget .body .comment-form .validation-error { color: ${errorForeground}; }`);
|
||||
}
|
||||
|
||||
const fontInfo = this.editor.getOption(EditorOption.fontInfo);
|
||||
content.push(`.monaco-editor .review-widget .body code {
|
||||
font-family: ${fontInfo.fontFamily};
|
||||
font-size: ${fontInfo.fontSize}px;
|
||||
font-weight: ${fontInfo.fontWeight};
|
||||
}`);
|
||||
|
||||
this._styleElement.textContent = content.join('\n');
|
||||
|
||||
// Editor decorations should also be responsive to theme changes
|
||||
this.setCommentEditorDecorations();
|
||||
}
|
||||
|
||||
show(rangeOrPos: IRange | IPosition, heightInLines: number): void {
|
||||
this._isExpanded = true;
|
||||
super.show(rangeOrPos, heightInLines);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this._isExpanded) {
|
||||
this._isExpanded = false;
|
||||
// Focus the container so that the comment editor will be blurred before it is hidden
|
||||
this.editor.focus();
|
||||
}
|
||||
super.hide();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = null;
|
||||
}
|
||||
|
||||
if (this._commentGlyph) {
|
||||
this._commentGlyph.dispose();
|
||||
this._commentGlyph = undefined;
|
||||
}
|
||||
|
||||
this._globalToDispose.dispose();
|
||||
this._commentThreadDisposables.forEach(global => global.dispose());
|
||||
this._submitActionsDisposables.forEach(local => local.dispose());
|
||||
this._onDidClose.fire(undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import 'vs/workbench/contrib/comments/browser/commentsEditorContribution';
|
||||
import { ICommentService, CommentService } from 'vs/workbench/contrib/comments/browser/commentService';
|
||||
import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
|
||||
export interface ICommentsConfiguration {
|
||||
openPanel: 'neverOpen' | 'openOnSessionStart' | 'openOnSessionStartWithComments';
|
||||
}
|
||||
|
||||
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
|
||||
id: 'comments',
|
||||
order: 20,
|
||||
title: nls.localize('commentsConfigurationTitle', "Comments"),
|
||||
type: 'object',
|
||||
properties: {
|
||||
'comments.openPanel': {
|
||||
enum: ['neverOpen', 'openOnSessionStart', 'openOnSessionStartWithComments'],
|
||||
default: 'openOnSessionStartWithComments',
|
||||
description: nls.localize('openComments', "Controls when the comments panel should open.")
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registerSingleton(ICommentService, CommentService);
|
||||
@@ -0,0 +1,831 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { $ } from 'vs/base/browser/dom';
|
||||
import { Action, IAction } from 'vs/base/common/actions';
|
||||
import { coalesce, findFirstInSorted } from 'vs/base/common/arrays';
|
||||
import { CancelablePromise, createCancelablePromise, Delayer } from 'vs/base/common/async';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import 'vs/css!./media/review';
|
||||
import { IMarginData } from 'vs/editor/browser/controller/mouseTarget';
|
||||
import { IActiveCodeEditor, ICodeEditor, IEditorMouseEvent, isCodeEditor, isDiffEditor, IViewZone, MouseTargetType } from 'vs/editor/browser/editorBrowser';
|
||||
import { EditorAction, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { IRange, Range } from 'vs/editor/common/core/range';
|
||||
import { IEditorContribution, IModelChangedEvent } from 'vs/editor/common/editorCommon';
|
||||
import { IModelDecorationOptions } from 'vs/editor/common/model';
|
||||
import { ModelDecorationOptions } from 'vs/editor/common/model/textModel';
|
||||
import * as modes from 'vs/editor/common/modes';
|
||||
import { peekViewResultsBackground, peekViewResultsSelectionBackground, peekViewTitleBackground } from 'vs/editor/contrib/peekView/peekView';
|
||||
import * as nls from 'vs/nls';
|
||||
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
|
||||
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { IQuickInputService, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput';
|
||||
import { editorForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { STATUS_BAR_ITEM_ACTIVE_BACKGROUND, STATUS_BAR_ITEM_HOVER_BACKGROUND } from 'vs/workbench/common/theme';
|
||||
import { overviewRulerCommentingRangeForeground } from 'vs/workbench/contrib/comments/browser/commentGlyphWidget';
|
||||
import { ICommentInfo, ICommentService } from 'vs/workbench/contrib/comments/browser/commentService';
|
||||
import { COMMENTEDITOR_DECORATION_KEY, ReviewZoneWidget } from 'vs/workbench/contrib/comments/browser/commentThreadWidget';
|
||||
import { ctxCommentEditorFocused, SimpleCommentEditor } from 'vs/workbench/contrib/comments/browser/simpleCommentEditor';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
|
||||
export const ID = 'editor.contrib.review';
|
||||
|
||||
export class ReviewViewZone implements IViewZone {
|
||||
public readonly afterLineNumber: number;
|
||||
public readonly domNode: HTMLElement;
|
||||
private callback: (top: number) => void;
|
||||
|
||||
constructor(afterLineNumber: number, onDomNodeTop: (top: number) => void) {
|
||||
this.afterLineNumber = afterLineNumber;
|
||||
this.callback = onDomNodeTop;
|
||||
|
||||
this.domNode = $('.review-viewzone');
|
||||
}
|
||||
|
||||
onDomNodeTop(top: number): void {
|
||||
this.callback(top);
|
||||
}
|
||||
}
|
||||
|
||||
class CommentingRangeDecoration {
|
||||
private _decorationId: string;
|
||||
|
||||
public get id(): string {
|
||||
return this._decorationId;
|
||||
}
|
||||
|
||||
constructor(private _editor: ICodeEditor, private _ownerId: string, private _extensionId: string | undefined, private _label: string | undefined, private _range: IRange, commentingOptions: ModelDecorationOptions, private commentingRangesInfo: modes.CommentingRanges) {
|
||||
const startLineNumber = _range.startLineNumber;
|
||||
const endLineNumber = _range.endLineNumber;
|
||||
let commentingRangeDecorations = [{
|
||||
range: {
|
||||
startLineNumber: startLineNumber, startColumn: 1,
|
||||
endLineNumber: endLineNumber, endColumn: 1
|
||||
},
|
||||
options: commentingOptions
|
||||
}];
|
||||
|
||||
this._decorationId = this._editor.deltaDecorations([], commentingRangeDecorations)[0];
|
||||
}
|
||||
|
||||
public getCommentAction(): { ownerId: string, extensionId: string | undefined, label: string | undefined, commentingRangesInfo: modes.CommentingRanges } {
|
||||
return {
|
||||
extensionId: this._extensionId,
|
||||
label: this._label,
|
||||
ownerId: this._ownerId,
|
||||
commentingRangesInfo: this.commentingRangesInfo
|
||||
};
|
||||
}
|
||||
|
||||
public getOriginalRange() {
|
||||
return this._range;
|
||||
}
|
||||
|
||||
public getActiveRange() {
|
||||
return this._editor.getModel()!.getDecorationRange(this._decorationId);
|
||||
}
|
||||
}
|
||||
class CommentingRangeDecorator {
|
||||
|
||||
private decorationOptions: ModelDecorationOptions;
|
||||
private commentingRangeDecorations: CommentingRangeDecoration[] = [];
|
||||
|
||||
constructor() {
|
||||
const decorationOptions: IModelDecorationOptions = {
|
||||
isWholeLine: true,
|
||||
linesDecorationsClassName: 'comment-range-glyph comment-diff-added'
|
||||
};
|
||||
|
||||
this.decorationOptions = ModelDecorationOptions.createDynamic(decorationOptions);
|
||||
}
|
||||
|
||||
public update(editor: ICodeEditor, commentInfos: ICommentInfo[]) {
|
||||
let model = editor.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
let commentingRangeDecorations: CommentingRangeDecoration[] = [];
|
||||
for (const info of commentInfos) {
|
||||
info.commentingRanges.ranges.forEach(range => {
|
||||
commentingRangeDecorations.push(new CommentingRangeDecoration(editor, info.owner, info.extensionId, info.label, range, this.decorationOptions, info.commentingRanges));
|
||||
});
|
||||
}
|
||||
|
||||
let oldDecorations = this.commentingRangeDecorations.map(decoration => decoration.id);
|
||||
editor.deltaDecorations(oldDecorations, []);
|
||||
|
||||
this.commentingRangeDecorations = commentingRangeDecorations;
|
||||
}
|
||||
|
||||
public getMatchedCommentAction(line: number) {
|
||||
let result = [];
|
||||
for (const decoration of this.commentingRangeDecorations) {
|
||||
const range = decoration.getActiveRange();
|
||||
if (range && range.startLineNumber <= line && line <= range.endLineNumber) {
|
||||
result.push(decoration.getCommentAction());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.commentingRangeDecorations = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentController implements IEditorContribution {
|
||||
private readonly globalToDispose = new DisposableStore();
|
||||
private readonly localToDispose = new DisposableStore();
|
||||
private editor!: ICodeEditor;
|
||||
private _commentWidgets: ReviewZoneWidget[];
|
||||
private _commentInfos: ICommentInfo[];
|
||||
private _commentingRangeDecorator!: CommentingRangeDecorator;
|
||||
private mouseDownInfo: { lineNumber: number } | null = null;
|
||||
private _commentingRangeSpaceReserved = false;
|
||||
private _computePromise: CancelablePromise<Array<ICommentInfo | null>> | null;
|
||||
private _addInProgress!: boolean;
|
||||
private _emptyThreadsToAddQueue: [number, IEditorMouseEvent | undefined][] = [];
|
||||
private _computeCommentingRangePromise!: CancelablePromise<ICommentInfo[]> | null;
|
||||
private _computeCommentingRangeScheduler!: Delayer<Array<ICommentInfo | null>> | null;
|
||||
private _pendingCommentCache: { [key: string]: { [key: string]: string } };
|
||||
|
||||
constructor(
|
||||
editor: ICodeEditor,
|
||||
@ICommentService private readonly commentService: ICommentService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
|
||||
@IContextMenuService readonly contextMenuService: IContextMenuService,
|
||||
@IQuickInputService private readonly quickInputService: IQuickInputService
|
||||
) {
|
||||
this._commentInfos = [];
|
||||
this._commentWidgets = [];
|
||||
this._pendingCommentCache = {};
|
||||
this._computePromise = null;
|
||||
|
||||
if (editor instanceof EmbeddedCodeEditorWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editor = editor;
|
||||
|
||||
this._commentingRangeDecorator = new CommentingRangeDecorator();
|
||||
|
||||
this.globalToDispose.add(this.commentService.onDidDeleteDataProvider(ownerId => {
|
||||
delete this._pendingCommentCache[ownerId];
|
||||
this.beginCompute();
|
||||
}));
|
||||
this.globalToDispose.add(this.commentService.onDidSetDataProvider(_ => this.beginCompute()));
|
||||
|
||||
this.globalToDispose.add(this.commentService.onDidSetResourceCommentInfos(e => {
|
||||
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
|
||||
if (editorURI && editorURI.toString() === e.resource.toString()) {
|
||||
this.setComments(e.commentInfos.filter(commentInfo => commentInfo !== null));
|
||||
}
|
||||
}));
|
||||
|
||||
this.globalToDispose.add(this.editor.onDidChangeModel(e => this.onModelChanged(e)));
|
||||
this.codeEditorService.registerDecorationType(COMMENTEDITOR_DECORATION_KEY, {});
|
||||
this.beginCompute();
|
||||
}
|
||||
|
||||
private beginCompute(): Promise<void> {
|
||||
this._computePromise = createCancelablePromise(token => {
|
||||
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
|
||||
|
||||
if (editorURI) {
|
||||
return this.commentService.getComments(editorURI);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
return this._computePromise.then(commentInfos => {
|
||||
this.setComments(coalesce(commentInfos));
|
||||
this._computePromise = null;
|
||||
}, error => console.log(error));
|
||||
}
|
||||
|
||||
private beginComputeCommentingRanges() {
|
||||
if (this._computeCommentingRangeScheduler) {
|
||||
if (this._computeCommentingRangePromise) {
|
||||
this._computeCommentingRangePromise.cancel();
|
||||
this._computeCommentingRangePromise = null;
|
||||
}
|
||||
|
||||
this._computeCommentingRangeScheduler.trigger(() => {
|
||||
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
|
||||
|
||||
if (editorURI) {
|
||||
return this.commentService.getComments(editorURI);
|
||||
}
|
||||
|
||||
return Promise.resolve([]);
|
||||
}).then(commentInfos => {
|
||||
const meaningfulCommentInfos = coalesce(commentInfos);
|
||||
this._commentingRangeDecorator.update(this.editor, meaningfulCommentInfos);
|
||||
}, (err) => {
|
||||
onUnexpectedError(err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static get(editor: ICodeEditor): CommentController {
|
||||
return editor.getContribution<CommentController>(ID);
|
||||
}
|
||||
|
||||
public revealCommentThread(threadId: string, commentUniqueId: number, fetchOnceIfNotExist: boolean): void {
|
||||
const commentThreadWidget = this._commentWidgets.filter(widget => widget.commentThread.threadId === threadId);
|
||||
if (commentThreadWidget.length === 1) {
|
||||
commentThreadWidget[0].reveal(commentUniqueId);
|
||||
} else if (fetchOnceIfNotExist) {
|
||||
if (this._computePromise) {
|
||||
this._computePromise.then(_ => {
|
||||
this.revealCommentThread(threadId, commentUniqueId, false);
|
||||
});
|
||||
} else {
|
||||
this.beginCompute().then(_ => {
|
||||
this.revealCommentThread(threadId, commentUniqueId, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public nextCommentThread(): void {
|
||||
if (!this._commentWidgets.length || !this.editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const after = this.editor.getSelection().getEndPosition();
|
||||
const sortedWidgets = this._commentWidgets.sort((a, b) => {
|
||||
if (a.commentThread.range.startLineNumber < b.commentThread.range.startLineNumber) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.commentThread.range.startLineNumber > b.commentThread.range.startLineNumber) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.commentThread.range.startColumn < b.commentThread.range.startColumn) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.commentThread.range.startColumn > b.commentThread.range.startColumn) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
let idx = findFirstInSorted(sortedWidgets, widget => {
|
||||
if (widget.commentThread.range.startLineNumber > after.lineNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (widget.commentThread.range.startLineNumber < after.lineNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (widget.commentThread.range.startColumn > after.column) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (idx === this._commentWidgets.length) {
|
||||
this._commentWidgets[0].reveal();
|
||||
this.editor.setSelection(this._commentWidgets[0].commentThread.range);
|
||||
} else {
|
||||
sortedWidgets[idx].reveal();
|
||||
this.editor.setSelection(sortedWidgets[idx].commentThread.range);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.globalToDispose.dispose();
|
||||
this.localToDispose.dispose();
|
||||
|
||||
this._commentWidgets.forEach(widget => widget.dispose());
|
||||
|
||||
this.editor = null!; // Strict null override — nulling out in dispose
|
||||
}
|
||||
|
||||
public onModelChanged(e: IModelChangedEvent): void {
|
||||
this.localToDispose.clear();
|
||||
|
||||
this.removeCommentWidgetsAndStoreCache();
|
||||
|
||||
this.localToDispose.add(this.editor.onMouseDown(e => this.onEditorMouseDown(e)));
|
||||
this.localToDispose.add(this.editor.onMouseUp(e => this.onEditorMouseUp(e)));
|
||||
|
||||
this._computeCommentingRangeScheduler = new Delayer<ICommentInfo[]>(200);
|
||||
this.localToDispose.add({
|
||||
dispose: () => {
|
||||
if (this._computeCommentingRangeScheduler) {
|
||||
this._computeCommentingRangeScheduler.cancel();
|
||||
}
|
||||
this._computeCommentingRangeScheduler = null;
|
||||
}
|
||||
});
|
||||
this.localToDispose.add(this.editor.onDidChangeModelContent(async () => {
|
||||
this.beginComputeCommentingRanges();
|
||||
}));
|
||||
this.localToDispose.add(this.commentService.onDidUpdateCommentThreads(async e => {
|
||||
const editorURI = this.editor && this.editor.hasModel() && this.editor.getModel().uri;
|
||||
if (!editorURI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._computePromise) {
|
||||
await this._computePromise;
|
||||
}
|
||||
|
||||
let commentInfo = this._commentInfos.filter(info => info.owner === e.owner);
|
||||
if (!commentInfo || !commentInfo.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let added = e.added.filter(thread => thread.resource && thread.resource.toString() === editorURI.toString());
|
||||
let removed = e.removed.filter(thread => thread.resource && thread.resource.toString() === editorURI.toString());
|
||||
let changed = e.changed.filter(thread => thread.resource && thread.resource.toString() === editorURI.toString());
|
||||
|
||||
removed.forEach(thread => {
|
||||
let matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId && zoneWidget.commentThread.threadId !== '');
|
||||
if (matchedZones.length) {
|
||||
let matchedZone = matchedZones[0];
|
||||
let index = this._commentWidgets.indexOf(matchedZone);
|
||||
this._commentWidgets.splice(index, 1);
|
||||
matchedZone.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
changed.forEach(thread => {
|
||||
let matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId);
|
||||
if (matchedZones.length) {
|
||||
let matchedZone = matchedZones[0];
|
||||
matchedZone.update(thread);
|
||||
}
|
||||
});
|
||||
added.forEach(thread => {
|
||||
let matchedZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && zoneWidget.commentThread.threadId === thread.threadId);
|
||||
if (matchedZones.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let matchedNewCommentThreadZones = this._commentWidgets.filter(zoneWidget => zoneWidget.owner === e.owner && (zoneWidget.commentThread as any).commentThreadHandle === -1 && Range.equalsRange(zoneWidget.commentThread.range, thread.range));
|
||||
|
||||
if (matchedNewCommentThreadZones.length) {
|
||||
matchedNewCommentThreadZones[0].update(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingCommentText = this._pendingCommentCache[e.owner] && this._pendingCommentCache[e.owner][thread.threadId!];
|
||||
this.displayCommentThread(e.owner, thread, pendingCommentText);
|
||||
this._commentInfos.filter(info => info.owner === e.owner)[0].threads.push(thread);
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
this.beginCompute();
|
||||
}
|
||||
|
||||
private displayCommentThread(owner: string, thread: modes.CommentThread, pendingComment: string | null): void {
|
||||
const zoneWidget = this.instantiationService.createInstance(ReviewZoneWidget, this.editor, owner, thread, pendingComment);
|
||||
zoneWidget.display(thread.range.startLineNumber);
|
||||
this._commentWidgets.push(zoneWidget);
|
||||
}
|
||||
|
||||
private onEditorMouseDown(e: IEditorMouseEvent): void {
|
||||
this.mouseDownInfo = null;
|
||||
|
||||
const range = e.target.range;
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.event.leftButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.target.detail as IMarginData;
|
||||
const gutterOffsetX = data.offsetX - data.glyphMarginWidth - data.lineNumbersWidth - data.glyphMarginLeft;
|
||||
|
||||
// don't collide with folding and git decorations
|
||||
if (gutterOffsetX > 14) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouseDownInfo = { lineNumber: range.startLineNumber };
|
||||
}
|
||||
|
||||
private onEditorMouseUp(e: IEditorMouseEvent): void {
|
||||
if (!this.mouseDownInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lineNumber } = this.mouseDownInfo;
|
||||
this.mouseDownInfo = null;
|
||||
|
||||
const range = e.target.range;
|
||||
|
||||
if (!range || range.startLineNumber !== lineNumber) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.target.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.element.className.indexOf('comment-diff-added') >= 0) {
|
||||
const lineNumber = e.target.position!.lineNumber;
|
||||
this.addOrToggleCommentAtLine(lineNumber, e);
|
||||
}
|
||||
}
|
||||
|
||||
public async addOrToggleCommentAtLine(lineNumber: number, e: IEditorMouseEvent | undefined): Promise<void> {
|
||||
// If an add is already in progress, queue the next add and process it after the current one finishes to
|
||||
// prevent empty comment threads from being added to the same line.
|
||||
if (!this._addInProgress) {
|
||||
this._addInProgress = true;
|
||||
// The widget's position is undefined until the widget has been displayed, so rely on the glyph position instead
|
||||
const existingCommentsAtLine = this._commentWidgets.filter(widget => widget.getGlyphPosition() === lineNumber);
|
||||
if (existingCommentsAtLine.length) {
|
||||
existingCommentsAtLine.forEach(widget => widget.toggleExpand(lineNumber));
|
||||
this.processNextThreadToAdd();
|
||||
return;
|
||||
} else {
|
||||
this.addCommentAtLine(lineNumber, e);
|
||||
}
|
||||
} else {
|
||||
this._emptyThreadsToAddQueue.push([lineNumber, e]);
|
||||
}
|
||||
}
|
||||
|
||||
private processNextThreadToAdd(): void {
|
||||
this._addInProgress = false;
|
||||
const info = this._emptyThreadsToAddQueue.shift();
|
||||
if (info) {
|
||||
this.addOrToggleCommentAtLine(info[0], info[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public addCommentAtLine(lineNumber: number, e: IEditorMouseEvent | undefined): Promise<void> {
|
||||
const newCommentInfos = this._commentingRangeDecorator.getMatchedCommentAction(lineNumber);
|
||||
if (!newCommentInfos.length || !this.editor.hasModel()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (newCommentInfos.length > 1) {
|
||||
if (e) {
|
||||
const anchor = { x: e.event.posx, y: e.event.posy };
|
||||
|
||||
this.contextMenuService.showContextMenu({
|
||||
getAnchor: () => anchor,
|
||||
getActions: () => this.getContextMenuActions(newCommentInfos, lineNumber),
|
||||
getActionsContext: () => newCommentInfos.length ? newCommentInfos[0] : undefined,
|
||||
onHide: () => { this._addInProgress = false; }
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
const picks = this.getCommentProvidersQuickPicks(newCommentInfos);
|
||||
return this.quickInputService.pick(picks, { placeHolder: nls.localize('pickCommentService', "Select Comment Provider"), matchOnDescription: true }).then(pick => {
|
||||
if (!pick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentInfos = newCommentInfos.filter(info => info.ownerId === pick.id);
|
||||
|
||||
if (commentInfos.length) {
|
||||
const { ownerId } = commentInfos[0];
|
||||
this.addCommentAtLine2(lineNumber, ownerId);
|
||||
}
|
||||
}).then(() => {
|
||||
this._addInProgress = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const { ownerId } = newCommentInfos[0]!;
|
||||
this.addCommentAtLine2(lineNumber, ownerId);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private getCommentProvidersQuickPicks(commentInfos: { ownerId: string, extensionId: string | undefined, label: string | undefined, commentingRangesInfo: modes.CommentingRanges | undefined }[]) {
|
||||
const picks: QuickPickInput[] = commentInfos.map((commentInfo) => {
|
||||
const { ownerId, extensionId, label } = commentInfo;
|
||||
|
||||
return <IQuickPickItem>{
|
||||
label: label || extensionId,
|
||||
id: ownerId
|
||||
};
|
||||
});
|
||||
|
||||
return picks;
|
||||
}
|
||||
|
||||
private getContextMenuActions(commentInfos: { ownerId: string, extensionId: string | undefined, label: string | undefined, commentingRangesInfo: modes.CommentingRanges }[], lineNumber: number): IAction[] {
|
||||
const actions: IAction[] = [];
|
||||
|
||||
commentInfos.forEach(commentInfo => {
|
||||
const { ownerId, extensionId, label } = commentInfo;
|
||||
|
||||
actions.push(new Action(
|
||||
'addCommentThread',
|
||||
`${label || extensionId}`,
|
||||
undefined,
|
||||
true,
|
||||
() => {
|
||||
this.addCommentAtLine2(lineNumber, ownerId);
|
||||
return Promise.resolve();
|
||||
}
|
||||
));
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
public addCommentAtLine2(lineNumber: number, ownerId: string) {
|
||||
const range = new Range(lineNumber, 1, lineNumber, 1);
|
||||
this.commentService.createCommentThreadTemplate(ownerId, this.editor.getModel()!.uri, range);
|
||||
this.processNextThreadToAdd();
|
||||
return;
|
||||
}
|
||||
|
||||
private setComments(commentInfos: ICommentInfo[]): void {
|
||||
if (!this.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._commentInfos = commentInfos;
|
||||
let lineDecorationsWidth: number = this.editor.getLayoutInfo().decorationsWidth;
|
||||
|
||||
if (this._commentInfos.some(info => Boolean(info.commentingRanges && (Array.isArray(info.commentingRanges) ? info.commentingRanges : info.commentingRanges.ranges).length))) {
|
||||
if (!this._commentingRangeSpaceReserved) {
|
||||
this._commentingRangeSpaceReserved = true;
|
||||
let extraEditorClassName: string[] = [];
|
||||
const configuredExtraClassName = this.editor.getRawOptions().extraEditorClassName;
|
||||
if (configuredExtraClassName) {
|
||||
extraEditorClassName = configuredExtraClassName.split(' ');
|
||||
}
|
||||
|
||||
const options = this.editor.getOptions();
|
||||
if (options.get(EditorOption.folding)) {
|
||||
lineDecorationsWidth -= 16;
|
||||
}
|
||||
lineDecorationsWidth += 9;
|
||||
extraEditorClassName.push('inline-comment');
|
||||
this.editor.updateOptions({
|
||||
extraEditorClassName: extraEditorClassName.join(' '),
|
||||
lineDecorationsWidth: lineDecorationsWidth
|
||||
});
|
||||
|
||||
// we only update the lineDecorationsWidth property but keep the width of the whole editor.
|
||||
const originalLayoutInfo = this.editor.getLayoutInfo();
|
||||
|
||||
this.editor.layout({
|
||||
width: originalLayoutInfo.width,
|
||||
height: originalLayoutInfo.height
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// create viewzones
|
||||
this.removeCommentWidgetsAndStoreCache();
|
||||
|
||||
this._commentInfos.forEach(info => {
|
||||
let providerCacheStore = this._pendingCommentCache[info.owner];
|
||||
info.threads = info.threads.filter(thread => !thread.isDisposed);
|
||||
info.threads.forEach(thread => {
|
||||
let pendingComment: string | null = null;
|
||||
if (providerCacheStore) {
|
||||
pendingComment = providerCacheStore[thread.threadId!];
|
||||
}
|
||||
|
||||
if (pendingComment) {
|
||||
thread.collapsibleState = modes.CommentThreadCollapsibleState.Expanded;
|
||||
}
|
||||
|
||||
this.displayCommentThread(info.owner, thread, pendingComment);
|
||||
});
|
||||
});
|
||||
|
||||
this._commentingRangeDecorator.update(this.editor, this._commentInfos);
|
||||
}
|
||||
|
||||
public closeWidget(): void {
|
||||
if (this._commentWidgets) {
|
||||
this._commentWidgets.forEach(widget => widget.hide());
|
||||
}
|
||||
|
||||
this.editor.focus();
|
||||
this.editor.revealRangeInCenter(this.editor.getSelection()!);
|
||||
}
|
||||
|
||||
private removeCommentWidgetsAndStoreCache() {
|
||||
if (this._commentWidgets) {
|
||||
this._commentWidgets.forEach(zone => {
|
||||
let pendingComment = zone.getPendingComment();
|
||||
let providerCacheStore = this._pendingCommentCache[zone.owner];
|
||||
|
||||
if (pendingComment) {
|
||||
if (!providerCacheStore) {
|
||||
this._pendingCommentCache[zone.owner] = {};
|
||||
}
|
||||
|
||||
this._pendingCommentCache[zone.owner][zone.commentThread.threadId!] = pendingComment;
|
||||
} else {
|
||||
if (providerCacheStore) {
|
||||
delete providerCacheStore[zone.commentThread.threadId!];
|
||||
}
|
||||
}
|
||||
|
||||
zone.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
this._commentWidgets = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class NextCommentThreadAction extends EditorAction {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'editor.action.nextCommentThreadAction',
|
||||
label: nls.localize('nextCommentThreadAction', "Go to Next Comment Thread"),
|
||||
alias: 'Go to Next Comment Thread',
|
||||
precondition: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
let controller = CommentController.get(editor);
|
||||
if (controller) {
|
||||
controller.nextCommentThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
registerEditorContribution(ID, CommentController);
|
||||
registerEditorAction(NextCommentThreadAction);
|
||||
|
||||
CommandsRegistry.registerCommand({
|
||||
id: 'workbench.action.addComment',
|
||||
handler: (accessor) => {
|
||||
const activeEditor = getActiveEditor(accessor);
|
||||
if (!activeEditor) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const controller = CommentController.get(activeEditor);
|
||||
if (!controller) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const position = activeEditor.getPosition();
|
||||
return controller.addOrToggleCommentAtLine(position.lineNumber, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'workbench.action.submitComment',
|
||||
weight: KeybindingWeight.EditorContrib,
|
||||
primary: KeyMod.CtrlCmd | KeyCode.Enter,
|
||||
when: ctxCommentEditorFocused,
|
||||
handler: (accessor, args) => {
|
||||
const activeCodeEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor();
|
||||
if (activeCodeEditor instanceof SimpleCommentEditor) {
|
||||
activeCodeEditor.getParentThread().submitComment();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
KeybindingsRegistry.registerCommandAndKeybindingRule({
|
||||
id: 'workbench.action.hideComment',
|
||||
weight: KeybindingWeight.EditorContrib,
|
||||
primary: KeyCode.Escape,
|
||||
secondary: [KeyMod.Shift | KeyCode.Escape],
|
||||
when: ctxCommentEditorFocused,
|
||||
handler: (accessor, args) => {
|
||||
const activeCodeEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor();
|
||||
if (activeCodeEditor instanceof SimpleCommentEditor) {
|
||||
activeCodeEditor.getParentThread().collapse();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function getActiveEditor(accessor: ServicesAccessor): IActiveCodeEditor | null {
|
||||
let activeTextEditorControl = accessor.get(IEditorService).activeTextEditorControl;
|
||||
|
||||
if (isDiffEditor(activeTextEditorControl)) {
|
||||
if (activeTextEditorControl.getOriginalEditor().hasTextFocus()) {
|
||||
activeTextEditorControl = activeTextEditorControl.getOriginalEditor();
|
||||
} else {
|
||||
activeTextEditorControl = activeTextEditorControl.getModifiedEditor();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCodeEditor(activeTextEditorControl) || !activeTextEditorControl.hasModel()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeTextEditorControl;
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const peekViewBackground = theme.getColor(peekViewResultsBackground);
|
||||
if (peekViewBackground) {
|
||||
collector.addRule(
|
||||
`.monaco-editor .review-widget,` +
|
||||
`.monaco-editor .review-widget {` +
|
||||
` background-color: ${peekViewBackground};` +
|
||||
`}`);
|
||||
}
|
||||
|
||||
const monacoEditorBackground = theme.getColor(peekViewTitleBackground);
|
||||
if (monacoEditorBackground) {
|
||||
collector.addRule(
|
||||
`.monaco-editor .review-widget .body .comment-form .review-thread-reply-button {` +
|
||||
` background-color: ${monacoEditorBackground}` +
|
||||
`}`
|
||||
);
|
||||
}
|
||||
|
||||
const monacoEditorForeground = theme.getColor(editorForeground);
|
||||
if (monacoEditorForeground) {
|
||||
collector.addRule(
|
||||
`.monaco-editor .review-widget .body .monaco-editor {` +
|
||||
` color: ${monacoEditorForeground}` +
|
||||
`}` +
|
||||
`.monaco-editor .review-widget .body .comment-form .review-thread-reply-button {` +
|
||||
` color: ${monacoEditorForeground};` +
|
||||
` font-size: inherit` +
|
||||
`}`
|
||||
);
|
||||
}
|
||||
|
||||
const selectionBackground = theme.getColor(peekViewResultsSelectionBackground);
|
||||
|
||||
if (selectionBackground) {
|
||||
collector.addRule(
|
||||
`@keyframes monaco-review-widget-focus {` +
|
||||
` 0% { background: ${selectionBackground}; }` +
|
||||
` 100% { background: transparent; }` +
|
||||
`}` +
|
||||
`.monaco-editor .review-widget .body .review-comment.focus {` +
|
||||
` animation: monaco-review-widget-focus 3s ease 0s;` +
|
||||
`}`
|
||||
);
|
||||
}
|
||||
|
||||
const commentingRangeForeground = theme.getColor(overviewRulerCommentingRangeForeground);
|
||||
if (commentingRangeForeground) {
|
||||
collector.addRule(`
|
||||
.monaco-editor .comment-diff-added {
|
||||
border-left: 3px solid ${commentingRangeForeground};
|
||||
}
|
||||
.monaco-editor .comment-diff-added:before {
|
||||
background: ${commentingRangeForeground};
|
||||
}
|
||||
.monaco-editor .comment-thread {
|
||||
border-left: 3px solid ${commentingRangeForeground};
|
||||
}
|
||||
.monaco-editor .comment-thread:before {
|
||||
background: ${commentingRangeForeground};
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
const statusBarItemHoverBackground = theme.getColor(STATUS_BAR_ITEM_HOVER_BACKGROUND);
|
||||
if (statusBarItemHoverBackground) {
|
||||
collector.addRule(`.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.active:hover { background-color: ${statusBarItemHoverBackground};}`);
|
||||
}
|
||||
|
||||
const statusBarItemActiveBackground = theme.getColor(STATUS_BAR_ITEM_ACTIVE_BACKGROUND);
|
||||
if (statusBarItemActiveBackground) {
|
||||
collector.addRule(`.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label:active { background-color: ${statusBarItemActiveBackground}; border: 1px solid transparent;}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import * as nls from 'vs/nls';
|
||||
import { renderMarkdown } from 'vs/base/browser/markdownRenderer';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels';
|
||||
import { CommentNode, CommentsModel, ResourceWithCommentThreads } from 'vs/workbench/contrib/comments/common/commentModel';
|
||||
import { IAsyncDataSource, ITreeNode } from 'vs/base/browser/ui/tree/tree';
|
||||
import { IListVirtualDelegate, IListRenderer } from 'vs/base/browser/ui/list/list';
|
||||
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { WorkbenchAsyncDataTree, IListService, IWorkbenchAsyncDataTreeOptions } from 'vs/platform/list/browser/listService';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IColorMapping } from 'vs/platform/theme/common/styler';
|
||||
|
||||
export const COMMENTS_VIEW_ID = 'workbench.panel.comments';
|
||||
export const COMMENTS_VIEW_TITLE = 'Comments';
|
||||
|
||||
export class CommentsAsyncDataSource implements IAsyncDataSource<any, any> {
|
||||
hasChildren(element: any): boolean {
|
||||
return element instanceof CommentsModel || element instanceof ResourceWithCommentThreads || (element instanceof CommentNode && !!element.replies.length);
|
||||
}
|
||||
|
||||
getChildren(element: any): any[] | Promise<any[]> {
|
||||
if (element instanceof CommentsModel) {
|
||||
return Promise.resolve(element.resourceCommentThreads);
|
||||
}
|
||||
if (element instanceof ResourceWithCommentThreads) {
|
||||
return Promise.resolve(element.commentThreads);
|
||||
}
|
||||
if (element instanceof CommentNode) {
|
||||
return Promise.resolve(element.replies);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
interface IResourceTemplateData {
|
||||
resourceLabel: IResourceLabel;
|
||||
}
|
||||
|
||||
interface ICommentThreadTemplateData {
|
||||
icon: HTMLImageElement;
|
||||
userName: HTMLSpanElement;
|
||||
commentText: HTMLElement;
|
||||
disposables: IDisposable[];
|
||||
}
|
||||
|
||||
export class CommentsModelVirualDelegate implements IListVirtualDelegate<any> {
|
||||
private static readonly RESOURCE_ID = 'resource-with-comments';
|
||||
private static readonly COMMENT_ID = 'comment-node';
|
||||
|
||||
|
||||
getHeight(element: any): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
public getTemplateId(element: any): string {
|
||||
if (element instanceof ResourceWithCommentThreads) {
|
||||
return CommentsModelVirualDelegate.RESOURCE_ID;
|
||||
}
|
||||
if (element instanceof CommentNode) {
|
||||
return CommentsModelVirualDelegate.COMMENT_ID;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceWithCommentsRenderer implements IListRenderer<ITreeNode<ResourceWithCommentThreads>, IResourceTemplateData> {
|
||||
templateId: string = 'resource-with-comments';
|
||||
|
||||
constructor(
|
||||
private labels: ResourceLabels
|
||||
) {
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement) {
|
||||
const data = <IResourceTemplateData>Object.create(null);
|
||||
const labelContainer = dom.append(container, dom.$('.resource-container'));
|
||||
data.resourceLabel = this.labels.create(labelContainer);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<ResourceWithCommentThreads>, index: number, templateData: IResourceTemplateData, height: number | undefined): void {
|
||||
templateData.resourceLabel.setFile(node.element.resource);
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IResourceTemplateData): void {
|
||||
templateData.resourceLabel.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentNodeRenderer implements IListRenderer<ITreeNode<CommentNode>, ICommentThreadTemplateData> {
|
||||
templateId: string = 'comment-node';
|
||||
|
||||
constructor(
|
||||
@IOpenerService private readonly openerService: IOpenerService
|
||||
) { }
|
||||
|
||||
renderTemplate(container: HTMLElement) {
|
||||
const data = <ICommentThreadTemplateData>Object.create(null);
|
||||
const labelContainer = dom.append(container, dom.$('.comment-container'));
|
||||
data.userName = dom.append(labelContainer, dom.$('.user'));
|
||||
data.commentText = dom.append(labelContainer, dom.$('.text'));
|
||||
data.disposables = [];
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<CommentNode>, index: number, templateData: ICommentThreadTemplateData, height: number | undefined): void {
|
||||
templateData.userName.textContent = node.element.comment.userName;
|
||||
templateData.commentText.innerText = '';
|
||||
const disposables = new DisposableStore();
|
||||
templateData.disposables.push(disposables);
|
||||
const renderedComment = renderMarkdown(node.element.comment.body, {
|
||||
inline: true,
|
||||
actionHandler: {
|
||||
callback: (content) => {
|
||||
this.openerService.open(content).catch(onUnexpectedError);
|
||||
},
|
||||
disposeables: disposables
|
||||
}
|
||||
});
|
||||
|
||||
const images = renderedComment.getElementsByTagName('img');
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const image = images[i];
|
||||
const textDescription = dom.$('');
|
||||
textDescription.textContent = image.alt ? nls.localize('imageWithLabel', "Image: {0}", image.alt) : nls.localize('image', "Image");
|
||||
image.parentNode!.replaceChild(textDescription, image);
|
||||
}
|
||||
|
||||
templateData.commentText.appendChild(renderedComment);
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: ICommentThreadTemplateData): void {
|
||||
templateData.disposables.forEach(disposeable => disposeable.dispose());
|
||||
}
|
||||
}
|
||||
|
||||
export interface ICommentsListOptions extends IWorkbenchAsyncDataTreeOptions<any, any> {
|
||||
overrideStyles?: IColorMapping;
|
||||
}
|
||||
|
||||
export class CommentsList extends WorkbenchAsyncDataTree<any, any> {
|
||||
constructor(
|
||||
labels: ResourceLabels,
|
||||
container: HTMLElement,
|
||||
options: ICommentsListOptions,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IListService listService: IListService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IAccessibilityService accessibilityService: IAccessibilityService
|
||||
) {
|
||||
const delegate = new CommentsModelVirualDelegate();
|
||||
const dataSource = new CommentsAsyncDataSource();
|
||||
|
||||
const renderers = [
|
||||
instantiationService.createInstance(ResourceWithCommentsRenderer, labels),
|
||||
instantiationService.createInstance(CommentNodeRenderer)
|
||||
];
|
||||
|
||||
super(
|
||||
'CommentsTree',
|
||||
container,
|
||||
delegate,
|
||||
renderers,
|
||||
dataSource,
|
||||
{
|
||||
accessibilityProvider: options.accessibilityProvider,
|
||||
identityProvider: {
|
||||
getId: (element: any) => {
|
||||
if (element instanceof CommentsModel) {
|
||||
return 'root';
|
||||
}
|
||||
if (element instanceof ResourceWithCommentThreads) {
|
||||
return `${element.owner}-${element.id}`;
|
||||
}
|
||||
if (element instanceof CommentNode) {
|
||||
return `${element.owner}-${element.resource.toString()}-${element.threadId}-${element.comment.uniqueIdInThread}` + (element.isRoot ? '-root' : '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
expandOnlyOnTwistieClick: (element: any) => {
|
||||
if (element instanceof CommentsModel || element instanceof ResourceWithCommentThreads) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
collapseByDefault: () => {
|
||||
return false;
|
||||
},
|
||||
overrideStyles: options.overrideStyles
|
||||
},
|
||||
contextKeyService,
|
||||
listService,
|
||||
themeService,
|
||||
configurationService,
|
||||
keybindingService,
|
||||
accessibilityService
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./media/panel';
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { basename, isEqual } from 'vs/base/common/resources';
|
||||
import { IAction, Action } from 'vs/base/common/actions';
|
||||
import { CollapseAllAction } from 'vs/base/browser/ui/tree/treeDefaults';
|
||||
import { isCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { CommentNode, CommentsModel, ResourceWithCommentThreads, ICommentThreadChangedEvent } from 'vs/workbench/contrib/comments/common/commentModel';
|
||||
import { CommentController } from 'vs/workbench/contrib/comments/browser/commentsEditorContribution';
|
||||
import { IWorkspaceCommentThreadsEvent, ICommentService } from 'vs/workbench/contrib/comments/browser/commentService';
|
||||
import { IEditorService, ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
|
||||
import { textLinkForeground, textLinkActiveForeground, focusBorder, textPreformatForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { ResourceLabels } from 'vs/workbench/browser/labels';
|
||||
import { CommentsList, COMMENTS_VIEW_ID, COMMENTS_VIEW_TITLE } from 'vs/workbench/contrib/comments/browser/commentsTreeViewer';
|
||||
import { ViewPane, IViewPaneOptions } from 'vs/workbench/browser/parts/views/viewPaneContainer';
|
||||
import { IViewDescriptorService, IViewsService } from 'vs/workbench/common/views';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
||||
|
||||
export class CommentsPanel extends ViewPane {
|
||||
private treeLabels!: ResourceLabels;
|
||||
private tree!: CommentsList;
|
||||
private treeContainer!: HTMLElement;
|
||||
private messageBoxContainer!: HTMLElement;
|
||||
private messageBox!: HTMLElement;
|
||||
private commentsModel!: CommentsModel;
|
||||
private collapseAllAction?: IAction;
|
||||
|
||||
readonly onDidChangeVisibility = this.onDidChangeBodyVisibility;
|
||||
|
||||
constructor(
|
||||
options: IViewPaneOptions,
|
||||
@IInstantiationService readonly instantiationService: IInstantiationService,
|
||||
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@ICommentService private readonly commentService: ICommentService,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
) {
|
||||
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService);
|
||||
}
|
||||
|
||||
public renderBody(container: HTMLElement): void {
|
||||
super.renderBody(container);
|
||||
|
||||
container.classList.add('comments-panel');
|
||||
|
||||
let domContainer = dom.append(container, dom.$('.comments-panel-container'));
|
||||
this.treeContainer = dom.append(domContainer, dom.$('.tree-container'));
|
||||
this.commentsModel = new CommentsModel();
|
||||
|
||||
this.createTree();
|
||||
this.createMessageBox(domContainer);
|
||||
|
||||
this._register(this.commentService.onDidSetAllCommentThreads(this.onAllCommentsChanged, this));
|
||||
this._register(this.commentService.onDidUpdateCommentThreads(this.onCommentsUpdated, this));
|
||||
|
||||
const styleElement = dom.createStyleSheet(container);
|
||||
this.applyStyles(styleElement);
|
||||
this._register(this.themeService.onDidColorThemeChange(_ => this.applyStyles(styleElement)));
|
||||
|
||||
this._register(this.onDidChangeBodyVisibility(visible => {
|
||||
if (visible) {
|
||||
this.refresh();
|
||||
}
|
||||
}));
|
||||
|
||||
this.renderComments();
|
||||
}
|
||||
|
||||
private applyStyles(styleElement: HTMLStyleElement) {
|
||||
const content: string[] = [];
|
||||
|
||||
const theme = this.themeService.getColorTheme();
|
||||
const linkColor = theme.getColor(textLinkForeground);
|
||||
if (linkColor) {
|
||||
content.push(`.comments-panel .comments-panel-container a { color: ${linkColor}; }`);
|
||||
}
|
||||
|
||||
const linkActiveColor = theme.getColor(textLinkActiveForeground);
|
||||
if (linkActiveColor) {
|
||||
content.push(`.comments-panel .comments-panel-container a:hover, a:active { color: ${linkActiveColor}; }`);
|
||||
}
|
||||
|
||||
const focusColor = theme.getColor(focusBorder);
|
||||
if (focusColor) {
|
||||
content.push(`.comments-panel .commenst-panel-container a:focus { outline-color: ${focusColor}; }`);
|
||||
}
|
||||
|
||||
const codeTextForegroundColor = theme.getColor(textPreformatForeground);
|
||||
if (codeTextForegroundColor) {
|
||||
content.push(`.comments-panel .comments-panel-container .text code { color: ${codeTextForegroundColor}; }`);
|
||||
}
|
||||
|
||||
styleElement.textContent = content.join('\n');
|
||||
}
|
||||
|
||||
private async renderComments(): Promise<void> {
|
||||
this.treeContainer.classList.toggle('hidden', !this.commentsModel.hasCommentThreads());
|
||||
await this.tree.setInput(this.commentsModel);
|
||||
this.renderMessage();
|
||||
}
|
||||
|
||||
public getActions(): IAction[] {
|
||||
if (!this.collapseAllAction) {
|
||||
this.collapseAllAction = new Action('vs.tree.collapse', nls.localize('collapseAll', "Collapse All"), 'collapse-all', true, () => this.tree ? new CollapseAllAction<any, any>(this.tree, true).run() : Promise.resolve());
|
||||
this._register(this.collapseAllAction);
|
||||
}
|
||||
|
||||
return [this.collapseAllAction];
|
||||
}
|
||||
|
||||
public layoutBody(height: number, width: number): void {
|
||||
super.layoutBody(height, width);
|
||||
this.tree.layout(height, width);
|
||||
}
|
||||
|
||||
public getTitle(): string {
|
||||
return COMMENTS_VIEW_TITLE;
|
||||
}
|
||||
|
||||
private createMessageBox(parent: HTMLElement): void {
|
||||
this.messageBoxContainer = dom.append(parent, dom.$('.message-box-container'));
|
||||
this.messageBox = dom.append(this.messageBoxContainer, dom.$('span'));
|
||||
this.messageBox.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
private renderMessage(): void {
|
||||
this.messageBox.textContent = this.commentsModel.getMessage();
|
||||
this.messageBoxContainer.classList.toggle('hidden', this.commentsModel.hasCommentThreads());
|
||||
}
|
||||
|
||||
private createTree(): void {
|
||||
this.treeLabels = this._register(this.instantiationService.createInstance(ResourceLabels, this));
|
||||
this.tree = this._register(this.instantiationService.createInstance(CommentsList, this.treeLabels, this.treeContainer, {
|
||||
overrideStyles: { listBackground: this.getBackgroundColor() },
|
||||
openOnFocus: true,
|
||||
accessibilityProvider: {
|
||||
getAriaLabel(element: any): string {
|
||||
if (element instanceof CommentsModel) {
|
||||
return nls.localize('rootCommentsLabel', "Comments for current workspace");
|
||||
}
|
||||
if (element instanceof ResourceWithCommentThreads) {
|
||||
return nls.localize('resourceWithCommentThreadsLabel', "Comments in {0}, full path {1}", basename(element.resource), element.resource.fsPath);
|
||||
}
|
||||
if (element instanceof CommentNode) {
|
||||
return nls.localize('resourceWithCommentLabel',
|
||||
"Comment from ${0} at line {1} column {2} in {3}, source: {4}",
|
||||
element.comment.userName,
|
||||
element.range.startLineNumber,
|
||||
element.range.startColumn,
|
||||
basename(element.resource),
|
||||
element.comment.body.value
|
||||
);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
getWidgetAriaLabel(): string {
|
||||
return COMMENTS_VIEW_TITLE;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(this.tree.onDidOpen(e => {
|
||||
this.openFile(e.element, e.editorOptions.pinned, e.editorOptions.preserveFocus, e.sideBySide);
|
||||
}));
|
||||
}
|
||||
|
||||
private openFile(element: any, pinned?: boolean, preserveFocus?: boolean, sideBySide?: boolean): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(element instanceof ResourceWithCommentThreads || element instanceof CommentNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].range : element.range;
|
||||
|
||||
const activeEditor = this.editorService.activeEditor;
|
||||
let currentActiveResource = activeEditor ? activeEditor.resource : undefined;
|
||||
if (currentActiveResource && isEqual(currentActiveResource, element.resource)) {
|
||||
const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].threadId : element.threadId;
|
||||
const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment.uniqueIdInThread : element.comment.uniqueIdInThread;
|
||||
const control = this.editorService.activeTextEditorControl;
|
||||
if (threadToReveal && isCodeEditor(control)) {
|
||||
const controller = CommentController.get(control);
|
||||
controller.revealCommentThread(threadToReveal, commentToReveal, false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const threadToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].threadId : element.threadId;
|
||||
const commentToReveal = element instanceof ResourceWithCommentThreads ? element.commentThreads[0].comment : element.comment;
|
||||
|
||||
this.editorService.openEditor({
|
||||
resource: element.resource,
|
||||
options: {
|
||||
pinned: pinned,
|
||||
preserveFocus: preserveFocus,
|
||||
selection: range
|
||||
}
|
||||
}, sideBySide ? SIDE_GROUP : ACTIVE_GROUP).then(editor => {
|
||||
if (editor) {
|
||||
const control = editor.getControl();
|
||||
if (threadToReveal && isCodeEditor(control)) {
|
||||
const controller = CommentController.get(control);
|
||||
controller.revealCommentThread(threadToReveal, commentToReveal.uniqueIdInThread, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
if (this.isVisible()) {
|
||||
if (this.collapseAllAction) {
|
||||
this.collapseAllAction.enabled = this.commentsModel.hasCommentThreads();
|
||||
}
|
||||
|
||||
this.treeContainer.classList.toggle('hidden', !this.commentsModel.hasCommentThreads());
|
||||
this.tree.updateChildren().then(() => {
|
||||
this.renderMessage();
|
||||
}, (e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onAllCommentsChanged(e: IWorkspaceCommentThreadsEvent): void {
|
||||
this.commentsModel.setCommentThreads(e.ownerId, e.commentThreads);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private onCommentsUpdated(e: ICommentThreadChangedEvent): void {
|
||||
const didUpdate = this.commentsModel.updateCommentThreads(e);
|
||||
if (didUpdate) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandsRegistry.registerCommand({
|
||||
id: 'workbench.action.focusCommentsPanel',
|
||||
handler: async (accessor) => {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
viewsService.openView(COMMENTS_VIEW_ID, true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.comments-panel .comments-panel-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .resource-container,
|
||||
.comments-panel .comments-panel-container .tree-container .comment-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comments-panel .user {
|
||||
padding-right: 5px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .comment-container .text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .comment-container .text * {
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .comment-container .text code {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .message-box-container {
|
||||
line-height: 22px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .message-box-container span:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .count-badge-wrapper {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.comments-panel .comments-panel-container .tree-container .comment-container {
|
||||
line-height: 22px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.monaco-editor .review-widget {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment {
|
||||
padding: 8px 16px 8px 20px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-actions .monaco-toolbar {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-title {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-title .action-label.codicon {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-title .monaco-dropdown .toolbar-toggle-more {
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body blockquote {
|
||||
margin: 0 7px 0 5px;
|
||||
padding: 0 16px 0 10px;
|
||||
border-left-width: 5px;
|
||||
border-left-style: solid;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .avatar-container {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .avatar-container img.avatar {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-reactions .monaco-text-button {
|
||||
margin: 0 7px 0 0;
|
||||
width: 30px;
|
||||
background-color: transparent;
|
||||
border: 1px solid grey;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents {
|
||||
padding-left: 20px;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body pre {
|
||||
overflow: auto;
|
||||
word-wrap: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .author {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .isPending {
|
||||
margin: 0 5px 0 5px;
|
||||
padding: 0 2px 0 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions {
|
||||
margin-top: 8px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .monaco-action-bar .actions-container {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item .action-label {
|
||||
padding: 1px 4px;
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item .action-label .reaction-icon {
|
||||
background-size: 14px;
|
||||
background-position: left center;
|
||||
background-repeat: no-repeat;
|
||||
width: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item .action-label .reaction-label {
|
||||
line-height: 20px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.toolbar-toggle-pickReactions {
|
||||
display: none;
|
||||
background-size: 16px;
|
||||
width: 26px;
|
||||
height: 16px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
margin-top: 3px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions:hover .action-item a.action-label.toolbar-toggle-pickReactions {
|
||||
display: inline-block;
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .comment-title .action-label {
|
||||
display: block;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
background-size: 16px;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .review-comment .review-comment-contents .comment-reactions .action-item a.action-label.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body p,
|
||||
.monaco-editor .review-widget .body .comment-body ul {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body p:first-child,
|
||||
.monaco-editor .review-widget .body .comment-body ul:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body p:last-child,
|
||||
.monaco-editor .review-widget .body.comment-body ul:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body li > p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body li > ul {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body code {
|
||||
border-radius: 3px;
|
||||
padding: 0 0.4em;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body span {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-body img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form {
|
||||
margin: 8px 20px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .validation-error {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4em;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
min-height: 34px;
|
||||
margin-top: -1px;
|
||||
margin-left: -1px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form.expand .review-thread-reply-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form.expand .monaco-editor,
|
||||
.monaco-editor .review-widget .body .comment-form.expand .form-actions {
|
||||
display: block;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .review-thread-reply-button {
|
||||
text-align: left;
|
||||
display: block;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 12px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
border: 0px;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .review-thread-reply-button:focus {
|
||||
outline-style: solid;
|
||||
outline-width: 1px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .monaco-editor,
|
||||
.monaco-editor .review-widget .body .edit-container .monaco-editor {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
max-height: 500px;
|
||||
border-radius: 3px;
|
||||
border: 0px;
|
||||
box-sizing: content-box;
|
||||
padding: 6px 0 6px 12px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .monaco-editor,
|
||||
.monaco-editor .review-widget .body .comment-form .form-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .form-actions,
|
||||
.monaco-editor .review-widget .body .edit-container .form-actions {
|
||||
overflow: auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .edit-container .form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .edit-textarea {
|
||||
height: 90px;
|
||||
margin: 5px 0 10px 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .monaco-text-button,
|
||||
.monaco-editor .review-widget .body .edit-container .monaco-text-button {
|
||||
width: auto;
|
||||
padding: 4px 10px;
|
||||
margin-left: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .body .comment-form .monaco-text-button {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-title {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
margin-left: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-title .dirname:not(:empty) {
|
||||
font-size: 0.9em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-actions {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-actions > .monaco-action-bar {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-actions > .monaco-action-bar,
|
||||
.monaco-editor .review-widget .head .review-actions > .monaco-action-bar > .actions-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .action-item {
|
||||
min-width: 18px;
|
||||
min-height: 20px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-actions > .monaco-action-bar .action-label {
|
||||
width: 16px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget .head .review-actions > .monaco-action-bar .action-label.codicon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.monaco-editor .review-widget > .body {
|
||||
border-top: 1px solid;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.monaco-editor .comment-range-glyph {
|
||||
margin-left: 5px;
|
||||
width: 4px !important;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
div.preview.inline .monaco-editor .comment-range-glyph {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.monaco-editor .comment-range-glyph:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 100%;
|
||||
width: 0;
|
||||
left: -2px;
|
||||
transition: width 80ms linear, left 80ms linear;
|
||||
}
|
||||
|
||||
.monaco-editor .margin-view-overlays > div:hover > .comment-range-glyph.comment-diff-added:before,
|
||||
.monaco-editor .comment-range-glyph.comment-thread:before {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 9px;
|
||||
left: -6px;
|
||||
z-index: 10;
|
||||
color: black;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-editor .margin-view-overlays > div:hover > .comment-range-glyph.comment-diff-added:before {
|
||||
content: "+";
|
||||
}
|
||||
|
||||
.monaco-editor .comment-range-glyph.comment-thread {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.monaco-editor .comment-range-glyph.comment-thread:before {
|
||||
content: "◆";
|
||||
font-size: 10px;
|
||||
line-height: 100%;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.monaco-editor.inline-comment .margin-view-overlays .folding {
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.monaco-editor.inline-comment .margin-view-overlays .dirty-diff-glyph {
|
||||
margin-left: 14px;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { Action, IAction } from 'vs/base/common/actions';
|
||||
import { URI, UriComponents } from 'vs/base/common/uri';
|
||||
import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
|
||||
|
||||
export class ToggleReactionsAction extends Action {
|
||||
static readonly ID = 'toolbar.toggle.pickReactions';
|
||||
private _menuActions: IAction[] = [];
|
||||
private toggleDropdownMenu: () => void;
|
||||
constructor(toggleDropdownMenu: () => void, title?: string) {
|
||||
super(ToggleReactionsAction.ID, title || nls.localize('pickReactions', "Pick Reactions..."), 'toggle-reactions', true);
|
||||
this.toggleDropdownMenu = toggleDropdownMenu;
|
||||
}
|
||||
run(): Promise<any> {
|
||||
this.toggleDropdownMenu();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
get menuActions() {
|
||||
return this._menuActions;
|
||||
}
|
||||
set menuActions(actions: IAction[]) {
|
||||
this._menuActions = actions;
|
||||
}
|
||||
}
|
||||
export class ReactionActionViewItem extends ActionViewItem {
|
||||
constructor(action: ReactionAction) {
|
||||
super(null, action, {});
|
||||
}
|
||||
updateLabel(): void {
|
||||
if (!this.label) {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = this.getAction() as ReactionAction;
|
||||
if (action.class) {
|
||||
this.label.classList.add(action.class);
|
||||
}
|
||||
|
||||
if (!action.icon) {
|
||||
let reactionLabel = dom.append(this.label, dom.$('span.reaction-label'));
|
||||
reactionLabel.innerText = action.label;
|
||||
} else {
|
||||
let reactionIcon = dom.append(this.label, dom.$('.reaction-icon'));
|
||||
reactionIcon.style.display = '';
|
||||
let uri = URI.revive(action.icon);
|
||||
reactionIcon.style.backgroundImage = `url('${uri}')`;
|
||||
reactionIcon.title = action.label;
|
||||
}
|
||||
if (action.count) {
|
||||
let reactionCount = dom.append(this.label, dom.$('span.reaction-count'));
|
||||
reactionCount.innerText = `${action.count}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
export class ReactionAction extends Action {
|
||||
static readonly ID = 'toolbar.toggle.reaction';
|
||||
constructor(id: string, label: string = '', cssClass: string = '', enabled: boolean = true, actionCallback?: (event?: any) => Promise<any>, public icon?: UriComponents, public count?: number) {
|
||||
super(ReactionAction.ID, label, cssClass, enabled, actionCallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
|
||||
import { EditorAction, EditorExtensionsRegistry, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget';
|
||||
import { IContextKeyService, RawContextKey, IContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ICommandService } from 'vs/platform/commands/common/commands';
|
||||
|
||||
// Allowed Editor Contributions:
|
||||
import { MenuPreventer } from 'vs/workbench/contrib/codeEditor/browser/menuPreventer';
|
||||
import { ContextMenuController } from 'vs/editor/contrib/contextmenu/contextmenu';
|
||||
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
|
||||
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
|
||||
import { TabCompletionController } from 'vs/workbench/contrib/snippets/browser/tabCompletion';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { INotificationService } from 'vs/platform/notification/common/notification';
|
||||
import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ICommentThreadWidget } from 'vs/workbench/contrib/comments/common/commentThreadWidget';
|
||||
import { CommentContextKeys } from 'vs/workbench/contrib/comments/common/commentContextKeys';
|
||||
|
||||
export const ctxCommentEditorFocused = new RawContextKey<boolean>('commentEditorFocused', false);
|
||||
|
||||
|
||||
export class SimpleCommentEditor extends CodeEditorWidget {
|
||||
private _parentEditor: ICodeEditor;
|
||||
private _parentThread: ICommentThreadWidget;
|
||||
private _commentEditorFocused: IContextKey<boolean>;
|
||||
private _commentEditorEmpty: IContextKey<boolean>;
|
||||
|
||||
constructor(
|
||||
domElement: HTMLElement,
|
||||
options: IEditorOptions,
|
||||
parentEditor: ICodeEditor,
|
||||
parentThread: ICommentThreadWidget,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@ICodeEditorService codeEditorService: ICodeEditorService,
|
||||
@ICommandService commandService: ICommandService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@INotificationService notificationService: INotificationService,
|
||||
@IAccessibilityService accessibilityService: IAccessibilityService
|
||||
) {
|
||||
const codeEditorWidgetOptions: ICodeEditorWidgetOptions = {
|
||||
isSimpleWidget: true,
|
||||
contributions: <IEditorContributionDescription[]>[
|
||||
{ id: MenuPreventer.ID, ctor: MenuPreventer },
|
||||
{ id: ContextMenuController.ID, ctor: ContextMenuController },
|
||||
{ id: SuggestController.ID, ctor: SuggestController },
|
||||
{ id: SnippetController2.ID, ctor: SnippetController2 },
|
||||
{ id: TabCompletionController.ID, ctor: TabCompletionController },
|
||||
]
|
||||
};
|
||||
|
||||
super(domElement, options, codeEditorWidgetOptions, instantiationService, codeEditorService, commandService, contextKeyService, themeService, notificationService, accessibilityService);
|
||||
|
||||
this._commentEditorFocused = ctxCommentEditorFocused.bindTo(contextKeyService);
|
||||
this._commentEditorEmpty = CommentContextKeys.commentIsEmpty.bindTo(contextKeyService);
|
||||
this._commentEditorEmpty.set(!this.getValue());
|
||||
this._parentEditor = parentEditor;
|
||||
this._parentThread = parentThread;
|
||||
|
||||
this._register(this.onDidFocusEditorWidget(_ => this._commentEditorFocused.set(true)));
|
||||
|
||||
this._register(this.onDidChangeModelContent(e => this._commentEditorEmpty.set(!this.getValue())));
|
||||
this._register(this.onDidBlurEditorWidget(_ => this._commentEditorFocused.reset()));
|
||||
}
|
||||
|
||||
getParentEditor(): ICodeEditor {
|
||||
return this._parentEditor;
|
||||
}
|
||||
|
||||
getParentThread(): ICommentThreadWidget {
|
||||
return this._parentThread;
|
||||
}
|
||||
|
||||
protected _getActions(): EditorAction[] {
|
||||
return EditorExtensionsRegistry.getEditorActions();
|
||||
}
|
||||
|
||||
public static getEditorOptions(): IEditorOptions {
|
||||
return {
|
||||
wordWrap: 'on',
|
||||
glyphMargin: false,
|
||||
lineNumbers: 'off',
|
||||
folding: false,
|
||||
selectOnLineNumbers: false,
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
verticalScrollbarSize: 14,
|
||||
horizontal: 'auto',
|
||||
useShadows: true,
|
||||
verticalHasArrows: false,
|
||||
horizontalHasArrows: false
|
||||
},
|
||||
overviewRulerLanes: 2,
|
||||
lineDecorationsWidth: 0,
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'none',
|
||||
fixedOverflowWidgets: true,
|
||||
acceptSuggestionOnEnter: 'smart',
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
quickSuggestions: false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { RawContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
|
||||
export namespace CommentContextKeys {
|
||||
/**
|
||||
* A context key that is set when the comment thread has no comments.
|
||||
*/
|
||||
export const commentThreadIsEmpty = new RawContextKey<boolean>('commentThreadIsEmpty', false);
|
||||
/**
|
||||
* A context key that is set when the comment has no input.
|
||||
*/
|
||||
export const commentIsEmpty = new RawContextKey<boolean>('commentIsEmpty', false);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IRange } from 'vs/editor/common/core/range';
|
||||
import { Comment, CommentThread, CommentThreadChangedEvent } from 'vs/editor/common/modes';
|
||||
import { groupBy, flatten } from 'vs/base/common/arrays';
|
||||
import { localize } from 'vs/nls';
|
||||
|
||||
export interface ICommentThreadChangedEvent extends CommentThreadChangedEvent {
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export class CommentNode {
|
||||
owner: string;
|
||||
threadId: string;
|
||||
range: IRange;
|
||||
comment: Comment;
|
||||
replies: CommentNode[] = [];
|
||||
resource: URI;
|
||||
isRoot: boolean;
|
||||
|
||||
constructor(owner: string, threadId: string, resource: URI, comment: Comment, range: IRange) {
|
||||
this.owner = owner;
|
||||
this.threadId = threadId;
|
||||
this.comment = comment;
|
||||
this.resource = resource;
|
||||
this.range = range;
|
||||
this.isRoot = false;
|
||||
}
|
||||
|
||||
hasReply(): boolean {
|
||||
return this.replies && this.replies.length !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceWithCommentThreads {
|
||||
id: string;
|
||||
owner: string;
|
||||
commentThreads: CommentNode[]; // The top level comments on the file. Replys are nested under each node.
|
||||
resource: URI;
|
||||
|
||||
constructor(owner: string, resource: URI, commentThreads: CommentThread[]) {
|
||||
this.owner = owner;
|
||||
this.id = resource.toString();
|
||||
this.resource = resource;
|
||||
this.commentThreads = commentThreads.filter(thread => thread.comments && thread.comments.length).map(thread => ResourceWithCommentThreads.createCommentNode(owner, resource, thread));
|
||||
}
|
||||
|
||||
public static createCommentNode(owner: string, resource: URI, commentThread: CommentThread): CommentNode {
|
||||
const { threadId, comments, range } = commentThread;
|
||||
const commentNodes: CommentNode[] = comments!.map(comment => new CommentNode(owner, threadId!, resource, comment, range));
|
||||
if (commentNodes.length > 1) {
|
||||
commentNodes[0].replies = commentNodes.slice(1, commentNodes.length);
|
||||
}
|
||||
|
||||
commentNodes[0].isRoot = true;
|
||||
|
||||
return commentNodes[0];
|
||||
}
|
||||
}
|
||||
|
||||
export class CommentsModel {
|
||||
resourceCommentThreads: ResourceWithCommentThreads[];
|
||||
commentThreadsMap: Map<string, ResourceWithCommentThreads[]>;
|
||||
|
||||
constructor() {
|
||||
this.resourceCommentThreads = [];
|
||||
this.commentThreadsMap = new Map<string, ResourceWithCommentThreads[]>();
|
||||
}
|
||||
|
||||
public setCommentThreads(owner: string, commentThreads: CommentThread[]): void {
|
||||
this.commentThreadsMap.set(owner, this.groupByResource(owner, commentThreads));
|
||||
this.resourceCommentThreads = flatten([...this.commentThreadsMap.values()]);
|
||||
}
|
||||
|
||||
public updateCommentThreads(event: ICommentThreadChangedEvent): boolean {
|
||||
const { owner, removed, changed, added } = event;
|
||||
|
||||
let threadsForOwner = this.commentThreadsMap.get(owner) || [];
|
||||
|
||||
removed.forEach(thread => {
|
||||
// Find resource that has the comment thread
|
||||
const matchingResourceIndex = threadsForOwner.findIndex((resourceData) => resourceData.id === thread.resource);
|
||||
const matchingResourceData = threadsForOwner[matchingResourceIndex];
|
||||
|
||||
// Find comment node on resource that is that thread and remove it
|
||||
const index = matchingResourceData.commentThreads.findIndex((commentThread) => commentThread.threadId === thread.threadId);
|
||||
matchingResourceData.commentThreads.splice(index, 1);
|
||||
|
||||
// If the comment thread was the last thread for a resource, remove that resource from the list
|
||||
if (matchingResourceData.commentThreads.length === 0) {
|
||||
threadsForOwner.splice(matchingResourceIndex, 1);
|
||||
}
|
||||
});
|
||||
|
||||
changed.forEach(thread => {
|
||||
// Find resource that has the comment thread
|
||||
const matchingResourceIndex = threadsForOwner.findIndex((resourceData) => resourceData.id === thread.resource);
|
||||
const matchingResourceData = threadsForOwner[matchingResourceIndex];
|
||||
|
||||
// Find comment node on resource that is that thread and replace it
|
||||
const index = matchingResourceData.commentThreads.findIndex((commentThread) => commentThread.threadId === thread.threadId);
|
||||
if (index >= 0) {
|
||||
matchingResourceData.commentThreads[index] = ResourceWithCommentThreads.createCommentNode(owner, URI.parse(matchingResourceData.id), thread);
|
||||
} else if (thread.comments && thread.comments.length) {
|
||||
matchingResourceData.commentThreads.push(ResourceWithCommentThreads.createCommentNode(owner, URI.parse(matchingResourceData.id), thread));
|
||||
}
|
||||
});
|
||||
|
||||
added.forEach(thread => {
|
||||
const existingResource = threadsForOwner.filter(resourceWithThreads => resourceWithThreads.resource.toString() === thread.resource);
|
||||
if (existingResource.length) {
|
||||
const resource = existingResource[0];
|
||||
if (thread.comments && thread.comments.length) {
|
||||
resource.commentThreads.push(ResourceWithCommentThreads.createCommentNode(owner, resource.resource, thread));
|
||||
}
|
||||
} else {
|
||||
threadsForOwner.push(new ResourceWithCommentThreads(owner, URI.parse(thread.resource!), [thread]));
|
||||
}
|
||||
});
|
||||
|
||||
this.commentThreadsMap.set(owner, threadsForOwner);
|
||||
this.resourceCommentThreads = flatten([...this.commentThreadsMap.values()]);
|
||||
|
||||
return removed.length > 0 || changed.length > 0 || added.length > 0;
|
||||
}
|
||||
|
||||
public hasCommentThreads(): boolean {
|
||||
return !!this.resourceCommentThreads.length;
|
||||
}
|
||||
|
||||
public getMessage(): string {
|
||||
if (!this.resourceCommentThreads.length) {
|
||||
return localize('noComments', "There are no comments on this review.");
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private groupByResource(owner: string, commentThreads: CommentThread[]): ResourceWithCommentThreads[] {
|
||||
const resourceCommentThreads: ResourceWithCommentThreads[] = [];
|
||||
const commentThreadsByResource = new Map<string, ResourceWithCommentThreads>();
|
||||
for (const group of groupBy(commentThreads, CommentsModel._compareURIs)) {
|
||||
commentThreadsByResource.set(group[0].resource!, new ResourceWithCommentThreads(owner, URI.parse(group[0].resource!), group));
|
||||
}
|
||||
|
||||
commentThreadsByResource.forEach((v, i, m) => {
|
||||
resourceCommentThreads.push(v);
|
||||
});
|
||||
|
||||
return resourceCommentThreads;
|
||||
}
|
||||
|
||||
private static _compareURIs(a: CommentThread, b: CommentThread) {
|
||||
const resourceA = a.resource!.toString();
|
||||
const resourceB = b.resource!.toString();
|
||||
if (resourceA < resourceB) {
|
||||
return -1;
|
||||
} else if (resourceA > resourceB) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export interface ICommentThreadWidget {
|
||||
submitComment: () => Promise<void>;
|
||||
collapse: () => void;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { INativeWorkbenchEnvironmentService } from 'vs/workbench/services/environment/electron-sandbox/environmentService';
|
||||
import { DefaultConfigurationExportHelper } from 'vs/workbench/contrib/configExporter/electron-sandbox/configurationExportHelper';
|
||||
|
||||
export class ExtensionPoints implements IWorkbenchContribution {
|
||||
|
||||
constructor(
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@INativeWorkbenchEnvironmentService environmentService: INativeWorkbenchEnvironmentService
|
||||
) {
|
||||
// Config Exporter
|
||||
if (environmentService.args['export-default-configuration']) {
|
||||
instantiationService.createInstance(DefaultConfigurationExportHelper);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ExtensionPoints, LifecyclePhase.Restored);
|
||||
@@ -0,0 +1,122 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { INativeWorkbenchEnvironmentService } from 'vs/workbench/services/environment/electron-sandbox/environmentService';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IConfigurationNode, IConfigurationRegistry, Extensions, IConfigurationPropertySchema } from 'vs/platform/configuration/common/configurationRegistry';
|
||||
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
|
||||
import { ICommandService } from 'vs/platform/commands/common/commands';
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
import { VSBuffer } from 'vs/base/common/buffer';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IProductService } from 'vs/platform/product/common/productService';
|
||||
|
||||
interface IExportedConfigurationNode {
|
||||
name: string;
|
||||
description: string;
|
||||
default: any;
|
||||
type?: string | string[];
|
||||
enum?: any[];
|
||||
enumDescriptions?: string[];
|
||||
}
|
||||
|
||||
interface IConfigurationExport {
|
||||
settings: IExportedConfigurationNode[];
|
||||
buildTime: number;
|
||||
commit?: string;
|
||||
buildNumber?: number;
|
||||
}
|
||||
|
||||
export class DefaultConfigurationExportHelper {
|
||||
|
||||
constructor(
|
||||
@INativeWorkbenchEnvironmentService environmentService: INativeWorkbenchEnvironmentService,
|
||||
@IExtensionService private readonly extensionService: IExtensionService,
|
||||
@ICommandService private readonly commandService: ICommandService,
|
||||
@IFileService private readonly fileService: IFileService,
|
||||
@IProductService private readonly productService: IProductService
|
||||
) {
|
||||
const exportDefaultConfigurationPath = environmentService.args['export-default-configuration'];
|
||||
if (exportDefaultConfigurationPath) {
|
||||
this.writeConfigModelAndQuit(URI.file(exportDefaultConfigurationPath));
|
||||
}
|
||||
}
|
||||
|
||||
private async writeConfigModelAndQuit(target: URI): Promise<void> {
|
||||
try {
|
||||
await this.extensionService.whenInstalledExtensionsRegistered();
|
||||
await this.writeConfigModel(target);
|
||||
} finally {
|
||||
this.commandService.executeCommand('workbench.action.quit');
|
||||
}
|
||||
}
|
||||
|
||||
private async writeConfigModel(target: URI): Promise<void> {
|
||||
const config = this.getConfigModel();
|
||||
|
||||
const resultString = JSON.stringify(config, undefined, ' ');
|
||||
await this.fileService.writeFile(target, VSBuffer.fromString(resultString));
|
||||
}
|
||||
|
||||
private getConfigModel(): IConfigurationExport {
|
||||
const configRegistry = Registry.as<IConfigurationRegistry>(Extensions.Configuration);
|
||||
const configurations = configRegistry.getConfigurations().slice();
|
||||
const settings: IExportedConfigurationNode[] = [];
|
||||
const processedNames = new Set<string>();
|
||||
|
||||
const processProperty = (name: string, prop: IConfigurationPropertySchema) => {
|
||||
if (processedNames.has(name)) {
|
||||
console.warn('Setting is registered twice: ' + name);
|
||||
return;
|
||||
}
|
||||
|
||||
processedNames.add(name);
|
||||
const propDetails: IExportedConfigurationNode = {
|
||||
name,
|
||||
description: prop.description || prop.markdownDescription || '',
|
||||
default: prop.default,
|
||||
type: prop.type
|
||||
};
|
||||
|
||||
if (prop.enum) {
|
||||
propDetails.enum = prop.enum;
|
||||
}
|
||||
|
||||
if (prop.enumDescriptions || prop.markdownEnumDescriptions) {
|
||||
propDetails.enumDescriptions = prop.enumDescriptions || prop.markdownEnumDescriptions;
|
||||
}
|
||||
|
||||
settings.push(propDetails);
|
||||
};
|
||||
|
||||
const processConfig = (config: IConfigurationNode) => {
|
||||
if (config.properties) {
|
||||
for (let name in config.properties) {
|
||||
processProperty(name, config.properties[name]);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.allOf) {
|
||||
config.allOf.forEach(processConfig);
|
||||
}
|
||||
};
|
||||
|
||||
configurations.forEach(processConfig);
|
||||
|
||||
const excludedProps = configRegistry.getExcludedConfigurationProperties();
|
||||
for (let name in excludedProps) {
|
||||
processProperty(name, excludedProps[name]);
|
||||
}
|
||||
|
||||
const result: IConfigurationExport = {
|
||||
settings: settings.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
buildTime: Date.now(),
|
||||
commit: this.productService.commit,
|
||||
buildNumber: this.productService.settingsSearchBuildId
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
|
||||
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
|
||||
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { EditorDescriptor, Extensions as EditorExtensions, IEditorRegistry } from 'vs/workbench/browser/editor';
|
||||
import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions';
|
||||
import { Extensions as EditorInputExtensions, IEditorInputFactoryRegistry } from 'vs/workbench/common/editor';
|
||||
import { CustomEditorInputFactory } from 'vs/workbench/contrib/customEditor/browser/customEditorInputFactory';
|
||||
import { ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { WebviewEditor } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditor';
|
||||
import { CustomEditorInput } from './customEditorInput';
|
||||
import { CustomEditorContribution, CustomEditorService } from './customEditors';
|
||||
|
||||
registerSingleton(ICustomEditorService, CustomEditorService);
|
||||
|
||||
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench)
|
||||
.registerWorkbenchContribution(CustomEditorContribution, LifecyclePhase.Starting);
|
||||
|
||||
Registry.as<IEditorRegistry>(EditorExtensions.Editors)
|
||||
.registerEditor(
|
||||
EditorDescriptor.create(
|
||||
WebviewEditor,
|
||||
WebviewEditor.ID,
|
||||
'Webview Editor',
|
||||
), [
|
||||
new SyncDescriptor(CustomEditorInput)
|
||||
]);
|
||||
|
||||
Registry.as<IEditorInputFactoryRegistry>(EditorInputExtensions.EditorInputFactories)
|
||||
.registerEditorInputFactory(
|
||||
CustomEditorInputFactory.ID,
|
||||
CustomEditorInputFactory);
|
||||
|
||||
Registry.as<IEditorInputFactoryRegistry>(EditorInputExtensions.EditorInputFactories)
|
||||
.registerCustomEditorInputFactory(Schemas.vscodeCustomEditor, CustomEditorInputFactory);
|
||||
@@ -0,0 +1,259 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { memoize } from 'vs/base/common/decorators';
|
||||
import { Lazy } from 'vs/base/common/lazy';
|
||||
import { IReference } from 'vs/base/common/lifecycle';
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
import { basename } from 'vs/base/common/path';
|
||||
import { isEqual } from 'vs/base/common/resources';
|
||||
import { assertIsDefined } from 'vs/base/common/types';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo';
|
||||
import { GroupIdentifier, IEditorInput, IRevertOptions, ISaveOptions, Verbosity } from 'vs/workbench/common/editor';
|
||||
import { ICustomEditorModel, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { IWebviewService, WebviewOverlay } from 'vs/workbench/contrib/webview/browser/webview';
|
||||
import { IWebviewWorkbenchService, LazilyResolvedWebviewEditorInput } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService';
|
||||
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { AutoSaveMode, IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
|
||||
|
||||
export class CustomEditorInput extends LazilyResolvedWebviewEditorInput {
|
||||
|
||||
public static typeId = 'workbench.editors.webviewEditor';
|
||||
|
||||
private readonly _editorResource: URI;
|
||||
private _defaultDirtyState: boolean | undefined;
|
||||
|
||||
private readonly _backupId: string | undefined;
|
||||
|
||||
get resource() { return this._editorResource; }
|
||||
|
||||
private _modelRef?: IReference<ICustomEditorModel>;
|
||||
|
||||
constructor(
|
||||
resource: URI,
|
||||
viewType: string,
|
||||
id: string,
|
||||
webview: Lazy<WebviewOverlay>,
|
||||
options: { startsDirty?: boolean, backupId?: string },
|
||||
@IWebviewService webviewService: IWebviewService,
|
||||
@IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@ILabelService private readonly labelService: ILabelService,
|
||||
@ICustomEditorService private readonly customEditorService: ICustomEditorService,
|
||||
@IFileDialogService private readonly fileDialogService: IFileDialogService,
|
||||
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IUndoRedoService private readonly undoRedoService: IUndoRedoService,
|
||||
) {
|
||||
super(id, viewType, '', webview, webviewService, webviewWorkbenchService);
|
||||
this._editorResource = resource;
|
||||
this._defaultDirtyState = options.startsDirty;
|
||||
this._backupId = options.backupId;
|
||||
}
|
||||
|
||||
public getTypeId(): string {
|
||||
return CustomEditorInput.typeId;
|
||||
}
|
||||
|
||||
public supportsSplitEditor() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@memoize
|
||||
getName(): string {
|
||||
return basename(this.labelService.getUriLabel(this.resource));
|
||||
}
|
||||
|
||||
matches(other: IEditorInput): boolean {
|
||||
return this === other || (other instanceof CustomEditorInput
|
||||
&& this.viewType === other.viewType
|
||||
&& isEqual(this.resource, other.resource));
|
||||
}
|
||||
|
||||
@memoize
|
||||
private get shortTitle(): string {
|
||||
return this.getName();
|
||||
}
|
||||
|
||||
@memoize
|
||||
private get mediumTitle(): string {
|
||||
return this.labelService.getUriLabel(this.resource, { relative: true });
|
||||
}
|
||||
|
||||
@memoize
|
||||
private get longTitle(): string {
|
||||
return this.labelService.getUriLabel(this.resource);
|
||||
}
|
||||
|
||||
public getTitle(verbosity?: Verbosity): string {
|
||||
switch (verbosity) {
|
||||
case Verbosity.SHORT:
|
||||
return this.shortTitle;
|
||||
default:
|
||||
case Verbosity.MEDIUM:
|
||||
return this.mediumTitle;
|
||||
case Verbosity.LONG:
|
||||
return this.longTitle;
|
||||
}
|
||||
}
|
||||
|
||||
public isReadonly(): boolean {
|
||||
return this._modelRef ? this._modelRef.object.isReadonly() : false;
|
||||
}
|
||||
|
||||
public isUntitled(): boolean {
|
||||
return this.resource.scheme === Schemas.untitled;
|
||||
}
|
||||
|
||||
public isDirty(): boolean {
|
||||
if (!this._modelRef) {
|
||||
return !!this._defaultDirtyState;
|
||||
}
|
||||
return this._modelRef.object.isDirty();
|
||||
}
|
||||
|
||||
public isSaving(): boolean {
|
||||
if (this.isUntitled()) {
|
||||
return false; // untitled is never saving automatically
|
||||
}
|
||||
|
||||
if (!this.isDirty()) {
|
||||
return false; // the editor needs to be dirty for being saved
|
||||
}
|
||||
|
||||
if (this.filesConfigurationService.getAutoSaveMode() === AutoSaveMode.AFTER_SHORT_DELAY) {
|
||||
return true; // a short auto save is configured, treat this as being saved
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async save(groupId: GroupIdentifier, options?: ISaveOptions): Promise<IEditorInput | undefined> {
|
||||
if (!this._modelRef) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const target = await this._modelRef.object.saveCustomEditor(options);
|
||||
if (!target) {
|
||||
return undefined; // save cancelled
|
||||
}
|
||||
|
||||
if (!isEqual(target, this.resource)) {
|
||||
return this.customEditorService.createInput(target, this.viewType, groupId);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public async saveAs(groupId: GroupIdentifier, options?: ISaveOptions): Promise<IEditorInput | undefined> {
|
||||
if (!this._modelRef) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dialogPath = this._editorResource;
|
||||
const target = await this.fileDialogService.pickFileToSave(dialogPath, options?.availableFileSystems);
|
||||
if (!target) {
|
||||
return undefined; // save cancelled
|
||||
}
|
||||
|
||||
if (!await this._modelRef.object.saveCustomEditorAs(this._editorResource, target, options)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.rename(groupId, target)?.editor;
|
||||
}
|
||||
|
||||
public async revert(group: GroupIdentifier, options?: IRevertOptions): Promise<void> {
|
||||
if (this._modelRef) {
|
||||
return this._modelRef.object.revert(options);
|
||||
}
|
||||
this._defaultDirtyState = false;
|
||||
this._onDidChangeDirty.fire();
|
||||
}
|
||||
|
||||
public async resolve(): Promise<null> {
|
||||
await super.resolve();
|
||||
|
||||
if (this.isDisposed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this._modelRef) {
|
||||
this._modelRef = this._register(assertIsDefined(await this.customEditorService.models.tryRetain(this.resource, this.viewType)));
|
||||
this._register(this._modelRef.object.onDidChangeDirty(() => this._onDidChangeDirty.fire()));
|
||||
|
||||
if (this.isDirty()) {
|
||||
this._onDidChangeDirty.fire();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
rename(group: GroupIdentifier, newResource: URI): { editor: IEditorInput } | undefined {
|
||||
// See if we can keep using the same custom editor provider
|
||||
const editorInfo = this.customEditorService.getCustomEditor(this.viewType);
|
||||
if (editorInfo?.matches(newResource)) {
|
||||
return { editor: this.doMove(group, newResource) };
|
||||
}
|
||||
|
||||
return { editor: this.editorService.createEditorInput({ resource: newResource, forceFile: true }) };
|
||||
}
|
||||
|
||||
private doMove(group: GroupIdentifier, newResource: URI): IEditorInput {
|
||||
if (!this._moveHandler) {
|
||||
return this.customEditorService.createInput(newResource, this.viewType, group);
|
||||
}
|
||||
|
||||
this._moveHandler(newResource);
|
||||
const newEditor = this.instantiationService.createInstance(CustomEditorInput,
|
||||
newResource,
|
||||
this.viewType,
|
||||
this.id,
|
||||
new Lazy(() => undefined!),
|
||||
{ startsDirty: this._defaultDirtyState, backupId: this._backupId }); // this webview is replaced in the transfer call
|
||||
this.transfer(newEditor);
|
||||
newEditor.updateGroup(group);
|
||||
return newEditor;
|
||||
}
|
||||
|
||||
public undo(): void | Promise<void> {
|
||||
assertIsDefined(this._modelRef);
|
||||
return this.undoRedoService.undo(this.resource);
|
||||
}
|
||||
|
||||
public redo(): void | Promise<void> {
|
||||
assertIsDefined(this._modelRef);
|
||||
return this.undoRedoService.redo(this.resource);
|
||||
}
|
||||
|
||||
private _moveHandler?: (newResource: URI) => void;
|
||||
|
||||
public onMove(handler: (newResource: URI) => void): void {
|
||||
// TODO: Move this to the service
|
||||
this._moveHandler = handler;
|
||||
}
|
||||
|
||||
protected transfer(other: CustomEditorInput): CustomEditorInput | undefined {
|
||||
if (!super.transfer(other)) {
|
||||
return;
|
||||
}
|
||||
|
||||
other._moveHandler = this._moveHandler;
|
||||
this._moveHandler = undefined;
|
||||
return other;
|
||||
}
|
||||
|
||||
get backupId(): string | undefined {
|
||||
if (this._modelRef) {
|
||||
return this._modelRef.object.backupId;
|
||||
}
|
||||
return this._backupId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Lazy } from 'vs/base/common/lazy';
|
||||
import { URI, UriComponents } from 'vs/base/common/uri';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IEditorInput } from 'vs/workbench/common/editor';
|
||||
import { CustomEditorInput } from 'vs/workbench/contrib/customEditor/browser/customEditorInput';
|
||||
import { IWebviewService, WebviewExtensionDescription, WebviewContentPurpose } from 'vs/workbench/contrib/webview/browser/webview';
|
||||
import { reviveWebviewExtensionDescription, SerializedWebview, WebviewEditorInputFactory, DeserializedWebview } from 'vs/workbench/contrib/webviewPanel/browser/webviewEditorInputFactory';
|
||||
import { IWebviewWorkbenchService, WebviewInputOptions } from 'vs/workbench/contrib/webviewPanel/browser/webviewWorkbenchService';
|
||||
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
|
||||
|
||||
export interface CustomDocumentBackupData {
|
||||
readonly viewType: string;
|
||||
readonly editorResource: UriComponents;
|
||||
backupId: string;
|
||||
|
||||
readonly extension: undefined | {
|
||||
readonly location: UriComponents;
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
readonly webview: {
|
||||
readonly id: string;
|
||||
readonly options: WebviewInputOptions;
|
||||
readonly state: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface SerializedCustomEditor extends SerializedWebview {
|
||||
readonly editorResource: UriComponents;
|
||||
readonly dirty: boolean;
|
||||
readonly backupId?: string;
|
||||
}
|
||||
|
||||
|
||||
interface DeserializedCustomEditor extends DeserializedWebview {
|
||||
readonly editorResource: URI;
|
||||
readonly dirty: boolean;
|
||||
readonly backupId?: string;
|
||||
}
|
||||
|
||||
|
||||
export class CustomEditorInputFactory extends WebviewEditorInputFactory {
|
||||
|
||||
public static readonly ID = CustomEditorInput.typeId;
|
||||
|
||||
public constructor(
|
||||
@IWebviewWorkbenchService webviewWorkbenchService: IWebviewWorkbenchService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IWebviewService private readonly _webviewService: IWebviewService,
|
||||
) {
|
||||
super(webviewWorkbenchService);
|
||||
}
|
||||
|
||||
public serialize(input: CustomEditorInput): string | undefined {
|
||||
const dirty = input.isDirty();
|
||||
const data: SerializedCustomEditor = {
|
||||
...this.toJson(input),
|
||||
editorResource: input.resource.toJSON(),
|
||||
dirty,
|
||||
backupId: dirty ? input.backupId : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected fromJson(data: SerializedCustomEditor): DeserializedCustomEditor {
|
||||
return {
|
||||
...super.fromJson(data),
|
||||
editorResource: URI.from(data.editorResource),
|
||||
dirty: data.dirty,
|
||||
};
|
||||
}
|
||||
|
||||
public deserialize(
|
||||
_instantiationService: IInstantiationService,
|
||||
serializedEditorInput: string
|
||||
): CustomEditorInput {
|
||||
const data = this.fromJson(JSON.parse(serializedEditorInput));
|
||||
const webview = CustomEditorInputFactory.reviveWebview(data, this._webviewService);
|
||||
const customInput = this._instantiationService.createInstance(CustomEditorInput, data.editorResource, data.viewType, data.id, webview, { startsDirty: data.dirty, backupId: data.backupId });
|
||||
if (typeof data.group === 'number') {
|
||||
customInput.updateGroup(data.group);
|
||||
}
|
||||
return customInput;
|
||||
}
|
||||
|
||||
private static reviveWebview(data: { id: string, state: any, options: WebviewInputOptions, extension?: WebviewExtensionDescription, }, webviewService: IWebviewService) {
|
||||
return new Lazy(() => {
|
||||
const webview = webviewService.createWebviewOverlay(data.id, {
|
||||
purpose: WebviewContentPurpose.CustomEditor,
|
||||
enableFindWidget: data.options.enableFindWidget,
|
||||
retainContextWhenHidden: data.options.retainContextWhenHidden
|
||||
}, data.options, data.extension);
|
||||
webview.state = data.state;
|
||||
return webview;
|
||||
});
|
||||
}
|
||||
|
||||
public static createCustomEditorInput(resource: URI, instantiationService: IInstantiationService): Promise<IEditorInput> {
|
||||
return instantiationService.invokeFunction(async accessor => {
|
||||
const webviewService = accessor.get<IWebviewService>(IWebviewService);
|
||||
const backupFileService = accessor.get<IBackupFileService>(IBackupFileService);
|
||||
|
||||
const backup = await backupFileService.resolve<CustomDocumentBackupData>(resource);
|
||||
if (!backup?.meta) {
|
||||
throw new Error(`No backup found for custom editor: ${resource}`);
|
||||
}
|
||||
|
||||
const backupData = backup.meta;
|
||||
const id = backupData.webview.id;
|
||||
const extension = reviveWebviewExtensionDescription(backupData.extension?.id, backupData.extension?.location);
|
||||
const webview = CustomEditorInputFactory.reviveWebview({ id, options: backupData.webview.options, state: backupData.webview.state, extension, }, webviewService);
|
||||
|
||||
const editor = instantiationService.createInstance(CustomEditorInput, URI.revive(backupData.editorResource), backupData.viewType, id, webview, { backupId: backupData.backupId });
|
||||
editor.updateGroup(0);
|
||||
return editor;
|
||||
});
|
||||
}
|
||||
|
||||
public static canResolveBackup(editorInput: IEditorInput, backupResource: URI): boolean {
|
||||
if (editorInput instanceof CustomEditorInput) {
|
||||
if (editorInput.resource.path === backupResource.path && backupResource.authority === editorInput.viewType) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { coalesce, distinct } from 'vs/base/common/arrays';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Lazy } from 'vs/base/common/lazy';
|
||||
import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { basename, extname, isEqual } from 'vs/base/common/resources';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';
|
||||
import * as nls from 'vs/nls';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { EditorActivation, IEditorOptions, ITextEditorOptions } from 'vs/platform/editor/common/editor';
|
||||
import { FileOperation, IFileService } from 'vs/platform/files/common/files';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { IStorageService } from 'vs/platform/storage/common/storage';
|
||||
import * as colorRegistry from 'vs/platform/theme/common/colorRegistry';
|
||||
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
|
||||
import { EditorInput, EditorOptions, Extensions as EditorInputExtensions, GroupIdentifier, IEditorInput, IEditorInputFactoryRegistry, IEditorPane } from 'vs/workbench/common/editor';
|
||||
import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput';
|
||||
import { CONTEXT_CUSTOM_EDITORS, CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE, CustomEditorCapabilities, CustomEditorInfo, CustomEditorInfoCollection, CustomEditorPriority, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { CustomEditorModelManager } from 'vs/workbench/contrib/customEditor/common/customEditorModelManager';
|
||||
import { IWebviewService, webviewHasOwnEditFunctionsContext } from 'vs/workbench/contrib/webview/browser/webview';
|
||||
import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
|
||||
import { CustomEditorAssociation, CustomEditorsAssociations, customEditorsAssociationsSettingId } from 'vs/workbench/services/editor/common/editorOpenWith';
|
||||
import { ICustomEditorInfo, ICustomEditorViewTypesHandler, IEditorService, IOpenEditorOverride, IOpenEditorOverrideEntry } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { ContributedCustomEditors, defaultCustomEditor } from '../common/contributedCustomEditors';
|
||||
import { CustomEditorInput } from './customEditorInput';
|
||||
|
||||
export class CustomEditorService extends Disposable implements ICustomEditorService, ICustomEditorViewTypesHandler {
|
||||
_serviceBrand: any;
|
||||
|
||||
private readonly _contributedEditors: ContributedCustomEditors;
|
||||
private readonly _editorCapabilities = new Map<string, CustomEditorCapabilities>();
|
||||
|
||||
private readonly _models = new CustomEditorModelManager();
|
||||
|
||||
private readonly _customEditorContextKey: IContextKey<string>;
|
||||
private readonly _focusedCustomEditorIsEditable: IContextKey<boolean>;
|
||||
private readonly _webviewHasOwnEditFunctions: IContextKey<boolean>;
|
||||
private readonly _onDidChangeViewTypes = new Emitter<void>();
|
||||
onDidChangeViewTypes: Event<void> = this._onDidChangeViewTypes.event;
|
||||
|
||||
private readonly _fileEditorInputFactory = Registry.as<IEditorInputFactoryRegistry>(EditorInputExtensions.EditorInputFactories).getFileEditorInputFactory();
|
||||
|
||||
constructor(
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IFileService fileService: IFileService,
|
||||
@IStorageService storageService: IStorageService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IQuickInputService private readonly quickInputService: IQuickInputService,
|
||||
@IWebviewService private readonly webviewService: IWebviewService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._customEditorContextKey = CONTEXT_CUSTOM_EDITORS.bindTo(contextKeyService);
|
||||
this._focusedCustomEditorIsEditable = CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE.bindTo(contextKeyService);
|
||||
this._webviewHasOwnEditFunctions = webviewHasOwnEditFunctionsContext.bindTo(contextKeyService);
|
||||
|
||||
this._contributedEditors = this._register(new ContributedCustomEditors(storageService));
|
||||
this._register(this._contributedEditors.onChange(() => {
|
||||
this.updateContexts();
|
||||
this._onDidChangeViewTypes.fire();
|
||||
}));
|
||||
this._register(this.editorService.registerCustomEditorViewTypesHandler('Custom Editor', this));
|
||||
this._register(this.editorService.onDidActiveEditorChange(() => this.updateContexts()));
|
||||
|
||||
this._register(fileService.onDidRunOperation(e => {
|
||||
if (e.isOperation(FileOperation.MOVE)) {
|
||||
this.handleMovedFileInOpenedFileEditors(e.resource, e.target.resource);
|
||||
}
|
||||
}));
|
||||
|
||||
const PRIORITY = 105;
|
||||
this._register(UndoCommand.addImplementation(PRIORITY, () => {
|
||||
return this.withActiveCustomEditor(editor => editor.undo());
|
||||
}));
|
||||
this._register(RedoCommand.addImplementation(PRIORITY, () => {
|
||||
return this.withActiveCustomEditor(editor => editor.redo());
|
||||
}));
|
||||
|
||||
this.updateContexts();
|
||||
}
|
||||
|
||||
getViewTypes(): ICustomEditorInfo[] {
|
||||
return [...this._contributedEditors];
|
||||
}
|
||||
|
||||
private withActiveCustomEditor(f: (editor: CustomEditorInput) => void | Promise<void>): boolean | Promise<void> {
|
||||
const activeEditor = this.editorService.activeEditor;
|
||||
if (activeEditor instanceof CustomEditorInput) {
|
||||
const result = f(activeEditor);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get models() { return this._models; }
|
||||
|
||||
public getCustomEditor(viewType: string): CustomEditorInfo | undefined {
|
||||
return this._contributedEditors.get(viewType);
|
||||
}
|
||||
|
||||
public getContributedCustomEditors(resource: URI): CustomEditorInfoCollection {
|
||||
return new CustomEditorInfoCollection(this._contributedEditors.getContributedEditors(resource));
|
||||
}
|
||||
|
||||
public getUserConfiguredCustomEditors(resource: URI): CustomEditorInfoCollection {
|
||||
const rawAssociations = this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsSettingId) || [];
|
||||
return new CustomEditorInfoCollection(
|
||||
coalesce(rawAssociations
|
||||
.filter(association => CustomEditorInfo.selectorMatches(association, resource))
|
||||
.map(association => this._contributedEditors.get(association.viewType))));
|
||||
}
|
||||
|
||||
public getAllCustomEditors(resource: URI): CustomEditorInfoCollection {
|
||||
return new CustomEditorInfoCollection([
|
||||
...this.getUserConfiguredCustomEditors(resource).allEditors,
|
||||
...this.getContributedCustomEditors(resource).allEditors,
|
||||
]);
|
||||
}
|
||||
|
||||
public async promptOpenWith(
|
||||
resource: URI,
|
||||
options?: ITextEditorOptions,
|
||||
group?: IEditorGroup,
|
||||
): Promise<IEditorPane | undefined> {
|
||||
const pick = await this.showOpenWithPrompt(resource, group);
|
||||
if (!pick) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.openWith(resource, pick, options, group);
|
||||
}
|
||||
|
||||
private showOpenWithPrompt(
|
||||
resource: URI,
|
||||
group?: IEditorGroup,
|
||||
): Promise<string | undefined> {
|
||||
const customEditors = new CustomEditorInfoCollection([
|
||||
defaultCustomEditor,
|
||||
...this.getAllCustomEditors(resource).allEditors,
|
||||
]);
|
||||
|
||||
let currentlyOpenedEditorType: undefined | string;
|
||||
for (const editor of group ? group.editors : []) {
|
||||
if (editor.resource && isEqual(editor.resource, resource)) {
|
||||
currentlyOpenedEditorType = editor instanceof CustomEditorInput ? editor.viewType : defaultCustomEditor.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const resourceExt = extname(resource);
|
||||
|
||||
const items = customEditors.allEditors.map((editorDescriptor): IQuickPickItem => ({
|
||||
label: editorDescriptor.displayName,
|
||||
id: editorDescriptor.id,
|
||||
description: editorDescriptor.id === currentlyOpenedEditorType
|
||||
? nls.localize('openWithCurrentlyActive', "Currently Active")
|
||||
: undefined,
|
||||
detail: editorDescriptor.providerDisplayName,
|
||||
buttons: resourceExt ? [{
|
||||
iconClass: 'codicon-settings-gear',
|
||||
tooltip: nls.localize('promptOpenWith.setDefaultTooltip', "Set as default editor for '{0}' files", resourceExt)
|
||||
}] : undefined
|
||||
}));
|
||||
|
||||
const picker = this.quickInputService.createQuickPick();
|
||||
picker.items = items;
|
||||
picker.placeholder = nls.localize('promptOpenWith.placeHolder', "Select editor to use for '{0}'...", basename(resource));
|
||||
|
||||
return new Promise<string | undefined>(resolve => {
|
||||
picker.onDidAccept(() => {
|
||||
resolve(picker.selectedItems.length === 1 ? picker.selectedItems[0].id : undefined);
|
||||
picker.dispose();
|
||||
});
|
||||
picker.onDidTriggerItemButton(e => {
|
||||
const pick = e.item.id;
|
||||
resolve(pick); // open the view
|
||||
picker.dispose();
|
||||
|
||||
// And persist the setting
|
||||
if (pick) {
|
||||
const newAssociation: CustomEditorAssociation = { viewType: pick, filenamePattern: '*' + resourceExt };
|
||||
const currentAssociations = [...this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsSettingId)];
|
||||
|
||||
// First try updating existing association
|
||||
for (let i = 0; i < currentAssociations.length; ++i) {
|
||||
const existing = currentAssociations[i];
|
||||
if (existing.filenamePattern === newAssociation.filenamePattern) {
|
||||
currentAssociations.splice(i, 1, newAssociation);
|
||||
this.configurationService.updateValue(customEditorsAssociationsSettingId, currentAssociations);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, create a new one
|
||||
currentAssociations.unshift(newAssociation);
|
||||
this.configurationService.updateValue(customEditorsAssociationsSettingId, currentAssociations);
|
||||
}
|
||||
});
|
||||
picker.show();
|
||||
});
|
||||
}
|
||||
|
||||
public async openWith(
|
||||
resource: URI,
|
||||
viewType: string,
|
||||
options?: ITextEditorOptions,
|
||||
group?: IEditorGroup,
|
||||
): Promise<IEditorPane | undefined> {
|
||||
if (viewType === defaultCustomEditor.id) {
|
||||
const fileEditorInput = this.editorService.createEditorInput({ resource, forceFile: true });
|
||||
return this.openEditorForResource(resource, fileEditorInput, { ...options, override: false }, group);
|
||||
}
|
||||
|
||||
if (!this._contributedEditors.get(viewType)) {
|
||||
return this.promptOpenWith(resource, options, group);
|
||||
}
|
||||
|
||||
const capabilities = this.getCustomEditorCapabilities(viewType) || {};
|
||||
if (!capabilities.supportsMultipleEditorsPerDocument) {
|
||||
const movedEditor = await this.tryRevealExistingEditorForResourceInGroup(resource, viewType, options, group);
|
||||
if (movedEditor) {
|
||||
return movedEditor;
|
||||
}
|
||||
}
|
||||
|
||||
const input = this.createInput(resource, viewType, group?.id);
|
||||
return this.openEditorForResource(resource, input, options, group);
|
||||
}
|
||||
|
||||
public createInput(
|
||||
resource: URI,
|
||||
viewType: string,
|
||||
group: GroupIdentifier | undefined,
|
||||
options?: { readonly customClasses: string; },
|
||||
): IEditorInput {
|
||||
if (viewType === defaultCustomEditor.id) {
|
||||
return this.editorService.createEditorInput({ resource, forceFile: true });
|
||||
}
|
||||
|
||||
const id = generateUuid();
|
||||
const webview = new Lazy(() => {
|
||||
return this.webviewService.createWebviewOverlay(id, { customClasses: options?.customClasses }, {}, undefined);
|
||||
});
|
||||
const input = this.instantiationService.createInstance(CustomEditorInput, resource, viewType, id, webview, {});
|
||||
if (typeof group !== 'undefined') {
|
||||
input.updateGroup(group);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
private async openEditorForResource(
|
||||
resource: URI,
|
||||
input: IEditorInput,
|
||||
options?: IEditorOptions,
|
||||
group?: IEditorGroup
|
||||
): Promise<IEditorPane | undefined> {
|
||||
const targetGroup = group || this.editorGroupService.activeGroup;
|
||||
|
||||
if (options && typeof options.activation === 'undefined') {
|
||||
options = { ...options, activation: options.preserveFocus ? EditorActivation.RESTORE : undefined };
|
||||
}
|
||||
|
||||
// Try to replace existing editors for resource
|
||||
const existingEditors = targetGroup.editors.filter(editor => editor.resource && isEqual(editor.resource, resource));
|
||||
if (existingEditors.length) {
|
||||
const existing = existingEditors[0];
|
||||
if (!input.matches(existing)) {
|
||||
await this.editorService.replaceEditors([{
|
||||
editor: existing,
|
||||
replacement: input,
|
||||
options: options ? EditorOptions.create(options) : undefined,
|
||||
}], targetGroup);
|
||||
|
||||
if (existing instanceof CustomEditorInput) {
|
||||
existing.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.editorService.openEditor(input, options, group);
|
||||
}
|
||||
|
||||
public registerCustomEditorCapabilities(viewType: string, options: CustomEditorCapabilities): IDisposable {
|
||||
if (this._editorCapabilities.has(viewType)) {
|
||||
throw new Error(`Capabilities for ${viewType} already set`);
|
||||
}
|
||||
this._editorCapabilities.set(viewType, options);
|
||||
return toDisposable(() => {
|
||||
this._editorCapabilities.delete(viewType);
|
||||
});
|
||||
}
|
||||
|
||||
private getCustomEditorCapabilities(viewType: string): CustomEditorCapabilities | undefined {
|
||||
return this._editorCapabilities.get(viewType);
|
||||
}
|
||||
|
||||
private updateContexts() {
|
||||
const activeEditorPane = this.editorService.activeEditorPane;
|
||||
const resource = activeEditorPane?.input?.resource;
|
||||
if (!resource) {
|
||||
this._customEditorContextKey.reset();
|
||||
this._focusedCustomEditorIsEditable.reset();
|
||||
this._webviewHasOwnEditFunctions.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const possibleEditors = this.getAllCustomEditors(resource).allEditors;
|
||||
|
||||
this._customEditorContextKey.set(possibleEditors.map(x => x.id).join(','));
|
||||
this._focusedCustomEditorIsEditable.set(activeEditorPane?.input instanceof CustomEditorInput);
|
||||
this._webviewHasOwnEditFunctions.set(possibleEditors.length > 0);
|
||||
}
|
||||
|
||||
private async handleMovedFileInOpenedFileEditors(oldResource: URI, newResource: URI): Promise<void> {
|
||||
if (extname(oldResource) === extname(newResource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const possibleEditors = this.getAllCustomEditors(newResource);
|
||||
|
||||
// See if we have any non-optional custom editor for this resource
|
||||
if (!possibleEditors.allEditors.some(editor => editor.priority !== CustomEditorPriority.option)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If so, check all editors to see if there are any file editors open for the new resource
|
||||
const editorsToReplace = new Map<GroupIdentifier, IEditorInput[]>();
|
||||
for (const group of this.editorGroupService.groups) {
|
||||
for (const editor of group.editors) {
|
||||
if (this._fileEditorInputFactory.isFileEditorInput(editor)
|
||||
&& !(editor instanceof CustomEditorInput)
|
||||
&& isEqual(editor.resource, newResource)
|
||||
) {
|
||||
let entry = editorsToReplace.get(group.id);
|
||||
if (!entry) {
|
||||
entry = [];
|
||||
editorsToReplace.set(group.id, entry);
|
||||
}
|
||||
entry.push(editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorsToReplace.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
let viewType: string | undefined;
|
||||
if (possibleEditors.defaultEditor) {
|
||||
viewType = possibleEditors.defaultEditor.id;
|
||||
} else {
|
||||
// If there is, show a single prompt for all editors to see if the user wants to re-open them
|
||||
//
|
||||
// TODO: instead of prompting eagerly, it'd likely be better to replace all the editors with
|
||||
// ones that would prompt when they first become visible
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
viewType = await this.showOpenWithPrompt(newResource);
|
||||
}
|
||||
|
||||
if (!viewType) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [group, entries] of editorsToReplace) {
|
||||
this.editorService.replaceEditors(entries.map(editor => {
|
||||
const replacement = this.createInput(newResource, viewType!, group);
|
||||
return {
|
||||
editor,
|
||||
replacement,
|
||||
options: {
|
||||
preserveFocus: true,
|
||||
}
|
||||
};
|
||||
}), group);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryRevealExistingEditorForResourceInGroup(
|
||||
resource: URI,
|
||||
viewType: string,
|
||||
options?: ITextEditorOptions,
|
||||
group?: IEditorGroup,
|
||||
): Promise<IEditorPane | undefined> {
|
||||
const editorInfoForResource = this.findExistingEditorsForResource(resource, viewType);
|
||||
if (!editorInfoForResource.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const editorToUse = editorInfoForResource[0];
|
||||
|
||||
// Replace all other editors
|
||||
for (const { editor, group } of editorInfoForResource) {
|
||||
if (editor !== editorToUse.editor) {
|
||||
group.closeEditor(editor);
|
||||
}
|
||||
}
|
||||
|
||||
const targetGroup = group || this.editorGroupService.activeGroup;
|
||||
const newEditor = await this.openEditorForResource(resource, editorToUse.editor, { ...options, override: false }, targetGroup);
|
||||
if (targetGroup.id !== editorToUse.group.id) {
|
||||
editorToUse.group.closeEditor(editorToUse.editor);
|
||||
}
|
||||
return newEditor;
|
||||
}
|
||||
|
||||
private findExistingEditorsForResource(
|
||||
resource: URI,
|
||||
viewType: string,
|
||||
): Array<{ editor: IEditorInput, group: IEditorGroup }> {
|
||||
const out: Array<{ editor: IEditorInput, group: IEditorGroup }> = [];
|
||||
const orderedGroups = distinct([
|
||||
this.editorGroupService.activeGroup,
|
||||
...this.editorGroupService.groups,
|
||||
]);
|
||||
|
||||
for (const group of orderedGroups) {
|
||||
for (const editor of group.editors) {
|
||||
if (isMatchingCustomEditor(editor, viewType, resource)) {
|
||||
out.push({ editor, group });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomEditorContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
private readonly _fileEditorInputFactory = Registry.as<IEditorInputFactoryRegistry>(EditorInputExtensions.EditorInputFactories).getFileEditorInputFactory();
|
||||
|
||||
constructor(
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@ICustomEditorService private readonly customEditorService: ICustomEditorService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(this.editorService.overrideOpenEditor({
|
||||
open: (editor, options, group) => {
|
||||
return this.onEditorOpening(editor, options, group);
|
||||
},
|
||||
getEditorOverrides: (resource: URI, options: IEditorOptions | undefined, group: IEditorGroup | undefined): IOpenEditorOverrideEntry[] => {
|
||||
const currentEditor = group?.editors.find(editor => isEqual(editor.resource, resource));
|
||||
|
||||
const toOverride = (entry: CustomEditorInfo): IOpenEditorOverrideEntry => {
|
||||
return {
|
||||
id: entry.id,
|
||||
active: currentEditor instanceof CustomEditorInput && currentEditor.viewType === entry.id,
|
||||
label: entry.displayName,
|
||||
detail: entry.providerDisplayName,
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof options?.override === 'string') {
|
||||
// A specific override was requested. Only return it.
|
||||
const matchingEditor = this.customEditorService.getCustomEditor(options.override);
|
||||
return matchingEditor ? [toOverride(matchingEditor)] : [];
|
||||
}
|
||||
|
||||
// Otherwise, return all potential overrides.
|
||||
const customEditors = this.customEditorService.getAllCustomEditors(resource);
|
||||
if (!customEditors.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return customEditors.allEditors
|
||||
.filter(entry => entry.id !== defaultCustomEditor.id)
|
||||
.map(toOverride);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private onEditorOpening(
|
||||
editor: IEditorInput,
|
||||
options: ITextEditorOptions | undefined,
|
||||
group: IEditorGroup
|
||||
): IOpenEditorOverride | undefined {
|
||||
const id = typeof options?.override === 'string' ? options.override : undefined;
|
||||
if (editor instanceof CustomEditorInput) {
|
||||
if (editor.group === group.id && (editor.viewType === id || typeof id !== 'string')) {
|
||||
// No need to do anything
|
||||
return undefined;
|
||||
} else {
|
||||
// Create a copy of the input.
|
||||
// Unlike normal editor inputs, we do not want to share custom editor inputs
|
||||
// between multiple editors / groups.
|
||||
return {
|
||||
override: this.customEditorService.openWith(editor.resource, id ?? editor.viewType, options, group)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (editor instanceof DiffEditorInput) {
|
||||
return this.onDiffEditorOpening(editor, options, group);
|
||||
}
|
||||
|
||||
const resource = editor.resource;
|
||||
if (!resource) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
return {
|
||||
override: this.customEditorService.openWith(resource, id, { ...options, override: false }, group)
|
||||
};
|
||||
}
|
||||
|
||||
return this.onResourceEditorOpening(resource, editor, options, group);
|
||||
}
|
||||
|
||||
private onResourceEditorOpening(
|
||||
resource: URI,
|
||||
editor: IEditorInput,
|
||||
options: ITextEditorOptions | undefined,
|
||||
group: IEditorGroup,
|
||||
): IOpenEditorOverride | undefined {
|
||||
const userConfiguredEditors = this.customEditorService.getUserConfiguredCustomEditors(resource);
|
||||
const contributedEditors = this.customEditorService.getContributedCustomEditors(resource);
|
||||
if (!userConfiguredEditors.length && !contributedEditors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check to see if there already an editor for the resource in the group.
|
||||
// If there is, we want to open that instead of creating a new editor.
|
||||
// This ensures that we preserve whatever type of editor was previously being used
|
||||
// when the user switches back to it.
|
||||
const strictMatchEditorInput = group.editors.find(e => e === editor && !this._fileEditorInputFactory.isFileEditorInput(e));
|
||||
if (strictMatchEditorInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEditorForResource = group.editors.find(editor => isEqual(resource, editor.resource));
|
||||
if (existingEditorForResource) {
|
||||
if (editor === existingEditorForResource) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
override: this.editorService.openEditor(existingEditorForResource, {
|
||||
...options,
|
||||
override: false,
|
||||
activation: options?.preserveFocus ? EditorActivation.RESTORE : undefined,
|
||||
}, group)
|
||||
};
|
||||
}
|
||||
|
||||
if (userConfiguredEditors.length) {
|
||||
return {
|
||||
override: this.customEditorService.openWith(resource, userConfiguredEditors.allEditors[0].id, options, group),
|
||||
};
|
||||
}
|
||||
|
||||
if (!contributedEditors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultEditor = contributedEditors.defaultEditor;
|
||||
if (defaultEditor) {
|
||||
return {
|
||||
override: this.customEditorService.openWith(resource, defaultEditor.id, options, group),
|
||||
};
|
||||
}
|
||||
|
||||
// If we have all optional editors, then open VS Code's standard editor
|
||||
if (contributedEditors.allEditors.every(editor => editor.priority === CustomEditorPriority.option)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open VS Code's standard editor but prompt user to see if they wish to use a custom one instead
|
||||
return {
|
||||
override: (async () => {
|
||||
const standardEditor = await this.editorService.openEditor(editor, { ...options, override: false }, group);
|
||||
// Give a moment to make sure the editor is showing.
|
||||
// Otherwise the focus shift can cause the prompt to be dismissed right away.
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const selectedEditor = await this.customEditorService.promptOpenWith(resource, options, group);
|
||||
if (selectedEditor && selectedEditor.input) {
|
||||
await group.replaceEditors([{
|
||||
editor,
|
||||
replacement: selectedEditor.input
|
||||
}]);
|
||||
return selectedEditor;
|
||||
}
|
||||
|
||||
return standardEditor;
|
||||
})()
|
||||
};
|
||||
}
|
||||
|
||||
private onDiffEditorOpening(
|
||||
editor: DiffEditorInput,
|
||||
options: ITextEditorOptions | undefined,
|
||||
group: IEditorGroup
|
||||
): IOpenEditorOverride | undefined {
|
||||
const getBestAvailableEditorForSubInput = (subInput: IEditorInput): CustomEditorInfo | undefined => {
|
||||
if (subInput instanceof CustomEditorInput) {
|
||||
return undefined;
|
||||
}
|
||||
const resource = subInput.resource;
|
||||
if (!resource) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Prefer default editors in the diff editor case but ultimately always take the first editor
|
||||
const allEditors = new CustomEditorInfoCollection([
|
||||
...this.customEditorService.getUserConfiguredCustomEditors(resource).allEditors,
|
||||
...this.customEditorService.getContributedCustomEditors(resource).allEditors.filter(x => x.priority !== CustomEditorPriority.option),
|
||||
]);
|
||||
return allEditors.bestAvailableEditor;
|
||||
};
|
||||
|
||||
const createEditorForSubInput = (subInput: IEditorInput, editor: CustomEditorInfo | undefined, customClasses: string): EditorInput | undefined => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (!subInput.resource) {
|
||||
return;
|
||||
}
|
||||
const input = this.customEditorService.createInput(subInput.resource, editor.id, group.id, { customClasses });
|
||||
return input instanceof EditorInput ? input : undefined;
|
||||
};
|
||||
|
||||
const modifiedEditorInfo = getBestAvailableEditorForSubInput(editor.modifiedInput);
|
||||
const originalEditorInfo = getBestAvailableEditorForSubInput(editor.originalInput);
|
||||
|
||||
// If we are only using default editors, no need to override anything
|
||||
if (
|
||||
(!modifiedEditorInfo || modifiedEditorInfo.id === defaultCustomEditor.id) &&
|
||||
(!originalEditorInfo || originalEditorInfo.id === defaultCustomEditor.id)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modifiedOverride = createEditorForSubInput(editor.modifiedInput, modifiedEditorInfo, 'modified');
|
||||
const originalOverride = createEditorForSubInput(editor.originalInput, originalEditorInfo, 'original');
|
||||
if (modifiedOverride || originalOverride) {
|
||||
return {
|
||||
override: (async () => {
|
||||
const input = new DiffEditorInput(editor.getName(), editor.getDescription(), originalOverride || editor.originalInput, modifiedOverride || editor.modifiedInput, true);
|
||||
return this.editorService.openEditor(input, { ...options, override: false }, group);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isMatchingCustomEditor(editor: IEditorInput, viewType: string, resource: URI): boolean {
|
||||
return editor instanceof CustomEditorInput
|
||||
&& editor.viewType === viewType
|
||||
&& isEqual(editor.resource, resource);
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const shadow = theme.getColor(colorRegistry.scrollbarShadow);
|
||||
if (shadow) {
|
||||
collector.addRule(`.webview.modified { box-shadow: -6px 0 5px -5px ${shadow}; }`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import * as nls from 'vs/nls';
|
||||
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
|
||||
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
|
||||
import { Memento } from 'vs/workbench/common/memento';
|
||||
import { CustomEditorDescriptor, CustomEditorInfo, CustomEditorPriority } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { customEditorsExtensionPoint, ICustomEditorsExtensionPoint } from 'vs/workbench/contrib/customEditor/common/extensionPoint';
|
||||
import { IExtensionPointUser } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||
import { DEFAULT_EDITOR_ID } from 'vs/workbench/services/editor/common/editorOpenWith';
|
||||
|
||||
const builtinProviderDisplayName = nls.localize('builtinProviderDisplayName', "Built-in");
|
||||
|
||||
export const defaultCustomEditor = new CustomEditorInfo({
|
||||
id: DEFAULT_EDITOR_ID,
|
||||
displayName: nls.localize('promptOpenWith.defaultEditor.displayName', "Text Editor"),
|
||||
providerDisplayName: builtinProviderDisplayName,
|
||||
selector: [
|
||||
{ filenamePattern: '*' }
|
||||
],
|
||||
priority: CustomEditorPriority.default,
|
||||
});
|
||||
|
||||
export class ContributedCustomEditors extends Disposable {
|
||||
|
||||
private static readonly CUSTOM_EDITORS_STORAGE_ID = 'customEditors';
|
||||
private static readonly CUSTOM_EDITORS_ENTRY_ID = 'editors';
|
||||
|
||||
private readonly _editors = new Map<string, CustomEditorInfo>();
|
||||
private readonly _memento: Memento;
|
||||
|
||||
constructor(storageService: IStorageService) {
|
||||
super();
|
||||
|
||||
this._memento = new Memento(ContributedCustomEditors.CUSTOM_EDITORS_STORAGE_ID, storageService);
|
||||
|
||||
const mementoObject = this._memento.getMemento(StorageScope.GLOBAL);
|
||||
for (const info of (mementoObject[ContributedCustomEditors.CUSTOM_EDITORS_ENTRY_ID] || []) as CustomEditorDescriptor[]) {
|
||||
this.add(new CustomEditorInfo(info));
|
||||
}
|
||||
|
||||
customEditorsExtensionPoint.setHandler(extensions => {
|
||||
this.update(extensions);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly _onChange = this._register(new Emitter<void>());
|
||||
public readonly onChange = this._onChange.event;
|
||||
|
||||
private update(extensions: readonly IExtensionPointUser<ICustomEditorsExtensionPoint[]>[]) {
|
||||
this._editors.clear();
|
||||
|
||||
for (const extension of extensions) {
|
||||
for (const webviewEditorContribution of extension.value) {
|
||||
this.add(new CustomEditorInfo({
|
||||
id: webviewEditorContribution.viewType,
|
||||
displayName: webviewEditorContribution.displayName,
|
||||
providerDisplayName: extension.description.isBuiltin ? builtinProviderDisplayName : extension.description.displayName || extension.description.identifier.value,
|
||||
selector: webviewEditorContribution.selector || [],
|
||||
priority: getPriorityFromContribution(webviewEditorContribution, extension.description),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const mementoObject = this._memento.getMemento(StorageScope.GLOBAL);
|
||||
mementoObject[ContributedCustomEditors.CUSTOM_EDITORS_ENTRY_ID] = Array.from(this._editors.values());
|
||||
this._memento.saveMemento();
|
||||
|
||||
this._onChange.fire();
|
||||
}
|
||||
|
||||
public [Symbol.iterator](): Iterator<CustomEditorInfo> {
|
||||
return this._editors.values();
|
||||
}
|
||||
|
||||
public get(viewType: string): CustomEditorInfo | undefined {
|
||||
return viewType === defaultCustomEditor.id
|
||||
? defaultCustomEditor
|
||||
: this._editors.get(viewType);
|
||||
}
|
||||
|
||||
public getContributedEditors(resource: URI): readonly CustomEditorInfo[] {
|
||||
return Array.from(this._editors.values())
|
||||
.filter(customEditor => customEditor.matches(resource));
|
||||
}
|
||||
|
||||
private add(info: CustomEditorInfo): void {
|
||||
if (info.id === defaultCustomEditor.id || this._editors.has(info.id)) {
|
||||
console.error(`Custom editor with id '${info.id}' already registered`);
|
||||
return;
|
||||
}
|
||||
this._editors.set(info.id, info);
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityFromContribution(
|
||||
contribution: ICustomEditorsExtensionPoint,
|
||||
extension: IExtensionDescription,
|
||||
): CustomEditorPriority {
|
||||
switch (contribution.priority) {
|
||||
case CustomEditorPriority.default:
|
||||
case CustomEditorPriority.option:
|
||||
return contribution.priority;
|
||||
|
||||
case CustomEditorPriority.builtin:
|
||||
// Builtin is only valid for builtin extensions
|
||||
return extension.isBuiltin ? CustomEditorPriority.builtin : CustomEditorPriority.default;
|
||||
|
||||
default:
|
||||
return CustomEditorPriority.default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { distinct, mergeSort } from 'vs/base/common/arrays';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import * as glob from 'vs/base/common/glob';
|
||||
import { IDisposable, IReference } from 'vs/base/common/lifecycle';
|
||||
import { Schemas } from 'vs/base/common/network';
|
||||
import { posix } from 'vs/base/common/path';
|
||||
import { basename } from 'vs/base/common/resources';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { RawContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { ITextEditorOptions } from 'vs/platform/editor/common/editor';
|
||||
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { GroupIdentifier, IEditorInput, IEditorPane, IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
|
||||
import { IEditorGroup } from 'vs/workbench/services/editor/common/editorGroupsService';
|
||||
|
||||
export const ICustomEditorService = createDecorator<ICustomEditorService>('customEditorService');
|
||||
|
||||
export const CONTEXT_CUSTOM_EDITORS = new RawContextKey<string>('customEditors', '');
|
||||
export const CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE = new RawContextKey<boolean>('focusedCustomEditorIsEditable', false);
|
||||
|
||||
export interface CustomEditorCapabilities {
|
||||
readonly supportsMultipleEditorsPerDocument?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomEditorService {
|
||||
_serviceBrand: any;
|
||||
|
||||
readonly models: ICustomEditorModelManager;
|
||||
|
||||
getCustomEditor(viewType: string): CustomEditorInfo | undefined;
|
||||
getAllCustomEditors(resource: URI): CustomEditorInfoCollection;
|
||||
getContributedCustomEditors(resource: URI): CustomEditorInfoCollection;
|
||||
getUserConfiguredCustomEditors(resource: URI): CustomEditorInfoCollection;
|
||||
|
||||
createInput(resource: URI, viewType: string, group: GroupIdentifier | undefined, options?: { readonly customClasses: string }): IEditorInput;
|
||||
|
||||
openWith(resource: URI, customEditorViewType: string, options?: ITextEditorOptions, group?: IEditorGroup): Promise<IEditorPane | undefined>;
|
||||
promptOpenWith(resource: URI, options?: ITextEditorOptions, group?: IEditorGroup): Promise<IEditorPane | undefined>;
|
||||
|
||||
registerCustomEditorCapabilities(viewType: string, options: CustomEditorCapabilities): IDisposable;
|
||||
}
|
||||
|
||||
export interface ICustomEditorModelManager {
|
||||
get(resource: URI, viewType: string): Promise<ICustomEditorModel | undefined>;
|
||||
|
||||
tryRetain(resource: URI, viewType: string): Promise<IReference<ICustomEditorModel>> | undefined;
|
||||
|
||||
add(resource: URI, viewType: string, model: Promise<ICustomEditorModel>): Promise<IReference<ICustomEditorModel>>;
|
||||
|
||||
disposeAllModelsForView(viewType: string): void;
|
||||
}
|
||||
|
||||
export interface ICustomEditorModel extends IDisposable {
|
||||
readonly viewType: string;
|
||||
readonly resource: URI;
|
||||
readonly backupId: string | undefined;
|
||||
|
||||
isReadonly(): boolean;
|
||||
|
||||
isDirty(): boolean;
|
||||
readonly onDidChangeDirty: Event<void>;
|
||||
|
||||
revert(options?: IRevertOptions): Promise<void>;
|
||||
|
||||
saveCustomEditor(options?: ISaveOptions): Promise<URI | undefined>;
|
||||
saveCustomEditorAs(resource: URI, targetResource: URI, currentOptions?: ISaveOptions): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const enum CustomEditorPriority {
|
||||
default = 'default',
|
||||
builtin = 'builtin',
|
||||
option = 'option',
|
||||
}
|
||||
|
||||
export interface CustomEditorSelector {
|
||||
readonly filenamePattern?: string;
|
||||
}
|
||||
|
||||
export interface CustomEditorDescriptor {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly providerDisplayName: string;
|
||||
readonly priority: CustomEditorPriority;
|
||||
readonly selector: readonly CustomEditorSelector[];
|
||||
}
|
||||
|
||||
export class CustomEditorInfo implements CustomEditorDescriptor {
|
||||
|
||||
private static readonly excludedSchemes = new Set([
|
||||
Schemas.extension,
|
||||
Schemas.webviewPanel,
|
||||
]);
|
||||
|
||||
public readonly id: string;
|
||||
public readonly displayName: string;
|
||||
public readonly providerDisplayName: string;
|
||||
public readonly priority: CustomEditorPriority;
|
||||
public readonly selector: readonly CustomEditorSelector[];
|
||||
|
||||
constructor(descriptor: CustomEditorDescriptor) {
|
||||
this.id = descriptor.id;
|
||||
this.displayName = descriptor.displayName;
|
||||
this.providerDisplayName = descriptor.providerDisplayName;
|
||||
this.priority = descriptor.priority;
|
||||
this.selector = descriptor.selector;
|
||||
}
|
||||
|
||||
matches(resource: URI): boolean {
|
||||
return this.selector.some(selector => CustomEditorInfo.selectorMatches(selector, resource));
|
||||
}
|
||||
|
||||
static selectorMatches(selector: CustomEditorSelector, resource: URI): boolean {
|
||||
if (CustomEditorInfo.excludedSchemes.has(resource.scheme)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selector.filenamePattern) {
|
||||
const matchOnPath = selector.filenamePattern.indexOf(posix.sep) >= 0;
|
||||
const target = matchOnPath ? resource.path : basename(resource);
|
||||
if (glob.match(selector.filenamePattern.toLowerCase(), target.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomEditorInfoCollection {
|
||||
|
||||
public readonly allEditors: readonly CustomEditorInfo[];
|
||||
|
||||
constructor(
|
||||
editors: readonly CustomEditorInfo[],
|
||||
) {
|
||||
this.allEditors = distinct(editors, editor => editor.id);
|
||||
}
|
||||
|
||||
public get length(): number { return this.allEditors.length; }
|
||||
|
||||
/**
|
||||
* Find the single default editor to use (if any) by looking at the editor's priority and the
|
||||
* other contributed editors.
|
||||
*/
|
||||
public get defaultEditor(): CustomEditorInfo | undefined {
|
||||
return this.allEditors.find(editor => {
|
||||
switch (editor.priority) {
|
||||
case CustomEditorPriority.default:
|
||||
case CustomEditorPriority.builtin:
|
||||
// A default editor must have higher priority than all other contributed editors.
|
||||
return this.allEditors.every(otherEditor =>
|
||||
otherEditor === editor || isLowerPriority(otherEditor, editor));
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best available editor to use.
|
||||
*
|
||||
* Unlike the `defaultEditor`, a bestAvailableEditor can exist even if there are other editors with
|
||||
* the same priority.
|
||||
*/
|
||||
public get bestAvailableEditor(): CustomEditorInfo | undefined {
|
||||
const editors = mergeSort(Array.from(this.allEditors), (a, b) => {
|
||||
return priorityToRank(a.priority) - priorityToRank(b.priority);
|
||||
});
|
||||
return editors[0];
|
||||
}
|
||||
}
|
||||
|
||||
function isLowerPriority(otherEditor: CustomEditorInfo, editor: CustomEditorInfo): unknown {
|
||||
return priorityToRank(otherEditor.priority) < priorityToRank(editor.priority);
|
||||
}
|
||||
|
||||
function priorityToRank(priority: CustomEditorPriority): number {
|
||||
switch (priority) {
|
||||
case CustomEditorPriority.default: return 3;
|
||||
case CustomEditorPriority.builtin: return 2;
|
||||
case CustomEditorPriority.option: return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IReference } from 'vs/base/common/lifecycle';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { ICustomEditorModel, ICustomEditorModelManager } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { once } from 'vs/base/common/functional';
|
||||
|
||||
export class CustomEditorModelManager implements ICustomEditorModelManager {
|
||||
|
||||
private readonly _references = new Map<string, {
|
||||
readonly viewType: string,
|
||||
readonly model: Promise<ICustomEditorModel>,
|
||||
counter: number
|
||||
}>();
|
||||
|
||||
public async get(resource: URI, viewType: string): Promise<ICustomEditorModel | undefined> {
|
||||
const key = this.key(resource, viewType);
|
||||
const entry = this._references.get(key);
|
||||
return entry?.model;
|
||||
}
|
||||
|
||||
public tryRetain(resource: URI, viewType: string): Promise<IReference<ICustomEditorModel>> | undefined {
|
||||
const key = this.key(resource, viewType);
|
||||
|
||||
const entry = this._references.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
entry.counter++;
|
||||
|
||||
return entry.model.then(model => {
|
||||
return {
|
||||
object: model,
|
||||
dispose: once(() => {
|
||||
if (--entry!.counter <= 0) {
|
||||
entry.model.then(x => x.dispose());
|
||||
this._references.delete(key);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public add(resource: URI, viewType: string, model: Promise<ICustomEditorModel>): Promise<IReference<ICustomEditorModel>> {
|
||||
const key = this.key(resource, viewType);
|
||||
const existing = this._references.get(key);
|
||||
if (existing) {
|
||||
throw new Error('Model already exists');
|
||||
}
|
||||
|
||||
this._references.set(key, { viewType, model, counter: 0 });
|
||||
return this.tryRetain(resource, viewType)!;
|
||||
}
|
||||
|
||||
public disposeAllModelsForView(viewType: string): void {
|
||||
for (const [key, value] of this._references) {
|
||||
if (value.viewType === viewType) {
|
||||
value.model.then(x => x.dispose());
|
||||
this._references.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private key(resource: URI, viewType: string): string {
|
||||
return `${resource.toString()}@@@${viewType}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable, IReference } from 'vs/base/common/lifecycle';
|
||||
import { isEqual } from 'vs/base/common/resources';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { IRevertOptions, ISaveOptions } from 'vs/workbench/common/editor';
|
||||
import { ICustomEditorModel } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
|
||||
export class CustomTextEditorModel extends Disposable implements ICustomEditorModel {
|
||||
|
||||
public static async create(
|
||||
instantiationService: IInstantiationService,
|
||||
viewType: string,
|
||||
resource: URI
|
||||
): Promise<CustomTextEditorModel> {
|
||||
return instantiationService.invokeFunction(async accessor => {
|
||||
const textModelResolverService = accessor.get(ITextModelService);
|
||||
const textFileService = accessor.get(ITextFileService);
|
||||
const model = await textModelResolverService.createModelReference(resource);
|
||||
return new CustomTextEditorModel(viewType, resource, model, textFileService);
|
||||
});
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public readonly viewType: string,
|
||||
private readonly _resource: URI,
|
||||
private readonly _model: IReference<IResolvedTextEditorModel>,
|
||||
@ITextFileService private readonly textFileService: ITextFileService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(_model);
|
||||
|
||||
this._register(this.textFileService.files.onDidChangeDirty(e => {
|
||||
if (isEqual(this.resource, e.resource)) {
|
||||
this._onDidChangeDirty.fire();
|
||||
this._onDidChangeContent.fire();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public get resource() {
|
||||
return this._resource;
|
||||
}
|
||||
|
||||
public isReadonly(): boolean {
|
||||
return this._model.object.isReadonly();
|
||||
}
|
||||
|
||||
public get backupId() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public isDirty(): boolean {
|
||||
return this.textFileService.isDirty(this.resource);
|
||||
}
|
||||
|
||||
private readonly _onDidChangeDirty: Emitter<void> = this._register(new Emitter<void>());
|
||||
readonly onDidChangeDirty: Event<void> = this._onDidChangeDirty.event;
|
||||
|
||||
private readonly _onDidChangeContent: Emitter<void> = this._register(new Emitter<void>());
|
||||
readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;
|
||||
|
||||
public async revert(options?: IRevertOptions) {
|
||||
return this.textFileService.revert(this.resource, options);
|
||||
}
|
||||
|
||||
public saveCustomEditor(options?: ISaveOptions): Promise<URI | undefined> {
|
||||
return this.textFileService.save(this.resource, options);
|
||||
}
|
||||
|
||||
public async saveCustomEditorAs(resource: URI, targetResource: URI, options?: ISaveOptions): Promise<boolean> {
|
||||
return !!await this.textFileService.saveAs(resource, targetResource, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IJSONSchema } from 'vs/base/common/jsonSchema';
|
||||
import * as nls from 'vs/nls';
|
||||
import { CustomEditorPriority, CustomEditorSelector } from 'vs/workbench/contrib/customEditor/common/customEditor';
|
||||
import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry';
|
||||
import { languagesExtPoint } from 'vs/workbench/services/mode/common/workbenchModeService';
|
||||
|
||||
namespace Fields {
|
||||
export const viewType = 'viewType';
|
||||
export const displayName = 'displayName';
|
||||
export const selector = 'selector';
|
||||
export const priority = 'priority';
|
||||
}
|
||||
|
||||
export interface ICustomEditorsExtensionPoint {
|
||||
readonly [Fields.viewType]: string;
|
||||
readonly [Fields.displayName]: string;
|
||||
readonly [Fields.selector]?: readonly CustomEditorSelector[];
|
||||
readonly [Fields.priority]?: string;
|
||||
}
|
||||
|
||||
const CustomEditorsContribution: IJSONSchema = {
|
||||
description: nls.localize('contributes.customEditors', 'Contributed custom editors.'),
|
||||
type: 'array',
|
||||
defaultSnippets: [{
|
||||
body: [{
|
||||
[Fields.viewType]: '$1',
|
||||
[Fields.displayName]: '$2',
|
||||
[Fields.selector]: [{
|
||||
filenamePattern: '$3'
|
||||
}],
|
||||
}]
|
||||
}],
|
||||
items: {
|
||||
type: 'object',
|
||||
required: [
|
||||
Fields.viewType,
|
||||
Fields.displayName,
|
||||
Fields.selector,
|
||||
],
|
||||
properties: {
|
||||
[Fields.viewType]: {
|
||||
type: 'string',
|
||||
markdownDescription: nls.localize('contributes.viewType', 'Identifier for the custom editor. This must be unique across all custom editors, so we recommend including your extension id as part of `viewType`. The `viewType` is used when registering custom editors with `vscode.registerCustomEditorProvider` and in the `onCustomEditor:${id}` [activation event](https://code.visualstudio.com/api/references/activation-events).'),
|
||||
},
|
||||
[Fields.displayName]: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.displayName', 'Human readable name of the custom editor. This is displayed to users when selecting which editor to use.'),
|
||||
},
|
||||
[Fields.selector]: {
|
||||
type: 'array',
|
||||
description: nls.localize('contributes.selector', 'Set of globs that the custom editor is enabled for.'),
|
||||
items: {
|
||||
type: 'object',
|
||||
defaultSnippets: [{
|
||||
body: {
|
||||
filenamePattern: '$1',
|
||||
}
|
||||
}],
|
||||
properties: {
|
||||
filenamePattern: {
|
||||
type: 'string',
|
||||
description: nls.localize('contributes.selector.filenamePattern', 'Glob that the custom editor is enabled for.'),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
[Fields.priority]: {
|
||||
type: 'string',
|
||||
markdownDeprecationMessage: nls.localize('contributes.priority', 'Controls if the custom editor is enabled automatically when the user opens a file. This may be overridden by users using the `workbench.editorAssociations` setting.'),
|
||||
enum: [
|
||||
CustomEditorPriority.default,
|
||||
CustomEditorPriority.option,
|
||||
],
|
||||
markdownEnumDescriptions: [
|
||||
nls.localize('contributes.priority.default', 'The editor is automatically used when the user opens a resource, provided that no other default custom editors are registered for that resource.'),
|
||||
nls.localize('contributes.priority.option', 'The editor is not automatically used when the user opens a resource, but a user can switch to the editor using the `Reopen With` command.'),
|
||||
],
|
||||
default: 'default'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const customEditorsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<ICustomEditorsExtensionPoint[]>({
|
||||
extensionPoint: 'customEditors',
|
||||
deps: [languagesExtPoint],
|
||||
jsonSchema: CustomEditorsContribution
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { IExpression, IDebugService, IExpressionContainer } from 'vs/workbench/contrib/debug/common/debug';
|
||||
import { Expression, Variable, ExpressionContainer } from 'vs/workbench/contrib/debug/common/debugModel';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IInputValidationOptions, InputBox } from 'vs/base/browser/ui/inputbox/inputBox';
|
||||
import { ITreeRenderer, ITreeNode } from 'vs/base/browser/ui/tree/tree';
|
||||
import { IDisposable, dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { attachInputBoxStyler } from 'vs/platform/theme/common/styler';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent';
|
||||
import { HighlightedLabel, IHighlight } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel';
|
||||
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
|
||||
import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector';
|
||||
import { ReplEvaluationResult } from 'vs/workbench/contrib/debug/common/replModel';
|
||||
import { once } from 'vs/base/common/functional';
|
||||
|
||||
export const MAX_VALUE_RENDER_LENGTH_IN_VIEWLET = 1024;
|
||||
export const twistiePixels = 20;
|
||||
const booleanRegex = /^true|false$/i;
|
||||
const stringRegex = /^(['"]).*\1$/;
|
||||
const $ = dom.$;
|
||||
|
||||
export interface IRenderValueOptions {
|
||||
showChanged?: boolean;
|
||||
maxValueLength?: number;
|
||||
showHover?: boolean;
|
||||
colorize?: boolean;
|
||||
linkDetector?: LinkDetector;
|
||||
}
|
||||
|
||||
export interface IVariableTemplateData {
|
||||
expression: HTMLElement;
|
||||
name: HTMLElement;
|
||||
value: HTMLElement;
|
||||
label: HighlightedLabel;
|
||||
}
|
||||
|
||||
export function renderViewTree(container: HTMLElement): HTMLElement {
|
||||
const treeContainer = $('.');
|
||||
treeContainer.classList.add('debug-view-content');
|
||||
container.appendChild(treeContainer);
|
||||
return treeContainer;
|
||||
}
|
||||
|
||||
export function renderExpressionValue(expressionOrValue: IExpressionContainer | string, container: HTMLElement, options: IRenderValueOptions): void {
|
||||
let value = typeof expressionOrValue === 'string' ? expressionOrValue : expressionOrValue.value;
|
||||
|
||||
// remove stale classes
|
||||
container.className = 'value';
|
||||
// when resolving expressions we represent errors from the server as a variable with name === null.
|
||||
if (value === null || ((expressionOrValue instanceof Expression || expressionOrValue instanceof Variable || expressionOrValue instanceof ReplEvaluationResult) && !expressionOrValue.available)) {
|
||||
container.classList.add('unavailable');
|
||||
if (value !== Expression.DEFAULT_VALUE) {
|
||||
container.classList.add('error');
|
||||
}
|
||||
} else if ((expressionOrValue instanceof ExpressionContainer) && options.showChanged && expressionOrValue.valueChanged && value !== Expression.DEFAULT_VALUE) {
|
||||
// value changed color has priority over other colors.
|
||||
container.className = 'value changed';
|
||||
expressionOrValue.valueChanged = false;
|
||||
}
|
||||
|
||||
if (options.colorize && typeof expressionOrValue !== 'string') {
|
||||
if (expressionOrValue.type === 'number' || expressionOrValue.type === 'boolean' || expressionOrValue.type === 'string') {
|
||||
container.classList.add(expressionOrValue.type);
|
||||
} else if (!isNaN(+value)) {
|
||||
container.classList.add('number');
|
||||
} else if (booleanRegex.test(value)) {
|
||||
container.classList.add('boolean');
|
||||
} else if (stringRegex.test(value)) {
|
||||
container.classList.add('string');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxValueLength && value && value.length > options.maxValueLength) {
|
||||
value = value.substr(0, options.maxValueLength) + '...';
|
||||
}
|
||||
if (!value) {
|
||||
value = '';
|
||||
}
|
||||
|
||||
if (options.linkDetector) {
|
||||
container.textContent = '';
|
||||
const session = (expressionOrValue instanceof ExpressionContainer) ? expressionOrValue.getSession() : undefined;
|
||||
container.appendChild(options.linkDetector.linkify(value, false, session ? session.root : undefined));
|
||||
} else {
|
||||
container.textContent = value;
|
||||
}
|
||||
if (options.showHover) {
|
||||
container.title = value || '';
|
||||
}
|
||||
}
|
||||
|
||||
export function renderVariable(variable: Variable, data: IVariableTemplateData, showChanged: boolean, highlights: IHighlight[], linkDetector?: LinkDetector): void {
|
||||
if (variable.available) {
|
||||
let text = variable.name;
|
||||
if (variable.value && typeof variable.name === 'string') {
|
||||
text += ':';
|
||||
}
|
||||
data.label.set(text, highlights, variable.type ? variable.type : variable.name);
|
||||
data.name.classList.toggle('virtual', !!variable.presentationHint && variable.presentationHint.kind === 'virtual');
|
||||
} else if (variable.value && typeof variable.name === 'string' && variable.name) {
|
||||
data.label.set(':');
|
||||
}
|
||||
|
||||
renderExpressionValue(variable, data.value, {
|
||||
showChanged,
|
||||
maxValueLength: MAX_VALUE_RENDER_LENGTH_IN_VIEWLET,
|
||||
showHover: true,
|
||||
colorize: true,
|
||||
linkDetector
|
||||
});
|
||||
}
|
||||
|
||||
export interface IInputBoxOptions {
|
||||
initialValue: string;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
validationOptions?: IInputValidationOptions;
|
||||
onFinish: (value: string, success: boolean) => void;
|
||||
}
|
||||
|
||||
export interface IExpressionTemplateData {
|
||||
expression: HTMLElement;
|
||||
name: HTMLSpanElement;
|
||||
value: HTMLSpanElement;
|
||||
inputBoxContainer: HTMLElement;
|
||||
toDispose: IDisposable;
|
||||
label: HighlightedLabel;
|
||||
}
|
||||
|
||||
export abstract class AbstractExpressionsRenderer implements ITreeRenderer<IExpression, FuzzyScore, IExpressionTemplateData> {
|
||||
|
||||
constructor(
|
||||
@IDebugService protected debugService: IDebugService,
|
||||
@IContextViewService private readonly contextViewService: IContextViewService,
|
||||
@IThemeService private readonly themeService: IThemeService
|
||||
) { }
|
||||
|
||||
abstract get templateId(): string;
|
||||
|
||||
renderTemplate(container: HTMLElement): IExpressionTemplateData {
|
||||
const expression = dom.append(container, $('.expression'));
|
||||
const name = dom.append(expression, $('span.name'));
|
||||
const value = dom.append(expression, $('span.value'));
|
||||
const label = new HighlightedLabel(name, false);
|
||||
|
||||
const inputBoxContainer = dom.append(expression, $('.inputBoxContainer'));
|
||||
|
||||
return { expression, name, value, label, inputBoxContainer, toDispose: Disposable.None };
|
||||
}
|
||||
|
||||
renderElement(node: ITreeNode<IExpression, FuzzyScore>, index: number, data: IExpressionTemplateData): void {
|
||||
data.toDispose.dispose();
|
||||
data.toDispose = Disposable.None;
|
||||
const { element } = node;
|
||||
this.renderExpression(element, data, createMatches(node.filterData));
|
||||
if (element === this.debugService.getViewModel().getSelectedExpression() || (element instanceof Variable && element.errorMessage)) {
|
||||
const options = this.getInputBoxOptions(element);
|
||||
if (options) {
|
||||
data.toDispose = this.renderInputBox(data.name, data.value, data.inputBoxContainer, options);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderInputBox(nameElement: HTMLElement, valueElement: HTMLElement, inputBoxContainer: HTMLElement, options: IInputBoxOptions): IDisposable {
|
||||
nameElement.style.display = 'none';
|
||||
valueElement.style.display = 'none';
|
||||
inputBoxContainer.style.display = 'initial';
|
||||
|
||||
const inputBox = new InputBox(inputBoxContainer, this.contextViewService, options);
|
||||
const styler = attachInputBoxStyler(inputBox, this.themeService);
|
||||
|
||||
inputBox.value = options.initialValue;
|
||||
inputBox.focus();
|
||||
inputBox.select();
|
||||
|
||||
const done = once((success: boolean, finishEditing: boolean) => {
|
||||
nameElement.style.display = 'initial';
|
||||
valueElement.style.display = 'initial';
|
||||
inputBoxContainer.style.display = 'none';
|
||||
const value = inputBox.value;
|
||||
dispose(toDispose);
|
||||
|
||||
if (finishEditing) {
|
||||
this.debugService.getViewModel().setSelectedExpression(undefined);
|
||||
options.onFinish(value, success);
|
||||
}
|
||||
});
|
||||
|
||||
const toDispose = [
|
||||
inputBox,
|
||||
dom.addStandardDisposableListener(inputBox.inputElement, dom.EventType.KEY_DOWN, (e: IKeyboardEvent) => {
|
||||
const isEscape = e.equals(KeyCode.Escape);
|
||||
const isEnter = e.equals(KeyCode.Enter);
|
||||
if (isEscape || isEnter) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
done(isEnter, true);
|
||||
}
|
||||
}),
|
||||
dom.addDisposableListener(inputBox.inputElement, dom.EventType.BLUR, () => {
|
||||
done(true, true);
|
||||
}),
|
||||
dom.addDisposableListener(inputBox.inputElement, dom.EventType.CLICK, e => {
|
||||
// Do not expand / collapse selected elements
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}),
|
||||
styler
|
||||
];
|
||||
|
||||
return toDisposable(() => {
|
||||
done(false, false);
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract renderExpression(expression: IExpression, data: IExpressionTemplateData, highlights: IHighlight[]): void;
|
||||
protected abstract getInputBoxOptions(expression: IExpression): IInputBoxOptions | undefined;
|
||||
|
||||
disposeElement(node: ITreeNode<IExpression, FuzzyScore>, index: number, templateData: IExpressionTemplateData): void {
|
||||
templateData.toDispose.dispose();
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IExpressionTemplateData): void {
|
||||
templateData.toDispose.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as env from 'vs/base/common/platform';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import severity from 'vs/base/common/severity';
|
||||
import { IAction, Action, SubmenuAction } from 'vs/base/common/actions';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { ICodeEditor, IEditorMouseEvent, MouseTargetType, IContentWidget, IActiveCodeEditor, IContentWidgetPosition, ContentWidgetPositionPreference } from 'vs/editor/browser/editorBrowser';
|
||||
import { IModelDecorationOptions, IModelDeltaDecoration, TrackedRangeStickiness, ITextModel, OverviewRulerLane, IModelDecorationOverviewRulerOptions } from 'vs/editor/common/model';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { RemoveBreakpointAction } from 'vs/workbench/contrib/debug/browser/debugActions';
|
||||
import { IDebugService, IBreakpoint, CONTEXT_BREAKPOINT_WIDGET_VISIBLE, BreakpointWidgetContext, IBreakpointEditorContribution, IBreakpointUpdateData, IDebugConfiguration, State, IDebugSession } from 'vs/workbench/contrib/debug/common/debug';
|
||||
import { IMarginData } from 'vs/editor/browser/controller/mouseTarget';
|
||||
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
|
||||
import { BreakpointWidget } from 'vs/workbench/contrib/debug/browser/breakpointWidget';
|
||||
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
|
||||
import { MarkdownString } from 'vs/base/common/htmlContent';
|
||||
import { getBreakpointMessageAndClassName } from 'vs/workbench/contrib/debug/browser/breakpointsView';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
import { memoize } from 'vs/base/common/decorators';
|
||||
import { StandardMouseEvent } from 'vs/base/browser/mouseEvent';
|
||||
import { distinct } from 'vs/base/common/arrays';
|
||||
import { RunOnceScheduler } from 'vs/base/common/async';
|
||||
import { EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { BrowserFeatures } from 'vs/base/browser/canIUse';
|
||||
import { isSafari } from 'vs/base/browser/browser';
|
||||
import { registerThemingParticipant, themeColorFromId } from 'vs/platform/theme/common/themeService';
|
||||
import { registerColor } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
interface IBreakpointDecoration {
|
||||
decorationId: string;
|
||||
breakpoint: IBreakpoint;
|
||||
range: Range;
|
||||
inlineWidget?: InlineBreakpointWidget;
|
||||
}
|
||||
|
||||
const breakpointHelperDecoration: IModelDecorationOptions = {
|
||||
glyphMarginClassName: 'codicon-debug-hint',
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
|
||||
};
|
||||
|
||||
export function createBreakpointDecorations(model: ITextModel, breakpoints: ReadonlyArray<IBreakpoint>, state: State, breakpointsActivated: boolean, showBreakpointsInOverviewRuler: boolean): { range: Range; options: IModelDecorationOptions; }[] {
|
||||
const result: { range: Range; options: IModelDecorationOptions; }[] = [];
|
||||
breakpoints.forEach((breakpoint) => {
|
||||
if (breakpoint.lineNumber > model.getLineCount()) {
|
||||
return;
|
||||
}
|
||||
const column = model.getLineFirstNonWhitespaceColumn(breakpoint.lineNumber);
|
||||
const range = model.validateRange(
|
||||
breakpoint.column ? new Range(breakpoint.lineNumber, breakpoint.column, breakpoint.lineNumber, breakpoint.column + 1)
|
||||
: new Range(breakpoint.lineNumber, column, breakpoint.lineNumber, column + 1) // Decoration has to have a width #20688
|
||||
);
|
||||
|
||||
result.push({
|
||||
options: getBreakpointDecorationOptions(model, breakpoint, state, breakpointsActivated, showBreakpointsInOverviewRuler),
|
||||
range
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getBreakpointDecorationOptions(model: ITextModel, breakpoint: IBreakpoint, state: State, breakpointsActivated: boolean, showBreakpointsInOverviewRuler: boolean): IModelDecorationOptions {
|
||||
const { className, message } = getBreakpointMessageAndClassName(state, breakpointsActivated, breakpoint, undefined);
|
||||
let glyphMarginHoverMessage: MarkdownString | undefined;
|
||||
|
||||
if (message) {
|
||||
if (breakpoint.condition || breakpoint.hitCondition) {
|
||||
const modeId = model.getLanguageIdentifier().language;
|
||||
glyphMarginHoverMessage = new MarkdownString().appendCodeblock(modeId, message);
|
||||
} else {
|
||||
glyphMarginHoverMessage = new MarkdownString().appendText(message);
|
||||
}
|
||||
}
|
||||
|
||||
let overviewRulerDecoration: IModelDecorationOverviewRulerOptions | null = null;
|
||||
if (showBreakpointsInOverviewRuler) {
|
||||
overviewRulerDecoration = {
|
||||
color: themeColorFromId(debugIconBreakpointForeground),
|
||||
position: OverviewRulerLane.Left
|
||||
};
|
||||
}
|
||||
|
||||
const renderInline = breakpoint.column && (breakpoint.column > model.getLineFirstNonWhitespaceColumn(breakpoint.lineNumber));
|
||||
return {
|
||||
glyphMarginClassName: `${className}`,
|
||||
glyphMarginHoverMessage,
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
beforeContentClassName: renderInline ? `debug-breakpoint-placeholder` : undefined,
|
||||
overviewRuler: overviewRulerDecoration
|
||||
};
|
||||
}
|
||||
|
||||
async function createCandidateDecorations(model: ITextModel, breakpointDecorations: IBreakpointDecoration[], session: IDebugSession): Promise<{ range: Range; options: IModelDecorationOptions; breakpoint: IBreakpoint | undefined }[]> {
|
||||
const lineNumbers = distinct(breakpointDecorations.map(bpd => bpd.range.startLineNumber));
|
||||
const result: { range: Range; options: IModelDecorationOptions; breakpoint: IBreakpoint | undefined }[] = [];
|
||||
if (session.capabilities.supportsBreakpointLocationsRequest) {
|
||||
await Promise.all(lineNumbers.map(async lineNumber => {
|
||||
try {
|
||||
const positions = await session.breakpointsLocations(model.uri, lineNumber);
|
||||
if (positions.length > 1) {
|
||||
// Do not render candidates if there is only one, since it is already covered by the line breakpoint
|
||||
const firstColumn = model.getLineFirstNonWhitespaceColumn(lineNumber);
|
||||
const lastColumn = model.getLineLastNonWhitespaceColumn(lineNumber);
|
||||
positions.forEach(p => {
|
||||
const range = new Range(p.lineNumber, p.column, p.lineNumber, p.column + 1);
|
||||
if (p.column <= firstColumn || p.column > lastColumn) {
|
||||
// Do not render candidates on the start of the line.
|
||||
return;
|
||||
}
|
||||
|
||||
const breakpointAtPosition = breakpointDecorations.find(bpd => bpd.range.equalsRange(range));
|
||||
if (breakpointAtPosition && breakpointAtPosition.inlineWidget) {
|
||||
// Space already occupied, do not render candidate.
|
||||
return;
|
||||
}
|
||||
result.push({
|
||||
range,
|
||||
options: {
|
||||
stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
|
||||
beforeContentClassName: breakpointAtPosition ? undefined : `debug-breakpoint-placeholder`
|
||||
},
|
||||
breakpoint: breakpointAtPosition ? breakpointAtPosition.breakpoint : undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If there is an error when fetching breakpoint locations just do not render them
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class BreakpointEditorContribution implements IBreakpointEditorContribution {
|
||||
|
||||
private breakpointHintDecoration: string[] = [];
|
||||
private breakpointWidget: BreakpointWidget | undefined;
|
||||
private breakpointWidgetVisible: IContextKey<boolean>;
|
||||
private toDispose: IDisposable[] = [];
|
||||
private ignoreDecorationsChangedEvent = false;
|
||||
private ignoreBreakpointsChangeEvent = false;
|
||||
private breakpointDecorations: IBreakpointDecoration[] = [];
|
||||
private candidateDecorations: { decorationId: string, inlineWidget: InlineBreakpointWidget }[] = [];
|
||||
private setDecorationsScheduler: RunOnceScheduler;
|
||||
|
||||
constructor(
|
||||
private readonly editor: ICodeEditor,
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@IContextMenuService private readonly contextMenuService: IContextMenuService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IDialogService private readonly dialogService: IDialogService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@ILabelService private readonly labelService: ILabelService
|
||||
) {
|
||||
this.breakpointWidgetVisible = CONTEXT_BREAKPOINT_WIDGET_VISIBLE.bindTo(contextKeyService);
|
||||
this.setDecorationsScheduler = new RunOnceScheduler(() => this.setDecorations(), 30);
|
||||
this.registerListeners();
|
||||
this.setDecorationsScheduler.schedule();
|
||||
}
|
||||
|
||||
private registerListeners(): void {
|
||||
this.toDispose.push(this.editor.onMouseDown(async (e: IEditorMouseEvent) => {
|
||||
if (!this.debugService.getConfigurationManager().hasDebuggers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = e.target.detail as IMarginData;
|
||||
const model = this.editor.getModel();
|
||||
if (!e.target.position || !model || e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN || data.isAfterLines || !this.marginFreeFromNonDebugDecorations(e.target.position.lineNumber)) {
|
||||
return;
|
||||
}
|
||||
const canSetBreakpoints = this.debugService.getConfigurationManager().canSetBreakpointsIn(model);
|
||||
const lineNumber = e.target.position.lineNumber;
|
||||
const uri = model.uri;
|
||||
|
||||
if (e.event.rightButton || (env.isMacintosh && e.event.leftButton && e.event.ctrlKey)) {
|
||||
if (!canSetBreakpoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = { x: e.event.posx, y: e.event.posy };
|
||||
const breakpoints = this.debugService.getModel().getBreakpoints({ lineNumber, uri });
|
||||
const actions = this.getContextMenuActions(breakpoints, uri, lineNumber);
|
||||
|
||||
this.contextMenuService.showContextMenu({
|
||||
getAnchor: () => anchor,
|
||||
getActions: () => actions,
|
||||
getActionsContext: () => breakpoints.length ? breakpoints[0] : undefined,
|
||||
onHide: () => dispose(actions)
|
||||
});
|
||||
} else {
|
||||
const breakpoints = this.debugService.getModel().getBreakpoints({ uri, lineNumber });
|
||||
|
||||
if (breakpoints.length) {
|
||||
// Show the dialog if there is a potential condition to be accidently lost.
|
||||
// Do not show dialog on linux due to electron issue freezing the mouse #50026
|
||||
if (!env.isLinux && breakpoints.some(bp => !!bp.condition || !!bp.logMessage || !!bp.hitCondition)) {
|
||||
const logPoint = breakpoints.every(bp => !!bp.logMessage);
|
||||
const breakpointType = logPoint ? nls.localize('logPoint', "Logpoint") : nls.localize('breakpoint', "Breakpoint");
|
||||
const disable = breakpoints.some(bp => bp.enabled);
|
||||
|
||||
const enabling = nls.localize('breakpointHasConditionDisabled',
|
||||
"This {0} has a {1} that will get lost on remove. Consider enabling the {0} instead.",
|
||||
breakpointType.toLowerCase(),
|
||||
logPoint ? nls.localize('message', "message") : nls.localize('condition', "condition")
|
||||
);
|
||||
const disabling = nls.localize('breakpointHasConditionEnabled',
|
||||
"This {0} has a {1} that will get lost on remove. Consider disabling the {0} instead.",
|
||||
breakpointType.toLowerCase(),
|
||||
logPoint ? nls.localize('message', "message") : nls.localize('condition', "condition")
|
||||
);
|
||||
|
||||
const { choice } = await this.dialogService.show(severity.Info, disable ? disabling : enabling, [
|
||||
nls.localize('removeLogPoint', "Remove {0}", breakpointType),
|
||||
nls.localize('disableLogPoint', "{0} {1}", disable ? nls.localize('disable', "Disable") : nls.localize('enable', "Enable"), breakpointType),
|
||||
nls.localize('cancel', "Cancel")
|
||||
], { cancelId: 2 });
|
||||
|
||||
if (choice === 0) {
|
||||
breakpoints.forEach(bp => this.debugService.removeBreakpoints(bp.getId()));
|
||||
}
|
||||
if (choice === 1) {
|
||||
breakpoints.forEach(bp => this.debugService.enableOrDisableBreakpoints(!disable, bp));
|
||||
}
|
||||
} else {
|
||||
breakpoints.forEach(bp => this.debugService.removeBreakpoints(bp.getId()));
|
||||
}
|
||||
} else if (canSetBreakpoints) {
|
||||
this.debugService.addBreakpoints(uri, [{ lineNumber }]);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
if (!(BrowserFeatures.pointerEvents && isSafari)) {
|
||||
/**
|
||||
* We disable the hover feature for Safari on iOS as
|
||||
* 1. Browser hover events are handled specially by the system (it treats first click as hover if there is `:hover` css registered). Below hover behavior will confuse users with inconsistent expeirence.
|
||||
* 2. When users click on line numbers, the breakpoint hint displays immediately, however it doesn't create the breakpoint unless users click on the left gutter. On a touch screen, it's hard to click on that small area.
|
||||
*/
|
||||
this.toDispose.push(this.editor.onMouseMove((e: IEditorMouseEvent) => {
|
||||
if (!this.debugService.getConfigurationManager().hasDebuggers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let showBreakpointHintAtLineNumber = -1;
|
||||
const model = this.editor.getModel();
|
||||
if (model && e.target.position && (e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN || e.target.type === MouseTargetType.GUTTER_LINE_NUMBERS) && this.debugService.getConfigurationManager().canSetBreakpointsIn(model) &&
|
||||
this.marginFreeFromNonDebugDecorations(e.target.position.lineNumber)) {
|
||||
const data = e.target.detail as IMarginData;
|
||||
if (!data.isAfterLines) {
|
||||
showBreakpointHintAtLineNumber = e.target.position.lineNumber;
|
||||
}
|
||||
}
|
||||
this.ensureBreakpointHintDecoration(showBreakpointHintAtLineNumber);
|
||||
}));
|
||||
this.toDispose.push(this.editor.onMouseLeave(() => {
|
||||
this.ensureBreakpointHintDecoration(-1);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
this.toDispose.push(this.editor.onDidChangeModel(async () => {
|
||||
this.closeBreakpointWidget();
|
||||
await this.setDecorations();
|
||||
}));
|
||||
this.toDispose.push(this.debugService.getModel().onDidChangeBreakpoints(() => {
|
||||
if (!this.ignoreBreakpointsChangeEvent && !this.setDecorationsScheduler.isScheduled()) {
|
||||
this.setDecorationsScheduler.schedule();
|
||||
}
|
||||
}));
|
||||
this.toDispose.push(this.debugService.onDidChangeState(() => {
|
||||
// We need to update breakpoint decorations when state changes since the top stack frame and breakpoint decoration might change
|
||||
if (!this.setDecorationsScheduler.isScheduled()) {
|
||||
this.setDecorationsScheduler.schedule();
|
||||
}
|
||||
}));
|
||||
this.toDispose.push(this.editor.onDidChangeModelDecorations(() => this.onModelDecorationsChanged()));
|
||||
this.toDispose.push(this.configurationService.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration('debug.showBreakpointsInOverviewRuler') || e.affectsConfiguration('debug.showInlineBreakpointCandidates')) {
|
||||
await this.setDecorations();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private getContextMenuActions(breakpoints: ReadonlyArray<IBreakpoint>, uri: URI, lineNumber: number, column?: number): IAction[] {
|
||||
const actions: IAction[] = [];
|
||||
if (breakpoints.length === 1) {
|
||||
const breakpointType = breakpoints[0].logMessage ? nls.localize('logPoint', "Logpoint") : nls.localize('breakpoint', "Breakpoint");
|
||||
actions.push(new RemoveBreakpointAction(RemoveBreakpointAction.ID, nls.localize('removeBreakpoint', "Remove {0}", breakpointType), this.debugService));
|
||||
actions.push(new Action(
|
||||
'workbench.debug.action.editBreakpointAction',
|
||||
nls.localize('editBreakpoint', "Edit {0}...", breakpointType),
|
||||
undefined,
|
||||
true,
|
||||
() => Promise.resolve(this.showBreakpointWidget(breakpoints[0].lineNumber, breakpoints[0].column))
|
||||
));
|
||||
|
||||
actions.push(new Action(
|
||||
`workbench.debug.viewlet.action.toggleBreakpoint`,
|
||||
breakpoints[0].enabled ? nls.localize('disableBreakpoint', "Disable {0}", breakpointType) : nls.localize('enableBreakpoint', "Enable {0}", breakpointType),
|
||||
undefined,
|
||||
true,
|
||||
() => this.debugService.enableOrDisableBreakpoints(!breakpoints[0].enabled, breakpoints[0])
|
||||
));
|
||||
} else if (breakpoints.length > 1) {
|
||||
const sorted = breakpoints.slice().sort((first, second) => (first.column && second.column) ? first.column - second.column : 1);
|
||||
actions.push(new SubmenuAction('debug.removeBreakpoints', nls.localize('removeBreakpoints', "Remove Breakpoints"), sorted.map(bp => new Action(
|
||||
'removeInlineBreakpoint',
|
||||
bp.column ? nls.localize('removeInlineBreakpointOnColumn', "Remove Inline Breakpoint on Column {0}", bp.column) : nls.localize('removeLineBreakpoint', "Remove Line Breakpoint"),
|
||||
undefined,
|
||||
true,
|
||||
() => this.debugService.removeBreakpoints(bp.getId())
|
||||
))));
|
||||
|
||||
actions.push(new SubmenuAction('debug.editBReakpoints', nls.localize('editBreakpoints', "Edit Breakpoints"), sorted.map(bp =>
|
||||
new Action('editBreakpoint',
|
||||
bp.column ? nls.localize('editInlineBreakpointOnColumn', "Edit Inline Breakpoint on Column {0}", bp.column) : nls.localize('editLineBrekapoint', "Edit Line Breakpoint"),
|
||||
undefined,
|
||||
true,
|
||||
() => Promise.resolve(this.showBreakpointWidget(bp.lineNumber, bp.column))
|
||||
)
|
||||
)));
|
||||
|
||||
actions.push(new SubmenuAction('debug.enableDisableBreakpoints', nls.localize('enableDisableBreakpoints', "Enable/Disable Breakpoints"), sorted.map(bp => new Action(
|
||||
bp.enabled ? 'disableColumnBreakpoint' : 'enableColumnBreakpoint',
|
||||
bp.enabled ? (bp.column ? nls.localize('disableInlineColumnBreakpoint', "Disable Inline Breakpoint on Column {0}", bp.column) : nls.localize('disableBreakpointOnLine', "Disable Line Breakpoint"))
|
||||
: (bp.column ? nls.localize('enableBreakpoints', "Enable Inline Breakpoint on Column {0}", bp.column) : nls.localize('enableBreakpointOnLine', "Enable Line Breakpoint")),
|
||||
undefined,
|
||||
true,
|
||||
() => this.debugService.enableOrDisableBreakpoints(!bp.enabled, bp)
|
||||
))));
|
||||
} else {
|
||||
actions.push(new Action(
|
||||
'addBreakpoint',
|
||||
nls.localize('addBreakpoint', "Add Breakpoint"),
|
||||
undefined,
|
||||
true,
|
||||
() => this.debugService.addBreakpoints(uri, [{ lineNumber, column }])
|
||||
));
|
||||
actions.push(new Action(
|
||||
'addConditionalBreakpoint',
|
||||
nls.localize('addConditionalBreakpoint', "Add Conditional Breakpoint..."),
|
||||
undefined,
|
||||
true,
|
||||
() => Promise.resolve(this.showBreakpointWidget(lineNumber, column, BreakpointWidgetContext.CONDITION))
|
||||
));
|
||||
actions.push(new Action(
|
||||
'addLogPoint',
|
||||
nls.localize('addLogPoint', "Add Logpoint..."),
|
||||
undefined,
|
||||
true,
|
||||
() => Promise.resolve(this.showBreakpointWidget(lineNumber, column, BreakpointWidgetContext.LOG_MESSAGE))
|
||||
));
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private marginFreeFromNonDebugDecorations(line: number): boolean {
|
||||
const decorations = this.editor.getLineDecorations(line);
|
||||
if (decorations) {
|
||||
for (const { options } of decorations) {
|
||||
if (options.glyphMarginClassName && options.glyphMarginClassName.indexOf('codicon-') === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private ensureBreakpointHintDecoration(showBreakpointHintAtLineNumber: number): void {
|
||||
const newDecoration: IModelDeltaDecoration[] = [];
|
||||
if (showBreakpointHintAtLineNumber !== -1) {
|
||||
newDecoration.push({
|
||||
options: breakpointHelperDecoration,
|
||||
range: {
|
||||
startLineNumber: showBreakpointHintAtLineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: showBreakpointHintAtLineNumber,
|
||||
endColumn: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.breakpointHintDecoration = this.editor.deltaDecorations(this.breakpointHintDecoration, newDecoration);
|
||||
}
|
||||
|
||||
private async setDecorations(): Promise<void> {
|
||||
if (!this.editor.hasModel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeCodeEditor = this.editor;
|
||||
const model = activeCodeEditor.getModel();
|
||||
const breakpoints = this.debugService.getModel().getBreakpoints({ uri: model.uri });
|
||||
const debugSettings = this.configurationService.getValue<IDebugConfiguration>('debug');
|
||||
const desiredBreakpointDecorations = createBreakpointDecorations(model, breakpoints, this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), debugSettings.showBreakpointsInOverviewRuler);
|
||||
|
||||
try {
|
||||
this.ignoreDecorationsChangedEvent = true;
|
||||
|
||||
// Set breakpoint decorations
|
||||
const decorationIds = activeCodeEditor.deltaDecorations(this.breakpointDecorations.map(bpd => bpd.decorationId), desiredBreakpointDecorations);
|
||||
this.breakpointDecorations.forEach(bpd => {
|
||||
if (bpd.inlineWidget) {
|
||||
bpd.inlineWidget.dispose();
|
||||
}
|
||||
});
|
||||
this.breakpointDecorations = decorationIds.map((decorationId, index) => {
|
||||
let inlineWidget: InlineBreakpointWidget | undefined = undefined;
|
||||
const breakpoint = breakpoints[index];
|
||||
if (desiredBreakpointDecorations[index].options.beforeContentClassName) {
|
||||
const contextMenuActions = () => this.getContextMenuActions([breakpoint], activeCodeEditor.getModel().uri, breakpoint.lineNumber, breakpoint.column);
|
||||
inlineWidget = new InlineBreakpointWidget(activeCodeEditor, decorationId, desiredBreakpointDecorations[index].options.glyphMarginClassName, breakpoint, this.debugService, this.contextMenuService, contextMenuActions);
|
||||
}
|
||||
|
||||
return {
|
||||
decorationId,
|
||||
breakpoint,
|
||||
range: desiredBreakpointDecorations[index].range,
|
||||
inlineWidget
|
||||
};
|
||||
});
|
||||
|
||||
} finally {
|
||||
this.ignoreDecorationsChangedEvent = false;
|
||||
}
|
||||
|
||||
// Set breakpoint candidate decorations
|
||||
const session = this.debugService.getViewModel().focusedSession;
|
||||
const desiredCandidateDecorations = debugSettings.showInlineBreakpointCandidates && session ? await createCandidateDecorations(this.editor.getModel(), this.breakpointDecorations, session) : [];
|
||||
const candidateDecorationIds = this.editor.deltaDecorations(this.candidateDecorations.map(c => c.decorationId), desiredCandidateDecorations);
|
||||
this.candidateDecorations.forEach(candidate => {
|
||||
candidate.inlineWidget.dispose();
|
||||
});
|
||||
this.candidateDecorations = candidateDecorationIds.map((decorationId, index) => {
|
||||
const candidate = desiredCandidateDecorations[index];
|
||||
// Candidate decoration has a breakpoint attached when a breakpoint is already at that location and we did not yet set a decoration there
|
||||
// In practice this happens for the first breakpoint that was set on a line
|
||||
// We could have also rendered this first decoration as part of desiredBreakpointDecorations however at that moment we have no location information
|
||||
const cssClass = candidate.breakpoint ? getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), candidate.breakpoint, this.labelService).className : 'codicon-debug-breakpoint-disabled';
|
||||
const contextMenuActions = () => this.getContextMenuActions(candidate.breakpoint ? [candidate.breakpoint] : [], activeCodeEditor.getModel().uri, candidate.range.startLineNumber, candidate.range.startColumn);
|
||||
const inlineWidget = new InlineBreakpointWidget(activeCodeEditor, decorationId, cssClass, candidate.breakpoint, this.debugService, this.contextMenuService, contextMenuActions);
|
||||
|
||||
return {
|
||||
decorationId,
|
||||
inlineWidget
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async onModelDecorationsChanged(): Promise<void> {
|
||||
if (this.breakpointDecorations.length === 0 || this.ignoreDecorationsChangedEvent || !this.editor.hasModel()) {
|
||||
// I have no decorations
|
||||
return;
|
||||
}
|
||||
let somethingChanged = false;
|
||||
const model = this.editor.getModel();
|
||||
this.breakpointDecorations.forEach(breakpointDecoration => {
|
||||
if (somethingChanged) {
|
||||
return;
|
||||
}
|
||||
const newBreakpointRange = model.getDecorationRange(breakpointDecoration.decorationId);
|
||||
if (newBreakpointRange && (!breakpointDecoration.range.equalsRange(newBreakpointRange))) {
|
||||
somethingChanged = true;
|
||||
breakpointDecoration.range = newBreakpointRange;
|
||||
}
|
||||
});
|
||||
if (!somethingChanged) {
|
||||
// nothing to do, my decorations did not change.
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new Map<string, IBreakpointUpdateData>();
|
||||
for (let i = 0, len = this.breakpointDecorations.length; i < len; i++) {
|
||||
const breakpointDecoration = this.breakpointDecorations[i];
|
||||
const decorationRange = model.getDecorationRange(breakpointDecoration.decorationId);
|
||||
// check if the line got deleted.
|
||||
if (decorationRange) {
|
||||
// since we know it is collapsed, it cannot grow to multiple lines
|
||||
if (breakpointDecoration.breakpoint) {
|
||||
data.set(breakpointDecoration.breakpoint.getId(), {
|
||||
lineNumber: decorationRange.startLineNumber,
|
||||
column: breakpointDecoration.breakpoint.column ? decorationRange.startColumn : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.ignoreBreakpointsChangeEvent = true;
|
||||
await this.debugService.updateBreakpoints(model.uri, data, true);
|
||||
} finally {
|
||||
this.ignoreBreakpointsChangeEvent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// breakpoint widget
|
||||
showBreakpointWidget(lineNumber: number, column: number | undefined, context?: BreakpointWidgetContext): void {
|
||||
if (this.breakpointWidget) {
|
||||
this.breakpointWidget.dispose();
|
||||
}
|
||||
|
||||
this.breakpointWidget = this.instantiationService.createInstance(BreakpointWidget, this.editor, lineNumber, column, context);
|
||||
this.breakpointWidget.show({ lineNumber, column: 1 });
|
||||
this.breakpointWidgetVisible.set(true);
|
||||
}
|
||||
|
||||
closeBreakpointWidget(): void {
|
||||
if (this.breakpointWidget) {
|
||||
this.breakpointWidget.dispose();
|
||||
this.breakpointWidget = undefined;
|
||||
this.breakpointWidgetVisible.reset();
|
||||
this.editor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.breakpointWidget) {
|
||||
this.breakpointWidget.dispose();
|
||||
}
|
||||
this.editor.deltaDecorations(this.breakpointDecorations.map(bpd => bpd.decorationId), []);
|
||||
dispose(this.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class InlineBreakpointWidget implements IContentWidget, IDisposable {
|
||||
|
||||
// editor.IContentWidget.allowEditorOverflow
|
||||
allowEditorOverflow = false;
|
||||
suppressMouseDown = true;
|
||||
|
||||
private domNode!: HTMLElement;
|
||||
private range: Range | null;
|
||||
private toDispose: IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly editor: IActiveCodeEditor,
|
||||
private readonly decorationId: string,
|
||||
cssClass: string | null | undefined,
|
||||
private readonly breakpoint: IBreakpoint | undefined,
|
||||
private readonly debugService: IDebugService,
|
||||
private readonly contextMenuService: IContextMenuService,
|
||||
private readonly getContextMenuActions: () => IAction[]
|
||||
) {
|
||||
this.range = this.editor.getModel().getDecorationRange(decorationId);
|
||||
this.toDispose.push(this.editor.onDidChangeModelDecorations(() => {
|
||||
const model = this.editor.getModel();
|
||||
const range = model.getDecorationRange(this.decorationId);
|
||||
if (this.range && !this.range.equalsRange(range)) {
|
||||
this.range = range;
|
||||
this.editor.layoutContentWidget(this);
|
||||
}
|
||||
}));
|
||||
this.create(cssClass);
|
||||
|
||||
this.editor.addContentWidget(this);
|
||||
this.editor.layoutContentWidget(this);
|
||||
}
|
||||
|
||||
private create(cssClass: string | null | undefined): void {
|
||||
this.domNode = $('.inline-breakpoint-widget');
|
||||
this.domNode.classList.add('codicon');
|
||||
if (cssClass) {
|
||||
this.domNode.classList.add(cssClass);
|
||||
}
|
||||
this.toDispose.push(dom.addDisposableListener(this.domNode, dom.EventType.CLICK, async e => {
|
||||
if (this.breakpoint) {
|
||||
await this.debugService.removeBreakpoints(this.breakpoint.getId());
|
||||
} else {
|
||||
await this.debugService.addBreakpoints(this.editor.getModel().uri, [{ lineNumber: this.range!.startLineNumber, column: this.range!.startColumn }]);
|
||||
}
|
||||
}));
|
||||
this.toDispose.push(dom.addDisposableListener(this.domNode, dom.EventType.CONTEXT_MENU, e => {
|
||||
const event = new StandardMouseEvent(e);
|
||||
const anchor = { x: event.posx, y: event.posy };
|
||||
const actions = this.getContextMenuActions();
|
||||
this.contextMenuService.showContextMenu({
|
||||
getAnchor: () => anchor,
|
||||
getActions: () => actions,
|
||||
getActionsContext: () => this.breakpoint,
|
||||
onHide: () => dispose(actions)
|
||||
});
|
||||
}));
|
||||
|
||||
const updateSize = () => {
|
||||
const lineHeight = this.editor.getOption(EditorOption.lineHeight);
|
||||
this.domNode.style.height = `${lineHeight}px`;
|
||||
this.domNode.style.width = `${Math.ceil(0.8 * lineHeight)}px`;
|
||||
this.domNode.style.marginLeft = `4px`;
|
||||
};
|
||||
updateSize();
|
||||
|
||||
this.toDispose.push(this.editor.onDidChangeConfiguration(c => {
|
||||
if (c.hasChanged(EditorOption.fontSize) || c.hasChanged(EditorOption.lineHeight)) {
|
||||
updateSize();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@memoize
|
||||
getId(): string {
|
||||
return generateUuid();
|
||||
}
|
||||
|
||||
getDomNode(): HTMLElement {
|
||||
return this.domNode;
|
||||
}
|
||||
|
||||
getPosition(): IContentWidgetPosition | null {
|
||||
if (!this.range) {
|
||||
return null;
|
||||
}
|
||||
// Workaround: since the content widget can not be placed before the first column we need to force the left position
|
||||
this.domNode.classList.toggle('line-start', this.range.startColumn === 1);
|
||||
|
||||
return {
|
||||
position: { lineNumber: this.range.startLineNumber, column: this.range.startColumn - 1 },
|
||||
preference: [ContentWidgetPositionPreference.EXACT]
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.editor.removeContentWidget(this);
|
||||
dispose(this.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const debugIconBreakpointColor = theme.getColor(debugIconBreakpointForeground);
|
||||
if (debugIconBreakpointColor) {
|
||||
collector.addRule(`
|
||||
.monaco-workbench .codicon-debug-breakpoint,
|
||||
.monaco-workbench .codicon-debug-breakpoint-conditional,
|
||||
.monaco-workbench .codicon-debug-breakpoint-log,
|
||||
.monaco-workbench .codicon-debug-breakpoint-function,
|
||||
.monaco-workbench .codicon-debug-breakpoint-data,
|
||||
.monaco-workbench .codicon-debug-breakpoint-unsupported,
|
||||
.monaco-workbench .codicon-debug-hint:not([class*='codicon-debug-breakpoint']):not([class*='codicon-debug-stackframe']),
|
||||
.monaco-workbench .codicon-debug-breakpoint.codicon-debug-stackframe-focused::after,
|
||||
.monaco-workbench .codicon-debug-breakpoint.codicon-debug-stackframe::after {
|
||||
color: ${debugIconBreakpointColor} !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
const debugIconBreakpointDisabledColor = theme.getColor(debugIconBreakpointDisabledForeground);
|
||||
if (debugIconBreakpointDisabledColor) {
|
||||
collector.addRule(`
|
||||
.monaco-workbench .codicon[class*='-disabled'] {
|
||||
color: ${debugIconBreakpointDisabledColor} !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
const debugIconBreakpointUnverifiedColor = theme.getColor(debugIconBreakpointUnverifiedForeground);
|
||||
if (debugIconBreakpointUnverifiedColor) {
|
||||
collector.addRule(`
|
||||
.monaco-workbench .codicon[class*='-unverified'] {
|
||||
color: ${debugIconBreakpointUnverifiedColor};
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
const debugIconBreakpointCurrentStackframeForegroundColor = theme.getColor(debugIconBreakpointCurrentStackframeForeground);
|
||||
if (debugIconBreakpointCurrentStackframeForegroundColor) {
|
||||
collector.addRule(`
|
||||
.monaco-workbench .codicon-debug-stackframe,
|
||||
.monaco-editor .debug-top-stack-frame-column::before {
|
||||
color: ${debugIconBreakpointCurrentStackframeForegroundColor} !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
const debugIconBreakpointStackframeFocusedColor = theme.getColor(debugIconBreakpointStackframeForeground);
|
||||
if (debugIconBreakpointStackframeFocusedColor) {
|
||||
collector.addRule(`
|
||||
.monaco-workbench .codicon-debug-stackframe-focused {
|
||||
color: ${debugIconBreakpointStackframeFocusedColor} !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
const debugIconBreakpointForeground = registerColor('debugIcon.breakpointForeground', { dark: '#E51400', light: '#E51400', hc: '#E51400' }, nls.localize('debugIcon.breakpointForeground', 'Icon color for breakpoints.'));
|
||||
const debugIconBreakpointDisabledForeground = registerColor('debugIcon.breakpointDisabledForeground', { dark: '#848484', light: '#848484', hc: '#848484' }, nls.localize('debugIcon.breakpointDisabledForeground', 'Icon color for disabled breakpoints.'));
|
||||
const debugIconBreakpointUnverifiedForeground = registerColor('debugIcon.breakpointUnverifiedForeground', { dark: '#848484', light: '#848484', hc: '#848484' }, nls.localize('debugIcon.breakpointUnverifiedForeground', 'Icon color for unverified breakpoints.'));
|
||||
const debugIconBreakpointCurrentStackframeForeground = registerColor('debugIcon.breakpointCurrentStackframeForeground', { dark: '#FFCC00', light: '#FFCC00', hc: '#FFCC00' }, nls.localize('debugIcon.breakpointCurrentStackframeForeground', 'Icon color for the current breakpoint stack frame.'));
|
||||
const debugIconBreakpointStackframeForeground = registerColor('debugIcon.breakpointStackframeForeground', { dark: '#89D185', light: '#89D185', hc: '#89D185' }, nls.localize('debugIcon.breakpointStackframeForeground', 'Icon color for all breakpoint stack frames.'));
|
||||
@@ -0,0 +1,390 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./media/breakpointWidget';
|
||||
import * as nls from 'vs/nls';
|
||||
import { KeyCode, KeyMod } from 'vs/base/common/keyCodes';
|
||||
import { SelectBox, ISelectOptionItem } from 'vs/base/browser/ui/selectBox/selectBox';
|
||||
import * as lifecycle from 'vs/base/common/lifecycle';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { Position, IPosition } from 'vs/editor/common/core/position';
|
||||
import { ICodeEditor, IActiveCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { ZoneWidget } from 'vs/editor/contrib/zoneWidget/zoneWidget';
|
||||
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IDebugService, IBreakpoint, BreakpointWidgetContext as Context, CONTEXT_BREAKPOINT_WIDGET_VISIBLE, DEBUG_SCHEME, CONTEXT_IN_BREAKPOINT_WIDGET, IBreakpointUpdateData, IBreakpointEditorContribution, BREAKPOINT_EDITOR_CONTRIBUTION_ID } from 'vs/workbench/contrib/debug/common/debug';
|
||||
import { attachSelectBoxStyler } from 'vs/platform/theme/common/styler';
|
||||
import { IThemeService, IColorTheme } from 'vs/platform/theme/common/themeService';
|
||||
import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { ServicesAccessor, EditorCommand, registerEditorCommand } from 'vs/editor/browser/editorExtensions';
|
||||
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
|
||||
import { IModelService } from 'vs/editor/common/services/modelService';
|
||||
import { URI as uri } from 'vs/base/common/uri';
|
||||
import { CompletionProviderRegistry, CompletionList, CompletionContext, CompletionItemKind } from 'vs/editor/common/modes';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { ITextModel } from 'vs/editor/common/model';
|
||||
import { provideSuggestionItems, CompletionOptions } from 'vs/editor/contrib/suggest/suggest';
|
||||
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
|
||||
import { transparent, editorForeground } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
|
||||
import { IDecorationOptions } from 'vs/editor/common/editorCommon';
|
||||
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
|
||||
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
|
||||
import { getSimpleEditorOptions, getSimpleCodeEditorWidgetOptions } from 'vs/workbench/contrib/codeEditor/browser/simpleEditorOptions';
|
||||
import { IRange, Range } from 'vs/editor/common/core/range';
|
||||
import { onUnexpectedError } from 'vs/base/common/errors';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IEditorOptions, EditorOption } from 'vs/editor/common/config/editorOptions';
|
||||
|
||||
const $ = dom.$;
|
||||
const IPrivateBreakpointWidgetService = createDecorator<IPrivateBreakpointWidgetService>('privateBreakpointWidgetService');
|
||||
export interface IPrivateBreakpointWidgetService {
|
||||
readonly _serviceBrand: undefined;
|
||||
close(success: boolean): void;
|
||||
}
|
||||
const DECORATION_KEY = 'breakpointwidgetdecoration';
|
||||
|
||||
function isCurlyBracketOpen(input: IActiveCodeEditor): boolean {
|
||||
const model = input.getModel();
|
||||
const prevBracket = model.findPrevBracket(input.getPosition());
|
||||
if (prevBracket && prevBracket.isOpen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function createDecorations(theme: IColorTheme, placeHolder: string): IDecorationOptions[] {
|
||||
const transparentForeground = transparent(editorForeground, 0.4)(theme);
|
||||
return [{
|
||||
range: {
|
||||
startLineNumber: 0,
|
||||
endLineNumber: 0,
|
||||
startColumn: 0,
|
||||
endColumn: 1
|
||||
},
|
||||
renderOptions: {
|
||||
after: {
|
||||
contentText: placeHolder,
|
||||
color: transparentForeground ? transparentForeground.toString() : undefined
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
export class BreakpointWidget extends ZoneWidget implements IPrivateBreakpointWidgetService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private selectContainer!: HTMLElement;
|
||||
private inputContainer!: HTMLElement;
|
||||
private input!: IActiveCodeEditor;
|
||||
private toDispose: lifecycle.IDisposable[];
|
||||
private conditionInput = '';
|
||||
private hitCountInput = '';
|
||||
private logMessageInput = '';
|
||||
private breakpoint: IBreakpoint | undefined;
|
||||
private context: Context;
|
||||
private heightInPx: number | undefined;
|
||||
|
||||
constructor(editor: ICodeEditor, private lineNumber: number, private column: number | undefined, context: Context | undefined,
|
||||
@IContextViewService private readonly contextViewService: IContextViewService,
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@IThemeService private readonly themeService: IThemeService,
|
||||
@IContextKeyService private readonly contextKeyService: IContextKeyService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IModelService private readonly modelService: IModelService,
|
||||
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService
|
||||
) {
|
||||
super(editor, { showFrame: true, showArrow: false, frameWidth: 1, isAccessible: true });
|
||||
|
||||
this.toDispose = [];
|
||||
const model = this.editor.getModel();
|
||||
if (model) {
|
||||
const uri = model.uri;
|
||||
const breakpoints = this.debugService.getModel().getBreakpoints({ lineNumber: this.lineNumber, column: this.column, uri });
|
||||
this.breakpoint = breakpoints.length ? breakpoints[0] : undefined;
|
||||
}
|
||||
|
||||
if (context === undefined) {
|
||||
if (this.breakpoint && !this.breakpoint.condition && !this.breakpoint.hitCondition && this.breakpoint.logMessage) {
|
||||
this.context = Context.LOG_MESSAGE;
|
||||
} else if (this.breakpoint && !this.breakpoint.condition && this.breakpoint.hitCondition) {
|
||||
this.context = Context.HIT_COUNT;
|
||||
} else {
|
||||
this.context = Context.CONDITION;
|
||||
}
|
||||
} else {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
this.toDispose.push(this.debugService.getModel().onDidChangeBreakpoints(e => {
|
||||
if (this.breakpoint && e && e.removed && e.removed.indexOf(this.breakpoint) >= 0) {
|
||||
this.dispose();
|
||||
}
|
||||
}));
|
||||
this.codeEditorService.registerDecorationType(DECORATION_KEY, {});
|
||||
|
||||
this.create();
|
||||
}
|
||||
|
||||
private get placeholder(): string {
|
||||
switch (this.context) {
|
||||
case Context.LOG_MESSAGE:
|
||||
return nls.localize('breakpointWidgetLogMessagePlaceholder', "Message to log when breakpoint is hit. Expressions within {} are interpolated. 'Enter' to accept, 'esc' to cancel.");
|
||||
case Context.HIT_COUNT:
|
||||
return nls.localize('breakpointWidgetHitCountPlaceholder', "Break when hit count condition is met. 'Enter' to accept, 'esc' to cancel.");
|
||||
default:
|
||||
return nls.localize('breakpointWidgetExpressionPlaceholder', "Break when expression evaluates to true. 'Enter' to accept, 'esc' to cancel.");
|
||||
}
|
||||
}
|
||||
|
||||
private getInputValue(breakpoint: IBreakpoint | undefined): string {
|
||||
switch (this.context) {
|
||||
case Context.LOG_MESSAGE:
|
||||
return breakpoint && breakpoint.logMessage ? breakpoint.logMessage : this.logMessageInput;
|
||||
case Context.HIT_COUNT:
|
||||
return breakpoint && breakpoint.hitCondition ? breakpoint.hitCondition : this.hitCountInput;
|
||||
default:
|
||||
return breakpoint && breakpoint.condition ? breakpoint.condition : this.conditionInput;
|
||||
}
|
||||
}
|
||||
|
||||
private rememberInput(): void {
|
||||
const value = this.input.getModel().getValue();
|
||||
switch (this.context) {
|
||||
case Context.LOG_MESSAGE:
|
||||
this.logMessageInput = value;
|
||||
break;
|
||||
case Context.HIT_COUNT:
|
||||
this.hitCountInput = value;
|
||||
break;
|
||||
default:
|
||||
this.conditionInput = value;
|
||||
}
|
||||
}
|
||||
|
||||
show(rangeOrPos: IRange | IPosition): void {
|
||||
const lineNum = this.input.getModel().getLineCount();
|
||||
super.show(rangeOrPos, lineNum + 1);
|
||||
}
|
||||
|
||||
fitHeightToContent(): void {
|
||||
const lineNum = this.input.getModel().getLineCount();
|
||||
this._relayout(lineNum + 1);
|
||||
}
|
||||
|
||||
protected _fillContainer(container: HTMLElement): void {
|
||||
this.setCssClass('breakpoint-widget');
|
||||
const selectBox = new SelectBox(<ISelectOptionItem[]>[{ text: nls.localize('expression', "Expression") }, { text: nls.localize('hitCount', "Hit Count") }, { text: nls.localize('logMessage', "Log Message") }], this.context, this.contextViewService, undefined, { ariaLabel: nls.localize('breakpointType', 'Breakpoint Type') });
|
||||
this.toDispose.push(attachSelectBoxStyler(selectBox, this.themeService));
|
||||
this.selectContainer = $('.breakpoint-select-container');
|
||||
selectBox.render(dom.append(container, this.selectContainer));
|
||||
selectBox.onDidSelect(e => {
|
||||
this.rememberInput();
|
||||
this.context = e.index;
|
||||
|
||||
const value = this.getInputValue(this.breakpoint);
|
||||
this.input.getModel().setValue(value);
|
||||
this.input.focus();
|
||||
});
|
||||
|
||||
this.inputContainer = $('.inputContainer');
|
||||
this.createBreakpointInput(dom.append(container, this.inputContainer));
|
||||
|
||||
this.input.getModel().setValue(this.getInputValue(this.breakpoint));
|
||||
this.toDispose.push(this.input.getModel().onDidChangeContent(() => {
|
||||
this.fitHeightToContent();
|
||||
}));
|
||||
this.input.setPosition({ lineNumber: 1, column: this.input.getModel().getLineMaxColumn(1) });
|
||||
// Due to an electron bug we have to do the timeout, otherwise we do not get focus
|
||||
setTimeout(() => this.input.focus(), 150);
|
||||
}
|
||||
|
||||
protected _doLayout(heightInPixel: number, widthInPixel: number): void {
|
||||
this.heightInPx = heightInPixel;
|
||||
this.input.layout({ height: heightInPixel, width: widthInPixel - 113 });
|
||||
this.centerInputVertically();
|
||||
}
|
||||
|
||||
private createBreakpointInput(container: HTMLElement): void {
|
||||
const scopedContextKeyService = this.contextKeyService.createScoped(container);
|
||||
this.toDispose.push(scopedContextKeyService);
|
||||
|
||||
const scopedInstatiationService = this.instantiationService.createChild(new ServiceCollection(
|
||||
[IContextKeyService, scopedContextKeyService], [IPrivateBreakpointWidgetService, this]));
|
||||
|
||||
const options = this.createEditorOptions();
|
||||
const codeEditorWidgetOptions = getSimpleCodeEditorWidgetOptions();
|
||||
this.input = <IActiveCodeEditor>scopedInstatiationService.createInstance(CodeEditorWidget, container, options, codeEditorWidgetOptions);
|
||||
CONTEXT_IN_BREAKPOINT_WIDGET.bindTo(scopedContextKeyService).set(true);
|
||||
const model = this.modelService.createModel('', null, uri.parse(`${DEBUG_SCHEME}:${this.editor.getId()}:breakpointinput`), true);
|
||||
this.input.setModel(model);
|
||||
this.toDispose.push(model);
|
||||
const setDecorations = () => {
|
||||
const value = this.input.getModel().getValue();
|
||||
const decorations = !!value ? [] : createDecorations(this.themeService.getColorTheme(), this.placeholder);
|
||||
this.input.setDecorations(DECORATION_KEY, decorations);
|
||||
};
|
||||
this.input.getModel().onDidChangeContent(() => setDecorations());
|
||||
this.themeService.onDidColorThemeChange(() => setDecorations());
|
||||
|
||||
this.toDispose.push(CompletionProviderRegistry.register({ scheme: DEBUG_SCHEME, hasAccessToAllModels: true }, {
|
||||
provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken): Promise<CompletionList> => {
|
||||
let suggestionsPromise: Promise<CompletionList>;
|
||||
const underlyingModel = this.editor.getModel();
|
||||
if (underlyingModel && (this.context === Context.CONDITION || (this.context === Context.LOG_MESSAGE && isCurlyBracketOpen(this.input)))) {
|
||||
suggestionsPromise = provideSuggestionItems(underlyingModel, new Position(this.lineNumber, 1), new CompletionOptions(undefined, new Set<CompletionItemKind>().add(CompletionItemKind.Snippet)), _context, token).then(suggestions => {
|
||||
|
||||
let overwriteBefore = 0;
|
||||
if (this.context === Context.CONDITION) {
|
||||
overwriteBefore = position.column - 1;
|
||||
} else {
|
||||
// Inside the currly brackets, need to count how many useful characters are behind the position so they would all be taken into account
|
||||
const value = this.input.getModel().getValue();
|
||||
while ((position.column - 2 - overwriteBefore >= 0) && value[position.column - 2 - overwriteBefore] !== '{' && value[position.column - 2 - overwriteBefore] !== ' ') {
|
||||
overwriteBefore++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: suggestions.items.map(s => {
|
||||
s.completion.range = Range.fromPositions(position.delta(0, -overwriteBefore), position);
|
||||
return s.completion;
|
||||
})
|
||||
};
|
||||
});
|
||||
} else {
|
||||
suggestionsPromise = Promise.resolve({ suggestions: [] });
|
||||
}
|
||||
|
||||
return suggestionsPromise;
|
||||
}
|
||||
}));
|
||||
|
||||
this.toDispose.push(this._configurationService.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('editor.fontSize') || e.affectsConfiguration('editor.lineHeight')) {
|
||||
this.input.updateOptions(this.createEditorOptions());
|
||||
this.centerInputVertically();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private createEditorOptions(): IEditorOptions {
|
||||
const editorConfig = this._configurationService.getValue<IEditorOptions>('editor');
|
||||
const options = getSimpleEditorOptions();
|
||||
options.fontSize = editorConfig.fontSize;
|
||||
return options;
|
||||
}
|
||||
|
||||
private centerInputVertically() {
|
||||
if (this.container && typeof this.heightInPx === 'number') {
|
||||
const lineHeight = this.input.getOption(EditorOption.lineHeight);
|
||||
const lineNum = this.input.getModel().getLineCount();
|
||||
const newTopMargin = (this.heightInPx - lineNum * lineHeight) / 2;
|
||||
this.inputContainer.style.marginTop = newTopMargin + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
close(success: boolean): void {
|
||||
if (success) {
|
||||
// if there is already a breakpoint on this location - remove it.
|
||||
|
||||
let condition = this.breakpoint && this.breakpoint.condition;
|
||||
let hitCondition = this.breakpoint && this.breakpoint.hitCondition;
|
||||
let logMessage = this.breakpoint && this.breakpoint.logMessage;
|
||||
this.rememberInput();
|
||||
|
||||
if (this.conditionInput || this.context === Context.CONDITION) {
|
||||
condition = this.conditionInput;
|
||||
}
|
||||
if (this.hitCountInput || this.context === Context.HIT_COUNT) {
|
||||
hitCondition = this.hitCountInput;
|
||||
}
|
||||
if (this.logMessageInput || this.context === Context.LOG_MESSAGE) {
|
||||
logMessage = this.logMessageInput;
|
||||
}
|
||||
|
||||
if (this.breakpoint) {
|
||||
const data = new Map<string, IBreakpointUpdateData>();
|
||||
data.set(this.breakpoint.getId(), {
|
||||
condition,
|
||||
hitCondition,
|
||||
logMessage
|
||||
});
|
||||
this.debugService.updateBreakpoints(this.breakpoint.uri, data, false).then(undefined, onUnexpectedError);
|
||||
} else {
|
||||
const model = this.editor.getModel();
|
||||
if (model) {
|
||||
this.debugService.addBreakpoints(model.uri, [{
|
||||
lineNumber: this.lineNumber,
|
||||
column: this.column,
|
||||
enabled: true,
|
||||
condition,
|
||||
hitCondition,
|
||||
logMessage
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
super.dispose();
|
||||
this.input.dispose();
|
||||
lifecycle.dispose(this.toDispose);
|
||||
setTimeout(() => this.editor.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
class AcceptBreakpointWidgetInputAction extends EditorCommand {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'breakpointWidget.action.acceptInput',
|
||||
precondition: CONTEXT_BREAKPOINT_WIDGET_VISIBLE,
|
||||
kbOpts: {
|
||||
kbExpr: CONTEXT_IN_BREAKPOINT_WIDGET,
|
||||
primary: KeyCode.Enter,
|
||||
weight: KeybindingWeight.EditorContrib
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void {
|
||||
accessor.get(IPrivateBreakpointWidgetService).close(true);
|
||||
}
|
||||
}
|
||||
|
||||
class CloseBreakpointWidgetCommand extends EditorCommand {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'closeBreakpointWidget',
|
||||
precondition: CONTEXT_BREAKPOINT_WIDGET_VISIBLE,
|
||||
kbOpts: {
|
||||
kbExpr: EditorContextKeys.textInputFocus,
|
||||
primary: KeyCode.Escape,
|
||||
secondary: [KeyMod.Shift | KeyCode.Escape],
|
||||
weight: KeybindingWeight.EditorContrib
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, args: any): void {
|
||||
const debugContribution = editor.getContribution<IBreakpointEditorContribution>(BREAKPOINT_EDITOR_CONTRIBUTION_ID);
|
||||
if (debugContribution) {
|
||||
// if focus is in outer editor we need to use the debug contribution to close
|
||||
return debugContribution.closeBreakpointWidget();
|
||||
}
|
||||
|
||||
accessor.get(IPrivateBreakpointWidgetService).close(false);
|
||||
}
|
||||
}
|
||||
|
||||
registerEditorCommand(new AcceptBreakpointWidgetInputAction());
|
||||
registerEditorCommand(new CloseBreakpointWidgetCommand());
|
||||
@@ -0,0 +1,782 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as nls from 'vs/nls';
|
||||
import * as resources from 'vs/base/common/resources';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { IAction, Action, Separator } from 'vs/base/common/actions';
|
||||
import { IDebugService, IBreakpoint, CONTEXT_BREAKPOINTS_FOCUSED, State, DEBUG_SCHEME, IFunctionBreakpoint, IExceptionBreakpoint, IEnablement, BREAKPOINT_EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution, IDebugModel, IDataBreakpoint } from 'vs/workbench/contrib/debug/common/debug';
|
||||
import { ExceptionBreakpoint, FunctionBreakpoint, Breakpoint, DataBreakpoint } from 'vs/workbench/contrib/debug/common/debugModel';
|
||||
import { AddFunctionBreakpointAction, ToggleBreakpointsActivatedAction, RemoveAllBreakpointsAction, RemoveBreakpointAction, EnableAllBreakpointsAction, DisableAllBreakpointsAction, ReapplyBreakpointsAction } from 'vs/workbench/contrib/debug/browser/debugActions';
|
||||
import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView';
|
||||
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
||||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { IThemeService } from 'vs/platform/theme/common/themeService';
|
||||
import { Constants } from 'vs/base/common/uint';
|
||||
import { dispose, IDisposable } from 'vs/base/common/lifecycle';
|
||||
import { IListVirtualDelegate, IListContextMenuEvent, IListRenderer } from 'vs/base/browser/ui/list/list';
|
||||
import { IEditorPane } from 'vs/workbench/common/editor';
|
||||
import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox';
|
||||
import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent';
|
||||
import { KeyCode } from 'vs/base/common/keyCodes';
|
||||
import { WorkbenchList, ListResourceNavigator } from 'vs/platform/list/browser/listService';
|
||||
import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet';
|
||||
import { attachInputBoxStyler } from 'vs/platform/theme/common/styler';
|
||||
import { isCodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
|
||||
import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { ViewPane } from 'vs/workbench/browser/parts/views/viewPaneContainer';
|
||||
import { ILabelService } from 'vs/platform/label/common/label';
|
||||
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
|
||||
import { Gesture } from 'vs/base/browser/touch';
|
||||
import { IViewDescriptorService } from 'vs/workbench/common/views';
|
||||
import { TextEditorSelectionRevealType } from 'vs/platform/editor/common/editor';
|
||||
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
||||
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
||||
import { Orientation } from 'vs/base/browser/ui/splitview/splitview';
|
||||
import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
function createCheckbox(): HTMLInputElement {
|
||||
const checkbox = <HTMLInputElement>$('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.tabIndex = -1;
|
||||
Gesture.ignoreTarget(checkbox);
|
||||
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_BREAKPOINTS = 9;
|
||||
export function getExpandedBodySize(model: IDebugModel, countLimit: number): number {
|
||||
const length = model.getBreakpoints().length + model.getExceptionBreakpoints().length + model.getFunctionBreakpoints().length + model.getDataBreakpoints().length;
|
||||
return Math.min(countLimit, length) * 22;
|
||||
}
|
||||
type BreakpointItem = IBreakpoint | IFunctionBreakpoint | IDataBreakpoint | IExceptionBreakpoint;
|
||||
|
||||
export class BreakpointsView extends ViewPane {
|
||||
|
||||
private list!: WorkbenchList<BreakpointItem>;
|
||||
private needsRefresh = false;
|
||||
private ignoreLayout = false;
|
||||
|
||||
constructor(
|
||||
options: IViewletViewOptions,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IEditorService private readonly editorService: IEditorService,
|
||||
@IContextViewService private readonly contextViewService: IContextViewService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
@ILabelService private readonly labelService: ILabelService,
|
||||
) {
|
||||
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService);
|
||||
|
||||
this._register(this.debugService.getModel().onDidChangeBreakpoints(() => this.onBreakpointsChange()));
|
||||
}
|
||||
|
||||
public renderBody(container: HTMLElement): void {
|
||||
super.renderBody(container);
|
||||
|
||||
this.element.classList.add('debug-pane');
|
||||
container.classList.add('debug-breakpoints');
|
||||
const delegate = new BreakpointsDelegate(this.debugService);
|
||||
|
||||
this.list = <WorkbenchList<BreakpointItem>>this.instantiationService.createInstance(WorkbenchList, 'Breakpoints', container, delegate, [
|
||||
this.instantiationService.createInstance(BreakpointsRenderer),
|
||||
new ExceptionBreakpointsRenderer(this.debugService),
|
||||
this.instantiationService.createInstance(FunctionBreakpointsRenderer),
|
||||
this.instantiationService.createInstance(DataBreakpointsRenderer),
|
||||
new FunctionBreakpointInputRenderer(this.debugService, this.contextViewService, this.themeService, this.labelService)
|
||||
], {
|
||||
identityProvider: { getId: (element: IEnablement) => element.getId() },
|
||||
multipleSelectionSupport: false,
|
||||
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: IEnablement) => e },
|
||||
accessibilityProvider: new BreakpointsAccessibilityProvider(this.debugService, this.labelService),
|
||||
overrideStyles: {
|
||||
listBackground: this.getBackgroundColor()
|
||||
}
|
||||
});
|
||||
|
||||
CONTEXT_BREAKPOINTS_FOCUSED.bindTo(this.list.contextKeyService);
|
||||
|
||||
this._register(this.list.onContextMenu(this.onListContextMenu, this));
|
||||
|
||||
this.list.onMouseMiddleClick(async ({ element }) => {
|
||||
if (element instanceof Breakpoint) {
|
||||
await this.debugService.removeBreakpoints(element.getId());
|
||||
} else if (element instanceof FunctionBreakpoint) {
|
||||
await this.debugService.removeFunctionBreakpoints(element.getId());
|
||||
} else if (element instanceof DataBreakpoint) {
|
||||
await this.debugService.removeDataBreakpoints(element.getId());
|
||||
}
|
||||
});
|
||||
|
||||
const resourceNavigator = this._register(new ListResourceNavigator(this.list, { configurationService: this.configurationService }));
|
||||
this._register(resourceNavigator.onDidOpen(async e => {
|
||||
if (e.element === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.browserEvent instanceof MouseEvent && e.browserEvent.button === 1) { // middle click
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this.list.element(e.element);
|
||||
|
||||
if (element instanceof Breakpoint) {
|
||||
openBreakpointSource(element, e.sideBySide, e.editorOptions.preserveFocus || false, this.debugService, this.editorService);
|
||||
}
|
||||
if (e.browserEvent instanceof MouseEvent && e.browserEvent.detail === 2 && element instanceof FunctionBreakpoint && element !== this.debugService.getViewModel().getSelectedFunctionBreakpoint()) {
|
||||
// double click
|
||||
this.debugService.getViewModel().setSelectedFunctionBreakpoint(element);
|
||||
this.onBreakpointsChange();
|
||||
}
|
||||
}));
|
||||
|
||||
this.list.splice(0, this.list.length, this.elements);
|
||||
|
||||
this._register(this.onDidChangeBodyVisibility(visible => {
|
||||
if (visible && this.needsRefresh) {
|
||||
this.onBreakpointsChange();
|
||||
}
|
||||
}));
|
||||
|
||||
const containerModel = this.viewDescriptorService.getViewContainerModel(this.viewDescriptorService.getViewContainerByViewId(this.id)!)!;
|
||||
this._register(containerModel.onDidChangeAllViewDescriptors(() => {
|
||||
this.updateSize();
|
||||
}));
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
super.focus();
|
||||
if (this.list) {
|
||||
this.list.domFocus();
|
||||
}
|
||||
}
|
||||
|
||||
protected layoutBody(height: number, width: number): void {
|
||||
if (this.ignoreLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
super.layoutBody(height, width);
|
||||
if (this.list) {
|
||||
this.list.layout(height, width);
|
||||
}
|
||||
try {
|
||||
this.ignoreLayout = true;
|
||||
this.updateSize();
|
||||
} finally {
|
||||
this.ignoreLayout = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onListContextMenu(e: IListContextMenuEvent<IEnablement>): void {
|
||||
if (!e.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions: IAction[] = [];
|
||||
const element = e.element;
|
||||
|
||||
const breakpointType = element instanceof Breakpoint && element.logMessage ? nls.localize('Logpoint', "Logpoint") : nls.localize('Breakpoint', "Breakpoint");
|
||||
if (element instanceof Breakpoint || element instanceof FunctionBreakpoint) {
|
||||
actions.push(new Action('workbench.action.debug.openEditorAndEditBreakpoint', nls.localize('editBreakpoint', "Edit {0}...", breakpointType), '', true, async () => {
|
||||
if (element instanceof Breakpoint) {
|
||||
const editor = await openBreakpointSource(element, false, false, this.debugService, this.editorService);
|
||||
if (editor) {
|
||||
const codeEditor = editor.getControl();
|
||||
if (isCodeEditor(codeEditor)) {
|
||||
codeEditor.getContribution<IBreakpointEditorContribution>(BREAKPOINT_EDITOR_CONTRIBUTION_ID).showBreakpointWidget(element.lineNumber, element.column);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.debugService.getViewModel().setSelectedFunctionBreakpoint(element);
|
||||
this.onBreakpointsChange();
|
||||
}
|
||||
}));
|
||||
actions.push(new Separator());
|
||||
}
|
||||
|
||||
actions.push(new RemoveBreakpointAction(RemoveBreakpointAction.ID, nls.localize('removeBreakpoint', "Remove {0}", breakpointType), this.debugService));
|
||||
|
||||
if (this.debugService.getModel().getBreakpoints().length + this.debugService.getModel().getFunctionBreakpoints().length > 1) {
|
||||
actions.push(new RemoveAllBreakpointsAction(RemoveAllBreakpointsAction.ID, RemoveAllBreakpointsAction.LABEL, this.debugService, this.keybindingService));
|
||||
actions.push(new Separator());
|
||||
|
||||
actions.push(new EnableAllBreakpointsAction(EnableAllBreakpointsAction.ID, EnableAllBreakpointsAction.LABEL, this.debugService, this.keybindingService));
|
||||
actions.push(new DisableAllBreakpointsAction(DisableAllBreakpointsAction.ID, DisableAllBreakpointsAction.LABEL, this.debugService, this.keybindingService));
|
||||
}
|
||||
|
||||
actions.push(new Separator());
|
||||
actions.push(new ReapplyBreakpointsAction(ReapplyBreakpointsAction.ID, ReapplyBreakpointsAction.LABEL, this.debugService, this.keybindingService));
|
||||
|
||||
this.contextMenuService.showContextMenu({
|
||||
getAnchor: () => e.anchor,
|
||||
getActions: () => actions,
|
||||
getActionsContext: () => element,
|
||||
onHide: () => dispose(actions)
|
||||
});
|
||||
}
|
||||
|
||||
public getActions(): IAction[] {
|
||||
return [
|
||||
new AddFunctionBreakpointAction(AddFunctionBreakpointAction.ID, AddFunctionBreakpointAction.LABEL, this.debugService, this.keybindingService),
|
||||
new ToggleBreakpointsActivatedAction(ToggleBreakpointsActivatedAction.ID, ToggleBreakpointsActivatedAction.ACTIVATE_LABEL, this.debugService, this.keybindingService),
|
||||
new RemoveAllBreakpointsAction(RemoveAllBreakpointsAction.ID, RemoveAllBreakpointsAction.LABEL, this.debugService, this.keybindingService)
|
||||
];
|
||||
}
|
||||
|
||||
private updateSize(): void {
|
||||
const containerModel = this.viewDescriptorService.getViewContainerModel(this.viewDescriptorService.getViewContainerByViewId(this.id)!)!;
|
||||
|
||||
// Adjust expanded body size
|
||||
this.minimumBodySize = this.orientation === Orientation.VERTICAL ? getExpandedBodySize(this.debugService.getModel(), MAX_VISIBLE_BREAKPOINTS) : 170;
|
||||
this.maximumBodySize = this.orientation === Orientation.VERTICAL && containerModel.visibleViewDescriptors.length > 1 ? getExpandedBodySize(this.debugService.getModel(), Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
private onBreakpointsChange(): void {
|
||||
if (this.isBodyVisible()) {
|
||||
this.updateSize();
|
||||
if (this.list) {
|
||||
const lastFocusIndex = this.list.getFocus()[0];
|
||||
// Check whether focused element was removed
|
||||
const needsRefocus = lastFocusIndex && !this.elements.includes(this.list.element(lastFocusIndex));
|
||||
this.list.splice(0, this.list.length, this.elements);
|
||||
this.needsRefresh = false;
|
||||
if (needsRefocus) {
|
||||
this.list.focusNth(Math.min(lastFocusIndex, this.list.length - 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.needsRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
private get elements(): BreakpointItem[] {
|
||||
const model = this.debugService.getModel();
|
||||
const elements = (<ReadonlyArray<IEnablement>>model.getExceptionBreakpoints()).concat(model.getFunctionBreakpoints()).concat(model.getDataBreakpoints()).concat(model.getBreakpoints());
|
||||
|
||||
return elements as BreakpointItem[];
|
||||
}
|
||||
}
|
||||
|
||||
class BreakpointsDelegate implements IListVirtualDelegate<BreakpointItem> {
|
||||
|
||||
constructor(private debugService: IDebugService) {
|
||||
// noop
|
||||
}
|
||||
|
||||
getHeight(_element: BreakpointItem): number {
|
||||
return 22;
|
||||
}
|
||||
|
||||
getTemplateId(element: BreakpointItem): string {
|
||||
if (element instanceof Breakpoint) {
|
||||
return BreakpointsRenderer.ID;
|
||||
}
|
||||
if (element instanceof FunctionBreakpoint) {
|
||||
const selected = this.debugService.getViewModel().getSelectedFunctionBreakpoint();
|
||||
if (!element.name || (selected && selected.getId() === element.getId())) {
|
||||
return FunctionBreakpointInputRenderer.ID;
|
||||
}
|
||||
|
||||
return FunctionBreakpointsRenderer.ID;
|
||||
}
|
||||
if (element instanceof ExceptionBreakpoint) {
|
||||
return ExceptionBreakpointsRenderer.ID;
|
||||
}
|
||||
if (element instanceof DataBreakpoint) {
|
||||
return DataBreakpointsRenderer.ID;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface IBaseBreakpointTemplateData {
|
||||
breakpoint: HTMLElement;
|
||||
name: HTMLElement;
|
||||
checkbox: HTMLInputElement;
|
||||
context: BreakpointItem;
|
||||
toDispose: IDisposable[];
|
||||
}
|
||||
|
||||
interface IBaseBreakpointWithIconTemplateData extends IBaseBreakpointTemplateData {
|
||||
icon: HTMLElement;
|
||||
}
|
||||
|
||||
interface IBreakpointTemplateData extends IBaseBreakpointWithIconTemplateData {
|
||||
lineNumber: HTMLElement;
|
||||
filePath: HTMLElement;
|
||||
}
|
||||
|
||||
interface IInputTemplateData {
|
||||
inputBox: InputBox;
|
||||
checkbox: HTMLInputElement;
|
||||
icon: HTMLElement;
|
||||
breakpoint: IFunctionBreakpoint;
|
||||
reactedOnEvent: boolean;
|
||||
toDispose: IDisposable[];
|
||||
}
|
||||
|
||||
class BreakpointsRenderer implements IListRenderer<IBreakpoint, IBreakpointTemplateData> {
|
||||
|
||||
constructor(
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@ILabelService private readonly labelService: ILabelService
|
||||
) {
|
||||
// noop
|
||||
}
|
||||
|
||||
static readonly ID = 'breakpoints';
|
||||
|
||||
get templateId() {
|
||||
return BreakpointsRenderer.ID;
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): IBreakpointTemplateData {
|
||||
const data: IBreakpointTemplateData = Object.create(null);
|
||||
data.breakpoint = dom.append(container, $('.breakpoint'));
|
||||
|
||||
data.icon = $('.icon');
|
||||
data.checkbox = createCheckbox();
|
||||
data.toDispose = [];
|
||||
data.toDispose.push(dom.addStandardDisposableListener(data.checkbox, 'change', (e) => {
|
||||
this.debugService.enableOrDisableBreakpoints(!data.context.enabled, data.context);
|
||||
}));
|
||||
|
||||
dom.append(data.breakpoint, data.icon);
|
||||
dom.append(data.breakpoint, data.checkbox);
|
||||
|
||||
data.name = dom.append(data.breakpoint, $('span.name'));
|
||||
|
||||
data.filePath = dom.append(data.breakpoint, $('span.file-path'));
|
||||
const lineNumberContainer = dom.append(data.breakpoint, $('.line-number-container'));
|
||||
data.lineNumber = dom.append(lineNumberContainer, $('span.line-number.monaco-count-badge'));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(breakpoint: IBreakpoint, index: number, data: IBreakpointTemplateData): void {
|
||||
data.context = breakpoint;
|
||||
data.breakpoint.classList.toggle('disabled', !this.debugService.getModel().areBreakpointsActivated());
|
||||
|
||||
data.name.textContent = resources.basenameOrAuthority(breakpoint.uri);
|
||||
data.lineNumber.textContent = breakpoint.lineNumber.toString();
|
||||
if (breakpoint.column) {
|
||||
data.lineNumber.textContent += `:${breakpoint.column}`;
|
||||
}
|
||||
data.filePath.textContent = this.labelService.getUriLabel(resources.dirname(breakpoint.uri), { relative: true });
|
||||
data.checkbox.checked = breakpoint.enabled;
|
||||
|
||||
const { message, className } = getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), breakpoint, this.labelService);
|
||||
data.icon.className = `codicon ${className}`;
|
||||
data.breakpoint.title = breakpoint.message || message || '';
|
||||
|
||||
const debugActive = this.debugService.state === State.Running || this.debugService.state === State.Stopped;
|
||||
if (debugActive && !breakpoint.verified) {
|
||||
data.breakpoint.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IBreakpointTemplateData): void {
|
||||
dispose(templateData.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class ExceptionBreakpointsRenderer implements IListRenderer<IExceptionBreakpoint, IBaseBreakpointTemplateData> {
|
||||
|
||||
constructor(
|
||||
private debugService: IDebugService
|
||||
) {
|
||||
// noop
|
||||
}
|
||||
|
||||
static readonly ID = 'exceptionbreakpoints';
|
||||
|
||||
get templateId() {
|
||||
return ExceptionBreakpointsRenderer.ID;
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): IBaseBreakpointTemplateData {
|
||||
const data: IBreakpointTemplateData = Object.create(null);
|
||||
data.breakpoint = dom.append(container, $('.breakpoint'));
|
||||
|
||||
data.checkbox = createCheckbox();
|
||||
data.toDispose = [];
|
||||
data.toDispose.push(dom.addStandardDisposableListener(data.checkbox, 'change', (e) => {
|
||||
this.debugService.enableOrDisableBreakpoints(!data.context.enabled, data.context);
|
||||
}));
|
||||
|
||||
dom.append(data.breakpoint, data.checkbox);
|
||||
|
||||
data.name = dom.append(data.breakpoint, $('span.name'));
|
||||
data.breakpoint.classList.add('exception');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(exceptionBreakpoint: IExceptionBreakpoint, index: number, data: IBaseBreakpointTemplateData): void {
|
||||
data.context = exceptionBreakpoint;
|
||||
data.name.textContent = exceptionBreakpoint.label || `${exceptionBreakpoint.filter} exceptions`;
|
||||
data.breakpoint.title = data.name.textContent;
|
||||
data.checkbox.checked = exceptionBreakpoint.enabled;
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IBaseBreakpointTemplateData): void {
|
||||
dispose(templateData.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class FunctionBreakpointsRenderer implements IListRenderer<FunctionBreakpoint, IBaseBreakpointWithIconTemplateData> {
|
||||
|
||||
constructor(
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@ILabelService private readonly labelService: ILabelService
|
||||
) {
|
||||
// noop
|
||||
}
|
||||
|
||||
static readonly ID = 'functionbreakpoints';
|
||||
|
||||
get templateId() {
|
||||
return FunctionBreakpointsRenderer.ID;
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): IBaseBreakpointWithIconTemplateData {
|
||||
const data: IBreakpointTemplateData = Object.create(null);
|
||||
data.breakpoint = dom.append(container, $('.breakpoint'));
|
||||
|
||||
data.icon = $('.icon');
|
||||
data.checkbox = createCheckbox();
|
||||
data.toDispose = [];
|
||||
data.toDispose.push(dom.addStandardDisposableListener(data.checkbox, 'change', (e) => {
|
||||
this.debugService.enableOrDisableBreakpoints(!data.context.enabled, data.context);
|
||||
}));
|
||||
|
||||
dom.append(data.breakpoint, data.icon);
|
||||
dom.append(data.breakpoint, data.checkbox);
|
||||
|
||||
data.name = dom.append(data.breakpoint, $('span.name'));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(functionBreakpoint: FunctionBreakpoint, _index: number, data: IBaseBreakpointWithIconTemplateData): void {
|
||||
data.context = functionBreakpoint;
|
||||
data.name.textContent = functionBreakpoint.name;
|
||||
const { className, message } = getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), functionBreakpoint, this.labelService);
|
||||
data.icon.className = `codicon ${className}`;
|
||||
data.icon.title = message ? message : '';
|
||||
data.checkbox.checked = functionBreakpoint.enabled;
|
||||
data.breakpoint.title = message ? message : '';
|
||||
|
||||
// Mark function breakpoints as disabled if deactivated or if debug type does not support them #9099
|
||||
const session = this.debugService.getViewModel().focusedSession;
|
||||
data.breakpoint.classList.toggle('disabled', (session && !session.capabilities.supportsFunctionBreakpoints) || !this.debugService.getModel().areBreakpointsActivated());
|
||||
if (session && !session.capabilities.supportsFunctionBreakpoints) {
|
||||
data.breakpoint.title = nls.localize('functionBreakpointsNotSupported', "Function breakpoints are not supported by this debug type");
|
||||
}
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IBaseBreakpointWithIconTemplateData): void {
|
||||
dispose(templateData.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class DataBreakpointsRenderer implements IListRenderer<DataBreakpoint, IBaseBreakpointWithIconTemplateData> {
|
||||
|
||||
constructor(
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@ILabelService private readonly labelService: ILabelService
|
||||
) {
|
||||
// noop
|
||||
}
|
||||
|
||||
static readonly ID = 'databreakpoints';
|
||||
|
||||
get templateId() {
|
||||
return DataBreakpointsRenderer.ID;
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): IBaseBreakpointWithIconTemplateData {
|
||||
const data: IBreakpointTemplateData = Object.create(null);
|
||||
data.breakpoint = dom.append(container, $('.breakpoint'));
|
||||
|
||||
data.icon = $('.icon');
|
||||
data.checkbox = createCheckbox();
|
||||
data.toDispose = [];
|
||||
data.toDispose.push(dom.addStandardDisposableListener(data.checkbox, 'change', (e) => {
|
||||
this.debugService.enableOrDisableBreakpoints(!data.context.enabled, data.context);
|
||||
}));
|
||||
|
||||
dom.append(data.breakpoint, data.icon);
|
||||
dom.append(data.breakpoint, data.checkbox);
|
||||
|
||||
data.name = dom.append(data.breakpoint, $('span.name'));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
renderElement(dataBreakpoint: DataBreakpoint, _index: number, data: IBaseBreakpointWithIconTemplateData): void {
|
||||
data.context = dataBreakpoint;
|
||||
data.name.textContent = dataBreakpoint.description;
|
||||
const { className, message } = getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), dataBreakpoint, this.labelService);
|
||||
data.icon.className = `codicon ${className}`;
|
||||
data.icon.title = message ? message : '';
|
||||
data.checkbox.checked = dataBreakpoint.enabled;
|
||||
data.breakpoint.title = message ? message : '';
|
||||
|
||||
// Mark function breakpoints as disabled if deactivated or if debug type does not support them #9099
|
||||
const session = this.debugService.getViewModel().focusedSession;
|
||||
data.breakpoint.classList.toggle('disabled', (session && !session.capabilities.supportsDataBreakpoints) || !this.debugService.getModel().areBreakpointsActivated());
|
||||
if (session && !session.capabilities.supportsDataBreakpoints) {
|
||||
data.breakpoint.title = nls.localize('dataBreakpointsNotSupported', "Data breakpoints are not supported by this debug type");
|
||||
}
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IBaseBreakpointWithIconTemplateData): void {
|
||||
dispose(templateData.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class FunctionBreakpointInputRenderer implements IListRenderer<IFunctionBreakpoint, IInputTemplateData> {
|
||||
|
||||
constructor(
|
||||
private debugService: IDebugService,
|
||||
private contextViewService: IContextViewService,
|
||||
private themeService: IThemeService,
|
||||
private labelService: ILabelService
|
||||
) {
|
||||
// noop
|
||||
}
|
||||
|
||||
static readonly ID = 'functionbreakpointinput';
|
||||
|
||||
get templateId() {
|
||||
return FunctionBreakpointInputRenderer.ID;
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): IInputTemplateData {
|
||||
const template: IInputTemplateData = Object.create(null);
|
||||
|
||||
const breakpoint = dom.append(container, $('.breakpoint'));
|
||||
template.icon = $('.icon');
|
||||
template.checkbox = createCheckbox();
|
||||
|
||||
dom.append(breakpoint, template.icon);
|
||||
dom.append(breakpoint, template.checkbox);
|
||||
const inputBoxContainer = dom.append(breakpoint, $('.inputBoxContainer'));
|
||||
const inputBox = new InputBox(inputBoxContainer, this.contextViewService, {
|
||||
placeholder: nls.localize('functionBreakpointPlaceholder', "Function to break on"),
|
||||
ariaLabel: nls.localize('functionBreakPointInputAriaLabel', "Type function breakpoint")
|
||||
});
|
||||
const styler = attachInputBoxStyler(inputBox, this.themeService);
|
||||
const toDispose: IDisposable[] = [inputBox, styler];
|
||||
|
||||
const wrapUp = (renamed: boolean) => {
|
||||
if (!template.reactedOnEvent) {
|
||||
template.reactedOnEvent = true;
|
||||
this.debugService.getViewModel().setSelectedFunctionBreakpoint(undefined);
|
||||
if (inputBox.value && (renamed || template.breakpoint.name)) {
|
||||
this.debugService.renameFunctionBreakpoint(template.breakpoint.getId(), renamed ? inputBox.value : template.breakpoint.name);
|
||||
} else {
|
||||
this.debugService.removeFunctionBreakpoints(template.breakpoint.getId());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toDispose.push(dom.addStandardDisposableListener(inputBox.inputElement, 'keydown', (e: IKeyboardEvent) => {
|
||||
const isEscape = e.equals(KeyCode.Escape);
|
||||
const isEnter = e.equals(KeyCode.Enter);
|
||||
if (isEscape || isEnter) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
wrapUp(isEnter);
|
||||
}
|
||||
}));
|
||||
toDispose.push(dom.addDisposableListener(inputBox.inputElement, 'blur', () => {
|
||||
// Need to react with a timeout on the blur event due to possible concurent splices #56443
|
||||
setTimeout(() => {
|
||||
if (!template.breakpoint.name) {
|
||||
wrapUp(true);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
template.inputBox = inputBox;
|
||||
template.toDispose = toDispose;
|
||||
return template;
|
||||
}
|
||||
|
||||
renderElement(functionBreakpoint: FunctionBreakpoint, _index: number, data: IInputTemplateData): void {
|
||||
data.breakpoint = functionBreakpoint;
|
||||
data.reactedOnEvent = false;
|
||||
const { className, message } = getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), functionBreakpoint, this.labelService);
|
||||
|
||||
data.icon.className = `codicon ${className}`;
|
||||
data.icon.title = message ? message : '';
|
||||
data.checkbox.checked = functionBreakpoint.enabled;
|
||||
data.checkbox.disabled = true;
|
||||
data.inputBox.value = functionBreakpoint.name || '';
|
||||
setTimeout(() => {
|
||||
data.inputBox.focus();
|
||||
data.inputBox.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
disposeTemplate(templateData: IInputTemplateData): void {
|
||||
dispose(templateData.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
class BreakpointsAccessibilityProvider implements IListAccessibilityProvider<BreakpointItem> {
|
||||
|
||||
constructor(
|
||||
private readonly debugService: IDebugService,
|
||||
private readonly labelService: ILabelService
|
||||
) { }
|
||||
|
||||
getWidgetAriaLabel(): string {
|
||||
return nls.localize('breakpoints', "Breakpoints");
|
||||
}
|
||||
|
||||
getRole() {
|
||||
return 'checkbox';
|
||||
}
|
||||
|
||||
isChecked(breakpoint: IEnablement) {
|
||||
return breakpoint.enabled;
|
||||
}
|
||||
|
||||
getAriaLabel(element: BreakpointItem): string | null {
|
||||
if (element instanceof ExceptionBreakpoint) {
|
||||
return element.toString();
|
||||
}
|
||||
|
||||
const { message } = getBreakpointMessageAndClassName(this.debugService.state, this.debugService.getModel().areBreakpointsActivated(), element as IBreakpoint | IDataBreakpoint | IFunctionBreakpoint, this.labelService);
|
||||
const toString = element.toString();
|
||||
|
||||
return message ? `${toString}, ${message}` : toString;
|
||||
}
|
||||
}
|
||||
|
||||
export function openBreakpointSource(breakpoint: IBreakpoint, sideBySide: boolean, preserveFocus: boolean, debugService: IDebugService, editorService: IEditorService): Promise<IEditorPane | undefined> {
|
||||
if (breakpoint.uri.scheme === DEBUG_SCHEME && debugService.state === State.Inactive) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const selection = breakpoint.endLineNumber ? {
|
||||
startLineNumber: breakpoint.lineNumber,
|
||||
endLineNumber: breakpoint.endLineNumber,
|
||||
startColumn: breakpoint.column || 1,
|
||||
endColumn: breakpoint.endColumn || Constants.MAX_SAFE_SMALL_INTEGER
|
||||
} : {
|
||||
startLineNumber: breakpoint.lineNumber,
|
||||
startColumn: breakpoint.column || 1,
|
||||
endLineNumber: breakpoint.lineNumber,
|
||||
endColumn: breakpoint.column || Constants.MAX_SAFE_SMALL_INTEGER
|
||||
};
|
||||
|
||||
return editorService.openEditor({
|
||||
resource: breakpoint.uri,
|
||||
options: {
|
||||
preserveFocus,
|
||||
selection,
|
||||
revealIfOpened: true,
|
||||
selectionRevealType: TextEditorSelectionRevealType.CenterIfOutsideViewport,
|
||||
pinned: !preserveFocus
|
||||
}
|
||||
}, sideBySide ? SIDE_GROUP : ACTIVE_GROUP);
|
||||
}
|
||||
|
||||
export function getBreakpointMessageAndClassName(state: State, breakpointsActivated: boolean, breakpoint: IBreakpoint | IFunctionBreakpoint | IDataBreakpoint, labelService?: ILabelService): { message?: string, className: string } {
|
||||
const debugActive = state === State.Running || state === State.Stopped;
|
||||
|
||||
if (!breakpoint.enabled || !breakpointsActivated) {
|
||||
return {
|
||||
className: breakpoint instanceof DataBreakpoint ? 'codicon-debug-breakpoint-data-disabled' : breakpoint instanceof FunctionBreakpoint ? 'codicon-debug-breakpoint-function-disabled' : breakpoint.logMessage ? 'codicon-debug-breakpoint-log-disabled' : 'codicon-debug-breakpoint-disabled',
|
||||
message: breakpoint.logMessage ? nls.localize('disabledLogpoint', "Disabled Logpoint") : nls.localize('disabledBreakpoint', "Disabled Breakpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
const appendMessage = (text: string): string => {
|
||||
return ('message' in breakpoint && breakpoint.message) ? text.concat(', ' + breakpoint.message) : text;
|
||||
};
|
||||
if (debugActive && !breakpoint.verified) {
|
||||
return {
|
||||
className: breakpoint instanceof DataBreakpoint ? 'codicon-debug-breakpoint-data-unverified' : breakpoint instanceof FunctionBreakpoint ? 'codicon-debug-breakpoint-function-unverified' : breakpoint.logMessage ? 'codicon-debug-breakpoint-log-unverified' : 'codicon-debug-breakpoint-unverified',
|
||||
message: ('message' in breakpoint && breakpoint.message) ? breakpoint.message : (breakpoint.logMessage ? nls.localize('unverifiedLogpoint', "Unverified Logpoint") : nls.localize('unverifiedBreakopint', "Unverified Breakpoint")),
|
||||
};
|
||||
}
|
||||
|
||||
if (breakpoint instanceof FunctionBreakpoint) {
|
||||
if (!breakpoint.supported) {
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint-function-unverified',
|
||||
message: nls.localize('functionBreakpointUnsupported', "Function breakpoints not supported by this debug type"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint-function',
|
||||
message: breakpoint.message || nls.localize('functionBreakpoint', "Function Breakpoint")
|
||||
};
|
||||
}
|
||||
|
||||
if (breakpoint instanceof DataBreakpoint) {
|
||||
if (!breakpoint.supported) {
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint-data-unverified',
|
||||
message: nls.localize('dataBreakpointUnsupported', "Data breakpoints not supported by this debug type"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint-data',
|
||||
message: breakpoint.message || nls.localize('dataBreakpoint', "Data Breakpoint")
|
||||
};
|
||||
}
|
||||
|
||||
if (breakpoint.logMessage || breakpoint.condition || breakpoint.hitCondition) {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (!breakpoint.supported) {
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint-unsupported',
|
||||
message: nls.localize('breakpointUnsupported', "Breakpoints of this type are not supported by the debugger"),
|
||||
};
|
||||
}
|
||||
|
||||
if (breakpoint.logMessage) {
|
||||
messages.push(nls.localize('logMessage', "Log Message: {0}", breakpoint.logMessage));
|
||||
}
|
||||
if (breakpoint.condition) {
|
||||
messages.push(nls.localize('expression', "Expression: {0}", breakpoint.condition));
|
||||
}
|
||||
if (breakpoint.hitCondition) {
|
||||
messages.push(nls.localize('hitCount', "Hit Count: {0}", breakpoint.hitCondition));
|
||||
}
|
||||
|
||||
return {
|
||||
className: breakpoint.logMessage ? 'codicon-debug-breakpoint-log' : 'codicon-debug-breakpoint-conditional',
|
||||
message: appendMessage(messages.join('\n'))
|
||||
};
|
||||
}
|
||||
|
||||
const message = ('message' in breakpoint && breakpoint.message) ? breakpoint.message : breakpoint instanceof Breakpoint && labelService ? labelService.getUriLabel(breakpoint.uri) : nls.localize('breakpoint', "Breakpoint");
|
||||
return {
|
||||
className: 'codicon-debug-breakpoint',
|
||||
message
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Constants } from 'vs/base/common/uint';
|
||||
import { Range, IRange } from 'vs/editor/common/core/range';
|
||||
import { TrackedRangeStickiness, IModelDeltaDecoration, IModelDecorationOptions } from 'vs/editor/common/model';
|
||||
import { IDebugService, IStackFrame } from 'vs/workbench/contrib/debug/common/debug';
|
||||
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
|
||||
import { registerColor } from 'vs/platform/theme/common/colorRegistry';
|
||||
import { localize } from 'vs/nls';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
|
||||
import { IEditorContribution } from 'vs/editor/common/editorCommon';
|
||||
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
|
||||
import { distinct } from 'vs/base/common/arrays';
|
||||
import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity';
|
||||
|
||||
const stickiness = TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges;
|
||||
|
||||
// we need a separate decoration for glyph margin, since we do not want it on each line of a multi line statement.
|
||||
const TOP_STACK_FRAME_MARGIN: IModelDecorationOptions = {
|
||||
glyphMarginClassName: 'codicon-debug-stackframe',
|
||||
stickiness
|
||||
};
|
||||
const FOCUSED_STACK_FRAME_MARGIN: IModelDecorationOptions = {
|
||||
glyphMarginClassName: 'codicon-debug-stackframe-focused',
|
||||
stickiness
|
||||
};
|
||||
const TOP_STACK_FRAME_DECORATION: IModelDecorationOptions = {
|
||||
isWholeLine: true,
|
||||
className: 'debug-top-stack-frame-line',
|
||||
stickiness
|
||||
};
|
||||
const TOP_STACK_FRAME_INLINE_DECORATION: IModelDecorationOptions = {
|
||||
beforeContentClassName: 'debug-top-stack-frame-column'
|
||||
};
|
||||
const FOCUSED_STACK_FRAME_DECORATION: IModelDecorationOptions = {
|
||||
isWholeLine: true,
|
||||
className: 'debug-focused-stack-frame-line',
|
||||
stickiness
|
||||
};
|
||||
|
||||
export function createDecorationsForStackFrame(stackFrame: IStackFrame, topStackFrameRange: IRange | undefined, isFocusedSession: boolean): IModelDeltaDecoration[] {
|
||||
// only show decorations for the currently focused thread.
|
||||
const result: IModelDeltaDecoration[] = [];
|
||||
const columnUntilEOLRange = new Range(stackFrame.range.startLineNumber, stackFrame.range.startColumn, stackFrame.range.startLineNumber, Constants.MAX_SAFE_SMALL_INTEGER);
|
||||
const range = new Range(stackFrame.range.startLineNumber, stackFrame.range.startColumn, stackFrame.range.startLineNumber, stackFrame.range.startColumn + 1);
|
||||
|
||||
// compute how to decorate the editor. Different decorations are used if this is a top stack frame, focused stack frame,
|
||||
// an exception or a stack frame that did not change the line number (we only decorate the columns, not the whole line).
|
||||
const topStackFrame = stackFrame.thread.getTopStackFrame();
|
||||
if (stackFrame.getId() === topStackFrame?.getId()) {
|
||||
if (isFocusedSession) {
|
||||
result.push({
|
||||
options: TOP_STACK_FRAME_MARGIN,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
options: TOP_STACK_FRAME_DECORATION,
|
||||
range: columnUntilEOLRange
|
||||
});
|
||||
|
||||
if (topStackFrameRange && topStackFrameRange.startLineNumber === stackFrame.range.startLineNumber && topStackFrameRange.startColumn !== stackFrame.range.startColumn) {
|
||||
result.push({
|
||||
options: TOP_STACK_FRAME_INLINE_DECORATION,
|
||||
range: columnUntilEOLRange
|
||||
});
|
||||
}
|
||||
topStackFrameRange = columnUntilEOLRange;
|
||||
} else {
|
||||
if (isFocusedSession) {
|
||||
result.push({
|
||||
options: FOCUSED_STACK_FRAME_MARGIN,
|
||||
range
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
options: FOCUSED_STACK_FRAME_DECORATION,
|
||||
range: columnUntilEOLRange
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class CallStackEditorContribution implements IEditorContribution {
|
||||
private toDispose: IDisposable[] = [];
|
||||
private decorationIds: string[] = [];
|
||||
private topStackFrameRange: Range | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly editor: ICodeEditor,
|
||||
@IDebugService private readonly debugService: IDebugService,
|
||||
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService
|
||||
) {
|
||||
const setDecorations = () => this.decorationIds = this.editor.deltaDecorations(this.decorationIds, this.createCallStackDecorations());
|
||||
this.toDispose.push(Event.any(this.debugService.getViewModel().onDidFocusStackFrame, this.debugService.getModel().onDidChangeCallStack)(() => {
|
||||
setDecorations();
|
||||
}));
|
||||
this.toDispose.push(this.editor.onDidChangeModel(e => {
|
||||
if (e.newModelUrl) {
|
||||
setDecorations();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private createCallStackDecorations(): IModelDeltaDecoration[] {
|
||||
const focusedStackFrame = this.debugService.getViewModel().focusedStackFrame;
|
||||
const decorations: IModelDeltaDecoration[] = [];
|
||||
this.debugService.getModel().getSessions().forEach(s => {
|
||||
const isSessionFocused = s === focusedStackFrame?.thread.session;
|
||||
s.getAllThreads().forEach(t => {
|
||||
if (t.stopped) {
|
||||
let candidateStackFrame = t === focusedStackFrame?.thread ? focusedStackFrame : undefined;
|
||||
if (!candidateStackFrame) {
|
||||
const callStack = t.getCallStack();
|
||||
if (callStack.length) {
|
||||
candidateStackFrame = callStack[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateStackFrame && this.uriIdentityService.extUri.isEqual(candidateStackFrame.source.uri, this.editor.getModel()?.uri)) {
|
||||
decorations.push(...createDecorationsForStackFrame(candidateStackFrame, this.topStackFrameRange, isSessionFocused));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Deduplicate same decorations so colors do not stack #109045
|
||||
return distinct(decorations, d => `${d.options.className} ${d.options.glyphMarginClassName} ${d.range.startLineNumber} ${d.range.startColumn}`);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.editor.deltaDecorations(this.decorationIds, []);
|
||||
this.toDispose = dispose(this.toDispose);
|
||||
}
|
||||
}
|
||||
|
||||
registerThemingParticipant((theme, collector) => {
|
||||
const topStackFrame = theme.getColor(topStackFrameColor);
|
||||
if (topStackFrame) {
|
||||
collector.addRule(`.monaco-editor .view-overlays .debug-top-stack-frame-line { background: ${topStackFrame}; }`);
|
||||
}
|
||||
|
||||
const focusedStackFrame = theme.getColor(focusedStackFrameColor);
|
||||
if (focusedStackFrame) {
|
||||
collector.addRule(`.monaco-editor .view-overlays .debug-focused-stack-frame-line { background: ${focusedStackFrame}; }`);
|
||||
}
|
||||
});
|
||||
|
||||
const topStackFrameColor = registerColor('editor.stackFrameHighlightBackground', { dark: '#ffff0033', light: '#ffff6673', hc: '#ffff0033' }, localize('topStackFrameLineHighlight', 'Background color for the highlight of line at the top stack frame position.'));
|
||||
const focusedStackFrameColor = registerColor('editor.focusedStackFrameHighlightBackground', { dark: '#7abd7a4d', light: '#cee7ce73', hc: '#7abd7a4d' }, localize('focusedStackFrameLineHighlight', 'Background color for the highlight of line at focused stack frame position.'));
|
||||
1122
lib/vscode/src/vs/workbench/contrib/debug/browser/callStackView.ts
Normal file
1122
lib/vscode/src/vs/workbench/contrib/debug/browser/callStackView.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user