diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index eb87c3c7da8dc1..5183564b3d57de 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -277,6 +277,10 @@ 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. +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. 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/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index c39a55ca2447c5..760076866866e9 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -82,7 +82,7 @@ width) captures a width to restore later. | **Hide Editor** (`right-panel-hide`) | Editor title bar (tab strip), after Maximize/Restore | 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. Always shown and always enabled, regardless of whether a detail panel is currently visible. | | **Show Editor** (`right-panel-show`) | Editor title bar (tab strip), same slot as Hide Editor | Reveals the (possibly empty) editor content again. Always shown whenever the editor area is closed, regardless of the active tab's detail support. | | **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()` remembers maximization, un-maximizes, then performs the collapse so the restored detail is also hidden. Reopening restores the complete side-pane composition before re-maximizing the editor. 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. | @@ -92,19 +92,19 @@ width) captures a width to restore later. **Managed Files tab.** The empty Files placeholder tab (and the Changes tab) is opened when the editor group is **empty** on a view-open trigger (a session switch or a side-pane reveal), and both remain present whenever the layout is **Detail only**. 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 while Editor is visible; the defaults return when the group empties and the side pane is reopened or when the layout enters Detail only. -**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) does not re-open defaults while Editor is visible; in Detail only, however, every reconcile restores both managed inputs because the detail panel depends on them. +**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) does not re-open defaults while Editor is visible; in Detail only, standard close actions cannot remove the managed inputs and every reconcile restores either input removed by lifecycle work. **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 invariant.** Whenever the side pane is **Detail only** (the aux-bar detail panel is visible 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 Changes and Files are always present. Every reconcile reads the settled, current part visibility and restores either input even when the group is non-empty; closing one while Detail only therefore re-creates it immediately. When Editor is visible, the strict "add only into an empty group" rule remains and a close is respected. +**Details-only invariant.** Whenever the side pane is **Detail only** (the aux-bar detail panel is visible 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 Changes and Files are always present. Both inputs adopt `EditorInputCapabilities.CannotClose`, and every reconcile reads the settled, current part visibility and restores either input removed by lifecycle work even when the group is non-empty. When Editor is visible, the capability is removed and the strict "add only into an empty group" rule remains. -**Closing managed tabs.** The user can close the managed Changes and Files tabs (they are non-preview, not sticky) while Editor is visible. Those 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), so closing one tab while another (or a real file) remains leaves the group non-empty and it is not re-created. Detail only is the exception: both inputs are required and immediately restored. 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 with Editor visible, 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 user can close the managed Changes and Files tabs (they are non-preview, not sticky) while Editor is visible. Those 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), so closing one tab while another (or a real file) remains leaves the group non-empty and it is not re-created. In Detail only, close commands and tab affordances consistently leave the backing inputs open, while internal lifecycle work can still force-close either input during working-set application, session switches, or stale-tab cleanup. Closing the last tab while Editor is visible closes the whole side pane; reopening it (empty group) restores the defaults. While a managed tab is closed for a workspace session with Editor visible, 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. **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 ea2497309668e2..5688be647c0b19 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; + 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 new file mode 100644 index 00000000000000..bb795303683810 --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/sessionChangesEditorInput.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { 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('updates managed Changes editor capabilities with editor area visibility', () => { + const instantiationService = disposables.add(new TestInstantiationService()); + 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++)); + + 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 f31aec1c5a9171..2a52daa9c7500f 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; + 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/browser/media/editorTabs.css b/src/vs/sessions/contrib/editor/browser/media/editorTabs.css index 26c7d9e9b139f0..1b8009b5606050 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 a4673aad0ff264..d372e3481b1aa8 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,41 @@ suite('Sessions - Editor Contribution', () => { }); }); + 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.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', () => { let editorVisible = false; const onDidChangePartVisibility = store.add(new Emitter()); diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts index a7d2fbacd6395c..097b538cc2e4bd 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts @@ -377,7 +377,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 136eaccb16047d..3b3a652810c292 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -2854,7 +2854,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 }); @@ -2903,7 +2903,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 }); @@ -3182,7 +3182,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(); @@ -3191,7 +3191,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 }); @@ -3223,7 +3223,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(); @@ -3232,7 +3232,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(); @@ -3244,7 +3244,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(); @@ -3253,7 +3253,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(); @@ -3274,7 +3274,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(); @@ -3347,7 +3347,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 () => { @@ -3359,7 +3360,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); @@ -3385,7 +3386,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]) { @@ -3425,7 +3426,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/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 1e4eb3b92d4df3..2df3b9ba45a005 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/auxiliaryEditorPart.ts b/src/vs/workbench/browser/parts/editor/auxiliaryEditorPart.ts index fcbeaa4a753965..0bfef71dc25751 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 52e0d7f5e72ae8..8a970a8edeaa63 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,7 +391,7 @@ 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_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 }); 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 }); @@ -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, diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 7928e0cde85769..ace6c86c53cd8e 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 183883989d87c2..75764e249292b7 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 @@ -585,6 +590,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 +810,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 ace30b01755eda..3792926104f651 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); } @@ -366,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); @@ -653,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; } } @@ -873,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 @@ -1549,6 +1564,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return false; } + if (!options?.force && !internalOptions?.force && editor.hasCapability(EditorInputCapabilities.CannotClose)) { + return false; + } + // Check for confirmation and veto const veto = await this.handleCloseConfirmation([editor]); if (veto) { @@ -1884,7 +1903,10 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return true; } - const editors = this.doGetEditorsToClose(args); + const editors = this.doGetEditorsToClose(args).filter(editor => options?.force || !editor.hasCapability(EditorInputCapabilities.CannotClose)); + if (!editors.length) { + return true; + } // Check for confirmation and veto const veto = await this.handleCloseConfirmation(editors.slice(0)); @@ -1955,7 +1977,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 +1999,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 +2015,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 +2087,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 +2107,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 7c0310fd9a5a06..d113781ba3df8b 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 30785a3e76f3ee..8b80b1955a4749 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 +179,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 +544,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); @@ -653,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 f45584aa4c3539..eb8d7f3910a70a 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 733c99a8de7538..15006aa3e26801 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); @@ -1057,7 +1061,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,6 +1620,7 @@ 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 @@ -1623,7 +1628,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Action const hasUnpinAction = isTabSticky && options.tabActionUnpinVisibility; - const hasCloseAction = !hasUnpinAction && options.tabActionCloseVisibility; + const hasCloseAction = isCloseable && !hasUnpinAction && options.tabActionCloseVisibility; const hasAction = hasUnpinAction || hasCloseAction; let tabAction; @@ -1644,6 +1649,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); diff --git a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts index 667082db3544ff..3677cbf4de6108 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 8d1c807d833f6e..22765db223266d 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 02a2be1812ffc9..28e1b6189fb805 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); } } @@ -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); diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index ae371f310c89cd..adbf28724fc6c0 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 4e47e009a606c3..b4a021f151f992 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -872,7 +872,15 @@ 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. Callers with an explicit lifecycle + * requirement can force the editor to close. + */ + 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 2904cc8dbb89a6..f3bbd63c2494a1 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 = { @@ -142,6 +147,7 @@ export type ICloseEditorsFilter = { export interface ICloseAllEditorsOptions { readonly excludeSticky?: boolean; readonly excludeConfirming?: boolean; + readonly force?: boolean; } export interface IEditorReplacement { @@ -1012,7 +1018,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 9e0d5b700936f7..039f77f2b039fe 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,26 @@ 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); + + 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 () => { const [part, instantiationService] = await createPart(); @@ -750,6 +778,30 @@ 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); + + 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 () => { const [part] = await createPart(); const group = part.activeGroup; @@ -1039,6 +1091,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 +1381,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 +1927,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(); 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 856f146fba7cbd..59dda4531cd102 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() }) }), };