Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preserve open editors in Cloud Changes #179507

Closed
wants to merge 12 commits into from
2 changes: 1 addition & 1 deletion src/vs/base/browser/ui/grid/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ export class Grid<T extends IView = IView> extends Disposable {
}

private getViewLocation(view: T): GridLocation {
const element = this.views.get(view);
const element = this.views.get(view) ?? [...this.views.values()][0];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are hacks and should not be shipped as is. I encountered issues with the lifecycle of the view attached to the active GridWidget. It seems that when we close all editors or dispose this.gridwidget, we still keep around one grid widget (and therefore one view, breadcrumb control etc.) which never gets disposed. This presents issues when trying to restore serialized state, because when deserializing editor view nodes, the previous view and breadcrumb control are still lying around, so getting the active view location and setting the breadcrumb control will throw. I am not familiar enough with the gridview to understand why this is desirable behavior and would appreciate a pointer on how to avoid this hack.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You would need to discuss with Grid owner @joaomoreno

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bpasero The grid's IView interface isn't disposable. The grid doesn't own each view's lifecycle. It won't dispose them, otherwise it would also have to conceptually create them. Since it's up to the grid user to create the views (which from the grid's viewpoint aren't "alive"), it's also up to the grid user to dispose them, if needed.

The grid is only disposable because it listens to DOM events, eg. mouse click on the sashes.


if (!element) {
throw new Error('View not found');
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/browser/parts/editor/breadcrumbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class BreadcrumbsService implements IBreadcrumbsService {

register(group: number, widget: BreadcrumbsWidget): IDisposable {
if (this._map.has(group)) {
throw new Error(`group (${group}) has already a widget`);
console.error(`group (${group}) has already a widget`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would not be needed if we can avoid the hack for restoring the editors.

}
this._map.set(group, widget);
return {
Expand Down
13 changes: 7 additions & 6 deletions src/vs/workbench/browser/parts/editor/editorGroupView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ export class EditorGroupView extends Themable implements IEditorGroupView {
//#region factory

static createNew(accessor: IEditorGroupsAccessor, index: number, instantiationService: IInstantiationService): IEditorGroupView {
return instantiationService.createInstance(EditorGroupView, accessor, null, index);
return instantiationService.createInstance(EditorGroupView, accessor, null, index, undefined);
}

static createFromSerialized(serialized: ISerializedEditorGroupModel, accessor: IEditorGroupsAccessor, index: number, instantiationService: IInstantiationService): IEditorGroupView {
return instantiationService.createInstance(EditorGroupView, accessor, serialized, index);
static createFromSerialized(serialized: ISerializedEditorGroupModel, accessor: IEditorGroupsAccessor, index: number, instantiationService: IInstantiationService, uriResolver?: (uri: URI) => URI): IEditorGroupView {
return instantiationService.createInstance(EditorGroupView, accessor, serialized, index, uriResolver);
}

static createCopy(copyFrom: IEditorGroupView, accessor: IEditorGroupsAccessor, index: number, instantiationService: IInstantiationService): IEditorGroupView {
return instantiationService.createInstance(EditorGroupView, accessor, copyFrom, index);
return instantiationService.createInstance(EditorGroupView, accessor, copyFrom, index, undefined);
}

//#endregion
Expand Down Expand Up @@ -139,6 +139,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView {
private accessor: IEditorGroupsAccessor,
from: IEditorGroupView | ISerializedEditorGroupModel | null,
private _index: number,
private readonly uriResolver: ((uri: URI) => URI) | undefined,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IThemeService themeService: IThemeService,
Expand All @@ -157,9 +158,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView {
if (from instanceof EditorGroupView) {
this.model = this._register(from.model.clone());
} else if (isSerializedEditorGroupModel(from)) {
this.model = this._register(instantiationService.createInstance(EditorGroupModel, from));
this.model = this._register(instantiationService.createInstance(EditorGroupModel, from, this.uriResolver));
} else {
this.model = this._register(instantiationService.createInstance(EditorGroupModel, undefined));
this.model = this._register(instantiationService.createInstance(EditorGroupModel, undefined, this.uriResolver));
}

//#region create()
Expand Down
60 changes: 56 additions & 4 deletions src/vs/workbench/browser/parts/editor/editorPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { DeferredPromise, Promises } from 'vs/base/common/async';
import { findGroup } from 'vs/workbench/services/editor/common/editorGroupFinder';
import { SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
import { IBoundarySashes } from 'vs/base/browser/ui/sash/sash';
import { EditSessionRegistry } from 'vs/platform/workspace/browser/editSessionsStorageService';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { URI } from 'vs/base/common/uri';

interface IEditorPartUIState {
serializedGrid: ISerializedGrid;
Expand Down Expand Up @@ -149,6 +152,55 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro
super(Parts.EDITOR_PART, { hasTitle: false }, themeService, storageService, layoutService);

this.registerListeners();

this._register(EditSessionRegistry.registerEditSessionsContribution('workbenchEditorLayout', this));
}

private static readonly EditSessionContributionSchemaVersion = 1;

getStateToStore() {
return {
version: EditorPart.EditSessionContributionSchemaVersion,
serializedGrid: this.gridWidget.serialize(),
activeGroup: this._activeGroup.id,
mostRecentActiveGroups: this.mostRecentActiveGroups
};
}

resumeState(state: unknown, uriResolver: (uri: URI) => URI) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks to me like a big hack to resume state, in fact you even execute workbench.action.closeAllEditors which will result in flicker and would not even close dirty editors. We have a dedicated location on startup where we determine which editors to open and I think we need to integrate session resume there:

const editors = await this.state.initialization.editor.editorsToOpen;

Having the editors to open there ensures that there will be no flicker and it will also not require to drop the current editor state and recreate it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue here is that at startup, the edit session payload is not guaranteed to be available (unless we move towards blocking resuming editor state on a network call to the storage server to retrieve the payload). Moreover an edit session payload today may be applied even after editor startup via the Resume Latest Changes From Cloud command, which together with open editors would support scenarios like https://github.com/microsoft/vscode/issues/35307.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we cannot just deserialize some state over existing state because you may have dirty editors opened that you cannot just close. So if we want to restore editors, it has to go through a different model that does not drop the grid and creates a new grid.

if (typeof state === 'object' && state !== null && 'serializedGrid' in state && 'activeGroup' in state && 'mostRecentActiveGroups' in state && 'version' in state) {
if (state.version === EditorPart.EditSessionContributionSchemaVersion) {
this.mostRecentActiveGroups = (state as IEditorPartUIState).mostRecentActiveGroups;
this.instantiationService.invokeFunction(async (accessor) => {
const restoreFocus = this.shouldRestoreFocus(this.container);

await accessor.get(ICommandService).executeCommand('workbench.action.closeAllEditors').catch(() => { });

const serializedGrid = (state as IEditorPartUIState).serializedGrid;

this.doCreateGridControlWithState(serializedGrid, (state as IEditorPartUIState).activeGroup, undefined, uriResolver);

// Layout
this.doLayout(this._contentDimension);

// Update container
this.updateContainer();

// Events for groups that got added
for (const groupView of this.getGroups(GroupsOrder.GRID_APPEARANCE)) {
this._onDidAddGroup.fire(groupView);
}

// Notify group index change given layout has changed
this.notifyGroupIndexChange();

// Restore focus as needed
if (restoreFocus) {
this._activeGroup.focus();
}
});
}
}
}

private registerListeners(): void {
Expand Down Expand Up @@ -557,14 +609,14 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro
return this._partOptions.splitSizing === 'split' ? Sizing.Split : Sizing.Distribute;
}

private doCreateGroupView(from?: IEditorGroupView | ISerializedEditorGroupModel | null): IEditorGroupView {
private doCreateGroupView(from?: IEditorGroupView | ISerializedEditorGroupModel | null, uriResolver?: (uri: URI) => URI): IEditorGroupView {

// Create group view
let groupView: IEditorGroupView;
if (from instanceof EditorGroupView) {
groupView = EditorGroupView.createCopy(from, this, this.count, this.instantiationService);
} else if (isSerializedEditorGroupModel(from)) {
groupView = EditorGroupView.createFromSerialized(from, this, this.count, this.instantiationService);
groupView = EditorGroupView.createFromSerialized(from, this, this.count, this.instantiationService, uriResolver);
} else {
groupView = EditorGroupView.createNew(this, this.count, this.instantiationService);
}
Expand Down Expand Up @@ -1064,7 +1116,7 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro
return true; // success
}

private doCreateGridControlWithState(serializedGrid: ISerializedGrid, activeGroupId: GroupIdentifier, editorGroupViewsToReuse?: IEditorGroupView[]): void {
private doCreateGridControlWithState(serializedGrid: ISerializedGrid, activeGroupId: GroupIdentifier, editorGroupViewsToReuse?: IEditorGroupView[], uriResolver?: (uri: URI) => URI): void {

// Determine group views to reuse if any
let reuseGroupViews: IEditorGroupView[];
Expand All @@ -1082,7 +1134,7 @@ export class EditorPart extends Part implements IEditorGroupsService, IEditorGro
if (reuseGroupViews.length > 0) {
groupView = reuseGroupViews.shift()!;
} else {
groupView = this.doCreateGroupView(serializedEditorGroup);
groupView = this.doCreateGroupView(serializedEditorGroup, uriResolver);
}

groupViews.push(groupView);
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/common/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export interface IEditorSerializer {
* Returns an editor from the provided serialized form of the editor. This form matches
* the value returned from the serialize() method.
*/
deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined;
deserialize(instantiationService: IInstantiationService, serializedEditor: string, uriResolver?: ((uri: URI) => URI)): EditorInput | undefined;
}

export interface IUntitledTextResourceEditorInput extends IBaseTextResourceEditorInput {
Expand Down
10 changes: 6 additions & 4 deletions src/vs/workbench/common/editor/editorGroupModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IConfigurationChangeEvent, IConfigurationService } from 'vs/platform/co
import { dispose, Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Registry } from 'vs/platform/registry/common/platform';
import { coalesce } from 'vs/base/common/arrays';
import { URI } from 'vs/base/common/uri';

const EditorOpenPositioning = {
LEFT: 'left',
Expand Down Expand Up @@ -191,13 +192,14 @@ export class EditorGroupModel extends Disposable {

constructor(
labelOrSerializedGroup: ISerializedEditorGroupModel | undefined,
uriResolver: ((uri: URI) => URI) | undefined,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IConfigurationService private readonly configurationService: IConfigurationService
) {
super();

if (isSerializedEditorGroupModel(labelOrSerializedGroup)) {
this._id = this.deserialize(labelOrSerializedGroup);
this._id = this.deserialize(labelOrSerializedGroup, uriResolver);
} else {
this._id = EditorGroupModel.IDS++;
}
Expand Down Expand Up @@ -963,7 +965,7 @@ export class EditorGroupModel extends Disposable {
}

clone(): EditorGroupModel {
const clone = this.instantiationService.createInstance(EditorGroupModel, undefined);
const clone = this.instantiationService.createInstance(EditorGroupModel, undefined, undefined);

// Copy over group properties
clone.editors = this.editors.slice(0);
Expand Down Expand Up @@ -1035,7 +1037,7 @@ export class EditorGroupModel extends Disposable {
};
}

private deserialize(data: ISerializedEditorGroupModel): number {
private deserialize(data: ISerializedEditorGroupModel, uriResolver?: (uri: URI) => URI): number {
const registry = Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory);

if (typeof data.id === 'number') {
Expand All @@ -1055,7 +1057,7 @@ export class EditorGroupModel extends Disposable {

const editorSerializer = registry.getEditorSerializer(e.id);
if (editorSerializer) {
const deserializedEditor = editorSerializer.deserialize(this.instantiationService, e.value);
const deserializedEditor = editorSerializer.deserialize(this.instantiationService, e.value, uriResolver);
if (deserializedEditor instanceof EditorInput) {
editor = deserializedEditor;
this.registerEditorListeners(editor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,18 @@ export class FileEditorInputSerializer implements IEditorSerializer {
return JSON.stringify(serializedFileEditorInput);
}

deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): FileEditorInput {
deserialize(instantiationService: IInstantiationService, serializedEditorInput: string, uriHandler: ((uri: URI) => URI) | undefined): FileEditorInput {
Copy link
Member

@bpasero bpasero Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a big fan of passing on the URI resolver to factories, shouldn't the factory when serializing and deserializing take care of using a format that can roam to other locations?

Besides, there are many factories for many editors (for example notebooks, custom editors), so this change will only work for text files and we would need an adoption in all factories.

See also #179507 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't the factory when serializing and deserializing take care of using a format that can roam to other locations

This is the first approach that I took, which I ultimately walked back because I found that it would lead to information duplication--to determine whether a URI is relevant to the current workspace, we must know four things:

  1. The workspace folder that contained the URI, if it was part of a workspace folder before (the 'base uri')
  2. The relative path from the workspace folder
  3. The additional metadata ('edit session identity') which associates the original workspace folder with one of the folders from the current workspace
  4. The actual current workspace folder that the relative path should be reunited with

If we try to make each factory 'self sufficient' by storing all of the above information, this leads to us duplicating and leaking knowledge of the edit session identity into the state of every workbench contribution which contributes to the payload, since now every workbench contribution must do the work of calculating and matching identities, workspace folders, and constructing URIs. IMHO this would raise the cost of adopting edit sessions and would lead to duplicated work across all contributors to edit sessions.

To simplify adoption, the next approach I took (in this PR) was to abstract all of this knowledge via the uriResolver function, which

  1. checks whether an absolute URI from another filesystem is relevant to a workspace folder, and if so which one
  2. resolves the absolute URI from another filesystem to one which should work in the current workspace folder

My intention is that putting this knowledge into a resolver rather than each contrib's state would make it easier to adopt across other workbench contribs, e.g. SCM (commit input), comments (draft comments), debug (breakpoints) and so on.

Besides, there are many factories for many editors (for example notebooks, custom editors), so this change will only work for text files and we would need an adoption in all factories.

You're absolutely right, this PR just shows a proof of concept for text editors, and once we have settled on a good approach I'd love to help adopt it for all editor types as well as other workbench contributions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets talk this through today.

return instantiationService.invokeFunction(accessor => {
const serializedFileEditorInput: ISerializedFileEditorInput = JSON.parse(serializedEditorInput);
const resource = URI.revive(serializedFileEditorInput.resourceJSON);
const resolvedResource = uriHandler ? uriHandler(resource) : resource;
const preferredResource = URI.revive(serializedFileEditorInput.preferredResourceJSON);
const name = serializedFileEditorInput.name;
const description = serializedFileEditorInput.description;
const encoding = serializedFileEditorInput.encoding;
const languageId = serializedFileEditorInput.modeId;

const fileEditorInput = accessor.get(ITextEditorService).createTextEditor({ resource, label: name, description, encoding, languageId, forceFile: true }) as FileEditorInput;
const fileEditorInput = accessor.get(ITextEditorService).createTextEditor({ resource: resolvedResource, label: name, description, encoding, languageId, forceFile: true }) as FileEditorInput;
if (preferredResource) {
fileEditorInput.setPreferredResource(preferredResource);
}
Expand Down
20 changes: 10 additions & 10 deletions src/vs/workbench/test/browser/parts/editor/editorGroupModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ suite('EditorGroupModel', () => {
}

function createEditorGroupModel(serialized?: ISerializedEditorGroupModel): EditorGroupModel {
return inst().createInstance(EditorGroupModel, serialized);
return inst().createInstance(EditorGroupModel, serialized, undefined);
}

function closeAllEditors(group: EditorGroupModel): void {
Expand Down Expand Up @@ -1041,7 +1041,7 @@ suite('EditorGroupModel', () => {
inst.stub(IConfigurationService, config);
config.setUserConfiguration('workbench', { editor: { openPositioning: 'left' } });

const group: EditorGroupModel = inst.createInstance(EditorGroupModel, undefined);
const group: EditorGroupModel = inst.createInstance(EditorGroupModel, undefined, undefined);

const events = groupListener(group);

Expand Down Expand Up @@ -1273,7 +1273,7 @@ suite('EditorGroupModel', () => {
config.setUserConfiguration('workbench', { editor: { focusRecentEditorAfterClose: false } });
inst.stub(IConfigurationService, config);

const group = inst.createInstance(EditorGroupModel, undefined);
const group = inst.createInstance(EditorGroupModel, undefined, undefined);
const events = groupListener(group);

const input1 = input();
Expand Down Expand Up @@ -1665,7 +1665,7 @@ suite('EditorGroupModel', () => {
assert.strictEqual(group.isActive(input1), true);

// Create model again - should load from storage
group = inst.createInstance(EditorGroupModel, group.serialize());
group = inst.createInstance(EditorGroupModel, group.serialize(), undefined);

assert.strictEqual(group.count, 1);
assert.strictEqual(group.activeEditor!.matches(input1), true);
Expand Down Expand Up @@ -1724,8 +1724,8 @@ suite('EditorGroupModel', () => {
assert.strictEqual(group2.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)[2].matches(g2_input2), true);

// Create model again - should load from storage
group1 = inst.createInstance(EditorGroupModel, group1.serialize());
group2 = inst.createInstance(EditorGroupModel, group2.serialize());
group1 = inst.createInstance(EditorGroupModel, group1.serialize(), undefined);
group2 = inst.createInstance(EditorGroupModel, group2.serialize(), undefined);

assert.strictEqual(group1.count, 3);
assert.strictEqual(group2.count, 3);
Expand Down Expand Up @@ -1777,7 +1777,7 @@ suite('EditorGroupModel', () => {
assert.strictEqual(group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)[2].matches(serializableInput1), true);

// Create model again - should load from storage
group = inst.createInstance(EditorGroupModel, group.serialize());
group = inst.createInstance(EditorGroupModel, group.serialize(), undefined);

assert.strictEqual(group.count, 2);
assert.strictEqual(group.activeEditor!.matches(serializableInput2), true);
Expand Down Expand Up @@ -1816,7 +1816,7 @@ suite('EditorGroupModel', () => {
assert.strictEqual(group.stickyCount, 1);

// Create model again - should load from storage
group = inst.createInstance(EditorGroupModel, group.serialize());
group = inst.createInstance(EditorGroupModel, group.serialize(), undefined);

assert.strictEqual(group.count, 2);
assert.strictEqual(group.stickyCount, 0);
Expand Down Expand Up @@ -1850,8 +1850,8 @@ suite('EditorGroupModel', () => {
group2.openEditor(nonSerializableInput);

// Create model again - should load from storage
group1 = inst.createInstance(EditorGroupModel, group1.serialize());
group2 = inst.createInstance(EditorGroupModel, group2.serialize());
group1 = inst.createInstance(EditorGroupModel, group1.serialize(), undefined);
group2 = inst.createInstance(EditorGroupModel, group2.serialize(), undefined);

assert.strictEqual(group1.count, 2);
assert.strictEqual(group1.getEditors(EditorsOrder.SEQUENTIAL)[0].matches(serializableInput1), true);
Expand Down