modal - tweaks to editor and extensions handling (#295542)

This commit is contained in:
Benjamin Pasero
2026-02-16 13:27:04 +01:00
committed by GitHub
parent dfec5880f8
commit a9b50c8372
14 changed files with 591 additions and 121 deletions

View File

@@ -320,6 +320,41 @@ export interface IEditorOptions {
*/
alwaysOnTop?: boolean;
};
/**
* Options that only apply when `MODAL_GROUP` is used for opening.
*/
modal?: IModalEditorPartOptions;
}
export interface IModalEditorPartOptions {
/**
* The navigation context for navigating between items
* within this modal editor. Pass `undefined` to clear.
*/
readonly navigation?: IModalEditorNavigation;
}
/**
* Context for navigating between items within a modal editor.
*/
export interface IModalEditorNavigation {
/**
* Total number of items in the navigation list.
*/
readonly total: number;
/**
* Current 0-based index in the navigation list.
*/
readonly current: number;
/**
* Navigate to the item at the given 0-based index.
*/
readonly navigate: (index: number) => void;
}
export interface ITextEditorSelection {

View File

@@ -27,7 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet
import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js';
import { SideBySideEditor } from './sideBySideEditor.js';
import { TextDiffEditor } from './textDiffEditor.js';
import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js';
import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js';
import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js';
import { EditorInput } from '../../../common/editor/editorInput.js';
import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js';
@@ -108,6 +108,8 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit
export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor';
export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain';
export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized';
export const NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID = 'workbench.action.navigateModalEditorPrevious';
export const NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID = 'workbench.action.navigateModalEditorNext';
export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open';
export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff';
@@ -1500,6 +1502,64 @@ function registerModalEditorCommands(): void {
}
}
});
registerAction2(class extends Action2 {
constructor() {
super({
id: NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID,
title: localize2('navigateModalEditorPrevious', 'Navigate to Previous Item in Modal Editor'),
category: Categories.View,
precondition: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext),
keybinding: {
primary: KeyMod.Alt | KeyCode.UpArrow,
weight: KeybindingWeight.WorkbenchContrib + 10,
when: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext)
}
});
}
run(accessor: ServicesAccessor): void {
const editorGroupsService = accessor.get(IEditorGroupsService);
for (const part of editorGroupsService.parts) {
if (isModalEditorPart(part)) {
const nav = part.navigation;
if (nav && nav.current > 0) {
nav.navigate(nav.current - 1);
}
break;
}
}
}
});
registerAction2(class extends Action2 {
constructor() {
super({
id: NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID,
title: localize2('navigateModalEditorNext', 'Navigate to Next Item in Modal Editor'),
category: Categories.View,
precondition: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext),
keybinding: {
primary: KeyMod.Alt | KeyCode.DownArrow,
weight: KeybindingWeight.WorkbenchContrib + 10,
when: ContextKeyExpr.and(EditorPartModalContext, EditorPartModalNavigationContext)
}
});
}
run(accessor: ServicesAccessor): void {
const editorGroupsService = accessor.get(IEditorGroupsService);
for (const part of editorGroupsService.parts) {
if (isModalEditorPart(part)) {
const nav = part.navigation;
if (nav && nav.current < nav.total - 1) {
nav.navigate(nav.current + 1);
}
break;
}
}
}
});
}
function isModalEditorPart(obj: unknown): obj is IModalEditorPart {
@@ -1510,6 +1570,8 @@ function isModalEditorPart(obj: unknown): obj is IModalEditorPart {
&& typeof part.onWillClose === 'function'
&& typeof part.toggleMaximized === 'function'
&& typeof part.maximized === 'boolean'
&& typeof part.updateOptions === 'function'
&& !!part.modalElement
&& part.windowId === mainWindow.vscodeWindowId;
}

View File

@@ -28,6 +28,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js
import { DeepPartial } from '../../../../base/common/types.js';
import { IStatusbarService } from '../../../services/statusbar/browser/statusbar.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js';
interface IEditorPartsUIState {
readonly auxiliary: IAuxiliaryEditorPartState[];
@@ -157,14 +158,16 @@ export class EditorParts extends MultiWindowParts<EditorPart, IEditorPartsMement
private modalEditorPart: IModalEditorPart | undefined;
get activeModalEditorPart(): IModalEditorPart | undefined { return this.modalEditorPart; }
async createModalEditorPart(): Promise<IModalEditorPart> {
async createModalEditorPart(options?: IModalEditorPartOptions): Promise<IModalEditorPart> {
// Reuse existing modal editor part if it exists
if (this.modalEditorPart) {
this.modalEditorPart.updateOptions(options);
return this.modalEditorPart;
}
const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create();
const { part, instantiationService, disposables } = await this.instantiationService.createInstance(ModalEditorPart, this).create(options);
// Keep instantiation service and reference to reuse
this.modalEditorPart = part;

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/** Modal Editor Part: Modal Block */
/** Modal Background Block */
.monaco-modal-editor-block {
position: fixed;
width: 100%;
@@ -17,20 +17,16 @@
align-items: center;
/* Never allow content to escape above the title bar */
overflow: hidden;
}
.monaco-modal-editor-block.dimmed {
background: rgba(0, 0, 0, 0.3);
.modal-editor-shadow {
box-shadow: 0 4px 32px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.2));
border-radius: 8px;
overflow: hidden;
}
}
/** Modal Editor Part: Shadow Container */
.monaco-modal-editor-block .modal-editor-shadow {
box-shadow: 0 4px 32px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.2));
border-radius: 8px;
overflow: hidden;
}
/** Modal Editor Part: Editor Container */
/** Modal Editor Container */
.monaco-modal-editor-block .modal-editor-part {
display: flex;
flex-direction: column;
@@ -40,13 +36,19 @@
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder));
border-radius: 8px;
overflow: hidden;
&:focus {
outline: none;
}
.content {
flex: 1;
position: relative;
overflow: hidden;
}
}
.monaco-modal-editor-block .modal-editor-part:focus {
outline: none;
}
/** Modal Editor Part: Header with title and close button */
/** Modal Editor Header */
.monaco-modal-editor-block .modal-editor-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
@@ -57,36 +59,75 @@
color: var(--vscode-titleBar-activeForeground);
background-color: var(--vscode-titleBar-activeBackground);
border-bottom: 1px solid var(--vscode-titleBar-border, transparent);
}
.monaco-modal-editor-block .modal-editor-title {
grid-column: 1;
font-size: 12px;
font-weight: 500;
color: var(--vscode-titleBar-activeForeground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Modal Editor Title */
.modal-editor-title {
grid-column: 1;
font-size: 12px;
font-weight: 500;
color: var(--vscode-titleBar-activeForeground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.monaco-modal-editor-block .modal-editor-action-container {
grid-column: 3;
display: flex;
align-items: center;
justify-content: flex-end;
}
/* Modal Editor Navigation */
.modal-editor-navigation {
grid-column: 2;
display: flex;
align-items: center;
height: 22px;
border-radius: 4px;
border: 1px solid var(--vscode-commandCenter-border, transparent);
overflow: hidden;
user-select: none;
-webkit-user-select: none;
.monaco-modal-editor-block .modal-editor-action-container .actions-container {
gap: 4px;
}
.modal-editor-nav-button.monaco-button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: none;
color: inherit;
opacity: 0.7;
}
.monaco-modal-editor-block .modal-editor-action-container .actions-container .codicon {
color: inherit;
}
.modal-editor-nav-button.monaco-button:hover:not(.disabled) {
opacity: 1;
background-color: var(--vscode-commandCenter-activeBackground);
}
/** Modal Editor Part: Ensure proper sizing */
.monaco-modal-editor-block .modal-editor-part .content {
flex: 1;
position: relative;
overflow: hidden;
.modal-editor-nav-button.monaco-button.disabled {
opacity: 0.3;
}
.modal-editor-nav-label {
font-size: 11px;
font-variant-numeric: tabular-nums;
opacity: 0.7;
white-space: nowrap;
padding: 0 6px;
}
}
/* Modal Editor Actions */
.modal-editor-action-container {
grid-column: 3;
display: flex;
align-items: center;
justify-content: flex-end;
.actions-container {
gap: 4px;
.codicon {
color: inherit;
}
}
}
}

View File

@@ -4,8 +4,9 @@
*--------------------------------------------------------------------------------------------*/
import './media/modalEditorPart.css';
import { $, addDisposableListener, append, EventHelper, EventType, isHTMLElement } from '../../../../base/browser/dom.js';
import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, show } from '../../../../base/browser/dom.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
@@ -23,13 +24,15 @@ import { IEditorGroupView, IEditorPartsView } from './editor.js';
import { EditorPart } from './editorPart.js';
import { GroupDirection, GroupsOrder, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js';
import { EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext } from '../../../common/contextkeys.js';
import { Verbosity } from '../../../common/editor.js';
import { IHostService } from '../../../services/host/browser/host.js';
import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { localize } from '../../../../nls.js';
import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js';
import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js';
const defaultModalEditorAllowableCommands = new Set([
'workbench.action.quit',
@@ -40,7 +43,9 @@ const defaultModalEditorAllowableCommands = new Set([
'workbench.action.files.saveAll',
CLOSE_MODAL_EDITOR_COMMAND_ID,
MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID,
TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID
TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID,
NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID,
NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID,
]);
export interface ICreateModalEditorPartResult {
@@ -61,17 +66,50 @@ export class ModalEditorPart {
) {
}
async create(): Promise<ICreateModalEditorPartResult> {
async create(options?: IModalEditorPartOptions): Promise<ICreateModalEditorPartResult> {
const disposables = new DisposableStore();
// Create modal container
const modalElement = $('.monaco-modal-editor-block.dimmed');
// Modal container
const modalElement = $('.monaco-modal-editor-block');
this.layoutService.mainContainer.appendChild(modalElement);
disposables.add(toDisposable(() => modalElement.remove()));
disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => {
if (e.target === modalElement) {
EventHelper.stop(e, true);
// Guide focus back into the modal when clicking outside modal
editorPartContainer.focus();
}
}));
disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => {
const event = new StandardKeyboardEvent(e);
// Close on Escape
if (event.equals(KeyCode.Escape)) {
EventHelper.stop(event, true);
editorPart.close();
}
// Prevent unsupported commands
else {
const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer);
if (resolved.kind === ResultKind.KbFound && resolved.commandId) {
if (
resolved.commandId.startsWith('workbench.') &&
!defaultModalEditorAllowableCommands.has(resolved.commandId)
) {
EventHelper.stop(event, true);
}
}
}
}));
const shadowElement = modalElement.appendChild($('.modal-editor-shadow'));
// Create editor part container
// Editor part container
const titleId = 'modal-editor-title';
const editorPartContainer = $('.part.editor.modal-editor-part', {
role: 'dialog',
@@ -81,7 +119,7 @@ export class ModalEditorPart {
});
shadowElement.appendChild(editorPartContainer);
// Create header with title and close button
// Header
const headerElement = editorPartContainer.appendChild($('.modal-editor-header'));
// Title element
@@ -89,7 +127,35 @@ export class ModalEditorPart {
titleElement.id = titleId;
titleElement.textContent = '';
// Action buttons
// Navigation widget
const navigationContainer = append(headerElement, $('div.modal-editor-navigation'));
hide(navigationContainer);
disposables.add(addDisposableListener(navigationContainer, EventType.DBLCLICK, e => EventHelper.stop(e, true)));
const previousButton = disposables.add(new Button(navigationContainer, { title: localize('previousItem', "Previous") }));
previousButton.icon = Codicon.chevronLeft;
previousButton.element.classList.add('modal-editor-nav-button');
disposables.add(previousButton.onDidClick(() => {
const navigation = editorPart.navigation;
if (navigation && navigation.current > 0) {
navigation.navigate(navigation.current - 1);
}
}));
const navigationLabel = append(navigationContainer, $('span.modal-editor-nav-label'));
navigationLabel.setAttribute('aria-live', 'polite');
const nextButton = disposables.add(new Button(navigationContainer, { title: localize('nextItem', "Next") }));
nextButton.icon = Codicon.chevronRight;
nextButton.element.classList.add('modal-editor-nav-button');
disposables.add(nextButton.onDidClick(() => {
const navigation = editorPart.navigation;
if (navigation && navigation.current < navigation.total - 1) {
navigation.navigate(navigation.current + 1);
}
}));
// Toolbar
const actionBarContainer = append(headerElement, $('div.modal-editor-action-container'));
// Create the editor part
@@ -98,10 +164,23 @@ export class ModalEditorPart {
mainWindow.vscodeWindowId,
this.editorPartsView,
modalElement,
options,
));
disposables.add(this.editorPartsView.registerPart(editorPart));
editorPart.create(editorPartContainer);
disposables.add(Event.once(editorPart.onWillClose)(() => disposables.dispose()));
disposables.add(Event.runAndSubscribe(editorPart.onDidChangeNavigation, ((navigation: IModalEditorNavigation | undefined) => {
if (navigation && navigation.total > 1) {
show(navigationContainer);
navigationLabel.textContent = localize('navigationCounter', "{0} of {1}", navigation.current + 1, navigation.total);
previousButton.enabled = navigation.current > 0;
nextButton.enabled = navigation.current < navigation.total - 1;
} else {
hide(navigationContainer);
}
}), editorPart.navigation));
// Create scoped instantiation service
const modalEditorService = this.editorService.createScoped(editorPart, disposables);
const scopedInstantiationService = disposables.add(editorPart.scopedInstantiationService.createChild(new ServiceCollection(
@@ -132,44 +211,6 @@ export class ModalEditorPart {
editorPart.toggleMaximized();
}));
// Guide focus back into the modal when clicking outside modal
disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => {
if (e.target === modalElement) {
EventHelper.stop(e, true);
editorPartContainer.focus();
}
}));
// Block certain workbench commands from being dispatched while the modal is open
disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => {
const event = new StandardKeyboardEvent(e);
// Close on Escape
if (event.equals(KeyCode.Escape)) {
EventHelper.stop(event, true);
editorPart.close();
}
// Prevent unsupported commands
else {
const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer);
if (resolved.kind === ResultKind.KbFound && resolved.commandId) {
if (
resolved.commandId.startsWith('workbench.') &&
!defaultModalEditorAllowableCommands.has(resolved.commandId)
) {
EventHelper.stop(event, true);
}
}
}
}));
// Handle close event from editor part
disposables.add(Event.once(editorPart.onWillClose)(() => {
disposables.dispose();
}));
// Layout the modal editor part
const layoutModal = () => {
@@ -231,9 +272,15 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
private readonly _onDidChangeMaximized = this._register(new Emitter<boolean>());
readonly onDidChangeMaximized = this._onDidChangeMaximized.event;
private readonly _onDidChangeNavigation = this._register(new Emitter<IModalEditorNavigation | undefined>());
readonly onDidChangeNavigation = this._onDidChangeNavigation.event;
private _maximized = false;
get maximized(): boolean { return this._maximized; }
private _navigation: IModalEditorNavigation | undefined;
get navigation(): IModalEditorNavigation | undefined { return this._navigation; }
private readonly optionsDisposable = this._register(new MutableDisposable());
private previousMainWindowActiveElement: Element | null = null;
@@ -241,7 +288,8 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
constructor(
windowId: number,
editorPartsView: IEditorPartsView,
private readonly modalElement: HTMLElement,
public readonly modalElement: HTMLElement,
options: IModalEditorPartOptions | undefined,
@IInstantiationService instantiationService: IInstantiationService,
@IThemeService themeService: IThemeService,
@IConfigurationService configurationService: IConfigurationService,
@@ -253,11 +301,9 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
const id = ModalEditorPartImpl.COUNTER++;
super(editorPartsView, `workbench.parts.modalEditor.${id}`, localize('modalEditorPart', "Modal Editor Area"), windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService);
this.enforceModalPartOptions();
}
this._navigation = options?.navigation;
getModalElement() {
return this.modalElement;
this.enforceModalPartOptions();
}
override create(parent: HTMLElement, options?: object): void {
@@ -270,6 +316,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
const editorCount = this.groups.reduce((count, group) => count + group.count, 0);
this.optionsDisposable.value = this.enforcePartOptions({
showTabs: editorCount > 1 ? 'multiple' : 'none',
enablePreview: true,
closeEmptyGroups: true,
tabActionCloseVisibility: editorCount > 1,
editorActionsLocation: 'default',
@@ -283,6 +330,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
this.enforceModalPartOptions();
}
updateOptions(options?: IModalEditorPartOptions): void {
this._navigation = options?.navigation;
this._onDidChangeNavigation.fire(options?.navigation);
}
toggleMaximized(): void {
this._maximized = !this._maximized;
@@ -297,6 +350,10 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
isMaximizedContext.set(this._maximized);
this._register(this.onDidChangeMaximized(maximized => isMaximizedContext.set(maximized)));
const hasNavigationContext = EditorPartModalNavigationContext.bindTo(this.scopedContextKeyService);
hasNavigationContext.set(!!this._navigation && this._navigation.total > 1);
this._register(this.onDidChangeNavigation(navigation => hasNavigationContext.set(!!navigation && navigation.total > 1)));
super.handleContextKeys();
}
@@ -398,4 +455,10 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart {
return result;
}
override dispose(): void {
this._navigation = undefined; // ensure to free the reference to the navigation closure
super.dispose();
}
}

View File

@@ -96,6 +96,7 @@ export const EditorPartMaximizedEditorGroupContext = new RawContextKey<boolean>(
export const EditorPartModalContext = new RawContextKey<boolean>('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part"));
export const EditorPartModalMaximizedContext = new RawContextKey<boolean>('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized"));
export const EditorPartModalNavigationContext = new RawContextKey<boolean>('editorPartModalNavigation', false, localize('editorPartModalNavigation', "Whether the modal editor part has navigation context"));
// Editor Layout Context Keys
export const EditorsVisibleContext = new RawContextKey<boolean>('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open"));

View File

@@ -94,8 +94,8 @@ import { fromNow } from '../../../../base/common/date.js';
class NavBar extends Disposable {
private _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>());
get onChange(): Event<{ id: string | null; focus: boolean }> { return this._onChange.event; }
private readonly _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>());
readonly onChange = this._onChange.event;
private _currentId: string | null = null;
get currentId(): string | null { return this._currentId; }
@@ -142,6 +142,11 @@ class NavBar extends Disposable {
this._onChange.fire({ id, focus: !!focus });
this.actions.forEach(a => a.checked = a.id === id);
}
override dispose(): void {
this.clear();
super.dispose();
}
}
interface ILayoutParticipant {

View File

@@ -5,7 +5,7 @@
import * as dom from '../../../../base/browser/dom.js';
import { localize } from '../../../../nls.js';
import { IDisposable, dispose, Disposable, DisposableStore, toDisposable, isDisposable } from '../../../../base/common/lifecycle.js';
import { IDisposable, dispose, Disposable, DisposableStore, toDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { Action, ActionRunner, IAction, Separator } from '../../../../base/common/actions.js';
import { IExtensionsWorkbenchService, IExtension, IExtensionsViewState } from '../common/extensions.js';
import { Event } from '../../../../base/common/event.js';
@@ -16,7 +16,8 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte
import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js';
import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js';
import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js';
import { isNonEmptyArray } from '../../../../base/common/arrays.js';
import { Delegate, Renderer } from './extensionsList.js';
import { listFocusForeground, listFocusBackground, foreground, editorBackground } from '../../../../platform/theme/common/colorRegistry.js';
@@ -35,6 +36,8 @@ import { INotificationService } from '../../../../platform/notification/common/n
import { getLocationBasedViewColors } from '../../../browser/parts/views/viewPane.js';
import { DelayedPagedModel, IPagedModel } from '../../../../base/common/paging.js';
import { ExtensionIconWidget } from './extensionsWidgets.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { isCancellationError } from '../../../../base/common/errors.js';
function getAriaLabelForExtension(extension: IExtension | null): string {
if (!extension) {
@@ -51,6 +54,8 @@ export class ExtensionsList extends Disposable {
readonly list: WorkbenchPagedList<IExtension>;
private readonly contextMenuActionRunner = this._register(new ActionRunner());
private readonly modalNavigationDisposable = this._register(new MutableDisposable());
constructor(
parent: HTMLElement,
viewId: string,
@@ -63,6 +68,7 @@ export class ExtensionsList extends Disposable {
@IContextMenuService private readonly contextMenuService: IContextMenuService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILogService private readonly logService: ILogService
) {
super();
this._register(this.contextMenuActionRunner.onDidRun(({ error }) => error && notificationService.error(error)));
@@ -115,7 +121,17 @@ export class ExtensionsList extends Disposable {
private openExtension(extension: IExtension, options: { sideByside?: boolean; preserveFocus?: boolean; pinned?: boolean }): void {
extension = this.extensionsWorkbenchService.local.filter(e => areSameExtensions(e.identifier, extension.identifier))[0] || extension;
this.extensionsWorkbenchService.open(extension, options);
this.extensionsWorkbenchService.open(extension, {
...options,
modal: options.sideByside ? undefined : buildModalNavigationForPagedList(
extension,
() => this.list.model,
(extA, extB) => areSameExtensions(extA.identifier, extB.identifier),
(ext, modal) => this.extensionsWorkbenchService.open(ext, { pinned: false, modal }),
this.modalNavigationDisposable,
this.logService
),
});
}
private async onContextMenu(e: IListContextMenuEvent<IExtension>): Promise<void> {
@@ -453,6 +469,80 @@ export async function getExtensions(extensions: string[], extensionsWorkbenchSer
return result;
}
/**
* Builds modal navigation options for navigating items in a paged list model.
*/
export function buildModalNavigationForPagedList<T>(
openedItem: T,
getModel: () => IPagedModel<T> | undefined,
isSame: (a: T, b: T) => boolean,
openItem: (item: T, modal: IModalEditorPartOptions) => void,
cancellationStore: MutableDisposable<IDisposable>,
logService: ILogService
): IModalEditorPartOptions | undefined {
const model = getModel();
if (!model) {
return undefined;
}
const total = model.length;
if (total <= 1) {
return undefined;
}
// Find the index of the opened item in the list
let current = -1;
for (let i = 0; i < total; i++) {
if (model.isResolved(i) && isSame(model.get(i), openedItem)) {
current = i;
break;
}
}
if (current === -1) {
return undefined;
}
const openAtIndex = (index: number, item: T) => {
const currentTotal = getModel()?.length ?? 0;
openItem(item, { navigation: { total: currentTotal, current: index, navigate } });
};
let cts: CancellationTokenSource | undefined;
const navigate = (index: number) => {
cts?.cancel();
cts = cancellationStore.value = new CancellationTokenSource();
const token = cts.token;
const currentModel = getModel();
if (!currentModel || index < 0 || index >= currentModel.length) {
return;
}
// Fast path: item already resolved
if (currentModel.isResolved(index)) {
openAtIndex(index, currentModel.get(index));
}
// Slow path: resolve the item first
else {
currentModel.resolve(index, token).then(item => {
if (token.isCancellationRequested) {
return;
}
openAtIndex(index, item);
}, error => {
if (!isCancellationError(error)) {
logService.error(`Error while resolving item at index ${index} for modal navigation`, error);
}
});
}
};
return { navigation: { total, current, navigate } };
}
registerThemingParticipant((theme: IColorTheme, collector: ICssStyleCollector) => {
const focusBackground = theme.getColor(listFocusBackground);
if (focusBackground) {

View File

@@ -9,7 +9,7 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { createMarkdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js';
import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable } from '../../../../base/common/lifecycle.js';
import { combinedDisposable, Disposable, DisposableStore, dispose, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { DelayedPagedModel, IPagedModel, PagedModel, IterativePagedModel } from '../../../../base/common/paging.js';
import { localize, localize2 } from '../../../../nls.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
@@ -52,6 +52,8 @@ import { IMcpGalleryManifestService, McpGalleryManifestStatus } from '../../../.
import { ProductQualityContext } from '../../../../platform/contextkey/common/contextkeys.js';
import { SeverityIcon } from '../../../../base/browser/ui/severityIcon/severityIcon.js';
import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { buildModalNavigationForPagedList } from '../../extensions/browser/extensionsViewer.js';
export interface McpServerListViewOptions {
showWelcome?: boolean;
@@ -81,6 +83,7 @@ export class McpServersListView extends AbstractExtensionsListView<IWorkbenchMcp
mcpServersList: HTMLElement;
} | undefined;
private readonly contextMenuActionRunner = this._register(new ActionRunner());
private readonly modalNavigationDisposable = this._register(new MutableDisposable());
private input: IQueryResult | undefined;
constructor(
@@ -100,6 +103,7 @@ export class McpServersListView extends AbstractExtensionsListView<IWorkbenchMcp
@IMcpGalleryManifestService protected readonly mcpGalleryManifestService: IMcpGalleryManifestService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IMarkdownRendererService protected readonly markdownRendererService: IMarkdownRendererService,
@ILogService private readonly logService: ILogService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
}
@@ -161,7 +165,17 @@ export class McpServersListView extends AbstractExtensionsListView<IWorkbenchMcp
openOnSingleClick: true,
}) as WorkbenchPagedList<IWorkbenchMcpServer>);
this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => {
this.mcpWorkbenchService.open(options.element!, options.editorOptions);
this.mcpWorkbenchService.open(options.element!, {
...options.editorOptions,
modal: options.sideBySide ? undefined : buildModalNavigationForPagedList(
options.element!,
() => this.list?.model,
(serverA, serverB) => serverA.id === serverB.id,
(server, modal) => this.mcpWorkbenchService.open(server, { pinned: false, modal }),
this.modalNavigationDisposable,
this.logService
),
});
}));
this._register(this.list.onContextMenu(e => this.onContextMenu(e), this));

View File

@@ -110,7 +110,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview {
// Webviews cannot be reparented in the dom as it will destroy their contents.
// Mount them to a high level node to avoid this depending on the active container.
const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.getModalElement();
const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement;
let root: HTMLElement;
if (isHTMLElement(modalEditorContainer)) {
root = modalEditorContainer;

View File

@@ -195,7 +195,7 @@ export class WebviewEditor extends EditorPane {
return;
}
const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.getModalElement();
const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.modalElement;
let clippingContainer: HTMLElement | undefined;
if (isHTMLElement(modalEditorContainer)) {
clippingContainer = modalEditorContainer;

View File

@@ -119,16 +119,13 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer
// Group: Aux Window
else if (preferredGroup === AUX_WINDOW_GROUP) {
group = editorGroupService.createAuxiliaryEditorPart({
bounds: options?.auxiliary?.bounds,
compact: options?.auxiliary?.compact,
alwaysOnTop: options?.auxiliary?.alwaysOnTop
}).then(group => group.activeGroup);
group = editorGroupService.createAuxiliaryEditorPart(options?.auxiliary)
.then(group => group.activeGroup);
}
// Group: Modal (gated behind a setting)
else if (preferredGroup === MODAL_GROUP && configurationService.getValue<boolean>('workbench.editor.allowOpenInModalEditor')) {
group = editorGroupService.createModalEditorPart()
group = editorGroupService.createModalEditorPart(options?.modal)
.then(part => part.activeGroup);
}

View File

@@ -7,7 +7,7 @@ import { Event } from '../../../../base/common/event.js';
import { IInstantiationService, createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { IEditorPane, GroupIdentifier, EditorInputWithOptions, CloseDirection, IEditorPartOptions, IEditorPartOptionsChangeEvent, EditorsOrder, IVisibleEditorPane, IEditorCloseEvent, IUntypedEditorInput, isEditorInput, IEditorWillMoveEvent, IMatchEditorOptions, IActiveEditorChangeEvent, IFindEditorOptions, IToolbarActions } from '../../../common/editor.js';
import { EditorInput } from '../../../common/editor/editorInput.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IEditorOptions, IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IDimension } from '../../../../editor/common/core/2d/dimension.js';
import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
@@ -524,9 +524,9 @@ export interface IAuxiliaryEditorPart extends IEditorPart {
export interface IModalEditorPart extends IEditorPart {
/**
* Fired when this modal editor part is about to close.
* Modal container of the editor part.
*/
readonly onWillClose: Event<void>;
readonly modalElement: unknown /* HTMLElement */;
/**
* Whether the modal editor part is currently maximized.
@@ -544,9 +544,19 @@ export interface IModalEditorPart extends IEditorPart {
toggleMaximized(): void;
/**
* Modal container of the editor part.
* The current navigation context, if any.
*/
getModalElement(): unknown /* HTMLElement */;
readonly navigation: IModalEditorNavigation | undefined;
/**
* Update options for the modal editor part.
*/
updateOptions(options?: IModalEditorPartOptions): void;
/**
* Fired when this modal editor part is about to close.
*/
readonly onWillClose: Event<void>;
/**
* Close this modal editor part after moving all
@@ -631,7 +641,7 @@ export interface IEditorGroupsService extends IEditorGroupsContainer {
* If a modal part already exists, it will be returned
* instead of creating a new one.
*/
createModalEditorPart(): Promise<IModalEditorPart>;
createModalEditorPart(options?: IModalEditorPartOptions): Promise<IModalEditorPart>;
/**
* The currently active modal editor part, if any.

View File

@@ -0,0 +1,149 @@
/*---------------------------------------------------------------------------------------------
* 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 { Emitter } from '../../../../../base/common/event.js';
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../../platform/editor/common/editor.js';
/**
* Simple test harness that mimics the ModalEditorPartImpl navigation behavior
* without requiring the full editor part infrastructure.
*/
class TestModalEditorNavigationHost {
private readonly _onDidChangeNavigation = new Emitter<IModalEditorNavigation | undefined>();
readonly onDidChangeNavigation = this._onDidChangeNavigation.event;
private _navigation: IModalEditorNavigation | undefined;
get navigation(): IModalEditorNavigation | undefined { return this._navigation; }
updateOptions(options: IModalEditorPartOptions): void {
this._navigation = options.navigation;
this._onDidChangeNavigation.fire(options.navigation);
}
dispose(): void {
this._onDidChangeNavigation.dispose();
}
}
suite('Modal Editor Navigation', () => {
const disposables = new DisposableStore();
teardown(() => disposables.clear());
ensureNoDisposablesAreLeakedInTestSuite();
test('updateOptions sets navigation and fires event', () => {
const host = new TestModalEditorNavigationHost();
disposables.add({ dispose: () => host.dispose() });
const events: (IModalEditorNavigation | undefined)[] = [];
disposables.add(host.onDidChangeNavigation(ctx => events.push(ctx)));
const nav: IModalEditorNavigation = {
total: 10,
current: 3,
navigate: () => { }
};
host.updateOptions({ navigation: nav });
assert.strictEqual(host.navigation, nav);
assert.deepStrictEqual(events, [nav]);
});
test('updateOptions with undefined navigation clears navigation', () => {
const host = new TestModalEditorNavigationHost();
disposables.add({ dispose: () => host.dispose() });
const events: (IModalEditorNavigation | undefined)[] = [];
disposables.add(host.onDidChangeNavigation(ctx => events.push(ctx)));
const nav: IModalEditorNavigation = {
total: 5,
current: 0,
navigate: () => { }
};
host.updateOptions({ navigation: nav });
host.updateOptions({ navigation: undefined });
assert.strictEqual(host.navigation, undefined);
assert.deepStrictEqual(events, [nav, undefined]);
});
test('navigate callback updates context', () => {
const host = new TestModalEditorNavigationHost();
disposables.add({ dispose: () => host.dispose() });
const navigatedIndices: number[] = [];
const navigate = (index: number) => {
navigatedIndices.push(index);
// Simulates what real navigation does: update the context with new index
host.updateOptions({ navigation: { total: 10, current: index, navigate } });
};
host.updateOptions({ navigation: { total: 10, current: 0, navigate } });
// Navigate forward
host.navigation!.navigate(1);
assert.strictEqual(host.navigation!.current, 1);
host.navigation!.navigate(5);
assert.strictEqual(host.navigation!.current, 5);
assert.deepStrictEqual(navigatedIndices, [1, 5]);
});
test('navigation boundary conditions', () => {
const host = new TestModalEditorNavigationHost();
disposables.add({ dispose: () => host.dispose() });
const navigate = (index: number) => {
if (index >= 0 && index < 3) {
host.updateOptions({ navigation: { total: 3, current: index, navigate } });
}
};
host.updateOptions({ navigation: { total: 3, current: 0, navigate } });
// At first item
assert.strictEqual(host.navigation!.current, 0);
assert.ok(host.navigation!.current <= 0); // previous disabled
// Navigate to last
host.navigation!.navigate(2);
assert.strictEqual(host.navigation!.current, 2);
assert.ok(host.navigation!.current >= host.navigation!.total - 1); // next disabled
// Navigate back to middle
host.navigation!.navigate(1);
assert.strictEqual(host.navigation!.current, 1);
});
test('navigation context fires multiple events', () => {
const host = new TestModalEditorNavigationHost();
disposables.add({ dispose: () => host.dispose() });
let eventCount = 0;
disposables.add(host.onDidChangeNavigation(() => eventCount++));
const navigate = (index: number) => {
host.updateOptions({ navigation: { total: 5, current: index, navigate } });
};
host.updateOptions({ navigation: { total: 5, current: 0, navigate } });
host.navigation!.navigate(1);
host.navigation!.navigate(2);
host.updateOptions({ navigation: undefined });
assert.strictEqual(eventCount, 4); // initial + 2 navigates + clear
});
});