Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts
5297 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();106this._sessionDisposables.dispose();107super.dispose();108}109110protected override renderBody(parent: HTMLElement): void {111super.renderBody(parent);112113const resourceLabels = this._instaService.createInstance(114ResourceLabels,115{ onDidChangeVisibility: this.onDidChangeBodyVisibility }116);117this._disposables.add(resourceLabels);118119const contentContainer = document.createElement('div');120contentContainer.className = 'content';121parent.appendChild(contentContainer);122123// tree124const treeContainer = document.createElement('div');125contentContainer.appendChild(treeContainer);126127this._treeDataSource = this._instaService.createInstance(BulkEditDataSource);128this._treeDataSource.groupByFile = this._storageService.getBoolean(BulkEditPane._memGroupByFile, StorageScope.PROFILE, true);129this._ctxGroupByFile.set(this._treeDataSource.groupByFile);130131this._tree = this._instaService.createInstance(132WorkbenchAsyncDataTree<BulkFileOperations, BulkEditElement, FuzzyScore>, this.id, treeContainer,133new BulkEditDelegate(),134[this._instaService.createInstance(TextEditElementRenderer), this._instaService.createInstance(FileElementRenderer, resourceLabels), this._instaService.createInstance(CategoryElementRenderer)],135this._treeDataSource,136{137accessibilityProvider: this._instaService.createInstance(BulkEditAccessibilityProvider),138identityProvider: new BulkEditIdentityProvider(),139expandOnlyOnTwistieClick: true,140multipleSelectionSupport: false,141keyboardNavigationLabelProvider: new BulkEditNaviLabelProvider(),142sorter: new BulkEditSorter(),143selectionNavigation: true144}145);146147this._disposables.add(this._tree.onContextMenu(this._onContextMenu, this));148this._disposables.add(this._tree.onDidOpen(e => this._openElementInMultiDiffEditor(e)));149150// buttons151const buttonsContainer = document.createElement('div');152buttonsContainer.className = 'buttons';153contentContainer.appendChild(buttonsContainer);154const buttonBar = new ButtonBar(buttonsContainer);155this._disposables.add(buttonBar);156157const btnConfirm = buttonBar.addButton({ supportIcons: true, ...defaultButtonStyles });158btnConfirm.label = localize('ok', 'Apply');159btnConfirm.onDidClick(() => this.accept(), this, this._disposables);160161const btnCancel = buttonBar.addButton({ ...defaultButtonStyles, secondary: true });162btnCancel.label = localize('cancel', 'Discard');163btnCancel.onDidClick(() => this.discard(), this, this._disposables);164165// message166this._message = document.createElement('span');167this._message.className = 'message';168this._message.innerText = localize('empty.msg', "Invoke a code action, like rename, to see a preview of its changes here.");169parent.appendChild(this._message);170171//172this._setState(State.Message);173}174175protected override layoutBody(height: number, width: number): void {176super.layoutBody(height, width);177const treeHeight = height - 50;178this._tree.getHTMLElement().parentElement!.style.height = `${treeHeight}px`;179this._tree.layout(treeHeight, width);180}181182private _setState(state: State): void {183this.element.dataset['state'] = state;184}185186async setInput(edit: ResourceEdit[], token: CancellationToken): Promise<ResourceEdit[] | undefined> {187this._setState(State.Data);188this._sessionDisposables.clear();189this._treeViewStates.clear();190191if (this._currentResolve) {192this._currentResolve(undefined);193this._currentResolve = undefined;194}195196const input = await this._instaService.invokeFunction(BulkFileOperations.create, edit);197this._currentProvider = this._instaService.createInstance(BulkEditPreviewProvider, input);198this._sessionDisposables.add(this._currentProvider);199this._sessionDisposables.add(input);200201//202const hasCategories = input.categories.length > 1;203this._ctxHasCategories.set(hasCategories);204this._treeDataSource.groupByFile = !hasCategories || this._treeDataSource.groupByFile;205this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);206207this._currentInput = input;208209return new Promise<ResourceEdit[] | undefined>(resolve => {210211token.onCancellationRequested(() => resolve(undefined));212213this._currentResolve = resolve;214this._setTreeInput(input);215216// refresh when check state changes217this._sessionDisposables.add(input.checked.onDidChange(() => {218this._tree.updateChildren();219this._ctxHasCheckedChanges.set(input.checked.checkedCount > 0);220}));221});222}223224hasInput(): boolean {225return Boolean(this._currentInput);226}227228private async _setTreeInput(input: BulkFileOperations) {229230const viewState = this._treeViewStates.get(this._treeDataSource.groupByFile);231await this._tree.setInput(input, viewState);232this._tree.domFocus();233234if (viewState) {235return;236}237238// async expandAll (max=10) is the default when no view state is given239const expand = [...this._tree.getNode(input).children].slice(0, 10);240while (expand.length > 0) {241const { element } = expand.shift()!;242if (element instanceof FileElement) {243await this._tree.expand(element, true);244}245if (element instanceof CategoryElement) {246await this._tree.expand(element, true);247expand.push(...this._tree.getNode(element).children);248}249}250}251252accept(): void {253254const conflicts = this._currentInput?.conflicts.list();255256if (!conflicts || conflicts.length === 0) {257this._done(true);258return;259}260261let message: string;262if (conflicts.length === 1) {263message = localize('conflict.1', "Cannot apply refactoring because '{0}' has changed in the meantime.", this._labelService.getUriLabel(conflicts[0], { relative: true }));264} else {265message = localize('conflict.N', "Cannot apply refactoring because {0} other files have changed in the meantime.", conflicts.length);266}267268this._dialogService.warn(message).finally(() => this._done(false));269}270271discard() {272this._done(false);273}274275private _done(accept: boolean): void {276this._currentResolve?.(accept ? this._currentInput?.getWorkspaceEdit() : undefined);277this._currentInput = undefined;278this._setState(State.Message);279this._sessionDisposables.clear();280}281282toggleChecked() {283const [first] = this._tree.getFocus();284if ((first instanceof FileElement || first instanceof TextEditElement) && !first.isDisabled()) {285first.setChecked(!first.isChecked());286} else if (first instanceof CategoryElement) {287first.setChecked(!first.isChecked());288}289}290291groupByFile(): void {292if (!this._treeDataSource.groupByFile) {293this.toggleGrouping();294}295}296297groupByType(): void {298if (this._treeDataSource.groupByFile) {299this.toggleGrouping();300}301}302303toggleGrouping() {304const input = this._tree.getInput();305if (input) {306307// (1) capture view state308const oldViewState = this._tree.getViewState();309this._treeViewStates.set(this._treeDataSource.groupByFile, oldViewState);310311// (2) toggle and update312this._treeDataSource.groupByFile = !this._treeDataSource.groupByFile;313this._setTreeInput(input);314315// (3) remember preference316this._storageService.store(BulkEditPane._memGroupByFile, this._treeDataSource.groupByFile, StorageScope.PROFILE, StorageTarget.USER);317this._ctxGroupByFile.set(this._treeDataSource.groupByFile);318}319}320321private async _openElementInMultiDiffEditor(e: IOpenEvent<BulkEditElement | undefined>): Promise<void> {322323const fileOperations = this._currentInput?.fileOperations;324if (!fileOperations) {325return;326}327328let selection: IRange | undefined = undefined;329let fileElement: FileElement;330if (e.element instanceof TextEditElement) {331fileElement = e.element.parent;332selection = e.element.edit.textEdit.textEdit.range;333} else if (e.element instanceof FileElement) {334fileElement = e.element;335selection = e.element.edit.textEdits[0]?.textEdit.textEdit.range;336} else {337// invalid event338return;339}340341const result = await this._computeResourceDiffEditorInputs.get(fileOperations);342const resourceId = await result.getResourceDiffEditorInputIdOfOperation(fileElement.edit);343const options: Mutable<IMultiDiffEditorOptions> = {344...e.editorOptions,345viewState: {346revealData: {347resource: resourceId,348range: selection,349}350}351};352const multiDiffSource = URI.from({ scheme: BulkEditPane.Schema });353const label = 'Refactor Preview';354this._editorService.openEditor({355multiDiffSource,356label,357options,358isTransient: true,359description: label,360resources: result.resources361}, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP);362}363364private readonly _computeResourceDiffEditorInputs = new LRUCachedFunction<365BulkFileOperation[],366Promise<{ resources: IMultiDiffEditorResource[]; getResourceDiffEditorInputIdOfOperation: (operation: BulkFileOperation) => Promise<IMultiDiffResourceId> }>367>(async (fileOperations) => {368const computeDiffEditorInput = new CachedFunction<BulkFileOperation, Promise<IMultiDiffEditorResource>>(async (fileOperation) => {369const fileOperationUri = fileOperation.uri;370const previewUri = this._currentProvider!.asPreviewUri(fileOperationUri);371// delete372if (fileOperation.type & BulkFileOperationType.Delete) {373return {374original: { resource: URI.revive(previewUri) },375modified: { resource: undefined },376goToFileResource: fileOperation.uri,377} satisfies IMultiDiffEditorResource;378379}380// rename, create, edits381else {382let leftResource: URI | undefined;383try {384(await this._textModelService.createModelReference(fileOperationUri)).dispose();385leftResource = fileOperationUri;386} catch {387leftResource = BulkEditPreviewProvider.emptyPreview;388}389return {390original: { resource: URI.revive(leftResource) },391modified: { resource: URI.revive(previewUri) },392goToFileResource: leftResource,393} satisfies IMultiDiffEditorResource;394}395});396397const sortedFileOperations = fileOperations.slice().sort(compareBulkFileOperations);398const resources: IResourceDiffEditorInput[] = [];399for (const operation of sortedFileOperations) {400resources.push(await computeDiffEditorInput.get(operation));401}402const getResourceDiffEditorInputIdOfOperation = async (operation: BulkFileOperation): Promise<IMultiDiffResourceId> => {403const resource = await computeDiffEditorInput.get(operation);404return { original: resource.original.resource, modified: resource.modified.resource };405};406return {407resources,408getResourceDiffEditorInputIdOfOperation409};410});411412private _onContextMenu(e: ITreeContextMenuEvent<any>): void {413414this._contextMenuService.showContextMenu({415menuId: MenuId.BulkEditContext,416contextKeyService: this.contextKeyService,417getAnchor: () => e.anchor418});419}420}421422423