Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ButtonBar } from '../../../../../base/browser/ui/button/button.js';6import type { IAsyncDataTreeViewState } from '../../../../../base/browser/ui/tree/asyncDataTree.js';7import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js';8import { CachedFunction, LRUCachedFunction } from '../../../../../base/common/cache.js';9import { CancellationToken } from '../../../../../base/common/cancellation.js';10import { FuzzyScore } from '../../../../../base/common/filters.js';11import { DisposableStore } from '../../../../../base/common/lifecycle.js';12import { Mutable } from '../../../../../base/common/types.js';13import { URI } from '../../../../../base/common/uri.js';14import './bulkEdit.css';15import { ResourceEdit } from '../../../../../editor/browser/services/bulkEditService.js';16import { IMultiDiffEditorOptions, IMultiDiffResourceId } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js';17import { IRange } from '../../../../../editor/common/core/range.js';18import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';19import { localize } from '../../../../../nls.js';20import { MenuId } from '../../../../../platform/actions/common/actions.js';21import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';22import { IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js';23import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';24import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';25import { IHoverService } from '../../../../../platform/hover/browser/hover.js';26import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';27import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';28import { ILabelService } from '../../../../../platform/label/common/label.js';29import { IOpenEvent, WorkbenchAsyncDataTree } from '../../../../../platform/list/browser/listService.js';30import { IOpenerService } from '../../../../../platform/opener/common/opener.js';31import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';32import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';33import { IThemeService } from '../../../../../platform/theme/common/themeService.js';34import { ResourceLabels } from '../../../../browser/labels.js';35import { ViewPane } from '../../../../browser/parts/views/viewPane.js';36import { IViewletViewOptions } from '../../../../browser/parts/views/viewsViewlet.js';37import { IMultiDiffEditorResource, IResourceDiffEditorInput } from '../../../../common/editor.js';38import { IViewDescriptorService } from '../../../../common/views.js';39import { BulkEditPreviewProvider, BulkFileOperation, BulkFileOperations, BulkFileOperationType } from './bulkEditPreview.js';40import { BulkEditAccessibilityProvider, BulkEditDataSource, BulkEditDelegate, BulkEditElement, BulkEditIdentityProvider, BulkEditNaviLabelProvider, BulkEditSorter, CategoryElement, CategoryElementRenderer, compareBulkFileOperations, FileElement, FileElementRenderer, TextEditElement, TextEditElementRenderer } from './bulkEditTree.js';41import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js';4243const enum State {44Data = 'data',45Message = 'message'46}4748export class BulkEditPane extends ViewPane {4950static readonly ID = 'refactorPreview';51static readonly Schema = 'vscode-bulkeditpreview-multieditor';5253static readonly ctxHasCategories = new RawContextKey('refactorPreview.hasCategories', false);54static readonly ctxGroupByFile = new RawContextKey('refactorPreview.groupByFile', true);55static readonly ctxHasCheckedChanges = new RawContextKey('refactorPreview.hasCheckedChanges', true);5657private static readonly _memGroupByFile = `${this.ID}.groupByFile`;5859private _tree!: WorkbenchAsyncDataTree<BulkFileOperations, BulkEditElement, FuzzyScore>;60private _treeDataSource!: BulkEditDataSource;61private _treeViewStates = new Map<boolean, IAsyncDataTreeViewState>();62private _message!: HTMLSpanElement;6364private readonly _ctxHasCategories: IContextKey<boolean>;65private readonly _ctxGroupByFile: IContextKey<boolean>;66private readonly _ctxHasCheckedChanges: IContextKey<boolean>;6768private readonly _disposables = new DisposableStore();69private readonly _sessionDisposables = new DisposableStore();70private _currentResolve?: (edit?: ResourceEdit[]) => void;71private _currentInput?: BulkFileOperations;72private _currentProvider?: BulkEditPreviewProvider;7374constructor(75options: IViewletViewOptions,76@IInstantiationService private readonly _instaService: IInstantiationService,77@IEditorService private readonly _editorService: IEditorService,78@ILabelService private readonly _labelService: ILabelService,79@ITextModelService private readonly _textModelService: ITextModelService,80@IDialogService private readonly _dialogService: IDialogService,81@IContextMenuService private readonly _contextMenuService: IContextMenuService,82@IStorageService private readonly _storageService: IStorageService,83@IContextKeyService contextKeyService: IContextKeyService,84@IViewDescriptorService viewDescriptorService: IViewDescriptorService,85@IKeybindingService keybindingService: IKeybindingService,86@IContextMenuService contextMenuService: IContextMenuService,87@IConfigurationService configurationService: IConfigurationService,88@IOpenerService openerService: IOpenerService,89@IThemeService themeService: IThemeService,90@IHoverService hoverService: IHoverService,91) {92super(93{ ...options, titleMenuId: MenuId.BulkEditTitle },94keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, _instaService, openerService, themeService, hoverService95);9697this.element.classList.add('bulk-edit-panel', 'show-file-icons');98this._ctxHasCategories = BulkEditPane.ctxHasCategories.bindTo(contextKeyService);99this._ctxGroupByFile = BulkEditPane.ctxGroupByFile.bindTo(contextKeyService);100this._ctxHasCheckedChanges = BulkEditPane.ctxHasCheckedChanges.bindTo(contextKeyService);101}102103override dispose(): void {104this._tree.dispose();105this._disposables.dispose();106super.dispose();107}108109protected override renderBody(parent: HTMLElement): void {110super.renderBody(parent);111112const resourceLabels = this._instaService.createInstance(113ResourceLabels,114{ onDidChangeVisibility: this.onDidChangeBodyVisibility }115);116this._disposables.add(resourceLabels);117118const contentContainer = document.createElement('div');119contentContainer.className = 'content';120parent.appendChild(contentContainer);121122// tree123const treeContainer = document.createElement('div');124contentContainer.appendChild(treeContainer);125126this._treeDataSource = this._instaService.createInstance(BulkEditDataSource);127this._treeDataSource.groupByFile = this._storageService.getBoolean(BulkEditPane._memGroupByFile, StorageScope.PROFILE, true);128this._ctxGroupByFile.set(this._treeDataSource.groupByFile);129130this._tree = this._instaService.createInstance(131WorkbenchAsyncDataTree<BulkFileOperations, BulkEditElement, FuzzyScore>, this.id, treeContainer,132new BulkEditDelegate(),133[this._instaService.createInstance(TextEditElementRenderer), this._instaService.createInstance(FileElementRenderer, resourceLabels), this._instaService.createInstance(CategoryElementRenderer)],134this._treeDataSource,135{136accessibilityProvider: this._instaService.createInstance(BulkEditAccessibilityProvider),137identityProvider: new BulkEditIdentityProvider(),138expandOnlyOnTwistieClick: true,139multipleSelectionSupport: false,140keyboardNavigationLabelProvider: new BulkEditNaviLabelProvider(),141sorter: new BulkEditSorter(),142selectionNavigation: true143}144);145146this._disposables.add(this._tree.onContextMenu(this._onContextMenu, this));147this._disposables.add(this._tree.onDidOpen(e => this._openElementInMultiDiffEditor(e)));148149// buttons150const buttonsContainer = document.createElement('div');151buttonsContainer.className = 'buttons';152contentContainer.appendChild(buttonsContainer);153const buttonBar = new ButtonBar(buttonsContainer);154this._disposables.add(buttonBar);155156const btnConfirm = buttonBar.addButton({ supportIcons: true, ...defaultButtonStyles });157btnConfirm.label = localize('ok', 'Apply');158btnConfirm.onDidClick(() => this.accept(), this, this._disposables);159160const btnCancel = buttonBar.addButton({ ...defaultButtonStyles, secondary: true });161btnCancel.label = localize('cancel', 'Discard');162btnCancel.onDidClick(() => this.discard(), this, this._disposables);163164// message165this._message = document.createElement('span');166this._message.className = 'message';167this._message.innerText = localize('empty.msg', "Invoke a code action, like rename, to see a preview of its changes here.");168parent.appendChild(this._message);169170//171this._setState(State.Message);172}173174protected override layoutBody(height: number, width: number): void {175super.layoutBody(height, width);176const treeHeight = height - 50;177this._tree.getHTMLElement().parentElement!.style.height = `${treeHeight}px`;178this._tree.layout(treeHeight, width);179}180181private _setState(state: State): void {182this.element.dataset['state'] = state;183}184185async setInput(edit: ResourceEdit[], token: CancellationToken): Promise<ResourceEdit[] | undefined> {186this._setState(State.Data);187this._sessionDisposables.clear();188this._treeViewStates.clear();189190if (this._currentResolve) {191this._currentResolve(undefined);192this._currentResolve = undefined;193}194195const input = await this._instaService.invokeFunction(BulkFileOperations.create, edit);196this._currentProvider = this._instaService.createInstance(BulkEditPreviewProvider, input);197this._sessionDisposables.add(this._currentProvider);198this._sessionDisposables.add(input);199200//201const hasCategories = input.categories.length > 1;202this._ctxHasCategories.set(hasCategories);203this._treeDataSource.groupByFile = !hasCategories || this._treeDataSource.groupByFile;204this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);205206this._currentInput = input;207208return new Promise<ResourceEdit[] | undefined>(resolve => {209210token.onCancellationRequested(() => resolve(undefined));211212this._currentResolve = resolve;213this._setTreeInput(input);214215// refresh when check state changes216this._sessionDisposables.add(input.checked.onDidChange(() => {217this._tree.updateChildren();218this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);219}));220});221}222223hasInput(): boolean {224return Boolean(this._currentInput);225}226227private async _setTreeInput(input: BulkFileOperations) {228229const viewState = this._treeViewStates.get(this._treeDataSource.groupByFile);230await this._tree.setInput(input, viewState);231this._tree.domFocus();232233if (viewState) {234return;235}236237// async expandAll (max=10) is the default when no view state is given238const expand = [...this._tree.getNode(input).children].slice(0, 10);239while (expand.length > 0) {240const { element } = expand.shift()!;241if (element instanceof FileElement) {242await this._tree.expand(element, true);243}244if (element instanceof CategoryElement) {245await this._tree.expand(element, true);246expand.push(...this._tree.getNode(element).children);247}248}249}250251accept(): void {252253const conflicts = this._currentInput?.conflicts.list();254255if (!conflicts || conflicts.length === 0) {256this._done(true);257return;258}259260let message: string;261if (conflicts.length === 1) {262message = localize('conflict.1', "Cannot apply refactoring because '{0}' has changed in the meantime.", this._labelService.getUriLabel(conflicts[0], { relative: true }));263} else {264message = localize('conflict.N', "Cannot apply refactoring because {0} other files have changed in the meantime.", conflicts.length);265}266267this._dialogService.warn(message).finally(() => this._done(false));268}269270discard() {271this._done(false);272}273274private _done(accept: boolean): void {275this._currentResolve?.(accept ? this._currentInput?.getWorkspaceEdit() : undefined);276this._currentInput = undefined;277this._setState(State.Message);278this._sessionDisposables.clear();279}280281toggleChecked() {282const [first] = this._tree.getFocus();283if ((first instanceof FileElement || first instanceof TextEditElement) && !first.isDisabled()) {284first.setChecked(!first.isChecked());285} else if (first instanceof CategoryElement) {286first.setChecked(!first.isChecked());287}288}289290groupByFile(): void {291if (!this._treeDataSource.groupByFile) {292this.toggleGrouping();293}294}295296groupByType(): void {297if (this._treeDataSource.groupByFile) {298this.toggleGrouping();299}300}301302toggleGrouping() {303const input = this._tree.getInput();304if (input) {305306// (1) capture view state307const oldViewState = this._tree.getViewState();308this._treeViewStates.set(this._treeDataSource.groupByFile, oldViewState);309310// (2) toggle and update311this._treeDataSource.groupByFile = !this._treeDataSource.groupByFile;312this._setTreeInput(input);313314// (3) remember preference315this._storageService.store(BulkEditPane._memGroupByFile, this._treeDataSource.groupByFile, StorageScope.PROFILE, StorageTarget.USER);316this._ctxGroupByFile.set(this._treeDataSource.groupByFile);317}318}319320private async _openElementInMultiDiffEditor(e: IOpenEvent<BulkEditElement | undefined>): Promise<void> {321322const fileOperations = this._currentInput?.fileOperations;323if (!fileOperations) {324return;325}326327let selection: IRange | undefined = undefined;328let fileElement: FileElement;329if (e.element instanceof TextEditElement) {330fileElement = e.element.parent;331selection = e.element.edit.textEdit.textEdit.range;332} else if (e.element instanceof FileElement) {333fileElement = e.element;334selection = e.element.edit.textEdits[0]?.textEdit.textEdit.range;335} else {336// invalid event337return;338}339340const result = await this._computeResourceDiffEditorInputs.get(fileOperations);341const resourceId = await result.getResourceDiffEditorInputIdOfOperation(fileElement.edit);342const options: Mutable<IMultiDiffEditorOptions> = {343...e.editorOptions,344viewState: {345revealData: {346resource: resourceId,347range: selection,348}349}350};351const multiDiffSource = URI.from({ scheme: BulkEditPane.Schema });352const label = 'Refactor Preview';353this._editorService.openEditor({354multiDiffSource,355label,356options,357isTransient: true,358description: label,359resources: result.resources360}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);361}362363private readonly _computeResourceDiffEditorInputs = new LRUCachedFunction<364BulkFileOperation[],365Promise<{ resources: IMultiDiffEditorResource[]; getResourceDiffEditorInputIdOfOperation: (operation: BulkFileOperation) => Promise<IMultiDiffResourceId> }>366>(async (fileOperations) => {367const computeDiffEditorInput = new CachedFunction<BulkFileOperation, Promise<IMultiDiffEditorResource>>(async (fileOperation) => {368const fileOperationUri = fileOperation.uri;369const previewUri = this._currentProvider!.asPreviewUri(fileOperationUri);370// delete371if (fileOperation.type & BulkFileOperationType.Delete) {372return {373original: { resource: URI.revive(previewUri) },374modified: { resource: undefined },375goToFileResource: fileOperation.uri,376} satisfies IMultiDiffEditorResource;377378}379// rename, create, edits380else {381let leftResource: URI | undefined;382try {383(await this._textModelService.createModelReference(fileOperationUri)).dispose();384leftResource = fileOperationUri;385} catch {386leftResource = BulkEditPreviewProvider.emptyPreview;387}388return {389original: { resource: URI.revive(leftResource) },390modified: { resource: URI.revive(previewUri) },391goToFileResource: leftResource,392} satisfies IMultiDiffEditorResource;393}394});395396const sortedFileOperations = fileOperations.slice().sort(compareBulkFileOperations);397const resources: IResourceDiffEditorInput[] = [];398for (const operation of sortedFileOperations) {399resources.push(await computeDiffEditorInput.get(operation));400}401const getResourceDiffEditorInputIdOfOperation = async (operation: BulkFileOperation): Promise<IMultiDiffResourceId> => {402const resource = await computeDiffEditorInput.get(operation);403return { original: resource.original.resource, modified: resource.modified.resource };404};405return {406resources,407getResourceDiffEditorInputIdOfOperation408};409});410411private _onContextMenu(e: ITreeContextMenuEvent<any>): void {412413this._contextMenuService.showContextMenu({414menuId: MenuId.BulkEditContext,415contextKeyService: this.contextKeyService,416getAnchor: () => e.anchor417});418}419}420421422