Path: blob/main/src/vs/workbench/browser/parts/titlebar/windowTitle.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 { dirname, basename } from '../../../../base/common/resources.js';7import { ITitleProperties, ITitleVariable } from './titlebarPart.js';8import { IConfigurationService, IConfigurationChangeEvent } from '../../../../platform/configuration/common/configuration.js';9import { IEditorService } from '../../../services/editor/common/editorService.js';10import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';11import { EditorResourceAccessor, Verbosity, SideBySideEditor } from '../../../common/editor.js';12import { IBrowserWorkbenchEnvironmentService } from '../../../services/environment/browser/environmentService.js';13import { IWorkspaceContextService, WorkbenchState, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';14import { isWindows, isWeb, isMacintosh, isNative } from '../../../../base/common/platform.js';15import { URI } from '../../../../base/common/uri.js';16import { trim } from '../../../../base/common/strings.js';17import { template } from '../../../../base/common/labels.js';18import { ILabelService, Verbosity as LabelVerbosity } from '../../../../platform/label/common/label.js';19import { Emitter } from '../../../../base/common/event.js';20import { RunOnceScheduler } from '../../../../base/common/async.js';21import { IProductService } from '../../../../platform/product/common/productService.js';22import { Schemas } from '../../../../base/common/network.js';23import { getVirtualWorkspaceLocation } from '../../../../platform/workspace/common/virtualWorkspace.js';24import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js';25import { IViewsService } from '../../../services/views/common/viewsService.js';26import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js';27import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';28import { getWindowById } from '../../../../base/browser/dom.js';29import { CodeWindow } from '../../../../base/browser/window.js';30import { IDecorationsService } from '../../../services/decorations/common/decorations.js';31import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js';3233const enum WindowSettingNames {34titleSeparator = 'window.titleSeparator',35title = 'window.title',36}3738export const defaultWindowTitle = (() => {39if (isMacintosh && isNative) {40return '${activeEditorShort}${separator}${rootName}${separator}${profileName}'; // macOS has native dirty indicator41}4243const base = '${dirty}${activeEditorShort}${separator}${rootName}${separator}${profileName}${separator}${appName}';44if (isWeb) {45return base + '${separator}${remoteName}'; // Web: always show remote name46}4748return base;49})();50export const defaultWindowTitleSeparator = isMacintosh ? ' \u2014 ' : ' - ';5152export class WindowTitle extends Disposable {5354private static readonly NLS_USER_IS_ADMIN = isWindows ? localize('userIsAdmin', "[Administrator]") : localize('userIsSudo', "[Superuser]");55private static readonly NLS_EXTENSION_HOST = localize('devExtensionWindowTitlePrefix', "[Extension Development Host]");56private static readonly TITLE_DIRTY = '\u25cf ';5758private readonly properties: ITitleProperties = { isPure: true, isAdmin: false, prefix: undefined };59private readonly variables = new Map<string /* context key */, string /* name */>();6061private readonly activeEditorListeners = this._register(new DisposableStore());62private readonly titleUpdater = this._register(new RunOnceScheduler(() => this.doUpdateTitle(), 0));6364private readonly onDidChangeEmitter = new Emitter<void>();65readonly onDidChange = this.onDidChangeEmitter.event;6667get value() { return this.title ?? ''; }68get workspaceName() { return this.labelService.getWorkspaceLabel(this.contextService.getWorkspace()); }69get fileName() {70const activeEditor = this.editorService.activeEditor;71if (!activeEditor) {72return undefined;73}74const fileName = activeEditor.getTitle(Verbosity.SHORT);75const dirty = activeEditor?.isDirty() && !activeEditor.isSaving() ? WindowTitle.TITLE_DIRTY : '';76return `${dirty}${fileName}`;77}7879private title: string | undefined;8081private titleIncludesFocusedView: boolean = false;82private titleIncludesEditorState: boolean = false;8384private readonly windowId: number;8586constructor(87targetWindow: CodeWindow,88@IConfigurationService protected readonly configurationService: IConfigurationService,89@IContextKeyService private readonly contextKeyService: IContextKeyService,90@IEditorService private readonly editorService: IEditorService,91@IBrowserWorkbenchEnvironmentService protected readonly environmentService: IBrowserWorkbenchEnvironmentService,92@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,93@ILabelService private readonly labelService: ILabelService,94@IUserDataProfileService private readonly userDataProfileService: IUserDataProfileService,95@IProductService private readonly productService: IProductService,96@IViewsService private readonly viewsService: IViewsService,97@IDecorationsService private readonly decorationsService: IDecorationsService,98@IAccessibilityService private readonly accessibilityService: IAccessibilityService99) {100super();101102this.windowId = targetWindow.vscodeWindowId;103104this.checkTitleVariables();105106this.registerListeners();107}108109private registerListeners(): void {110this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationChanged(e)));111this._register(this.editorService.onDidActiveEditorChange(() => this.onActiveEditorChange()));112this._register(this.contextService.onDidChangeWorkspaceFolders(() => this.titleUpdater.schedule()));113this._register(this.contextService.onDidChangeWorkbenchState(() => this.titleUpdater.schedule()));114this._register(this.contextService.onDidChangeWorkspaceName(() => this.titleUpdater.schedule()));115this._register(this.labelService.onDidChangeFormatters(() => this.titleUpdater.schedule()));116this._register(this.userDataProfileService.onDidChangeCurrentProfile(() => this.titleUpdater.schedule()));117this._register(this.viewsService.onDidChangeFocusedView(() => {118if (this.titleIncludesFocusedView) {119this.titleUpdater.schedule();120}121}));122this._register(this.contextKeyService.onDidChangeContext(e => {123if (e.affectsSome(this.variables)) {124this.titleUpdater.schedule();125}126}));127this._register(this.accessibilityService.onDidChangeScreenReaderOptimized(() => this.titleUpdater.schedule()));128}129130private onConfigurationChanged(event: IConfigurationChangeEvent): void {131const affectsTitleConfiguration = event.affectsConfiguration(WindowSettingNames.title);132if (affectsTitleConfiguration) {133this.checkTitleVariables();134}135136if (affectsTitleConfiguration || event.affectsConfiguration(WindowSettingNames.titleSeparator)) {137this.titleUpdater.schedule();138}139}140141private checkTitleVariables(): void {142const titleTemplate = this.configurationService.getValue<unknown>(WindowSettingNames.title);143if (typeof titleTemplate === 'string') {144this.titleIncludesFocusedView = titleTemplate.includes('${focusedView}');145this.titleIncludesEditorState = titleTemplate.includes('${activeEditorState}');146}147}148149private onActiveEditorChange(): void {150151// Dispose old listeners152this.activeEditorListeners.clear();153154// Calculate New Window Title155this.titleUpdater.schedule();156157// Apply listener for dirty and label changes158const activeEditor = this.editorService.activeEditor;159if (activeEditor) {160this.activeEditorListeners.add(activeEditor.onDidChangeDirty(() => this.titleUpdater.schedule()));161this.activeEditorListeners.add(activeEditor.onDidChangeLabel(() => this.titleUpdater.schedule()));162}163164// Apply listeners for tracking focused code editor165if (this.titleIncludesFocusedView) {166const activeTextEditorControl = this.editorService.activeTextEditorControl;167const textEditorControls: ICodeEditor[] = [];168if (isCodeEditor(activeTextEditorControl)) {169textEditorControls.push(activeTextEditorControl);170} else if (isDiffEditor(activeTextEditorControl)) {171textEditorControls.push(activeTextEditorControl.getOriginalEditor(), activeTextEditorControl.getModifiedEditor());172}173174for (const textEditorControl of textEditorControls) {175this.activeEditorListeners.add(textEditorControl.onDidBlurEditorText(() => this.titleUpdater.schedule()));176this.activeEditorListeners.add(textEditorControl.onDidFocusEditorText(() => this.titleUpdater.schedule()));177}178}179180// Apply listener for decorations to track editor state181if (this.titleIncludesEditorState) {182this.activeEditorListeners.add(this.decorationsService.onDidChangeDecorations(() => this.titleUpdater.schedule()));183}184}185186private doUpdateTitle(): void {187const title = this.getFullWindowTitle();188if (title !== this.title) {189190// Always set the native window title to identify us properly to the OS191let nativeTitle = title;192if (!trim(nativeTitle)) {193nativeTitle = this.productService.nameLong;194}195196const window = getWindowById(this.windowId, true).window;197if (!window.document.title && isMacintosh && nativeTitle === this.productService.nameLong) {198// TODO@electron macOS: if we set a window title for199// the first time and it matches the one we set in200// `windowImpl.ts` somehow the window does not appear201// in the "Windows" menu. As such, we set the title202// briefly to something different to ensure macOS203// recognizes we have a window.204// See: https://github.com/microsoft/vscode/issues/191288205window.document.title = `${this.productService.nameLong} ${WindowTitle.TITLE_DIRTY}`;206}207208window.document.title = nativeTitle;209this.title = title;210211this.onDidChangeEmitter.fire();212}213}214215private getFullWindowTitle(): string {216const { prefix, suffix } = this.getTitleDecorations();217218let title = this.getWindowTitle() || this.productService.nameLong;219if (prefix) {220title = `${prefix} ${title}`;221}222223if (suffix) {224title = `${title} ${suffix}`;225}226227// Replace non-space whitespace228return title.replace(/[^\S ]/g, ' ');229}230231getTitleDecorations() {232let prefix: string | undefined;233let suffix: string | undefined;234235if (this.properties.prefix) {236prefix = this.properties.prefix;237}238239if (this.environmentService.isExtensionDevelopment) {240prefix = !prefix241? WindowTitle.NLS_EXTENSION_HOST242: `${WindowTitle.NLS_EXTENSION_HOST} - ${prefix}`;243}244245if (this.properties.isAdmin) {246suffix = WindowTitle.NLS_USER_IS_ADMIN;247}248249return { prefix, suffix };250}251252updateProperties(properties: ITitleProperties): void {253const isAdmin = typeof properties.isAdmin === 'boolean' ? properties.isAdmin : this.properties.isAdmin;254const isPure = typeof properties.isPure === 'boolean' ? properties.isPure : this.properties.isPure;255const prefix = typeof properties.prefix === 'string' ? properties.prefix : this.properties.prefix;256257if (isAdmin !== this.properties.isAdmin || isPure !== this.properties.isPure || prefix !== this.properties.prefix) {258this.properties.isAdmin = isAdmin;259this.properties.isPure = isPure;260this.properties.prefix = prefix;261262this.titleUpdater.schedule();263}264}265266registerVariables(variables: ITitleVariable[]): void {267let changed = false;268269for (const { name, contextKey } of variables) {270if (!this.variables.has(contextKey)) {271this.variables.set(contextKey, name);272273changed = true;274}275}276277if (changed) {278this.titleUpdater.schedule();279}280}281282/**283* Possible template values:284*285* {activeEditorLong}: e.g. /Users/Development/myFolder/myFileFolder/myFile.txt286* {activeEditorMedium}: e.g. myFolder/myFileFolder/myFile.txt287* {activeEditorShort}: e.g. myFile.txt288* {activeFolderLong}: e.g. /Users/Development/myFolder/myFileFolder289* {activeFolderMedium}: e.g. myFolder/myFileFolder290* {activeFolderShort}: e.g. myFileFolder291* {rootName}: e.g. myFolder1, myFolder2, myFolder3292* {rootPath}: e.g. /Users/Development293* {folderName}: e.g. myFolder294* {folderPath}: e.g. /Users/Development/myFolder295* {appName}: e.g. VS Code296* {remoteName}: e.g. SSH297* {dirty}: indicator298* {focusedView}: e.g. Terminal299* {separator}: conditional separator300* {activeEditorState}: e.g. Modified301*/302getWindowTitle(): string {303const editor = this.editorService.activeEditor;304const workspace = this.contextService.getWorkspace();305306// Compute root307let root: URI | undefined;308if (workspace.configuration) {309root = workspace.configuration;310} else if (workspace.folders.length) {311root = workspace.folders[0].uri;312}313314// Compute active editor folder315const editorResource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY });316let editorFolderResource = editorResource ? dirname(editorResource) : undefined;317if (editorFolderResource?.path === '.') {318editorFolderResource = undefined;319}320321// Compute folder resource322// Single Root Workspace: always the root single workspace in this case323// Otherwise: root folder of the currently active file if any324let folder: IWorkspaceFolder | undefined = undefined;325if (this.contextService.getWorkbenchState() === WorkbenchState.FOLDER) {326folder = workspace.folders[0];327} else if (editorResource) {328folder = this.contextService.getWorkspaceFolder(editorResource) ?? undefined;329}330331// Compute remote332// vscode-remtoe: use as is333// otherwise figure out if we have a virtual folder opened334let remoteName: string | undefined = undefined;335if (this.environmentService.remoteAuthority && !isWeb) {336remoteName = this.labelService.getHostLabel(Schemas.vscodeRemote, this.environmentService.remoteAuthority);337} else {338const virtualWorkspaceLocation = getVirtualWorkspaceLocation(workspace);339if (virtualWorkspaceLocation) {340remoteName = this.labelService.getHostLabel(virtualWorkspaceLocation.scheme, virtualWorkspaceLocation.authority);341}342}343344// Variables345const activeEditorShort = editor ? editor.getTitle(Verbosity.SHORT) : '';346const activeEditorMedium = editor ? editor.getTitle(Verbosity.MEDIUM) : activeEditorShort;347const activeEditorLong = editor ? editor.getTitle(Verbosity.LONG) : activeEditorMedium;348const activeFolderShort = editorFolderResource ? basename(editorFolderResource) : '';349const activeFolderMedium = editorFolderResource ? this.labelService.getUriLabel(editorFolderResource, { relative: true }) : '';350const activeFolderLong = editorFolderResource ? this.labelService.getUriLabel(editorFolderResource) : '';351const rootName = this.labelService.getWorkspaceLabel(workspace);352const rootNameShort = this.labelService.getWorkspaceLabel(workspace, { verbose: LabelVerbosity.SHORT });353const rootPath = root ? this.labelService.getUriLabel(root) : '';354const folderName = folder ? folder.name : '';355const folderPath = folder ? this.labelService.getUriLabel(folder.uri) : '';356const dirty = editor?.isDirty() && !editor.isSaving() ? WindowTitle.TITLE_DIRTY : '';357const appName = this.productService.nameLong;358const profileName = this.userDataProfileService.currentProfile.isDefault ? '' : this.userDataProfileService.currentProfile.name;359const focusedView: string = this.viewsService.getFocusedViewName();360const activeEditorState = editorResource ? this.decorationsService.getDecoration(editorResource, false)?.tooltip : undefined;361362const variables: Record<string, string> = {};363for (const [contextKey, name] of this.variables) {364variables[name] = this.contextKeyService.getContextKeyValue(contextKey) ?? '';365}366367let titleTemplate = this.configurationService.getValue<string>(WindowSettingNames.title);368if (typeof titleTemplate !== 'string') {369titleTemplate = defaultWindowTitle;370}371372if (!this.titleIncludesEditorState && this.accessibilityService.isScreenReaderOptimized() && this.configurationService.getValue('accessibility.windowTitleOptimized')) {373titleTemplate += '${separator}${activeEditorState}';374}375376let separator = this.configurationService.getValue<string>(WindowSettingNames.titleSeparator);377if (typeof separator !== 'string') {378separator = defaultWindowTitleSeparator;379}380381return template(titleTemplate, {382...variables,383activeEditorShort,384activeEditorLong,385activeEditorMedium,386activeFolderShort,387activeFolderMedium,388activeFolderLong,389rootName,390rootPath,391rootNameShort,392folderName,393folderPath,394dirty,395appName,396remoteName,397profileName,398focusedView,399activeEditorState,400separator: { label: separator }401});402}403404isCustomTitleFormat(): boolean {405if (this.accessibilityService.isScreenReaderOptimized() || this.titleIncludesEditorState) {406return true;407}408const title = this.configurationService.inspect<string>(WindowSettingNames.title);409const titleSeparator = this.configurationService.inspect<string>(WindowSettingNames.titleSeparator);410411return title.value !== title.defaultValue || titleSeparator.value !== titleSeparator.defaultValue;412}413}414415416