// ***************************************************************************** // Copyright (C) 2019 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ // some code is copied and modified from: // https://github.com/microsoft/vscode/blob/7cf4cca47aa025a590fc939af54932042302be63/src/vs/workbench/services/themes/browser/fileIconThemeData.ts import debounce = require('@theia/core/shared/lodash.debounce'); import * as jsoncparser from 'jsonc-parser'; import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { IconThemeService, IconTheme, IconThemeDefinition } from '@theia/core/lib/browser/icon-theme-service'; import { IconThemeContribution, DeployedPlugin, UiTheme, getPluginId } from '../../common/plugin-protocol'; import URI from '@theia/core/lib/common/uri'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { Emitter } from '@theia/core/lib/common/event'; import { RecursivePartial } from '@theia/core/lib/common/types'; import { LabelProviderContribution, DidChangeLabelEvent, LabelProvider, URIIconReference } from '@theia/core/lib/browser/label-provider'; import { ThemeType } from '@theia/core/lib/common/theme'; import { FileStatNode, DirNode } from '@theia/filesystem/lib/browser'; import { WorkspaceRootNode } from '@theia/navigator/lib/browser/navigator-tree'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat, FileChangeType } from '@theia/filesystem/lib/common/files'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { ILanguageService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/language'; import { LanguageService } from '@theia/core/lib/browser/language-service'; import { DEFAULT_ICON_SIZE, PLUGIN_FILE_ICON_CLASS } from './plugin-shared-style'; export interface PluginIconDefinition { iconPath: string; fontColor: string; fontCharacter: string; fontSize: string; fontId: string; } export interface PluginFontDefinition { id: string; weight: string; style: string; size: string; src: { path: string; format: string; }[]; } export interface PluginIconsAssociation { folder?: string; file?: string; folderExpanded?: string; rootFolder?: string; rootFolderExpanded?: string; folderNames?: { [folderName: string]: string; }; folderNamesExpanded?: { [folderName: string]: string; }; fileExtensions?: { [extension: string]: string; }; fileNames?: { [fileName: string]: string; }; languageIds?: { [languageId: string]: string; }; } export interface PluginIconDefinitions { [key: string]: PluginIconDefinition } export interface PluginIconThemeDocument extends PluginIconsAssociation { iconDefinitions: PluginIconDefinitions; fonts: PluginFontDefinition[]; light?: PluginIconsAssociation; highContrast?: PluginIconsAssociation; hidesExplorerArrows?: boolean; showLanguageModeIcons?: boolean; } export const PluginIconThemeFactory = Symbol('PluginIconThemeFactory'); export type PluginIconThemeFactory = (definition: PluginIconThemeDefinition) => PluginIconTheme; @injectable() export class PluginIconThemeDefinition implements IconThemeDefinition, IconThemeContribution { id: string; label: string; description?: string; uri: string; uiTheme?: UiTheme; pluginId: string; packageUri: string; hasFileIcons?: boolean; hasFolderIcons?: boolean; hidesExplorerArrows?: boolean; showLanguageModeIcons?: boolean; } class PluginLanguageIconInfo { hasSpecificFileIcons: boolean = false; coveredLanguages: { [languageId: string]: boolean } = {}; }; @injectable() export class PluginIconTheme extends PluginIconThemeDefinition implements IconTheme, Disposable { @inject(FileService) protected readonly fileService: FileService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(PluginIconThemeDefinition) protected readonly definition: PluginIconThemeDefinition; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(LanguageService) protected readonly languageService: LanguageService; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; protected readonly toDeactivate = new DisposableCollection(); protected readonly toUnload = new DisposableCollection(); protected readonly toDisposeStyleElement = new DisposableCollection(); protected readonly toDispose = new DisposableCollection( this.toDeactivate, this.toDisposeStyleElement, this.toUnload, this.onDidChangeEmitter ); protected packageRootUri: URI; protected locationUri: URI; protected styleSheetContent: string | undefined; protected readonly icons = new Set(); @postConstruct() protected init(): void { Object.assign(this, this.definition); this.packageRootUri = new URI(this.packageUri); this.locationUri = new URI(this.uri).parent; } dispose(): void { this.toDispose.dispose(); } protected fireDidChange(): void { this.onDidChangeEmitter.fire({ affects: () => true }); } activate(): Disposable { if (!this.toDeactivate.disposed) { return this.toDeactivate; } this.toDeactivate.push(Disposable.create(() => this.fireDidChange())); this.doActivate(); return this.toDeactivate; } protected async doActivate(): Promise { await this.load(); this.updateStyleElement(); } protected updateStyleElement(): void { this.toDisposeStyleElement.dispose(); if (this.toDeactivate.disposed || !this.styleSheetContent) { return; } const styleElement = document.createElement('style'); styleElement.type = 'text/css'; styleElement.className = 'theia-icon-theme'; styleElement.innerText = this.styleSheetContent; document.head.appendChild(styleElement); const toRemoveStyleElement = Disposable.create(() => styleElement.remove()); this.toDisposeStyleElement.push(toRemoveStyleElement); this.toDeactivate.push(toRemoveStyleElement); this.fireDidChange(); } protected reload = debounce(() => { this.toUnload.dispose(); this.doActivate(); }, 50); /** * This should be aligned with * https://github.com/microsoft/vscode/blob/7cf4cca47aa025a590fc939af54932042302be63/src/vs/workbench/services/themes/browser/fileIconThemeData.ts#L201 */ protected async load(): Promise { if (this.styleSheetContent !== undefined) { return; } this.styleSheetContent = ''; this.toUnload.push(Disposable.create(() => { this.styleSheetContent = undefined; this.hasFileIcons = undefined; this.hasFolderIcons = undefined; this.hidesExplorerArrows = undefined; this.icons.clear(); })); const uri = new URI(this.uri); const result = await this.fileService.read(uri); const content = result.value; const json: RecursivePartial = jsoncparser.parse(content, undefined, { disallowComments: false }); this.hidesExplorerArrows = !!json.hidesExplorerArrows; const toUnwatch = this.fileService.watch(uri); if (this.toUnload.disposed) { toUnwatch.dispose(); } else { this.toUnload.push(toUnwatch); this.toUnload.push(this.fileService.onDidFilesChange(e => { if (e.contains(uri, FileChangeType.ADDED) || e.contains(uri, FileChangeType.UPDATED)) { this.reload(); } })); } const iconDefinitions = json.iconDefinitions; if (!iconDefinitions) { return; } const definitionSelectors = new Map(); const acceptSelector = (themeType: ThemeType, definitionId: string, ...icons: string[]) => { if (!iconDefinitions[definitionId]) { return; } let selector = ''; for (const icon of icons) { if (icon) { selector += '.' + icon; this.icons.add(icon); } } if (!selector) { return; } const selectors = definitionSelectors.get(definitionId) || []; if (themeType !== 'dark') { selector = '.theia-' + themeType + ' ' + selector; } selectors.push(selector + '::before'); definitionSelectors.set(definitionId, selectors); }; let iconInfo = this.collectSelectors(json, acceptSelector.bind(undefined, 'dark')); if (json.light) { iconInfo = this.collectSelectors(json.light, acceptSelector.bind(undefined, 'light')); } if (json.highContrast) { iconInfo = this.collectSelectors(json.highContrast, acceptSelector.bind(undefined, 'hc')); } const showLanguageModeIcons = this.showLanguageModeIcons === true || json.showLanguageModeIcons === true || (iconInfo.hasSpecificFileIcons && json.showLanguageModeIcons !== false); const fonts = json.fonts; if (Array.isArray(fonts)) { for (const font of fonts) { if (font) { let src = ''; if (Array.isArray(font.src)) { for (const srcLocation of font.src) { if (srcLocation && srcLocation.path) { const cssUrl = this.toCSSUrl(srcLocation.path); if (cssUrl) { if (src) { src += ', '; } src += `${cssUrl} format('${srcLocation.format}')`; } } } } if (src) { this.styleSheetContent += `@font-face { src: ${src}; font-family: '${font.id}'; font-weight: ${font.weight}; font-style: ${font.style}; } `; } } } const firstFont = fonts[0]; if (firstFont && firstFont.id) { this.styleSheetContent += `.${this.fileIcon}::before, .${this.folderIcon}::before, .${this.rootFolderIcon}::before { font-family: '${firstFont.id}'; font-size: ${firstFont.size || '150%'}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; vertical-align: top; } `; } } for (const definitionId of definitionSelectors.keys()) { const iconDefinition = iconDefinitions[definitionId]; const selectors = definitionSelectors.get(definitionId); if (selectors && iconDefinition) { const cssUrl = this.toCSSUrl(iconDefinition.iconPath); if (cssUrl) { this.styleSheetContent += `${selectors.join(', ')} { content: ' '; background-image: ${cssUrl}; background-size: ${DEFAULT_ICON_SIZE}px; background-position: left center; background-repeat: no-repeat; } `; } if (iconDefinition.fontCharacter || iconDefinition.fontColor) { let body = ''; if (iconDefinition.fontColor) { body += ` color: ${iconDefinition.fontColor};`; } if (iconDefinition.fontCharacter) { body += ` content: '${iconDefinition.fontCharacter}';`; } if (iconDefinition.fontSize) { body += ` font-size: ${iconDefinition.fontSize};`; } if (iconDefinition.fontId) { body += ` font-family: ${iconDefinition.fontId};`; } this.styleSheetContent += `${selectors.join(', ')} {${body} }\n`; } } } if (showLanguageModeIcons) { for (const language of this.languageService.languages) { // only show language icons if there are no more specific icons in the style document if (!iconInfo.coveredLanguages[language.id]) { const icon = this.languageService.getIcon(language.id); if (icon) { this.icons.add(this.fileIcon); this.icons.add(this.languageIcon(language.id)); this.icons.add(icon); } } } } } protected toCSSUrl(iconPath: string | undefined): string | undefined { if (!iconPath) { return undefined; } const iconUri = this.locationUri.resolve(iconPath); const relativePath = this.packageRootUri.path.relative(iconUri.path.normalize()); return relativePath && `url('${new Endpoint({ path: `hostedPlugin/${this.pluginId}/${encodeURIComponent(relativePath.normalize().toString())}` }).getRestUrl().toString()}')`; } protected escapeCSS(value: string): string { value = value.replace(/[^\-a-zA-Z0-9]/g, '-'); if (value.charAt(0).match(/[0-9\-]/)) { value = '-' + value; } return value; } protected readonly fileIcon = PLUGIN_FILE_ICON_CLASS; protected readonly folderIcon = 'theia-plugin-folder-icon'; protected readonly folderExpandedIcon = 'theia-plugin-folder-expanded-icon'; protected readonly rootFolderIcon = 'theia-plugin-root-folder-icon'; protected readonly rootFolderExpandedIcon = 'theia-plugin-root-folder-expanded-icon'; protected folderNameIcon(folderName: string): string { return 'theia-plugin-' + this.escapeCSS(folderName.toLowerCase()) + '-folder-name-icon'; } protected expandedFolderNameIcon(folderName: string): string { return 'theia-plugin-' + this.escapeCSS(folderName.toLowerCase()) + '-expanded-folder-name-icon'; } protected fileNameIcon(fileName: string): string[] { fileName = fileName.toLowerCase(); const extIndex = fileName.indexOf('.'); const icons = extIndex !== -1 ? this.fileExtensionIcon(fileName.substring(extIndex + 1)) : []; icons.unshift('theia-plugin-' + this.escapeCSS(fileName) + '-file-name-icon'); return icons; } protected fileExtensionIcon(fileExtension: string): string[] { fileExtension = fileExtension.toString(); const icons = []; const segments = fileExtension.split('.'); if (segments.length) { if (segments.length) { for (let i = 0; i < segments.length; i++) { icons.push('theia-plugin-' + this.escapeCSS(segments.slice(i).join('.')) + '-ext-file-icon'); } icons.push('theia-plugin-ext-file-icon'); // extra segment to increase file-ext score } } return icons; } protected languageIcon(languageId: string): string { return 'theia-plugin-' + this.escapeCSS(languageId) + '-lang-file-icon'; } protected collectSelectors(associations: RecursivePartial, accept: (definitionId: string, ...icons: string[]) => void): PluginLanguageIconInfo { const iconInfo = new PluginLanguageIconInfo(); if (associations.folder) { accept(associations.folder, this.folderIcon); if (associations.folderExpanded === undefined) { // Use the same icon for expanded state (issue #12727). Check for // undefined folderExpanded property to allow for // "folderExpanded": null in case a developer really wants that accept(associations.folder, this.folderExpandedIcon); } this.hasFolderIcons = true; } if (associations.folderExpanded) { accept(associations.folderExpanded, this.folderExpandedIcon); this.hasFolderIcons = true; } const rootFolder = associations.rootFolder || associations.folder; if (rootFolder) { accept(rootFolder, this.rootFolderIcon); this.hasFolderIcons = true; } const rootFolderExpanded = associations.rootFolderExpanded || associations.folderExpanded; if (rootFolderExpanded) { accept(rootFolderExpanded, this.rootFolderExpandedIcon); this.hasFolderIcons = true; } if (associations.file) { accept(associations.file, this.fileIcon); this.hasFileIcons = true; } const folderNames = associations.folderNames; if (folderNames) { // eslint-disable-next-line guard-for-in for (const folderName in folderNames) { accept(folderNames[folderName]!, this.folderNameIcon(folderName), this.folderIcon); this.hasFolderIcons = true; } } const folderNamesExpanded = associations.folderNamesExpanded; if (folderNamesExpanded) { // eslint-disable-next-line guard-for-in for (const folderName in folderNamesExpanded) { accept(folderNamesExpanded[folderName]!, this.expandedFolderNameIcon(folderName), this.folderExpandedIcon); this.hasFolderIcons = true; } } const languageIds = associations.languageIds; if (languageIds) { if (!languageIds.jsonc && languageIds.json) { languageIds.jsonc = languageIds.json; } // eslint-disable-next-line guard-for-in for (const languageId in languageIds) { accept(languageIds[languageId]!, this.languageIcon(languageId), this.fileIcon); this.hasFileIcons = true; iconInfo.hasSpecificFileIcons = true; iconInfo.coveredLanguages[languageId] = true; } } const fileExtensions = associations.fileExtensions; if (fileExtensions) { // eslint-disable-next-line guard-for-in for (const fileExtension in fileExtensions) { accept(fileExtensions[fileExtension]!, ...this.fileExtensionIcon(fileExtension), this.fileIcon); this.hasFileIcons = true; iconInfo.hasSpecificFileIcons = true; } } const fileNames = associations.fileNames; if (fileNames) { // eslint-disable-next-line guard-for-in for (const fileName in fileNames) { accept(fileNames[fileName]!, ...this.fileNameIcon(fileName), this.fileIcon); this.hasFileIcons = true; iconInfo.hasSpecificFileIcons = true; } } return iconInfo; } /** * This should be aligned with * https://github.com/microsoft/vscode/blob/7cf4cca47aa025a590fc939af54932042302be63/src/vs/editor/common/services/getIconClasses.ts#L5 */ getIcon(element: URI | URIIconReference | FileStat | FileStatNode | WorkspaceRootNode): string { let icon = ''; for (const className of this.getClassNames(element)) { if (this.icons.has(className)) { if (icon) { icon += ' '; } icon += className; } } return icon; } protected getClassNames(element: URI | URIIconReference | FileStat | FileStatNode | WorkspaceRootNode): string[] { if (WorkspaceRootNode.is(element)) { const name = this.labelProvider.getName(element); if (element.expanded) { return [this.rootFolderExpandedIcon, this.expandedFolderNameIcon(name)]; } return [this.rootFolderIcon, this.folderNameIcon(name)]; } if (DirNode.is(element)) { if (element.expanded) { const name = this.labelProvider.getName(element); return [this.folderExpandedIcon, this.expandedFolderNameIcon(name)]; } return this.getFolderClassNames(element); } if (FileStatNode.is(element)) { return this.getFileClassNames(element, element.fileStat.resource.toString()); } if (FileStat.is(element)) { if (element.isDirectory) { return this.getFolderClassNames(element); } return this.getFileClassNames(element, element.resource.toString()); } if (URIIconReference.is(element)) { if (element.id === 'folder') { return this.getFolderClassNames(element); } return this.getFileClassNames(element, element.uri && element.uri.toString()); } return this.getFileClassNames(element, element.toString()); } protected getFolderClassNames(element: object): string[] { const name = this.labelProvider.getName(element); return [this.folderIcon, this.folderNameIcon(name)]; } protected getFileClassNames(element: object, uri?: string): string[] { const name = this.labelProvider.getName(element); const classNames = this.fileNameIcon(name); if (uri) { const parsedURI = new URI(uri); const isRoot = this.workspaceService.getWorkspaceRootUri(new URI(uri))?.isEqual(parsedURI); if (isRoot) { classNames.unshift(this.rootFolderIcon); } else { classNames.unshift(this.fileIcon); } const language = StandaloneServices.get(ILanguageService).createByFilepathOrFirstLine(parsedURI['codeUri']); classNames.push(this.languageIcon(language.languageId)); const defaultLanguageIcon = this.languageService.getIcon(language.languageId); if (defaultLanguageIcon) { classNames.push(defaultLanguageIcon); } } return classNames; } } @injectable() export class PluginIconThemeService implements LabelProviderContribution { @inject(IconThemeService) protected readonly iconThemeService: IconThemeService; @inject(PluginIconThemeFactory) protected readonly iconThemeFactory: PluginIconThemeFactory; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; protected fireDidChange(): void { this.onDidChangeEmitter.fire({ affects: () => true }); } register(contribution: IconThemeContribution, plugin: DeployedPlugin): Disposable { const pluginId = getPluginId(plugin.metadata.model); const packageUri = plugin.metadata.model.packageUri; const iconTheme = this.iconThemeFactory({ id: contribution.id, label: contribution.label || new URI(contribution.uri).path.base, description: contribution.description, uri: contribution.uri, uiTheme: contribution.uiTheme, pluginId, packageUri }); return new DisposableCollection( iconTheme, iconTheme.onDidChange(() => this.fireDidChange()), this.iconThemeService.register(iconTheme) ); } canHandle(element: object): number { const current = this.iconThemeService.getDefinition(this.iconThemeService.current); if (current instanceof PluginIconTheme && ( (element instanceof URI && element.scheme === 'file') || URIIconReference.is(element) || FileStat.is(element) || FileStatNode.is(element) )) { return Number.MAX_SAFE_INTEGER; } return 0; } getIcon(element: URI | URIIconReference | FileStat | FileStatNode | WorkspaceRootNode): string | undefined { const current = this.iconThemeService.getDefinition(this.iconThemeService.current); if (current instanceof PluginIconTheme) { return current.getIcon(element); } return undefined; } }