Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/vs/sessions/LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ The Editor and Auxiliary Bar compose one side pane next to the active session.
Editor tabs choose either editor content or a details view while the layout
coordinators preserve one coherent visibility model.

The main Editor supports exactly one editor group. Its shared multiple-group
capability is disabled, which removes editor split/grid commands, keybindings,
menus, and split drop targets; the part also rejects group creation and
multi-group layout requests from open-to-side and programmatic paths. The
independent chat grid remains supported.

The durable state and transition catalog lives in
[SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md). Implementation behavior is
covered by the layout-controller and single-pane strategy tests.
Expand Down
4 changes: 4 additions & 0 deletions src/vs/sessions/SINGLE_PANE_SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ bar spanning the editor content and a docked detail panel).
single-pane layout described here. Phone viewports always use the classic
layout. When the setting is **OFF**, all Agents windows use the classic layout
and nothing else in this document applies.
- The main Editor supports exactly one editor group. Editor split/grid commands,
keybindings, menus, open-to-side requests, and split drop targets are disabled;
programmatic group creation and multi-group layout requests are rejected. This
restriction does not apply to the separate chat grid.
- Companion specs: [Editor presentation](LAYOUT.md#editor-presentation),
[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md), and
[contrib/layout/browser/desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md).
Expand Down
33 changes: 32 additions & 1 deletion src/vs/sessions/browser/parts/editorParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@

import './media/editorPart.css';
import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js';
import { IEditorGroupView } from '../../../workbench/browser/parts/editor/editor.js';
import { EditorParts as EditorPartsBase } from '../../../workbench/browser/parts/editor/editorParts.js';
import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js';
import { GroupIdentifier } from '../../../workbench/common/editor.js';
import { GroupDirection, IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js';
import { IAgentWorkbenchLayoutService } from '../workbench.js';
import { MainEditorPart } from './editorPart.js';
import { SinglePaneMainEditorPart } from './singlePaneEditorPart.js';
Expand All @@ -21,6 +23,35 @@ export class EditorParts extends EditorPartsBase {

return editorPart;
}

override moveGroup(group: IEditorGroupView | GroupIdentifier, location: IEditorGroupView | GroupIdentifier, direction: GroupDirection): IEditorGroupView {
if (this.involvesSinglePaneMainPart(group, location)) {
return this.resolveGroup(group);
}

return super.moveGroup(group, location, direction);
}

override copyGroup(group: IEditorGroupView | GroupIdentifier, location: IEditorGroupView | GroupIdentifier, direction: GroupDirection): IEditorGroupView {
if (this.involvesSinglePaneMainPart(group, location)) {
return this.resolveGroup(group);
}

return super.copyGroup(group, location, direction);
}

private involvesSinglePaneMainPart(group: IEditorGroupView | GroupIdentifier, location: IEditorGroupView | GroupIdentifier): boolean {
return this.mainPart instanceof SinglePaneMainEditorPart
&& (this.getPart(group) === this.mainPart || this.getPart(location) === this.mainPart);
}

private resolveGroup(group: IEditorGroupView | GroupIdentifier): IEditorGroupView {
const resolvedGroup = typeof group === 'number' ? this.getGroup(group) : group;
if (!resolvedGroup) {
throw new Error('Invalid editor group provided!');
}
return resolvedGroup;
}
}

registerSingleton(IEditorGroupsService, EditorParts, InstantiationType.Eager);
41 changes: 39 additions & 2 deletions src/vs/sessions/browser/parts/singlePaneEditorPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
*--------------------------------------------------------------------------------------------*/

import { mainWindow } from '../../../base/browser/window.js';
import { DisposableMap, MutableDisposable } from '../../../base/common/lifecycle.js';
import { DisposableMap, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
import { IConfigurationService } from '../../../platform/configuration/common/configuration.js';
import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
import { IStorageService } from '../../../platform/storage/common/storage.js';
import { IThemeService } from '../../../platform/theme/common/themeService.js';
import { IEditorGroupViewOptions, IEditorPartCreationOptions, IEditorPartsView } from '../../../workbench/browser/parts/editor/editor.js';
import { IEditorGroupView, IEditorGroupViewOptions, IEditorPartCreationOptions, IEditorPartsView } from '../../../workbench/browser/parts/editor/editor.js';
import { IEditorPartUIState } from '../../../workbench/browser/parts/editor/editorPart.js';
import { EditorGroupView } from '../../../workbench/browser/parts/editor/editorGroupView.js';
import { GroupIdentifier } from '../../../workbench/common/editor.js';
import { EditorGroupLayout, GroupDirection, GroupLayoutArgument, IEditorDropTargetDelegate } from '../../../workbench/services/editor/common/editorGroupsService.js';
import { Parts } from '../../../workbench/services/layout/browser/layoutService.js';
import { IHostService } from '../../../workbench/services/host/browser/host.js';
import { DockedAuxiliaryBarController } from '../dockedAuxiliaryBarController.js';
Expand Down Expand Up @@ -154,6 +157,32 @@ export class SinglePaneMainEditorPart extends MainEditorPart {
return container;
}

override addGroup(location: IEditorGroupView | GroupIdentifier, _direction: GroupDirection, _groupToCopy?: IEditorGroupView): IEditorGroupView {
return this.assertGroupView(location);
}

override applyLayout(layout: EditorGroupLayout): void {
if (countEditorGroups(layout.groups) > 1) {
return;
}
super.applyLayout(layout);
}

override createEditorDropTarget(container: unknown, delegate: IEditorDropTargetDelegate): IDisposable {
return super.createEditorDropTarget(container, { ...delegate, supportsSplitting: false });
}

override async applyState(state: IEditorPartUIState | 'empty', options?: IEditorGroupViewOptions): Promise<void> {
await super.applyState(state, options);
this._ensureSingleEditorGroup();
}

private _ensureSingleEditorGroup(): void {
if (this.count > 1) {
this.mergeAllGroups(this.activeGroup);
}
}

/**
* Keeps the docked auxiliary bar aligned after group-local relayouts.
*/
Expand Down Expand Up @@ -186,3 +215,11 @@ export class SinglePaneMainEditorPart extends MainEditorPart {
this._dockedAuxBar?.layout();
}
}

function countEditorGroups(groups: GroupLayoutArgument[]): number {
let count = 0;
for (const group of groups) {
count += group.groups ? countEditorGroups(group.groups) : 1;
}
return count;
}
77 changes: 77 additions & 0 deletions src/vs/sessions/test/browser/workbench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import { DockedAuxiliaryBarController, IDockedAuxiliaryBarHost } from '../../bro
import { ISidePaneToggleEvent, Workbench } from '../../browser/workbench.js';
import { DockedEditorSizeMemento, SinglePaneWorkbench } from '../../browser/singlePaneWorkbench.js';
import { SinglePaneMainEditorPart } from '../../browser/parts/singlePaneEditorPart.js';
import { EditorParts } from '../../browser/parts/editorParts.js';
import { DockedEditorInput } from '../../common/dockedEditorInput.js';
import { EditorInputCapabilities } from '../../../workbench/common/editor.js';
import { GroupDirection, GroupOrientation } from '../../../workbench/services/editor/common/editorGroupsService.js';
import { SESSIONS_LIST_MINIMUM_WIDTH } from '../../browser/parts/sidebarPart.js';
import { Menus } from '../../browser/menus.js';
import { DEFAULT_NOTIFICATION_ROW_HEIGHT, onDidChangeNotificationRowHeight, setNotificationRowHeight } from '../../../workbench/browser/parts/notifications/notificationsViewer.js';
Expand Down Expand Up @@ -1633,6 +1635,81 @@ suite('Sessions - Workbench', () => {
});
});

test('single-pane editor part rejects editor group creation and multi-group layouts', () => {
const group = {};
const addGroup = Reflect.get(SinglePaneMainEditorPart.prototype, 'addGroup') as (location: object, direction: GroupDirection) => object;
const applyLayout = Reflect.get(SinglePaneMainEditorPart.prototype, 'applyLayout') as (layout: { orientation: GroupOrientation; groups: object[] }) => void;

assert.deepStrictEqual({
addGroupResult: addGroup.call({ assertGroupView: () => group }, group, GroupDirection.RIGHT),
multiGroupLayoutRejected: (() => {
applyLayout.call({}, { orientation: GroupOrientation.HORIZONTAL, groups: [{}, {}] });
return true;
})(),
}, {
addGroupResult: group,
multiGroupLayoutRejected: true,
});
});

test('single-pane editor parts reject cross-part group moves and copies', () => {
const mainPart = Object.create(SinglePaneMainEditorPart.prototype) as SinglePaneMainEditorPart;
const auxiliaryPart = {};
const mainGroup = {};
const auxiliaryGroup = {};
const involvesSinglePaneMainPart = Reflect.get(EditorParts.prototype, 'involvesSinglePaneMainPart') as (group: object, location: object) => boolean;
const host = {
mainPart,
getPart: (group: object) => group === mainGroup ? mainPart : auxiliaryPart,
resolveGroup: (group: object) => group,
involvesSinglePaneMainPart,
};
const moveGroup = Reflect.get(EditorParts.prototype, 'moveGroup') as (group: object, location: object, direction: GroupDirection) => object;
const copyGroup = Reflect.get(EditorParts.prototype, 'copyGroup') as (group: object, location: object, direction: GroupDirection) => object;

assert.deepStrictEqual({
moveFromMain: moveGroup.call(host, mainGroup, auxiliaryGroup, GroupDirection.RIGHT),
moveToMain: moveGroup.call(host, auxiliaryGroup, mainGroup, GroupDirection.RIGHT),
copyFromMain: copyGroup.call(host, mainGroup, auxiliaryGroup, GroupDirection.RIGHT),
copyToMain: copyGroup.call(host, auxiliaryGroup, mainGroup, GroupDirection.RIGHT),
}, {
moveFromMain: mainGroup,
moveToMain: auxiliaryGroup,
copyFromMain: mainGroup,
copyToMain: auxiliaryGroup,
});
});

test('single-pane editor retains restored editors when collapsing restored groups', () => {
const firstEditor = { id: 'first' };
const secondEditor = { id: 'second' };
const activeGroup = { editors: [secondEditor], activeEditor: secondEditor };
const sourceGroup = { editors: [firstEditor], activeEditor: firstEditor };
const host = {
count: 2,
activeGroup,
groups: [sourceGroup, activeGroup],
mergeAllGroups(target: typeof activeGroup) {
target.editors.unshift(...sourceGroup.editors);
this.groups = [target];
this.count = 1;
},
};
const ensureSingleEditorGroup = Reflect.get(SinglePaneMainEditorPart.prototype, '_ensureSingleEditorGroup') as () => void;

ensureSingleEditorGroup.call(host);

assert.deepStrictEqual({
groupCount: host.count,
editors: host.activeGroup.editors.map(editor => editor.id),
activeEditor: host.activeGroup.activeEditor.id,
}, {
groupCount: 1,
editors: ['first', 'second'],
activeEditor: 'second',
});
});

test('applies an even split when revealing the docked editor with no captured width even after the initial split', () => {
const host = createHost({ single: true, sessionsWidth: 1000, windowWidth: 1300, hasAppliedInitialEditorSplit: true, dockedWidth: 300, editorWidth: 300, partVisibility: { editor: false, auxiliaryBar: true } });

Expand Down
17 changes: 14 additions & 3 deletions src/vs/workbench/browser/parts/editor/editorDropTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class DropOverlay extends Themable {

constructor(
private readonly groupView: IEditorGroupView,
private readonly supportsSplitting: boolean,
@IThemeService themeService: IThemeService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
Expand Down Expand Up @@ -181,8 +182,8 @@ class DropOverlay extends Themable {
// Position overlay and conditionally enable or disable
// editor group splitting support based on setting and
// keymodifiers used.
let splitOnDragAndDrop = !!this.groupView.groupsView.partOptions.splitOnDragAndDrop;
if (this.isToggleSplitOperation(e)) {
let splitOnDragAndDrop = this.supportsSplitting && !!this.groupView.groupsView.partOptions.splitOnDragAndDrop;
if (this.supportsSplitting && this.isToggleSplitOperation(e)) {
splitOnDragAndDrop = !splitOnDragAndDrop;
}
this.positionOverlay(e.offsetX, e.offsetY, isDraggingGroup, splitOnDragAndDrop);
Expand Down Expand Up @@ -388,6 +389,16 @@ class DropOverlay extends Themable {
const editorControlWidth = this.groupView.element.clientWidth;
const editorControlHeight = this.groupView.element.clientHeight - this.getOverlayOffsetHeight();

if (!enableSplitting) {
this.doPositionOverlay({ top: '0', left: '0', width: '100%', height: '100%' });
this.toggleDropIntoPrompt(true);
const overlay = assertReturnsDefined(this.overlay);
overlay.style.opacity = '1';
setTimeout(() => overlay.classList.add('overlay-move-transition'), 0);
this.currentDropOperation = { splitDirection: undefined };
return;
}

let edgeWidthThresholdFactor: number;
let edgeHeightThresholdFactor: number;
if (enableSplitting) {
Expand Down Expand Up @@ -644,7 +655,7 @@ export class EditorDropTarget extends Themable {
if (!this.overlay) {
const targetGroupView = this.findTargetGroupView(target);
if (targetGroupView) {
this._overlay = this.instantiationService.createInstance(DropOverlay, targetGroupView);
this._overlay = this.instantiationService.createInstance(DropOverlay, targetGroupView, this.delegate.supportsSplitting !== false);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ export interface IEditorDropTargetDelegate {
* A helper to figure out if the drop target contains the provided group.
*/
containsGroup?(groupView: IEditorGroup): boolean;

/**
* Whether the drop target supports creating editor groups.
*/
readonly supportsSplitting?: boolean;
}

/**
Expand Down
Loading