/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import * as DOM from 'vs/base/browser/dom'; import * as glob from 'vs/base/common/glob'; import { IListVirtualDelegate, ListDragOverEffect } from 'vs/base/browser/ui/list/list'; import { IProgressService, ProgressLocation, IProgressStep, IProgress } from 'vs/platform/progress/common/progress'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IFileService, FileKind, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, ByteSize } from 'vs/platform/files/common/files'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IDisposable, Disposable, dispose, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { KeyCode } from 'vs/base/common/keyCodes'; import { IFileLabelOptions, IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; import { ITreeNode, ITreeFilter, TreeVisibility, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction, TreeDragOverBubble } from 'vs/base/browser/ui/tree/tree'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IFilesConfiguration, IExplorerService, VIEW_ID } from 'vs/workbench/contrib/files/common/files'; import { dirname, joinPath, basename, distinctParents, basenameOrAuthority } from 'vs/base/common/resources'; import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; import { localize } from 'vs/nls'; import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; import { once } from 'vs/base/common/functional'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { equals, deepClone } from 'vs/base/common/objects'; import * as path from 'vs/base/common/path'; import { ExplorerItem, NewExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { compareFileNamesDefault, compareFileExtensionsDefault } from 'vs/base/common/comparers'; import { fillResourceDataTransfers, CodeDataTransfers, extractResources, containsDragType } from 'vs/workbench/browser/dnd'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IDragAndDropData, DataTransfers } from 'vs/base/browser/dnd'; import { Schemas } from 'vs/base/common/network'; import { NativeDragAndDropData, ExternalElementsDragAndDropData, ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { isMacintosh, isWeb } from 'vs/base/common/platform'; import { IDialogService, IConfirmation, getFileNamesMessage } from 'vs/platform/dialogs/common/dialogs'; import { IHostService } from 'vs/workbench/services/host/browser/host'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; import { URI } from 'vs/base/common/uri'; import { ITask, RunOnceWorker, sequence } from 'vs/base/common/async'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IWorkspaceFolderCreationData } from 'vs/platform/workspaces/common/workspaces'; import { findValidPasteFileTarget } from 'vs/workbench/contrib/files/browser/fileActions'; import { FuzzyScore, createMatches } from 'vs/base/common/filters'; import { Emitter, Event, EventMultiplexer } from 'vs/base/common/event'; import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { VSBuffer, newWriteableBufferStream } from 'vs/base/common/buffer'; import { ILabelService } from 'vs/platform/label/common/label'; import { isNumber } from 'vs/base/common/types'; import { domEvent } from 'vs/base/browser/event'; import { IEditableData } from 'vs/workbench/common/views'; import { IEditorInput } from 'vs/workbench/common/editor'; import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { IBulkEditService, ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService'; export class ExplorerDelegate implements IListVirtualDelegate { static readonly ITEM_HEIGHT = 22; getHeight(element: ExplorerItem): number { return ExplorerDelegate.ITEM_HEIGHT; } getTemplateId(element: ExplorerItem): string { return FilesRenderer.ID; } } export const explorerRootErrorEmitter = new Emitter(); export class ExplorerDataSource implements IAsyncDataSource { constructor( @IProgressService private readonly progressService: IProgressService, @INotificationService private readonly notificationService: INotificationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IFileService private readonly fileService: IFileService, @IExplorerService private readonly explorerService: IExplorerService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService ) { } hasChildren(element: ExplorerItem | ExplorerItem[]): boolean { return Array.isArray(element) || element.isDirectory; } getChildren(element: ExplorerItem | ExplorerItem[]): Promise { if (Array.isArray(element)) { return Promise.resolve(element); } const sortOrder = this.explorerService.sortOrder; const promise = element.fetchChildren(sortOrder).then(undefined, e => { if (element instanceof ExplorerItem && element.isRoot) { if (this.contextService.getWorkbenchState() === WorkbenchState.FOLDER) { // Single folder create a dummy explorer item to show error const placeholder = new ExplorerItem(element.resource, this.fileService, undefined, false); placeholder.isError = true; return [placeholder]; } else { explorerRootErrorEmitter.fire(element.resource); } } else { // Do not show error for roots since we already use an explorer decoration to notify user this.notificationService.error(e); } return []; // we could not resolve any children because of an error }); this.progressService.withProgress({ location: ProgressLocation.Explorer, delay: this.layoutService.isRestored() ? 800 : 1200 // less ugly initial startup }, _progress => promise); return promise; } } export interface ICompressedNavigationController { readonly current: ExplorerItem; readonly currentId: string; readonly items: ExplorerItem[]; readonly labels: HTMLElement[]; readonly index: number; readonly count: number; readonly onDidChange: Event; previous(): void; next(): void; first(): void; last(): void; setIndex(index: number): void; updateCollapsed(collapsed: boolean): void; } export class CompressedNavigationController implements ICompressedNavigationController, IDisposable { static ID = 0; private _index: number; private _labels!: HTMLElement[]; private _updateLabelDisposable: IDisposable; get index(): number { return this._index; } get count(): number { return this.items.length; } get current(): ExplorerItem { return this.items[this._index]!; } get currentId(): string { return `${this.id}_${this.index}`; } get labels(): HTMLElement[] { return this._labels; } private _onDidChange = new Emitter(); readonly onDidChange = this._onDidChange.event; constructor(private id: string, readonly items: ExplorerItem[], templateData: IFileTemplateData, private depth: number, private collapsed: boolean) { this._index = items.length - 1; this.updateLabels(templateData); this._updateLabelDisposable = templateData.label.onDidRender(() => this.updateLabels(templateData)); } private updateLabels(templateData: IFileTemplateData): void { this._labels = Array.from(templateData.container.querySelectorAll('.label-name')) as HTMLElement[]; let parents = ''; for (let i = 0; i < this.labels.length; i++) { const ariaLabel = parents.length ? `${this.items[i].name}, compact, ${parents}` : this.items[i].name; this.labels[i].setAttribute('aria-label', ariaLabel); this.labels[i].setAttribute('aria-level', `${this.depth + i}`); parents = parents.length ? `${this.items[i].name} ${parents}` : this.items[i].name; } this.updateCollapsed(this.collapsed); if (this._index < this.labels.length) { this.labels[this._index].classList.add('active'); } } previous(): void { if (this._index <= 0) { return; } this.setIndex(this._index - 1); } next(): void { if (this._index >= this.items.length - 1) { return; } this.setIndex(this._index + 1); } first(): void { if (this._index === 0) { return; } this.setIndex(0); } last(): void { if (this._index === this.items.length - 1) { return; } this.setIndex(this.items.length - 1); } setIndex(index: number): void { if (index < 0 || index >= this.items.length) { return; } this.labels[this._index].classList.remove('active'); this._index = index; this.labels[this._index].classList.add('active'); this._onDidChange.fire(); } updateCollapsed(collapsed: boolean): void { this.collapsed = collapsed; for (let i = 0; i < this.labels.length; i++) { this.labels[i].setAttribute('aria-expanded', collapsed ? 'false' : 'true'); } } dispose(): void { this._onDidChange.dispose(); this._updateLabelDisposable.dispose(); } } export interface IFileTemplateData { elementDisposable: IDisposable; label: IResourceLabel; container: HTMLElement; } export class FilesRenderer implements ICompressibleTreeRenderer, IListAccessibilityProvider, IDisposable { static readonly ID = 'file'; private config: IFilesConfiguration; private configListener: IDisposable; private compressedNavigationControllers = new Map(); private _onDidChangeActiveDescendant = new EventMultiplexer(); readonly onDidChangeActiveDescendant = this._onDidChangeActiveDescendant.event; constructor( private labels: ResourceLabels, private updateWidth: (stat: ExplorerItem) => void, @IContextViewService private readonly contextViewService: IContextViewService, @IThemeService private readonly themeService: IThemeService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExplorerService private readonly explorerService: IExplorerService, @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService ) { this.config = this.configurationService.getValue(); this.configListener = this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('explorer')) { this.config = this.configurationService.getValue(); } }); } getWidgetAriaLabel(): string { return localize('treeAriaLabel', "Files Explorer"); } get templateId(): string { return FilesRenderer.ID; } renderTemplate(container: HTMLElement): IFileTemplateData { const elementDisposable = Disposable.None; const label = this.labels.create(container, { supportHighlights: true }); return { elementDisposable, label, container }; } renderElement(node: ITreeNode, index: number, templateData: IFileTemplateData): void { templateData.elementDisposable.dispose(); const stat = node.element; const editableData = this.explorerService.getEditableData(stat); templateData.label.element.classList.remove('compressed'); // File Label if (!editableData) { templateData.label.element.style.display = 'flex'; templateData.elementDisposable = this.renderStat(stat, stat.name, undefined, node.filterData, templateData); } // Input Box else { templateData.label.element.style.display = 'none'; templateData.elementDisposable = this.renderInputBox(templateData.container, stat, editableData); } } renderCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: IFileTemplateData, height: number | undefined): void { templateData.elementDisposable.dispose(); const stat = node.element.elements[node.element.elements.length - 1]; const editable = node.element.elements.filter(e => this.explorerService.isEditable(e)); const editableData = editable.length === 0 ? undefined : this.explorerService.getEditableData(editable[0]); // File Label if (!editableData) { templateData.label.element.classList.add('compressed'); templateData.label.element.style.display = 'flex'; const disposables = new DisposableStore(); const id = `compressed-explorer_${CompressedNavigationController.ID++}`; const label = node.element.elements.map(e => e.name); disposables.add(this.renderStat(stat, label, id, node.filterData, templateData)); const compressedNavigationController = new CompressedNavigationController(id, node.element.elements, templateData, node.depth, node.collapsed); disposables.add(compressedNavigationController); this.compressedNavigationControllers.set(stat, compressedNavigationController); // accessibility disposables.add(this._onDidChangeActiveDescendant.add(compressedNavigationController.onDidChange)); domEvent(templateData.container, 'mousedown')(e => { const result = getIconLabelNameFromHTMLElement(e.target); if (result) { compressedNavigationController.setIndex(result.index); } }, undefined, disposables); disposables.add(toDisposable(() => this.compressedNavigationControllers.delete(stat))); templateData.elementDisposable = disposables; } // Input Box else { templateData.label.element.classList.remove('compressed'); templateData.label.element.style.display = 'none'; templateData.elementDisposable = this.renderInputBox(templateData.container, editable[0], editableData); } } private renderStat(stat: ExplorerItem, label: string | string[], domId: string | undefined, filterData: FuzzyScore | undefined, templateData: IFileTemplateData): IDisposable { templateData.label.element.style.display = 'flex'; const extraClasses = ['explorer-item']; if (this.explorerService.isCut(stat)) { extraClasses.push('cut'); } templateData.label.setResource({ resource: stat.resource, name: label }, { fileKind: stat.isRoot ? FileKind.ROOT_FOLDER : stat.isDirectory ? FileKind.FOLDER : FileKind.FILE, extraClasses, fileDecorations: this.config.explorer.decorations, matches: createMatches(filterData), separator: this.labelService.getSeparator(stat.resource.scheme, stat.resource.authority), domId }); return templateData.label.onDidRender(() => { try { this.updateWidth(stat); } catch (e) { // noop since the element might no longer be in the tree, no update of width necessery } }); } private renderInputBox(container: HTMLElement, stat: ExplorerItem, editableData: IEditableData): IDisposable { // Use a file label only for the icon next to the input box const label = this.labels.create(container); const extraClasses = ['explorer-item', 'explorer-item-edited']; const fileKind = stat.isRoot ? FileKind.ROOT_FOLDER : stat.isDirectory ? FileKind.FOLDER : FileKind.FILE; const labelOptions: IFileLabelOptions = { hidePath: true, hideLabel: true, fileKind, extraClasses }; const parent = stat.name ? dirname(stat.resource) : stat.resource; const value = stat.name || ''; label.setFile(joinPath(parent, value || ' '), labelOptions); // Use icon for ' ' if name is empty. // hack: hide label (label.element.firstElementChild as HTMLElement).style.display = 'none'; // Input field for name const inputBox = new InputBox(label.element, this.contextViewService, { validationOptions: { validation: (value) => { const message = editableData.validationMessage(value); if (!message || message.severity !== Severity.Error) { return null; } return { content: message.content, formatContent: true, type: MessageType.ERROR }; } }, ariaLabel: localize('fileInputAriaLabel', "Type file name. Press Enter to confirm or Escape to cancel.") }); const styler = attachInputBoxStyler(inputBox, this.themeService); const lastDot = value.lastIndexOf('.'); inputBox.value = value; inputBox.focus(); inputBox.select({ start: 0, end: lastDot > 0 && !stat.isDirectory ? lastDot : value.length }); const done = once((success: boolean, finishEditing: boolean) => { label.element.style.display = 'none'; const value = inputBox.value; dispose(toDispose); label.element.remove(); if (finishEditing) { editableData.onFinish(value, success); } }); const showInputBoxNotification = () => { if (inputBox.isInputValid()) { const message = editableData.validationMessage(inputBox.value); if (message) { inputBox.showMessage({ content: message.content, formatContent: true, type: message.severity === Severity.Info ? MessageType.INFO : message.severity === Severity.Warning ? MessageType.WARNING : MessageType.ERROR }); } else { inputBox.hideMessage(); } } }; showInputBoxNotification(); const toDispose = [ inputBox, inputBox.onDidChange(value => { label.setFile(joinPath(parent, value || ' '), labelOptions); // update label icon while typing! }), DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, (e: IKeyboardEvent) => { if (e.equals(KeyCode.Enter)) { if (inputBox.validate()) { done(true, true); } } else if (e.equals(KeyCode.Escape)) { done(false, true); } }), DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_UP, (e: IKeyboardEvent) => { showInputBoxNotification(); }), DOM.addDisposableListener(inputBox.inputElement, DOM.EventType.BLUR, () => { done(inputBox.isInputValid(), true); }), label, styler ]; return toDisposable(() => { done(false, false); }); } disposeElement(element: ITreeNode, index: number, templateData: IFileTemplateData): void { templateData.elementDisposable.dispose(); } disposeCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: IFileTemplateData): void { templateData.elementDisposable.dispose(); } disposeTemplate(templateData: IFileTemplateData): void { templateData.elementDisposable.dispose(); templateData.label.dispose(); } getCompressedNavigationController(stat: ExplorerItem): ICompressedNavigationController | undefined { return this.compressedNavigationControllers.get(stat); } // IAccessibilityProvider getAriaLabel(element: ExplorerItem): string { return element.name; } getAriaLevel(element: ExplorerItem): number { // We need to comput aria level on our own since children of compact folders will otherwise have an incorrect level #107235 let depth = 0; let parent = element.parent; while (parent) { parent = parent.parent; depth++; } if (this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE) { depth = depth + 1; } return depth; } getActiveDescendantId(stat: ExplorerItem): string | undefined { const compressedNavigationController = this.compressedNavigationControllers.get(stat); return compressedNavigationController?.currentId; } dispose(): void { this.configListener.dispose(); } } interface CachedParsedExpression { original: glob.IExpression; parsed: glob.ParsedExpression; } /** * Respectes files.exclude setting in filtering out content from the explorer. * Makes sure that visible editors are always shown in the explorer even if they are filtered out by settings. */ export class FilesFilter implements ITreeFilter { private hiddenExpressionPerRoot: Map; private uriVisibilityMap = new Map(); private editorsAffectingFilter = new Set(); private _onDidChange = new Emitter(); private toDispose: IDisposable[] = []; constructor( @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IConfigurationService private readonly configurationService: IConfigurationService, @IExplorerService private readonly explorerService: IExplorerService, @IEditorService private readonly editorService: IEditorService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService ) { this.hiddenExpressionPerRoot = new Map(); this.toDispose.push(this.contextService.onDidChangeWorkspaceFolders(() => this.updateConfiguration())); this.toDispose.push(this.configurationService.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('files.exclude')) { this.updateConfiguration(); } })); this.toDispose.push(this.editorService.onDidVisibleEditorsChange(() => { const editors = this.editorService.visibleEditors; let shouldFire = false; this.uriVisibilityMap.forEach((visible, uri) => { if (!visible) { editors.forEach(e => { if (e.resource && this.uriIdentityService.extUri.isEqualOrParent(e.resource, uri)) { // A filtered resource suddenly became visible since user opened an editor shouldFire = true; } }); } }); this.editorsAffectingFilter.forEach(e => { if (!editors.includes(e)) { // Editor that was affecting filtering is no longer visible shouldFire = true; } }); if (shouldFire) { this.editorsAffectingFilter.clear(); this.uriVisibilityMap.clear(); this._onDidChange.fire(); } })); this.updateConfiguration(); } get onDidChange(): Event { return this._onDidChange.event; } private updateConfiguration(): void { let shouldFire = false; this.contextService.getWorkspace().folders.forEach(folder => { const configuration = this.configurationService.getValue({ resource: folder.uri }); const excludesConfig: glob.IExpression = configuration?.files?.exclude || Object.create(null); if (!shouldFire) { const cached = this.hiddenExpressionPerRoot.get(folder.uri.toString()); shouldFire = !cached || !equals(cached.original, excludesConfig); } const excludesConfigCopy = deepClone(excludesConfig); // do not keep the config, as it gets mutated under our hoods this.hiddenExpressionPerRoot.set(folder.uri.toString(), { original: excludesConfigCopy, parsed: glob.parse(excludesConfigCopy) }); }); if (shouldFire) { this.editorsAffectingFilter.clear(); this.uriVisibilityMap.clear(); this._onDidChange.fire(); } } filter(stat: ExplorerItem, parentVisibility: TreeVisibility): boolean { const cachedVisibility = this.uriVisibilityMap.get(stat.resource); if (typeof cachedVisibility === 'boolean') { return cachedVisibility; } const isVisible = this.isVisible(stat, parentVisibility); this.uriVisibilityMap.set(stat.resource, isVisible); return isVisible; } private isVisible(stat: ExplorerItem, parentVisibility: TreeVisibility): boolean { stat.isExcluded = false; if (parentVisibility === TreeVisibility.Hidden) { stat.isExcluded = true; return false; } if (this.explorerService.getEditableData(stat) || stat.isRoot) { return true; // always visible } // Hide those that match Hidden Patterns const cached = this.hiddenExpressionPerRoot.get(stat.root.resource.toString()); if ((cached && cached.parsed(path.relative(stat.root.resource.path, stat.resource.path), stat.name, name => !!(stat.parent && stat.parent.getChild(name)))) || stat.parent?.isExcluded) { stat.isExcluded = true; const editors = this.editorService.visibleEditors; const editor = editors.find(e => e.resource && this.uriIdentityService.extUri.isEqualOrParent(e.resource, stat.resource)); if (editor) { this.editorsAffectingFilter.add(editor); return true; // Show all opened files and their parents } return false; // hidden through pattern } return true; } dispose(): void { dispose(this.toDispose); } } // Explorer Sorter export class FileSorter implements ITreeSorter { constructor( @IExplorerService private readonly explorerService: IExplorerService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService ) { } compare(statA: ExplorerItem, statB: ExplorerItem): number { // Do not sort roots if (statA.isRoot) { if (statB.isRoot) { const workspaceA = this.contextService.getWorkspaceFolder(statA.resource); const workspaceB = this.contextService.getWorkspaceFolder(statB.resource); return workspaceA && workspaceB ? (workspaceA.index - workspaceB.index) : -1; } return -1; } if (statB.isRoot) { return 1; } const sortOrder = this.explorerService.sortOrder; // Sort Directories switch (sortOrder) { case 'type': if (statA.isDirectory && !statB.isDirectory) { return -1; } if (statB.isDirectory && !statA.isDirectory) { return 1; } if (statA.isDirectory && statB.isDirectory) { return compareFileNamesDefault(statA.name, statB.name); } break; case 'filesFirst': if (statA.isDirectory && !statB.isDirectory) { return 1; } if (statB.isDirectory && !statA.isDirectory) { return -1; } break; case 'mixed': break; // not sorting when "mixed" is on default: /* 'default', 'modified' */ if (statA.isDirectory && !statB.isDirectory) { return -1; } if (statB.isDirectory && !statA.isDirectory) { return 1; } break; } // Sort Files switch (sortOrder) { case 'type': return compareFileExtensionsDefault(statA.name, statB.name); case 'modified': if (statA.mtime !== statB.mtime) { return (statA.mtime && statB.mtime && statA.mtime < statB.mtime) ? 1 : -1; } return compareFileNamesDefault(statA.name, statB.name); default: /* 'default', 'mixed', 'filesFirst' */ return compareFileNamesDefault(statA.name, statB.name); } } } function getFileOverwriteConfirm(name: string): IConfirmation { return { message: localize('confirmOverwrite', "A file or folder with the name '{0}' already exists in the destination folder. Do you want to replace it?", name), detail: localize('irreversible', "This action is irreversible!"), primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), type: 'warning' }; } function getMultipleFilesOverwriteConfirm(files: URI[]): IConfirmation { if (files.length > 1) { return { message: localize('confirmManyOverwrites', "The following {0} files and/or folders already exist in the destination folder. Do you want to replace them?", files.length), detail: getFileNamesMessage(files) + '\n' + localize('irreversible', "This action is irreversible!"), primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), type: 'warning' }; } return getFileOverwriteConfirm(basename(files[0])); } interface IWebkitDataTransfer { items: IWebkitDataTransferItem[]; } interface IWebkitDataTransferItem { webkitGetAsEntry(): IWebkitDataTransferItemEntry; } interface IWebkitDataTransferItemEntry { name: string | undefined; isFile: boolean; isDirectory: boolean; file(resolve: (file: File) => void, reject: () => void): void; createReader(): IWebkitDataTransferItemEntryReader; } interface IWebkitDataTransferItemEntryReader { readEntries(resolve: (file: IWebkitDataTransferItemEntry[]) => void, reject: () => void): void } interface IUploadOperation { startTime: number; progressScheduler: RunOnceWorker; filesTotal: number; filesUploaded: number; totalBytesUploaded: number; } export class FileDragAndDrop implements ITreeDragAndDrop { private static readonly CONFIRM_DND_SETTING_KEY = 'explorer.confirmDragAndDrop'; private compressedDragOverElement: HTMLElement | undefined; private compressedDropTargetDisposable: IDisposable = Disposable.None; private toDispose: IDisposable[]; private dropEnabled = false; constructor( @INotificationService private notificationService: INotificationService, @IExplorerService private explorerService: IExplorerService, @IEditorService private editorService: IEditorService, @IDialogService private dialogService: IDialogService, @IWorkspaceContextService private contextService: IWorkspaceContextService, @IFileService private fileService: IFileService, @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private instantiationService: IInstantiationService, @IHostService private hostService: IHostService, @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService, @IProgressService private readonly progressService: IProgressService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IBulkEditService private readonly bulkEditService: IBulkEditService ) { this.toDispose = []; const updateDropEnablement = () => { this.dropEnabled = this.configurationService.getValue('explorer.enableDragAndDrop'); }; updateDropEnablement(); this.toDispose.push(this.configurationService.onDidChangeConfiguration((e) => updateDropEnablement())); } onDragOver(data: IDragAndDropData, target: ExplorerItem | undefined, targetIndex: number | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction { if (!this.dropEnabled) { return false; } // Compressed folders if (target) { const compressedTarget = FileDragAndDrop.getCompressedStatFromDragEvent(target, originalEvent); if (compressedTarget) { const iconLabelName = getIconLabelNameFromHTMLElement(originalEvent.target); if (iconLabelName && iconLabelName.index < iconLabelName.count - 1) { const result = this.handleDragOver(data, compressedTarget, targetIndex, originalEvent); if (result) { if (iconLabelName.element !== this.compressedDragOverElement) { this.compressedDragOverElement = iconLabelName.element; this.compressedDropTargetDisposable.dispose(); this.compressedDropTargetDisposable = toDisposable(() => { iconLabelName.element.classList.remove('drop-target'); this.compressedDragOverElement = undefined; }); iconLabelName.element.classList.add('drop-target'); } return typeof result === 'boolean' ? result : { ...result, feedback: [] }; } this.compressedDropTargetDisposable.dispose(); return false; } } } this.compressedDropTargetDisposable.dispose(); return this.handleDragOver(data, target, targetIndex, originalEvent); } private handleDragOver(data: IDragAndDropData, target: ExplorerItem | undefined, targetIndex: number | undefined, originalEvent: DragEvent): boolean | ITreeDragOverReaction { const isCopy = originalEvent && ((originalEvent.ctrlKey && !isMacintosh) || (originalEvent.altKey && isMacintosh)); const isNative = data instanceof NativeDragAndDropData; const effect = (isNative || isCopy) ? ListDragOverEffect.Copy : ListDragOverEffect.Move; // Native DND if (isNative) { if (!containsDragType(originalEvent, DataTransfers.FILES, CodeDataTransfers.FILES)) { return false; } } // Other-Tree DND else if (data instanceof ExternalElementsDragAndDropData) { return false; } // In-Explorer DND else { const items = FileDragAndDrop.getStatsFromDragAndDropData(data as ElementsDragAndDropData); if (!target) { // Dropping onto the empty area. Do not accept if items dragged are already // children of the root unless we are copying the file if (!isCopy && items.every(i => !!i.parent && i.parent.isRoot)) { return false; } return { accept: true, bubble: TreeDragOverBubble.Down, effect, autoExpand: false }; } if (!Array.isArray(items)) { return false; } if (items.some((source) => { if (source.isRoot && target instanceof ExplorerItem && !target.isRoot) { return true; // Root folder can not be moved to a non root file stat. } if (this.uriIdentityService.extUri.isEqual(source.resource, target.resource)) { return true; // Can not move anything onto itself } if (source.isRoot && target instanceof ExplorerItem && target.isRoot) { // Disable moving workspace roots in one another return false; } if (!isCopy && this.uriIdentityService.extUri.isEqual(dirname(source.resource), target.resource)) { return true; // Can not move a file to the same parent unless we copy } if (this.uriIdentityService.extUri.isEqualOrParent(target.resource, source.resource)) { return true; // Can not move a parent folder into one of its children } return false; })) { return false; } } // All (target = model) if (!target) { return { accept: true, bubble: TreeDragOverBubble.Down, effect }; } // All (target = file/folder) else { if (target.isDirectory) { if (target.isReadonly) { return false; } return { accept: true, bubble: TreeDragOverBubble.Down, effect, autoExpand: true }; } if (this.contextService.getWorkspace().folders.every(folder => folder.uri.toString() !== target.resource.toString())) { return { accept: true, bubble: TreeDragOverBubble.Up, effect }; } } return false; } getDragURI(element: ExplorerItem): string | null { if (this.explorerService.isEditable(element)) { return null; } return element.resource.toString(); } getDragLabel(elements: ExplorerItem[], originalEvent: DragEvent): string | undefined { if (elements.length === 1) { const stat = FileDragAndDrop.getCompressedStatFromDragEvent(elements[0], originalEvent); return stat.name; } return String(elements.length); } onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void { const items = FileDragAndDrop.getStatsFromDragAndDropData(data as ElementsDragAndDropData, originalEvent); if (items && items.length && originalEvent.dataTransfer) { // Apply some datatransfer types to allow for dragging the element outside of the application this.instantiationService.invokeFunction(fillResourceDataTransfers, items, undefined, originalEvent); // The only custom data transfer we set from the explorer is a file transfer // to be able to DND between multiple code file explorers across windows const fileResources = items.filter(s => !s.isDirectory && s.resource.scheme === Schemas.file).map(r => r.resource.fsPath); if (fileResources.length) { originalEvent.dataTransfer.setData(CodeDataTransfers.FILES, JSON.stringify(fileResources)); } } } drop(data: IDragAndDropData, target: ExplorerItem | undefined, targetIndex: number | undefined, originalEvent: DragEvent): void { this.compressedDropTargetDisposable.dispose(); // Find compressed target if (target) { const compressedTarget = FileDragAndDrop.getCompressedStatFromDragEvent(target, originalEvent); if (compressedTarget) { target = compressedTarget; } } // Find parent to add to if (!target) { target = this.explorerService.roots[this.explorerService.roots.length - 1]; } if (!target.isDirectory && target.parent) { target = target.parent; } if (target.isReadonly) { return; } const resolvedTarget = target; if (!resolvedTarget) { return; } // Desktop DND (Import file) if (data instanceof NativeDragAndDropData) { const cts = new CancellationTokenSource(); // Indicate progress globally const dropPromise = this.progressService.withProgress({ location: ProgressLocation.Window, delay: 800, cancellable: true, title: isWeb ? localize('uploadingFiles', "Uploading") : localize('copyingFiles', "Copying") }, async progress => { try { if (isWeb) { await this.handleWebExternalDrop(data, resolvedTarget, originalEvent, progress, cts.token); } else { await this.handleExternalDrop(data, resolvedTarget, originalEvent, progress, cts.token); } } catch (error) { this.notificationService.warn(error); } }, () => cts.dispose(true)); // Also indicate progress in the files view this.progressService.withProgress({ location: VIEW_ID, delay: 800 }, () => dropPromise); } // In-Explorer DND (Move/Copy file) else { this.handleExplorerDrop(data as ElementsDragAndDropData, resolvedTarget, originalEvent).then(undefined, e => this.notificationService.warn(e)); } } private async handleWebExternalDrop(data: NativeDragAndDropData, target: ExplorerItem, originalEvent: DragEvent, progress: IProgress, token: CancellationToken): Promise { const items = (originalEvent.dataTransfer as unknown as IWebkitDataTransfer).items; // Somehow the items thing is being modified at random, maybe as a security // measure since this is a DND operation. As such, we copy the items into // an array we own as early as possible before using it. const entries: IWebkitDataTransferItemEntry[] = []; for (const item of items) { entries.push(item.webkitGetAsEntry()); } const results: { isFile: boolean, resource: URI }[] = []; const operation: IUploadOperation = { startTime: Date.now(), progressScheduler: new RunOnceWorker(steps => { progress.report(steps[steps.length - 1]); }, 1000), filesTotal: entries.length, filesUploaded: 0, totalBytesUploaded: 0 }; for (let entry of entries) { if (token.isCancellationRequested) { break; } // Confirm overwrite as needed if (target && entry.name && target.getChild(entry.name)) { const { confirmed } = await this.dialogService.confirm(getFileOverwriteConfirm(entry.name)); if (!confirmed) { continue; } await this.bulkEditService.apply([new ResourceFileEdit(joinPath(target.resource, entry.name), undefined, { recursive: true })], { undoRedoSource: this.explorerService.undoRedoSource, label: localize('overwrite', "Overwrite {0}", entry.name) }); if (token.isCancellationRequested) { break; } } // Upload entry const result = await this.doUploadWebFileEntry(entry, target.resource, target, progress, operation, token); if (result) { results.push(result); } } operation.progressScheduler.dispose(); // Open uploaded file in editor only if we upload just one const firstUploadedFile = results[0]; if (!token.isCancellationRequested && firstUploadedFile?.isFile) { await this.editorService.openEditor({ resource: firstUploadedFile.resource, options: { pinned: true } }); } } private async doUploadWebFileEntry(entry: IWebkitDataTransferItemEntry, parentResource: URI, target: ExplorerItem | undefined, progress: IProgress, operation: IUploadOperation, token: CancellationToken): Promise<{ isFile: boolean, resource: URI } | undefined> { if (token.isCancellationRequested || !entry.name || (!entry.isFile && !entry.isDirectory)) { return undefined; } // Report progress let fileBytesUploaded = 0; const reportProgress = (fileSize: number, bytesUploaded: number): void => { fileBytesUploaded += bytesUploaded; operation.totalBytesUploaded += bytesUploaded; const bytesUploadedPerSecond = operation.totalBytesUploaded / ((Date.now() - operation.startTime) / 1000); // Small file let message: string; if (fileSize < ByteSize.MB) { if (operation.filesTotal === 1) { message = `${entry.name}`; } else { message = localize('uploadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesUploaded, operation.filesTotal, ByteSize.formatSize(bytesUploadedPerSecond)); } } // Large file else { message = localize('uploadProgressLarge', "{0} ({1} of {2}, {3}/s)", entry.name, ByteSize.formatSize(fileBytesUploaded), ByteSize.formatSize(fileSize), ByteSize.formatSize(bytesUploadedPerSecond)); } // Report progress but limit to update only once per second operation.progressScheduler.work({ message }); }; operation.filesUploaded++; reportProgress(0, 0); // Handle file upload const resource = joinPath(parentResource, entry.name); if (entry.isFile) { const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); if (token.isCancellationRequested) { return undefined; } // Chrome/Edge/Firefox support stream method, but only use it for // larger files to reduce the overhead of the streaming approach if (typeof file.stream === 'function' && file.size > ByteSize.MB) { await this.doUploadWebFileEntryBuffered(resource, file, reportProgress, token); } // Fallback to unbuffered upload for other browsers or small files else { await this.doUploadWebFileEntryUnbuffered(resource, file, reportProgress); } return { isFile: true, resource }; } // Handle folder upload else { // Create target folder await this.fileService.createFolder(resource); if (token.isCancellationRequested) { return undefined; } // Recursive upload files in this directory const dirReader = entry.createReader(); const childEntries: IWebkitDataTransferItemEntry[] = []; let done = false; do { const childEntriesChunk = await new Promise((resolve, reject) => dirReader.readEntries(resolve, reject)); if (childEntriesChunk.length > 0) { childEntries.push(...childEntriesChunk); } else { done = true; // an empty array is a signal that all entries have been read } } while (!done && !token.isCancellationRequested); // Update operation total based on new counts operation.filesTotal += childEntries.length; // Upload all entries as files to target const folderTarget = target && target.getChild(entry.name) || undefined; for (let childEntry of childEntries) { await this.doUploadWebFileEntry(childEntry, resource, folderTarget, progress, operation, token); } return { isFile: false, resource }; } } private async doUploadWebFileEntryBuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void, token: CancellationToken): Promise { const writeableStream = newWriteableBufferStream({ // Set a highWaterMark to prevent the stream // for file upload to produce large buffers // in-memory highWaterMark: 10 }); const writeFilePromise = this.fileService.writeFile(resource, writeableStream); // Read the file in chunks using File.stream() web APIs try { const reader: ReadableStreamDefaultReader = file.stream().getReader(); let res = await reader.read(); while (!res.done) { if (token.isCancellationRequested) { return undefined; } // Write buffer into stream but make sure to wait // in case the highWaterMark is reached const buffer = VSBuffer.wrap(res.value); await writeableStream.write(buffer); if (token.isCancellationRequested) { return undefined; } // Report progress progressReporter(file.size, buffer.byteLength); res = await reader.read(); } writeableStream.end(res.value instanceof Uint8Array ? VSBuffer.wrap(res.value) : undefined); } catch (error) { writeableStream.end(error); } if (token.isCancellationRequested) { return undefined; } // Wait for file being written to target await writeFilePromise; } private doUploadWebFileEntryUnbuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async event => { try { if (event.target?.result instanceof ArrayBuffer) { const buffer = VSBuffer.wrap(new Uint8Array(event.target.result)); await this.fileService.writeFile(resource, buffer); // Report progress progressReporter(file.size, buffer.byteLength); } else { throw new Error('Could not read from dropped file.'); } resolve(); } catch (error) { reject(error); } }; // Start reading the file to trigger `onload` reader.readAsArrayBuffer(file); }); } private async handleExternalDrop(data: NativeDragAndDropData, target: ExplorerItem, originalEvent: DragEvent, progress: IProgress, token: CancellationToken): Promise { // Check for dropped external files to be folders const droppedResources = extractResources(originalEvent, true); const result = await this.fileService.resolveAll(droppedResources.map(droppedResource => ({ resource: droppedResource.resource }))); if (token.isCancellationRequested) { return; } // Pass focus to window this.hostService.focus(); // Handle folders by adding to workspace if we are in workspace context const folders = result.filter(r => r.success && r.stat && r.stat.isDirectory).map(result => ({ uri: result.stat!.resource })); if (folders.length > 0) { const buttons = [ folders.length > 1 ? localize('copyFolders', "&&Copy Folders") : localize('copyFolder', "&&Copy Folder"), localize('cancel', "Cancel") ]; const workspaceFolderSchemas = this.contextService.getWorkspace().folders.map(f => f.uri.scheme); let message = folders.length > 1 ? localize('copyfolders', "Are you sure to want to copy folders?") : localize('copyfolder', "Are you sure to want to copy '{0}'?", basename(folders[0].uri)); if (folders.some(f => workspaceFolderSchemas.indexOf(f.uri.scheme) >= 0)) { // We only allow to add a folder to the workspace if there is already a workspace folder with that scheme buttons.unshift(folders.length > 1 ? localize('addFolders', "&&Add Folders to Workspace") : localize('addFolder', "&&Add Folder to Workspace")); message = folders.length > 1 ? localize('dropFolders', "Do you want to copy the folders or add the folders to the workspace?") : localize('dropFolder', "Do you want to copy '{0}' or add '{0}' as a folder to the workspace?", basename(folders[0].uri)); } const { choice } = await this.dialogService.show(Severity.Info, message, buttons); if (choice === buttons.length - 3) { return this.workspaceEditingService.addFolders(folders); } if (choice === buttons.length - 2) { return this.addResources(target, droppedResources.map(res => res.resource), progress, token); } return undefined; } // Handle dropped files (only support FileStat as target) else if (target instanceof ExplorerItem) { return this.addResources(target, droppedResources.map(res => res.resource), progress, token); } } private async addResources(target: ExplorerItem, resources: URI[], progress: IProgress, token: CancellationToken): Promise { if (resources && resources.length > 0) { // Resolve target to check for name collisions and ask user const targetStat = await this.fileService.resolve(target.resource); if (token.isCancellationRequested) { return; } // Check for name collisions const targetNames = new Set(); const caseSensitive = this.fileService.hasCapability(target.resource, FileSystemProviderCapabilities.PathCaseSensitive); if (targetStat.children) { targetStat.children.forEach(child => { targetNames.add(caseSensitive ? child.name : child.name.toLowerCase()); }); } // Run add in sequence const addPromisesFactory: ITask>[] = []; await Promise.all(resources.map(async resource => { if (targetNames.has(caseSensitive ? basename(resource) : basename(resource).toLowerCase())) { const confirmationResult = await this.dialogService.confirm(getFileOverwriteConfirm(basename(resource))); if (!confirmationResult.confirmed) { return; } } addPromisesFactory.push(async () => { if (token.isCancellationRequested) { return; } const sourceFile = resource; const sourceFileName = basename(sourceFile); const targetFile = joinPath(target.resource, sourceFileName); progress.report({ message: sourceFileName }); await this.bulkEditService.apply([new ResourceFileEdit(sourceFile, targetFile, { overwrite: true, copy: true })], { undoRedoSource: this.explorerService.undoRedoSource, label: localize('copyFile', "Copy {0}", sourceFileName) }); // if we only add one file, just open it directly const item = this.explorerService.findClosest(targetFile); if (resources.length === 1 && item && !item.isDirectory) { this.editorService.openEditor({ resource: item.resource, options: { pinned: true } }); } }); })); await sequence(addPromisesFactory); } } private async handleExplorerDrop(data: ElementsDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise { const elementsData = FileDragAndDrop.getStatsFromDragAndDropData(data); const items = distinctParents(elementsData, s => s.resource); const isCopy = (originalEvent.ctrlKey && !isMacintosh) || (originalEvent.altKey && isMacintosh); // Handle confirm setting const confirmDragAndDrop = !isCopy && this.configurationService.getValue(FileDragAndDrop.CONFIRM_DND_SETTING_KEY); if (confirmDragAndDrop) { const message = items.length > 1 && items.every(s => s.isRoot) ? localize('confirmRootsMove', "Are you sure you want to change the order of multiple root folders in your workspace?") : items.length > 1 ? localize('confirmMultiMove', "Are you sure you want to move the following {0} files into '{1}'?", items.length, target.name) : items[0].isRoot ? localize('confirmRootMove', "Are you sure you want to change the order of root folder '{0}' in your workspace?", items[0].name) : localize('confirmMove', "Are you sure you want to move '{0}' into '{1}'?", items[0].name, target.name); const detail = items.length > 1 && !items.every(s => s.isRoot) ? getFileNamesMessage(items.map(i => i.resource)) : undefined; const confirmation = await this.dialogService.confirm({ message, detail, checkbox: { label: localize('doNotAskAgain', "Do not ask me again") }, type: 'question', primaryButton: localize({ key: 'moveButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Move") }); if (!confirmation.confirmed) { return; } // Check for confirmation checkbox if (confirmation.checkboxChecked === true) { await this.configurationService.updateValue(FileDragAndDrop.CONFIRM_DND_SETTING_KEY, false); } } await this.doHandleRootDrop(items.filter(s => s.isRoot), target); const sources = items.filter(s => !s.isRoot); if (isCopy) { await this.doHandleExplorerDropOnCopy(sources, target); } else { return this.doHandleExplorerDropOnMove(sources, target); } } private doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem): Promise { if (roots.length === 0) { return Promise.resolve(undefined); } const folders = this.contextService.getWorkspace().folders; let targetIndex: number | undefined; const workspaceCreationData: IWorkspaceFolderCreationData[] = []; const rootsToMove: IWorkspaceFolderCreationData[] = []; for (let index = 0; index < folders.length; index++) { const data = { uri: folders[index].uri, name: folders[index].name }; if (target instanceof ExplorerItem && this.uriIdentityService.extUri.isEqual(folders[index].uri, target.resource)) { targetIndex = index; } if (roots.every(r => r.resource.toString() !== folders[index].uri.toString())) { workspaceCreationData.push(data); } else { rootsToMove.push(data); } } if (targetIndex === undefined) { targetIndex = workspaceCreationData.length; } workspaceCreationData.splice(targetIndex, 0, ...rootsToMove); return this.workspaceEditingService.updateFolders(0, workspaceCreationData.length, workspaceCreationData); } private async doHandleExplorerDropOnCopy(sources: ExplorerItem[], target: ExplorerItem): Promise { // Reuse duplicate action when user copies const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; const resourceFileEdits = sources.map(({ resource, isDirectory }) => (new ResourceFileEdit(resource, findValidPasteFileTarget(this.explorerService, target, { resource, isDirectory, allowOverwrite: false }, incrementalNaming), { copy: true }))); await this.bulkEditService.apply(resourceFileEdits, { undoRedoSource: this.explorerService.undoRedoSource, label: resourceFileEdits.length > 1 ? localize('copy', "Copy {0} files", resourceFileEdits.length) : localize('copyOneFile', "Copy {0}", basenameOrAuthority(resourceFileEdits[0].newResource!)) }); const editors = resourceFileEdits.filter(edit => { const item = edit.newResource ? this.explorerService.findClosest(edit.newResource) : undefined; return item && !item.isDirectory; }).map(edit => ({ resource: edit.newResource, options: { pinned: true } })); await this.editorService.openEditors(editors); } private async doHandleExplorerDropOnMove(sources: ExplorerItem[], target: ExplorerItem): Promise { // Do not allow moving readonly items const resourceFileEdits = sources.filter(source => !source.isReadonly).map(source => new ResourceFileEdit(source.resource, joinPath(target.resource, source.name))); const label = sources.length > 1 ? localize('move', "Move {0} files", sources.length) : localize('moveOneFile', "Move {0}", sources[0].name); try { await this.bulkEditService.apply(resourceFileEdits, { undoRedoSource: this.explorerService.undoRedoSource, label }); } catch (error) { // Conflict if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { const overwrites: URI[] = []; for (const edit of resourceFileEdits) { if (edit.newResource && await this.fileService.exists(edit.newResource)) { overwrites.push(edit.newResource); } } const confirm = getMultipleFilesOverwriteConfirm(overwrites); // Move with overwrite if the user confirms const { confirmed } = await this.dialogService.confirm(confirm); if (confirmed) { try { await this.bulkEditService.apply(resourceFileEdits.map(re => new ResourceFileEdit(re.oldResource, re.newResource, { overwrite: true })), { undoRedoSource: this.explorerService.undoRedoSource, label }); } catch (error) { this.notificationService.error(error); } } } // Any other error else { this.notificationService.error(error); } } } private static getStatsFromDragAndDropData(data: ElementsDragAndDropData, dragStartEvent?: DragEvent): ExplorerItem[] { if (data.context) { return data.context; } // Detect compressed folder dragging if (dragStartEvent && data.elements.length === 1) { data.context = [FileDragAndDrop.getCompressedStatFromDragEvent(data.elements[0], dragStartEvent)]; return data.context; } return data.elements; } private static getCompressedStatFromDragEvent(stat: ExplorerItem, dragEvent: DragEvent): ExplorerItem { const target = document.elementFromPoint(dragEvent.clientX, dragEvent.clientY); const iconLabelName = getIconLabelNameFromHTMLElement(target); if (iconLabelName) { const { count, index } = iconLabelName; let i = count - 1; while (i > index && stat.parent) { stat = stat.parent; i--; } return stat; } return stat; } onDragEnd(): void { this.compressedDropTargetDisposable.dispose(); } } function getIconLabelNameFromHTMLElement(target: HTMLElement | EventTarget | Element | null): { element: HTMLElement, count: number, index: number } | null { if (!(target instanceof HTMLElement)) { return null; } let element: HTMLElement | null = target; while (element && !element.classList.contains('monaco-list-row')) { if (element.classList.contains('label-name') && element.hasAttribute('data-icon-label-count')) { const count = Number(element.getAttribute('data-icon-label-count')); const index = Number(element.getAttribute('data-icon-label-index')); if (isNumber(count) && isNumber(index)) { return { element: element, count, index }; } } element = element.parentElement; } return null; } export function isCompressedFolderName(target: HTMLElement | EventTarget | Element | null): boolean { return !!getIconLabelNameFromHTMLElement(target); } export class ExplorerCompressionDelegate implements ITreeCompressionDelegate { isIncompressible(stat: ExplorerItem): boolean { return stat.isRoot || !stat.isDirectory || stat instanceof NewExplorerItem || (!stat.parent || stat.parent.isRoot); } }