diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 16a7b2f3877563..f57d78acf47259 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -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. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index 53d16f37eecb76..a37e63cec2fe61 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -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). diff --git a/src/vs/sessions/browser/parts/editorParts.ts b/src/vs/sessions/browser/parts/editorParts.ts index 6074b4ce33a22c..238d4fb53a8e26 100644 --- a/src/vs/sessions/browser/parts/editorParts.ts +++ b/src/vs/sessions/browser/parts/editorParts.ts @@ -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'; @@ -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); diff --git a/src/vs/sessions/browser/parts/singlePaneEditorPart.ts b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts index 3fda9ddb79e7d1..6926e1d461558f 100644 --- a/src/vs/sessions/browser/parts/singlePaneEditorPart.ts +++ b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts @@ -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'; @@ -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 { + 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. */ @@ -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; +} diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 92b63fc32989bc..a7ad5c8e7c74ea 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -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'; @@ -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 } }); diff --git a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts index f9c80ed1dd266a..154633ebab7737 100644 --- a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +++ b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts @@ -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, @@ -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); @@ -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) { @@ -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); } } } diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index f3bbd63c2494a1..0897af3ee2dfeb 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -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; } /**