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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions .github/instructions/modal-editor-part.instructions.md
Original file line number Diff line number Diff line change
@@ -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<void>;
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<IModalEditorPart>;
```

## 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
Comment on lines +69 to +76
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

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

The documentation incorrectly states that the modal editor enforces showTabs: 'single' in two places (lines 69 and 76), but the actual implementation in modalEditorPart.ts line 200 enforces showTabs: 'none'. The documentation should be updated to accurately reflect the implementation which hides tabs entirely rather than showing a single tab.

Suggested change
- 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
- Enforces `showTabs: 'none'` 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. **Tabs Hidden**: Modal enforces `showTabs: 'none'` so no tabs are shown

Copilot uses AI. Check for mistakes.
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<IModalEditorPart> {
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
3 changes: 3 additions & 0 deletions src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/vs/workbench/browser/parts/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -195,6 +195,7 @@ export interface IEditorPartsView {
readonly count: number;

createAuxiliaryEditorPart(options?: IAuxiliaryWindowOpenOptions): Promise<IAuxiliaryEditorPart>;
createModalEditorPart(): Promise<IModalEditorPart>;

bind<T extends ContextKeyValue>(contextKey: RawContextKey<T>, group: IEditorGroupView): IContextKey<T>;
}
Expand Down
90 changes: 77 additions & 13 deletions src/vs/workbench/browser/parts/editor/editorParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,14 +14,15 @@ 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';
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';
Expand Down Expand Up @@ -94,20 +95,34 @@ export class EditorParts extends MultiWindowParts<EditorPart, IEditorPartsMement
//#region Scoped Instantiation Services

private readonly mapPartToInstantiationService = new Map<number /* window ID */, IInstantiationService>();
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;
Expand Down Expand Up @@ -137,6 +152,35 @@ export class EditorParts extends MultiWindowParts<EditorPart, IEditorPartsMement

//#endregion

//#region Modal Editor Part

private modalEditorPart: IModalEditorPart | undefined;

async createModalEditorPart(): Promise<IModalEditorPart> {

// 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 {
Expand Down Expand Up @@ -218,6 +262,27 @@ export class EditorParts extends MultiWindowParts<EditorPart, IEditorPartsMement

//#region Helpers

protected override getPartByDocument(document: Document): EditorPart {
if (this._parts.size > 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 {
Expand Down Expand Up @@ -309,14 +374,13 @@ export class EditorParts extends MultiWindowParts<EditorPart, IEditorPartsMement

private createState(): IEditorPartsUIState {
return {
auxiliary: this.parts.filter(part => 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))
};
}
Expand Down
Loading
Loading