Path: blob/main/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.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 { compareFileNames } from '../../../../base/common/comparers.js';6import { onUnexpectedError } from '../../../../base/common/errors.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { createMatches, FuzzyScore } from '../../../../base/common/filters.js';9import * as glob from '../../../../base/common/glob.js';10import { IDisposable, DisposableStore, MutableDisposable, Disposable } from '../../../../base/common/lifecycle.js';11import { posix, relative } from '../../../../base/common/path.js';12import { basename, dirname, isEqual } from '../../../../base/common/resources.js';13import { URI } from '../../../../base/common/uri.js';14import './media/breadcrumbscontrol.css';15import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';16import { FileKind, FileSystemProviderCapabilities, IFileService, IFileStat } from '../../../../platform/files/common/files.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { WorkbenchDataTree, WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js';19import { breadcrumbsPickerBackground, widgetBorder, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js';20import { isWorkspace, isWorkspaceFolder, IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';21import { ResourceLabels, IResourceLabel, DEFAULT_LABELS_CONTAINER } from '../../labels.js';22import { BreadcrumbsConfig } from './breadcrumbs.js';23import { OutlineElement2, FileElement } from './breadcrumbsModel.js';24import { IAsyncDataSource, ITreeRenderer, ITreeNode, ITreeFilter, TreeVisibility, ITreeSorter } from '../../../../base/browser/ui/tree/tree.js';25import { IIdentityProvider, IListVirtualDelegate, IKeyboardNavigationLabelProvider } from '../../../../base/browser/ui/list/list.js';26import { IFileIconTheme, IThemeService } from '../../../../platform/theme/common/themeService.js';27import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js';28import { localize } from '../../../../nls.js';29import { IOutline, IOutlineComparator } from '../../../services/outline/browser/outline.js';30import { IEditorOptions } from '../../../../platform/editor/common/editor.js';31import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js';32import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';3334interface ILayoutInfo {35maxHeight: number;36width: number;37arrowSize: number;38arrowOffset: number;39inputHeight: number;40}4142type Tree<I, E> = WorkbenchDataTree<I, E, FuzzyScore> | WorkbenchAsyncDataTree<I, E, FuzzyScore>;4344export interface SelectEvent {45target: unknown;46browserEvent: UIEvent;47}4849export abstract class BreadcrumbsPicker<TInput, TElement> {5051protected readonly _disposables = new DisposableStore();52protected readonly _domNode: HTMLDivElement;53protected _arrow!: HTMLDivElement;54protected _treeContainer!: HTMLDivElement;55protected _tree!: Tree<TInput, TElement>;56protected _fakeEvent = new UIEvent('fakeEvent');57protected _layoutInfo!: ILayoutInfo;5859protected readonly _onWillPickElement = new Emitter<void>();60readonly onWillPickElement: Event<void> = this._onWillPickElement.event;6162private readonly _previewDispoables = new MutableDisposable();6364constructor(65parent: HTMLElement,66protected resource: URI,67@IInstantiationService protected readonly _instantiationService: IInstantiationService,68@IThemeService protected readonly _themeService: IThemeService,69@IConfigurationService protected readonly _configurationService: IConfigurationService,70) {71this._domNode = document.createElement('div');72this._domNode.className = 'monaco-breadcrumbs-picker show-file-icons';73parent.appendChild(this._domNode);74}7576dispose(): void {77this._disposables.dispose();78this._previewDispoables.dispose();79this._onWillPickElement.dispose();80this._domNode.remove();81setTimeout(() => this._tree.dispose(), 0); // tree cannot be disposed while being opened...82}8384async show(input: FileElement | OutlineElement2, maxHeight: number, width: number, arrowSize: number, arrowOffset: number): Promise<void> {8586const theme = this._themeService.getColorTheme();87const color = theme.getColor(breadcrumbsPickerBackground);8889this._arrow = document.createElement('div');90this._arrow.className = 'arrow';91this._arrow.style.borderColor = `transparent transparent ${color ? color.toString() : ''}`;92this._domNode.appendChild(this._arrow);9394this._treeContainer = document.createElement('div');95this._treeContainer.style.background = color ? color.toString() : '';96this._treeContainer.style.paddingTop = '2px';97this._treeContainer.style.borderRadius = '3px';98this._treeContainer.style.boxShadow = `0 0 8px 2px ${this._themeService.getColorTheme().getColor(widgetShadow)}`;99this._treeContainer.style.border = `1px solid ${this._themeService.getColorTheme().getColor(widgetBorder)}`;100this._domNode.appendChild(this._treeContainer);101102this._layoutInfo = { maxHeight, width, arrowSize, arrowOffset, inputHeight: 0 };103this._tree = this._createTree(this._treeContainer, input);104105this._disposables.add(this._tree.onDidOpen(async e => {106const { element, editorOptions, sideBySide } = e;107const didReveal = await this._revealElement(element, { ...editorOptions, preserveFocus: false }, sideBySide);108if (!didReveal) {109return;110}111}));112this._disposables.add(this._tree.onDidChangeFocus(e => {113this._previewDispoables.value = this._previewElement(e.elements[0]);114}));115this._disposables.add(this._tree.onDidChangeContentHeight(() => {116this._layout();117}));118119this._domNode.focus();120try {121await this._setInput(input);122this._layout();123} catch (err) {124onUnexpectedError(err);125}126}127128protected _layout(): void {129130const headerHeight = 2 * this._layoutInfo.arrowSize;131const treeHeight = Math.min(this._layoutInfo.maxHeight - headerHeight, this._tree.contentHeight);132const totalHeight = treeHeight + headerHeight;133134this._domNode.style.height = `${totalHeight}px`;135this._domNode.style.width = `${this._layoutInfo.width}px`;136this._arrow.style.top = `-${2 * this._layoutInfo.arrowSize}px`;137this._arrow.style.borderWidth = `${this._layoutInfo.arrowSize}px`;138this._arrow.style.marginLeft = `${this._layoutInfo.arrowOffset}px`;139this._treeContainer.style.height = `${treeHeight}px`;140this._treeContainer.style.width = `${this._layoutInfo.width}px`;141this._tree.layout(treeHeight, this._layoutInfo.width);142}143144restoreViewState(): void { }145146protected abstract _setInput(element: FileElement | OutlineElement2): Promise<void>;147protected abstract _createTree(container: HTMLElement, input: unknown): Tree<TInput, TElement>;148protected abstract _previewElement(element: unknown): IDisposable;149protected abstract _revealElement(element: unknown, options: IEditorOptions, sideBySide: boolean): Promise<boolean>;150151}152153//#region - Files154155class FileVirtualDelegate implements IListVirtualDelegate<IFileStat | IWorkspaceFolder> {156getHeight(_element: IFileStat | IWorkspaceFolder) {157return 22;158}159getTemplateId(_element: IFileStat | IWorkspaceFolder): string {160return 'FileStat';161}162}163164class FileIdentityProvider implements IIdentityProvider<IWorkspace | IWorkspaceFolder | IFileStat | URI> {165getId(element: IWorkspace | IWorkspaceFolder | IFileStat | URI): { toString(): string } {166if (URI.isUri(element)) {167return element.toString();168} else if (isWorkspace(element)) {169return element.id;170} else if (isWorkspaceFolder(element)) {171return element.uri.toString();172} else {173return element.resource.toString();174}175}176}177178179class FileDataSource implements IAsyncDataSource<IWorkspace | URI, IWorkspaceFolder | IFileStat> {180181constructor(182@IFileService private readonly _fileService: IFileService,183) { }184185hasChildren(element: IWorkspace | URI | IWorkspaceFolder | IFileStat): boolean {186return URI.isUri(element)187|| isWorkspace(element)188|| isWorkspaceFolder(element)189|| element.isDirectory;190}191192async getChildren(element: IWorkspace | URI | IWorkspaceFolder | IFileStat): Promise<(IWorkspaceFolder | IFileStat)[]> {193if (isWorkspace(element)) {194return element.folders;195}196let uri: URI;197if (isWorkspaceFolder(element)) {198uri = element.uri;199} else if (URI.isUri(element)) {200uri = element;201} else {202uri = element.resource;203}204const stat = await this._fileService.resolve(uri);205return stat.children ?? [];206}207}208209class FileRenderer implements ITreeRenderer<IFileStat | IWorkspaceFolder, FuzzyScore, IResourceLabel> {210211readonly templateId: string = 'FileStat';212213constructor(214private readonly _labels: ResourceLabels,215@IConfigurationService private readonly _configService: IConfigurationService,216) { }217218219renderTemplate(container: HTMLElement): IResourceLabel {220return this._labels.create(container, { supportHighlights: true });221}222223renderElement(node: ITreeNode<IWorkspaceFolder | IFileStat, [number, number, number]>, index: number, templateData: IResourceLabel): void {224const fileDecorations = this._configService.getValue<{ colors: boolean; badges: boolean }>('explorer.decorations');225const { element } = node;226let resource: URI;227let fileKind: FileKind;228if (isWorkspaceFolder(element)) {229resource = element.uri;230fileKind = FileKind.ROOT_FOLDER;231} else {232resource = element.resource;233fileKind = element.isDirectory ? FileKind.FOLDER : FileKind.FILE;234}235templateData.setFile(resource, {236fileKind,237hidePath: true,238fileDecorations: fileDecorations,239matches: createMatches(node.filterData),240extraClasses: ['picker-item']241});242}243244disposeTemplate(templateData: IResourceLabel): void {245templateData.dispose();246}247}248249class FileNavigationLabelProvider implements IKeyboardNavigationLabelProvider<IWorkspaceFolder | IFileStat> {250251getKeyboardNavigationLabel(element: IWorkspaceFolder | IFileStat): { toString(): string } {252return element.name;253}254}255256class FileAccessibilityProvider implements IListAccessibilityProvider<IWorkspaceFolder | IFileStat> {257258getWidgetAriaLabel(): string {259return localize('breadcrumbs', "Breadcrumbs");260}261262getAriaLabel(element: IWorkspaceFolder | IFileStat): string | null {263return element.name;264}265}266267class FileFilter implements ITreeFilter<IWorkspaceFolder | IFileStat> {268269private readonly _cachedExpressions = new Map<string, glob.ParsedExpression>();270private readonly _disposables = new DisposableStore();271272constructor(273@IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService,274@IConfigurationService configService: IConfigurationService,275@IFileService fileService: IFileService,276) {277const config = BreadcrumbsConfig.FileExcludes.bindTo(configService);278const update = () => {279_workspaceService.getWorkspace().folders.forEach(folder => {280const excludesConfig = config.getValue({ resource: folder.uri });281if (!excludesConfig) {282return;283}284// adjust patterns to be absolute in case they aren't285// free floating (**/)286const adjustedConfig: glob.IExpression = {};287for (const pattern in excludesConfig) {288if (typeof excludesConfig[pattern] !== 'boolean') {289continue;290}291const patternAbs = pattern.indexOf('**/') !== 0292? posix.join(folder.uri.path, pattern)293: pattern;294295adjustedConfig[patternAbs] = excludesConfig[pattern];296}297const ignoreCase = !fileService.hasCapability(folder.uri, FileSystemProviderCapabilities.PathCaseSensitive);298this._cachedExpressions.set(folder.uri.toString(), glob.parse(adjustedConfig, { ignoreCase }));299});300};301update();302this._disposables.add(config);303this._disposables.add(config.onDidChange(update));304this._disposables.add(_workspaceService.onDidChangeWorkspaceFolders(update));305}306307dispose(): void {308this._disposables.dispose();309}310311filter(element: IWorkspaceFolder | IFileStat, _parentVisibility: TreeVisibility): boolean {312if (isWorkspaceFolder(element)) {313// not a file314return true;315}316const folder = this._workspaceService.getWorkspaceFolder(element.resource);317if (!folder || !this._cachedExpressions.has(folder.uri.toString())) {318// no folder or no filer319return true;320}321322const expression = this._cachedExpressions.get(folder.uri.toString())!;323return !expression(relative(folder.uri.path, element.resource.path), basename(element.resource));324}325}326327328export class FileSorter implements ITreeSorter<IFileStat | IWorkspaceFolder> {329compare(a: IFileStat | IWorkspaceFolder, b: IFileStat | IWorkspaceFolder): number {330if (isWorkspaceFolder(a) && isWorkspaceFolder(b)) {331return a.index - b.index;332}333if ((a as IFileStat).isDirectory === (b as IFileStat).isDirectory) {334// same type -> compare on names335return compareFileNames(a.name, b.name);336} else if ((a as IFileStat).isDirectory) {337return -1;338} else {339return 1;340}341}342}343344export class BreadcrumbsFilePicker extends BreadcrumbsPicker<IWorkspace | URI, IWorkspaceFolder | IFileStat> {345346constructor(347parent: HTMLElement,348resource: URI,349@IInstantiationService instantiationService: IInstantiationService,350@IThemeService themeService: IThemeService,351@IConfigurationService configService: IConfigurationService,352@IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService,353@IEditorService private readonly _editorService: IEditorService,354) {355super(parent, resource, instantiationService, themeService, configService);356}357358protected _createTree(container: HTMLElement) {359360// tree icon theme specials361this._treeContainer.classList.add('file-icon-themable-tree');362this._treeContainer.classList.add('show-file-icons');363const onFileIconThemeChange = (fileIconTheme: IFileIconTheme) => {364this._treeContainer.classList.toggle('align-icons-and-twisties', fileIconTheme.hasFileIcons && !fileIconTheme.hasFolderIcons);365this._treeContainer.classList.toggle('hide-arrows', fileIconTheme.hidesExplorerArrows === true);366};367this._disposables.add(this._themeService.onDidFileIconThemeChange(onFileIconThemeChange));368onFileIconThemeChange(this._themeService.getFileIconTheme());369370const labels = this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER /* TODO@Jo visibility propagation */);371this._disposables.add(labels);372373return this._instantiationService.createInstance(374WorkbenchAsyncDataTree<IWorkspace | URI, IWorkspaceFolder | IFileStat, FuzzyScore>,375'BreadcrumbsFilePicker',376container,377new FileVirtualDelegate(),378[this._instantiationService.createInstance(FileRenderer, labels)],379this._instantiationService.createInstance(FileDataSource),380{381multipleSelectionSupport: false,382sorter: new FileSorter(),383filter: this._instantiationService.createInstance(FileFilter),384identityProvider: new FileIdentityProvider(),385keyboardNavigationLabelProvider: new FileNavigationLabelProvider(),386accessibilityProvider: this._instantiationService.createInstance(FileAccessibilityProvider),387showNotFoundMessage: false,388overrideStyles: {389listBackground: breadcrumbsPickerBackground390},391});392}393394protected async _setInput(element: FileElement | OutlineElement2): Promise<void> {395const { uri, kind } = (element as FileElement);396let input: IWorkspace | URI;397if (kind === FileKind.ROOT_FOLDER) {398input = this._workspaceService.getWorkspace();399} else {400input = dirname(uri);401}402403const tree = this._tree as WorkbenchAsyncDataTree<IWorkspace | URI, IWorkspaceFolder | IFileStat, FuzzyScore>;404await tree.setInput(input);405let focusElement: IWorkspaceFolder | IFileStat | undefined;406for (const { element } of tree.getNode().children) {407if (isWorkspaceFolder(element) && isEqual(element.uri, uri)) {408focusElement = element;409break;410} else if (isEqual((element as IFileStat).resource, uri)) {411focusElement = element as IFileStat;412break;413}414}415if (focusElement) {416tree.reveal(focusElement, 0.5);417tree.setFocus([focusElement], this._fakeEvent);418}419tree.domFocus();420}421422protected _previewElement(_element: unknown): IDisposable {423return Disposable.None;424}425426protected async _revealElement(element: IFileStat | IWorkspaceFolder, options: IEditorOptions, sideBySide: boolean): Promise<boolean> {427if (!isWorkspaceFolder(element) && element.isFile) {428this._onWillPickElement.fire();429await this._editorService.openEditor({ resource: element.resource, options }, sideBySide ? SIDE_GROUP : undefined);430return true;431}432return false;433}434}435//#endregion436437//#region - Outline438439class OutlineTreeSorter<E> implements ITreeSorter<E> {440441private _order: 'name' | 'type' | 'position';442443constructor(444private comparator: IOutlineComparator<E>,445uri: URI | undefined,446@ITextResourceConfigurationService configService: ITextResourceConfigurationService,447) {448this._order = configService.getValue(uri, 'breadcrumbs.symbolSortOrder');449}450451compare(a: E, b: E): number {452if (this._order === 'name') {453return this.comparator.compareByName(a, b);454} else if (this._order === 'type') {455return this.comparator.compareByType(a, b);456} else {457return this.comparator.compareByPosition(a, b);458}459}460}461462export class BreadcrumbsOutlinePicker extends BreadcrumbsPicker<IOutline<unknown>, unknown> {463464protected _createTree(container: HTMLElement, input: OutlineElement2) {465466const { config } = input.outline;467468return this._instantiationService.createInstance(469WorkbenchDataTree<IOutline<unknown>, unknown, FuzzyScore>,470'BreadcrumbsOutlinePicker',471container,472config.delegate,473config.renderers,474config.treeDataSource,475{476...config.options,477sorter: this._instantiationService.createInstance(OutlineTreeSorter, config.comparator, undefined),478collapseByDefault: true,479expandOnlyOnTwistieClick: true,480multipleSelectionSupport: false,481showNotFoundMessage: false482}483);484}485486protected _setInput(input: OutlineElement2): Promise<void> {487488const viewState = input.outline.captureViewState();489this.restoreViewState = () => { viewState.dispose(); };490491const tree = this._tree as WorkbenchDataTree<IOutline<unknown>, unknown, FuzzyScore>;492493tree.setInput(input.outline);494if (input.element !== input.outline) {495tree.reveal(input.element, 0.5);496tree.setFocus([input.element], this._fakeEvent);497}498tree.domFocus();499500return Promise.resolve();501}502503protected _previewElement(element: unknown): IDisposable {504const outline: IOutline<unknown> = this._tree.getInput()!;505return outline.preview(element);506}507508protected async _revealElement(element: unknown, options: IEditorOptions, sideBySide: boolean): Promise<boolean> {509this._onWillPickElement.fire();510const outline: IOutline<unknown> = this._tree.getInput()!;511await outline.reveal(element, options, sideBySide, false);512return true;513}514}515516//#endregion517518519