Skip to content

Commit 693de57

Browse files
authored
Allow to configure title bar information (fixes microsoft#1723) (microsoft#19932)
1 parent c1c0b65 commit 693de57

9 files changed

Lines changed: 347 additions & 154 deletions

File tree

extensions/json/server/src/jsonServerMain.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import Strings = require('./utils/strings');
1919
import { JSONDocument, JSONSchema, LanguageSettings, getLanguageService } from 'vscode-json-languageservice';
2020
import { ProjectJSONContribution } from './jsoncontributions/projectJSONContribution';
2121
import { GlobPatternContribution } from './jsoncontributions/globPatternContribution';
22+
import { WindowTitleContribution } from './jsoncontributions/windowTitleContribution';
2223
import { FileAssociationContribution } from './jsoncontributions/fileAssociationContribution';
2324
import { getLanguageModelCache } from './languageModelCache';
2425

@@ -128,6 +129,7 @@ let languageService = getLanguageService({
128129
contributions: [
129130
new ProjectJSONContribution(),
130131
new GlobPatternContribution(),
132+
new WindowTitleContribution(),
131133
filesAssociationContribution
132134
]
133135
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
'use strict';
6+
7+
import { MarkedString } from 'vscode-languageserver';
8+
import Strings = require('../utils/strings');
9+
import { JSONWorkerContribution, JSONPath, CompletionsCollector } from 'vscode-json-languageservice';
10+
11+
import * as nls from 'vscode-nls';
12+
const localize = nls.loadMessageBundle();
13+
14+
export class WindowTitleContribution implements JSONWorkerContribution {
15+
16+
constructor() {
17+
}
18+
19+
private isSettingsFile(resource: string): boolean {
20+
return Strings.endsWith(resource, '/settings.json');
21+
}
22+
23+
public collectDefaultCompletions(resource: string, result: CompletionsCollector): Thenable<any> {
24+
return null;
25+
}
26+
27+
public collectPropertyCompletions(resource: string, location: JSONPath, currentWord: string, addValue: boolean, isLast: boolean, result: CompletionsCollector): Thenable<any> {
28+
return null;
29+
}
30+
31+
public collectValueCompletions(resource: string, location: JSONPath, currentKey: string, result: CompletionsCollector): Thenable<any> {
32+
return null;
33+
}
34+
35+
public getInfoContribution(resource: string, location: JSONPath): Thenable<MarkedString[]> {
36+
if (this.isSettingsFile(resource) && location.length === 1 && location[0] === 'window.title') {
37+
return Promise.resolve([
38+
MarkedString.fromPlainText(localize('windowTitle.description', "Controls the window title based on the active editor. Variables are substituted based on the context:")),
39+
MarkedString.fromPlainText(localize('windowTitle.activeEditorName', "$(activeEditorName): e.g. myFile.txt")),
40+
MarkedString.fromPlainText(localize('windowTitle.activeFilePath', "$(activeFilePath): e.g. /Users/Development/myProject/myFile.txt")),
41+
MarkedString.fromPlainText(localize('windowTitle.rootName', "$(rootName): e.g. myProject")),
42+
MarkedString.fromPlainText(localize('windowTitle.rootPath', "$(rootPath): e.g. /Users/Development/myProject")),
43+
MarkedString.fromPlainText(localize('windowTitle.appName', "$(appName): e.g. VS Code")),
44+
MarkedString.fromPlainText(localize('windowTitle.dirty', "$(dirty): a dirty indicator if the active editor is dirty")),
45+
MarkedString.fromPlainText(localize('windowTitle.separator', "$(separator): a conditional separator (\" - \") that only shows when surrounded by variables with values"))
46+
]);
47+
}
48+
49+
return null;
50+
}
51+
}

src/vs/base/common/labels.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,90 @@ export function shorten(paths: string[]): string[] {
134134
}
135135

136136
return shortenedPaths;
137+
}
138+
139+
export interface ISeparator {
140+
label: string;
141+
}
142+
143+
enum Type {
144+
TEXT,
145+
VARIABLE,
146+
SEPARATOR
147+
}
148+
149+
interface ISegment {
150+
value: string;
151+
type: Type;
152+
}
153+
154+
/**
155+
* Helper to insert values for specific template variables into the string. E.g. "this $(is) a $(template)" can be
156+
* passed to this function together with an object that maps "is" and "template" to strings to have them replaced.
157+
* @param value string to which templating is applied
158+
* @param values the values of the templates to use
159+
*/
160+
export function template(template: string, values: { [key: string]: string | ISeparator } = Object.create(null)): string {
161+
const segments: ISegment[] = [];
162+
163+
let inVariable = false;
164+
let char: string;
165+
let curVal = '';
166+
for (let i = 0; i < template.length; i++) {
167+
char = template[i];
168+
169+
// Beginning of variable
170+
if (char === '$' || (inVariable && char === '(')) {
171+
if (curVal) {
172+
segments.push({ value: curVal, type: Type.TEXT });
173+
}
174+
175+
curVal = '';
176+
inVariable = true;
177+
}
178+
179+
// End of variable
180+
else if (char === ')' && inVariable) {
181+
const resolved = values[curVal];
182+
183+
// Variable
184+
if (typeof resolved === 'string') {
185+
if (resolved.length) {
186+
segments.push({ value: resolved, type: Type.VARIABLE });
187+
}
188+
}
189+
190+
// Separator
191+
else if (resolved) {
192+
segments.push({ value: resolved.label, type: Type.SEPARATOR });
193+
}
194+
195+
curVal = '';
196+
inVariable = false;
197+
}
198+
199+
// Text or Variable Name
200+
else {
201+
curVal += char;
202+
}
203+
}
204+
205+
// Tail
206+
if (curVal && !inVariable) {
207+
segments.push({ value: curVal, type: Type.TEXT });
208+
}
209+
210+
return segments.filter((segment, index) => {
211+
212+
// Only keep separator if we have values to the left and right
213+
if (segment.type === Type.SEPARATOR) {
214+
const left = segments[index - 1];
215+
const right = segments[index + 1];
216+
217+
return [left, right].every(segment => segment && segment.type === Type.VARIABLE && segment.value.length > 0);
218+
}
219+
220+
// accept any TEXT and VARIABLE
221+
return true;
222+
}).map(segment => segment.value).join('');
137223
}

src/vs/base/test/common/labels.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,35 @@ suite('Labels', () => {
9898
assert.deepEqual(labels.shorten(['a', 'a/b', 'b']), ['a', 'a/b', 'b']);
9999
assert.deepEqual(labels.shorten(['', 'a', 'b', 'b/c', 'a/c']), ['', 'a', 'b', 'b/c', 'a/c']);
100100
});
101+
102+
test('template', function () {
103+
104+
// simple
105+
assert.strictEqual(labels.template('Foo Bar'), 'Foo Bar');
106+
assert.strictEqual(labels.template('Foo$()Bar'), 'FooBar');
107+
assert.strictEqual(labels.template('$FooBar'), '');
108+
assert.strictEqual(labels.template(')FooBar'), ')FooBar');
109+
assert.strictEqual(labels.template('Foo $(one) Bar', { one: 'value' }), 'Foo value Bar');
110+
assert.strictEqual(labels.template('Foo $(one) Bar $(two)', { one: 'value', two: 'other value' }), 'Foo value Bar other value');
111+
112+
// conditional separator
113+
assert.strictEqual(labels.template('Foo$(separator)Bar'), 'FooBar');
114+
assert.strictEqual(labels.template('Foo$(separator)Bar', { separator: { label: ' - ' } }), 'FooBar');
115+
assert.strictEqual(labels.template('$(separator)Foo$(separator)Bar', { value: 'something', separator: { label: ' - ' } }), 'FooBar');
116+
assert.strictEqual(labels.template('$(value) Foo$(separator)Bar', { value: 'something', separator: { label: ' - ' } }), 'something FooBar');
117+
118+
// // real world example (macOS)
119+
let t = '$(activeEditorName)$(separator)$(rootName)';
120+
assert.strictEqual(labels.template(t, { activeEditorName: '', rootName: '', separator: { label: ' - ' } }), '');
121+
assert.strictEqual(labels.template(t, { activeEditorName: '', rootName: 'root', separator: { label: ' - ' } }), 'root');
122+
assert.strictEqual(labels.template(t, { activeEditorName: 'markdown.txt', rootName: 'root', separator: { label: ' - ' } }), 'markdown.txt - root');
123+
124+
// // real world example (other)
125+
t = '$(dirty)$(activeEditorName)$(separator)$(rootName)$(separator)$(appName)';
126+
assert.strictEqual(labels.template(t, { dirty: '', activeEditorName: '', rootName: '', appName: '', separator: { label: ' - ' } }), '');
127+
assert.strictEqual(labels.template(t, { dirty: '', activeEditorName: '', rootName: '', appName: 'Visual Studio Code', separator: { label: ' - ' } }), 'Visual Studio Code');
128+
assert.strictEqual(labels.template(t, { dirty: '', activeEditorName: '', rootName: 'monaco', appName: 'Visual Studio Code', separator: { label: ' - ' } }), 'monaco - Visual Studio Code');
129+
assert.strictEqual(labels.template(t, { dirty: '', activeEditorName: 'somefile.txt', rootName: 'monaco', appName: 'Visual Studio Code', separator: { label: ' - ' } }), 'somefile.txt - monaco - Visual Studio Code');
130+
assert.strictEqual(labels.template(t, { dirty: '* ', activeEditorName: 'somefile.txt', rootName: 'monaco', appName: 'Visual Studio Code', separator: { label: ' - ' } }), '* somefile.txt - monaco - Visual Studio Code');
131+
});
101132
});

src/vs/workbench/browser/parts/titlebar/titlebarPart.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,178 @@ import * as errors from 'vs/base/common/errors';
1818
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
1919
import { StandardMouseEvent } from 'vs/base/browser/mouseEvent';
2020
import { IAction, Action } from 'vs/base/common/actions';
21+
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
22+
import { IIntegrityService } from 'vs/platform/integrity/common/integrity';
23+
import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService';
24+
import { IEditorGroupService } from 'vs/workbench/services/group/common/groupService';
25+
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
26+
import { isMacintosh, isLinux } from 'vs/base/common/platform';
27+
import nls = require('vs/nls');
28+
import * as labels from 'vs/base/common/labels';
29+
import { EditorInput, toResource } from 'vs/workbench/common/editor';
30+
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
31+
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
2132

2233
export class TitlebarPart extends Part implements ITitleService {
2334

2435
public _serviceBrand: any;
2536

37+
private static NLS_UNSUPPORTED = nls.localize('patchedWindowTitle', "[Unsupported]");
38+
private static NLS_EXTENSION_HOST = nls.localize('devExtensionWindowTitlePrefix', "[Extension Development Host]");
39+
private static TITLE_DIRTY = '\u25cf ';
40+
private static TITLE_SEPARATOR = ' - ';
41+
2642
private titleContainer: Builder;
2743
private title: Builder;
2844
private pendingTitle: string;
2945
private initialTitleFontSize: number;
3046
private representedFileName: string;
3147

48+
private titleTemplate: string;
49+
private isPure: boolean;
50+
private activeEditorListeners: IDisposable[];
51+
private workspacePath: string;
52+
3253
constructor(
3354
id: string,
3455
@IContextMenuService private contextMenuService: IContextMenuService,
3556
@IWindowService private windowService: IWindowService,
36-
@IWindowsService private windowsService: IWindowsService
57+
@IConfigurationService private configurationService: IConfigurationService,
58+
@IWindowsService private windowsService: IWindowsService,
59+
@IWorkbenchEditorService private editorService: IWorkbenchEditorService,
60+
@IEditorGroupService private editorGroupService: IEditorGroupService,
61+
@IIntegrityService private integrityService: IIntegrityService,
62+
@IEnvironmentService private environmentService: IEnvironmentService,
63+
@IWorkspaceContextService private contextService: IWorkspaceContextService
3764
) {
3865
super(id, { hasTitle: false });
3966

67+
this.isPure = true;
68+
this.activeEditorListeners = [];
69+
this.workspacePath = contextService.hasWorkspace() ? this.tildify(labels.getPathLabel(contextService.getWorkspace().resource)) : '';
70+
71+
this.init();
72+
4073
this.registerListeners();
4174
}
4275

76+
private init(): void {
77+
78+
// Read initial config
79+
this.onConfigurationChanged();
80+
81+
// Initial window title
82+
this.setTitle(this.getWindowTitle());
83+
84+
// Integrity for window title
85+
this.integrityService.isPure().then(r => {
86+
if (!r.isPure) {
87+
this.isPure = false;
88+
this.setTitle(this.getWindowTitle());
89+
}
90+
});
91+
}
92+
4393
private registerListeners(): void {
4494
this.toUnbind.push(DOM.addDisposableListener(window, DOM.EventType.BLUR, () => { if (this.titleContainer) { this.titleContainer.addClass('blurred'); } }));
4595
this.toUnbind.push(DOM.addDisposableListener(window, DOM.EventType.FOCUS, () => { if (this.titleContainer) { this.titleContainer.removeClass('blurred'); } }));
96+
this.toUnbind.push(this.configurationService.onDidUpdateConfiguration(() => this.onConfigurationChanged(true)));
97+
this.toUnbind.push(this.editorGroupService.onEditorsChanged(() => this.onEditorsChanged()));
98+
}
99+
100+
private onConfigurationChanged(update?: boolean): void {
101+
const currentTitleTemplate = this.titleTemplate;
102+
this.titleTemplate = this.configurationService.lookup<string>('window.title').value;
103+
104+
if (update && currentTitleTemplate !== this.titleTemplate) {
105+
this.setTitle(this.getWindowTitle());
106+
}
107+
}
108+
109+
private onEditorsChanged(): void {
110+
111+
// Dispose old listeners
112+
dispose(this.activeEditorListeners);
113+
this.activeEditorListeners = [];
114+
115+
const activeEditor = this.editorService.getActiveEditor();
116+
const activeInput = activeEditor ? activeEditor.input : void 0;
117+
118+
// Calculate New Window Title
119+
this.setTitle(this.getWindowTitle());
120+
121+
// Apply listener for dirty and label changes
122+
if (activeInput instanceof EditorInput) {
123+
this.activeEditorListeners.push(activeInput.onDidChangeDirty(() => {
124+
this.setTitle(this.getWindowTitle());
125+
}));
126+
127+
this.activeEditorListeners.push(activeInput.onDidChangeLabel(() => {
128+
this.setTitle(this.getWindowTitle());
129+
}));
130+
}
131+
}
132+
133+
private getWindowTitle(): string {
134+
let title = this.doGetWindowTitle();
135+
if (!title) {
136+
title = this.environmentService.appNameLong;
137+
}
138+
139+
if (!this.isPure) {
140+
title = `${title} ${TitlebarPart.NLS_UNSUPPORTED}`;
141+
}
142+
143+
// Extension Development Host gets a special title to identify itself
144+
if (this.environmentService.isExtensionDevelopment) {
145+
title = `${TitlebarPart.NLS_EXTENSION_HOST} - ${title}`;
146+
}
147+
148+
return title;
149+
}
150+
151+
/**
152+
* Possible template values:
153+
*
154+
* {activeEditorName}: e.g. myFile.txt
155+
* {activeFilePath}: e.g. /Users/Development/myProject/myFile.txt
156+
* {rootName}: e.g. myProject
157+
* {rootPath}: e.g. /Users/Development/myProject
158+
* {appName}: e.g. VS Code
159+
* {dirty}: indiactor
160+
* {separator}: conditional separator
161+
*/
162+
private doGetWindowTitle(): string {
163+
const input = this.editorService.getActiveEditorInput();
164+
const workspace = this.contextService.getWorkspace();
165+
const file = toResource(input, { filter: 'file' });
166+
167+
// Variables
168+
const activeEditorName = input ? input.getName() : '';
169+
const activeFilePath = file ? this.tildify(labels.getPathLabel(file)) : '';
170+
const rootName = workspace ? workspace.name : '';
171+
const rootPath = workspace ? this.workspacePath : '';
172+
const dirty = input && input.isDirty() ? TitlebarPart.TITLE_DIRTY : '';
173+
const appName = this.environmentService.appNameLong;
174+
const separator = TitlebarPart.TITLE_SEPARATOR;
175+
176+
return labels.template(this.titleTemplate, {
177+
activeEditorName,
178+
activeFilePath,
179+
rootName,
180+
rootPath,
181+
dirty,
182+
appName,
183+
separator: { label: separator }
184+
});
185+
}
186+
187+
private tildify(path: string): string {
188+
if (path && (isMacintosh || isLinux) && path.indexOf(this.environmentService.userHome) === 0) {
189+
path = `~${path.substr(this.environmentService.userHome.length)}`;
190+
}
191+
192+
return path;
46193
}
47194

48195
public createContentArea(parent: Builder): Builder {
@@ -127,7 +274,7 @@ export class TitlebarPart extends Part implements ITitleService {
127274
return actions;
128275
}
129276

130-
public updateTitle(title: string): void {
277+
public setTitle(title: string): void {
131278

132279
// Always set the native window title to identify us properly to the OS
133280
window.document.title = title;

0 commit comments

Comments
 (0)