vscode/src/vs/workbench/browser/parts/editor/editorPart.ts
2021-11-24 13:58:32 +01:00

1203 lines
40 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { Part } from 'vs/workbench/browser/part';
import { Dimension, isAncestor, $, EventHelper, addDisposableGenericMouseDownListner } from 'vs/base/browser/dom';
import { Event, Emitter, Relay } from 'vs/base/common/event';
import { contrastBorder, editorBackground } from 'vs/platform/theme/common/colorRegistry';
import { GroupDirection, IAddGroupOptions, GroupsArrangement, GroupOrientation, IMergeGroupOptions, MergeGroupMode, GroupsOrder, GroupLocation, IFindGroupScope, EditorGroupLayout, GroupLayoutArgument, IEditorGroupsService, IEditorSideGroup } from 'vs/workbench/services/editor/common/editorGroupsService';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IView, orthogonal, LayoutPriority, IViewSize, Direction, SerializableGrid, Sizing, ISerializedGrid, Orientation, GridBranchNode, isGridBranchNode, GridNode, createSerializedGrid, Grid } from 'vs/base/browser/ui/grid/grid';
import { GroupIdentifier, EditorInputWithOptions, IEditorPartOptions, IEditorPartOptionsChangeEvent, GroupChangeKind } from 'vs/workbench/common/editor';
import { EDITOR_GROUP_BORDER, EDITOR_PANE_BACKGROUND } from 'vs/workbench/common/theme';
import { distinct, coalesce, firstOrDefault } from 'vs/base/common/arrays';
import { IEditorGroupsAccessor, IEditorGroupView, getEditorPartOptions, impactsEditorPartOptions, IEditorPartCreationOptions } from 'vs/workbench/browser/parts/editor/editor';
import { EditorGroupView } from 'vs/workbench/browser/parts/editor/editorGroupView';
import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration';
import { IDisposable, dispose, toDisposable, DisposableStore } from 'vs/base/common/lifecycle';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ISerializedEditorGroupModel, isSerializedEditorGroupModel } from 'vs/workbench/common/editor/editorGroupModel';
import { EditorDropTarget, IEditorDropTargetDelegate } from 'vs/workbench/browser/parts/editor/editorDropTarget';
import { IEditorDropService } from 'vs/workbench/services/editor/browser/editorDropService';
import { Color } from 'vs/base/common/color';
import { CenteredViewLayout } from 'vs/base/browser/ui/centered/centeredViewLayout';
import { onUnexpectedError } from 'vs/base/common/errors';
import { Parts, IWorkbenchLayoutService, Position } from 'vs/workbench/services/layout/browser/layoutService';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { assertIsDefined } from 'vs/base/common/types';
import { IBoundarySashes } from 'vs/base/browser/ui/grid/gridview';
import { CompositeDragAndDropObserver } from 'vs/workbench/browser/dnd';
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';
interface IEditorPartUIState {
serializedGrid: ISerializedGrid;
activeGroup: GroupIdentifier;
mostRecentActiveGroups: GroupIdentifier[];
}
class GridWidgetView<T extends IView> implements IView {
readonly element: HTMLElement = $('.grid-view-container');
get minimumWidth(): number { return this.gridWidget ? this.gridWidget.minimumWidth : 0; }
get maximumWidth(): number { return this.gridWidget ? this.gridWidget.maximumWidth : Number.POSITIVE_INFINITY; }
get minimumHeight(): number { return this.gridWidget ? this.gridWidget.minimumHeight : 0; }
get maximumHeight(): number { return this.gridWidget ? this.gridWidget.maximumHeight : Number.POSITIVE_INFINITY; }
private _onDidChange = new Relay<{ width: number; height: number; } | undefined>();
readonly onDidChange = this._onDidChange.event;
private _gridWidget: Grid<T> | undefined;
get gridWidget(): Grid<T> | undefined {
return this._gridWidget;
}
set gridWidget(grid: Grid<T> | undefined) {
this.element.innerText = '';
if (grid) {
this.element.appendChild(grid.element);
this._onDidChange.input = grid.onDidChange;
} else {
this._onDidChange.input = Event.None;
}
this._gridWidget = grid;
}
layout(width: number, height: number, top: number, left: number): void {
if (this.gridWidget) {
this.gridWidget.layout(width, height, top, left);
}
}
dispose(): void {
this._onDidChange.dispose();
}
}
export class EditorPart extends Part implements IEditorGroupsService, IEditorGroupsAccessor, IEditorDropService {
declare readonly _serviceBrand: undefined;
private static readonly EDITOR_PART_UI_STATE_STORAGE_KEY = 'editorpart.state';
private static readonly EDITOR_PART_CENTERED_VIEW_STORAGE_KEY = 'editorpart.centeredview';
//#region Events
private readonly _onDidLayout = this._register(new Emitter<Dimension>());
readonly onDidLayout = this._onDidLayout.event;
private readonly _onDidChangeActiveGroup = this._register(new Emitter<IEditorGroupView>());
readonly onDidChangeActiveGroup = this._onDidChangeActiveGroup.event;
private readonly _onDidChangeGroupIndex = this._register(new Emitter<IEditorGroupView>());
readonly onDidChangeGroupIndex = this._onDidChangeGroupIndex.event;
private readonly _onDidChangeGroupLocked = this._register(new Emitter<IEditorGroupView>());
readonly onDidChangeGroupLocked = this._onDidChangeGroupLocked.event;
private readonly _onDidActivateGroup = this._register(new Emitter<IEditorGroupView>());
readonly onDidActivateGroup = this._onDidActivateGroup.event;
private readonly _onDidAddGroup = this._register(new Emitter<IEditorGroupView>());
readonly onDidAddGroup = this._onDidAddGroup.event;
private readonly _onDidRemoveGroup = this._register(new Emitter<IEditorGroupView>());
readonly onDidRemoveGroup = this._onDidRemoveGroup.event;
private readonly _onDidMoveGroup = this._register(new Emitter<IEditorGroupView>());
readonly onDidMoveGroup = this._onDidMoveGroup.event;
private readonly onDidSetGridWidget = this._register(new Emitter<{ width: number; height: number; } | undefined>());
private readonly _onDidChangeSizeConstraints = this._register(new Relay<{ width: number; height: number; } | undefined>());
readonly onDidChangeSizeConstraints = Event.any(this.onDidSetGridWidget.event, this._onDidChangeSizeConstraints.event);
private readonly _onDidChangeEditorPartOptions = this._register(new Emitter<IEditorPartOptionsChangeEvent>());
readonly onDidChangeEditorPartOptions = this._onDidChangeEditorPartOptions.event;
//#endregion
private readonly workspaceMemento = this.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);
private readonly globalMemento = this.getMemento(StorageScope.GLOBAL, StorageTarget.MACHINE);
private readonly groupViews = new Map<GroupIdentifier, IEditorGroupView>();
private mostRecentActiveGroups: GroupIdentifier[] = [];
private container: HTMLElement | undefined;
private centeredLayoutWidget!: CenteredViewLayout;
private gridWidget!: SerializableGrid<IEditorGroupView>;
private readonly gridWidgetView = this._register(new GridWidgetView<IEditorGroupView>());
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IThemeService themeService: IThemeService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IStorageService storageService: IStorageService,
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService
) {
super(Parts.EDITOR_PART, { hasTitle: false }, themeService, storageService, layoutService);
this.registerListeners();
}
private registerListeners(): void {
this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e)));
this._register(this.themeService.onDidFileIconThemeChange(() => this.handleChangedPartOptions()));
}
private onConfigurationUpdated(event: IConfigurationChangeEvent): void {
if (impactsEditorPartOptions(event)) {
this.handleChangedPartOptions();
}
}
private handleChangedPartOptions(): void {
const oldPartOptions = this._partOptions;
const newPartOptions = getEditorPartOptions(this.configurationService, this.themeService);
for (const enforcedPartOptions of this.enforcedPartOptions) {
Object.assign(newPartOptions, enforcedPartOptions); // check for overrides
}
this._partOptions = newPartOptions;
this._onDidChangeEditorPartOptions.fire({ oldPartOptions, newPartOptions });
}
//#region IEditorGroupsService
private enforcedPartOptions: IEditorPartOptions[] = [];
private _partOptions = getEditorPartOptions(this.configurationService, this.themeService);
get partOptions(): IEditorPartOptions { return this._partOptions; }
enforcePartOptions(options: IEditorPartOptions): IDisposable {
this.enforcedPartOptions.push(options);
this.handleChangedPartOptions();
return toDisposable(() => {
this.enforcedPartOptions.splice(this.enforcedPartOptions.indexOf(options), 1);
this.handleChangedPartOptions();
});
}
private _contentDimension!: Dimension;
get contentDimension(): Dimension { return this._contentDimension; }
private _activeGroup!: IEditorGroupView;
get activeGroup(): IEditorGroupView {
return this._activeGroup;
}
readonly sideGroup: IEditorSideGroup = {
openEditor: (editor, options) => {
const [group] = this.instantiationService.invokeFunction(accessor => findGroup(accessor, { editor, options }, SIDE_GROUP));
return group.openEditor(editor, options);
}
};
get groups(): IEditorGroupView[] {
return Array.from(this.groupViews.values());
}
get count(): number {
return this.groupViews.size;
}
get orientation(): GroupOrientation {
return (this.gridWidget && this.gridWidget.orientation === Orientation.VERTICAL) ? GroupOrientation.VERTICAL : GroupOrientation.HORIZONTAL;
}
private _isReady = false;
get isReady(): boolean { return this._isReady; }
private readonly whenReadyPromise = new DeferredPromise<void>();
readonly whenReady = this.whenReadyPromise.p;
private readonly whenRestoredPromise = new DeferredPromise<void>();
readonly whenRestored = this.whenRestoredPromise.p;
get hasRestorableState(): boolean {
return !!this.workspaceMemento[EditorPart.EDITOR_PART_UI_STATE_STORAGE_KEY];
}
getGroups(order = GroupsOrder.CREATION_TIME): IEditorGroupView[] {
switch (order) {
case GroupsOrder.CREATION_TIME:
return this.groups;
case GroupsOrder.MOST_RECENTLY_ACTIVE:
const mostRecentActive = coalesce(this.mostRecentActiveGroups.map(groupId => this.getGroup(groupId)));
// there can be groups that got never active, even though they exist. in this case
// make sure to just append them at the end so that all groups are returned properly
return distinct([...mostRecentActive, ...this.groups]);
case GroupsOrder.GRID_APPEARANCE:
const views: IEditorGroupView[] = [];
if (this.gridWidget) {
this.fillGridNodes(views, this.gridWidget.getViews());
}
return views;
}
}
private fillGridNodes(target: IEditorGroupView[], node: GridBranchNode<IEditorGroupView> | GridNode<IEditorGroupView>): void {
if (isGridBranchNode(node)) {
node.children.forEach(child => this.fillGridNodes(target, child));
} else {
target.push(node.view);
}
}
getGroup(identifier: GroupIdentifier): IEditorGroupView | undefined {
return this.groupViews.get(identifier);
}
findGroup(scope: IFindGroupScope, source: IEditorGroupView | GroupIdentifier = this.activeGroup, wrap?: boolean): IEditorGroupView | undefined {
// by direction
if (typeof scope.direction === 'number') {
return this.doFindGroupByDirection(scope.direction, source, wrap);
}
// by location
if (typeof scope.location === 'number') {
return this.doFindGroupByLocation(scope.location, source, wrap);
}
throw new Error('invalid arguments');
}
private doFindGroupByDirection(direction: GroupDirection, source: IEditorGroupView | GroupIdentifier, wrap?: boolean): IEditorGroupView | undefined {
const sourceGroupView = this.assertGroupView(source);
// Find neighbours and sort by our MRU list
const neighbours = this.gridWidget.getNeighborViews(sourceGroupView, this.toGridViewDirection(direction), wrap);
neighbours.sort(((n1, n2) => this.mostRecentActiveGroups.indexOf(n1.id) - this.mostRecentActiveGroups.indexOf(n2.id)));
return neighbours[0];
}
private doFindGroupByLocation(location: GroupLocation, source: IEditorGroupView | GroupIdentifier, wrap?: boolean): IEditorGroupView | undefined {
const sourceGroupView = this.assertGroupView(source);
const groups = this.getGroups(GroupsOrder.GRID_APPEARANCE);
const index = groups.indexOf(sourceGroupView);
switch (location) {
case GroupLocation.FIRST:
return groups[0];
case GroupLocation.LAST:
return groups[groups.length - 1];
case GroupLocation.NEXT:
let nextGroup: IEditorGroupView | undefined = groups[index + 1];
if (!nextGroup && wrap) {
nextGroup = this.doFindGroupByLocation(GroupLocation.FIRST, source);
}
return nextGroup;
case GroupLocation.PREVIOUS:
let previousGroup: IEditorGroupView | undefined = groups[index - 1];
if (!previousGroup && wrap) {
previousGroup = this.doFindGroupByLocation(GroupLocation.LAST, source);
}
return previousGroup;
}
}
activateGroup(group: IEditorGroupView | GroupIdentifier): IEditorGroupView {
const groupView = this.assertGroupView(group);
this.doSetGroupActive(groupView);
this._onDidActivateGroup.fire(groupView);
return groupView;
}
restoreGroup(group: IEditorGroupView | GroupIdentifier): IEditorGroupView {
const groupView = this.assertGroupView(group);
this.doRestoreGroup(groupView);
return groupView;
}
getSize(group: IEditorGroupView | GroupIdentifier): { width: number, height: number } {
const groupView = this.assertGroupView(group);
return this.gridWidget.getViewSize(groupView);
}
setSize(group: IEditorGroupView | GroupIdentifier, size: { width: number, height: number }): void {
const groupView = this.assertGroupView(group);
this.gridWidget.resizeView(groupView, size);
}
arrangeGroups(arrangement: GroupsArrangement, target = this.activeGroup): void {
if (this.count < 2) {
return; // require at least 2 groups to show
}
if (!this.gridWidget) {
return; // we have not been created yet
}
switch (arrangement) {
case GroupsArrangement.EVEN:
this.gridWidget.distributeViewSizes();
break;
case GroupsArrangement.MINIMIZE_OTHERS:
this.gridWidget.maximizeViewSize(target);
break;
case GroupsArrangement.TOGGLE:
if (this.isGroupMaximized(target)) {
this.arrangeGroups(GroupsArrangement.EVEN);
} else {
this.arrangeGroups(GroupsArrangement.MINIMIZE_OTHERS);
}
break;
}
}
private isGroupMaximized(targetGroup: IEditorGroupView): boolean {
for (const group of this.groups) {
if (group === targetGroup) {
continue; // ignore target group
}
if (!group.isMinimized) {
return false; // target cannot be maximized if one group is not minimized
}
}
return true;
}
setGroupOrientation(orientation: GroupOrientation): void {
if (!this.gridWidget) {
return; // we have not been created yet
}
const newOrientation = (orientation === GroupOrientation.HORIZONTAL) ? Orientation.HORIZONTAL : Orientation.VERTICAL;
if (this.gridWidget.orientation !== newOrientation) {
this.gridWidget.orientation = newOrientation;
}
}
applyLayout(layout: EditorGroupLayout): void {
const restoreFocus = this.shouldRestoreFocus(this.container);
// Determine how many groups we need overall
let layoutGroupsCount = 0;
function countGroups(groups: GroupLayoutArgument[]): void {
for (const group of groups) {
if (Array.isArray(group.groups)) {
countGroups(group.groups);
} else {
layoutGroupsCount++;
}
}
}
countGroups(layout.groups);
// If we currently have too many groups, merge them into the last one
let currentGroupViews = this.getGroups(GroupsOrder.GRID_APPEARANCE);
if (layoutGroupsCount < currentGroupViews.length) {
const lastGroupInLayout = currentGroupViews[layoutGroupsCount - 1];
currentGroupViews.forEach((group, index) => {
if (index >= layoutGroupsCount) {
this.mergeGroup(group, lastGroupInLayout);
}
});
currentGroupViews = this.getGroups(GroupsOrder.GRID_APPEARANCE);
}
const activeGroup = this.activeGroup;
// Prepare grid descriptor to create new grid from
const gridDescriptor = createSerializedGrid({
orientation: this.toGridViewOrientation(
layout.orientation,
this.isTwoDimensionalGrid() ?
this.gridWidget.orientation : // preserve original orientation for 2-dimensional grids
orthogonal(this.gridWidget.orientation) // otherwise flip (fix https://github.com/microsoft/vscode/issues/52975)
),
groups: layout.groups
});
// Recreate gridwidget with descriptor
this.doCreateGridControlWithState(gridDescriptor, activeGroup.id, currentGroupViews);
// Layout
this.doLayout(this._contentDimension, 0, 0);
// Update container
this.updateContainer();
// Events for groups that got added
for (const groupView of this.getGroups(GroupsOrder.GRID_APPEARANCE)) {
if (!currentGroupViews.includes(groupView)) {
this._onDidAddGroup.fire(groupView);
}
}
// Notify group index change given layout has changed
this.notifyGroupIndexChange();
// Restore focus as needed
if (restoreFocus) {
this._activeGroup.focus();
}
}
private shouldRestoreFocus(target: Element | undefined): boolean {
if (!target) {
return false;
}
const activeElement = document.activeElement;
if (activeElement === document.body) {
return true; // always restore focus if nothing is focused currently
}
// otherwise check for the active element being an ancestor of the target
return isAncestor(activeElement, target);
}
private isTwoDimensionalGrid(): boolean {
const views = this.gridWidget.getViews();
if (isGridBranchNode(views)) {
// the grid is 2-dimensional if any children
// of the grid is a branch node
return views.children.some(child => isGridBranchNode(child));
}
return false;
}
addGroup(location: IEditorGroupView | GroupIdentifier, direction: GroupDirection, options?: IAddGroupOptions): IEditorGroupView {
const locationView = this.assertGroupView(location);
const group = this.doAddGroup(locationView, direction);
if (options?.activate) {
this.doSetGroupActive(group);
}
return group;
}
private doAddGroup(locationView: IEditorGroupView, direction: GroupDirection, groupToCopy?: IEditorGroupView): IEditorGroupView {
const newGroupView = this.doCreateGroupView(groupToCopy);
// Add to grid widget
this.gridWidget.addView(
newGroupView,
this.getSplitSizingStyle(),
locationView,
this.toGridViewDirection(direction),
);
// Update container
this.updateContainer();
// Event
this._onDidAddGroup.fire(newGroupView);
// Notify group index change given a new group was added
this.notifyGroupIndexChange();
return newGroupView;
}
private getSplitSizingStyle(): Sizing {
return this._partOptions.splitSizing === 'split' ? Sizing.Split : Sizing.Distribute;
}
private doCreateGroupView(from?: IEditorGroupView | ISerializedEditorGroupModel | null): 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);
} else {
groupView = EditorGroupView.createNew(this, this.count, this.instantiationService);
}
// Keep in map
this.groupViews.set(groupView.id, groupView);
// Track focus
const groupDisposables = new DisposableStore();
groupDisposables.add(groupView.onDidFocus(() => {
this.doSetGroupActive(groupView);
}));
// Track editor change
groupDisposables.add(groupView.onDidGroupChange(e => {
switch (e.kind) {
case GroupChangeKind.EDITOR_ACTIVE:
this.updateContainer();
break;
case GroupChangeKind.GROUP_INDEX:
this._onDidChangeGroupIndex.fire(groupView);
break;
case GroupChangeKind.GROUP_LOCKED:
this._onDidChangeGroupLocked.fire(groupView);
break;
}
}));
// Track dispose
Event.once(groupView.onWillDispose)(() => {
dispose(groupDisposables);
this.groupViews.delete(groupView.id);
this.doUpdateMostRecentActive(groupView);
});
return groupView;
}
private doSetGroupActive(group: IEditorGroupView): void {
if (this._activeGroup === group) {
return; // return if this is already the active group
}
const previousActiveGroup = this._activeGroup;
this._activeGroup = group;
// Update list of most recently active groups
this.doUpdateMostRecentActive(group, true);
// Mark previous one as inactive
if (previousActiveGroup) {
previousActiveGroup.setActive(false);
}
// Mark group as new active
group.setActive(true);
// Maximize the group if it is currently minimized
this.doRestoreGroup(group);
// Event
this._onDidChangeActiveGroup.fire(group);
}
private doRestoreGroup(group: IEditorGroupView): void {
if (this.gridWidget) {
const viewSize = this.gridWidget.getViewSize(group);
if (viewSize.width === group.minimumWidth || viewSize.height === group.minimumHeight) {
this.arrangeGroups(GroupsArrangement.MINIMIZE_OTHERS, group);
}
}
}
private doUpdateMostRecentActive(group: IEditorGroupView, makeMostRecentlyActive?: boolean): void {
const index = this.mostRecentActiveGroups.indexOf(group.id);
// Remove from MRU list
if (index !== -1) {
this.mostRecentActiveGroups.splice(index, 1);
}
// Add to front as needed
if (makeMostRecentlyActive) {
this.mostRecentActiveGroups.unshift(group.id);
}
}
private toGridViewDirection(direction: GroupDirection): Direction {
switch (direction) {
case GroupDirection.UP: return Direction.Up;
case GroupDirection.DOWN: return Direction.Down;
case GroupDirection.LEFT: return Direction.Left;
case GroupDirection.RIGHT: return Direction.Right;
}
}
private toGridViewOrientation(orientation: GroupOrientation, fallback: Orientation): Orientation {
if (typeof orientation === 'number') {
return orientation === GroupOrientation.HORIZONTAL ? Orientation.HORIZONTAL : Orientation.VERTICAL;
}
return fallback;
}
removeGroup(group: IEditorGroupView | GroupIdentifier): void {
const groupView = this.assertGroupView(group);
if (this.count === 1) {
return; // Cannot remove the last root group
}
// Remove empty group
if (groupView.isEmpty) {
return this.doRemoveEmptyGroup(groupView);
}
// Remove group with editors
this.doRemoveGroupWithEditors(groupView);
}
private doRemoveGroupWithEditors(groupView: IEditorGroupView): void {
const mostRecentlyActiveGroups = this.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE);
let lastActiveGroup: IEditorGroupView;
if (this._activeGroup === groupView) {
lastActiveGroup = mostRecentlyActiveGroups[1];
} else {
lastActiveGroup = mostRecentlyActiveGroups[0];
}
// Removing a group with editors should merge these editors into the
// last active group and then remove this group.
this.mergeGroup(groupView, lastActiveGroup);
}
private doRemoveEmptyGroup(groupView: IEditorGroupView): void {
const restoreFocus = this.shouldRestoreFocus(this.container);
// Activate next group if the removed one was active
if (this._activeGroup === groupView) {
const mostRecentlyActiveGroups = this.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE);
const nextActiveGroup = mostRecentlyActiveGroups[1]; // [0] will be the current group we are about to dispose
this.activateGroup(nextActiveGroup);
}
// Remove from grid widget & dispose
this.gridWidget.removeView(groupView, this.getSplitSizingStyle());
groupView.dispose();
// Restore focus if we had it previously (we run this after gridWidget.removeView() is called
// because removing a view can mean to reparent it and thus focus would be removed otherwise)
if (restoreFocus) {
this._activeGroup.focus();
}
// Notify group index change given a group was removed
this.notifyGroupIndexChange();
// Update container
this.updateContainer();
// Update locked state: clear when we are at just 1 group
if (this.count === 1) {
firstOrDefault(this.groups)?.lock(false);
}
// Event
this._onDidRemoveGroup.fire(groupView);
}
moveGroup(group: IEditorGroupView | GroupIdentifier, location: IEditorGroupView | GroupIdentifier, direction: GroupDirection): IEditorGroupView {
const sourceView = this.assertGroupView(group);
const targetView = this.assertGroupView(location);
if (sourceView.id === targetView.id) {
throw new Error('Cannot move group into its own');
}
const restoreFocus = this.shouldRestoreFocus(sourceView.element);
// Move through grid widget API
this.gridWidget.moveView(sourceView, this.getSplitSizingStyle(), targetView, this.toGridViewDirection(direction));
// Restore focus if we had it previously (we run this after gridWidget.removeView() is called
// because removing a view can mean to reparent it and thus focus would be removed otherwise)
if (restoreFocus) {
sourceView.focus();
}
// Event
this._onDidMoveGroup.fire(sourceView);
// Notify group index change given a group was moved
this.notifyGroupIndexChange();
return sourceView;
}
copyGroup(group: IEditorGroupView | GroupIdentifier, location: IEditorGroupView | GroupIdentifier, direction: GroupDirection): IEditorGroupView {
const groupView = this.assertGroupView(group);
const locationView = this.assertGroupView(location);
const restoreFocus = this.shouldRestoreFocus(groupView.element);
// Copy the group view
const copiedGroupView = this.doAddGroup(locationView, direction, groupView);
// Restore focus if we had it
if (restoreFocus) {
copiedGroupView.focus();
}
return copiedGroupView;
}
mergeGroup(group: IEditorGroupView | GroupIdentifier, target: IEditorGroupView | GroupIdentifier, options?: IMergeGroupOptions): IEditorGroupView {
const sourceView = this.assertGroupView(group);
const targetView = this.assertGroupView(target);
// Collect editors to move/copy
const editors: EditorInputWithOptions[] = [];
let index = (options && typeof options.index === 'number') ? options.index : targetView.count;
for (const editor of sourceView.editors) {
const inactive = !sourceView.isActive(editor) || this._activeGroup !== sourceView;
const sticky = sourceView.isSticky(editor);
const options = { index: !sticky ? index : undefined /* do not set index to preserve sticky flag */, inactive, preserveFocus: inactive };
editors.push({ editor, options });
index++;
}
// Move/Copy editors over into target
if (options?.mode === MergeGroupMode.COPY_EDITORS) {
sourceView.copyEditors(editors, targetView);
} else {
sourceView.moveEditors(editors, targetView);
}
// Remove source if the view is now empty and not already removed
if (sourceView.isEmpty && !sourceView.disposed /* could have been disposed already via workbench.editor.closeEmptyGroups setting */) {
this.removeGroup(sourceView);
}
return targetView;
}
mergeAllGroups(target = this.activeGroup): IEditorGroupView {
for (const group of this.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) {
if (group === target) {
continue; // keep target
}
this.mergeGroup(group, target);
}
return target;
}
private assertGroupView(group: IEditorGroupView | GroupIdentifier): IEditorGroupView {
let groupView: IEditorGroupView | undefined;
if (typeof group === 'number') {
groupView = this.getGroup(group);
} else {
groupView = group;
}
if (!groupView) {
throw new Error('Invalid editor group provided!');
}
return groupView;
}
//#endregion
//#region IEditorDropService
createEditorDropTarget(container: HTMLElement, delegate: IEditorDropTargetDelegate): IDisposable {
return this.instantiationService.createInstance(EditorDropTarget, this, container, delegate);
}
//#endregion
//#region Part
// TODO @sbatten @joao find something better to prevent editor taking over #79897
get minimumWidth(): number { return Math.min(this.centeredLayoutWidget.minimumWidth, this.layoutService.getMaximumEditorDimensions().width); }
get maximumWidth(): number { return this.centeredLayoutWidget.maximumWidth; }
get minimumHeight(): number { return Math.min(this.centeredLayoutWidget.minimumHeight, this.layoutService.getMaximumEditorDimensions().height); }
get maximumHeight(): number { return this.centeredLayoutWidget.maximumHeight; }
readonly snap = true;
override get onDidChange(): Event<IViewSize | undefined> { return Event.any(this.centeredLayoutWidget.onDidChange, this.onDidSetGridWidget.event); }
readonly priority: LayoutPriority = LayoutPriority.High;
private get gridSeparatorBorder(): Color {
return this.theme.getColor(EDITOR_GROUP_BORDER) || this.theme.getColor(contrastBorder) || Color.transparent;
}
override updateStyles(): void {
const container = assertIsDefined(this.container);
container.style.backgroundColor = this.getColor(editorBackground) || '';
const separatorBorderStyle = { separatorBorder: this.gridSeparatorBorder, background: this.theme.getColor(EDITOR_PANE_BACKGROUND) || Color.transparent };
this.gridWidget.style(separatorBorderStyle);
this.centeredLayoutWidget.styles(separatorBorderStyle);
}
override createContentArea(parent: HTMLElement, options?: IEditorPartCreationOptions): HTMLElement {
// Container
this.element = parent;
this.container = document.createElement('div');
this.container.classList.add('content');
parent.appendChild(this.container);
// Grid control
this.doCreateGridControl(options);
// Centered layout widget
this.centeredLayoutWidget = this._register(new CenteredViewLayout(this.container, this.gridWidgetView, this.globalMemento[EditorPart.EDITOR_PART_CENTERED_VIEW_STORAGE_KEY]));
// Drag & Drop support
this.setupDragAndDropSupport(parent, this.container);
// Signal ready
this.whenReadyPromise.complete();
this._isReady = true;
// Signal restored
Promises.settled(this.groups.map(group => group.whenRestored)).finally(() => {
this.whenRestoredPromise.complete();
});
return this.container;
}
private setupDragAndDropSupport(parent: HTMLElement, container: HTMLElement): void {
// Editor drop target
this._register(this.createEditorDropTarget(container, Object.create(null)));
// No drop in the editor
const overlay = document.createElement('div');
overlay.classList.add('drop-block-overlay');
parent.appendChild(overlay);
// Hide the block if a mouse down event occurs #99065
this._register(addDisposableGenericMouseDownListner(overlay, () => overlay.classList.remove('visible')));
this._register(CompositeDragAndDropObserver.INSTANCE.registerTarget(this.element, {
onDragStart: e => overlay.classList.add('visible'),
onDragEnd: e => overlay.classList.remove('visible')
}));
let horizontalOpenerTimeout: any;
let verticalOpenerTimeout: any;
let lastOpenHorizontalPosition: Position | undefined;
let lastOpenVerticalPosition: Position | undefined;
const openPartAtPosition = (position: Position) => {
if (!this.layoutService.isVisible(Parts.PANEL_PART) && position === this.layoutService.getPanelPosition()) {
this.layoutService.setPartHidden(false, Parts.PANEL_PART);
} else if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART) && position === (this.layoutService.getSideBarPosition() === Position.RIGHT ? Position.LEFT : Position.RIGHT)) {
this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART);
}
};
const clearAllTimeouts = () => {
if (horizontalOpenerTimeout) {
clearTimeout(horizontalOpenerTimeout);
horizontalOpenerTimeout = undefined;
}
if (verticalOpenerTimeout) {
clearTimeout(verticalOpenerTimeout);
verticalOpenerTimeout = undefined;
}
};
this._register(CompositeDragAndDropObserver.INSTANCE.registerTarget(overlay, {
onDragOver: e => {
EventHelper.stop(e.eventData, true);
if (e.eventData.dataTransfer) {
e.eventData.dataTransfer.dropEffect = 'none';
}
const boundingRect = overlay.getBoundingClientRect();
let openHorizontalPosition: Position | undefined = undefined;
let openVerticalPosition: Position | undefined = undefined;
const proximity = 100;
if (e.eventData.clientX < boundingRect.left + proximity) {
openHorizontalPosition = Position.LEFT;
}
if (e.eventData.clientX > boundingRect.right - proximity) {
openHorizontalPosition = Position.RIGHT;
}
if (e.eventData.clientY > boundingRect.bottom - proximity) {
openVerticalPosition = Position.BOTTOM;
}
if (horizontalOpenerTimeout && openHorizontalPosition !== lastOpenHorizontalPosition) {
clearTimeout(horizontalOpenerTimeout);
horizontalOpenerTimeout = undefined;
}
if (verticalOpenerTimeout && openVerticalPosition !== lastOpenVerticalPosition) {
clearTimeout(verticalOpenerTimeout);
verticalOpenerTimeout = undefined;
}
if (!horizontalOpenerTimeout && openHorizontalPosition !== undefined) {
lastOpenHorizontalPosition = openHorizontalPosition;
horizontalOpenerTimeout = setTimeout(() => openPartAtPosition(openHorizontalPosition!), 200);
}
if (!verticalOpenerTimeout && openVerticalPosition !== undefined) {
lastOpenVerticalPosition = openVerticalPosition;
verticalOpenerTimeout = setTimeout(() => openPartAtPosition(openVerticalPosition!), 200);
}
},
onDragLeave: () => clearAllTimeouts(),
onDragEnd: () => clearAllTimeouts(),
onDrop: () => clearAllTimeouts()
}));
}
centerLayout(active: boolean): void {
this.centeredLayoutWidget.activate(active);
this._activeGroup.focus();
}
isLayoutCentered(): boolean {
if (this.centeredLayoutWidget) {
return this.centeredLayoutWidget.isActive();
}
return false;
}
private doCreateGridControl(options?: IEditorPartCreationOptions): void {
// Grid Widget (with previous UI state)
let restoreError = false;
if (!options || options.restorePreviousState) {
restoreError = !this.doCreateGridControlWithPreviousState();
}
// Grid Widget (no previous UI state or failed to restore)
if (!this.gridWidget || restoreError) {
const initialGroup = this.doCreateGroupView();
this.doSetGridWidget(new SerializableGrid(initialGroup));
// Ensure a group is active
this.doSetGroupActive(initialGroup);
}
// Update container
this.updateContainer();
// Notify group index change we created the entire grid
this.notifyGroupIndexChange();
}
private doCreateGridControlWithPreviousState(): boolean {
const uiState: IEditorPartUIState = this.workspaceMemento[EditorPart.EDITOR_PART_UI_STATE_STORAGE_KEY];
if (uiState?.serializedGrid) {
try {
// MRU
this.mostRecentActiveGroups = uiState.mostRecentActiveGroups;
// Grid Widget
this.doCreateGridControlWithState(uiState.serializedGrid, uiState.activeGroup);
// Ensure last active group has focus
this._activeGroup.focus();
} catch (error) {
// Log error
onUnexpectedError(new Error(`Error restoring editor grid widget: ${error} (with state: ${JSON.stringify(uiState)})`));
// Clear any state we have from the failing restore
this.groupViews.forEach(group => group.dispose());
this.groupViews.clear();
this.mostRecentActiveGroups = [];
return false; // failure
}
}
return true; // success
}
private doCreateGridControlWithState(serializedGrid: ISerializedGrid, activeGroupId: GroupIdentifier, editorGroupViewsToReuse?: IEditorGroupView[]): void {
// Determine group views to reuse if any
let reuseGroupViews: IEditorGroupView[];
if (editorGroupViewsToReuse) {
reuseGroupViews = editorGroupViewsToReuse.slice(0); // do not modify original array
} else {
reuseGroupViews = [];
}
// Create new
const groupViews: IEditorGroupView[] = [];
const gridWidget = SerializableGrid.deserialize(serializedGrid, {
fromJSON: (serializedEditorGroup: ISerializedEditorGroupModel | null) => {
let groupView: IEditorGroupView;
if (reuseGroupViews.length > 0) {
groupView = reuseGroupViews.shift()!;
} else {
groupView = this.doCreateGroupView(serializedEditorGroup);
}
groupViews.push(groupView);
if (groupView.id === activeGroupId) {
this.doSetGroupActive(groupView);
}
return groupView;
}
}, { styles: { separatorBorder: this.gridSeparatorBorder } });
// If the active group was not found when restoring the grid
// make sure to make at least one group active. We always need
// an active group.
if (!this._activeGroup) {
this.doSetGroupActive(groupViews[0]);
}
// Validate MRU group views matches grid widget state
if (this.mostRecentActiveGroups.some(groupId => !this.getGroup(groupId))) {
this.mostRecentActiveGroups = groupViews.map(group => group.id);
}
// Set it
this.doSetGridWidget(gridWidget);
}
private doSetGridWidget(gridWidget: SerializableGrid<IEditorGroupView>): void {
let boundarySashes: IBoundarySashes = {};
if (this.gridWidget) {
boundarySashes = this.gridWidget.boundarySashes;
this.gridWidget.dispose();
}
this.gridWidget = gridWidget;
this.gridWidget.boundarySashes = boundarySashes;
this.gridWidgetView.gridWidget = gridWidget;
this._onDidChangeSizeConstraints.input = gridWidget.onDidChange;
this.onDidSetGridWidget.fire(undefined);
}
private updateContainer(): void {
const container = assertIsDefined(this.container);
container.classList.toggle('empty', this.isEmpty);
}
private notifyGroupIndexChange(): void {
this.getGroups(GroupsOrder.GRID_APPEARANCE).forEach((group, index) => group.notifyIndexChanged(index));
}
private get isEmpty(): boolean {
return this.count === 1 && this._activeGroup.isEmpty;
}
setBoundarySashes(sashes: IBoundarySashes): void {
this.gridWidget.boundarySashes = sashes;
this.centeredLayoutWidget.boundarySashes = sashes;
}
override layout(width: number, height: number, top: number, left: number): void {
// Layout contents
const contentAreaSize = super.layoutContents(width, height).contentSize;
// Layout editor container
this.doLayout(Dimension.lift(contentAreaSize), top, left);
}
private doLayout(dimension: Dimension, top: number, left: number): void {
this._contentDimension = dimension;
// Layout Grid
this.centeredLayoutWidget.layout(this._contentDimension.width, this._contentDimension.height, top, left);
// Event
this._onDidLayout.fire(dimension);
}
protected override saveState(): void {
// Persist grid UI state
if (this.gridWidget) {
const uiState: IEditorPartUIState = {
serializedGrid: this.gridWidget.serialize(),
activeGroup: this._activeGroup.id,
mostRecentActiveGroups: this.mostRecentActiveGroups
};
if (this.isEmpty) {
delete this.workspaceMemento[EditorPart.EDITOR_PART_UI_STATE_STORAGE_KEY];
} else {
this.workspaceMemento[EditorPart.EDITOR_PART_UI_STATE_STORAGE_KEY] = uiState;
}
}
// Persist centered view state
if (this.centeredLayoutWidget) {
const centeredLayoutState = this.centeredLayoutWidget.state;
if (this.centeredLayoutWidget.isDefault(centeredLayoutState)) {
delete this.globalMemento[EditorPart.EDITOR_PART_CENTERED_VIEW_STORAGE_KEY];
} else {
this.globalMemento[EditorPart.EDITOR_PART_CENTERED_VIEW_STORAGE_KEY] = centeredLayoutState;
}
}
super.saveState();
}
toJSON(): object {
return {
type: Parts.EDITOR_PART
};
}
override dispose(): void {
// Forward to all groups
this.groupViews.forEach(group => group.dispose());
this.groupViews.clear();
// Grid widget
this.gridWidget?.dispose();
super.dispose();
}
//#endregion
}
class EditorDropService implements IEditorDropService {
declare readonly _serviceBrand: undefined;
constructor(@IEditorGroupsService private readonly editorPart: EditorPart) { }
createEditorDropTarget(container: HTMLElement, delegate: IEditorDropTargetDelegate): IDisposable {
return this.editorPart.createEditorDropTarget(container, delegate);
}
}
registerSingleton(IEditorGroupsService, EditorPart);
registerSingleton(IEditorDropService, EditorDropService);