import type { Event } from '@difizen/mana-common'; import { URI, Priority } from '@difizen/mana-common'; import { Emitter, Disposable } from '@difizen/mana-common'; import { ApplicationContribution } from '@difizen/mana-core'; import type { Contribution } from '@difizen/mana-syringe'; import { contrib, postConstruct, singleton, Syringe } from '@difizen/mana-syringe'; import fileIcons from 'file-icons-js'; export type ResourceLabelFormatter = { scheme: string; authority?: string; priority?: boolean; formatting: ResourceLabelFormatting; }; export type ResourceLabelFormatting = { label: string; // myLabel:/${path} separator: '/' | '\\' | ''; normalizeDriveLetter?: boolean; authorityPrefix?: string; }; /** * @internal don't export it, use `LabelProvider.folderIcon` instead. */ const DEFAULT_FOLDER_ICON = 'fa fa-folder default-folder-icon'; /** * @internal don't export it, use `LabelProvider.fileIcon` instead. */ const DEFAULT_FILE_ICON = 'fa fa-file default-file-icon'; export const LabelProviderContribution = Syringe.defineToken( 'LabelProviderContribution', ); /** * A {@link LabelProviderContribution} determines how specific elements/nodes are displayed in the workbench. * Mana views use a common {@link LabelProvider} to determine the label and/or an icon for elements shown in the UI. This includes elements in lists * and trees, but also view specific locations like headers. The common {@link LabelProvider} collects all {@links LabelProviderContribution} and delegates * to the contribution with the highest priority. This is determined via calling the {@link LabelProviderContribution.canHandle} function, so contributions * define which elements they are responsible for. * As arbitrary views can consume LabelProviderContributions, they must be generic for the covered element type, not view specific. Label providers and * contributions can be used for arbitrary element and node types, e.g. for markers or domain-specific elements. */ export interface LabelProviderContribution { /** * Determines whether this contribution can handle the given element and with what priority. * All contributions are ordered by the returned number if greater than zero. The highest number wins. * If two or more contributions return the same positive number one of those will be used. It is undefined which one. */ canHandle: (element: Record) => number; /** * returns an icon class for the given element. */ getIcon?: (element: T) => string | undefined; /** * returns a short name for the given element. */ getName?: (element: T) => string | undefined; /** * returns a long name for the given element. */ getLongName?: (element: T) => string | undefined; /** * returns an icon component for the given element. */ getIconComponent?: (element: T) => React.ReactNode; /** * returns an name component for the given element. */ getNameComponent?: (element: T) => React.ReactNode; /** * returns an description component for the given element. */ getDescriptionComponent?: (element: T) => React.ReactNode; /** * Emit when something has changed that may result in this label provider returning a different * value for one or more properties (name, icon etc). */ readonly onDidChange?: Event; /** * Checks whether the given element is affected by the given change event. * Contributions delegating to the label provider can use this hook * to perform a recursive check. */ affects?: (element: T, event: DidChangeLabelEvent) => boolean; } export type DidChangeLabelEvent = { affects: (element: Record) => boolean; }; export type URIIconReference = { kind: 'uriIconReference'; id: 'file' | 'folder'; uri?: URI | undefined; }; export namespace URIIconReference { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function is(element: any | undefined): element is URIIconReference { return ( !!element && typeof element === 'object' && 'kind' in element && element.kind === 'uriIconReference' ); } export function create(id: URIIconReference['id'], uri?: URI): URIIconReference { return { kind: 'uriIconReference', id, uri }; } } @singleton({ contrib: LabelProviderContribution }) export class DefaultUriLabelProviderContribution implements LabelProviderContribution> { protected formatters: ResourceLabelFormatter[] = []; protected readonly onDidChangeEmitter = new Emitter(); protected homePath: string | undefined = '/'; @postConstruct() init(): void { // } canHandle(element: Record): number { if (element instanceof URI || URIIconReference.is(element)) { return 1; } return 0; } getIcon(element: Record): string { if (URIIconReference.is(element) && element.id === 'folder') { return this.defaultFolderIcon; } const uri = URIIconReference.is(element) ? element.uri : element; if (uri) { const iconClass = uri && this.getFileIcon(uri as URI); return iconClass || this.defaultFileIcon; } return ''; } get defaultFolderIcon(): string { return DEFAULT_FOLDER_ICON; } get defaultFileIcon(): string { return DEFAULT_FILE_ICON; } protected getFileIcon(uri: URI): string | undefined { const fileIcon = fileIcons.getClassWithColor(uri.displayName); if (!fileIcon) { return undefined; } return `${fileIcon} mana-file-icons-js`; } getName(element: Record): string | undefined { const uri = this.getUri(element as URI | URIIconReference); return uri && uri.displayName; } getLongName(element: Record): string | undefined { const uri = this.getUri(element as URI | URIIconReference); if (uri) { const formatting = this.findFormatting(uri); if (formatting) { return this.formatUri(uri, formatting); } } return uri && uri.path.toString(); } protected getUri(element: URI | URIIconReference): URI | undefined { return URIIconReference.is(element) ? element.uri : element; } registerFormatter(formatter: ResourceLabelFormatter): Disposable { this.formatters.push(formatter); this.fireOnDidChange(); return Disposable.create(() => { this.formatters = this.formatters.filter((f) => f !== formatter); this.fireOnDidChange(); }); } get onDidChange(): Event { return this.onDidChangeEmitter.event; } private fireOnDidChange(): void { this.onDidChangeEmitter.fire({ affects: (element: Record) => this.canHandle(element as any) > 0, }); } // copied and modified from https://github.com/microsoft/vscode/blob/1.44.2/src/vs/workbench/services/label/common/labelService.ts /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ private readonly labelMatchingRegexp = /\${(scheme|authority|path|query)}/g; protected formatUri(resource: URI, formatting: ResourceLabelFormatting): string { let label = formatting.label.replace(this.labelMatchingRegexp, (_match, token) => { switch (token) { case 'scheme': return resource.scheme; case 'authority': return resource.authority; case 'path': return resource.path.toString(); case 'query': return resource.query; default: return ''; } }); // convert \c:\something => C:\something if (formatting.normalizeDriveLetter && this.hasDriveLetter(label)) { label = label.charAt(1).toUpperCase() + label.substr(2); } if (formatting.authorityPrefix && resource.authority) { label = formatting.authorityPrefix + label; } return label.replace(/\//g, formatting.separator); } private hasDriveLetter(path: string): boolean { return !!(path && path[2] === ':'); } protected findFormatting(resource: URI): ResourceLabelFormatting | undefined { let bestResult: ResourceLabelFormatter | undefined; this.formatters.forEach((formatter) => { if (formatter.scheme === resource.scheme) { if (!bestResult && !formatter.authority) { bestResult = formatter; return; } if (!formatter.authority) { return; } if ( formatter.authority.toLowerCase() === resource.authority.toLowerCase() && (!bestResult || !bestResult.authority || formatter.authority.length > bestResult.authority.length || (formatter.authority.length === bestResult.authority.length && formatter.priority)) ) { bestResult = formatter; } } }); return bestResult ? bestResult.formatting : undefined; } } /** * The {@link LabelProvider} determines how elements/nodes are displayed in the workbench. For any element, it can determine a short label, a long label * and an icon. The {@link LabelProvider} is to be used in lists, trees and tables, but also view specific locations like headers. * The common {@link LabelProvider} can be extended/adapted via {@link LabelProviderContribution}s. For every element, the {@links LabelProvider} will determine the * {@link LabelProviderContribution} with the hightest priority and delegate to it. Mana registers default {@link LabelProviderContribution} for common types, e.g. * the {@link DefaultUriLabelProviderContribution} for elements that have a URI. * Using the {@link LabelProvider} across the workbench ensures a common look and feel for elements across multiple views. To adapt the way how specific * elements/nodes are rendered, use a {@link LabelProviderContribution} rather than adapting or sub classing the {@link LabelProvider}. This way, your adaptation * is applied to all views in Mana that use the {@link LabelProvider} */ @singleton({ contrib: ApplicationContribution }) export class LabelProvider implements ApplicationContribution { protected readonly onDidChangeEmitter = new Emitter(); protected readonly contributionProvider: Contribution.Provider; constructor( @contrib(LabelProviderContribution) contributionProvider: Contribution.Provider, ) { this.contributionProvider = contributionProvider; } /** * Start listening to contributions. * * Don't call this method directly! * It's called by the frontend application during initialization. */ initialize(): void { const contributions = this.contributionProvider.getContributions(); for (const eventContribution of contributions) { if (eventContribution.onDidChange) { eventContribution.onDidChange((event) => { this.onDidChangeEmitter.fire({ affects: (element) => this.affects(element, event), }); }); } } } protected affects(element: Record, event: DidChangeLabelEvent): boolean { if (event.affects(element)) { return true; } for (const contribution of this.findContribution(element)) { if (contribution.affects && contribution.affects(element, event)) { return true; } } return false; } get onDidChange(): Event { return this.onDidChangeEmitter.event; } /** * Return a default file icon for the current icon theme. */ get fileIcon(): string { return this.getIcon(URIIconReference.create('file')); } /** * Return a default folder icon for the current icon theme. */ get folderIcon(): string { return this.getIcon(URIIconReference.create('folder')); } /** * Get the icon class from the list of available {@link LabelProviderContribution} for the given element. * @return the icon class */ getIcon(element: Record): string { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getIcon && contribution.getIcon(element); if (value === undefined) { continue; } return value; } return ''; } /** * Get a short name from the list of available {@link LabelProviderContribution} for the given element. * @return the short name */ getName(element: Record): string { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getName && contribution.getName(element); if (value === undefined) { continue; } return value; } return ''; } /** * Get a long name from the list of available {@link LabelProviderContribution} for the given element. * @return the long name */ getLongName(element: Record): string { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getLongName && contribution.getLongName(element); if (value === undefined) { continue; } return value; } return ''; } /** * Get a icon component from the list of available {@link LabelProviderContribution} for the given element. * @return the icon component */ getIconCompomponent(element: Record): React.ReactNode { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getIconComponent && contribution.getIconComponent(element); if (value === undefined) { continue; } return value; } return null; } /** * Get a name component from the list of available {@link LabelProviderContribution} for the given element. * @return the name component */ getNameComponent(element: Record): React.ReactNode { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getNameComponent && contribution.getNameComponent(element); if (value === undefined) { continue; } return value; } return ''; } /** * Get a description component from the list of available {@link LabelProviderContribution} for the given element. * @return the description component */ getDescriptionComponent(element: Record): React.ReactNode { const contributions = this.findContribution(element); for (const contribution of contributions) { const value = contribution.getDescriptionComponent && contribution.getDescriptionComponent(element); if (value === undefined) { continue; } return value; } return null; } protected findContribution(element: Record): LabelProviderContribution[] { const prioritized = Priority.sortSync( this.contributionProvider.getContributions(), (contribution) => contribution.canHandle(element), ); return prioritized.map((c) => c.value); } }