Path: blob/main/src/vs/workbench/services/label/common/labelService.ts
5241 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 posixPathSeparatorRegexp = /\//g; // on Unix, backslash is a valid filename character78const winPathSeparatorRegexp = /[\\\/]/g; // on Windows, neither slash nor backslash are valid filename characters79const labelMatchingRegexp = /\$\{(scheme|authoritySuffix|authority|path|(query)\.(.+?))\}/g;8081function hasDriveLetterIgnorePlatform(path: string): boolean {82return !!(path && path[2] === ':');83}8485class ResourceLabelFormattersHandler implements IWorkbenchContribution {8687private readonly formattersDisposables = new Map<ResourceLabelFormatter, IDisposable>();8889constructor(@ILabelService labelService: ILabelService) {90resourceLabelFormattersExtPoint.setHandler((extensions, delta) => {91for (const added of delta.added) {92for (const untrustedFormatter of added.value) {9394// We cannot trust that the formatter as it comes from an extension95// adheres to our interface, so for the required properties we fill96// in some defaults if missing.9798const formatter = { ...untrustedFormatter };99if (typeof formatter.formatting.label !== 'string') {100formatter.formatting.label = '${authority}${path}';101}102if (typeof formatter.formatting.separator !== `string`) {103formatter.formatting.separator = sep;104}105106if (!isProposedApiEnabled(added.description, 'contribLabelFormatterWorkspaceTooltip') && formatter.formatting.workspaceTooltip) {107formatter.formatting.workspaceTooltip = undefined; // workspaceTooltip is only proposed108}109110this.formattersDisposables.set(formatter, labelService.registerFormatter(formatter));111}112}113114for (const removed of delta.removed) {115for (const formatter of removed.value) {116dispose(this.formattersDisposables.get(formatter));117}118}119});120}121}122Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(ResourceLabelFormattersHandler, LifecyclePhase.Restored);123124const FORMATTER_CACHE_SIZE = 50;125126interface IStoredFormatters {127formatters?: ResourceLabelFormatter[];128i?: number;129}130131export class LabelService extends Disposable implements ILabelService {132133declare readonly _serviceBrand: undefined;134135private formatters: ResourceLabelFormatter[];136137private readonly _onDidChangeFormatters = this._register(new Emitter<IFormatterChangeEvent>({ leakWarningThreshold: 400 }));138readonly onDidChangeFormatters = this._onDidChangeFormatters.event;139140private readonly storedFormattersMemento: Memento<IStoredFormatters>;141private readonly storedFormatters: IStoredFormatters;142private os: OperatingSystem;143private userHome: URI | undefined;144145constructor(146@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,147@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,148@IPathService private readonly pathService: IPathService,149@IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService,150@IStorageService storageService: IStorageService,151@ILifecycleService lifecycleService: ILifecycleService,152) {153super();154155// Find some meaningful defaults until the remote environment156// is resolved, by taking the current OS we are running in157// and by taking the local `userHome` if we run on a local158// file scheme.159this.os = OS;160this.userHome = pathService.defaultUriScheme === Schemas.file ? this.pathService.userHome({ preferLocal: true }) : undefined;161162const memento = this.storedFormattersMemento = new Memento('cachedResourceLabelFormatters2', storageService);163this.storedFormatters = memento.getMemento(StorageScope.PROFILE, StorageTarget.MACHINE);164this.formatters = this.storedFormatters?.formatters?.slice() || [];165166// Remote environment is potentially long running167this.resolveRemoteEnvironment();168}169170private async resolveRemoteEnvironment(): Promise<void> {171172// OS173const env = await this.remoteAgentService.getEnvironment();174this.os = env?.os ?? OS;175176// User home177this.userHome = await this.pathService.userHome();178}179180findFormatting(resource: URI): ResourceLabelFormatting | undefined {181let bestResult: ResourceLabelFormatter | undefined;182183for (const formatter of this.formatters) {184if (formatter.scheme === resource.scheme) {185if (!formatter.authority && (!bestResult || formatter.priority)) {186bestResult = formatter;187continue;188}189190if (!formatter.authority) {191continue;192}193194if (match(formatter.authority, resource.authority, { ignoreCase: true }) &&195(196!bestResult?.authority ||197formatter.authority.length > bestResult.authority.length ||198((formatter.authority.length === bestResult.authority.length) && formatter.priority)199)200) {201bestResult = formatter;202}203}204}205206return bestResult ? bestResult.formatting : undefined;207}208209getUriLabel(resource: URI, options: { relative?: boolean; noPrefix?: boolean; separator?: '/' | '\\'; appendWorkspaceSuffix?: boolean } = {}): string {210let formatting = this.findFormatting(resource);211if (formatting && options.separator) {212// mixin separator if defined from the outside213formatting = { ...formatting, separator: options.separator };214}215216let label = this.doGetUriLabel(resource, formatting, options);217218// Without formatting we still need to support the separator219// as provided in options (https://github.com/microsoft/vscode/issues/130019)220if (!formatting && options.separator) {221label = this.adjustPathSeparators(label, options.separator);222}223224if (options.appendWorkspaceSuffix && formatting?.workspaceSuffix) {225label = this.appendWorkspaceSuffix(label, resource);226}227228return label;229}230231private doGetUriLabel(resource: URI, formatting?: ResourceLabelFormatting, options: { relative?: boolean; noPrefix?: boolean } = {}): string {232if (!formatting) {233return getPathLabel(resource, {234os: this.os,235tildify: this.userHome ? { userHome: this.userHome } : undefined,236relative: options.relative ? {237noPrefix: options.noPrefix,238getWorkspace: () => this.contextService.getWorkspace(),239getWorkspaceFolder: resource => this.contextService.getWorkspaceFolder(resource)240} : undefined241});242}243244// Relative label245if (options.relative && this.contextService) {246let folder = this.contextService.getWorkspaceFolder(resource);247if (!folder) {248249// It is possible that the resource we want to resolve the250// workspace folder for is not using the same scheme as251// the folders in the workspace, so we help by trying again252// to resolve a workspace folder by trying again with a253// scheme that is workspace contained.254255const workspace = this.contextService.getWorkspace();256const firstFolder = workspace.folders.at(0);257if (firstFolder && resource.scheme !== firstFolder.uri.scheme && resource.path.startsWith(posix.sep)) {258folder = this.contextService.getWorkspaceFolder(firstFolder.uri.with({ path: resource.path }));259}260}261262if (folder) {263const folderLabel = this.formatUri(folder.uri, formatting, options.noPrefix);264265let relativeLabel = this.formatUri(resource, formatting, options.noPrefix);266let overlap = 0;267while (relativeLabel[overlap] && relativeLabel[overlap] === folderLabel[overlap]) {268overlap++;269}270271if (!relativeLabel[overlap] || relativeLabel[overlap] === formatting.separator) {272relativeLabel = relativeLabel.substring(1 + overlap);273} else if (overlap === folderLabel.length && folder.uri.path === posix.sep) {274relativeLabel = relativeLabel.substring(overlap);275}276277// always show root basename if there are multiple folders278const hasMultipleRoots = this.contextService.getWorkspace().folders.length > 1;279if (hasMultipleRoots && !options.noPrefix) {280const rootName = folder?.name ?? basenameOrAuthority(folder.uri);281relativeLabel = relativeLabel ? `${rootName} • ${relativeLabel}` : rootName;282}283284return relativeLabel;285}286}287288// Absolute label289return this.formatUri(resource, formatting, options.noPrefix);290}291292getUriBasenameLabel(resource: URI): string {293const formatting = this.findFormatting(resource);294const label = this.doGetUriLabel(resource, formatting);295296let pathLib: typeof win32 | typeof posix;297if (formatting?.separator === win32.sep) {298pathLib = win32;299} else if (formatting?.separator === posix.sep) {300pathLib = posix;301} else {302pathLib = (this.os === OperatingSystem.Windows) ? win32 : posix;303}304305return pathLib.basename(label);306}307308getWorkspaceLabel(workspace: IWorkspace | IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI, options?: { verbose: Verbosity }): string {309if (isWorkspace(workspace)) {310if (workspace.isAgentSessionsWorkspace) {311return localize('agentSessionsWorkspace', "Agent Sessions");312}313314const identifier = toWorkspaceIdentifier(workspace);315if (isSingleFolderWorkspaceIdentifier(identifier) || isWorkspaceIdentifier(identifier)) {316return this.getWorkspaceLabel(identifier, options);317}318319return '';320}321322// Workspace: Single Folder (as URI)323if (URI.isUri(workspace)) {324return this.doGetSingleFolderWorkspaceLabel(workspace, options);325}326327// Workspace: Single Folder (as workspace identifier)328if (isSingleFolderWorkspaceIdentifier(workspace)) {329return this.doGetSingleFolderWorkspaceLabel(workspace.uri, options);330}331332// Workspace: Multi Root333if (isWorkspaceIdentifier(workspace)) {334return this.doGetWorkspaceLabel(workspace.configPath, options);335}336337return '';338}339340private doGetWorkspaceLabel(workspaceUri: URI, options?: { verbose: Verbosity }): string {341342// Workspace: Untitled343if (isUntitledWorkspace(workspaceUri, this.environmentService)) {344return localize('untitledWorkspace', "Untitled (Workspace)");345}346347// Workspace: Temporary348if (isTemporaryWorkspace(workspaceUri)) {349return localize('temporaryWorkspace', "Workspace");350}351352// Workspace: Saved353let filename = basename(workspaceUri);354if (filename.endsWith(WORKSPACE_EXTENSION)) {355filename = filename.substr(0, filename.length - WORKSPACE_EXTENSION.length - 1);356}357358let label: string;359switch (options?.verbose) {360case Verbosity.SHORT:361label = filename; // skip suffix for short label362break;363case Verbosity.LONG:364label = localize('workspaceNameVerbose', "{0} (Workspace)", this.getUriLabel(joinPath(dirname(workspaceUri), filename)));365break;366case Verbosity.MEDIUM:367default:368label = localize('workspaceName', "{0} (Workspace)", filename);369break;370}371372if (options?.verbose === Verbosity.SHORT) {373return label; // skip suffix for short label374}375376return this.appendWorkspaceSuffix(label, workspaceUri);377}378379private doGetSingleFolderWorkspaceLabel(folderUri: URI, options?: { verbose: Verbosity }): string {380let label: string;381switch (options?.verbose) {382case Verbosity.LONG:383label = this.getUriLabel(folderUri);384break;385case Verbosity.SHORT:386case Verbosity.MEDIUM:387default:388label = basename(folderUri) || posix.sep;389break;390}391392if (options?.verbose === Verbosity.SHORT) {393return label; // skip suffix for short label394}395396return this.appendWorkspaceSuffix(label, folderUri);397}398399getSeparator(scheme: string, authority?: string): '/' | '\\' {400const formatter = this.findFormatting(URI.from({ scheme, authority }));401402return formatter?.separator || posix.sep;403}404405getHostLabel(scheme: string, authority?: string): string {406const formatter = this.findFormatting(URI.from({ scheme, authority }));407408return formatter?.workspaceSuffix || authority || '';409}410411getHostTooltip(scheme: string, authority?: string): string | undefined {412const formatter = this.findFormatting(URI.from({ scheme, authority }));413414return formatter?.workspaceTooltip;415}416417registerCachedFormatter(formatter: ResourceLabelFormatter): IDisposable {418const list = this.storedFormatters.formatters ??= [];419420let replace = list.findIndex(f => f.scheme === formatter.scheme && f.authority === formatter.authority);421if (replace === -1 && list.length >= FORMATTER_CACHE_SIZE) {422replace = FORMATTER_CACHE_SIZE - 1; // at max capacity, replace the last element423}424425if (replace === -1) {426list.unshift(formatter);427} else {428for (let i = replace; i > 0; i--) {429list[i] = list[i - 1];430}431list[0] = formatter;432}433434this.storedFormattersMemento.saveMemento();435436return this.registerFormatter(formatter);437}438439registerFormatter(formatter: ResourceLabelFormatter): IDisposable {440this.formatters.push(formatter);441this._onDidChangeFormatters.fire({ scheme: formatter.scheme });442443return {444dispose: () => {445this.formatters = this.formatters.filter(f => f !== formatter);446this._onDidChangeFormatters.fire({ scheme: formatter.scheme });447}448};449}450451private formatUri(resource: URI, formatting: ResourceLabelFormatting, forceNoTildify?: boolean): string {452let label = formatting.label.replace(labelMatchingRegexp, (match, token, qsToken, qsValue) => {453switch (token) {454case 'scheme': return resource.scheme;455case 'authority': return resource.authority;456case 'authoritySuffix': {457const i = resource.authority.indexOf('+');458return i === -1 ? resource.authority : resource.authority.slice(i + 1);459}460case 'path':461return formatting.stripPathStartingSeparator462? resource.path.slice(resource.path[0] === formatting.separator ? 1 : 0)463: resource.path;464default: {465if (qsToken === 'query') {466const { query } = resource;467if (query && query[0] === '{' && query[query.length - 1] === '}') {468try {469return JSON.parse(query)[qsValue] || '';470} catch { }471}472}473474return '';475}476}477});478479// convert \c:\something => C:\something480if (formatting.normalizeDriveLetter && hasDriveLetterIgnorePlatform(label)) {481label = label.charAt(1).toUpperCase() + label.substr(2);482}483484if (formatting.tildify && !forceNoTildify) {485if (this.userHome) {486label = tildify(label, this.userHome.fsPath, this.os);487}488}489490if (formatting.authorityPrefix && resource.authority) {491label = formatting.authorityPrefix + label;492}493494return this.adjustPathSeparators(label, formatting.separator);495}496497private adjustPathSeparators(label: string, separator: '/' | '\\' | ''): string {498return label.replace(this.os === OperatingSystem.Windows ? winPathSeparatorRegexp : posixPathSeparatorRegexp, separator);499}500501private appendWorkspaceSuffix(label: string, uri: URI): string {502const formatting = this.findFormatting(uri);503const suffix = formatting && (typeof formatting.workspaceSuffix === 'string') ? formatting.workspaceSuffix : undefined;504505return suffix ? `${label} [${suffix}]` : label;506}507}508509registerSingleton(ILabelService, LabelService, InstantiationType.Delayed);510511512