Path: blob/main/src/vs/workbench/services/label/common/labelService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../../nls.js';6import { URI } from '../../../../base/common/uri.js';7import { IDisposable, Disposable, dispose } from '../../../../base/common/lifecycle.js';8import { posix, sep, win32 } from '../../../../base/common/path.js';9import { Emitter } from '../../../../base/common/event.js';10import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry, IWorkbenchContribution } from '../../../common/contributions.js';11import { Registry } from '../../../../platform/registry/common/platform.js';12import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';13import { IWorkspaceContextService, IWorkspace, isWorkspace, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier, toWorkspaceIdentifier, WORKSPACE_EXTENSION, isUntitledWorkspace, isTemporaryWorkspace } from '../../../../platform/workspace/common/workspace.js';14import { basenameOrAuthority, basename, joinPath, dirname } from '../../../../base/common/resources.js';15import { tildify, getPathLabel } from '../../../../base/common/labels.js';16import { ILabelService, ResourceLabelFormatter, ResourceLabelFormatting, IFormatterChangeEvent, Verbosity } from '../../../../platform/label/common/label.js';17import { ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js';18import { match } from '../../../../base/common/glob.js';19import { ILifecycleService, LifecyclePhase } from '../../lifecycle/common/lifecycle.js';20import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';21import { IPathService } from '../../path/common/pathService.js';22import { isProposedApiEnabled } from '../../extensions/common/extensions.js';23import { OperatingSystem, OS } from '../../../../base/common/platform.js';24import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js';25import { Schemas } from '../../../../base/common/network.js';26import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';27import { Memento } from '../../../common/memento.js';2829const resourceLabelFormattersExtPoint = ExtensionsRegistry.registerExtensionPoint<ResourceLabelFormatter[]>({30extensionPoint: 'resourceLabelFormatters',31jsonSchema: {32description: localize('vscode.extension.contributes.resourceLabelFormatters', 'Contributes resource label formatting rules.'),33type: 'array',34items: {35type: 'object',36required: ['scheme', 'formatting'],37properties: {38scheme: {39type: 'string',40description: localize('vscode.extension.contributes.resourceLabelFormatters.scheme', 'URI scheme on which to match the formatter on. For example "file". Simple glob patterns are supported.'),41},42authority: {43type: 'string',44description: localize('vscode.extension.contributes.resourceLabelFormatters.authority', 'URI authority on which to match the formatter on. Simple glob patterns are supported.'),45},46formatting: {47description: localize('vscode.extension.contributes.resourceLabelFormatters.formatting', "Rules for formatting uri resource labels."),48type: 'object',49properties: {50label: {51type: 'string',52description: localize('vscode.extension.contributes.resourceLabelFormatters.label', "Label rules to display. For example: myLabel:/${path}. ${path}, ${scheme}, ${authority} and ${authoritySuffix} are supported as variables.")53},54separator: {55type: 'string',56description: localize('vscode.extension.contributes.resourceLabelFormatters.separator', "Separator to be used in the uri label display. '/' or '\' as an example.")57},58stripPathStartingSeparator: {59type: 'boolean',60description: localize('vscode.extension.contributes.resourceLabelFormatters.stripPathStartingSeparator', "Controls whether `${path}` substitutions should have starting separator characters stripped.")61},62tildify: {63type: 'boolean',64description: localize('vscode.extension.contributes.resourceLabelFormatters.tildify', "Controls if the start of the uri label should be tildified when possible.")65},66workspaceSuffix: {67type: 'string',68description: localize('vscode.extension.contributes.resourceLabelFormatters.formatting.workspaceSuffix', "Suffix appended to the workspace label.")69}70}71}72}73}74}75});7677const sepRegexp = /\//g;78const labelMatchingRegexp = /\$\{(scheme|authoritySuffix|authority|path|(query)\.(.+?))\}/g;7980function hasDriveLetterIgnorePlatform(path: string): boolean {81return !!(path && path[2] === ':');82}8384class ResourceLabelFormattersHandler implements IWorkbenchContribution {8586private readonly formattersDisposables = new Map<ResourceLabelFormatter, IDisposable>();8788constructor(@ILabelService labelService: ILabelService) {89resourceLabelFormattersExtPoint.setHandler((extensions, delta) => {90for (const added of delta.added) {91for (const untrustedFormatter of added.value) {9293// We cannot trust that the formatter as it comes from an extension94// adheres to our interface, so for the required properties we fill95// in some defaults if missing.9697const formatter = { ...untrustedFormatter };98if (typeof formatter.formatting.label !== 'string') {99formatter.formatting.label = '${authority}${path}';100}101if (typeof formatter.formatting.separator !== `string`) {102formatter.formatting.separator = sep;103}104105if (!isProposedApiEnabled(added.description, 'contribLabelFormatterWorkspaceTooltip') && formatter.formatting.workspaceTooltip) {106formatter.formatting.workspaceTooltip = undefined; // workspaceTooltip is only proposed107}108109this.formattersDisposables.set(formatter, labelService.registerFormatter(formatter));110}111}112113for (const removed of delta.removed) {114for (const formatter of removed.value) {115dispose(this.formattersDisposables.get(formatter));116}117}118});119}120}121Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ResourceLabelFormattersHandler, LifecyclePhase.Restored);122123const FORMATTER_CACHE_SIZE = 50;124125interface IStoredFormatters {126formatters?: ResourceLabelFormatter[];127i?: number;128}129130export class LabelService extends Disposable implements ILabelService {131132declare readonly _serviceBrand: undefined;133134private formatters: ResourceLabelFormatter[];135136private readonly _onDidChangeFormatters = this._register(new Emitter<IFormatterChangeEvent>({ leakWarningThreshold: 400 }));137readonly onDidChangeFormatters = this._onDidChangeFormatters.event;138139private readonly storedFormattersMemento: Memento;140private readonly storedFormatters: IStoredFormatters;141private os: OperatingSystem;142private userHome: URI | undefined;143144constructor(145@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,146@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,147@IPathService private readonly pathService: IPathService,148@IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService,149@IStorageService storageService: IStorageService,150@ILifecycleService lifecycleService: ILifecycleService,151) {152super();153154// Find some meaningful defaults until the remote environment155// is resolved, by taking the current OS we are running in156// and by taking the local `userHome` if we run on a local157// file scheme.158this.os = OS;159this.userHome = pathService.defaultUriScheme === Schemas.file ? this.pathService.userHome({ preferLocal: true }) : undefined;160161const memento = this.storedFormattersMemento = new Memento('cachedResourceLabelFormatters2', storageService);162this.storedFormatters = memento.getMemento(StorageScope.PROFILE, StorageTarget.MACHINE);163this.formatters = this.storedFormatters?.formatters?.slice() || [];164165// Remote environment is potentially long running166this.resolveRemoteEnvironment();167}168169private async resolveRemoteEnvironment(): Promise<void> {170171// OS172const env = await this.remoteAgentService.getEnvironment();173this.os = env?.os ?? OS;174175// User home176this.userHome = await this.pathService.userHome();177}178179findFormatting(resource: URI): ResourceLabelFormatting | undefined {180let bestResult: ResourceLabelFormatter | undefined;181182for (const formatter of this.formatters) {183if (formatter.scheme === resource.scheme) {184if (!formatter.authority && (!bestResult || formatter.priority)) {185bestResult = formatter;186continue;187}188189if (!formatter.authority) {190continue;191}192193if (194match(formatter.authority.toLowerCase(), resource.authority.toLowerCase()) &&195(196!bestResult ||197!bestResult.authority ||198formatter.authority.length > bestResult.authority.length ||199((formatter.authority.length === bestResult.authority.length) && formatter.priority)200)201) {202bestResult = formatter;203}204}205}206207return bestResult ? bestResult.formatting : undefined;208}209210getUriLabel(resource: URI, options: { relative?: boolean; noPrefix?: boolean; separator?: '/' | '\\'; appendWorkspaceSuffix?: boolean } = {}): string {211let formatting = this.findFormatting(resource);212if (formatting && options.separator) {213// mixin separator if defined from the outside214formatting = { ...formatting, separator: options.separator };215}216217let label = this.doGetUriLabel(resource, formatting, options);218219// Without formatting we still need to support the separator220// as provided in options (https://github.com/microsoft/vscode/issues/130019)221if (!formatting && options.separator) {222label = label.replace(sepRegexp, options.separator);223}224225if (options.appendWorkspaceSuffix && formatting?.workspaceSuffix) {226label = this.appendWorkspaceSuffix(label, resource);227}228229return label;230}231232private doGetUriLabel(resource: URI, formatting?: ResourceLabelFormatting, options: { relative?: boolean; noPrefix?: boolean } = {}): string {233if (!formatting) {234return getPathLabel(resource, {235os: this.os,236tildify: this.userHome ? { userHome: this.userHome } : undefined,237relative: options.relative ? {238noPrefix: options.noPrefix,239getWorkspace: () => this.contextService.getWorkspace(),240getWorkspaceFolder: resource => this.contextService.getWorkspaceFolder(resource)241} : undefined242});243}244245// Relative label246if (options.relative && this.contextService) {247let folder = this.contextService.getWorkspaceFolder(resource);248if (!folder) {249250// It is possible that the resource we want to resolve the251// workspace folder for is not using the same scheme as252// the folders in the workspace, so we help by trying again253// to resolve a workspace folder by trying again with a254// scheme that is workspace contained.255256const workspace = this.contextService.getWorkspace();257const firstFolder = workspace.folders.at(0);258if (firstFolder && resource.scheme !== firstFolder.uri.scheme && resource.path.startsWith(posix.sep)) {259folder = this.contextService.getWorkspaceFolder(firstFolder.uri.with({ path: resource.path }));260}261}262263if (folder) {264const folderLabel = this.formatUri(folder.uri, formatting, options.noPrefix);265266let relativeLabel = this.formatUri(resource, formatting, options.noPrefix);267let overlap = 0;268while (relativeLabel[overlap] && relativeLabel[overlap] === folderLabel[overlap]) {269overlap++;270}271272if (!relativeLabel[overlap] || relativeLabel[overlap] === formatting.separator) {273relativeLabel = relativeLabel.substring(1 + overlap);274} else if (overlap === folderLabel.length && folder.uri.path === posix.sep) {275relativeLabel = relativeLabel.substring(overlap);276}277278// always show root basename if there are multiple folders279const hasMultipleRoots = this.contextService.getWorkspace().folders.length > 1;280if (hasMultipleRoots && !options.noPrefix) {281const rootName = folder?.name ?? basenameOrAuthority(folder.uri);282relativeLabel = relativeLabel ? `${rootName} • ${relativeLabel}` : rootName;283}284285return relativeLabel;286}287}288289// Absolute label290return this.formatUri(resource, formatting, options.noPrefix);291}292293getUriBasenameLabel(resource: URI): string {294const formatting = this.findFormatting(resource);295const label = this.doGetUriLabel(resource, formatting);296297let pathLib: typeof win32 | typeof posix;298if (formatting?.separator === win32.sep) {299pathLib = win32;300} else if (formatting?.separator === posix.sep) {301pathLib = posix;302} else {303pathLib = (this.os === OperatingSystem.Windows) ? win32 : posix;304}305306return pathLib.basename(label);307}308309getWorkspaceLabel(workspace: IWorkspace | IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI, options?: { verbose: Verbosity }): string {310if (isWorkspace(workspace)) {311const identifier = toWorkspaceIdentifier(workspace);312if (isSingleFolderWorkspaceIdentifier(identifier) || isWorkspaceIdentifier(identifier)) {313return this.getWorkspaceLabel(identifier, options);314}315316return '';317}318319// Workspace: Single Folder (as URI)320if (URI.isUri(workspace)) {321return this.doGetSingleFolderWorkspaceLabel(workspace, options);322}323324// Workspace: Single Folder (as workspace identifier)325if (isSingleFolderWorkspaceIdentifier(workspace)) {326return this.doGetSingleFolderWorkspaceLabel(workspace.uri, options);327}328329// Workspace: Multi Root330if (isWorkspaceIdentifier(workspace)) {331return this.doGetWorkspaceLabel(workspace.configPath, options);332}333334return '';335}336337private doGetWorkspaceLabel(workspaceUri: URI, options?: { verbose: Verbosity }): string {338339// Workspace: Untitled340if (isUntitledWorkspace(workspaceUri, this.environmentService)) {341return localize('untitledWorkspace', "Untitled (Workspace)");342}343344// Workspace: Temporary345if (isTemporaryWorkspace(workspaceUri)) {346return localize('temporaryWorkspace', "Workspace");347}348349// Workspace: Saved350let filename = basename(workspaceUri);351if (filename.endsWith(WORKSPACE_EXTENSION)) {352filename = filename.substr(0, filename.length - WORKSPACE_EXTENSION.length - 1);353}354355let label: string;356switch (options?.verbose) {357case Verbosity.SHORT:358label = filename; // skip suffix for short label359break;360case Verbosity.LONG:361label = localize('workspaceNameVerbose', "{0} (Workspace)", this.getUriLabel(joinPath(dirname(workspaceUri), filename)));362break;363case Verbosity.MEDIUM:364default:365label = localize('workspaceName', "{0} (Workspace)", filename);366break;367}368369if (options?.verbose === Verbosity.SHORT) {370return label; // skip suffix for short label371}372373return this.appendWorkspaceSuffix(label, workspaceUri);374}375376private doGetSingleFolderWorkspaceLabel(folderUri: URI, options?: { verbose: Verbosity }): string {377let label: string;378switch (options?.verbose) {379case Verbosity.LONG:380label = this.getUriLabel(folderUri);381break;382case Verbosity.SHORT:383case Verbosity.MEDIUM:384default:385label = basename(folderUri) || posix.sep;386break;387}388389if (options?.verbose === Verbosity.SHORT) {390return label; // skip suffix for short label391}392393return this.appendWorkspaceSuffix(label, folderUri);394}395396getSeparator(scheme: string, authority?: string): '/' | '\\' {397const formatter = this.findFormatting(URI.from({ scheme, authority }));398399return formatter?.separator || posix.sep;400}401402getHostLabel(scheme: string, authority?: string): string {403const formatter = this.findFormatting(URI.from({ scheme, authority }));404405return formatter?.workspaceSuffix || authority || '';406}407408getHostTooltip(scheme: string, authority?: string): string | undefined {409const formatter = this.findFormatting(URI.from({ scheme, authority }));410411return formatter?.workspaceTooltip;412}413414registerCachedFormatter(formatter: ResourceLabelFormatter): IDisposable {415const list = this.storedFormatters.formatters ??= [];416417let replace = list.findIndex(f => f.scheme === formatter.scheme && f.authority === formatter.authority);418if (replace === -1 && list.length >= FORMATTER_CACHE_SIZE) {419replace = FORMATTER_CACHE_SIZE - 1; // at max capacity, replace the last element420}421422if (replace === -1) {423list.unshift(formatter);424} else {425for (let i = replace; i > 0; i--) {426list[i] = list[i - 1];427}428list[0] = formatter;429}430431this.storedFormattersMemento.saveMemento();432433return this.registerFormatter(formatter);434}435436registerFormatter(formatter: ResourceLabelFormatter): IDisposable {437this.formatters.push(formatter);438this._onDidChangeFormatters.fire({ scheme: formatter.scheme });439440return {441dispose: () => {442this.formatters = this.formatters.filter(f => f !== formatter);443this._onDidChangeFormatters.fire({ scheme: formatter.scheme });444}445};446}447448private formatUri(resource: URI, formatting: ResourceLabelFormatting, forceNoTildify?: boolean): string {449let label = formatting.label.replace(labelMatchingRegexp, (match, token, qsToken, qsValue) => {450switch (token) {451case 'scheme': return resource.scheme;452case 'authority': return resource.authority;453case 'authoritySuffix': {454const i = resource.authority.indexOf('+');455return i === -1 ? resource.authority : resource.authority.slice(i + 1);456}457case 'path':458return formatting.stripPathStartingSeparator459? resource.path.slice(resource.path[0] === formatting.separator ? 1 : 0)460: resource.path;461default: {462if (qsToken === 'query') {463const { query } = resource;464if (query && query[0] === '{' && query[query.length - 1] === '}') {465try {466return JSON.parse(query)[qsValue] || '';467} catch { }468}469}470471return '';472}473}474});475476// convert \c:\something => C:\something477if (formatting.normalizeDriveLetter && hasDriveLetterIgnorePlatform(label)) {478label = label.charAt(1).toUpperCase() + label.substr(2);479}480481if (formatting.tildify && !forceNoTildify) {482if (this.userHome) {483label = tildify(label, this.userHome.fsPath, this.os);484}485}486487if (formatting.authorityPrefix && resource.authority) {488label = formatting.authorityPrefix + label;489}490491return label.replace(sepRegexp, formatting.separator);492}493494private appendWorkspaceSuffix(label: string, uri: URI): string {495const formatting = this.findFormatting(uri);496const suffix = formatting && (typeof formatting.workspaceSuffix === 'string') ? formatting.workspaceSuffix : undefined;497498return suffix ? `${label} [${suffix}]` : label;499}500}501502registerSingleton(ILabelService, LabelService, InstantiationType.Delayed);503504505