Skip to content

Commit ceabb06

Browse files
author
Benjamin Pasero
committed
Unsaved tab labels should preview text instead of "Untitled-#" (fixes microsoft#37414)
1 parent c09fa21 commit ceabb06

10 files changed

Lines changed: 206 additions & 66 deletions

File tree

src/vs/workbench/browser/parts/editor/editorActions.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as nls from 'vs/nls';
77
import { Action } from 'vs/base/common/actions';
88
import { mixin } from 'vs/base/common/objects';
9-
import { IEditorInput, EditorInput, IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder } from 'vs/workbench/common/editor';
9+
import { IEditorInput, EditorInput, IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder, SideBySideEditorInput } from 'vs/workbench/common/editor';
1010
import { QuickOpenEntryGroup } from 'vs/base/parts/quickopen/browser/quickOpenModel';
1111
import { EditorQuickOpenEntry, EditorQuickOpenEntryGroup, IEditorQuickOpenEntry, QuickOpenAction } from 'vs/workbench/browser/quickopen';
1212
import { IQuickOpenService } from 'vs/platform/quickOpen/common/quickOpen';
@@ -23,7 +23,7 @@ import { DisposableStore } from 'vs/base/common/lifecycle';
2323
import { IWorkspacesService } from 'vs/platform/workspaces/common/workspaces';
2424
import { IFileDialogService, ConfirmResult } from 'vs/platform/dialogs/common/dialogs';
2525
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
26-
import { ResourceMap, values } from 'vs/base/common/map';
26+
import { values } from 'vs/base/common/map';
2727

2828
export class ExecuteCommandAction extends Action {
2929

@@ -640,23 +640,24 @@ export abstract class BaseCloseAllAction extends Action {
640640
return undefined;
641641
}));
642642

643-
const dirtyEditorsToConfirmByName = new Set<string>();
644-
const dirtyEditorsToConfirmByResource = new ResourceMap();
643+
const dirtyEditorsToConfirm = new Set<string>();
645644

646645
for (const editor of this.editorService.editors) {
647646
if (!editor.isDirty() || editor.isSaving()) {
648647
continue; // only interested in dirty editors (unless in the process of saving)
649648
}
650649

651-
const resource = editor.getResource();
652-
if (resource) {
653-
dirtyEditorsToConfirmByResource.set(resource, true);
650+
let name: string;
651+
if (editor instanceof SideBySideEditorInput) {
652+
name = editor.master.getName(); // prefer shorter names by using master's name in this case
654653
} else {
655-
dirtyEditorsToConfirmByName.add(editor.getName());
654+
name = editor.getName();
656655
}
656+
657+
dirtyEditorsToConfirm.add(name);
657658
}
658659

659-
const confirm = await this.fileDialogService.showSaveConfirm([...dirtyEditorsToConfirmByResource.keys(), ...values(dirtyEditorsToConfirmByName)]);
660+
const confirm = await this.fileDialogService.showSaveConfirm(values(dirtyEditorsToConfirm));
660661
if (confirm === ConfirmResult.CANCEL) {
661662
return;
662663
}

src/vs/workbench/browser/parts/editor/editorGroupView.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import 'vs/css!./media/editorgroupview';
77

88
import { EditorGroup, IEditorOpenOptions, EditorCloseEvent, ISerializedEditorGroup, isSerializedEditorGroup } from 'vs/workbench/common/editor/editorGroup';
9-
import { EditorInput, EditorOptions, GroupIdentifier, SideBySideEditorInput, CloseDirection, IEditorCloseEvent, EditorGroupActiveEditorDirtyContext, IEditor, EditorGroupEditorsCountContext, toResource, SideBySideEditor, SaveReason, SaveContext, IEditorPartOptionsChangeEvent, EditorsOrder } from 'vs/workbench/common/editor';
9+
import { EditorInput, EditorOptions, GroupIdentifier, SideBySideEditorInput, CloseDirection, IEditorCloseEvent, EditorGroupActiveEditorDirtyContext, IEditor, EditorGroupEditorsCountContext, SaveReason, SaveContext, IEditorPartOptionsChangeEvent, EditorsOrder } from 'vs/workbench/common/editor';
1010
import { Event, Emitter, Relay } from 'vs/base/common/event';
1111
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
1212
import { addClass, addClasses, Dimension, trackFocus, toggleClass, removeClass, addDisposableListener, EventType, EventHelper, findParentWithClass, clearNode, isAncestor } from 'vs/base/browser/dom';
@@ -1306,8 +1306,14 @@ export class EditorGroupView extends Themable implements IEditorGroupView {
13061306
// Switch to editor that we want to handle and confirm to save/revert
13071307
await this.openEditor(editor);
13081308

1309-
const editorResource = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER });
1310-
const res = await this.fileDialogService.showSaveConfirm(editorResource ? [editorResource] : [editor.getName()]);
1309+
let name: string;
1310+
if (editor instanceof SideBySideEditorInput) {
1311+
name = editor.master.getName(); // prefer shorter names by using master's name in this case
1312+
} else {
1313+
name = editor.getName();
1314+
}
1315+
1316+
const res = await this.fileDialogService.showSaveConfirm([name]);
13111317

13121318
// It could be that the editor saved meanwhile or is saving, so we check
13131319
// again to see if anything needs to happen before closing for good.

src/vs/workbench/common/editor.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { localize } from 'vs/nls';
67
import { Event, Emitter } from 'vs/base/common/event';
78
import { assign } from 'vs/base/common/objects';
89
import { withNullAsUndefined, assertIsDefined } from 'vs/base/common/types';
@@ -682,7 +683,7 @@ export class SideBySideEditorInput extends EditorInput {
682683
static readonly ID: string = 'workbench.editorinputs.sidebysideEditorInput';
683684

684685
constructor(
685-
private readonly name: string,
686+
protected readonly name: string | undefined,
686687
private readonly description: string | undefined,
687688
private readonly _details: EditorInput,
688689
private readonly _master: EditorInput
@@ -700,6 +701,22 @@ export class SideBySideEditorInput extends EditorInput {
700701
return this._details;
701702
}
702703

704+
getTypeId(): string {
705+
return SideBySideEditorInput.ID;
706+
}
707+
708+
getName(): string {
709+
if (!this.name) {
710+
return localize('sideBySideLabels', "{0} - {1}", this._details.getName(), this._master.getName());
711+
}
712+
713+
return this.name;
714+
}
715+
716+
getDescription(): string | undefined {
717+
return this.description;
718+
}
719+
703720
isReadonly(): boolean {
704721
return this.master.isReadonly();
705722
}
@@ -760,18 +777,6 @@ export class SideBySideEditorInput extends EditorInput {
760777
return null;
761778
}
762779

763-
getTypeId(): string {
764-
return SideBySideEditorInput.ID;
765-
}
766-
767-
getName(): string {
768-
return this.name;
769-
}
770-
771-
getDescription(): string | undefined {
772-
return this.description;
773-
}
774-
775780
matches(otherInput: unknown): boolean {
776781
if (super.matches(otherInput) === true) {
777782
return true;

src/vs/workbench/common/editor/diffEditorInput.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { EditorModel, EditorInput, SideBySideEditorInput, TEXT_DIFF_EDITOR_ID, B
77
import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel';
88
import { DiffEditorModel } from 'vs/workbench/common/editor/diffEditorModel';
99
import { TextDiffEditorModel } from 'vs/workbench/common/editor/textDiffEditorModel';
10+
import { localize } from 'vs/nls';
1011

1112
/**
1213
* The base editor input for the diff editor. It is made up of two editor inputs, the original version
@@ -19,33 +20,25 @@ export class DiffEditorInput extends SideBySideEditorInput {
1920
private cachedModel: DiffEditorModel | null = null;
2021

2122
constructor(
22-
name: string,
23+
protected name: string | undefined,
2324
description: string | undefined,
24-
original: EditorInput,
25-
modified: EditorInput,
25+
public readonly originalInput: EditorInput,
26+
public readonly modifiedInput: EditorInput,
2627
private readonly forceOpenAsBinary?: boolean
2728
) {
28-
super(name, description, original, modified);
29-
}
30-
31-
matches(otherInput: unknown): boolean {
32-
if (!super.matches(otherInput)) {
33-
return false;
34-
}
35-
36-
return otherInput instanceof DiffEditorInput && otherInput.forceOpenAsBinary === this.forceOpenAsBinary;
29+
super(name, description, originalInput, modifiedInput);
3730
}
3831

3932
getTypeId(): string {
4033
return DiffEditorInput.ID;
4134
}
4235

43-
get originalInput(): EditorInput {
44-
return this.details;
45-
}
36+
getName(): string {
37+
if (!this.name) {
38+
return localize('sideBySideLabels', "{0} ↔ {1}", this.originalInput.getName(), this.modifiedInput.getName());
39+
}
4640

47-
get modifiedInput(): EditorInput {
48-
return this.master;
41+
return this.name;
4942
}
5043

5144
async resolve(): Promise<EditorModel> {
@@ -88,6 +81,14 @@ export class DiffEditorInput extends SideBySideEditorInput {
8881
return new DiffEditorModel(originalEditorModel, modifiedEditorModel);
8982
}
9083

84+
matches(otherInput: unknown): boolean {
85+
if (!super.matches(otherInput)) {
86+
return false;
87+
}
88+
89+
return otherInput instanceof DiffEditorInput && otherInput.forceOpenAsBinary === this.forceOpenAsBinary;
90+
}
91+
9192
dispose(): void {
9293

9394
// Free the diff editor model but do not propagate the dispose() call to the two inputs

src/vs/workbench/common/editor/untitledTextEditorInput.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,14 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
2828

2929
private static readonly MEMOIZER = createMemoizer();
3030

31+
private static readonly FIRST_LINE_MAX_TITLE_LENGTH = 50;
32+
3133
private readonly _onDidModelChangeEncoding = this._register(new Emitter<void>());
3234
readonly onDidModelChangeEncoding = this._onDidModelChangeEncoding.event;
3335

3436
private cachedModel: UntitledTextEditorModel | null = null;
37+
private cachedModelFirstLine: string | undefined = undefined;
38+
3539
private modelResolve: Promise<UntitledTextEditorModel & IResolvedTextEditorModel> | null = null;
3640

3741
private preferredMode: string | undefined;
@@ -71,6 +75,15 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
7175
}
7276

7377
getName(): string {
78+
79+
// Take name from first line if present and only if
80+
// we have no associated file path. In that case we
81+
// prefer the file name as title.
82+
if (!this._hasAssociatedFilePath && this.cachedModelFirstLine) {
83+
return this.cachedModelFirstLine;
84+
}
85+
86+
// Otherwise fallback to resource
7487
return this.hasAssociatedFilePath ? basenameOrAuthority(this.resource) : this.resource.path;
7588
}
7689

@@ -278,11 +291,30 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
278291
private createModel(): UntitledTextEditorModel {
279292
const model = this._register(this.instantiationService.createInstance(UntitledTextEditorModel, this.preferredMode, this.resource, this.hasAssociatedFilePath, this.initialValue, this.preferredEncoding));
280293

294+
this.registerModelListeners(model);
295+
296+
return model;
297+
}
298+
299+
private registerModelListeners(model: UntitledTextEditorModel): void {
300+
281301
// re-emit some events from the model
282302
this._register(model.onDidChangeDirty(() => this._onDidChangeDirty.fire()));
283303
this._register(model.onDidChangeEncoding(() => this._onDidModelChangeEncoding.fire()));
284304

285-
return model;
305+
// listen for first line change events if we use it for the label
306+
// by checking the contents of the first line has changed
307+
if (!this._hasAssociatedFilePath) {
308+
this._register(model.onDidChangeFirstLine(() => this.onDidChangeFirstLine(model)));
309+
}
310+
}
311+
312+
private onDidChangeFirstLine(model: UntitledTextEditorModel): void {
313+
const firstLineText = model.textEditorModel?.getValueInRange({ startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: UntitledTextEditorInput.FIRST_LINE_MAX_TITLE_LENGTH }).trim();
314+
if (firstLineText !== this.cachedModelFirstLine) {
315+
this.cachedModelFirstLine = firstLineText;
316+
this._onDidChangeLabel.fire();
317+
}
286318
}
287319

288320
matches(otherInput: unknown): boolean {

src/vs/workbench/common/editor/untitledTextEditorModel.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { createTextBufferFactory } from 'vs/editor/common/model/textModel';
1616
import { IResolvedTextEditorModel, ITextEditorModel } from 'vs/editor/common/services/resolverService';
1717
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
1818
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
19+
import { IModelContentChangedEvent } from 'vs/editor/common/model/textModelEvents';
1920

2021
export interface IUntitledTextEditorModel extends ITextEditorModel, IModeSupport, IEncodingSupport, IWorkingCopy { }
2122

@@ -24,6 +25,9 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
2425
private readonly _onDidChangeContent = this._register(new Emitter<void>());
2526
readonly onDidChangeContent = this._onDidChangeContent.event;
2627

28+
private readonly _onDidChangeFirstLine = this._register(new Emitter<void>());
29+
readonly onDidChangeFirstLine = this._onDidChangeFirstLine.event;
30+
2731
private readonly _onDidChangeDirty = this._register(new Emitter<void>());
2832
readonly onDidChangeDirty = this._onDidChangeDirty.event;
2933

@@ -170,15 +174,22 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
170174
const textEditorModel = this.textEditorModel!;
171175

172176
// Listen to content changes
173-
this._register(textEditorModel.onDidChangeContent(() => this.onModelContentChanged()));
177+
this._register(textEditorModel.onDidChangeContent(e => this.onModelContentChanged(e)));
174178

175179
// Listen to mode changes
176180
this._register(textEditorModel.onDidChangeLanguage(() => this.onConfigurationChange())); // mode change can have impact on config
177181

182+
// If we have initial contents, make sure to emit this
183+
// as the appropiate events to the outside.
184+
if (backup || this.initialValue) {
185+
this._onDidChangeContent.fire();
186+
this._onDidChangeFirstLine.fire();
187+
}
188+
178189
return this as UntitledTextEditorModel & IResolvedTextEditorModel;
179190
}
180191

181-
private onModelContentChanged(): void {
192+
private onModelContentChanged(e: IModelContentChangedEvent): void {
182193
if (!this.isResolved()) {
183194
return;
184195
}
@@ -196,8 +207,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
196207
this.setDirty(true);
197208
}
198209

199-
// Emit as event
210+
// Emit as general content change event
200211
this._onDidChangeContent.fire();
212+
213+
// Emit as first line change event depending on actual change
214+
if (e.changes.some(change => change.range.startLineNumber === 1 || change.range.endLineNumber === 1)) {
215+
this._onDidChangeFirstLine.fire();
216+
}
201217
}
202218

203219
isReadonly(): boolean {

src/vs/workbench/contrib/backup/electron-browser/backupOnShutdown.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { WorkbenchState, IWorkspaceContextService } from 'vs/platform/workspace/
1717
import { isMacintosh } from 'vs/base/common/platform';
1818
import { HotExitConfiguration } from 'vs/platform/files/common/files';
1919
import { IElectronService } from 'vs/platform/electron/node/electron';
20-
import type { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
20+
import { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
2121

2222
export class BackupOnShutdown extends Disposable implements IWorkbenchContribution {
2323

src/vs/workbench/contrib/backup/test/electron-browser/backupTracker.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { toResource } from 'vs/base/test/common/utils';
3232
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
3333
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
3434
import { ILogService } from 'vs/platform/log/common/log';
35+
import { INewUntitledTextEditorOptions } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
3536

3637
const userdataDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backuprestorer');
3738
const backupHome = path.join(userdataDir, 'Backups');
@@ -118,16 +119,17 @@ suite('BackupTracker', () => {
118119
return [accessor, part, tracker];
119120
}
120121

121-
test('Track backups (untitled)', async function () {
122-
this.timeout(20000);
123-
122+
async function untitledBackupTest(options?: INewUntitledTextEditorOptions): Promise<void> {
124123
const [accessor, part, tracker] = await createTracker();
125124

126-
const untitledEditor = accessor.textFileService.untitled.create();
125+
const untitledEditor = accessor.textFileService.untitled.create(options);
127126
await accessor.editorService.openEditor(untitledEditor, { pinned: true });
128127

129128
const untitledModel = await untitledEditor.resolve();
130-
untitledModel.textEditorModel.setValue('Super Good');
129+
130+
if (!options?.initialValue) {
131+
untitledModel.textEditorModel.setValue('Super Good');
132+
}
131133

132134
await accessor.backupFileService.joinBackupResource();
133135

@@ -141,6 +143,18 @@ suite('BackupTracker', () => {
141143

142144
part.dispose();
143145
tracker.dispose();
146+
}
147+
148+
test('Track backups (untitled)', function () {
149+
this.timeout(20000);
150+
151+
return untitledBackupTest();
152+
});
153+
154+
test('Track backups (untitled with initial contents)', function () {
155+
this.timeout(20000);
156+
157+
return untitledBackupTest({ initialValue: 'Foo Bar' });
144158
});
145159

146160
test('Track backups (file)', async function () {

0 commit comments

Comments
 (0)