提交 ceabb063 编写于 作者: B Benjamin Pasero

Unsaved tab labels should preview text instead of "Untitled-#" (fixes #37414)

上级 c09fa216
......@@ -6,7 +6,7 @@
import * as nls from 'vs/nls';
import { Action } from 'vs/base/common/actions';
import { mixin } from 'vs/base/common/objects';
import { IEditorInput, EditorInput, IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder } from 'vs/workbench/common/editor';
import { IEditorInput, EditorInput, IEditorIdentifier, IEditorCommandsContext, CloseDirection, SaveReason, EditorsOrder, SideBySideEditorInput } from 'vs/workbench/common/editor';
import { QuickOpenEntryGroup } from 'vs/base/parts/quickopen/browser/quickOpenModel';
import { EditorQuickOpenEntry, EditorQuickOpenEntryGroup, IEditorQuickOpenEntry, QuickOpenAction } from 'vs/workbench/browser/quickopen';
import { IQuickOpenService } from 'vs/platform/quickOpen/common/quickOpen';
......@@ -23,7 +23,7 @@ import { DisposableStore } from 'vs/base/common/lifecycle';
import { IWorkspacesService } from 'vs/platform/workspaces/common/workspaces';
import { IFileDialogService, ConfirmResult } from 'vs/platform/dialogs/common/dialogs';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ResourceMap, values } from 'vs/base/common/map';
import { values } from 'vs/base/common/map';
export class ExecuteCommandAction extends Action {
......@@ -640,23 +640,24 @@ export abstract class BaseCloseAllAction extends Action {
return undefined;
}));
const dirtyEditorsToConfirmByName = new Set<string>();
const dirtyEditorsToConfirmByResource = new ResourceMap();
const dirtyEditorsToConfirm = new Set<string>();
for (const editor of this.editorService.editors) {
if (!editor.isDirty() || editor.isSaving()) {
continue; // only interested in dirty editors (unless in the process of saving)
}
const resource = editor.getResource();
if (resource) {
dirtyEditorsToConfirmByResource.set(resource, true);
let name: string;
if (editor instanceof SideBySideEditorInput) {
name = editor.master.getName(); // prefer shorter names by using master's name in this case
} else {
dirtyEditorsToConfirmByName.add(editor.getName());
name = editor.getName();
}
dirtyEditorsToConfirm.add(name);
}
const confirm = await this.fileDialogService.showSaveConfirm([...dirtyEditorsToConfirmByResource.keys(), ...values(dirtyEditorsToConfirmByName)]);
const confirm = await this.fileDialogService.showSaveConfirm(values(dirtyEditorsToConfirm));
if (confirm === ConfirmResult.CANCEL) {
return;
}
......
......@@ -6,7 +6,7 @@
import 'vs/css!./media/editorgroupview';
import { EditorGroup, IEditorOpenOptions, EditorCloseEvent, ISerializedEditorGroup, isSerializedEditorGroup } from 'vs/workbench/common/editor/editorGroup';
import { EditorInput, EditorOptions, GroupIdentifier, SideBySideEditorInput, CloseDirection, IEditorCloseEvent, EditorGroupActiveEditorDirtyContext, IEditor, EditorGroupEditorsCountContext, toResource, SideBySideEditor, SaveReason, SaveContext, IEditorPartOptionsChangeEvent, EditorsOrder } from 'vs/workbench/common/editor';
import { EditorInput, EditorOptions, GroupIdentifier, SideBySideEditorInput, CloseDirection, IEditorCloseEvent, EditorGroupActiveEditorDirtyContext, IEditor, EditorGroupEditorsCountContext, SaveReason, SaveContext, IEditorPartOptionsChangeEvent, EditorsOrder } from 'vs/workbench/common/editor';
import { Event, Emitter, Relay } from 'vs/base/common/event';
import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
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 {
// Switch to editor that we want to handle and confirm to save/revert
await this.openEditor(editor);
const editorResource = toResource(editor, { supportSideBySide: SideBySideEditor.MASTER });
const res = await this.fileDialogService.showSaveConfirm(editorResource ? [editorResource] : [editor.getName()]);
let name: string;
if (editor instanceof SideBySideEditorInput) {
name = editor.master.getName(); // prefer shorter names by using master's name in this case
} else {
name = editor.getName();
}
const res = await this.fileDialogService.showSaveConfirm([name]);
// It could be that the editor saved meanwhile or is saving, so we check
// again to see if anything needs to happen before closing for good.
......
......@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize } from 'vs/nls';
import { Event, Emitter } from 'vs/base/common/event';
import { assign } from 'vs/base/common/objects';
import { withNullAsUndefined, assertIsDefined } from 'vs/base/common/types';
......@@ -682,7 +683,7 @@ export class SideBySideEditorInput extends EditorInput {
static readonly ID: string = 'workbench.editorinputs.sidebysideEditorInput';
constructor(
private readonly name: string,
protected readonly name: string | undefined,
private readonly description: string | undefined,
private readonly _details: EditorInput,
private readonly _master: EditorInput
......@@ -700,6 +701,22 @@ export class SideBySideEditorInput extends EditorInput {
return this._details;
}
getTypeId(): string {
return SideBySideEditorInput.ID;
}
getName(): string {
if (!this.name) {
return localize('sideBySideLabels', "{0} - {1}", this._details.getName(), this._master.getName());
}
return this.name;
}
getDescription(): string | undefined {
return this.description;
}
isReadonly(): boolean {
return this.master.isReadonly();
}
......@@ -760,18 +777,6 @@ export class SideBySideEditorInput extends EditorInput {
return null;
}
getTypeId(): string {
return SideBySideEditorInput.ID;
}
getName(): string {
return this.name;
}
getDescription(): string | undefined {
return this.description;
}
matches(otherInput: unknown): boolean {
if (super.matches(otherInput) === true) {
return true;
......
......@@ -7,6 +7,7 @@ import { EditorModel, EditorInput, SideBySideEditorInput, TEXT_DIFF_EDITOR_ID, B
import { BaseTextEditorModel } from 'vs/workbench/common/editor/textEditorModel';
import { DiffEditorModel } from 'vs/workbench/common/editor/diffEditorModel';
import { TextDiffEditorModel } from 'vs/workbench/common/editor/textDiffEditorModel';
import { localize } from 'vs/nls';
/**
* 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 {
private cachedModel: DiffEditorModel | null = null;
constructor(
name: string,
protected name: string | undefined,
description: string | undefined,
original: EditorInput,
modified: EditorInput,
public readonly originalInput: EditorInput,
public readonly modifiedInput: EditorInput,
private readonly forceOpenAsBinary?: boolean
) {
super(name, description, original, modified);
}
matches(otherInput: unknown): boolean {
if (!super.matches(otherInput)) {
return false;
}
return otherInput instanceof DiffEditorInput && otherInput.forceOpenAsBinary === this.forceOpenAsBinary;
super(name, description, originalInput, modifiedInput);
}
getTypeId(): string {
return DiffEditorInput.ID;
}
get originalInput(): EditorInput {
return this.details;
}
getName(): string {
if (!this.name) {
return localize('sideBySideLabels', "{0} ↔ {1}", this.originalInput.getName(), this.modifiedInput.getName());
}
get modifiedInput(): EditorInput {
return this.master;
return this.name;
}
async resolve(): Promise<EditorModel> {
......@@ -88,6 +81,14 @@ export class DiffEditorInput extends SideBySideEditorInput {
return new DiffEditorModel(originalEditorModel, modifiedEditorModel);
}
matches(otherInput: unknown): boolean {
if (!super.matches(otherInput)) {
return false;
}
return otherInput instanceof DiffEditorInput && otherInput.forceOpenAsBinary === this.forceOpenAsBinary;
}
dispose(): void {
// Free the diff editor model but do not propagate the dispose() call to the two inputs
......
......@@ -28,10 +28,14 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
private static readonly MEMOIZER = createMemoizer();
private static readonly FIRST_LINE_MAX_TITLE_LENGTH = 50;
private readonly _onDidModelChangeEncoding = this._register(new Emitter<void>());
readonly onDidModelChangeEncoding = this._onDidModelChangeEncoding.event;
private cachedModel: UntitledTextEditorModel | null = null;
private cachedModelFirstLine: string | undefined = undefined;
private modelResolve: Promise<UntitledTextEditorModel & IResolvedTextEditorModel> | null = null;
private preferredMode: string | undefined;
......@@ -71,6 +75,15 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
}
getName(): string {
// Take name from first line if present and only if
// we have no associated file path. In that case we
// prefer the file name as title.
if (!this._hasAssociatedFilePath && this.cachedModelFirstLine) {
return this.cachedModelFirstLine;
}
// Otherwise fallback to resource
return this.hasAssociatedFilePath ? basenameOrAuthority(this.resource) : this.resource.path;
}
......@@ -278,11 +291,30 @@ export class UntitledTextEditorInput extends TextEditorInput implements IEncodin
private createModel(): UntitledTextEditorModel {
const model = this._register(this.instantiationService.createInstance(UntitledTextEditorModel, this.preferredMode, this.resource, this.hasAssociatedFilePath, this.initialValue, this.preferredEncoding));
this.registerModelListeners(model);
return model;
}
private registerModelListeners(model: UntitledTextEditorModel): void {
// re-emit some events from the model
this._register(model.onDidChangeDirty(() => this._onDidChangeDirty.fire()));
this._register(model.onDidChangeEncoding(() => this._onDidModelChangeEncoding.fire()));
return model;
// listen for first line change events if we use it for the label
// by checking the contents of the first line has changed
if (!this._hasAssociatedFilePath) {
this._register(model.onDidChangeFirstLine(() => this.onDidChangeFirstLine(model)));
}
}
private onDidChangeFirstLine(model: UntitledTextEditorModel): void {
const firstLineText = model.textEditorModel?.getValueInRange({ startLineNumber: 1, endLineNumber: 1, startColumn: 1, endColumn: UntitledTextEditorInput.FIRST_LINE_MAX_TITLE_LENGTH }).trim();
if (firstLineText !== this.cachedModelFirstLine) {
this.cachedModelFirstLine = firstLineText;
this._onDidChangeLabel.fire();
}
}
matches(otherInput: unknown): boolean {
......
......@@ -16,6 +16,7 @@ import { createTextBufferFactory } from 'vs/editor/common/model/textModel';
import { IResolvedTextEditorModel, ITextEditorModel } from 'vs/editor/common/services/resolverService';
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { IModelContentChangedEvent } from 'vs/editor/common/model/textModelEvents';
export interface IUntitledTextEditorModel extends ITextEditorModel, IModeSupport, IEncodingSupport, IWorkingCopy { }
......@@ -24,6 +25,9 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
private readonly _onDidChangeContent = this._register(new Emitter<void>());
readonly onDidChangeContent = this._onDidChangeContent.event;
private readonly _onDidChangeFirstLine = this._register(new Emitter<void>());
readonly onDidChangeFirstLine = this._onDidChangeFirstLine.event;
private readonly _onDidChangeDirty = this._register(new Emitter<void>());
readonly onDidChangeDirty = this._onDidChangeDirty.event;
......@@ -170,15 +174,22 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
const textEditorModel = this.textEditorModel!;
// Listen to content changes
this._register(textEditorModel.onDidChangeContent(() => this.onModelContentChanged()));
this._register(textEditorModel.onDidChangeContent(e => this.onModelContentChanged(e)));
// Listen to mode changes
this._register(textEditorModel.onDidChangeLanguage(() => this.onConfigurationChange())); // mode change can have impact on config
// If we have initial contents, make sure to emit this
// as the appropiate events to the outside.
if (backup || this.initialValue) {
this._onDidChangeContent.fire();
this._onDidChangeFirstLine.fire();
}
return this as UntitledTextEditorModel & IResolvedTextEditorModel;
}
private onModelContentChanged(): void {
private onModelContentChanged(e: IModelContentChangedEvent): void {
if (!this.isResolved()) {
return;
}
......@@ -196,8 +207,13 @@ export class UntitledTextEditorModel extends BaseTextEditorModel implements IUnt
this.setDirty(true);
}
// Emit as event
// Emit as general content change event
this._onDidChangeContent.fire();
// Emit as first line change event depending on actual change
if (e.changes.some(change => change.range.startLineNumber === 1 || change.range.endLineNumber === 1)) {
this._onDidChangeFirstLine.fire();
}
}
isReadonly(): boolean {
......
......@@ -17,7 +17,7 @@ import { WorkbenchState, IWorkspaceContextService } from 'vs/platform/workspace/
import { isMacintosh } from 'vs/base/common/platform';
import { HotExitConfiguration } from 'vs/platform/files/common/files';
import { IElectronService } from 'vs/platform/electron/node/electron';
import type { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
import { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
export class BackupOnShutdown extends Disposable implements IWorkbenchContribution {
......
......@@ -32,6 +32,7 @@ import { toResource } from 'vs/base/test/common/utils';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { ILogService } from 'vs/platform/log/common/log';
import { INewUntitledTextEditorOptions } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
const userdataDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backuprestorer');
const backupHome = path.join(userdataDir, 'Backups');
......@@ -118,16 +119,17 @@ suite('BackupTracker', () => {
return [accessor, part, tracker];
}
test('Track backups (untitled)', async function () {
this.timeout(20000);
async function untitledBackupTest(options?: INewUntitledTextEditorOptions): Promise<void> {
const [accessor, part, tracker] = await createTracker();
const untitledEditor = accessor.textFileService.untitled.create();
const untitledEditor = accessor.textFileService.untitled.create(options);
await accessor.editorService.openEditor(untitledEditor, { pinned: true });
const untitledModel = await untitledEditor.resolve();
untitledModel.textEditorModel.setValue('Super Good');
if (!options?.initialValue) {
untitledModel.textEditorModel.setValue('Super Good');
}
await accessor.backupFileService.joinBackupResource();
......@@ -141,6 +143,18 @@ suite('BackupTracker', () => {
part.dispose();
tracker.dispose();
}
test('Track backups (untitled)', function () {
this.timeout(20000);
return untitledBackupTest();
});
test('Track backups (untitled with initial contents)', function () {
this.timeout(20000);
return untitledBackupTest({ initialValue: 'Foo Bar' });
});
test('Track backups (file)', async function () {
......
......@@ -16,7 +16,6 @@ import { Event, Emitter } from 'vs/base/common/event';
import { URI } from 'vs/base/common/uri';
import { basename, isEqual } from 'vs/base/common/resources';
import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput';
import { localize } from 'vs/nls';
import { IEditorGroupsService, IEditorGroup, GroupsOrder, IEditorReplacement, GroupChangeKind, preferredSideBySideGroupDirection } from 'vs/workbench/services/editor/common/editorGroupsService';
import { IResourceEditor, SIDE_GROUP, IResourceEditorReplacement, IOpenEditorOverrideHandler, IVisibleEditor, IEditorService, SIDE_GROUP_TYPE, ACTIVE_GROUP_TYPE, ISaveEditorsOptions, ISaveAllEditorsOptions, IRevertAllEditorsOptions, IBaseSaveRevertAllEditorOptions } from 'vs/workbench/services/editor/common/editorService';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
......@@ -587,11 +586,10 @@ export class EditorService extends Disposable implements EditorServiceImpl {
if (resourceSideBySideInput.masterResource && resourceSideBySideInput.detailResource) {
const masterInput = this.createInput({ resource: resourceSideBySideInput.masterResource, forceFile: resourceSideBySideInput.forceFile });
const detailInput = this.createInput({ resource: resourceSideBySideInput.detailResource, forceFile: resourceSideBySideInput.forceFile });
const label = resourceSideBySideInput.label || masterInput.getName() || localize('sideBySideLabels', "{0} - {1}", this.toDiffLabel(masterInput), this.toDiffLabel(detailInput));
return new SideBySideEditorInput(
label,
typeof resourceSideBySideInput.description === 'string' ? resourceSideBySideInput.description : masterInput.getDescription(),
resourceSideBySideInput.label || this.toSideBySideLabel(detailInput, masterInput, '-'),
resourceSideBySideInput.description,
detailInput,
masterInput
);
......@@ -602,9 +600,13 @@ export class EditorService extends Disposable implements EditorServiceImpl {
if (resourceDiffInput.leftResource && resourceDiffInput.rightResource) {
const leftInput = this.createInput({ resource: resourceDiffInput.leftResource, forceFile: resourceDiffInput.forceFile });
const rightInput = this.createInput({ resource: resourceDiffInput.rightResource, forceFile: resourceDiffInput.forceFile });
const label = resourceDiffInput.label || localize('compareLabels', "{0} ↔ {1}", this.toDiffLabel(leftInput), this.toDiffLabel(rightInput));
return new DiffEditorInput(label, resourceDiffInput.description, leftInput, rightInput);
return new DiffEditorInput(
resourceDiffInput.label || this.toSideBySideLabel(leftInput, rightInput, ''),
resourceDiffInput.description,
leftInput,
rightInput
);
}
// Untitled file support
......@@ -687,19 +689,24 @@ export class EditorService extends Disposable implements EditorServiceImpl {
return input;
}
private toDiffLabel(input: EditorInput): string | undefined {
const res = input.getResource();
if (!res) {
private toSideBySideLabel(leftInput: EditorInput, rightInput: EditorInput, divider: string): string | undefined {
const leftResource = leftInput.getResource();
const rightResource = rightInput.getResource();
// Without any resource, do not try to compute a label
if (!leftResource || !rightResource) {
return undefined;
}
// Do not try to extract any paths from simple untitled text editors
if (res.scheme === Schemas.untitled && !this.untitledTextEditorService.hasAssociatedFilePath(res)) {
return input.getName();
// If both editors are file inputs, we produce an optimized label
// by adding the relative path of both inputs to the label. This
// makes it easier to understand a file-based comparison.
if (this.fileInputFactory.isFileInput(leftInput) && this.fileInputFactory.isFileInput(rightInput)) {
return `${this.labelService.getUriLabel(leftResource, { relative: true })} ${divider} ${this.labelService.getUriLabel(rightResource, { relative: true })}`;
}
// Otherwise: for diff labels prefer to see the path as part of the label
return this.labelService.getUriLabel(res, { relative: true });
// Signal back that the label should be computed from within the editor
return undefined;
}
//#endregion
......
......@@ -16,6 +16,8 @@ import { snapshotToString } from 'vs/workbench/services/textfile/common/textfile
import { ModesRegistry, PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry';
import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IIdentifiedSingleEditOperation } from 'vs/editor/common/model';
import { Range } from 'vs/editor/common/core/range';
class ServiceAccessor {
constructor(
......@@ -328,6 +330,62 @@ suite('Workbench untitled text editors', () => {
model.dispose();
});
test('onDidChangeFirstLine event and input name', async function () {
const service = accessor.untitledTextEditorService;
const input = service.create();
let counter = 0;
let model = await input.resolve();
model.onDidChangeFirstLine(() => counter++);
model.textEditorModel.setValue('foo');
assert.equal(input.getName(), 'foo');
assert.equal(counter, 1);
model.textEditorModel.setValue('bar');
assert.equal(input.getName(), 'bar');
assert.equal(counter, 2);
model.textEditorModel.setValue('');
assert.equal(input.getName(), 'Untitled-1');
assert.equal(counter, 3);
model.textEditorModel.setValue('Hello\nWorld');
assert.equal(counter, 4);
function createSingleEditOp(text: string, positionLineNumber: number, positionColumn: number, selectionLineNumber: number = positionLineNumber, selectionColumn: number = positionColumn): IIdentifiedSingleEditOperation {
let range = new Range(
selectionLineNumber,
selectionColumn,
positionLineNumber,
positionColumn
);
return {
identifier: null,
range,
text,
forceMoveMarkers: false
};
}
model.textEditorModel.applyEdits([createSingleEditOp('hello', 2, 2)]);
assert.equal(counter, 4); // change was not on first line
input.dispose();
model.dispose();
const inputWithContents = service.create({ initialValue: 'Foo' });
model = await inputWithContents.resolve();
assert.equal(inputWithContents.getName(), 'Foo');
inputWithContents.dispose();
model.dispose();
});
test('onDidChangeDirty event', async function () {
const service = accessor.untitledTextEditorService;
const input = service.create();
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册