From 6e6b7e0cded5af62c5c5cf6f180622ce9ca71a70 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 6 Aug 2026 12:22:34 +0200 Subject: [PATCH 1/5] Add EditorInputCapabilities.CannotClose for editors that cannot be closed via standard user actions Fixes #329370 Introduces a new editor input capability that allows an editor to opt out of being closed through standard user-initiated actions (tab close button, middle click, Close Editor / Close All / Close Group commands and keybindings, and toolbar close/unpin affordances) while preserving internal/programmatic force-close paths such as shutdown, window reload, workspace transitions, editor replacement and restore failure handling. - EditorGroupView.closeEditor/closeEditors/closeAllEditors now filter out non-closeable editors for user-facing calls, while a new `force`/internal option lets lifecycle call sites (state reapplication, cross-window group moves, auxiliary window merge) bypass the restriction. - Mixed selections/bulk operations close whatever is closeable and leave the rest open rather than failing the whole operation. - Editor replacement continues to work for CannotClose editors since it is considered an internal operation. - Tab close/unpin actions, context menu items and toolbar items are hidden for CannotClose editors via a new ActiveEditorCannotCloseContext context key, avoiding misleading close affordances. - Middle-click tab closing is a no-op for CannotClose editors. - CloseAllEditorGroupsAction only removes a group once it is actually empty, so a group containing a CannotClose editor is not disposed. - Added targeted tests covering individual close, bulk close/close all, mixed sets, replacement, and group-close semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/auxiliaryEditorPart.ts | 2 +- .../parts/editor/editor.contribution.ts | 20 ++-- .../workbench/browser/parts/editor/editor.ts | 6 + .../browser/parts/editor/editorActions.ts | 8 +- .../browser/parts/editor/editorGroupView.ts | 29 +++-- .../browser/parts/editor/editorPart.ts | 4 +- .../browser/parts/editor/editorParts.ts | 2 +- .../browser/parts/editor/editorTabsControl.ts | 5 +- .../parts/editor/multiEditorTabsControl.ts | 7 +- .../parts/editor/singleEditorTabsControl.ts | 4 +- src/vs/workbench/common/contextkeys.ts | 1 + src/vs/workbench/common/editor.ts | 12 +- .../editor/common/editorGroupsService.ts | 3 +- .../test/browser/editorGroupsService.test.ts | 109 ++++++++++++++++++ 14 files changed, 182 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index fcbeaa4a75396..0bfef71dc2575 100644 --- a/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts @@ -450,7 +450,7 @@ class AuxiliaryEditorPartImpl extends EditorPart implements IAuxiliaryEditorPart // First close all editors that are non-confirming for (const group of this.groups) { - group.closeAllEditors({ excludeConfirming: true }); + group.closeAllEditors({ excludeConfirming: true, force: true }); } // Then merge remaining to main part diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 52e0d7f5e72ae..f53c3fe987550 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -11,7 +11,7 @@ import { TextCompareEditorActiveContext, ActiveEditorPinnedContext, EditorGroupEditorsCountContext, ActiveEditorStickyContext, ActiveEditorAvailableEditorIdsContext, EditorPartMultipleEditorGroupsContext, ActiveEditorDirtyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, EditorTabsVisibleContext, ActiveEditorLastInGroupContext, EditorPartMaximizedEditorGroupContext, MultipleEditorGroupsContext, InEditorZenModeContext, - IsAuxiliaryWindowContext, ActiveCompareEditorCanSwapContext, MultipleEditorsSelectedInGroupContext, SplitEditorsVertically, + IsAuxiliaryWindowContext, ActiveCompareEditorCanSwapContext, MultipleEditorsSelectedInGroupContext, SplitEditorsVertically, ActiveEditorCannotCloseContext, IsSessionsWindowContext, ActiveCustomEditorDiffCanToggleLayoutContext, ActiveCustomEditorTextDiffContext, EditorPartModalContext } from '../../../common/contextkeys.js'; import { SideBySideEditorInput, SideBySideEditorInputSerializer } from '../../../common/editor/sideBySideEditorInput.js'; @@ -391,11 +391,11 @@ MenuRegistry.appendMenuItem(MenuId.EditorActionsPositionSubmenu, { command: { id MenuRegistry.appendMenuItem(MenuId.EditorTabsBarContext, { command: { id: ConfigureEditorTabsAction.ID, title: localize('configureTabs', "Configure Tabs") }, group: '9_configure', order: 10 }); // Editor Title Context Menu -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITOR_COMMAND_ID, title: localize('close', "Close") }, group: '1_close', order: 10 }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeOthers', "Close Others"), precondition: EditorGroupEditorsCountContext.notEqualsTo('1') }, group: '1_close', order: 20 }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_TO_THE_RIGHT_COMMAND_ID, title: localize('closeRight', "Close to the Right"), precondition: ContextKeyExpr.and(ActiveEditorLastInGroupContext.toNegated(), MultipleEditorsSelectedInGroupContext.negate()) }, group: '1_close', order: 30, when: EditorTabsVisibleContext }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '1_close', order: 40 }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '1_close', order: 50 }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITOR_COMMAND_ID, title: localize('close', "Close") }, group: '1_close', order: 10, when: ActiveEditorCannotCloseContext.toNegated() }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeOthers', "Close Others"), precondition: EditorGroupEditorsCountContext.notEqualsTo('1') }, group: '1_close', order: 20, when: ActiveEditorCannotCloseContext.toNegated() }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_TO_THE_RIGHT_COMMAND_ID, title: localize('closeRight', "Close to the Right"), precondition: ContextKeyExpr.and(ActiveEditorLastInGroupContext.toNegated(), MultipleEditorsSelectedInGroupContext.negate()) }, group: '1_close', order: 30, when: ContextKeyExpr.and(EditorTabsVisibleContext, ActiveEditorCannotCloseContext.toNegated()) }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '1_close', order: 40, when: ActiveEditorCannotCloseContext.toNegated() }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '1_close', order: 50, when: ActiveEditorCannotCloseContext.toNegated() }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: REOPEN_WITH_COMMAND_ID, title: localize('reopenWith', "Reopen Editor With...") }, group: '1_open', order: 10, when: ActiveEditorAvailableEditorIdsContext }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: KEEP_EDITOR_COMMAND_ID, title: localize('keepOpen', "Keep Open"), precondition: ActiveEditorPinnedContext.toNegated() }, group: '3_preview', order: 10, when: ContextKeyExpr.has('config.workbench.editor.enablePreview') }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: PIN_EDITOR_COMMAND_ID, title: localize('pin', "Pin") }, group: '3_preview', order: 20, when: ActiveEditorStickyContext.toNegated() }); @@ -539,7 +539,7 @@ appendEditorToolItem( title: localize('close', "Close"), icon: Codicon.close }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext.toNegated()), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext.toNegated(), ActiveEditorCannotCloseContext.toNegated()), CLOSE_ORDER, { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, @@ -555,7 +555,7 @@ appendEditorToolItem( title: localize('close', "Close"), icon: Codicon.closeDirty }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext.toNegated()), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext.toNegated(), ActiveEditorCannotCloseContext.toNegated()), CLOSE_ORDER, { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, @@ -571,7 +571,7 @@ appendEditorToolItem( title: localize('unpin', "Unpin"), icon: Codicon.pinned }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext, ActiveEditorCannotCloseContext.toNegated()), CLOSE_ORDER, { id: CLOSE_EDITOR_COMMAND_ID, @@ -587,7 +587,7 @@ appendEditorToolItem( title: localize('unpin', "Unpin"), icon: Codicon.pinnedDirty }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext, ActiveEditorCannotCloseContext.toNegated()), CLOSE_ORDER, { id: CLOSE_EDITOR_COMMAND_ID, diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 7928e0cde8576..ace6c86c53cd8 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -445,6 +445,12 @@ export interface IInternalEditorCloseOptions extends IInternalEditorTitleControl * Additional context as to why an editor is closed. */ readonly context?: EditorCloseContext; + + /** + * Forces the editor to close even if it declares + * `EditorInputCapabilities.CannotClose`. + */ + readonly force?: boolean; } export interface IInternalMoveCopyOptions extends IInternalEditorOpenOptions { diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index 183883989d87c..fd73f570db049 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -585,6 +585,10 @@ abstract class AbstractCloseAllAction extends Action2 { const editorsWithCustomConfirm = new Map>(); for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL, { excludeSticky: this.excludeSticky })) { + if (editor.hasCapability(EditorInputCapabilities.CannotClose)) { + continue; + } + let confirmClose = false; let handlerDidError = false; if (editor.closeHandler) { @@ -801,7 +805,9 @@ export class CloseAllEditorGroupsAction extends AbstractCloseAllAction { await super.doCloseAll(editorGroupService); for (const groupToClose of this.groupsToClose(editorGroupService)) { - editorGroupService.removeGroup(groupToClose); + if (groupToClose.count === 0) { + editorGroupService.removeGroup(groupToClose); + } } } } diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index ace30b01755ed..090319358cef2 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -6,7 +6,7 @@ import './media/editorgroupview.css'; import { EditorGroupModel, IEditorOpenOptions, IGroupModelChangeEvent, ISerializedEditorGroupModel, isGroupEditorCloseEvent, isGroupEditorOpenEvent, isSerializedEditorGroupModel } from '../../../common/editor/editorGroupModel.js'; import { GroupIdentifier, CloseDirection, IEditorCloseEvent, IEditorPane, SaveReason, IEditorPartOptionsChangeEvent, EditorsOrder, IVisibleEditorPane, EditorResourceAccessor, EditorInputCapabilities, IUntypedEditorInput, DEFAULT_EDITOR_ASSOCIATION, SideBySideEditor, EditorCloseContext, IEditorWillMoveEvent, IEditorWillOpenEvent, IMatchEditorOptions, GroupModelChangeKind, IActiveEditorChangeEvent, IFindEditorOptions, TEXT_DIFF_EDITOR_ID } from '../../../common/editor.js'; -import { ActiveEditorGroupLockedContext, ActiveEditorDirtyContext, EditorGroupEditorsCountContext, ActiveEditorStickyContext, ActiveEditorPinnedContext, ActiveEditorLastInGroupContext, ActiveEditorFirstInGroupContext, ResourceContextKey, applyAvailableEditorIds, ActiveEditorAvailableEditorIdsContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveEditorContext, ActiveEditorReadonlyContext, ActiveEditorCanRevertContext, ActiveEditorCanToggleReadonlyContext, ActiveCompareEditorCanSwapContext, MultipleEditorsSelectedInGroupContext, TwoEditorsSelectedInGroupContext, SelectedEditorsInGroupFileOrUntitledResourceContextKey } from '../../../common/contextkeys.js'; +import { ActiveEditorGroupLockedContext, ActiveEditorDirtyContext, EditorGroupEditorsCountContext, ActiveEditorStickyContext, ActiveEditorPinnedContext, ActiveEditorLastInGroupContext, ActiveEditorFirstInGroupContext, ResourceContextKey, applyAvailableEditorIds, ActiveEditorAvailableEditorIdsContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveEditorContext, ActiveEditorReadonlyContext, ActiveEditorCanRevertContext, ActiveEditorCanToggleReadonlyContext, ActiveCompareEditorCanSwapContext, MultipleEditorsSelectedInGroupContext, TwoEditorsSelectedInGroupContext, SelectedEditorsInGroupFileOrUntitledResourceContextKey, ActiveEditorCannotCloseContext } from '../../../common/contextkeys.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { Emitter, Event, Relay } from '../../../../base/common/event.js'; @@ -283,6 +283,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { const groupActiveEditorAvailableEditorIds = this.editorPartsView.bind(ActiveEditorAvailableEditorIdsContext, this); const groupActiveEditorCanSplitInGroupContext = this.editorPartsView.bind(ActiveEditorCanSplitInGroupContext, this); + const groupActiveEditorCannotCloseContext = this.editorPartsView.bind(ActiveEditorCannotCloseContext, this); const groupActiveEditorIsSideBySideEditorContext = this.editorPartsView.bind(SideBySideEditorActiveContext, this); const activeEditorListener = this._register(new MutableDisposable()); @@ -300,6 +301,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { if (activeEditor) { groupActiveEditorCanSplitInGroupContext.set(activeEditor.hasCapability(EditorInputCapabilities.CanSplitInGroup)); + groupActiveEditorCannotCloseContext.set(activeEditor.hasCapability(EditorInputCapabilities.CannotClose)); groupActiveEditorIsSideBySideEditorContext.set(activeEditor.typeId === SideBySideEditorInput.ID); groupActiveEditorDirtyContext.set(activeEditor.isDirty() && !activeEditor.isSaving()); @@ -308,6 +310,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { }); } else { groupActiveEditorCanSplitInGroupContext.set(false); + groupActiveEditorCannotCloseContext.set(false); groupActiveEditorIsSideBySideEditorContext.set(false); groupActiveEditorDirtyContext.set(false); } @@ -1549,6 +1552,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return false; } + if (!internalOptions?.force && editor.hasCapability(EditorInputCapabilities.CannotClose)) { + return false; + } + // Check for confirmation and veto const veto = await this.handleCloseConfirmation([editor]); if (veto) { @@ -1884,7 +1891,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return true; } - const editors = this.doGetEditorsToClose(args); + const editors = this.doGetEditorsToClose(args).filter(editor => !editor.hasCapability(EditorInputCapabilities.CannotClose)); + if (!editors.length) { + return true; + } // Check for confirmation and veto const veto = await this.handleCloseConfirmation(editors.slice(0)); @@ -1955,7 +1965,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { //#region closeAllEditors() - closeAllEditors(options: { excludeConfirming: true }): boolean; + closeAllEditors(options: { excludeConfirming: true; force?: boolean }): boolean; closeAllEditors(options?: ICloseAllEditorsOptions): Promise; closeAllEditors(options?: ICloseAllEditorsOptions): boolean | Promise { if (this.isEmpty) { @@ -1977,7 +1987,12 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } // Otherwise go through potential confirmation "async" - return this.handleCloseConfirmation(this.model.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, options)).then(veto => { + const editors = this.model.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE, options).filter(editor => options?.force || !editor.hasCapability(EditorInputCapabilities.CannotClose)); + if (!editors.length) { + return true; + } + + return this.handleCloseConfirmation(editors).then(veto => { if (veto) { return false; } @@ -1988,7 +2003,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } private doCloseAllEditors(options?: ICloseAllEditorsOptions): void { - let editors = this.model.getEditors(EditorsOrder.SEQUENTIAL, options); + let editors = this.model.getEditors(EditorsOrder.SEQUENTIAL, options).filter(editor => options?.force || !editor.hasCapability(EditorInputCapabilities.CannotClose)); if (options?.excludeConfirming) { editors = editors.filter(editor => !this.shouldConfirmClose(editor)); } @@ -2060,7 +2075,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.doCloseEditor(editor, true, { context: EditorCloseContext.REPLACE }); closed = true; } else { - closed = await this.doCloseEditorWithConfirmationHandling(editor, { preserveFocus: true }, { context: EditorCloseContext.REPLACE }); + closed = await this.doCloseEditorWithConfirmationHandling(editor, { preserveFocus: true }, { context: EditorCloseContext.REPLACE, force: true }); } if (!closed) { @@ -2080,7 +2095,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { if (activeReplacement.forceReplaceDirty) { this.doCloseEditor(activeReplacement.editor, true, { context: EditorCloseContext.REPLACE }); } else { - await this.doCloseEditorWithConfirmationHandling(activeReplacement.editor, { preserveFocus: true }, { context: EditorCloseContext.REPLACE }); + await this.doCloseEditorWithConfirmationHandling(activeReplacement.editor, { preserveFocus: true }, { context: EditorCloseContext.REPLACE, force: true }); } } diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 7c0310fd9a5a0..d113781ba3df8 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -902,7 +902,7 @@ export class EditorPart extends Part implements IEditorPart, // Different groups view: move via groups view API else { movedView = targetView.groupsView.addGroup(targetView, direction, sourceView); - sourceView.closeAllEditors(); + sourceView.closeAllEditors({ force: true }); this.removeGroup(sourceView, restoreFocus); } @@ -1573,7 +1573,7 @@ export class EditorPart extends Part implements IEditorPart, const groups = this.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); for (const group of groups) { - await group.closeAllEditors({ excludeConfirming: true }); + await group.closeAllEditors({ excludeConfirming: true, force: true }); } return groups; diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 30785a3e76f3e..8b80b1955a474 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -571,7 +571,7 @@ export class EditorParts extends MultiWindowParts; private editorDirtyContext: IContextKey; private editorAvailableEditorIds: IContextKey; + private editorCannotCloseContext: IContextKey; private editorCanSplitInGroupContext: IContextKey; private sideBySideEditorContext: IContextKey; @@ -177,6 +178,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC this.editorStickyContext = ActiveEditorStickyContext.bindTo(this.contextMenuContextKeyService); this.editorDirtyContext = ActiveEditorDirtyContext.bindTo(this.contextMenuContextKeyService); this.editorAvailableEditorIds = ActiveEditorAvailableEditorIdsContext.bindTo(this.contextMenuContextKeyService); + this.editorCannotCloseContext = ActiveEditorCannotCloseContext.bindTo(this.contextMenuContextKeyService); this.editorCanSplitInGroupContext = ActiveEditorCanSplitInGroupContext.bindTo(this.contextMenuContextKeyService); this.sideBySideEditorContext = SideBySideEditorActiveContext.bindTo(this.contextMenuContextKeyService); @@ -541,6 +543,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC this.editorIsLastContext.set(this.tabsModel.isLast(editor)); this.editorStickyContext.set(this.tabsModel.isSticky(editor)); this.editorDirtyContext.set(editor.isDirty() && !editor.isSaving()); + this.editorCannotCloseContext.set(editor.hasCapability(EditorInputCapabilities.CannotClose)); this.groupLockedContext.set(this.tabsModel.isLocked); this.editorCanSplitInGroupContext.set(editor.hasCapability(EditorInputCapabilities.CanSplitInGroup)); this.sideBySideEditorContext.set(editor.typeId === SideBySideEditorInput.ID); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 733c99a8de753..c5a2615e1404d 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -1057,7 +1057,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { const editor = this.tabsModel.getEditorByIndex(tabIndex); if (editor) { - if (preventEditorClose(this.tabsModel, editor, EditorCloseMethod.MOUSE, this.groupsView.partOptions)) { + if (editor.hasCapability(EditorInputCapabilities.CannotClose) || preventEditorClose(this.tabsModel, editor, EditorCloseMethod.MOUSE, this.groupsView.partOptions)) { return; } @@ -1616,14 +1616,15 @@ export class MultiEditorTabsControl extends EditorTabsControl { private redrawTab(editor: EditorInput, tabIndex: number, tabContainer: HTMLElement, tabLabelWidget: IResourceLabel, tabLabel: IEditorInputLabel, tabActionBar: ActionBar): void { const isTabSticky = this.tabsModel.isSticky(tabIndex); + const isCloseable = !editor.hasCapability(EditorInputCapabilities.CannotClose); const options = this.groupsView.partOptions; // Label this.redrawTabLabel(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel); // Action - const hasUnpinAction = isTabSticky && options.tabActionUnpinVisibility; - const hasCloseAction = !hasUnpinAction && options.tabActionCloseVisibility; + const hasUnpinAction = isCloseable && isTabSticky && options.tabActionUnpinVisibility; + const hasCloseAction = isCloseable && !hasUnpinAction && options.tabActionCloseVisibility; const hasAction = hasUnpinAction || hasCloseAction; let tabAction; diff --git a/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts index 02a2be1812ffc..5cd89ffdc203d 100644 --- a/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/singleeditortabscontrol.css'; -import { EditorResourceAccessor, Verbosity, IEditorPartOptions, SideBySideEditor, preventEditorClose, EditorCloseMethod, IToolbarActions } from '../../../common/editor.js'; +import { EditorResourceAccessor, Verbosity, IEditorPartOptions, SideBySideEditor, preventEditorClose, EditorCloseMethod, IToolbarActions, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { EditorTabsControl } from './editorTabsControl.js'; import { ResourceLabel, IResourceLabel } from '../../labels.js'; @@ -119,7 +119,7 @@ export class SingleEditorTabsControl extends EditorTabsControl { if (e.button === 1 /* Middle Button */ && this.tabsModel.activeEditor) { EventHelper.stop(e, true /* for https://github.com/microsoft/vscode/issues/56715 */); - if (!preventEditorClose(this.tabsModel, this.tabsModel.activeEditor, EditorCloseMethod.MOUSE, this.groupsView.partOptions)) { + if (!this.tabsModel.activeEditor.hasCapability(EditorInputCapabilities.CannotClose) && !preventEditorClose(this.tabsModel, this.tabsModel.activeEditor, EditorCloseMethod.MOUSE, this.groupsView.partOptions)) { this.groupView.closeEditor(this.tabsModel.activeEditor); } } diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index ae371f310c89c..adbf28724fc6c 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -68,6 +68,7 @@ export const ActiveCompareEditorCanSwapContext = new RawContextKey('act export const ActiveEditorCanToggleReadonlyContext = new RawContextKey('activeEditorCanToggleReadonly', true, localize('activeEditorCanToggleReadonly', "Whether the active editor can toggle between being read-only or writeable")); export const ActiveEditorCanRevertContext = new RawContextKey('activeEditorCanRevert', false, localize('activeEditorCanRevert', "Whether the active editor can revert")); export const ActiveEditorCanSplitInGroupContext = new RawContextKey('activeEditorCanSplitInGroup', true); +export const ActiveEditorCannotCloseContext = new RawContextKey('activeEditorCannotClose', false, localize('activeEditorCannotClose', "Whether the active editor cannot be closed through standard user actions")); // Editor Kind Context Keys export const ActiveEditorContext = new RawContextKey('activeEditor', null, { type: 'string', description: localize('activeEditor', "The identifier of the active editor") }); diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index 4e47e009a606c..7dbb581b5e173 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -872,7 +872,17 @@ export const enum EditorInputCapabilities { * limit (`workbench.editor.limit`): it never counts towards the * limit and is never auto-closed to satisfy it. */ - ExcludeFromEditorLimit = 1 << 12 + ExcludeFromEditorLimit = 1 << 12, + + /** + * Signals that the editor cannot be closed through standard user + * initiated close actions, such as the tab close button, middle + * click, or close commands (Close Editor, Close All, Close Group, + * etc). Internal or programmatic close operations (for example + * during shutdown, window reload, workspace changes or when an + * editor fails to restore) are not affected by this capability. + */ + CannotClose = 1 << 13 } export type IUntypedEditorInput = IResourceEditorInput | ITextResourceEditorInput | IUntitledTextResourceEditorInput | IResourceDiffEditorInput | IResourceMultiDiffEditorInput | IResourceSideBySideEditorInput | IResourceMergeEditorInput; diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 2904cc8dbb89a..9e4581bd8d45c 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -142,6 +142,7 @@ export type ICloseEditorsFilter = { export interface ICloseAllEditorsOptions { readonly excludeSticky?: boolean; readonly excludeConfirming?: boolean; + readonly force?: boolean; } export interface IEditorReplacement { @@ -1012,7 +1013,7 @@ export interface IEditorGroup { * * @returns a promise if confirmation is needed when all editors are closed. */ - closeAllEditors(options: { excludeConfirming: true }): boolean; + closeAllEditors(options: { excludeConfirming: true; force?: boolean }): boolean; closeAllEditors(options?: ICloseAllEditorsOptions): Promise; /** diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 9e0d5b700936f..4987dbc0d300d 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -22,6 +22,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { Emitter } from '../../../../../base/common/event.js'; import { isEqual } from '../../../../../base/common/resources.js'; +import { CloseAllEditorGroupsAction } from '../../../../browser/parts/editor/editorActions.js'; suite('EditorGroupsService', () => { @@ -64,6 +65,13 @@ suite('EditorGroupsService', () => { return disposables.add(new TestFileEditorInput(resource, typeId)); } + function createCannotCloseTestFileEditorInput(resource: URI, typeId: string): TestFileEditorInput { + const input = createTestFileEditorInput(resource, typeId); + input.capabilities = EditorInputCapabilities.CannotClose; + + return input; + } + test('groups basics', async function () { const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); const [part] = await createPart(instantiationService); @@ -693,6 +701,21 @@ suite('EditorGroupsService', () => { assert.ok(input.gotDisposed); }); + test('closeEditor - cannot close editor handling', async () => { + const [part] = await createPart(); + const group = part.activeGroup; + + const input = createCannotCloseTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + + await group.openEditor(input); + + const closed = await group.closeEditor(input); + assert.strictEqual(closed, false); + assert.strictEqual(group.count, 1); + assert.strictEqual(group.activeEditor, input); + assert.ok(!input.gotDisposed); + }); + test('closeEditors - dirty editor handling', async () => { const [part, instantiationService] = await createPart(); @@ -750,6 +773,25 @@ suite('EditorGroupsService', () => { assert.strictEqual(group.getEditorByIndex(0), input2); }); + test('closeEditors - cannot close editor handling', async () => { + const [part] = await createPart(); + const group = part.activeGroup; + + const input1 = createTestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = createCannotCloseTestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } } + ]); + + const closeResult = await group.closeEditors([input1, input2]); + assert.strictEqual(closeResult, true); + assert.deepStrictEqual(group.getEditors(EditorsOrder.SEQUENTIAL), [input2]); + assert.ok(input1.gotDisposed); + assert.ok(!input2.gotDisposed); + }); + test('closeEditors (except one, sticky editor)', async () => { const [part] = await createPart(); const group = part.activeGroup; @@ -1039,6 +1081,39 @@ suite('EditorGroupsService', () => { assert.strictEqual(group.isEmpty, true); }); + test('closeAllEditors - cannot close editor handling', async () => { + const [part] = await createPart(); + const group = part.activeGroup; + + const input1 = createTestFileEditorInput(URI.file('foo/bar1'), TEST_EDITOR_INPUT_ID); + const input2 = createCannotCloseTestFileEditorInput(URI.file('foo/bar2'), TEST_EDITOR_INPUT_ID); + + await group.openEditors([ + { editor: input1, options: { pinned: true } }, + { editor: input2, options: { pinned: true } } + ]); + + const closeResult = await group.closeAllEditors(); + assert.strictEqual(closeResult, true); + assert.deepStrictEqual(group.getEditors(EditorsOrder.SEQUENTIAL), [input2]); + assert.ok(input1.gotDisposed); + assert.ok(!input2.gotDisposed); + }); + + test('closeAllEditors - force closes cannot close editors', async () => { + const [part] = await createPart(); + const group = part.activeGroup; + + const input = createCannotCloseTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + + await group.openEditor(input); + + const closeResult = await group.closeAllEditors({ force: true }); + assert.strictEqual(closeResult, true); + assert.strictEqual(group.isEmpty, true); + assert.ok(input.gotDisposed); + }); + test('moveEditor (same group)', async () => { const [part] = await createPart(); const group = part.activeGroup; @@ -1296,6 +1371,20 @@ suite('EditorGroupsService', () => { assert.strictEqual(group.getEditorByIndex(0), input); }); + test('replaceEditors - cannot close editor handling', async () => { + const [part] = await createPart(); + const group = part.activeGroup; + + const input = createCannotCloseTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const replacement = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + + await group.openEditor(input); + await group.replaceEditors([{ editor: input, replacement }]); + + assert.deepStrictEqual(group.getEditors(EditorsOrder.SEQUENTIAL), [replacement]); + assert.ok(input.gotDisposed); + }); + test('find editors', async () => { const [part] = await createPart(); const group = part.activeGroup; @@ -1828,6 +1917,26 @@ suite('EditorGroupsService', () => { assert.strictEqual(rightGroup.isLocked, true); }); + test('closeAllGroups action - cannot close editor handling', async () => { + const [part, instantiationService] = await createPart(); + const rootGroup = part.activeGroup; + const rightGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + + const rootInput = createTestFileEditorInput(URI.file('foo/root'), TEST_EDITOR_INPUT_ID); + const rightInput = createCannotCloseTestFileEditorInput(URI.file('foo/right'), TEST_EDITOR_INPUT_ID); + + await rootGroup.openEditor(rootInput); + await rightGroup.openEditor(rightInput); + + await instantiationService.invokeFunction(accessor => new CloseAllEditorGroupsAction().run(accessor)); + + assert.strictEqual(part.count, 1); + assert.strictEqual(part.activeGroup, rightGroup); + assert.deepStrictEqual(rightGroup.getEditors(EditorsOrder.SEQUENTIAL), [rightInput]); + assert.ok(rootInput.gotDisposed); + assert.ok(!rightInput.gotDisposed); + }); + test('locked groups - auto locking via setting', async () => { const instantiationService = workbenchInstantiationService(undefined, disposables); const configurationService = new TestConfigurationService(); From c8b98f4af85749df313944a2e9edec2c5127f838 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 6 Aug 2026 14:00:56 +0200 Subject: [PATCH 2/5] Adopt non-closeable managed tabs in Agents window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT_CONTROLLER.md | 2 ++ src/vs/sessions/SINGLE_PANE_SCENARIOS.md | 10 +++---- .../browser/sessionChangesEditorInput.ts | 2 +- .../browser/sessionChangesEditorInput.test.ts | 26 +++++++++++++++++++ .../editor/browser/emptyFileEditorInput.ts | 2 +- .../editor/browser/media/editorTabs.css | 8 ++++++ .../test/browser/editor.contribution.test.ts | 14 ++++++++++ .../desktopSessionLayoutController.test.ts | 24 ++++++++--------- .../parts/editor/multiEditorTabsControl.ts | 1 + 9 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index eb87c3c7da8dc..e47d8e913e451 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -277,6 +277,8 @@ editor part before opening the managed Changes editor; this keeps tab activation non-revealing while the pill reliably shows the multi-diff editor. The `+` Add Tab managed-tab actions are also explicit tab-add gestures: they pass the active group's end index so a re-added managed Changes/Files tab lands after the existing tabs rather than at the automatic Changes default position. +The managed Changes editor and Files placeholder declare `EditorInputCapabilities.CannotClose`, so +standard close actions cannot remove either managed tab. While the detail is visible, every diff editor selects the Changes container and every file editor selects the Files container, regardless of whether the file is inside the active session workspace. Rendered Markdown preview and Markdown custom editors also select Files. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index 3d24268de28f8..43d669dfd308f 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -81,7 +81,7 @@ width) captures a width to restore later. | **Hide Editor** (chevron `>`) | Editor header, trailing inline group after Toggle Details | Closes the editor content and keeps the detail (→ *Detail only*). The docked side pane shrinks to the detail width so the freed editor width goes to the **chat**, not the detail. Shown whenever Toggle Details is shown and disabled while the detail panel is hidden. | | **Maximize / Restore** | Editor title bar, primary inline | Maximizes the editor area (forces the Changes detail while maximized; restores on un-maximize). Default keybinding **`⌥⌘E`** toggles maximize/restore while the editor area is visible. | | **Collapse All Diffs** | Changes editor header, primary inline | Collapses every file in the Changes multi-diff (`SessionChangesEditor.collapseAllDiffs`). | -| **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (Browser `⇧⌘K B`, Search `⌘K S`; a **Changes** entry when the Changes editor tab is closed, and a **Files** entry `⌘K B` when the Files tab is closed — both for any workspace session). Re-added managed Changes/Files tabs are inserted at the **end** of the tab strip. Search opens a new Search editor. **Hidden when the editor area is closed.** | +| **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (Browser `⇧⌘K B`, Search `⌘K S`; a **Changes** entry when the Changes editor tab is absent, and a **Files** entry `⌘K B` when the Files tab is absent — both for any workspace session). Restored managed Changes/Files tabs are inserted at the **end** of the tab strip. Search opens a new Search editor. **Hidden when the editor area is closed.** | | **Toggle Side Panel** | Command / keybinding | Closes/opens the **whole** side pane (editor + detail together) → chat-only and back. The mechanics live on the workbench layout service (`toggleSidePane`); while the editor area is maximized, the shared `Workbench.toggleSidePane()` emits its will event, un-maximizes, then performs the collapse so the restored detail is also hidden. Hiding a focused side pane moves focus to the sessions list. | | **Toggle Sessions List** | Title bar / command | Collapses/opens the left sessions list. Collapsing it gives the freed width to the editor/detail side pane (not the chat); reopening restores the previous editor/detail width so the chat gets that space back. No single-pane editor or detail action changes this visibility. | | **Grid sash** | Between the chat and the third pane | Dragging a detail-only side pane wider keeps the editor content closed. When editor content and details are visible but no longer fit, the detail panel hides; widening past the hysteresis threshold restores it. | @@ -91,19 +91,19 @@ width) captures a width to restore later. **Managed Files tab.** The empty Files placeholder tab (and the Changes tab) is opened only when the editor group is **empty** on a view-open trigger (a session switch or a side-pane reveal). Opening a real workspace file **tidies away** the empty placeholder (a `[Changes][file]` strip) as a **one-shot reaction to that open** — not a standing rule — so the user can still add the Files tab via **`+` Files** while a real file is open (that opens an `EmptyFileEditorInput`, not a real file, so it is not tidied away). The placeholder is **not** re-added when the real file closes; the defaults return only when the group empties and the side pane is reopened. -**Layout-driven vs user editor changes.** The default docked tabs are (re)opened into an empty group on a **settled** session-switch restore — the base controller fires `onDidEndSessionLayoutRestore` once the restore epoch (working-set apply + aux restore) completes, and the strategy reconciles off that. This matters for a new session: its **empty** working set closes the previous session's docked tabs, emptying the group *after* the switch; reconciling on the settled restore-end reads the reliably-empty group and re-opens both managed tabs. Reacting to the transient editor-change *during* the async apply would race the empty state. A **user-driven** editor change (opening a file, closing a tab) is *not* a restore, so it never re-opens the defaults and a user close still sticks / still closes the side pane. +**Layout-driven vs user editor changes.** The default docked tabs are (re)opened into an empty group on a **settled** session-switch restore — the base controller fires `onDidEndSessionLayoutRestore` once the restore epoch (working-set apply + aux restore) completes, and the strategy reconciles off that. This matters for a new session: its **empty** working set closes the previous session's docked tabs, emptying the group *after* the switch; reconciling on the settled restore-end reads the reliably-empty group and re-opens both managed tabs. Reacting to the transient editor-change *during* the async apply would race the empty state. A **user-driven** editor change is not a restore and never re-opens missing defaults; standard close actions cannot remove the managed tabs. **Folder-less composer to workspace draft.** Opening **New Session** first exposes a folder-less composer and then seeds its concrete workspace draft. The first step removes the previous session's Changes tab while the shared Files placeholder can keep the editor group non-empty, so the second step explicitly ensures Changes when `wantsChangesTab` becomes true. When the selected session folder differs from the new-session default folder, the workspace-gated working-set restore can settle later and remove that early Changes tab while retaining Files; the settled restore therefore repeats the one-shot Changes ensure for the uncreated session. Relying only on the empty-group rule or only on the initial eligibility transition leaves Files as the sole tab until another reveal or New Session gesture. **New-session submit.** Submit preserves the current editor/detail visibility and seeds the Existing Sessions profile from that composition, avoiding any layout jump. The Files tab remains active until the submitted session reports its first file changes; then Changes becomes active without revealing Editor. This pending activation is scoped to the submitted session, so switching away cannot activate Changes in another session. -**Details-only reveal.** When the side pane is opened as **details-only** (the aux-bar detail panel is revealed without the editor area — e.g. the new-session view, or a created session whose editor was hidden), the docked details panel *shows* the managed docked inputs, so they must always be present. On such a reveal the Changes and Files inputs are ensured **even when the group is non-empty** — e.g. if the user had earlier closed one of them, it is restored. This is tied to the reveal gesture, so a close *within* an already-open details view still sticks until the next reveal. An editor-included reveal (the editor area is visible) keeps the strict "add only into an empty group" rule, so a close there is respected. +**Details-only reveal.** When the side pane is opened as **details-only** (the aux-bar detail panel is revealed without the editor area — e.g. the new-session view, or a created session whose editor was hidden), the docked details panel *shows* the managed docked inputs, so they must always be present. On such a reveal the Changes and Files inputs are ensured **even when the group is non-empty**, restoring either input when lifecycle work left it absent. An editor-included reveal (the editor area is visible) keeps the strict "add only into an empty group" rule. -**Closing managed tabs.** The user can close the managed Changes and Files tabs (they are non-preview, not sticky). Closes are respected without any dismissal bookkeeping: the default tabs are opened **only into an empty editor group** on a view-open trigger (plus the one-shot submit activation above, and the details-only reveal ensure), so closing one tab while another (or a real file) remains leaves the group non-empty and it is not re-created. Closing the last tab closes the whole side pane; reopening it (empty group) restores the defaults. While a managed tab is closed for a workspace session, the `+` Add Tab menu offers a matching entry to reopen it — **Changes** (gated on `SinglePaneChangesTabMissingContext`) and **Files** (gated on `SinglePaneFilesTabMissingContext`); the re-added tab makes the group non-empty, so it survives. +**Closing managed tabs.** The managed Changes and Files tabs cannot be closed through standard user actions; both adopt `EditorInputCapabilities.CannotClose` so close commands and tab affordances consistently leave them open. Internal lifecycle work can still force-close either input while applying a working set, switching sessions, or cleaning up stale managed tabs. If lifecycle work leaves a managed tab absent, the missing-tab context and `+` Add Tab entries still allow it to be restored; reopening the side pane with an empty group restores both defaults. **Per-session detail state.** A created session's detail-panel (aux-bar) visible/hidden choice is captured per session and restored on switch-back and reload (a detail-closed session stays detail-closed when returning to it), even if an external component transiently reveals the aux bar during the working-set restore or a queued detail-container sync from the previous session runs later. -**Reopening after closing all tabs.** Closing all tabs closes the whole side pane; the managed Changes and Files tabs are re-ensured, so reopening the side pane is never empty. +**Reopening after lifecycle cleanup.** If lifecycle work force-closes every tab, the whole side pane can close; the managed Changes and Files tabs are re-ensured when the side pane reopens. **Side-pane-closed persists across reload.** Closing the whole side pane is remembered across a window reload. On reload the restored managed tab does **not** re-reveal the detail: the detail-panel forced reveal is gated on the editor content being visible, so a fully-closed side pane stays closed until the user reopens it. diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts index ea2497309668e..fe1c5a185e470 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -46,7 +46,7 @@ export class SessionChangesEditorInput extends DockedEditorInput { } override get capabilities(): EditorInputCapabilities { - return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly | EditorInputCapabilities.CannotClose; } override getName(): string { diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts new file mode 100644 index 0000000000000..ce9d20beb390c --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { EditorInputCapabilities } from '../../../../../workbench/common/editor.js'; +import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; + +suite('SessionChangesEditorInput', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('declares managed Changes editor capabilities', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + const input = disposables.add(new SessionChangesEditorInput(URI.parse('test-changes:session'), instantiationService)); + + assert.strictEqual(input.capabilities, + EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.Readonly | + EditorInputCapabilities.CannotClose); + }); +}); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts index f31aec1c5a917..6732cc74448e1 100644 --- a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts @@ -59,7 +59,7 @@ export class EmptyFileEditorInput extends DockedEditorInput { } override get capabilities(): EditorInputCapabilities { - return super.capabilities | EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; + return super.capabilities | EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal | EditorInputCapabilities.CannotClose; } override getName(): string { diff --git a/src/vs/sessions/contrib/editor/browser/media/editorTabs.css b/src/vs/sessions/contrib/editor/browser/media/editorTabs.css index 26c7d9e9b139f..1b8009b560605 100644 --- a/src/vs/sessions/contrib/editor/browser/media/editorTabs.css +++ b/src/vs/sessions/contrib/editor/browser/media/editorTabs.css @@ -28,6 +28,14 @@ --agent-sessions-editor-tab-padding: 0 var(--vscode-spacing-size100) 0 0; } +.agent-sessions-workbench .part.editor .tabs-container > .tab.cannot-close { + margin-right: var(--vscode-spacing-size40); +} + +.agent-sessions-workbench .part.editor .tabs-container > .tab.cannot-close:not(.dirty):not(.sticky-compact) { + --agent-sessions-editor-tab-padding: 0 var(--vscode-spacing-size40); +} + /* Size tabs to their content and never shrink them, so that when more tabs * exist than fit the container they overflow and the horizontal scrollbar * appears (matching the default editor behaviour). The default `sizing-fit` diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index a4673aad0ff26..07ab725f5f52c 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -14,6 +14,7 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { EditorInputCapabilities } from '../../../../../workbench/common/editor.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; @@ -118,6 +119,19 @@ suite('Sessions - Editor Contribution', () => { }); }); + test('empty file editor declares managed Files capabilities', () => { + const instantiationService = store.add(new TestInstantiationService()); + const layoutService = stubEditorVisibility(instantiationService, true); + const input = store.add(new EmptyFileEditorInput(undefined, layoutService)); + + assert.strictEqual(input.capabilities, + EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Readonly | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.ForceReveal | + EditorInputCapabilities.CannotClose); + }); + test('empty file editor exposes its breadcrumb resource only while the editor area is visible', () => { let editorVisible = false; const onDidChangePartVisibility = store.add(new Emitter()); diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index c76b36c46740a..a91b5a393948e 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -2819,7 +2819,7 @@ suite('LayoutController (desktop)', () => { await settle(); assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: true, hasFilesTab: true }); - // User closes the Files tab; the Changes tab remains (group non-empty). + // Simulate lifecycle removal of Files while Changes keeps the group non-empty. const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); harness.onDidCloseEditor.fire({ editor: fileTab }); @@ -2846,7 +2846,7 @@ suite('LayoutController (desktop)', () => { harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); await settle(); - // User closes the Files tab; the Changes tab remains. + // Simulate lifecycle removal of Files while Changes remains. const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); harness.onDidCloseEditor.fire({ editor: fileTab }); @@ -3125,7 +3125,7 @@ suite('LayoutController (desktop)', () => { }); }); - test('[managed tabs / close] does not re-open a managed tab after the user closes it (group stays non-empty)', async () => { + test('[managed tabs / lifecycle removal] does not re-open a missing managed tab while the group stays non-empty', async () => { createSinglePaneController({ activateAux: true }); await settle(); @@ -3134,7 +3134,7 @@ suite('LayoutController (desktop)', () => { const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; assert.ok(fileTab); - // User closes the Files tab. + // Simulate lifecycle removal of the non-closeable Files tab. const index = harness.activeGroupEditors.indexOf(fileTab); harness.activeGroupEditors.splice(index, 1); harness.onDidCloseEditor.fire({ editor: fileTab }); @@ -3166,7 +3166,7 @@ suite('LayoutController (desktop)', () => { assert.strictEqual(hasFilesTab(), true, 'the default tabs are opened for the new session'); }); - test('[managed tabs / add-tab] closing the Changes tab flips SinglePaneChangesTabMissingContext', async () => { + test('[managed tabs / add-tab] a missing Changes tab flips SinglePaneChangesTabMissingContext', async () => { createSinglePaneController({ activateAux: true }); await settle(); @@ -3175,7 +3175,7 @@ suite('LayoutController (desktop)', () => { const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; assert.strictEqual(harness.contextKeyService.getContextKeyValue(SinglePaneChangesTabMissingContext.key), false); - // User closes the Changes tab. + // Simulate an internal lifecycle removal of the non-closeable Changes tab. harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(changesTab), 1); harness.onDidCloseEditor.fire({ editor: changesTab }); harness.onDidEditorsChange.fire(); @@ -3187,7 +3187,7 @@ suite('LayoutController (desktop)', () => { }, { hasChangesTab: false, changesTabMissing: true }); }); - test('[managed tabs / add-tab] closing the Files tab flips SinglePaneFilesTabMissingContext', async () => { + test('[managed tabs / add-tab] a missing Files tab flips SinglePaneFilesTabMissingContext', async () => { createSinglePaneController({ activateAux: true }); await settle(); @@ -3196,7 +3196,7 @@ suite('LayoutController (desktop)', () => { const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; assert.strictEqual(harness.contextKeyService.getContextKeyValue(SinglePaneFilesTabMissingContext.key), false); - // User closes the Files tab. + // Simulate lifecycle removal of the non-closeable Files tab. harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); harness.onDidCloseEditor.fire({ editor: fileTab }); harness.onDidEditorsChange.fire(); @@ -3217,7 +3217,7 @@ suite('LayoutController (desktop)', () => { await settle(); const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; - // User closes the Changes tab -> the missing context becomes true. + // Simulate an internal lifecycle removal of the non-closeable Changes tab. harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(changesTab), 1); harness.onDidCloseEditor.fire({ editor: changesTab }); harness.onDidEditorsChange.fire(); @@ -3302,7 +3302,7 @@ suite('LayoutController (desktop)', () => { const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; assert.ok(fileTab); - // User closes the Files tab; the whole side pane closes (aux hidden). + // Simulate lifecycle removal of Files followed by the side pane hiding. harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); harness.onDidCloseEditor.fire({ editor: fileTab }); harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); @@ -3328,7 +3328,7 @@ suite('LayoutController (desktop)', () => { await settle(); assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: true, hasFilesTab: true }); - // User closes both managed tabs; the whole side pane closes (both parts hidden). + // Simulate lifecycle cleanup removing both managed tabs and closing the side pane. const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; const filesTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; for (const tab of [changesTab, filesTab]) { @@ -3368,7 +3368,7 @@ suite('LayoutController (desktop)', () => { harness.activeSessionObs.set(makeSession(session), undefined); await settle(); - // User closes both managed tabs; the whole side pane closes. + // Simulate lifecycle cleanup removing both managed tabs and closing the side pane. for (const tab of [...harness.activeGroupEditors]) { harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(tab), 1); harness.onDidCloseEditor.fire({ editor: tab }); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index c5a2615e1404d..6b1b8b42e3bca 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -1645,6 +1645,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { tabContainer.classList.toggle(`pinned-action-off`, isTabSticky && !hasUnpinAction); tabContainer.classList.toggle(`close-action-off`, !hasUnpinAction && !hasCloseAction); + tabContainer.classList.toggle('cannot-close', !isCloseable); for (const option of ['left', 'right']) { tabContainer.classList.toggle(`tab-actions-${option}`, hasAction && options.tabActionLocation === option); From 9e7193ff680525bc4865f6d29d5bb241a0719bef Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 6 Aug 2026 17:13:57 +0200 Subject: [PATCH 3/5] editor: refresh tabs when input capabilities change Update editor tab controls and active-editor context keys immediately when an input changes capabilities. Use this to make the Agents Changes and Files inputs non-closeable only while the editor area is hidden.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT_CONTROLLER.md | 5 ++- src/vs/sessions/SINGLE_PANE_SCENARIOS.md | 4 +- .../browser/sessionChangesEditorInput.ts | 11 ++++- .../browser/sessionChangesEditorInput.test.ts | 40 +++++++++++++++---- .../editor/browser/emptyFileEditorInput.ts | 4 +- .../test/browser/editor.contribution.test.ts | 40 ++++++++++++++----- .../browser/parts/editor/editorGroupView.ts | 12 ++++++ .../browser/parts/editor/editorTabsControl.ts | 3 ++ .../parts/editor/editorTitleControl.ts | 4 ++ .../parts/editor/multiEditorTabsControl.ts | 4 ++ .../parts/editor/multiRowEditorTabsControl.ts | 4 ++ .../parts/editor/noEditorTabsControl.ts | 2 + .../parts/editor/singleEditorTabsControl.ts | 4 ++ 13 files changed, 115 insertions(+), 22 deletions(-) diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index e47d8e913e451..99d6053135840 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -277,8 +277,9 @@ editor part before opening the managed Changes editor; this keeps tab activation non-revealing while the pill reliably shows the multi-diff editor. The `+` Add Tab managed-tab actions are also explicit tab-add gestures: they pass the active group's end index so a re-added managed Changes/Files tab lands after the existing tabs rather than at the automatic Changes default position. -The managed Changes editor and Files placeholder declare `EditorInputCapabilities.CannotClose`, so -standard close actions cannot remove either managed tab. +While the editor area is hidden, the managed Changes editor and Files placeholder declare +`EditorInputCapabilities.CannotClose`, so standard close actions cannot remove either tab from the +visible detail panel. Revealing the editor area makes both tabs closeable again. While the detail is visible, every diff editor selects the Changes container and every file editor selects the Files container, regardless of whether the file is inside the active session workspace. Rendered Markdown preview and Markdown custom editors also select Files. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index 43d669dfd308f..428f6f0e28610 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -91,7 +91,7 @@ width) captures a width to restore later. **Managed Files tab.** The empty Files placeholder tab (and the Changes tab) is opened only when the editor group is **empty** on a view-open trigger (a session switch or a side-pane reveal). Opening a real workspace file **tidies away** the empty placeholder (a `[Changes][file]` strip) as a **one-shot reaction to that open** — not a standing rule — so the user can still add the Files tab via **`+` Files** while a real file is open (that opens an `EmptyFileEditorInput`, not a real file, so it is not tidied away). The placeholder is **not** re-added when the real file closes; the defaults return only when the group empties and the side pane is reopened. -**Layout-driven vs user editor changes.** The default docked tabs are (re)opened into an empty group on a **settled** session-switch restore — the base controller fires `onDidEndSessionLayoutRestore` once the restore epoch (working-set apply + aux restore) completes, and the strategy reconciles off that. This matters for a new session: its **empty** working set closes the previous session's docked tabs, emptying the group *after* the switch; reconciling on the settled restore-end reads the reliably-empty group and re-opens both managed tabs. Reacting to the transient editor-change *during* the async apply would race the empty state. A **user-driven** editor change is not a restore and never re-opens missing defaults; standard close actions cannot remove the managed tabs. +**Layout-driven vs user editor changes.** The default docked tabs are (re)opened into an empty group on a **settled** session-switch restore — the base controller fires `onDidEndSessionLayoutRestore` once the restore epoch (working-set apply + aux restore) completes, and the strategy reconciles off that. This matters for a new session: its **empty** working set closes the previous session's docked tabs, emptying the group *after* the switch; reconciling on the settled restore-end reads the reliably-empty group and re-opens both managed tabs. Reacting to the transient editor-change *during* the async apply would race the empty state. A **user-driven** editor change is not a restore and never re-opens missing defaults; standard close actions cannot remove the managed tabs while the editor area is hidden. **Folder-less composer to workspace draft.** Opening **New Session** first exposes a folder-less composer and then seeds its concrete workspace draft. The first step removes the previous session's Changes tab while the shared Files placeholder can keep the editor group non-empty, so the second step explicitly ensures Changes when `wantsChangesTab` becomes true. When the selected session folder differs from the new-session default folder, the workspace-gated working-set restore can settle later and remove that early Changes tab while retaining Files; the settled restore therefore repeats the one-shot Changes ensure for the uncreated session. Relying only on the empty-group rule or only on the initial eligibility transition leaves Files as the sole tab until another reveal or New Session gesture. @@ -99,7 +99,7 @@ width) captures a width to restore later. **Details-only reveal.** When the side pane is opened as **details-only** (the aux-bar detail panel is revealed without the editor area — e.g. the new-session view, or a created session whose editor was hidden), the docked details panel *shows* the managed docked inputs, so they must always be present. On such a reveal the Changes and Files inputs are ensured **even when the group is non-empty**, restoring either input when lifecycle work left it absent. An editor-included reveal (the editor area is visible) keeps the strict "add only into an empty group" rule. -**Closing managed tabs.** The managed Changes and Files tabs cannot be closed through standard user actions; both adopt `EditorInputCapabilities.CannotClose` so close commands and tab affordances consistently leave them open. Internal lifecycle work can still force-close either input while applying a working set, switching sessions, or cleaning up stale managed tabs. If lifecycle work leaves a managed tab absent, the missing-tab context and `+` Add Tab entries still allow it to be restored; reopening the side pane with an empty group restores both defaults. +**Closing managed tabs.** While the editor area is hidden, the managed Changes and Files tabs adopt `EditorInputCapabilities.CannotClose`, so close commands and tab affordances consistently leave the detail panel's backing inputs open. Revealing the editor area removes the capability and makes both tabs normally closeable. Internal lifecycle work can still force-close either input while applying a working set, switching sessions, or cleaning up stale managed tabs. If lifecycle work leaves a managed tab absent, the missing-tab context and `+` Add Tab entries still allow it to be restored; reopening the side pane with an empty group restores both defaults. **Per-session detail state.** A created session's detail-panel (aux-bar) visible/hidden choice is captured per session and restored on switch-back and reload (a detail-closed session stays detail-closed when returning to it), even if an external component transiently reveals the aux bar during the working-set restore or a queued detail-container sync from the previous session runs later. diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts index fe1c5a185e470..5688be647c0b1 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; +import { mainWindow } from '../../../../base/browser/window.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -12,6 +13,7 @@ import { EditorInput } from '../../../../workbench/common/editor/editorInput.js' import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { DockedEditorInput } from '../../../common/dockedEditorInput.js'; /** @@ -29,8 +31,14 @@ export class SessionChangesEditorInput extends DockedEditorInput { constructor( readonly multiDiffSource: URI, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); + this._register(layoutService.onDidChangePartVisibility(event => { + if (event.partId === Parts.EDITOR_PART) { + this._onDidChangeCapabilities.fire(); + } + })); } override get resource(): URI { @@ -46,7 +54,8 @@ export class SessionChangesEditorInput extends DockedEditorInput { } override get capabilities(): EditorInputCapabilities { - return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly | EditorInputCapabilities.CannotClose; + const capabilities = super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + return this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow) ? capabilities : capabilities | EditorInputCapabilities.CannotClose; } override getName(): string { diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts index ce9d20beb390c..bb79530368381 100644 --- a/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts @@ -4,23 +4,49 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { EditorInputCapabilities } from '../../../../../workbench/common/editor.js'; +import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { SessionChangesEditorInput } from '../../browser/sessionChangesEditorInput.js'; suite('SessionChangesEditorInput', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - test('declares managed Changes editor capabilities', () => { + test('updates managed Changes editor capabilities with editor area visibility', () => { const instantiationService = disposables.add(new TestInstantiationService()); - const input = disposables.add(new SessionChangesEditorInput(URI.parse('test-changes:session'), instantiationService)); + let editorVisible = false; + const onDidChangePartVisibility = disposables.add(new Emitter()); + const layoutService = new class extends mock() { + override readonly onDidChangePartVisibility = onDidChangePartVisibility.event; + override isVisible(part: Parts): boolean { + return part === Parts.EDITOR_PART && editorVisible; + } + }; + const input = disposables.add(new SessionChangesEditorInput(URI.parse('test-changes:session'), instantiationService, layoutService)); + let capabilitiesChanges = 0; + disposables.add(input.onDidChangeCapabilities(() => capabilitiesChanges++)); - assert.strictEqual(input.capabilities, - EditorInputCapabilities.ExcludeFromEditorLimit | - EditorInputCapabilities.Singleton | - EditorInputCapabilities.Readonly | - EditorInputCapabilities.CannotClose); + const hiddenCapabilities = input.capabilities; + editorVisible = true; + onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + + assert.deepStrictEqual({ + hiddenCapabilities, + visibleCapabilities: input.capabilities, + capabilitiesChanges + }, { + hiddenCapabilities: EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.Readonly | + EditorInputCapabilities.CannotClose, + visibleCapabilities: EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.Readonly, + capabilitiesChanges: 1 + }); }); }); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts index 6732cc74448e1..2a52daa9c7500 100644 --- a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts @@ -29,6 +29,7 @@ export class EmptyFileEditorInput extends DockedEditorInput { this._register(layoutService.onDidChangePartVisibility(event => { if (event.partId === Parts.EDITOR_PART) { this._onDidChangeLabel.fire(); + this._onDidChangeCapabilities.fire(); } })); } @@ -59,7 +60,8 @@ export class EmptyFileEditorInput extends DockedEditorInput { } override get capabilities(): EditorInputCapabilities { - return super.capabilities | EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal | EditorInputCapabilities.CannotClose; + const capabilities = super.capabilities | EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; + return this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow) ? capabilities : capabilities | EditorInputCapabilities.CannotClose; } override getName(): string { diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index 07ab725f5f52c..d372e3481b1aa 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -119,17 +119,39 @@ suite('Sessions - Editor Contribution', () => { }); }); - test('empty file editor declares managed Files capabilities', () => { - const instantiationService = store.add(new TestInstantiationService()); - const layoutService = stubEditorVisibility(instantiationService, true); + test('empty file editor updates managed Files capabilities with editor area visibility', () => { + let editorVisible = false; + const onDidChangePartVisibility = store.add(new Emitter()); + const layoutService = new class extends mock() { + override readonly onDidChangePartVisibility = onDidChangePartVisibility.event; + override isVisible(part: Parts): boolean { + return part === Parts.EDITOR_PART && editorVisible; + } + }; const input = store.add(new EmptyFileEditorInput(undefined, layoutService)); + let capabilitiesChanges = 0; + store.add(input.onDidChangeCapabilities(() => capabilitiesChanges++)); + + const hiddenCapabilities = input.capabilities; + editorVisible = true; + onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); - assert.strictEqual(input.capabilities, - EditorInputCapabilities.ExcludeFromEditorLimit | - EditorInputCapabilities.Readonly | - EditorInputCapabilities.Singleton | - EditorInputCapabilities.ForceReveal | - EditorInputCapabilities.CannotClose); + assert.deepStrictEqual({ + hiddenCapabilities, + visibleCapabilities: input.capabilities, + capabilitiesChanges + }, { + hiddenCapabilities: EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Readonly | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.ForceReveal | + EditorInputCapabilities.CannotClose, + visibleCapabilities: EditorInputCapabilities.ExcludeFromEditorLimit | + EditorInputCapabilities.Readonly | + EditorInputCapabilities.Singleton | + EditorInputCapabilities.ForceReveal, + capabilitiesChanges: 1 + }); }); test('empty file editor exposes its breadcrumb resource only while the editor area is visible', () => { diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 090319358cef2..6c73463a501eb 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -369,6 +369,11 @@ export class EditorGroupView extends Themable implements IEditorGroupView { groupActiveEditorStickyContext.set(this.model.isSticky(this.model.activeEditor)); } break; + case GroupModelChangeKind.EDITOR_CAPABILITIES: + if (e.editor && e.editor === this.model.activeEditor) { + observeActiveEditor(); + } + break; case GroupModelChangeKind.EDITORS_SELECTION: multipleEditorsSelectedContext.set(this.model.selectedEditors.length > 1); twoEditorsSelectedContext.set(this.model.selectedEditors.length === 2); @@ -656,6 +661,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { case GroupModelChangeKind.EDITOR_LABEL: this.onDidChangeEditorLabel(e.editor); break; + case GroupModelChangeKind.EDITOR_CAPABILITIES: + this.onDidChangeEditorCapabilities(e.editor); + break; } } @@ -876,6 +884,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.titleControl.updateEditorLabel(editor); } + private onDidChangeEditorCapabilities(editor: EditorInput): void { + this.titleControl.updateEditorCapabilities(editor); + } + private onDidChangeEditorSelection(): void { // Forward to title control diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 62cc948647403..660d1da421874 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -88,6 +88,7 @@ export interface IEditorTabsControl extends IDisposable { setActive(isActive: boolean): void; updateEditorSelections(): void; updateEditorLabel(editor: EditorInput): void; + updateEditorCapabilities(editor: EditorInput): void; updateEditorDirty(editor: EditorInput): void; layout(dimensions: IEditorTitleControlDimensions): Dimension; getHeight(): number; @@ -656,6 +657,8 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC abstract updateEditorLabel(editor: EditorInput): void; + abstract updateEditorCapabilities(editor: EditorInput): void; + abstract updateEditorDirty(editor: EditorInput): void; abstract layout(dimensions: IEditorTitleControlDimensions): Dimension; diff --git a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts index f45584aa4c353..eb8d7f3910a70 100644 --- a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts @@ -153,6 +153,10 @@ export class EditorTitleControl extends Themable { } } + updateEditorCapabilities(editor: EditorInput): void { + this.editorTabsControl.updateEditorCapabilities(editor); + } + updateEditorDirty(editor: EditorInput): void { return this.editorTabsControl.updateEditorDirty(editor); } diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 6b1b8b42e3bca..a67604b1639e3 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -807,6 +807,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar)); } + updateEditorCapabilities(editor: EditorInput): void { + this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTab(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar)); + } + override updateOptions(oldOptions: IEditorPartOptions, newOptions: IEditorPartOptions): void { super.updateOptions(oldOptions, newOptions); diff --git a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts index 667082db3544f..3677cbf4de610 100644 --- a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts @@ -183,6 +183,10 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont this.getEditorTabsController(editor).updateEditorLabel(editor); } + updateEditorCapabilities(editor: EditorInput): void { + this.getEditorTabsController(editor).updateEditorCapabilities(editor); + } + updateEditorDirty(editor: EditorInput): void { this.getEditorTabsController(editor).updateEditorDirty(editor); } diff --git a/src/vs/workbench/browser/parts/editor/noEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/noEditorTabsControl.ts index 8d1c807d833f6..22765db223266 100644 --- a/src/vs/workbench/browser/parts/editor/noEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/noEditorTabsControl.ts @@ -80,6 +80,8 @@ export class NoEditorTabsControl extends EditorTabsControl { updateEditorLabel(editor: EditorInput): void { } + updateEditorCapabilities(editor: EditorInput): void { } + updateEditorDirty(editor: EditorInput): void { } getHeight(): number { diff --git a/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts index 5cd89ffdc203d..28e1b6189fb80 100644 --- a/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/singleEditorTabsControl.ts @@ -195,6 +195,10 @@ export class SingleEditorTabsControl extends EditorTabsControl { this.ifEditorIsActive(editor, () => this.redraw()); } + updateEditorCapabilities(editor: EditorInput): void { + this.ifEditorIsActive(editor, () => this.redraw()); + } + updateEditorDirty(editor: EditorInput): void { this.ifEditorIsActive(editor, () => { const titleContainer = assertReturnsDefined(this.titleContainer); From 0b167b08b16c64764c260ebd9c4a87d9da426b40 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 6 Aug 2026 17:30:10 +0200 Subject: [PATCH 4/5] editor: address non-closeable input review feedback Add explicit forced targeted closes for lifecycle cleanup, keep unpin and mixed bulk actions available, and prevent Revert and Close from reverting protected inputs.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT_CONTROLLER.md | 3 ++- .../singlePane/singlePaneManagedTabsStrategy.ts | 2 +- .../browser/desktopSessionLayoutController.test.ts | 3 ++- .../layout/test/browser/layoutControllerTestUtils.ts | 8 ++++++-- .../browser/parts/editor/editor.contribution.ts | 12 ++++++------ .../workbench/browser/parts/editor/editorActions.ts | 9 +++++++-- .../browser/parts/editor/editorGroupView.ts | 4 ++-- .../browser/parts/editor/multiEditorTabsControl.ts | 2 +- src/vs/workbench/common/editor.ts | 6 ++---- .../services/editor/common/editorGroupsService.ts | 5 +++++ .../editor/test/browser/editorGroupsService.test.ts | 10 ++++++++++ 11 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 99d6053135840..5183564b3d57d 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -279,7 +279,8 @@ are also explicit tab-add gestures: they pass the active group's end index so a Changes/Files tab lands after the existing tabs rather than at the automatic Changes default position. While the editor area is hidden, the managed Changes editor and Files placeholder declare `EditorInputCapabilities.CannotClose`, so standard close actions cannot remove either tab from the -visible detail panel. Revealing the editor area makes both tabs closeable again. +visible detail panel. Revealing the editor area makes both tabs closeable again. Managed-tab +reconciliation uses an explicit forced close when it removes stale inputs or tidies the Files placeholder. While the detail is visible, every diff editor selects the Changes container and every file editor selects the Files container, regardless of whether the file is inside the active session workspace. Rendered Markdown preview and Markdown custom editors also select Files. diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts index 3625317bd921a..cec4d9fe91051 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts @@ -382,7 +382,7 @@ export class SinglePaneManagedTabsStrategy extends SinglePaneLayoutStrategy { /** Closes editors we own, preserving focus so a transient close never steals it. */ private async _closeManagedEditors(group: IEditorGroup, editors: EditorInput[]): Promise { - await this._editorService.closeEditors(editors.map(editor => ({ groupId: group.id, editor })), { preserveFocus: true }); + await this._editorService.closeEditors(editors.map(editor => ({ groupId: group.id, editor })), { preserveFocus: true, force: true }); } private _pinFirst(group: IEditorGroup, editor: EditorInput): void { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index a91b5a393948e..1e54da777a1f0 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -3290,7 +3290,8 @@ suite('LayoutController (desktop)', () => { const staleClosed = harness.closedEditors.some(e => e.resource && isEqual(e.resource, staleChangesResource)); const allClosesSuppressed = harness.closeSuppressionFlags.every(flag => flag); - assert.deepStrictEqual({ staleClosed, allClosesSuppressed }, { staleClosed: true, allClosesSuppressed: true }); + const allClosesForced = harness.closeForceFlags.every(flag => flag); + assert.deepStrictEqual({ staleClosed, allClosesSuppressed, allClosesForced }, { staleClosed: true, allClosesSuppressed: true, allClosesForced: true }); }); test('[managed tabs / Issue 1] re-ensures the Files tab when the side pane is reopened via the aux bar alone', async () => { diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 1e4eb3b92d4df..2df3b9ba45a00 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -19,7 +19,7 @@ import { IStorageService, StorageScope } from '../../../../../platform/storage/c import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IViewContainerModel, IViewDescriptorService, ViewContainer, ViewContainerLocation } from '../../../../../workbench/common/views.js'; -import { IEditorGroup, IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { ICloseEditorOptions, IEditorGroup, IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../../../workbench/services/panecomposite/browser/panecomposite.js'; @@ -208,6 +208,8 @@ export interface ITestLayoutHarness { openedEditors: IUntypedEditorInput[]; /** Records the depth-at-close for each `closeEditors` call, to assert layout-driven closes happen while suppressed. */ closeSuppressionFlags: boolean[]; + /** Records whether each `closeEditors` call forces lifecycle cleanup. */ + closeForceFlags: boolean[]; activePaneCompositeId: string | undefined; pinnedAuxiliaryBarContainerIds: string[]; visibleEditorsList: readonly unknown[]; @@ -317,6 +319,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption closedEditors: [], openedEditors: [], closeSuppressionFlags: [], + closeForceFlags: [], activePaneCompositeId: undefined, pinnedAuxiliaryBarContainerIds: [SESSIONS_FILES_CONTAINER_ID, CHANGES_VIEW_CONTAINER_ID], visibleEditorsList: [], @@ -593,12 +596,13 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } return []; } - override async closeEditors(editors: readonly { editor: EditorInput }[]): Promise { + override async closeEditors(editors: readonly { editor: EditorInput }[], options?: ICloseEditorOptions): Promise { await harness.onCloseEditors?.(); for (const { editor } of editors) { const index = harness.activeGroupEditors.indexOf(editor); if (index !== -1) { harness.closeSuppressionFlags.push(harness.editorPartAutoVisibilitySuppressionDepth > 0); + harness.closeForceFlags.push(options?.force === true); harness.activeGroupEditors.splice(index, 1); harness.closedEditors.push(editor); } diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index f53c3fe987550..8a970a8edeaa6 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -392,10 +392,10 @@ MenuRegistry.appendMenuItem(MenuId.EditorTabsBarContext, { command: { id: Config // Editor Title Context Menu MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITOR_COMMAND_ID, title: localize('close', "Close") }, group: '1_close', order: 10, when: ActiveEditorCannotCloseContext.toNegated() }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeOthers', "Close Others"), precondition: EditorGroupEditorsCountContext.notEqualsTo('1') }, group: '1_close', order: 20, when: ActiveEditorCannotCloseContext.toNegated() }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_TO_THE_RIGHT_COMMAND_ID, title: localize('closeRight', "Close to the Right"), precondition: ContextKeyExpr.and(ActiveEditorLastInGroupContext.toNegated(), MultipleEditorsSelectedInGroupContext.negate()) }, group: '1_close', order: 30, when: ContextKeyExpr.and(EditorTabsVisibleContext, ActiveEditorCannotCloseContext.toNegated()) }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '1_close', order: 40, when: ActiveEditorCannotCloseContext.toNegated() }); -MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '1_close', order: 50, when: ActiveEditorCannotCloseContext.toNegated() }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_OTHER_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeOthers', "Close Others"), precondition: EditorGroupEditorsCountContext.notEqualsTo('1') }, group: '1_close', order: 20 }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_TO_THE_RIGHT_COMMAND_ID, title: localize('closeRight', "Close to the Right"), precondition: ContextKeyExpr.and(ActiveEditorLastInGroupContext.toNegated(), MultipleEditorsSelectedInGroupContext.negate()) }, group: '1_close', order: 30, when: EditorTabsVisibleContext }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_SAVED_EDITORS_COMMAND_ID, title: localize('closeAllSaved', "Close Saved") }, group: '1_close', order: 40 }); +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: CLOSE_EDITORS_IN_GROUP_COMMAND_ID, title: localize('closeAll', "Close All") }, group: '1_close', order: 50 }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: REOPEN_WITH_COMMAND_ID, title: localize('reopenWith', "Reopen Editor With...") }, group: '1_open', order: 10, when: ActiveEditorAvailableEditorIdsContext }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: KEEP_EDITOR_COMMAND_ID, title: localize('keepOpen', "Keep Open"), precondition: ActiveEditorPinnedContext.toNegated() }, group: '3_preview', order: 10, when: ContextKeyExpr.has('config.workbench.editor.enablePreview') }); MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: PIN_EDITOR_COMMAND_ID, title: localize('pin', "Pin") }, group: '3_preview', order: 20, when: ActiveEditorStickyContext.toNegated() }); @@ -571,7 +571,7 @@ appendEditorToolItem( title: localize('unpin', "Unpin"), icon: Codicon.pinned }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext, ActiveEditorCannotCloseContext.toNegated()), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext.toNegated(), ActiveEditorStickyContext), CLOSE_ORDER, { id: CLOSE_EDITOR_COMMAND_ID, @@ -587,7 +587,7 @@ appendEditorToolItem( title: localize('unpin', "Unpin"), icon: Codicon.pinnedDirty }, - ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext, ActiveEditorCannotCloseContext.toNegated()), + ContextKeyExpr.and(EditorTabsVisibleContext.toNegated(), ActiveEditorDirtyContext, ActiveEditorStickyContext), CLOSE_ORDER, { id: CLOSE_EDITOR_COMMAND_ID, diff --git a/src/vs/workbench/browser/parts/editor/editorActions.ts b/src/vs/workbench/browser/parts/editor/editorActions.ts index fd73f570db049..75764e249292b 100644 --- a/src/vs/workbench/browser/parts/editor/editorActions.ts +++ b/src/vs/workbench/browser/parts/editor/editorActions.ts @@ -32,7 +32,7 @@ import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { IKeybindingRule, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { ActiveEditorAvailableEditorIdsContext, ActiveEditorContext, ActiveEditorGroupEmptyContext, AuxiliaryBarVisibleContext, EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext, InAutomationContext, IsAuxiliaryWindowFocusedContext, MultipleEditorGroupsContext, SideBarVisibleContext } from '../../../common/contextkeys.js'; +import { ActiveEditorAvailableEditorIdsContext, ActiveEditorCannotCloseContext, ActiveEditorContext, ActiveEditorGroupEmptyContext, AuxiliaryBarVisibleContext, EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext, InAutomationContext, IsAuxiliaryWindowFocusedContext, MultipleEditorGroupsContext, SideBarVisibleContext } from '../../../common/contextkeys.js'; import { getActiveDocument } from '../../../../base/browser/dom.js'; import { ICommandActionTitle } from '../../../../platform/action/common/action.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; @@ -490,7 +490,8 @@ export class RevertAndCloseEditorAction extends Action2 { id: 'workbench.action.revertAndCloseActiveEditor', title: localize2('revertAndCloseActiveEditor', 'Revert and Close Editor'), f1: true, - category: Categories.View + category: Categories.View, + precondition: ActiveEditorCannotCloseContext.toNegated() }); } @@ -501,6 +502,10 @@ export class RevertAndCloseEditorAction extends Action2 { const activeEditorPane = editorService.activeEditorPane; if (activeEditorPane) { const editor = activeEditorPane.input; + if (editor.hasCapability(EditorInputCapabilities.CannotClose)) { + return; + } + const group = activeEditorPane.group; // first try a normal revert where the contents of the editor are restored diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 6c73463a501eb..3792926104f65 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -1564,7 +1564,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return false; } - if (!internalOptions?.force && editor.hasCapability(EditorInputCapabilities.CannotClose)) { + if (!options?.force && !internalOptions?.force && editor.hasCapability(EditorInputCapabilities.CannotClose)) { return false; } @@ -1903,7 +1903,7 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return true; } - const editors = this.doGetEditorsToClose(args).filter(editor => !editor.hasCapability(EditorInputCapabilities.CannotClose)); + const editors = this.doGetEditorsToClose(args).filter(editor => options?.force || !editor.hasCapability(EditorInputCapabilities.CannotClose)); if (!editors.length) { return true; } diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index a67604b1639e3..15006aa3e2680 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -1627,7 +1627,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.redrawTabLabel(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel); // Action - const hasUnpinAction = isCloseable && isTabSticky && options.tabActionUnpinVisibility; + const hasUnpinAction = isTabSticky && options.tabActionUnpinVisibility; const hasCloseAction = isCloseable && !hasUnpinAction && options.tabActionCloseVisibility; const hasAction = hasUnpinAction || hasCloseAction; diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index 7dbb581b5e173..b4a021f151f99 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -877,10 +877,8 @@ export const enum EditorInputCapabilities { /** * Signals that the editor cannot be closed through standard user * initiated close actions, such as the tab close button, middle - * click, or close commands (Close Editor, Close All, Close Group, - * etc). Internal or programmatic close operations (for example - * during shutdown, window reload, workspace changes or when an - * editor fails to restore) are not affected by this capability. + * click, or close commands. Callers with an explicit lifecycle + * requirement can force the editor to close. */ CannotClose = 1 << 13 } diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 9e4581bd8d45c..f3bbd63c2494a 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -130,6 +130,11 @@ export interface IMergeGroupOptions { export interface ICloseEditorOptions { readonly preserveFocus?: boolean; + + /** + * Forces editors to close even when they declare `EditorInputCapabilities.CannotClose`. + */ + readonly force?: boolean; } export type ICloseEditorsFilter = { diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index 4987dbc0d300d..039f77f2b039f 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -714,6 +714,11 @@ suite('EditorGroupsService', () => { assert.strictEqual(group.count, 1); assert.strictEqual(group.activeEditor, input); assert.ok(!input.gotDisposed); + + const forceClosed = await group.closeEditor(input, { force: true }); + assert.strictEqual(forceClosed, true); + assert.strictEqual(group.isEmpty, true); + assert.ok(input.gotDisposed); }); test('closeEditors - dirty editor handling', async () => { @@ -790,6 +795,11 @@ suite('EditorGroupsService', () => { assert.deepStrictEqual(group.getEditors(EditorsOrder.SEQUENTIAL), [input2]); assert.ok(input1.gotDisposed); assert.ok(!input2.gotDisposed); + + const forceCloseResult = await group.closeEditors([input2], { force: true }); + assert.strictEqual(forceCloseResult, true); + assert.strictEqual(group.isEmpty, true); + assert.ok(input2.gotDisposed); }); test('closeEditors (except one, sticky editor)', async () => { From 81c73068f7b572cc0185ac96f55fbae52c3d6c9f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 6 Aug 2026 17:34:02 +0200 Subject: [PATCH 5/5] editor: add non-closeable tab fixtures Add active, dirty, and sticky protected editor variants to the real editor tab-bar component fixture.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../editor/editorTabBar.fixture.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 856f146fba7cb..59dda4531cd10 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -233,6 +233,28 @@ function singleDirtyEditorSpecs(): IEditorSpec[] { ]; } +function cannotCloseEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, active: true }, + { resource: file('/project/src/app/main.ts'), pinned: true }, + { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true }, + ]; +} + +function cannotCloseDirtyEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, dirty: true, active: true }, + { resource: file('/project/src/app/main.ts'), pinned: true }, + ]; +} + +function cannotCloseStickyEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, sticky: true, active: true }, + { resource: file('/project/src/app/main.ts'), pinned: true }, + ]; +} + // ============================================================================ // File decorations // ============================================================================ @@ -597,6 +619,15 @@ function createFixtures(modernUI: boolean, additionalThemes: readonly ComponentF // Single-tab mode with a dirty editor: the single tab control renders the dirty dot. SingleTabDirty: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single' }, editors: singleDirtyEditorSpecs() }) }), + // Protected editors hide close affordances while ordinary neighboring tabs remain closeable. + CannotCloseActive: defineComponentFixture({ render: render(modernUI, { editors: cannotCloseEditorSpecs() }), additionalThemes }), + + // Protected dirty editors retain the modified indicator without exposing a close action. + CannotCloseDirty: defineComponentFixture({ render: render(modernUI, { editors: cannotCloseDirtyEditorSpecs() }), additionalThemes }), + + // Sticky protected editors retain the Unpin affordance because unpinning does not close them. + CannotCloseSticky: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabSizing: 'normal', tabActionUnpinVisibility: true }, editors: cannotCloseStickyEditorSpecs() }), additionalThemes }), + // Pinned tabs on a separate row combined with compact pinned sizing. PinnedSeparateRowCompact: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabsOnSeparateRow: true, pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }) }), };