diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index d5fd9c166b5..6b5c3a65891 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -30,6 +30,10 @@ "name": "vs/workbench/api/common", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/bulkEdit", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/cli", "project": "vscode-workbench" diff --git a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts index 163dffb2d5a..e408f21afd2 100644 --- a/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts +++ b/src/vs/base/browser/ui/highlightedlabel/highlightedLabel.ts @@ -10,6 +10,7 @@ import { escape } from 'vs/base/common/strings'; export interface IHighlight { start: number; end: number; + extraClasses?: string; } export class HighlightedLabel { @@ -69,7 +70,11 @@ export class HighlightedLabel { htmlContent += ''; pos = highlight.end; } - htmlContent += ''; + if (highlight.extraClasses) { + htmlContent += ``; + } else { + htmlContent += ``; + } const substring = this.text.substring(highlight.start, highlight.end); htmlContent += this.supportCodicons ? renderCodicons(escape(substring)) : escape(substring); htmlContent += ''; diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts index 61d2436b045..4f6b55c3fe5 100644 --- a/src/vs/base/common/map.ts +++ b/src/vs/base/common/map.ts @@ -454,8 +454,8 @@ export class ResourceMap { return this.map.delete(this.toKey(resource)); } - forEach(clb: (value: T) => void): void { - this.map.forEach(clb); + forEach(clb: (value: T, key: URI) => void): void { + this.map.forEach((value, index) => clb(value, URI.parse(index))); } values(): T[] { diff --git a/src/vs/editor/browser/services/bulkEditService.ts b/src/vs/editor/browser/services/bulkEditService.ts index ada4ed7b23d..d7d1ad70417 100644 --- a/src/vs/editor/browser/services/bulkEditService.ts +++ b/src/vs/editor/browser/services/bulkEditService.ts @@ -7,22 +7,28 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { WorkspaceEdit } from 'vs/editor/common/modes'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; +import { IDisposable } from 'vs/base/common/lifecycle'; export const IBulkEditService = createDecorator('IWorkspaceEditService'); - export interface IBulkEditOptions { editor?: ICodeEditor; progress?: IProgress; + showPreview?: boolean; + label?: string; } export interface IBulkEditResult { ariaSummary: string; } +export type IBulkEditPreviewHandler = (edit: WorkspaceEdit, options?: IBulkEditOptions) => Promise; + export interface IBulkEditService { _serviceBrand: undefined; + setPreviewHandler(handler: IBulkEditPreviewHandler): IDisposable; + apply(edit: WorkspaceEdit, options?: IBulkEditOptions): Promise; } diff --git a/src/vs/editor/contrib/rename/rename.ts b/src/vs/editor/contrib/rename/rename.ts index 92fccb848b4..9f3c10786f6 100644 --- a/src/vs/editor/contrib/rename/rename.ts +++ b/src/vs/editor/contrib/rename/rename.ts @@ -6,7 +6,7 @@ import * as nls from 'vs/nls'; import { illegalArgument, onUnexpectedError } from 'vs/base/common/errors'; import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; -import { IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { IEditorProgressService } from 'vs/platform/progress/common/progress'; import { registerEditorAction, registerEditorContribution, ServicesAccessor, EditorAction, EditorCommand, registerEditorCommand, registerDefaultLanguageCommand } from 'vs/editor/browser/editorExtensions'; import { IEditorContribution } from 'vs/editor/common/editorCommon'; @@ -14,7 +14,6 @@ import { ITextModel } from 'vs/editor/common/model'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { RenameInputField, CONTEXT_RENAME_INPUT_VISIBLE } from './renameInputField'; -import { IThemeService } from 'vs/platform/theme/common/themeService'; import { WorkspaceEdit, RenameProviderRegistry, RenameProvider, RenameLocation, Rejection } from 'vs/editor/common/modes'; import { Position, IPosition } from 'vs/editor/common/core/position'; import { alert } from 'vs/base/browser/ui/aria/aria'; @@ -30,6 +29,8 @@ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cance import { DisposableStore } from 'vs/base/common/lifecycle'; import { IdleValue, raceCancellation } from 'vs/base/common/async'; import { withNullAsUndefined } from 'vs/base/common/types'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; class RenameSkeleton { @@ -109,13 +110,13 @@ class RenameController implements IEditorContribution { constructor( private readonly editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, @INotificationService private readonly _notificationService: INotificationService, @IBulkEditService private readonly _bulkEditService: IBulkEditService, @IEditorProgressService private readonly _progressService: IEditorProgressService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IThemeService private readonly _themeService: IThemeService, + @ILogService private readonly _logService: ILogService, ) { - this._renameInputField = this._dispoableStore.add(new IdleValue(() => this._dispoableStore.add(new RenameInputField(this.editor, this._themeService, this._contextKeyService)))); + this._renameInputField = this._dispoableStore.add(new IdleValue(() => this._dispoableStore.add(this._instaService.createInstance(RenameInputField, this.editor, ['acceptRenameInput', 'acceptRenameInputWithPreview'])))); } dispose(): void { @@ -174,11 +175,11 @@ class RenameController implements IEditorContribution { selectionEnd = Math.min(loc.range.endColumn, selection.endColumn) - loc.range.startColumn; } - const newNameOrFocusFlag = await this._renameInputField.getValue().getInput(loc.range, loc.text, selectionStart, selectionEnd); + const inputFieldResult = await this._renameInputField.getValue().getInput(loc.range, loc.text, selectionStart, selectionEnd); - - if (typeof newNameOrFocusFlag === 'boolean') { - if (newNameOrFocusFlag) { + // no result, only hint to focus the editor or not + if (typeof inputFieldResult === 'boolean') { + if (inputFieldResult) { this.editor.focus(); } return undefined; @@ -186,7 +187,7 @@ class RenameController implements IEditorContribution { this.editor.focus(); - const renameOperation = raceCancellation(skeleton.provideRenameEdits(newNameOrFocusFlag, 0, [], this._cts.token), this._cts.token).then(async renameResult => { + const renameOperation = raceCancellation(skeleton.provideRenameEdits(inputFieldResult.newName, 0, [], this._cts.token), this._cts.token).then(async renameResult => { if (!renameResult || !this.editor.hasModel()) { return; @@ -197,16 +198,22 @@ class RenameController implements IEditorContribution { return; } - const editResult = await this._bulkEditService.apply(renameResult, { editor: this.editor }); - - // alert - if (editResult.ariaSummary) { - alert(nls.localize('aria', "Successfully renamed '{0}' to '{1}'. Summary: {2}", loc!.text, newNameOrFocusFlag, editResult.ariaSummary)); - } + this._bulkEditService.apply(renameResult, { + editor: this.editor, + showPreview: inputFieldResult.wantsPreview, + label: nls.localize('label', "Renaming '{0}'", loc?.text) + }).then(result => { + if (result.ariaSummary) { + alert(nls.localize('aria', "Successfully renamed '{0}' to '{1}'. Summary: {2}", loc!.text, inputFieldResult.newName, result.ariaSummary)); + } + }).catch(err => { + this._notificationService.error(nls.localize('rename.failedApply', "Rename failed to apply edits")); + this._logService.error(err); + }); }, err => { - this._notificationService.error(nls.localize('rename.failed', "Rename failed to execute.")); - return Promise.reject(err); + this._notificationService.error(nls.localize('rename.failed', "Rename failed to compute edits")); + this._logService.error(err); }); this._progressService.showWhile(renameOperation, 250); @@ -214,8 +221,8 @@ class RenameController implements IEditorContribution { } - acceptRenameInput(): void { - this._renameInputField.getValue().acceptInput(); + acceptRenameInput(wantsPreview: boolean): void { + this._renameInputField.getValue().acceptInput(wantsPreview); } cancelRenameInput(): void { @@ -282,7 +289,7 @@ const RenameCommand = EditorCommand.bindToContribution(RenameC registerEditorCommand(new RenameCommand({ id: 'acceptRenameInput', precondition: CONTEXT_RENAME_INPUT_VISIBLE, - handler: x => x.acceptRenameInput(), + handler: x => x.acceptRenameInput(false), kbOpts: { weight: KeybindingWeight.EditorContrib + 99, kbExpr: EditorContextKeys.focus, @@ -290,6 +297,17 @@ registerEditorCommand(new RenameCommand({ } })); +registerEditorCommand(new RenameCommand({ + id: 'acceptRenameInputWithPreview', + precondition: CONTEXT_RENAME_INPUT_VISIBLE, + handler: x => x.acceptRenameInput(true), + kbOpts: { + weight: KeybindingWeight.EditorContrib + 99, + kbExpr: EditorContextKeys.focus, + primary: KeyMod.Shift + KeyCode.Enter + } +})); + registerEditorCommand(new RenameCommand({ id: 'cancelRenameInput', precondition: CONTEXT_RENAME_INPUT_VISIBLE, diff --git a/src/vs/editor/contrib/rename/renameInputField.css b/src/vs/editor/contrib/rename/renameInputField.css index 0478cd7ce48..a61b3741c2f 100644 --- a/src/vs/editor/contrib/rename/renameInputField.css +++ b/src/vs/editor/contrib/rename/renameInputField.css @@ -6,8 +6,10 @@ .monaco-editor .rename-box { z-index: 100; color: inherit; + padding: 4px; } .monaco-editor .rename-box .rename-input { padding: 4px; + width: calc(100% - 8px); } diff --git a/src/vs/editor/contrib/rename/renameInputField.ts b/src/vs/editor/contrib/rename/renameInputField.ts index 5f36cd8ae21..f70ed3c15d1 100644 --- a/src/vs/editor/contrib/rename/renameInputField.ts +++ b/src/vs/editor/contrib/rename/renameInputField.ts @@ -4,166 +4,182 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./renameInputField'; -import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { ScrollType } from 'vs/editor/common/editorCommon'; import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { inputBackground, inputBorder, inputForeground, widgetShadow } from 'vs/platform/theme/common/colorRegistry'; +import { inputBackground, inputBorder, inputForeground, widgetShadow, editorWidgetBackground } from 'vs/platform/theme/common/colorRegistry'; import { ITheme, IThemeService } from 'vs/platform/theme/common/themeService'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; export const CONTEXT_RENAME_INPUT_VISIBLE = new RawContextKey('renameInputVisible', false); -export class RenameInputField implements IContentWidget, IDisposable { +export interface RenameInputFieldResult { + newName: string; + wantsPreview?: boolean; +} + +export class RenameInputField implements IContentWidget { - private _editor: ICodeEditor; private _position?: Position; private _domNode?: HTMLElement; - private _inputField?: HTMLInputElement; + private _input?: HTMLInputElement; + private _label?: HTMLDivElement; private _visible?: boolean; private readonly _visibleContextKey: IContextKey; private readonly _disposables = new DisposableStore(); - // Editor.IContentWidget.allowEditorOverflow - allowEditorOverflow: boolean = true; + readonly allowEditorOverflow: boolean = true; constructor( - editor: ICodeEditor, - private readonly themeService: IThemeService, - contextKeyService: IContextKeyService, + private readonly _editor: ICodeEditor, + private readonly _acceptKeybindings: [string, string], + @IThemeService private readonly _themeService: IThemeService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, ) { this._visibleContextKey = CONTEXT_RENAME_INPUT_VISIBLE.bindTo(contextKeyService); - this._editor = editor; this._editor.addContentWidget(this); - this._disposables.add(editor.onDidChangeConfiguration(e => { + this._disposables.add(this._editor.onDidChangeConfiguration(e => { if (e.hasChanged(EditorOption.fontInfo)) { - this.updateFont(); + this._updateFont(); } })); - this._disposables.add(themeService.onThemeChange(theme => this.onThemeChange(theme))); + this._disposables.add(_themeService.onThemeChange(this._updateStyles, this)); } - private onThemeChange(theme: ITheme): void { - this.updateStyles(theme); - } - - public dispose(): void { + dispose(): void { this._disposables.dispose(); this._editor.removeContentWidget(this); } - public getId(): string { + getId(): string { return '__renameInputWidget'; } - public getDomNode(): HTMLElement { + getDomNode(): HTMLElement { if (!this._domNode) { - this._inputField = document.createElement('input'); - this._inputField.className = 'rename-input'; - this._inputField.type = 'text'; - this._inputField.setAttribute('aria-label', localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit.")); this._domNode = document.createElement('div'); - this._domNode.style.height = `${this._editor.getOption(EditorOption.lineHeight)}px`; this._domNode.className = 'monaco-editor rename-box'; - this._domNode.appendChild(this._inputField); - this.updateFont(); - this.updateStyles(this.themeService.getTheme()); + this._input = document.createElement('input'); + this._input.className = 'rename-input'; + this._input.type = 'text'; + this._input.setAttribute('aria-label', localize('renameAriaLabel', "Rename input. Type new name and press Enter to commit.")); + this._domNode.appendChild(this._input); + + this._label = document.createElement('div'); + this._label.className = 'rename-label'; + this._domNode.appendChild(this._label); + const updateLabel = () => { + const [accept, preview] = this._acceptKeybindings; + this._keybindingService.lookupKeybinding(accept); + this._label!.innerText = localize('label', "Press {0} to Rename, {1} to Preview", this._keybindingService.lookupKeybinding(accept)?.getLabel(), this._keybindingService.lookupKeybinding(preview)?.getLabel()); + }; + updateLabel(); + this._disposables.add(this._keybindingService.onDidUpdateKeybindings(updateLabel)); + + this._updateFont(); + this._updateStyles(this._themeService.getTheme()); } return this._domNode; } - private updateStyles(theme: ITheme): void { - if (!this._inputField) { + private _updateStyles(theme: ITheme): void { + if (!this._input || !this._domNode) { return; } - const background = theme.getColor(inputBackground); - const foreground = theme.getColor(inputForeground); const widgetShadowColor = theme.getColor(widgetShadow); + this._domNode.style.backgroundColor = String(theme.getColor(editorWidgetBackground) ?? ''); + this._domNode.style.boxShadow = widgetShadowColor ? ` 0 2px 8px ${widgetShadowColor}` : ''; + this._domNode.style.color = String(theme.getColor(inputForeground) ?? ''); + + this._input.style.backgroundColor = String(theme.getColor(inputBackground) ?? ''); + // this._input.style.color = String(theme.getColor(inputForeground) ?? ''); const border = theme.getColor(inputBorder); - - this._inputField.style.backgroundColor = background ? background.toString() : ''; - this._inputField.style.color = foreground ? foreground.toString() : ''; - - this._inputField.style.borderWidth = border ? '1px' : '0px'; - this._inputField.style.borderStyle = border ? 'solid' : 'none'; - this._inputField.style.borderColor = border ? border.toString() : 'none'; - - this._domNode!.style.boxShadow = widgetShadowColor ? ` 0 2px 8px ${widgetShadowColor}` : ''; + this._input.style.borderWidth = border ? '1px' : '0px'; + this._input.style.borderStyle = border ? 'solid' : 'none'; + this._input.style.borderColor = border?.toString() ?? 'none'; } - private updateFont(): void { - if (!this._inputField) { + private _updateFont(): void { + if (!this._input || !this._label) { return; } const fontInfo = this._editor.getOption(EditorOption.fontInfo); - this._inputField.style.fontFamily = fontInfo.fontFamily; - this._inputField.style.fontWeight = fontInfo.fontWeight; - this._inputField.style.fontSize = `${fontInfo.fontSize}px`; + this._input.style.fontFamily = fontInfo.fontFamily; + this._input.style.fontWeight = fontInfo.fontWeight; + this._input.style.fontSize = `${fontInfo.fontSize}px`; + + this._label.style.fontSize = `${fontInfo.fontSize * 0.8}px`; } - public getPosition(): IContentWidgetPosition | null { - return this._visible - ? { position: this._position!, preference: [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE] } - : null; + getPosition(): IContentWidgetPosition | null { + if (!this._visible) { + return null; + } + return { + position: this._position!, + preference: [ContentWidgetPositionPreference.BELOW, ContentWidgetPositionPreference.ABOVE] + }; } - private _currentAcceptInput: (() => void) | null = null; - private _currentCancelInput: ((focusEditor: boolean) => void) | null = null; + private _currentAcceptInput?: (wantsPreview: boolean) => void; + private _currentCancelInput?: (focusEditor: boolean) => void; - public acceptInput(): void { + acceptInput(wantsPreview: boolean): void { if (this._currentAcceptInput) { - this._currentAcceptInput(); + this._currentAcceptInput(wantsPreview); } } - public cancelInput(focusEditor: boolean): void { + cancelInput(focusEditor: boolean): void { if (this._currentCancelInput) { this._currentCancelInput(focusEditor); } } - public getInput(where: IRange, value: string, selectionStart: number, selectionEnd: number): Promise { + getInput(where: IRange, value: string, selectionStart: number, selectionEnd: number): Promise { this._position = new Position(where.startLineNumber, where.startColumn); - this._inputField!.value = value; - this._inputField!.setAttribute('selectionStart', selectionStart.toString()); - this._inputField!.setAttribute('selectionEnd', selectionEnd.toString()); - this._inputField!.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); + this._input!.value = value; + this._input!.setAttribute('selectionStart', selectionStart.toString()); + this._input!.setAttribute('selectionEnd', selectionEnd.toString()); + this._input!.size = Math.max((where.endColumn - where.startColumn) * 1.1, 20); const disposeOnDone = new DisposableStore(); - const always = () => { - disposeOnDone.dispose(); - this._hide(); - }; - return new Promise(resolve => { + return new Promise(resolve => { this._currentCancelInput = (focusEditor) => { - this._currentAcceptInput = null; - this._currentCancelInput = null; + this._currentAcceptInput = undefined; + this._currentCancelInput = undefined; resolve(focusEditor); return true; }; - this._currentAcceptInput = () => { - if (this._inputField!.value.trim().length === 0 || this._inputField!.value === value) { + this._currentAcceptInput = (wantsPreview) => { + if (this._input!.value.trim().length === 0 || this._input!.value === value) { // empty or whitespace only or not changed this.cancelInput(true); return; } - this._currentAcceptInput = null; - this._currentCancelInput = null; - resolve(this._inputField!.value); + this._currentAcceptInput = undefined; + this._currentCancelInput = undefined; + resolve({ + newName: this._input!.value, + wantsPreview + }); }; let onCursorChanged = () => { @@ -178,12 +194,9 @@ export class RenameInputField implements IContentWidget, IDisposable { this._show(); - }).then(newValue => { - always(); - return newValue; - }, err => { - always(); - return Promise.reject(err); + }).finally(() => { + disposeOnDone.dispose(); + this._hide(); }); } @@ -194,10 +207,10 @@ export class RenameInputField implements IContentWidget, IDisposable { this._editor.layoutContentWidget(this); setTimeout(() => { - this._inputField!.focus(); - this._inputField!.setSelectionRange( - parseInt(this._inputField!.getAttribute('selectionStart')!), - parseInt(this._inputField!.getAttribute('selectionEnd')!)); + this._input!.focus(); + this._input!.setSelectionRange( + parseInt(this._input!.getAttribute('selectionStart')!), + parseInt(this._input!.getAttribute('selectionEnd')!)); }, 100); } diff --git a/src/vs/editor/standalone/browser/simpleServices.ts b/src/vs/editor/standalone/browser/simpleServices.ts index 409a1dfeb52..19a2aead6c3 100644 --- a/src/vs/editor/standalone/browser/simpleServices.ts +++ b/src/vs/editor/standalone/browser/simpleServices.ts @@ -635,6 +635,10 @@ export class SimpleBulkEditService implements IBulkEditService { // } + setPreviewHandler(): IDisposable { + return Disposable.None; + } + apply(workspaceEdit: WorkspaceEdit, options?: IBulkEditOptions): Promise { let edits = new Map(); diff --git a/src/vs/workbench/api/browser/mainThreadEditors.ts b/src/vs/workbench/api/browser/mainThreadEditors.ts index c4658d50a69..8dd058290d0 100644 --- a/src/vs/workbench/api/browser/mainThreadEditors.ts +++ b/src/vs/workbench/api/browser/mainThreadEditors.ts @@ -217,7 +217,7 @@ export class MainThreadTextEditors implements MainThreadTextEditorsShape { $tryApplyWorkspaceEdit(dto: IWorkspaceEditDto): Promise { const { edits } = reviveWorkspaceEditDto(dto); - return this._bulkEditService.apply({ edits }, undefined).then(() => true, err => false); + return this._bulkEditService.apply({ edits }).then(() => true, _err => false); } $tryInsertSnippet(id: string, template: string, ranges: readonly IRange[], opts: IUndoStopOptions): Promise { diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.contribution.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.contribution.ts new file mode 100644 index 00000000000..19f870dec79 --- /dev/null +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.contribution.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * 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/platform/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, IBulkEditOptions } from 'vs/editor/browser/services/bulkEditService'; +import { WorkspaceEdit } from 'vs/editor/common/modes'; +import { BulkEditPane } from 'vs/workbench/contrib/bulkEdit/browser/bulkEditPane'; +import { IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, IViewsRegistry } from 'vs/workbench/common/views'; +import { localize } from 'vs/nls'; +import { ViewPaneContainer, ViewPane } from 'vs/workbench/browser/parts/views/viewPaneContainer'; +import { PaneCompositePanel } from 'vs/workbench/browser/panel'; +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/bulkEditPreview'; +import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; +import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; +import { WorkbenchListFocusContextKey } from 'vs/platform/list/browser/listService'; + +function getBulkEditPane(panelService: IPanelService): BulkEditPane | undefined { + let view: ViewPane | undefined; + const activePanel = panelService.openPanel(BulkEditPane.ID, true); + if (activePanel instanceof PaneCompositePanel) { + view = activePanel.getViewPaneContainer().getView(BulkEditPane.ID); + } + if (view instanceof BulkEditPane) { + return view; + } + return undefined; +} + +class BulkEditPreviewContribution { + + static readonly ctxEnabled = new RawContextKey('refactorPreview.enabled', false); + + private readonly _ctxEnabled: IContextKey; + + constructor( + @IPanelService private _panelService: IPanelService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IBulkEditService bulkEditService: IBulkEditService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + bulkEditService.setPreviewHandler((edit, options) => this._previewEdit(edit, options)); + this._ctxEnabled = BulkEditPreviewContribution.ctxEnabled.bindTo(contextKeyService); + } + + private async _previewEdit(edit: WorkspaceEdit, options?: IBulkEditOptions) { + this._ctxEnabled.set(true); + const oldActivePanel = this._panelService.getActivePanel(); + + try { + const view = getBulkEditPane(this._panelService); + if (!view) { + return edit; + } + + const newEditOrUndefined = await view.setInput(edit, options?.label); + if (!newEditOrUndefined) { + return { edits: [] }; + } + + return newEditOrUndefined; + + } finally { + // restore UX state + + // (1) hide refactor panel + this._ctxEnabled.set(false); + + // (2) restore previous panel + if (oldActivePanel) { + this._panelService.openPanel(oldActivePanel.getId()); + } else { + this._panelService.hideActivePanel(); + } + + // (3) close preview editors + for (let group of this._editorGroupsService.groups) { + for (let input of group.editors) { + if (input instanceof DiffEditorInput && input.modifiedInput.getResource()?.scheme === BulkEditPreviewProvider.Schema) { + group.closeEditor(input, { preserveFocus: true }); + } + } + } + } + } +} + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'refactorPreview.apply', + weight: KeybindingWeight.WorkbenchContrib, + when: BulkEditPreviewContribution.ctxEnabled, + primary: KeyMod.Shift + KeyCode.Enter, + handler(accessor) { + const panelService = accessor.get(IPanelService); + const view = getBulkEditPane(panelService); + if (view) { + view.accept(); + } + } +}); + +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'refactorPreview.toggleCheckedState', + weight: KeybindingWeight.WorkbenchContrib, + when: ContextKeyExpr.and(BulkEditPreviewContribution.ctxEnabled, WorkbenchListFocusContextKey), + primary: KeyCode.Space, + handler(accessor) { + const panelService = accessor.get(IPanelService); + const view = getBulkEditPane(panelService); + if (view) { + view.toggleChecked(); + } + } +}); + +Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution( + BulkEditPreviewContribution, LifecyclePhase.Ready +); + +const container = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: BulkEditPane.ID, + name: localize('panel', "Refactor Preview"), + hideIfEmpty: true, + ctorDescriptor: { + ctor: ViewPaneContainer, + arguments: [BulkEditPane.ID, BulkEditPane.ID, { mergeViewWithContainerWhenSingleView: true, donotShowContainerTitleWhenMergedWithContainer: true }] + } +}, ViewContainerLocation.Panel); + +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ + id: BulkEditPane.ID, + name: localize('panel', "Refactor Preview"), + when: BulkEditPreviewContribution.ctxEnabled, + ctorDescriptor: { ctor: BulkEditPane }, +}], container); + diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.css b/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.css new file mode 100644 index 00000000000..52edbe56a36 --- /dev/null +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEdit.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.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 .details { + margin-left: .5em; + opacity: .7; + font-size: 0.9em; + white-space: pre +} diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPane.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPane.ts new file mode 100644 index 00000000000..c4456386b4f --- /dev/null +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPane.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from 'vs/platform/list/browser/listService'; +import { WorkspaceEdit } from 'vs/editor/common/modes'; +import { BulkEditElement, BulkEditDelegate, TextEditElementRenderer, FileElementRenderer, BulkEditDataSource, BulkEditIdentityProvider, FileElement, TextEditElement } from 'vs/workbench/contrib/bulkEdit/browser/bulkEditTree'; +import { FuzzyScore } from 'vs/base/common/filters'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { registerThemingParticipant, ITheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; +import { Action } from 'vs/base/common/actions'; +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 } from 'vs/workbench/contrib/bulkEdit/browser/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 } 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'; + +const enum State { + Data = 'data', + Message = 'message' +} + +export class BulkEditPane extends ViewPane { + + static readonly ID = 'refactorPreview'; + + private _tree!: WorkbenchAsyncDataTree; + private _message!: HTMLSpanElement; + + private readonly _acceptAction = new Action('ok', localize('ok', "Apply Changes"), 'codicon-check', false, async () => this.accept()); + private readonly _discardAction = new Action('discard', localize('discard', "Discard Changes"), 'codicon-clear-all', false, async () => this.discard()); + + private readonly _disposables = new DisposableStore(); + + private readonly _sessionDisposables = new DisposableStore(); + private _currentResolve?: (edit?: WorkspaceEdit) => 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, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super( + options, + keybindingService, contextMenuService, configurationService, contextKeyService + ); + + this.element.classList.add('bulk-edit-panel', 'show-file-icons'); + } + + dispose(): void { + this._tree.dispose(); + this._disposables.dispose(); + } + + protected renderBody(parent: HTMLElement): void { + + const resourceLabels = this._instaService.createInstance( + ResourceLabels, + { 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._tree = this._instaService.createInstance( + WorkbenchAsyncDataTree, this.id, treeContainer, + new BulkEditDelegate(), + [new TextEditElementRenderer(), this._instaService.createInstance(FileElementRenderer, resourceLabels)], + this._instaService.createInstance(BulkEditDataSource), + { + identityProvider: new BulkEditIdentityProvider(), + expandOnlyOnTwistieClick: true + } + ); + + this._disposables.add(this._tree.onDidOpen(e => { + const [first] = e.elements; + if (first instanceof TextEditElement) { + this._previewTextEditElement(first); + } else if (first instanceof FileElement) { + this._previewFileElement(); + } + })); + + // 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); + } + + getActions() { + return [this._acceptAction, this._discardAction]; + } + + protected layoutBody(height: number, width: number): void { + this._tree.layout(height, width); + } + + private _setState(state: State): void { + this.element.dataset['state'] = state; + } + + async setInput(edit: WorkspaceEdit, label?: string): Promise { + this._setState(State.Data); + this._sessionDisposables.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); + + this._currentInput = input; + + this._acceptAction.enabled = true; + this._discardAction.enabled = true; + + return new Promise(async resolve => { + + this._currentResolve = resolve; + await this._tree.setInput(input); + this._tree.domFocus(); + this._tree.focusFirst(); + + const first = this._tree.getFirstElementChild(); + if (first instanceof FileElement) { + this._tree.expand(first); + } + + // refresh when check state changes + this._sessionDisposables.add(input.onDidChangeCheckedState(() => { + this._tree.updateChildren(); + })); + }); + } + + 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); + } + + toggleChecked() { + const [first] = this._tree.getFocus(); + if (first) { + first.edit.updateChecked(!first.edit.isChecked()); + } + } + + private _done(accept: boolean): void { + if (this._currentResolve) { + this._currentResolve(accept ? this._currentInput?.asWorkspaceEdit() : undefined); + this._acceptAction.enabled = false; + this._discardAction.enabled = false; + this._currentInput = undefined; + } + this._setState(State.Message); + this._sessionDisposables.clear(); + } + + private async _previewTextEditElement(element: TextEditElement): Promise { + + let leftResource: URI; + try { + (await this._textModelService.createModelReference(element.parent.uri)).dispose(); + leftResource = element.parent.uri; + } catch { + leftResource = BulkEditPreviewProvider.emptyPreview; + } + + const previewUri = BulkEditPreviewProvider.asPreviewUri(element.parent.uri); + + this._editorService.openEditor({ + leftResource, + rightResource: previewUri, + label: localize('edt.title', "{0} (Refactor Preview)", this._labelService.getUriLabel(element.parent.uri)), + options: { + selection: element.edit.edit.range, + revealInCenterIfOutsideViewport: true, + preserveFocus: true + } + }); + } + + private _previewFileElement(): void { + + } +} + +registerThemingParticipant((theme: ITheme, 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}; }`); + } +}); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPreview.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPreview.ts new file mode 100644 index 00000000000..614c152f94a --- /dev/null +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditPreview.ts @@ -0,0 +1,324 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { WorkspaceEdit, isResourceTextEdit, TextEdit, ResourceTextEdit, ResourceFileEdit } 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/services/bulkEdit/browser/conflicts'; + +class CheckedObject { + + private _checked: boolean = true; + + constructor(protected _emitter: Emitter) { } + + updateChecked(checked: boolean) { + if (this._checked !== checked) { + this._checked = checked; + this._emitter.fire(this); + } + } + + isChecked(): boolean { + return this._checked; + } +} + +export class BulkTextEdit extends CheckedObject { + + constructor( + readonly parent: BulkFileOperation, + readonly edit: TextEdit, + emitter: Emitter + ) { + super(emitter); + } +} + +export const enum BulkFileOperationType { + None = 0, + Create = 0b0001, + Delete = 0b0010, + Rename = 0b0100, +} + +export class BulkFileOperation extends CheckedObject { + + type = BulkFileOperationType.None; + textEdits: BulkTextEdit[] = []; + originalEdits = new Map(); + newUri?: URI; + + constructor( + readonly uri: URI, + readonly parent: BulkFileOperations + ) { + super(parent._onDidChangeCheckedState); + } + + addEdit(index: number, type: BulkFileOperationType, edit: ResourceTextEdit | ResourceFileEdit, ) { + this.type += type; + this.originalEdits.set(index, edit); + if (isResourceTextEdit(edit)) { + this.textEdits = this.textEdits.concat(edit.edits.map(edit => new BulkTextEdit(this, edit, this._emitter))); + + } else if (type === BulkFileOperationType.Rename) { + this.newUri = edit.newUri; + } + } +} + +export class BulkFileOperations { + + static async create(accessor: ServicesAccessor, bulkEdit: WorkspaceEdit): Promise { + const result = accessor.get(IInstantiationService).createInstance(BulkFileOperations, bulkEdit); + return await result._init(); + } + + readonly _onDidChangeCheckedState = new Emitter(); + readonly onDidChangeCheckedState: Event = this._onDidChangeCheckedState.event; + + readonly fileOperations: BulkFileOperation[] = []; + + readonly conflicts: ConflictDetector; + + constructor( + private readonly _bulkEdit: WorkspaceEdit, + @IFileService private readonly _fileService: IFileService, + @IInstantiationService instaService: IInstantiationService, + ) { + this.conflicts = instaService.createInstance(ConflictDetector, _bulkEdit); + } + + dispose(): void { + this.conflicts.dispose(); + } + + async _init() { + const operationByResource = new Map(); + const newToOldUri = new Map(); + + for (let idx = 0; idx < this._bulkEdit.edits.length; idx++) { + const edit = this._bulkEdit.edits[idx]; + + let uri: URI; + let type: BulkFileOperationType; + + if (isResourceTextEdit(edit)) { + type = BulkFileOperationType.None; + uri = edit.resource; + + } else if (edit.newUri && edit.oldUri) { + type = BulkFileOperationType.Rename; + uri = edit.oldUri; + if (edit.options?.overwrite === undefined && edit.options?.ignoreIfExists && await this._fileService.exists(uri)) { + // noop -> "soft" rename to something that already exists + continue; + } + // map newUri onto oldUri so that text-edit appear for + // the same file element + newToOldUri.set(edit.newUri.toString(), uri.toString()); + + } else if (edit.oldUri) { + type = BulkFileOperationType.Delete; + uri = edit.oldUri; + if (edit.options?.ignoreIfNotExists && !await this._fileService.exists(uri)) { + // noop -> "soft" delete something that doesn't exist + continue; + } + + } else if (edit.newUri) { + type = BulkFileOperationType.Create; + uri = edit.newUri; + 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; + } + + let key = uri.toString(); + let operation = operationByResource.get(key); + + // rename + if (!operation && newToOldUri.has(key)) { + key = newToOldUri.get(key)!; + operation = operationByResource.get(key); + } + + if (!operation) { + operation = new BulkFileOperation(uri, this); + operationByResource.set(key, operation); + } + operation.addEdit(idx, type, edit); + } + + operationByResource.forEach(value => this.fileOperations.push(value)); + return this; + } + + asWorkspaceEdit(): WorkspaceEdit { + const result: WorkspaceEdit = { edits: [] }; + let allAccepted = true; + for (let file of this.fileOperations) { + + if (!file.isChecked()) { + allAccepted = false; + continue; + } + + const keyOfEdit = (edit: TextEdit) => JSON.stringify(edit); + const checkedEdits = new Set(); + + for (let edit of file.textEdits) { + if (edit.isChecked()) { + checkedEdits.add(keyOfEdit(edit.edit)); + } + } + + file.originalEdits.forEach((value, idx) => { + + if (isResourceTextEdit(value)) { + let newValue: ResourceTextEdit = { ...value, edits: [] }; + let allEditsAccepted = true; + for (let edit of value.edits) { + if (!checkedEdits.has(keyOfEdit(edit))) { + allEditsAccepted = false; + } else { + newValue.edits.push(edit); + } + } + if (!allEditsAccepted) { + value = newValue; + allAccepted = false; + } + } + + result.edits[idx] = value; + }); + } + if (!allAccepted) { + // only return a new edit when something has changed + coalesceInPlace(result.edits); + return result; + } + return this._bulkEdit; + + } +} + +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; + private readonly _modelPreviewEdits = new Map(); + + 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); + } + this._disposables.add(this._operations.onDidChangeCheckedState(element => { + let operation = element instanceof BulkFileOperation ? element : element.parent; + this._applyTextEditsToPreviewModel(operation); + })); + } + + private async _applyTextEditsToPreviewModel(operation: BulkFileOperation) { + const model = await this._getOrCreatePreviewModel(operation.uri); + + // undo edits that have been done before + let undoEdits = this._modelPreviewEdits.get(model.id); + if (undoEdits) { + model.applyEdits(undoEdits); + } + // compute new edits + const newEdits = mergeSort( + operation.textEdits.filter(edit => edit.isChecked() && edit.parent.isChecked()).map(edit => EditOperation.replaceMove(Range.lift(edit.edit.range), edit.edit.text)), + (a, b) => Range.compareRangesUsingStarts(a.range, b.range) + ); + // apply edits and keep undo edits + undoEdits = model.applyEdits(newEdits); + this._modelPreviewEdits.set(model.id, undoEdits); + } + + 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); + } +} diff --git a/src/vs/workbench/contrib/bulkEdit/browser/bulkEditTree.ts b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditTree.ts new file mode 100644 index 00000000000..0fbdcd26345 --- /dev/null +++ b/src/vs/workbench/contrib/bulkEdit/browser/bulkEditTree.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } 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 { URI } from 'vs/base/common/uri'; +import { HighlightedLabel, IHighlight } from 'vs/base/browser/ui/highlightedlabel/highlightedLabel'; +import { IIdentityProvider, IListVirtualDelegate } 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 } from 'vs/workbench/contrib/bulkEdit/browser/bulkEditPreview'; +import { FileKind } from 'vs/platform/files/common/files'; +import { localize } from 'vs/nls'; +import { ILabelService } from 'vs/platform/label/common/label'; + +// --- VIEW MODEL + +export class FileElement { + + readonly uri: URI; + + constructor(readonly edit: BulkFileOperation) { + this.uri = edit.uri; + } +} + +export class TextEditElement { + + constructor( + readonly parent: FileElement, + readonly edit: BulkTextEdit, + readonly prefix: string, readonly selecting: string, readonly inserting: string, readonly suffix: string + ) { } +} + +export type BulkEditElement = FileElement | TextEditElement; + +// --- DATA SOURCE + +export class BulkEditDataSource implements IAsyncDataSource { + + constructor(@ITextModelService private readonly _textModelService: ITextModelService) { } + + 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 { + + // root -> file/text edits + if (element instanceof BulkFileOperations) { + return element.fileOperations.map(op => new FileElement(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 = TextModel.createFromString(''); + textModelDisposable = textModel; + } + + const result = element.edit.textEdits.map(edit => { + const range = Range.lift(edit.edit.range); + + const tokens = textModel.getLineTokens(range.endLineNumber); + let suffixLen = 0; + for (let idx = tokens.findTokenIndexAtOffset(range.endColumn); suffixLen < 50 && idx < tokens.getCount(); idx++) { + suffixLen += tokens.getEndOffset(idx) - tokens.getStartOffset(idx); + } + + return new TextEditElement( + element, + edit, + textModel.getValueInRange(new Range(range.startLineNumber, 1, range.startLineNumber, range.startColumn)), // line start to edit start, + textModel.getValueInRange(range), + edit.edit.text, + textModel.getValueInRange(new Range(range.endLineNumber, range.endColumn, range.endLineNumber, range.endColumn + suffixLen)) + ); + }); + + textModelDisposable.dispose(); + return result; + } + + return []; + } +} + +// --- IDENT + +export class BulkEditIdentityProvider implements IIdentityProvider { + + getId(element: BulkEditElement): { toString(): string; } { + if (element instanceof FileElement) { + return element.uri; + } else { + return element.parent.uri.toString() + JSON.stringify(element.edit.edit); + } + } +} + +// --- RENDERER + +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._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', (() => element.edit.updateChecked(this._checkbox.checked)))); + this._checkbox.checked = element.edit.isChecked(); + + this._label.setFile(element.uri, { + matches: createMatches(score), + fileKind: FileKind.FILE, + fileDecorations: { colors: true, badges: false }, + }); + + // details + if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) { + this._details.innerText = localize('detail.rename', "(renaming to {0})", this._labelService.getUriLabel(element.edit.newUri, { relative: true })); + } else if (element.edit.type & BulkFileOperationType.Create) { + this._details.innerText = localize('detail.create', "(creating)"); + } else if (element.edit.type & BulkFileOperationType.Create) { + this._details.innerText = localize('detail.del', "(deleting)"); + } else { + this._details.innerText = ''; + } + } +} + +export class FileElementRenderer implements ITreeRenderer { + + 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, _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 _label: HighlightedLabel; + + constructor(container: HTMLElement) { + 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 = new HighlightedLabel(container, false); + dom.addClass(this._label.element, 'textedit'); + } + + dispose(): void { + this._localDisposables.dispose(); + this._disposables.dispose(); + } + + set(element: TextEditElement) { + this._localDisposables.clear(); + this._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', () => element.edit.updateChecked(this._checkbox.checked))); + this._checkbox.checked = element.edit.isChecked(); + dom.toggleClass(this._checkbox, 'disabled', !element.edit.parent.isChecked()); + + 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' }; + + this._label.set(value, [selectHighlight, insertHighlight], undefined, true); + } +} + +export class TextEditElementRenderer implements ITreeRenderer { + + static readonly id = 'TextEditElementRenderer'; + + readonly templateId: string = TextEditElementRenderer.id; + + renderTemplate(container: HTMLElement): TextEditElementTemplate { + return new TextEditElementTemplate(container); + } + + renderElement({ element }: ITreeNode, _index: number, template: TextEditElementTemplate): void { + template.set(element); + } + + disposeTemplate(_template: TextEditElementTemplate): void { } +} + +export class BulkEditDelegate implements IListVirtualDelegate { + + getHeight(): number { + return 23; + } + + getTemplateId(element: BulkEditElement): string { + return element instanceof FileElement + ? FileElementRenderer.id + : TextEditElementRenderer.id; + } +} diff --git a/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts b/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts index fd4c3bdaad9..11136917170 100644 --- a/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts +++ b/src/vs/workbench/services/bulkEdit/browser/bulkEditService.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { mergeSort } from 'vs/base/common/arrays'; -import { dispose, IDisposable, IReference } from 'vs/base/common/lifecycle'; +import { dispose, IDisposable, IReference, toDisposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { IBulkEditOptions, IBulkEditResult, IBulkEditService } from 'vs/editor/browser/services/bulkEditService'; +import { IBulkEditOptions, IBulkEditResult, IBulkEditService, IBulkEditPreviewHandler } from 'vs/editor/browser/services/bulkEditService'; import { EditOperation } from 'vs/editor/common/core/editOperation'; import { Range } from 'vs/editor/common/core/range'; import { EndOfLineSequence, IIdentifiedSingleEditOperation, ITextModel } from 'vs/editor/common/model'; @@ -25,25 +25,8 @@ import { ILabelService } from 'vs/platform/label/common/label'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; +import { Recording } from 'vs/workbench/services/bulkEdit/browser/conflicts'; -abstract class Recording { - - static start(fileService: IFileService): Recording { - - let _changes = new Set(); - let subscription = fileService.onAfterOperation(e => { - _changes.add(e.resource.toString()); - }); - - return { - stop() { return subscription.dispose(); }, - hasChanged(resource) { return _changes.has(resource.toString()); } - }; - } - - abstract stop(): void; - abstract hasChanged(resource: URI): boolean; -} type ValidationResult = { canApply: true } | { canApply: false, reason: URI }; @@ -198,7 +181,7 @@ class BulkEditModel implements IDisposable { for (const edit of value) { if (makeMinimal) { const newEdits = await this._editorWorker.computeMoreMinimalEdits(edit.resource, edit.edits); - task.addEdit({ ...edit, edits: newEdits! }); + task.addEdit({ ...edit, edits: newEdits ?? edit.edits }); } else { task.addEdit(edit); @@ -381,6 +364,8 @@ export class BulkEditService implements IBulkEditService { _serviceBrand: undefined; + private _previewHandler?: IBulkEditPreviewHandler; + constructor( @ILogService private readonly _logService: ILogService, @IModelService private readonly _modelService: IModelService, @@ -393,10 +378,23 @@ export class BulkEditService implements IBulkEditService { @IConfigurationService private readonly _configurationService: IConfigurationService ) { } - apply(edit: WorkspaceEdit, options: IBulkEditOptions = {}): Promise { + setPreviewHandler(handler: IBulkEditPreviewHandler): IDisposable { + this._previewHandler = handler; + return toDisposable(() => { + if (this._previewHandler === handler) { + this._previewHandler = undefined; + } + }); + } - let { edits } = edit; - let codeEditor = options.editor; + async apply(edit: WorkspaceEdit, options?: IBulkEditOptions): Promise { + + if (this._previewHandler && options?.showPreview) { + edit = await this._previewHandler(edit, options); + } + + const { edits } = edit; + let codeEditor = options?.editor; // First check if loaded models were not changed in the meantime for (const edit of edits) { @@ -423,7 +421,7 @@ export class BulkEditService implements IBulkEditService { codeEditor = undefined; } const bulkEdit = new BulkEdit( - codeEditor, options.progress, edits, + codeEditor, options?.progress, edits, this._logService, this._textModelService, this._fileService, this._workerService, this._textFileService, this._labelService, this._configurationService ); return bulkEdit.perform().then(() => { diff --git a/src/vs/workbench/services/bulkEdit/browser/conflicts.ts b/src/vs/workbench/services/bulkEdit/browser/conflicts.ts new file mode 100644 index 00000000000..6c9c2dc6c0a --- /dev/null +++ b/src/vs/workbench/services/bulkEdit/browser/conflicts.ts @@ -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 { IFileService } from 'vs/platform/files/common/files'; +import { URI } from 'vs/base/common/uri'; +import { WorkspaceEdit, isResourceTextEdit } from 'vs/editor/common/modes'; +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 type { ITextModel } from 'vs/editor/common/model'; + +export abstract class Recording { + + static start(fileService: IFileService): Recording { + + let _changes = new Set(); + let subscription = fileService.onAfterOperation(e => { + _changes.add(e.resource.toString()); + }); + + return { + stop() { return subscription.dispose(); }, + hasChanged(resource) { return _changes.has(resource.toString()); } + }; + } + + abstract stop(): void; + abstract hasChanged(resource: URI): boolean; +} + +export class ConflictDetector { + + private readonly _conflicts = new ResourceMap(); + private readonly _changes = new ResourceMap(); + private readonly _disposables = new DisposableStore(); + + private readonly _onDidConflict = new Emitter(); + readonly onDidConflict: Event = this._onDidConflict.event; + + constructor( + workspaceEdit: WorkspaceEdit, + @IFileService fileService: IFileService, + @IModelService modelService: IModelService, + ) { + + const _workspaceEditResources = new ResourceMap(); + + for (let edit of workspaceEdit.edits) { + if (isResourceTextEdit(edit)) { + + _workspaceEditResources.set(edit.resource, true); + + if (typeof edit.modelVersionId === 'number') { + const model = modelService.getModel(edit.resource); + if (model && model.getVersionId() !== edit.modelVersionId) { + this._conflicts.set(edit.resource, true); + this._onDidConflict.fire(this); + } + } + + } else if (edit.newUri) { + _workspaceEditResources.set(edit.newUri, true); + + } else if (edit.oldUri) { + _workspaceEditResources.set(edit.oldUri, true); + } + } + + // listen to file changes + this._disposables.add(fileService.onFileChanges(e => { + for (let change of e.changes) { + + // change + this._changes.set(change.resource, true); + + // conflict + if (_workspaceEditResources.has(change.resource)) { + this._conflicts.set(change.resource, true); + this._onDidConflict.fire(this); + } + } + })); + + + // listen to model changes...? + const onDidChangeModel = (model: ITextModel) => { + // change + this._changes.set(model.uri, true); + + // 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[] { + const result: URI[] = this._conflicts.keys(); + this._changes.forEach((_value, key) => { + if (!this._conflicts.has(key)) { + result.push(key); + } + }); + return result; + } +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 2c97f517bbc..c437848af7e 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -150,6 +150,9 @@ import 'vs/workbench/contrib/files/browser/files.contribution'; // Backup import 'vs/workbench/contrib/backup/common/backup.contribution'; +// bulkEdit +import 'vs/workbench/contrib/bulkEdit/browser/bulkEdit.contribution'; + // Search import 'vs/workbench/contrib/search/browser/search.contribution'; import 'vs/workbench/contrib/search/browser/searchView';