diff --git a/.github/instructions/modal-editor-part.instructions.md b/.github/instructions/modal-editor-part.instructions.md new file mode 100644 index 0000000000000..f81d2922ff75a --- /dev/null +++ b/.github/instructions/modal-editor-part.instructions.md @@ -0,0 +1,213 @@ +--- +description: Architecture documentation for VS Code modal editor part. Use when working with modal editor functionality in `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` +applyTo: src/vs/workbench/**/modal*.ts +--- + +# Modal Editor Part Design Document + +This document describes the conceptual design of the Modal Editor Part feature in VS Code. Use this as a reference when working with modal editor functionality. + +## Overview + +The Modal Editor Part is a new editor part concept that displays editors in a modal overlay on top of the workbench. It follows the same architectural pattern as `AUX_WINDOW_GROUP` (auxiliary window editor parts) but renders within the main window as an overlay instead of a separate window. + +## Architecture + +### Constants and Types + +Location: `src/vs/workbench/services/editor/common/editorService.ts` + +```typescript +export const MODAL_GROUP = -4; +export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; +``` + +The `MODAL_GROUP` constant follows the pattern of other special group identifiers: +- `ACTIVE_GROUP = -1` +- `SIDE_GROUP = -2` +- `AUX_WINDOW_GROUP = -3` +- `MODAL_GROUP = -4` + +### Interfaces + +Location: `src/vs/workbench/services/editor/common/editorGroupsService.ts` + +```typescript +export interface IModalEditorPart extends IEditorPart { + readonly onWillClose: Event; + close(): boolean; +} +``` + +The `IModalEditorPart` interface extends `IEditorPart` and adds: +- `onWillClose`: Event fired before the modal closes +- `close()`: Closes the modal, merging confirming editors back to the main part + +### Service Method + +The `IEditorGroupsService` interface includes: + +```typescript +createModalEditorPart(): Promise; +``` + +## Implementation + +### ModalEditorPart Class + +Location: `src/vs/workbench/browser/parts/editor/modalEditorPart.ts` + +The implementation consists of two classes: + +1. **`ModalEditorPart`**: Factory class that creates the modal UI + - Creates modal backdrop with dimmed overlay + - Creates shadow container for the modal window + - Handles layout relative to main container dimensions + - Registers escape key and click-outside handlers for closing + +2. **`ModalEditorPartImpl`**: The actual editor part extending `EditorPart` + - Enforces `showTabs: 'single'` and `closeEmptyGroups: true` + - Overrides `removeGroup` to close modal when last group is removed + - Does not persist state (modal is transient) + - Merges editors back to main part on close + +### Key Behaviors + +1. **Single Tab Mode**: Modal enforces `showTabs: 'single'` for a focused experience +2. **Auto-close on Empty**: When all editors are closed, the modal closes automatically +3. **Merge on Close**: Confirming editors (dirty, etc.) are merged back to main part +4. **Escape to Close**: Pressing Escape closes the modal +5. **Click Outside to Close**: Clicking the dimmed backdrop closes the modal + +### CSS Styling + +Location: `src/vs/workbench/browser/parts/editor/media/modalEditorPart.css` + +```css +.monaco-modal-editor-block { + /* Full-screen overlay with flexbox centering */ +} + +.monaco-modal-editor-block.dimmed { + /* Semi-transparent dark background */ +} + +.modal-editor-shadow { + /* Shadow and border-radius for the modal window */ +} +``` + +## Integration Points + +### EditorParts Service + +Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` + +The `EditorParts` class implements `createModalEditorPart()`: + +```typescript +async createModalEditorPart(): Promise { + const { part, disposables } = await this.instantiationService + .createInstance(ModalEditorPart, this).create(); + + this._onDidAddGroup.fire(part.activeGroup); + + disposables.add(toDisposable(() => { + this._onDidRemoveGroup.fire(part.activeGroup); + })); + + return part; +} +``` + +### Active Part Detection + +Location: `src/vs/workbench/browser/parts/editor/editorParts.ts` + +Override of `getPartByDocument` to detect when focus is in a modal: + +```typescript +protected override getPartByDocument(document: Document): EditorPart { + if (this._parts.size > 1) { + const activeElement = getActiveElement(); + + for (const part of this._parts) { + if (part !== this.mainPart && part.element?.ownerDocument === document) { + const container = part.getContainer(); + if (container && isAncestor(activeElement, container)) { + return part; + } + } + } + } + return super.getPartByDocument(document); +} +``` + +This ensures that when focus is in the modal, it is considered the active part for editor opening via quick open, etc. + +### Editor Group Finder + +Location: `src/vs/workbench/services/editor/common/editorGroupFinder.ts` + +The `findGroup` function handles `MODAL_GROUP`: + +```typescript +else if (preferredGroup === MODAL_GROUP) { + group = editorGroupService.createModalEditorPart() + .then(part => part.activeGroup); +} +``` + +## Usage Examples + +### Opening an Editor in Modal + +```typescript +// Using the editor service +await editorService.openEditor(input, options, MODAL_GROUP); + +// Using a flag pattern (e.g., settings) +interface IOpenSettingsOptions { + openInModal?: boolean; +} + +// Implementation checks the flag +if (options.openInModal) { + group = await findGroup(accessor, {}, MODAL_GROUP); +} +``` + +### Current Integrations + +1. **Settings Editor**: Opens in modal via `openInModal: true` option +2. **Keyboard Shortcuts Editor**: Opens in modal via `openInModal: true` option +3. **Extensions Editor**: Uses `openInModal: true` in `IExtensionEditorOptions` +4. **Profiles Editor**: Opens directly with `MODAL_GROUP` + +## Testing + +Location: `src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts` + +Test categories: +- Constants and types verification +- Creation and initial state +- Editor operations (open, split) +- Closing behavior and events +- Options enforcement +- Integration with EditorParts service + +## Design Decisions + +1. **Why extend EditorPart?**: Reuses all editor group functionality without duplication +2. **Why single tab mode?**: Modal is for focused, single-editor experiences +3. **Why merge on close?**: Prevents data loss for dirty editors +4. **Why same window?**: Avoids complexity of auxiliary windows while providing overlay UX +5. **Why transient state?**: Modal is meant for temporary focused editing, not persistence + +## Future Considerations + +- Consider adding animation for open/close transitions +- Consider size/position customization +- Consider multiple modal stacking (though likely not needed) +- Consider keyboard navigation between modal and main editor areas diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index 5442e4c9f7690..3b9082b1a0ae2 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -439,6 +439,9 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart // Then merge remaining to main part result = this.mergeGroupsToMainPart(); + if (!result) { + return false; // Do not close when editors could not be merged back + } } this._onWillClose.fire(); diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 6ad27655a91d7..9677912440325 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -5,7 +5,7 @@ import { GroupIdentifier, IWorkbenchEditorConfiguration, IEditorIdentifier, IEditorCloseEvent, IEditorPartOptions, IEditorPartOptionsChangeEvent, SideBySideEditor, EditorCloseContext, IEditorPane, IEditorPartLimitOptions, IEditorPartDecorationOptions, IEditorWillOpenEvent, EditorInputWithOptions } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Dimension } from '../../../../base/browser/dom.js'; import { Event } from '../../../../base/common/event.js'; @@ -195,6 +195,7 @@ export interface IEditorPartsView { readonly count: number; createAuxiliaryEditorPart(options?: IAuxiliaryWindowOpenOptions): Promise; + createModalEditorPart(): Promise; bind(contextKey: RawContextKey, group: IEditorGroupView): IContextKey; } diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 4e7ff2d640f45..71864ffb0c290 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; -import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupLocation, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, IEditorGroupContextKeyProvider, IEditorDropTargetDelegate, IEditorGroupsService, IEditorSideGroup, IEditorWorkingSet, IFindGroupScope, IMergeGroupOptions, IEditorWorkingSetOptions, IEditorPart, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { GroupIdentifier, IEditorPartOptions } from '../../../common/editor.js'; @@ -14,6 +14,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { distinct } from '../../../../base/common/arrays.js'; import { AuxiliaryEditorPart, IAuxiliaryEditorPartOpenOptions } from './auxiliaryEditorPart.js'; +import { ModalEditorPart } from './modalEditorPart.js'; import { MultiWindowParts } from '../../part.js'; import { DeferredPromise } from '../../../../base/common/async.js'; import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -21,7 +22,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IAuxiliaryWindowOpenOptions, IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ContextKeyValue, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { isHTMLElement } from '../../../../base/browser/dom.js'; +import { getActiveElement, isAncestor, isHTMLElement } from '../../../../base/browser/dom.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { DeepPartial } from '../../../../base/common/types.js'; @@ -94,20 +95,34 @@ export class EditorParts extends MultiWindowParts(); + private modalPartInstantiationService: IInstantiationService | undefined; getScopedInstantiationService(part: IEditorPart): IInstantiationService { + + // Main Part if (part === this.mainPart) { - if (!this.mapPartToInstantiationService.has(part.windowId)) { - this.instantiationService.invokeFunction(accessor => { + let mainPartInstantiationService = this.mapPartToInstantiationService.get(part.windowId); + if (!mainPartInstantiationService) { + mainPartInstantiationService = this.instantiationService.invokeFunction(accessor => { const editorService = accessor.get(IEditorService); const statusbarService = accessor.get(IStatusbarService); - this.mapPartToInstantiationService.set(part.windowId, this._register(this.mainPart.scopedInstantiationService.createChild(new ServiceCollection( + const mainPartInstantiationService = this._register(this.mainPart.scopedInstantiationService.createChild(new ServiceCollection( [IEditorService, editorService.createScoped(this.mainPart, this._store)], [IStatusbarService, statusbarService.createScoped(statusbarService, this._store)] - )))); + ))); + this.mapPartToInstantiationService.set(part.windowId, mainPartInstantiationService); + + return mainPartInstantiationService; }); } + + return mainPartInstantiationService; + } + + // Modal Part (if opened) + if (part === this.modalEditorPart && this.modalPartInstantiationService) { + return this.modalPartInstantiationService; } return this.mapPartToInstantiationService.get(part.windowId) ?? this.instantiationService; @@ -137,6 +152,35 @@ export class EditorParts extends MultiWindowParts { + + // Reuse existing modal editor part if it exists + if (this.modalEditorPart) { + return this.modalEditorPart; + } + + const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create(); + + // Keep instantiation service and reference to reuse + this.modalEditorPart = part; + this.modalPartInstantiationService = instantiationService; + disposables.add(toDisposable(() => { + this.modalPartInstantiationService = undefined; + this.modalEditorPart = undefined; + })); + + // Events + this._onDidAddGroup.fire(part.activeGroup); + + return part; + } + + //#endregion + //#region Registration override registerPart(part: EditorPart): IDisposable { @@ -218,6 +262,27 @@ export class EditorParts extends MultiWindowParts 1) { + const activeElement = getActiveElement(); + + // Find parts that match the document and check if any + // non-main part contains the active element. This handles + // modal parts that share the same document as the main part. + + for (const part of this._parts) { + if (part !== this.mainPart && part.element?.ownerDocument === document) { + const container = part.getContainer(); + if (container && isAncestor(activeElement, container)) { + return part; + } + } + } + } + + return super.getPartByDocument(document); + } + override getPart(group: IEditorGroupView | GroupIdentifier): EditorPart; override getPart(element: HTMLElement): EditorPart; override getPart(groupOrElement: IEditorGroupView | GroupIdentifier | HTMLElement): EditorPart { @@ -309,14 +374,13 @@ export class EditorParts extends MultiWindowParts part !== this.mainPart).map(part => { - const auxiliaryWindow = this.auxiliaryWindowService.getWindow(part.windowId); - - return { + auxiliary: this.parts + .map(part => ({ part, auxiliaryWindow: this.auxiliaryWindowService.getWindow(part.windowId) })) + .filter(({ auxiliaryWindow }) => auxiliaryWindow !== undefined) + .map(({ part, auxiliaryWindow }) => ({ state: part.createState(), - ...auxiliaryWindow?.createState() - }; - }), + ...auxiliaryWindow!.createState() + })), mru: this.mostRecentActiveParts.map(part => this.parts.indexOf(part)) }; } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css new file mode 100644 index 0000000000000..863dd8acd6a32 --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** Modal Editor Part: Modal Block */ +.monaco-modal-editor-block { + position: fixed; + height: 100%; + width: 100%; + left: 0; + top: 0; + /* z-index cannot be above iframes (50) to support showing them */ + z-index: 40; + display: flex; + justify-content: center; + align-items: center; +} + +.monaco-modal-editor-block.dimmed { + background: rgba(0, 0, 0, 0.3); +} + +/** Modal Editor Part: Shadow Container */ +.monaco-modal-editor-block .modal-editor-shadow { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; +} + +/** Modal Editor Part: Editor Container */ +.monaco-modal-editor-block .modal-editor-part { + display: flex; + flex-direction: column; + min-width: 400px; + min-height: 300px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-radius: 8px; + overflow: hidden; +} + +.monaco-modal-editor-block .modal-editor-part:focus { + outline: none; +} + +/** Modal Editor Part: Header with title and close button */ +.monaco-modal-editor-block .modal-editor-header { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + height: 32px; + min-height: 32px; + padding: 0 8px; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, transparent); +} + +.monaco-modal-editor-block .modal-editor-title { + grid-column: 2; + font-size: 12px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.monaco-modal-editor-block .modal-editor-action-container { + grid-column: 3; + display: flex; + align-items: center; + justify-content: flex-end; +} + +/** Modal Editor Part: Ensure proper sizing */ +.monaco-modal-editor-block .modal-editor-part .content { + flex: 1; + position: relative; + overflow: hidden; +} diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts new file mode 100644 index 0000000000000..54542653a9eed --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/modalEditorPart.css'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { widgetClose } from '../../../../platform/theme/common/iconRegistry.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 { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IEditorGroupView, IEditorPartsView } from './editor.js'; +import { EditorPart } from './editorPart.js'; +import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Verbosity } from '../../../common/editor.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { localize } from '../../../../nls.js'; + +export interface ICreateModalEditorPartResult { + readonly part: ModalEditorPartImpl; + readonly instantiationService: IInstantiationService; + readonly disposables: DisposableStore; +} + +export class ModalEditorPart { + + constructor( + private readonly editorPartsView: IEditorPartsView, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IEditorService private readonly editorService: IEditorService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { + } + + async create(): Promise { + const disposables = new DisposableStore(); + + // Create modal container + const modalElement = $('.monaco-modal-editor-block.dimmed'); + modalElement.tabIndex = -1; + this.layoutService.mainContainer.appendChild(modalElement); + disposables.add(toDisposable(() => modalElement.remove())); + + const shadowElement = modalElement.appendChild($('.modal-editor-shadow')); + + // Create editor part container + const titleId = 'modal-editor-title'; + const editorPartContainer = $('.part.editor.modal-editor-part', { + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': titleId + }); + shadowElement.appendChild(editorPartContainer); + + // Create header with title and close button + const headerElement = editorPartContainer.appendChild($('.modal-editor-header')); + + // Title element (centered) + const titleElement = append(headerElement, $('div.modal-editor-title')); + titleElement.id = titleId; + titleElement.textContent = ''; + + // Action buttons using ActionBar for proper accessibility + const actionBarContainer = append(headerElement, $('div.modal-editor-action-container')); + const actionBar = disposables.add(new ActionBar(actionBarContainer)); + + // Open as Editor + const openAsEditorAction = disposables.add(new Action( + 'modalEditorPart.openAsEditor', + localize('openAsEditor', "Open as Editor"), + ThemeIcon.asClassName(Codicon.openInProduct), + true, + async () => { + const activeEditor = editorPart.activeGroup.activeEditor; + if (activeEditor) { + await this.editorService.openEditor(activeEditor, { pinned: true, preserveFocus: false }, this.editorPartsView.mainPart.activeGroup.id); + editorPart.close(); + } + } + )); + actionBar.push(openAsEditorAction, { icon: true, label: false }); + + // Close action + const closeAction = disposables.add(new Action( + 'modalEditorPart.close', + localize('close', "Close"), + ThemeIcon.asClassName(widgetClose), + true, + async () => editorPart.close() + )); + actionBar.push(closeAction, { icon: true, label: false, keybinding: localize('escape', "Escape") }); + + // Create the editor part + const editorPart = disposables.add(this.instantiationService.createInstance( + ModalEditorPartImpl, + mainWindow.vscodeWindowId, + this.editorPartsView, + localize('modalEditorPart', "Modal Editor Area") + )); + disposables.add(this.editorPartsView.registerPart(editorPart)); + editorPart.create(editorPartContainer); + + // Create scoped instantiation service + const modalEditorService = this.editorService.createScoped(editorPart, disposables); + const scopedInstantiationService = disposables.add(editorPart.scopedInstantiationService.createChild(new ServiceCollection( + [IEditorService, modalEditorService] + ))); + + // Update title when active editor changes + disposables.add(Event.runAndSubscribe(modalEditorService.onDidActiveEditorChange, (() => { + const activeEditor = editorPart.activeGroup.activeEditor; + titleElement.textContent = activeEditor?.getTitle(Verbosity.MEDIUM) ?? ''; + }))); + + // Handle close on click outside (on the dimmed background) + disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => { + if (e.target === modalElement) { + editorPart.close(); + } + })); + + // Handle escape key to close + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Escape) { + editorPart.close(); + } + })); + + // Handle close event from editor part + disposables.add(Event.once(editorPart.onWillClose)(() => { + disposables.dispose(); + })); + + // Layout the modal editor part + disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, () => { + const containerDimension = this.layoutService.mainContainerDimension; + const width = Math.min(containerDimension.width * 0.8, 1200); + const height = Math.min(containerDimension.height * 0.8, 800); + + editorPartContainer.style.width = `${width}px`; + editorPartContainer.style.height = `${height}px`; + + const borderSize = 2; // Account for 1px border on all sides and modal header height + const headerHeight = 35; + editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); + })); + + // Focus the modal + editorPartContainer.focus(); + + return { + part: editorPart, + instantiationService: scopedInstantiationService, + disposables + }; + } +} + +class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { + + private static COUNTER = 1; + + private readonly _onWillClose = this._register(new Emitter()); + readonly onWillClose = this._onWillClose.event; + + private readonly optionsDisposable = this._register(new MutableDisposable()); + + constructor( + windowId: number, + editorPartsView: IEditorPartsView, + groupsLabel: string, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IHostService hostService: IHostService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + const id = ModalEditorPartImpl.COUNTER++; + super(editorPartsView, `workbench.parts.modalEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + + // Enforce some editor part options for modal editors + this.optionsDisposable.value = this.enforcePartOptions({ + showTabs: 'none', + closeEmptyGroups: true, + tabActionCloseVisibility: false, + editorActionsLocation: 'default', + tabHeight: 'default', + wrapTabs: false + }); + } + + override removeGroup(group: number | IEditorGroupView, preserveFocus?: boolean): void { + + // Close modal when last group removed + const groupView = this.assertGroupView(group); + if (this.count === 1 && this.activeGroup === groupView) { + this.doRemoveLastGroup(preserveFocus); + } + + // Otherwise delegate to parent implementation + else { + super.removeGroup(group, preserveFocus); + } + } + + private doRemoveLastGroup(preserveFocus?: boolean): void { + const restoreFocus = !preserveFocus && this.shouldRestoreFocus(this.container); + + // Activate next group + const mostRecentlyActiveGroups = this.editorPartsView.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + const nextActiveGroup = mostRecentlyActiveGroups[1]; // [0] will be the current group we are about to dispose + if (nextActiveGroup) { + nextActiveGroup.groupsView.activateGroup(nextActiveGroup); + + if (restoreFocus) { + nextActiveGroup.focus(); + } + } + + this.doClose(false /* do not merge any confirming editors to main part */); + } + + protected override saveState(): void { + return; // disabled, modal editor part state is not persisted + } + + close(): boolean { + return this.doClose(true /* merge all confirming editors to main part */); + } + + private doClose(mergeConfirmingEditorsToMainPart: boolean): boolean { + let result = true; + if (mergeConfirmingEditorsToMainPart) { + + // First close all editors that are non-confirming + for (const group of this.groups) { + group.closeAllEditors({ excludeConfirming: true }); + } + + // Then merge remaining to main part + result = this.mergeGroupsToMainPart(); + if (!result) { + return false; // Do not close when editors could not be merged back + } + } + + this._onWillClose.fire(); + + return result; + } + + private mergeGroupsToMainPart(): boolean { + if (!this.groups.some(group => group.count > 0)) { + return true; // skip if we have no editors opened + } + + // Find the most recent group that is not locked + let targetGroup: IEditorGroupView | undefined = undefined; + for (const group of this.editorPartsView.mainPart.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + if (!group.isLocked) { + targetGroup = group; + break; + } + } + + if (!targetGroup) { + targetGroup = this.editorPartsView.mainPart.addGroup(this.editorPartsView.mainPart.activeGroup, this.partOptions.openSideBySideDirection === 'right' ? GroupDirection.RIGHT : GroupDirection.DOWN); + } + + const result = this.mergeAllGroups(targetGroup, { + // Try to reduce the impact of closing the modal + // as much as possible by not changing existing editors + // in the main window. + preserveExistingIndex: true + }); + targetGroup.focus(); + + return result; + } +} diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index c7c3f27d5b71f..270f1abdc1750 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -349,6 +349,15 @@ const registry = Registry.as(ConfigurationExtensions.Con 'description': localize('revealIfOpen', "Controls whether an editor is revealed in any of the visible groups if opened. If disabled, an editor will prefer to open in the currently active editor group. If enabled, an already opened editor will be revealed instead of opened again in the currently active editor group. Note that there are some cases where this setting is ignored, such as when forcing an editor to open in a specific group or to the side of the currently active group."), 'default': false }, + 'workbench.editor.allowOpenInModalEditor': { + 'type': 'boolean', + 'description': localize('allowOpenInModalEditor', "Controls whether editors can be opened in a modal overlay. When enabled, certain editors such as Settings and Keyboard Shortcuts may open in a centered modal overlay instead of as a regular editor tab."), + 'default': product.quality !== 'stable', // TODO@bpasero figure out the default for stable + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', 'description': localize('swipeToNavigate', "Navigate between open files using three-finger swipe horizontally. Note that System Preferences > Trackpad > More Gestures > 'Swipe between pages' must be set to 'Swipe with two or three fingers'."), diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts index f3c901e717c92..4a7795926440f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagement.contribution.ts @@ -15,8 +15,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../browser/editor.js'; import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../services/editor/common/editorService.js'; import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CONTEXT_MODELS_EDITOR, CONTEXT_MODELS_SEARCH_FOCUS, MANAGE_CHAT_COMMAND_ID } from '../../common/constants.js'; @@ -141,9 +140,9 @@ class ChatManagementActionsContribution extends Disposable implements IWorkbench }); } async run(accessor: ServicesAccessor, args: string | IOpenManageCopilotEditorActionOptions) { - const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); args = sanitizeOpenManageCopilotEditorArgs(args); - return editorGroupsService.activeGroup.openEditor(new ModelsManagementEditorInput(), { pinned: true }); + return editorService.openEditor(new ModelsManagementEditorInput(), { pinned: true }, MODAL_GROUP); } })); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 23ea0f7354a25..154ed6f780869 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -35,7 +35,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHostService } from '../../../services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, AutoUpdateConfigurationKey, AutoCheckUpdatesConfigurationKey, HasOutdatedExtensionsContext, AutoUpdateConfigurationValue, InstallExtensionOptions, ExtensionRuntimeState, ExtensionRuntimeActionType, AutoRestartConfigurationKey, VIEWLET_ID, IExtensionsViewPaneContainer, IExtensionsNotification } from '../common/extensions.js'; -import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IURLService, IURLHandler, IOpenURLOptions } from '../../../../platform/url/common/url.js'; import { ExtensionsInput, IExtensionEditorOptions } from '../common/extensionsInput.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1563,7 +1563,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!extension) { throw new Error(`Extension not found. ${extension}`); } - await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : ACTIVE_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, MODAL_GROUP); } async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index dc4ed6e1f88c8..be6384268ca1c 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -248,7 +248,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor, args: string | IOpenSettingsActionOptions) { // args takes a string for backcompat const opts = typeof args === 'string' ? { query: args } : sanitizeOpenSettingsArgs(args); - return accessor.get(IPreferencesService).openSettings(opts); + return accessor.get(IPreferencesService).openSettings({ openInModal: true, ...opts }); } })); this._register(registerAction2(class extends Action2 { @@ -846,7 +846,7 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon run(accessor: ServicesAccessor, ...args: unknown[]) { const query = typeof args[0] === 'string' ? args[0] : undefined; const groupId = getEditorGroupFromArguments(accessor, args)?.id; - return accessor.get(IPreferencesService).openGlobalKeybindingSettings(false, { query, groupId }); + return accessor.get(IPreferencesService).openGlobalKeybindingSettings(false, { query, groupId, openInModal: true }); } })); this._register(MenuRegistry.appendMenuItem(MenuId.MenubarPreferencesMenu, { diff --git a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts index 6b18596e49d0e..48652f3e747b6 100644 --- a/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts +++ b/src/vs/workbench/contrib/userDataProfile/browser/userDataProfile.ts @@ -27,6 +27,7 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { UserDataProfilesEditor, UserDataProfilesEditorInput, UserDataProfilesEditorInputSerializer } from './userDataProfilesEditor.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IUserDataProfilesEditor } from '../common/userDataProfile.js'; @@ -55,7 +56,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IWorkspaceTagsService private readonly workspaceTagsService: IWorkspaceTagsService, @IContextKeyService contextKeyService: IContextKeyService, - @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IEditorService private readonly editorService: IEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IURLService private readonly urlService: IURLService, @@ -107,7 +108,7 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements } private async openProfilesEditor(): Promise { - const editor = await this.editorGroupsService.activeGroup.openEditor(new UserDataProfilesEditorInput(this.instantiationService)); + const editor = await this.editorService.openEditor(new UserDataProfilesEditorInput(this.instantiationService), undefined, MODAL_GROUP); return editor as IUserDataProfilesEditor; } @@ -386,9 +387,9 @@ export class UserDataProfilesWorkbenchContribution extends Disposable implements }); } run(accessor: ServicesAccessor) { - const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); const instantiationService = accessor.get(IInstantiationService); - return editorGroupsService.activeGroup.openEditor(new UserDataProfilesEditorInput(instantiationService)); + return editorService.openEditor(new UserDataProfilesEditorInput(instantiationService), undefined, MODAL_GROUP); } })); disposables.add(MenuRegistry.appendMenuItem(MenuId.CommandPalette, { diff --git a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts index d13453552f1b2..1159e4ce41e37 100644 --- a/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts +++ b/src/vs/workbench/contrib/workspace/browser/workspace.contribution.ts @@ -17,7 +17,7 @@ import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService, IWo import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; @@ -747,7 +747,7 @@ registerAction2(class extends Action2 { const input = instantiationService.createInstance(WorkspaceTrustEditorInput); - editorService.openEditor(input, { pinned: true }); + editorService.openEditor(input, { pinned: true }, MODAL_GROUP); return; } }); diff --git a/src/vs/workbench/services/editor/common/editorGroupColumn.ts b/src/vs/workbench/services/editor/common/editorGroupColumn.ts index a11920833366a..b8747a91b2496 100644 --- a/src/vs/workbench/services/editor/common/editorGroupColumn.ts +++ b/src/vs/workbench/services/editor/common/editorGroupColumn.ts @@ -6,7 +6,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { GroupIdentifier } from '../../../common/editor.js'; import { IEditorGroupsService, GroupsOrder, IEditorGroup, preferredSideBySideGroupDirection } from './editorGroupsService.js'; -import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, SIDE_GROUP, SIDE_GROUP_TYPE } from './editorService.js'; +import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, MODAL_GROUP, SIDE_GROUP, SIDE_GROUP_TYPE } from './editorService.js'; /** * A way to address editor groups through a column based system @@ -16,7 +16,7 @@ import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP, SIDE_GROUP, SIDE_GRO export type EditorGroupColumn = number; export function columnToEditorGroup(editorGroupService: IEditorGroupsService, configurationService: IConfigurationService, column = ACTIVE_GROUP): GroupIdentifier | ACTIVE_GROUP_TYPE | SIDE_GROUP_TYPE { - if (column === ACTIVE_GROUP || column === SIDE_GROUP || column === AUX_WINDOW_GROUP) { + if (column === ACTIVE_GROUP || column === SIDE_GROUP || column === AUX_WINDOW_GROUP || column === MODAL_GROUP) { return column; // return early for when column is well known } diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index afcc959e8b162..1b93908f2f76e 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -9,19 +9,19 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService } from './editorGroupsService.js'; -import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; +import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; /** * Finds the target `IEditorGroup` given the instructions provided * that is best for the editor and matches the preferred group if * possible. */ -export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; -export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; -export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: Exclude | undefined): [IEditorGroup, EditorActivation | undefined]; +export function findGroup(accessor: ServicesAccessor, editor: IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; +export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE): Promise<[IEditorGroup, EditorActivation | undefined]>; export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined): Promise<[IEditorGroup, EditorActivation | undefined]> | [IEditorGroup, EditorActivation | undefined]; export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined): Promise<[IEditorGroup, EditorActivation | undefined]> | [IEditorGroup, EditorActivation | undefined] { const editorGroupService = accessor.get(IEditorGroupsService); @@ -99,6 +99,12 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer }).then(group => group.activeGroup); } + // Group: Modal (gated behind a setting) + else if (preferredGroup === MODAL_GROUP && configurationService.getValue('workbench.editor.allowOpenInModalEditor')) { + group = editorGroupService.createModalEditorPart() + .then(part => part.activeGroup); + } + // Group: Unspecified without a specific index to open else if (!options || typeof options.index !== 'number') { const groupsByLastActive = editorGroupService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 59e86cc19cc75..a0185e64ef769 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -502,6 +502,22 @@ export interface IAuxiliaryEditorPart extends IEditorPart { close(): boolean; } +export interface IModalEditorPart extends IEditorPart { + + /** + * Fired when this modal editor part is about to close. + */ + readonly onWillClose: Event; + + /** + * Close this modal editor part after moving all + * editors of all groups back to the main editor part. + * + * @returns `false` if an editor could not be moved back. + */ + close(): boolean; +} + export interface IEditorWorkingSet { readonly id: string; readonly name: string; @@ -567,6 +583,15 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { */ createAuxiliaryEditorPart(options?: { bounds?: Partial; compact?: boolean; alwaysOnTop?: boolean }): Promise; + /** + * Creates a modal editor part that shows in a modal overlay + * on top of the main workbench window. + * + * If a modal part already exists, it will be returned + * instead of creating a new one. + */ + createModalEditorPart(): Promise; + /** * Returns the instantiation service that is scoped to the * provided editor part. Use this method when building UI diff --git a/src/vs/workbench/services/editor/common/editorService.ts b/src/vs/workbench/services/editor/common/editorService.ts index ce8157a8c35f4..5c7b3802c2e92 100644 --- a/src/vs/workbench/services/editor/common/editorService.ts +++ b/src/vs/workbench/services/editor/common/editorService.ts @@ -34,7 +34,13 @@ export type SIDE_GROUP_TYPE = typeof SIDE_GROUP; export const AUX_WINDOW_GROUP = -3; export type AUX_WINDOW_GROUP_TYPE = typeof AUX_WINDOW_GROUP; -export type PreferredGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE; +/** + * Open an editor in a modal overlay on top of the workbench. + */ +export const MODAL_GROUP = -4; +export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; + +export type PreferredGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE; export function isPreferredGroup(obj: unknown): obj is PreferredGroup { const candidate = obj as PreferredGroup | undefined; diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts new file mode 100644 index 0000000000000..c20d6b95d326e --- /dev/null +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -0,0 +1,376 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { workbenchInstantiationService, registerTestEditor, TestFileEditorInput, createEditorParts } from '../../../../test/browser/workbenchTestServices.js'; +import { GroupsOrder, IEditorGroupsService } from '../../common/editorGroupsService.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../common/editor.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { MockScopableContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEditorInput.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; + +suite('Modal Editor Group', () => { + + const TEST_EDITOR_ID = 'MyFileEditorForModalEditorGroup'; + const TEST_EDITOR_INPUT_ID = 'testEditorInputForModalEditorGroup'; + + const disposables = new DisposableStore(); + + setup(() => { + disposables.add(registerTestEditor(TEST_EDITOR_ID, [new SyncDescriptor(TestFileEditorInput), new SyncDescriptor(SideBySideEditorInput)], TEST_EDITOR_INPUT_ID)); + }); + + teardown(() => { + disposables.clear(); + }); + + function createTestFileEditorInput(resource: URI, typeId: string): TestFileEditorInput { + return disposables.add(new TestFileEditorInput(resource, typeId)); + } + + test('MODAL_GROUP constant is defined correctly', () => { + assert.strictEqual(MODAL_GROUP, -4); + assert.strictEqual(typeof MODAL_GROUP, 'number'); + }); + + test('MODAL_GROUP_TYPE type exists', () => { + const modalGroupValue: MODAL_GROUP_TYPE = MODAL_GROUP; + assert.strictEqual(modalGroupValue, -4); + }); + + test('createModalEditorPart creates a modal editor part', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.ok(modalPart); + assert.ok(modalPart.activeGroup); + assert.strictEqual(typeof modalPart.close, 'function'); + + modalPart.close(); + }); + + test('modal editor part has correct initial state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal part should have exactly one group initially with 0 editors + assert.strictEqual(modalPart.activeGroup.count, 0); + + modalPart.close(); + }); + + test('modal editor part can open editors', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.strictEqual(modalPart.activeGroup.count, 1); + assert.strictEqual(modalPart.activeGroup.activeEditor, input); + + modalPart.close(); + }); + + test('modal editor part is added to parts list', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const initialGroupCount = parts.groups.length; + + const modalPart = await parts.createModalEditorPart(); + + // Modal part's group should be added to the total groups + assert.strictEqual(parts.groups.length, initialGroupCount + 1); + + modalPart.close(); + }); + + test('closing modal part fires onWillClose event', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Verify onWillClose is an event that can be listened to + assert.ok(typeof modalPart.onWillClose === 'function'); + assert.ok(modalPart.onWillClose !== undefined); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Verify close returns true + const result = modalPart.close(); + assert.strictEqual(result, true); + }); + + test('modal editor part close returns true when no confirming editors', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + const result = modalPart.close(); + + assert.strictEqual(result, true); + }); + + test('modal editor part getGroups returns groups in correct order', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Modal part group should be in the groups list + const allGroups = parts.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + const modalGroup = modalPart.activeGroup; + + assert.ok(allGroups.some(g => g.id === modalGroup.id)); + + modalPart.close(); + }); + + test('modal editor part is singleton - subsequent calls return same instance', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart1 = await parts.createModalEditorPart(); + const modalPart2 = await parts.createModalEditorPart(); + + // Same instance should be returned + assert.ok(modalPart1); + assert.ok(modalPart2); + assert.strictEqual(modalPart1, modalPart2); + assert.strictEqual(modalPart1.activeGroup.id, modalPart2.activeGroup.id); + + modalPart1.close(); + }); + + test('modal editor part singleton is reset after close', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create first modal + const modalPart1 = await parts.createModalEditorPart(); + const firstGroupId = modalPart1.activeGroup.id; + + // Close it + modalPart1.close(); + + // Create another modal - should be a new instance + const modalPart2 = await parts.createModalEditorPart(); + + // Should be a different group + assert.notStrictEqual(modalPart2.activeGroup.id, firstGroupId); + + modalPart2.close(); + }); + + test('modal editor part onDidAddGroup fires only once for singleton', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + let addGroupCount = 0; + disposables.add(parts.onDidAddGroup(() => { + addGroupCount++; + })); + + // Create modal twice + await parts.createModalEditorPart(); + await parts.createModalEditorPart(); + + // onDidAddGroup should fire only once since it's a singleton + assert.strictEqual(addGroupCount, 1); + + (await parts.createModalEditorPart()).close(); + }); + + test('modal editor part enforces no tabs mode', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal parts should enforce no tabs mode + assert.strictEqual(modalPart.partOptions.showTabs, 'none'); + + modalPart.close(); + }); + + test('modal editor part enforces closeEmptyGroups', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + // Modal parts should enforce closeEmptyGroups + assert.strictEqual(modalPart.partOptions.closeEmptyGroups, true); + + modalPart.close(); + }); + + test('closing all editors in modal removes the modal group', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + const modalGroupId = modalPart.activeGroup.id; + + // The modal group should exist in parts + assert.ok(parts.getGroup(modalGroupId)); + + // Closing the last editor in the last group should close the modal + // which removes the group from parts + await modalPart.activeGroup.closeAllEditors(); + + // The modal group should no longer exist in parts + assert.strictEqual(parts.getGroup(modalGroupId), undefined); + }); + + test('modal editor part does not persist state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Modal part should have saveState as a no-op (we can't directly test this, + // but we verify the modal was created successfully which means state handling works) + assert.ok(modalPart.activeGroup); + + modalPart.close(); + }); + + test('activePart returns modal when focused', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // Focus the modal group + modalPart.activeGroup.focus(); + + // The modal group should be included in the groups + const groups = parts.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + assert.ok(groups.some(g => g.id === modalPart.activeGroup.id)); + + modalPart.close(); + }); + + test('modal part group can be found by id', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const modalGroup = modalPart.activeGroup; + const foundGroup = parts.getGroup(modalGroup.id); + + assert.ok(foundGroup); + assert.strictEqual(foundGroup!.id, modalGroup.id); + + modalPart.close(); + }); + + test('onDidAddGroup fires when modal is created', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + let addedGroupId: number | undefined; + disposables.add(parts.onDidAddGroup(group => { + addedGroupId = group.id; + })); + + const modalPart = await parts.createModalEditorPart(); + + assert.ok(addedGroupId !== undefined); + assert.strictEqual(addedGroupId, modalPart.activeGroup.id); + + modalPart.close(); + }); + + test('onDidRemoveGroup fires when modal is closed', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const modalGroupId = modalPart.activeGroup.id; + + let removedGroupId: number | undefined; + disposables.add(parts.onDidRemoveGroup(group => { + removedGroupId = group.id; + })); + + modalPart.close(); + + assert.ok(removedGroupId !== undefined); + assert.strictEqual(removedGroupId, modalGroupId); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index 069e1db04be42..e7f4c5c2262c3 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -29,8 +29,8 @@ import { DEFAULT_EDITOR_ASSOCIATION, IEditorPane } from '../../../common/editor. import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { IJSONEditingService } from '../../configuration/common/jsonEditing.js'; -import { GroupDirection, IEditorGroup, IEditorGroupsService } from '../../editor/common/editorGroupsService.js'; -import { IEditorService, SIDE_GROUP } from '../../editor/common/editorService.js'; +import { GroupDirection, IEditorGroupsService } from '../../editor/common/editorGroupsService.js'; +import { ACTIVE_GROUP, IEditorService, MODAL_GROUP, PreferredGroup, SIDE_GROUP } from '../../editor/common/editorService.js'; import { KeybindingsEditorInput } from './keybindingsEditorInput.js'; import { DEFAULT_SETTINGS_EDITOR_SETTING, FOLDER_SETTINGS_PATH, IKeybindingsEditorPane, IOpenKeybindingsEditorOptions, IOpenSettingsOptions, IPreferencesEditorModel, IPreferencesService, ISetting, ISettingsEditorOptions, ISettingsGroup, SETTINGS_AUTHORITY, USE_SPLIT_JSON_SETTING, validateSettingsEditorOptions } from '../common/preferences.js'; import { PreferencesEditorInput, SettingsEditor2Input } from '../common/preferencesEditorInput.js'; @@ -48,7 +48,6 @@ import { IURLService } from '../../../../platform/url/common/url.js'; import { compareIgnoreCase } from '../../../../base/common/strings.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; -import { findGroup } from '../../editor/common/editorGroupFinder.js'; const emptyEditableSettingsContent = '{\n}'; @@ -215,7 +214,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic } async openPreferences(): Promise { - await this.editorGroupService.activeGroup.openEditor(this.instantiationService.createInstance(PreferencesEditorInput)); + await this.editorService.openEditor(this.instantiationService.createInstance(PreferencesEditorInput)); } openSettings(options: IOpenSettingsOptions = {}): Promise { @@ -274,8 +273,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic ...options, focusSearch: true }; - const group = await this.getEditorGroupFromOptions(options); - return group.openEditor(input, validateSettingsEditorOptions(options)); + const group = this.getEditorGroupFromOptions(options); + return this.editorService.openEditor(input, validateSettingsEditorOptions(options), group); } openApplicationSettings(options: IOpenSettingsOptions = {}): Promise { @@ -359,7 +358,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic } } else { - const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, options.groupId)) as IKeybindingsEditorPane; + const group = this.getEditorGroupFromOptions(options); + const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, group)) as IKeybindingsEditorPane; if (options.query) { editor.search(options.query); } @@ -371,16 +371,21 @@ export class PreferencesService extends Disposable implements IPreferencesServic return this.editorService.openEditor({ resource: this.defaultKeybindingsResource, label: nls.localize('defaultKeybindings', "Default Keybindings") }); } - private async getEditorGroupFromOptions(options: IOpenSettingsOptions): Promise { - let group = options?.groupId !== undefined ? this.editorGroupService.getGroup(options.groupId) ?? this.editorGroupService.activeGroup : this.editorGroupService.activeGroup; + private getEditorGroupFromOptions(options: { groupId?: number; openInModal?: boolean; openToSide?: boolean }): PreferredGroup { if (options.openToSide) { - group = (await this.instantiationService.invokeFunction(findGroup, {}, SIDE_GROUP))[0]; + return SIDE_GROUP; } - return group; + if (options.openInModal) { + return MODAL_GROUP; + } + if (options?.groupId !== undefined) { + return this.editorGroupService.getGroup(options.groupId) ?? this.editorGroupService.activeGroup; + } + return ACTIVE_GROUP; } private async openSettingsJson(resource: URI, options: IOpenSettingsOptions): Promise { - const group = await this.getEditorGroupFromOptions(options); + const group = this.getEditorGroupFromOptions(options); const editor = await this.doOpenSettingsJson(resource, options, group); if (editor && options?.revealSetting) { await this.revealSetting(options.revealSetting.key, !!options.revealSetting.edit, editor, resource); @@ -388,7 +393,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic return editor; } - private async doOpenSettingsJson(resource: URI, options: ISettingsEditorOptions, group: IEditorGroup): Promise { + private async doOpenSettingsJson(resource: URI, options: ISettingsEditorOptions, group: PreferredGroup): Promise { const openSplitJSON = !!this.configurationService.getValue(USE_SPLIT_JSON_SETTING); const openDefaultSettings = !!this.configurationService.getValue(DEFAULT_SETTINGS_EDITOR_SETTING); if (openSplitJSON || openDefaultSettings) { @@ -398,15 +403,15 @@ export class PreferencesService extends Disposable implements IPreferencesServic const configurationTarget = options?.target ?? ConfigurationTarget.USER; const editableSettingsEditorInput = await this.getOrCreateEditableSettingsEditorInput(configurationTarget, resource); options = { ...options, pinned: true }; - return await group.openEditor(editableSettingsEditorInput, { ...validateSettingsEditorOptions(options) }); + return await this.editorService.openEditor(editableSettingsEditorInput, { ...validateSettingsEditorOptions(options) }, group); } - private async doOpenSplitJSON(resource: URI, options: ISettingsEditorOptions = {}, group: IEditorGroup,): Promise { + private async doOpenSplitJSON(resource: URI, options: ISettingsEditorOptions = {}, group: PreferredGroup,): Promise { const configurationTarget = options.target ?? ConfigurationTarget.USER; await this.createSettingsIfNotExists(configurationTarget, resource); const preferencesEditorInput = this.createSplitJsonEditorInput(configurationTarget, resource); options = { ...options, pinned: true }; - return group.openEditor(preferencesEditorInput, validateSettingsEditorOptions(options)); + return this.editorService.openEditor(preferencesEditorInput, validateSettingsEditorOptions(options), group); } public createSplitJsonEditorInput(configurationTarget: ConfigurationTarget, resource: URI): EditorInput { diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f487988b..c6ffca6aad9f6 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -222,6 +222,7 @@ export interface ISettingsEditorOptions extends IEditorOptions { export interface IOpenSettingsOptions extends ISettingsEditorOptions { jsonEditor?: boolean; openToSide?: boolean; + openInModal?: boolean; groupId?: number; } @@ -245,6 +246,7 @@ export interface IKeybindingsEditorOptions extends IEditorOptions { export interface IOpenKeybindingsEditorOptions extends IKeybindingsEditorOptions { groupId?: number; + openInModal?: boolean; } export const IPreferencesService = createDecorator('preferencesService'); diff --git a/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts b/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts index 77c36590b8a36..422974d30a012 100644 --- a/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts +++ b/src/vs/workbench/services/preferences/test/browser/preferencesService.test.ts @@ -10,16 +10,16 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IURLService } from '../../../../../platform/url/common/url.js'; -import { DEFAULT_EDITOR_ASSOCIATION, IEditorPane } from '../../../../common/editor.js'; +import { DEFAULT_EDITOR_ASSOCIATION, isEditorInput, IUntypedEditorInput } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IJSONEditingService } from '../../../configuration/common/jsonEditing.js'; import { TestJSONEditingService } from '../../../configuration/test/common/testServices.js'; +import { IEditorService, PreferredGroup } from '../../../editor/common/editorService.js'; import { PreferencesService } from '../../browser/preferencesService.js'; import { IPreferencesService, ISettingsEditorOptions } from '../../common/preferences.js'; import { IRemoteAgentService } from '../../../remote/common/remoteAgentService.js'; -import { TestRemoteAgentService, ITestInstantiationService, workbenchInstantiationService, TestEditorGroupView, TestEditorGroupsService } from '../../../../test/browser/workbenchTestServices.js'; -import { IEditorGroupsService } from '../../../editor/common/editorGroupsService.js'; +import { TestRemoteAgentService, ITestInstantiationService, workbenchInstantiationService, TestEditorService } from '../../../../test/browser/workbenchTestServices.js'; import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; -import { SettingsEditor2Input } from '../../common/preferencesEditorInput.js'; suite('PreferencesService', () => { let testInstantiationService: ITestInstantiationService; @@ -30,17 +30,18 @@ suite('PreferencesService', () => { setup(() => { testInstantiationService = workbenchInstantiationService({}, disposables); - class TestOpenEditorGroupView extends TestEditorGroupView { - lastOpenEditorOptions: any; - override openEditor(_editor: SettingsEditor2Input, options?: IEditorOptions): Promise { - lastOpenEditorOptions = options; - _editor.dispose(); - return Promise.resolve(undefined!); + class TestPreferencesEditorService extends TestEditorService { + override async openEditor(editor: EditorInput | IUntypedEditorInput, optionsOrGroup?: IEditorOptions | PreferredGroup, group?: PreferredGroup): Promise { + lastOpenEditorOptions = optionsOrGroup as IEditorOptions; + // openEditor takes ownership of the input + if (isEditorInput(editor)) { + editor.dispose(); + } + return undefined; } } - const testEditorGroupService = new TestEditorGroupsService([new TestOpenEditorGroupView(0)]); - testInstantiationService.stub(IEditorGroupsService, testEditorGroupService); + testInstantiationService.stub(IEditorService, disposables.add(new TestPreferencesEditorService())); testInstantiationService.stub(IJSONEditingService, TestJSONEditingService); testInstantiationService.stub(IRemoteAgentService, TestRemoteAgentService); testInstantiationService.stub(ICommandService, TestCommandService); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index cccd1d1ec6f95..271301ca45285 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -148,7 +148,7 @@ import { CodeEditorService } from '../../services/editor/browser/codeEditorServi import { EditorPaneService } from '../../services/editor/browser/editorPaneService.js'; import { EditorResolverService } from '../../services/editor/browser/editorResolverService.js'; import { CustomEditorLabelService, ICustomEditorLabelService } from '../../services/editor/common/customEditorLabelService.js'; -import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions } from '../../services/editor/common/editorGroupsService.js'; +import { EditorGroupLayout, GroupDirection, GroupOrientation, GroupsArrangement, GroupsOrder, IAuxiliaryEditorPart, ICloseAllEditorsOptions, ICloseEditorOptions, ICloseEditorsFilter, IEditorDropTargetDelegate, IEditorGroup, IEditorGroupContextKeyProvider, IEditorGroupsContainer, IEditorGroupsService, IEditorPart, IEditorReplacement, IEditorWorkingSet, IEditorWorkingSetOptions, IFindGroupScope, IMergeGroupOptions, IModalEditorPart } from '../../services/editor/common/editorGroupsService.js'; import { IEditorPaneService } from '../../services/editor/common/editorPaneService.js'; import { IEditorResolverService } from '../../services/editor/common/editorResolverService.js'; import { IEditorsChangeEvent, IEditorService, IRevertAllEditorsOptions, ISaveEditorsOptions, ISaveEditorsResult, PreferredGroup } from '../../services/editor/common/editorService.js'; @@ -923,6 +923,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { readonly mainPart = this; registerEditorPart(part: any): IDisposable { return Disposable.None; } createAuxiliaryEditorPart(): Promise { throw new Error('Method not implemented.'); } + createModalEditorPart(): Promise { throw new Error('Method not implemented.'); } } export class TestEditorGroupView implements IEditorGroupView { @@ -1662,6 +1663,10 @@ export class TestEditorPart extends MainEditorPart implements IEditorGroupsServi throw new Error('Method not implemented.'); } + createModalEditorPart(): Promise { + throw new Error('Method not implemented.'); + } + getScopedInstantiationService(part: IEditorPart): IInstantiationService { throw new Error('Method not implemented.'); }