Path: blob/main/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.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 { IAsyncDataSource, ITreeRenderer, ITreeNode, ITreeSorter } from '../../../../../base/browser/ui/tree/tree.js';6import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';7import { FuzzyScore, createMatches } from '../../../../../base/common/filters.js';8import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js';9import { HighlightedLabel, IHighlight } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js';10import { IIdentityProvider, IListVirtualDelegate, IKeyboardNavigationLabelProvider } from '../../../../../base/browser/ui/list/list.js';11import { Range } from '../../../../../editor/common/core/range.js';12import * as dom from '../../../../../base/browser/dom.js';13import { ITextModel } from '../../../../../editor/common/model.js';14import { IDisposable, DisposableStore } from '../../../../../base/common/lifecycle.js';15import { TextModel } from '../../../../../editor/common/model/textModel.js';16import { BulkFileOperations, BulkFileOperation, BulkFileOperationType, BulkTextEdit, BulkCategory } from './bulkEditPreview.js';17import { FileKind } from '../../../../../platform/files/common/files.js';18import { localize } from '../../../../../nls.js';19import { ILabelService } from '../../../../../platform/label/common/label.js';20import type { IListAccessibilityProvider } from '../../../../../base/browser/ui/list/listWidget.js';21import { IconLabel } from '../../../../../base/browser/ui/iconLabel/iconLabel.js';22import { basename } from '../../../../../base/common/resources.js';23import { IThemeService } from '../../../../../platform/theme/common/themeService.js';24import { ThemeIcon } from '../../../../../base/common/themables.js';25import { compare } from '../../../../../base/common/strings.js';26import { URI } from '../../../../../base/common/uri.js';27import { ResourceFileEdit } from '../../../../../editor/browser/services/bulkEditService.js';28import { PLAINTEXT_LANGUAGE_ID } from '../../../../../editor/common/languages/modesRegistry.js';29import { SnippetParser } from '../../../../../editor/contrib/snippet/browser/snippetParser.js';30import { AriaRole } from '../../../../../base/browser/ui/aria/aria.js';31import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';32import * as css from '../../../../../base/browser/cssValue.js';3334// --- VIEW MODEL3536export interface ICheckable {37isChecked(): boolean;38setChecked(value: boolean): void;39}4041export class CategoryElement implements ICheckable {4243constructor(44readonly parent: BulkFileOperations,45readonly category: BulkCategory46) { }4748isChecked(): boolean {49const model = this.parent;50let checked = true;51for (const file of this.category.fileOperations) {52for (const edit of file.originalEdits.values()) {53checked = checked && model.checked.isChecked(edit);54}55}56return checked;57}5859setChecked(value: boolean): void {60const model = this.parent;61for (const file of this.category.fileOperations) {62for (const edit of file.originalEdits.values()) {63model.checked.updateChecked(edit, value);64}65}66}67}6869export class FileElement implements ICheckable {7071constructor(72readonly parent: CategoryElement | BulkFileOperations,73readonly edit: BulkFileOperation74) { }7576isChecked(): boolean {77const model = this.parent instanceof CategoryElement ? this.parent.parent : this.parent;7879let checked = true;8081// only text edit children -> reflect children state82if (this.edit.type === BulkFileOperationType.TextEdit) {83checked = !this.edit.textEdits.every(edit => !model.checked.isChecked(edit.textEdit));84}8586// multiple file edits -> reflect single state87for (const edit of this.edit.originalEdits.values()) {88if (edit instanceof ResourceFileEdit) {89checked = checked && model.checked.isChecked(edit);90}91}9293// multiple categories and text change -> read all elements94if (this.parent instanceof CategoryElement && this.edit.type === BulkFileOperationType.TextEdit) {95for (const category of model.categories) {96for (const file of category.fileOperations) {97if (file.uri.toString() === this.edit.uri.toString()) {98for (const edit of file.originalEdits.values()) {99if (edit instanceof ResourceFileEdit) {100checked = checked && model.checked.isChecked(edit);101}102}103}104}105}106}107108return checked;109}110111setChecked(value: boolean): void {112const model = this.parent instanceof CategoryElement ? this.parent.parent : this.parent;113for (const edit of this.edit.originalEdits.values()) {114model.checked.updateChecked(edit, value);115}116117// multiple categories and file change -> update all elements118if (this.parent instanceof CategoryElement && this.edit.type !== BulkFileOperationType.TextEdit) {119for (const category of model.categories) {120for (const file of category.fileOperations) {121if (file.uri.toString() === this.edit.uri.toString()) {122for (const edit of file.originalEdits.values()) {123model.checked.updateChecked(edit, value);124}125}126}127}128}129}130131isDisabled(): boolean {132if (this.parent instanceof CategoryElement && this.edit.type === BulkFileOperationType.TextEdit) {133const model = this.parent.parent;134let checked = true;135for (const category of model.categories) {136for (const file of category.fileOperations) {137if (file.uri.toString() === this.edit.uri.toString()) {138for (const edit of file.originalEdits.values()) {139if (edit instanceof ResourceFileEdit) {140checked = checked && model.checked.isChecked(edit);141}142}143}144}145}146return !checked;147}148return false;149}150}151152export class TextEditElement implements ICheckable {153154constructor(155readonly parent: FileElement,156readonly idx: number,157readonly edit: BulkTextEdit,158readonly prefix: string, readonly selecting: string, readonly inserting: string, readonly suffix: string159) { }160161isChecked(): boolean {162let model = this.parent.parent;163if (model instanceof CategoryElement) {164model = model.parent;165}166return model.checked.isChecked(this.edit.textEdit);167}168169setChecked(value: boolean): void {170let model = this.parent.parent;171if (model instanceof CategoryElement) {172model = model.parent;173}174175// check/uncheck this element176model.checked.updateChecked(this.edit.textEdit, value);177178// make sure parent is checked when this element is checked...179if (value) {180for (const edit of this.parent.edit.originalEdits.values()) {181if (edit instanceof ResourceFileEdit) {182(<BulkFileOperations>model).checked.updateChecked(edit, value);183}184}185}186}187188isDisabled(): boolean {189return this.parent.isDisabled();190}191}192193export type BulkEditElement = CategoryElement | FileElement | TextEditElement;194195// --- DATA SOURCE196197export class BulkEditDataSource implements IAsyncDataSource<BulkFileOperations, BulkEditElement> {198199public groupByFile: boolean = true;200201constructor(202@ITextModelService private readonly _textModelService: ITextModelService,203@IInstantiationService private readonly _instantiationService: IInstantiationService,204) { }205206hasChildren(element: BulkFileOperations | BulkEditElement): boolean {207if (element instanceof FileElement) {208return element.edit.textEdits.length > 0;209}210if (element instanceof TextEditElement) {211return false;212}213return true;214}215216async getChildren(element: BulkFileOperations | BulkEditElement): Promise<BulkEditElement[]> {217218// root -> file/text edits219if (element instanceof BulkFileOperations) {220return this.groupByFile221? element.fileOperations.map(op => new FileElement(element, op))222: element.categories.map(cat => new CategoryElement(element, cat));223}224225// category226if (element instanceof CategoryElement) {227return Array.from(element.category.fileOperations, op => new FileElement(element, op));228}229230// file: text edit231if (element instanceof FileElement && element.edit.textEdits.length > 0) {232// const previewUri = BulkEditPreviewProvider.asPreviewUri(element.edit.resource);233let textModel: ITextModel;234let textModelDisposable: IDisposable;235try {236const ref = await this._textModelService.createModelReference(element.edit.uri);237textModel = ref.object.textEditorModel;238textModelDisposable = ref;239} catch {240textModel = this._instantiationService.createInstance(TextModel, '', PLAINTEXT_LANGUAGE_ID, TextModel.DEFAULT_CREATION_OPTIONS, null);241textModelDisposable = textModel;242}243244const result = element.edit.textEdits.map((edit, idx) => {245const range = textModel.validateRange(edit.textEdit.textEdit.range);246247//prefix-math248const startTokens = textModel.tokenization.getLineTokens(range.startLineNumber);249let prefixLen = 23; // default value for the no tokens/grammar case250for (let idx = startTokens.findTokenIndexAtOffset(range.startColumn - 1) - 1; prefixLen < 50 && idx >= 0; idx--) {251prefixLen = range.startColumn - startTokens.getStartOffset(idx);252}253254//suffix-math255const endTokens = textModel.tokenization.getLineTokens(range.endLineNumber);256let suffixLen = 0;257for (let idx = endTokens.findTokenIndexAtOffset(range.endColumn - 1); suffixLen < 50 && idx < endTokens.getCount(); idx++) {258suffixLen += endTokens.getEndOffset(idx) - endTokens.getStartOffset(idx);259}260261return new TextEditElement(262element,263idx,264edit,265textModel.getValueInRange(new Range(range.startLineNumber, range.startColumn - prefixLen, range.startLineNumber, range.startColumn)),266textModel.getValueInRange(range),267!edit.textEdit.textEdit.insertAsSnippet ? edit.textEdit.textEdit.text : SnippetParser.asInsertText(edit.textEdit.textEdit.text),268textModel.getValueInRange(new Range(range.endLineNumber, range.endColumn, range.endLineNumber, range.endColumn + suffixLen))269);270});271272textModelDisposable.dispose();273return result;274}275276return [];277}278}279280281export class BulkEditSorter implements ITreeSorter<BulkEditElement> {282283compare(a: BulkEditElement, b: BulkEditElement): number {284if (a instanceof FileElement && b instanceof FileElement) {285return compareBulkFileOperations(a.edit, b.edit);286}287288if (a instanceof TextEditElement && b instanceof TextEditElement) {289return Range.compareRangesUsingStarts(a.edit.textEdit.textEdit.range, b.edit.textEdit.textEdit.range);290}291292return 0;293}294}295296export function compareBulkFileOperations(a: BulkFileOperation, b: BulkFileOperation): number {297return compare(a.uri.toString(), b.uri.toString());298}299300// --- ACCESSI301302export class BulkEditAccessibilityProvider implements IListAccessibilityProvider<BulkEditElement> {303304constructor(@ILabelService private readonly _labelService: ILabelService) { }305306getWidgetAriaLabel(): string {307return localize('bulkEdit', "Bulk Edit");308}309310getRole(_element: BulkEditElement): AriaRole {311return 'checkbox';312}313314getAriaLabel(element: BulkEditElement): string | null {315if (element instanceof FileElement) {316if (element.edit.textEdits.length > 0) {317if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {318return localize(319'aria.renameAndEdit', "Renaming {0} to {1}, also making text edits",320this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })321);322323} else if (element.edit.type & BulkFileOperationType.Create) {324return localize(325'aria.createAndEdit', "Creating {0}, also making text edits",326this._labelService.getUriLabel(element.edit.uri, { relative: true })327);328329} else if (element.edit.type & BulkFileOperationType.Delete) {330return localize(331'aria.deleteAndEdit', "Deleting {0}, also making text edits",332this._labelService.getUriLabel(element.edit.uri, { relative: true }),333);334} else {335return localize(336'aria.editOnly', "{0}, making text edits",337this._labelService.getUriLabel(element.edit.uri, { relative: true }),338);339}340341} else {342if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {343return localize(344'aria.rename', "Renaming {0} to {1}",345this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })346);347348} else if (element.edit.type & BulkFileOperationType.Create) {349return localize(350'aria.create', "Creating {0}",351this._labelService.getUriLabel(element.edit.uri, { relative: true })352);353354} else if (element.edit.type & BulkFileOperationType.Delete) {355return localize(356'aria.delete', "Deleting {0}",357this._labelService.getUriLabel(element.edit.uri, { relative: true }),358);359}360}361}362363if (element instanceof TextEditElement) {364if (element.selecting.length > 0 && element.inserting.length > 0) {365// edit: replace366return localize('aria.replace', "line {0}, replacing {1} with {2}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting, element.inserting);367} else if (element.selecting.length > 0 && element.inserting.length === 0) {368// edit: delete369return localize('aria.del', "line {0}, removing {1}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting);370} else if (element.selecting.length === 0 && element.inserting.length > 0) {371// edit: insert372return localize('aria.insert', "line {0}, inserting {1}", element.edit.textEdit.textEdit.range.startLineNumber, element.selecting);373}374}375376return null;377}378}379380// --- IDENT381382export class BulkEditIdentityProvider implements IIdentityProvider<BulkEditElement> {383384getId(element: BulkEditElement): { toString(): string } {385if (element instanceof FileElement) {386return element.edit.uri + (element.parent instanceof CategoryElement ? JSON.stringify(element.parent.category.metadata) : '');387} else if (element instanceof TextEditElement) {388return element.parent.edit.uri.toString() + element.idx;389} else {390return JSON.stringify(element.category.metadata);391}392}393}394395// --- RENDERER396397class CategoryElementTemplate {398399readonly icon: HTMLDivElement;400readonly label: IconLabel;401402constructor(container: HTMLElement) {403container.classList.add('category');404this.icon = document.createElement('div');405container.appendChild(this.icon);406this.label = new IconLabel(container);407}408}409410export class CategoryElementRenderer implements ITreeRenderer<CategoryElement, FuzzyScore, CategoryElementTemplate> {411412static readonly id: string = 'CategoryElementRenderer';413414readonly templateId: string = CategoryElementRenderer.id;415416constructor(@IThemeService private readonly _themeService: IThemeService) { }417418renderTemplate(container: HTMLElement): CategoryElementTemplate {419return new CategoryElementTemplate(container);420}421422renderElement(node: ITreeNode<CategoryElement, FuzzyScore>, _index: number, template: CategoryElementTemplate): void {423424template.icon.style.setProperty('--background-dark', null);425template.icon.style.setProperty('--background-light', null);426template.icon.style.color = '';427428const { metadata } = node.element.category;429if (ThemeIcon.isThemeIcon(metadata.iconPath)) {430// css431const className = ThemeIcon.asClassName(metadata.iconPath);432template.icon.className = className ? `theme-icon ${className}` : '';433template.icon.style.color = metadata.iconPath.color ? this._themeService.getColorTheme().getColor(metadata.iconPath.color.id)?.toString() ?? '' : '';434435436} else if (URI.isUri(metadata.iconPath)) {437// background-image438template.icon.className = 'uri-icon';439template.icon.style.setProperty('--background-dark', css.asCSSUrl(metadata.iconPath));440template.icon.style.setProperty('--background-light', css.asCSSUrl(metadata.iconPath));441442} else if (metadata.iconPath) {443// background-image444template.icon.className = 'uri-icon';445template.icon.style.setProperty('--background-dark', css.asCSSUrl(metadata.iconPath.dark));446template.icon.style.setProperty('--background-light', css.asCSSUrl(metadata.iconPath.light));447}448449template.label.setLabel(metadata.label, metadata.description, {450descriptionMatches: createMatches(node.filterData),451});452}453454disposeTemplate(template: CategoryElementTemplate): void {455template.label.dispose();456}457}458459class FileElementTemplate {460461private readonly _disposables = new DisposableStore();462private readonly _localDisposables = new DisposableStore();463464private readonly _checkbox: HTMLInputElement;465private readonly _label: IResourceLabel;466private readonly _details: HTMLSpanElement;467468constructor(469container: HTMLElement,470resourceLabels: ResourceLabels,471@ILabelService private readonly _labelService: ILabelService,472) {473474this._checkbox = document.createElement('input');475this._checkbox.className = 'edit-checkbox';476this._checkbox.type = 'checkbox';477this._checkbox.setAttribute('role', 'checkbox');478container.appendChild(this._checkbox);479480this._label = resourceLabels.create(container, { supportHighlights: true });481482this._details = document.createElement('span');483this._details.className = 'details';484container.appendChild(this._details);485}486487dispose(): void {488this._localDisposables.dispose();489this._disposables.dispose();490this._label.dispose();491}492493set(element: FileElement, score: FuzzyScore | undefined) {494this._localDisposables.clear();495496this._checkbox.checked = element.isChecked();497this._checkbox.disabled = element.isDisabled();498this._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', () => {499element.setChecked(this._checkbox.checked);500}));501502if (element.edit.type & BulkFileOperationType.Rename && element.edit.newUri) {503// rename: oldName → newName504this._label.setResource({505resource: element.edit.uri,506name: localize('rename.label', "{0} → {1}", this._labelService.getUriLabel(element.edit.uri, { relative: true }), this._labelService.getUriLabel(element.edit.newUri, { relative: true })),507}, {508fileDecorations: { colors: true, badges: false }509});510511this._details.innerText = localize('detail.rename', "(renaming)");512513} else {514// create, delete, edit: NAME515const options = {516matches: createMatches(score),517fileKind: FileKind.FILE,518fileDecorations: { colors: true, badges: false },519extraClasses: <string[]>[]520};521if (element.edit.type & BulkFileOperationType.Create) {522this._details.innerText = localize('detail.create', "(creating)");523} else if (element.edit.type & BulkFileOperationType.Delete) {524this._details.innerText = localize('detail.del', "(deleting)");525options.extraClasses.push('delete');526} else {527this._details.innerText = '';528}529this._label.setFile(element.edit.uri, options);530}531}532}533534export class FileElementRenderer implements ITreeRenderer<FileElement, FuzzyScore, FileElementTemplate> {535536static readonly id: string = 'FileElementRenderer';537538readonly templateId: string = FileElementRenderer.id;539540constructor(541private readonly _resourceLabels: ResourceLabels,542@ILabelService private readonly _labelService: ILabelService,543) { }544545renderTemplate(container: HTMLElement): FileElementTemplate {546return new FileElementTemplate(container, this._resourceLabels, this._labelService);547}548549renderElement(node: ITreeNode<FileElement, FuzzyScore>, _index: number, template: FileElementTemplate): void {550template.set(node.element, node.filterData);551}552553disposeTemplate(template: FileElementTemplate): void {554template.dispose();555}556}557558class TextEditElementTemplate {559560private readonly _disposables = new DisposableStore();561private readonly _localDisposables = new DisposableStore();562563private readonly _checkbox: HTMLInputElement;564private readonly _icon: HTMLDivElement;565private readonly _label: HighlightedLabel;566567constructor(container: HTMLElement, @IThemeService private readonly _themeService: IThemeService) {568container.classList.add('textedit');569570this._checkbox = document.createElement('input');571this._checkbox.className = 'edit-checkbox';572this._checkbox.type = 'checkbox';573this._checkbox.setAttribute('role', 'checkbox');574container.appendChild(this._checkbox);575576this._icon = document.createElement('div');577container.appendChild(this._icon);578579this._label = this._disposables.add(new HighlightedLabel(container));580}581582dispose(): void {583this._localDisposables.dispose();584this._disposables.dispose();585}586587set(element: TextEditElement) {588this._localDisposables.clear();589590this._localDisposables.add(dom.addDisposableListener(this._checkbox, 'change', e => {591element.setChecked(this._checkbox.checked);592e.preventDefault();593}));594if (element.parent.isChecked()) {595this._checkbox.checked = element.isChecked();596this._checkbox.disabled = element.isDisabled();597} else {598this._checkbox.checked = element.isChecked();599this._checkbox.disabled = element.isDisabled();600}601602let value = '';603value += element.prefix;604value += element.selecting;605value += element.inserting;606value += element.suffix;607608const selectHighlight: IHighlight = { start: element.prefix.length, end: element.prefix.length + element.selecting.length, extraClasses: ['remove'] };609const insertHighlight: IHighlight = { start: selectHighlight.end, end: selectHighlight.end + element.inserting.length, extraClasses: ['insert'] };610611let title: string | undefined;612const { metadata } = element.edit.textEdit;613if (metadata && metadata.description) {614title = localize('title', "{0} - {1}", metadata.label, metadata.description);615} else if (metadata) {616title = metadata.label;617}618619const iconPath = metadata?.iconPath;620if (!iconPath) {621this._icon.style.display = 'none';622} else {623this._icon.style.display = 'block';624625this._icon.style.setProperty('--background-dark', null);626this._icon.style.setProperty('--background-light', null);627628if (ThemeIcon.isThemeIcon(iconPath)) {629// css630const className = ThemeIcon.asClassName(iconPath);631this._icon.className = className ? `theme-icon ${className}` : '';632this._icon.style.color = iconPath.color ? this._themeService.getColorTheme().getColor(iconPath.color.id)?.toString() ?? '' : '';633634635} else if (URI.isUri(iconPath)) {636// background-image637this._icon.className = 'uri-icon';638this._icon.style.setProperty('--background-dark', css.asCSSUrl(iconPath));639this._icon.style.setProperty('--background-light', css.asCSSUrl(iconPath));640641} else {642// background-image643this._icon.className = 'uri-icon';644this._icon.style.setProperty('--background-dark', css.asCSSUrl(iconPath.dark));645this._icon.style.setProperty('--background-light', css.asCSSUrl(iconPath.light));646}647}648649this._label.set(value, [selectHighlight, insertHighlight], title, true);650this._icon.title = title || '';651}652}653654export class TextEditElementRenderer implements ITreeRenderer<TextEditElement, FuzzyScore, TextEditElementTemplate> {655656static readonly id = 'TextEditElementRenderer';657658readonly templateId: string = TextEditElementRenderer.id;659660constructor(@IThemeService private readonly _themeService: IThemeService) { }661662renderTemplate(container: HTMLElement): TextEditElementTemplate {663return new TextEditElementTemplate(container, this._themeService);664}665666renderElement({ element }: ITreeNode<TextEditElement, FuzzyScore>, _index: number, template: TextEditElementTemplate): void {667template.set(element);668}669670disposeTemplate(_template: TextEditElementTemplate): void { }671}672673export class BulkEditDelegate implements IListVirtualDelegate<BulkEditElement> {674675getHeight(): number {676return 23;677}678679getTemplateId(element: BulkEditElement): string {680681if (element instanceof FileElement) {682return FileElementRenderer.id;683} else if (element instanceof TextEditElement) {684return TextEditElementRenderer.id;685} else {686return CategoryElementRenderer.id;687}688}689}690691692export class BulkEditNaviLabelProvider implements IKeyboardNavigationLabelProvider<BulkEditElement> {693694getKeyboardNavigationLabel(element: BulkEditElement) {695if (element instanceof FileElement) {696return basename(element.edit.uri);697} else if (element instanceof CategoryElement) {698return element.category.metadata.label;699}700return undefined;701}702}703704705