From 80554a54235a53ce850e33a4e8444cfba1a0ae62 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Mon, 7 Sep 2026 18:06:10 +0100 Subject: [PATCH 01/26] feat: add connected tabs support with custom styling and layout adjustments --- .../lib/stylelint/vscode-known-variables.json | 4 + .../browser/parts/editor/editorTabsControl.ts | 4 +- .../modernUI/browser/connectedEditorTabs.ts | 19 +++ .../browser/media/connectedEditorTabs.css | 109 ++++++++++++++++++ .../modernUI/browser/modernUI.contribution.ts | 1 + .../browser/modernUI.contribution.test.ts | 17 +++ .../editor/editorTabBar.fixture.ts | 89 ++++++++++++-- .../editor/multiEditorTabsControl.test.ts | 37 +++++- 8 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts create mode 100644 src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index b69790f1a38e28..9ac78e912ef84e 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1074,6 +1074,10 @@ "--pane-header-size", "--model-hover-surface-background", "--modern-ui-activitybar-lane", + "--modern-ui-connected-tab-border", + "--modern-ui-connected-tab-gutter", + "--modern-ui-connected-tab-radius", + "--modern-ui-connected-tab-shoulder-radius", "--modern-ui-editor-border-color", "--modern-ui-editor-tab-action-active-background", "--modern-ui-editor-tab-action-active-hover-background", diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index c26072ed3d7fdd..6e0590648871f5 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -629,7 +629,9 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC // hosts using modern UI tabs opt into the same aligned height. const usesModernMultiTabHeight = this.parent.classList.contains('tabs') && this.parent.closest('.modern-ui-tabs'); if (usesModernMultiTabHeight || this.useModernUITabs) { - return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.modernUICompact : EditorTabsControl.EDITOR_TAB_HEIGHT.modernUI; + const height = isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.modernUICompact : EditorTabsControl.EDITOR_TAB_HEIGHT.modernUI; + // Connected tabs reserve one extra pixel for the separator below the gutter. + return height + (usesModernMultiTabHeight && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') ? 1 : 0); } return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.compact : EditorTabsControl.EDITOR_TAB_HEIGHT.normal; } diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts new file mode 100644 index 00000000000000..a6aff83eb23669 --- /dev/null +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { EDITOR_GROUP_BORDER } from '../../../common/theme.js'; +import './media/connectedEditorTabs.css'; + +registerThemingParticipant((theme, collector) => { + const border = theme.getColor(EDITOR_GROUP_BORDER); + const background = theme.getColor(editorBackground); + if (border && background) { + // Resolve alpha once so the cap, shoulder and separator joins cannot double the stroke. + const stroke = border.isTransparent() ? border : border.makeOpaque(background); + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-tabs { --modern-ui-connected-tab-border: ${stroke}; }`); + } +}); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css new file mode 100644 index 00000000000000..ba460b045d58d9 --- /dev/null +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Only the full Modern UI experiment joins editor tabs to the editor surface. + * The independently enabled modern tab treatment keeps its existing pills. */ +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { + --modern-ui-editor-tab-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-unfocused-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-unfocused-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-inactive-background: var(--vscode-panel-background); + --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-panel-background); + --modern-ui-editor-tab-action-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-unfocused-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-panel-background)); + --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); + --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { + background-color: var(--vscode-panel-background); + padding-left: var(--vscode-spacing-size60); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { + padding-bottom: var(--vscode-strokeThickness); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { + background-color: var(--vscode-panel-background); +} + +/* Keep the separator underneath the active fill without changing the tab, + * scrollbar, pinned-tab or drag-and-drop stacking order. */ +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { + z-index: 0; + background-color: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(:first-child) { + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size60) + var(--vscode-strokeThickness)); +} + +/* Preserve the existing explicit selection and focus borders in high contrast. */ +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { + --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-active-border: transparent; + --modern-ui-editor-tab-unfocused-active-border: transparent; + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); + z-index: 1; + border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-radius: var(--modern-ui-connected-tab-radius) var(--modern-ui-connected-tab-radius) 0 0; + box-shadow: none; +} + +/* The shoulders are concentric with adjacent tab corners; their radius includes + * the gap and stroke. The clipped shadow covers the cap's continuing side border. */ +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { + content: ''; + position: absolute; + bottom: calc(-1 * var(--vscode-strokeThickness)); + width: var(--modern-ui-connected-tab-shoulder-radius); + height: var(--modern-ui-connected-tab-shoulder-radius); + box-sizing: border-box; + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + clip-path: inset(0); + pointer-events: none; +} + +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { + right: 100%; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); +} + +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { + left: 100%; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { + border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; +} + +.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { + border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; +} diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index 3fd55793b50cec..928b08c032db20 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -36,6 +36,7 @@ import './media/sashHandles.css'; import './media/shadows.css'; import './media/statusBar.css'; import './media/tabs.css'; +import './connectedEditorTabs.js'; import './media/titlebar.css'; import '../../../services/themes/browser/modernTabColorCustomizations.js'; diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 4f9bbfee6623ea..fde712e059f3f6 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1784,6 +1784,23 @@ suite('ModernUIContribution', () => { }); }); + test('flattens connected tab strokes while preserving opaque and transparent borders', () => { + const strokes = [ + { background: '#1f1f1f', border: '#ffffff17' }, + { background: '#ffffff', border: '#e5e5e5' }, + { background: '#1f1f1f', border: '#00000000' }, + ].map(({ background, border }) => { + const theme = ColorThemeData.createUnloadedTheme('vs-dark', { + [editorBackground]: background, + 'editorGroup.border': border, + }); + const css = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return /--modern-ui-connected-tab-border: (?[^;]+);/.exec(css)?.groups?.stroke; + }); + + assert.deepStrictEqual(strokes, ['#333333', '#e5e5e5', 'rgba(0, 0, 0, 0)']); + }); + test('uses the registered modern tab colors', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; 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 74619a7f1d512d..d3460e06a8d8a2 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -29,6 +29,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActions, Verbosity } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; @@ -56,8 +57,9 @@ import { IOutlineService } from '../../../../services/outline/browser/outline.js import { LayoutSettings } from '../../../../services/layout/browser/layoutService.js'; import { TestContextService } from '../../../common/workbenchTestServices.js'; import { workbenchInstantiationService } from '../../workbenchTestServices.js'; -import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; import './editorTabBar.fixture.css'; // ============================================================================ @@ -364,6 +366,7 @@ export interface IEditorTabBarFixtureOptions { readonly colorCustomizations?: Readonly>; readonly forcedHoverTab?: number; readonly focusedTabAction?: number; + readonly editorContents?: string; } function createPartOptions(overrides?: Partial): IEditorPartOptions { @@ -519,7 +522,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: const editorContainer = $('.editor-container'); editorContainer.style.height = '96px'; - editorContainer.style.opacity = '0.6'; + editorContainer.style.backgroundColor = 'var(--vscode-editor-background)'; editorPart.appendChild(content); content.appendChild(groupContainer); @@ -527,6 +530,23 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: groupContainer.appendChild(editorContainer); container.appendChild(editorPart); + if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { + editorContainer.style.height = '240px'; + const editorServices = createEditorServices(disposableStore, { colorTheme: theme }); + const textModel = disposableStore.add(createTextModel(editorServices, options.editorContents, model.activeEditor.resource, 'typescript')); + const editor = disposableStore.add(editorServices.createInstance(CodeEditorWidget, editorContainer, { + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: 'on', + folding: false, + renderLineHighlight: 'none', + padding: { top: 16 }, + }, { contributions: [] })); + editor.setModel(textModel); + editor.layout(new Dimension(width, 240)); + } + container.style.width = `${width}px`; groupContainer.style.width = `${width}px`; @@ -571,13 +591,22 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: } function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { - return (ctx: ComponentFixtureContext) => renderEditorTabBarFixture(ctx, { ...options, modernUI }); + return (ctx: ComponentFixtureContext) => { + ctx.container.classList.toggle('modern-ui', modernUI); + renderEditorTabBarFixture(ctx, { ...options, modernUI }); + }; } function createFixtures(modernUI: boolean, additionalThemes: readonly ComponentFixtureAdditionalTheme[] = []) { return { // Baseline: multiple tabs with mixed sticky / pinned / preview / dirty state. - Default: defineComponentFixture({ render: render(modernUI, {}), additionalThemes }), + Default: defineComponentFixture({ + render: render(modernUI, {}), + additionalThemes, + expectedVisualDescriptions: modernUI ? [ + 'Inactive editor tabs and the tab strip use the panel background. The active tab flows into the editor with curved shoulders and an open lower edge. A subtle stroke separates the surfaces. High contrast retains explicit selection borders.', + ] : [], + }), // showTabs ShowTabsSingle: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single' }, breadcrumbs: {} }), additionalThemes }), @@ -710,11 +739,14 @@ function getModernEditorTabColorCustomizations(theme: ComponentFixtureContext['t } function renderThemeColors(options: Omit): (ctx: ComponentFixtureContext) => void { - return ctx => renderEditorTabBarFixture(ctx, { - ...options, - modernUI: true, - colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), - }); + return ctx => { + ctx.container.classList.add('modern-ui'); + renderEditorTabBarFixture(ctx, { + ...options, + modernUI: true, + colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), + }); + }; } function createThemeColorFixtures() { @@ -726,6 +758,28 @@ function createThemeColorFixtures() { }; } +function renderConnectedSurface(stroke: boolean): (ctx: ComponentFixtureContext) => void { + return render(true, { + editors: [ + { resource: file('/project/README.md'), pinned: true }, + { resource: file('/project/src/main.ts'), pinned: true, active: true }, + { resource: file('/project/src/styles.css'), pinned: true }, + { resource: file('/project/package.json'), pinned: true, dirty: true }, + ], + editorContents: [ + 'import { createApp } from \'./app\';', + '', + 'const app = createApp({', + '\ttheme: \'system\',', + '\trestoreSession: true,', + '});', + '', + 'await app.start();', + ].join('\n'), + colorCustomizations: stroke ? undefined : { 'editorGroup.border': 'transparent' }, + }); +} + export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FileIconThemes: defineThemedFixtureGroup({ Seti: defineComponentFixture({ fileIconTheme: 'vs-seti', render: render(false, {}) }), @@ -737,4 +791,21 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ...createFixtures(true, ['darkHighContrast']), ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), + ConnectedSurface: defineThemedFixtureGroup({ + Stroke: defineComponentFixture({ + render: renderConnectedSurface(true), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'The active main.ts tab joins the code editor with curved shoulders and no bottom divider. Inactive tabs sit on the panel-colored strip. A single subtle stroke follows the active tab into the strip separator. High contrast retains explicit focus and selection borders.', + 'The stroke remains uniform through the cap, shoulders and separator, including dark themes with translucent borders. There are no brighter overlaps at the tangent joins.', + 'In standard themes, the larger concave shoulders are concentric with the rounded corners of neighbouring tabs, maintaining even clearance. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', + ], + }), + WithoutStroke: defineComponentFixture({ + render: renderConnectedSurface(false), + expectedVisualDescriptions: [ + 'The active main.ts tab joins the code editor with curved shoulders. Only the contrast between the panel background and editor background distinguishes the surfaces; there is no decorative stroke.', + ], + }), + }), }); diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 2205b40ce05bbb..b49ed6a34fc46b 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, EventType, ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; +import { $, Dimension, EventType, ModifierKeyEmitter, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { Event } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -15,7 +15,7 @@ import { TreeViewsDnDService } from '../../../../../editor/common/services/treeV import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { MultiEditorTabsControl } from '../../../../browser/parts/editor/multiEditorTabsControl.js'; -import { EditorsOrder } from '../../../../common/editor.js'; +import { EditorsOrder, IEditorPartOptions } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IHostService } from '../../../../services/host/browser/host.js'; @@ -28,9 +28,12 @@ suite('MultiEditorTabsControl', () => { let container: HTMLElement; let hostService: TestHostService; + let control: MultiEditorTabsControl; + let partOptions: IEditorPartOptions; setup(() => { disposables = new DisposableStore(); + partOptions = { ...DEFAULT_EDITOR_PART_OPTIONS }; // The tabs control resolves the shared modifier key emitter on creation, // so dispose it again to keep each test independent of the Alt state that @@ -71,7 +74,7 @@ suite('MultiEditorTabsControl', () => { }; const groupsView = new class extends mock() { - override get partOptions() { return DEFAULT_EDITOR_PART_OPTIONS; } + override get partOptions() { return partOptions; } override get activeGroup(): IEditorGroupView { return groupView; } override get groups(): IEditorGroupView[] { return [groupView]; } override readonly onDidChangeEditorPartOptions = Event.None; @@ -86,7 +89,7 @@ suite('MultiEditorTabsControl', () => { container = $('.title.tabs'); mainWindow.document.body.appendChild(container); - const control = disposables.add(instantiationService.createInstance(MultiEditorTabsControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); + control = disposables.add(instantiationService.createInstance(MultiEditorTabsControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); }); @@ -122,6 +125,32 @@ suite('MultiEditorTabsControl', () => { mainWindow.dispatchEvent(new KeyboardEvent(pressed ? EventType.KEY_DOWN : EventType.KEY_UP, { key: 'Alt', altKey: pressed })); } + test('connected tabs reserve separator height without changing classic or shared modern tabs', async () => { + const readHeight = async () => { + control.layout({ container: Dimension.None, available: Dimension.None }); + await new Promise(resolve => { + disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve())); + }); + return control.getHeight(); + }; + const heights = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, tabHeight }; + control.updateOptions(oldOptions, partOptions); + container.classList.remove('modern-ui', 'modern-ui-tabs'); + const classic = await readHeight(); + container.classList.add('modern-ui-tabs'); + const sharedModern = await readHeight(); + container.classList.add('modern-ui'); + heights.push({ tabHeight, classic, sharedModern, connected: await readHeight() }); + } + assert.deepStrictEqual(heights, [ + { tabHeight: 'default', classic: 35, sharedModern: 32, connected: 33 }, + { tabHeight: 'compact', classic: 22, sharedModern: 28, connected: 29 }, + ]); + }); + test('Alt swaps the close action of the hovered tab only', () => { const actions = [tabActions()]; From 8d58ff9ce468cff86c2abbfa2b64612d4dcd3dc1 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 11:35:22 +0100 Subject: [PATCH 02/26] style: adjust connected tab styles and add tests for stroke behavior --- .../browser/media/connectedEditorTabs.css | 14 ++- .../browser/modernUI.contribution.test.ts | 112 ++++++++++++++++++ .../editor/editorTabBar.fixture.ts | 15 ++- 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index ba460b045d58d9..0911a67aad0ccd 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -21,13 +21,12 @@ --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); - --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40)); --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); } .modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { background-color: var(--vscode-panel-background); - padding-left: var(--vscode-spacing-size60); } .modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { @@ -64,15 +63,16 @@ --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-active-border: transparent; --modern-ui-editor-tab-unfocused-active-border: transparent; + top: calc(-1 * var(--vscode-strokeThickness)); + inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 1; border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); - border-radius: var(--modern-ui-connected-tab-radius) var(--modern-ui-connected-tab-radius) 0 0; + border-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)) calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)) 0 0; box-shadow: none; } -/* The shoulders are concentric with adjacent tab corners; their radius includes - * the gap and stroke. The clipped shadow covers the cap's continuing side border. */ +/* The clipped shadow covers the cap's continuing side border. */ .modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, .modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { content: ''; @@ -93,6 +93,10 @@ box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); } +.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before { + content: none; +} + .modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { left: 100%; border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index fde712e059f3f6..5317bae6fb0dcd 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1801,6 +1801,118 @@ suite('ModernUIContribution', () => { assert.deepStrictEqual(strokes, ['#333333', '#e5e5e5', 'rgba(0, 0, 0, 0)']); }); + test('paints connected tab strokes outside the fill without moving tab content', () => { + const root = document.createElement('div'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size60', '6px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--modern-ui-connected-tab-border', '#333333'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + tab.style.position = 'relative'; + tab.style.width = '120px'; + const fill = appendElement(tab, 'tab-fill'); + const { label, name } = createEditorTabLabel(tab); + name.textContent = 'main.ts'; + const targetWindow = getWindow(root); + + for (const modernUI of [true, false]) { + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui-tabs ${modernUI ? 'modern-ui' : ''} ${theme}`; + const connected = modernUI && !theme.startsWith('hc-'); + for (const activeGroup of [true, false]) { + group.classList.toggle('active', activeGroup); + for (const compact of [true, false]) { + title.classList.toggle('compact-height', compact); + tab.classList.remove('active'); + const tabBounds = tab.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const fillBounds = fill.getBoundingClientRect(); + tab.classList.add('active'); + const activeFillBounds = fill.getBoundingClientRect(); + const activeFillStyle = targetWindow.getComputedStyle(fill); + + assert.deepStrictEqual({ + tabBounds: tab.getBoundingClientRect().toJSON(), + labelBounds: label.getBoundingClientRect().toJSON(), + fillExpansion: [ + fillBounds.top - activeFillBounds.top, + fillBounds.left - activeFillBounds.left, + activeFillBounds.right - fillBounds.right, + ], + topRadius: activeFillStyle.borderTopLeftRadius, + }, { + tabBounds: tabBounds.toJSON(), + labelBounds: labelBounds.toJSON(), + fillExpansion: connected ? [1, 1, 1] : [0, 0, 0], + topRadius: connected ? '5px' : '4px', + }, JSON.stringify({ modernUI, theme, activeGroup, compact })); + } + } + } + } + }); + + test('adjusts connected shoulder radii for the outside stroke and omits the first outer shoulder', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs'; + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + const firstTab = appendElement(tabs, 'tab active'); + const firstFill = appendElement(firstTab, 'tab-fill'); + const nextTab = appendElement(tabs, 'tab'); + const nextFill = appendElement(nextTab, 'tab-fill'); + const targetWindow = getWindow(root); + const getShoulders = (fill: HTMLElement) => [ + targetWindow.getComputedStyle(fill, '::before').content, + targetWindow.getComputedStyle(fill, '::after').content, + ]; + + const firstShoulders = getShoulders(firstFill); + firstTab.classList.remove('active'); + nextTab.classList.add('active'); + const leftShoulderStyle = targetWindow.getComputedStyle(nextFill, '::before'); + const rightShoulderStyle = targetWindow.getComputedStyle(nextFill, '::after'); + + assert.deepStrictEqual({ + firstTab: firstShoulders, + nextTab: { + shoulders: getShoulders(nextFill), + radii: [leftShoulderStyle.borderBottomRightRadius, rightShoulderStyle.borderBottomLeftRadius], + sizes: [ + [leftShoulderStyle.width, leftShoulderStyle.height], + [rightShoulderStyle.width, rightShoulderStyle.height], + ], + }, + }, { + firstTab: ['none', '""'], + nextTab: { + shoulders: ['""', '""'], + radii: ['8px', '8px'], + sizes: [['8px', '8px'], ['8px', '8px']], + }, + }); + }); + test('uses the registered modern tab colors', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; 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 d3460e06a8d8a2..daecbf52d1519d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -758,11 +758,11 @@ function createThemeColorFixtures() { }; } -function renderConnectedSurface(stroke: boolean): (ctx: ComponentFixtureContext) => void { +function renderConnectedSurface(stroke: boolean, firstTabActive = false): (ctx: ComponentFixtureContext) => void { return render(true, { editors: [ - { resource: file('/project/README.md'), pinned: true }, - { resource: file('/project/src/main.ts'), pinned: true, active: true }, + { resource: file('/project/README.md'), pinned: true, active: firstTabActive }, + { resource: file('/project/src/main.ts'), pinned: true, active: !firstTabActive }, { resource: file('/project/src/styles.css'), pinned: true }, { resource: file('/project/package.json'), pinned: true, dirty: true }, ], @@ -798,7 +798,14 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { expectedVisualDescriptions: [ 'The active main.ts tab joins the code editor with curved shoulders and no bottom divider. Inactive tabs sit on the panel-colored strip. A single subtle stroke follows the active tab into the strip separator. High contrast retains explicit focus and selection borders.', 'The stroke remains uniform through the cap, shoulders and separator, including dark themes with translucent borders. There are no brighter overlaps at the tangent joins.', - 'In standard themes, the larger concave shoulders are concentric with the rounded corners of neighbouring tabs, maintaining even clearance. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', + 'In standard themes, the concave shoulder radii are reduced by the outward stroke offset while the convex cap radii grow. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', + ], + }), + FirstTabActive: defineComponentFixture({ + render: renderConnectedSurface(true, true), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'The first active tab has a straight left edge meeting the strip separator, with no clipped outer shoulder. The right shoulder still curves into the separator. High contrast retains explicit selection borders.', ], }), WithoutStroke: defineComponentFixture({ From 32f7de14d86c94d98fbb0cd10bfd024d71053956 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 11:52:33 +0100 Subject: [PATCH 03/26] feat: introduce connected editor tab style with customizable border --- .../lib/stylelint/vscode-known-variables.json | 1 + .../browser/workbench.contribution.ts | 14 ++- src/vs/workbench/common/theme.ts | 2 + src/vs/workbench/contrib/modernUI/README.md | 26 +++++ .../modernUI/browser/connectedEditorTabs.ts | 6 +- .../browser/media/connectedEditorTabs.css | 37 +++--- .../modernUI/browser/modernUI.contribution.ts | 13 ++- .../browser/modernUI.contribution.test.ts | 110 +++++++++++++++++- .../services/layout/browser/layoutService.ts | 6 + .../editor/editorTabBar.fixture.ts | 12 +- 10 files changed, 194 insertions(+), 33 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 9ac78e912ef84e..d93871b4bb57cc 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -560,6 +560,7 @@ "--vscode-modernEditorTab-activeForeground", "--vscode-modernEditorTab-activeHoverActionBackground", "--vscode-modernEditorTab-activeHoverBackground", + "--vscode-modernEditorTab-connectedBorder", "--vscode-modernEditorTab-hoverActionBackground", "--vscode-modernEditorTab-hoverBackground", "--vscode-modernEditorTab-hoverForeground", diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index e813c53fc54376..049d0a749c2b4a 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -15,7 +15,7 @@ import { NotificationsPosition, NotificationsSettings } from '../common/notifica import { ACCOUNTS_AVATAR_SETTING } from '../services/authentication/common/authentication.js'; import { CustomEditorLabelService } from '../services/editor/common/customEditorLabelService.js'; import { MOUSE_BACK_FORWARD_NAVIGATION_SETTING } from '../services/history/common/history.js'; -import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings, ModernUIDensity } from '../services/layout/browser/layoutService.js'; +import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../services/layout/browser/layoutService.js'; import { defaultWindowTitle, defaultWindowTitleSeparator } from './parts/titlebar/windowTitle.js'; const registry = Registry.as(ConfigurationExtensions.Configuration); @@ -843,6 +843,18 @@ const registry = Registry.as(ConfigurationExtensions.Con 'tags': ['experimental'], 'markdownDescription': localize({ key: 'modernUIUppercaseViewHeaders', comment: ['{0} is a placeholder for a setting identifier.'] }, "Controls whether view headers, side bar titles, and panel tabs use uppercase text when {0} is enabled.", '`#workbench.experimental.modernUI#`'), }, + [LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE]: { + 'type': 'string', + 'enum': [ModernUIEditorTabStyle.Connected, ModernUIEditorTabStyle.Pill], + 'enumDescriptions': [ + localize('modernUIEditorTabStyle.connected', "Connect the active tab to the editor surface with an outside border and curved shoulders."), + localize('modernUIEditorTabStyle.pill', "Show editor tabs as separate rounded pills."), + ], + 'default': ModernUIEditorTabStyle.Connected, + 'tags': ['experimental'], + 'markdownDescription': localize({ key: 'modernUIEditorTabStyle', comment: ['{0} is a placeholder for a setting identifier.'] }, "Controls the editor tab style when {0} is enabled. High contrast themes retain explicit selection and focus borders.", '`#workbench.experimental.modernUI#`'), + agentsWindow: { default: ModernUIEditorTabStyle.Pill, readOnly: true }, + }, } }); diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index fdb58dce364a99..05e76023ec550f 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -714,6 +714,8 @@ export const MODERN_TAB_HOVER_FOREGROUND = registerColor('modernTab.hoverForegro // < --- Modern Editor Tabs --- > +export const MODERN_EDITOR_TAB_CONNECTED_BORDER = registerColor('modernEditorTab.connectedBorder', EDITOR_GROUP_BORDER, localize('modernEditorTabConnectedBorder', "Border color of the active editor tab and the tab strip separator when the connected modern editor tab style is enabled. Does not replace high contrast selection or focus borders.")); + export const MODERN_EDITOR_TAB_ACTIVE_BACKGROUND = registerColor('modernEditorTab.activeBackground', MODERN_TAB_ACTIVE_BACKGROUND, localize('modernEditorTabActiveBackground', "Background color of active editor tabs when the modern tab style is enabled.")); export const MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND = registerColor('modernEditorTab.activeActionBackground', opaque(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, editorBackground), localize('modernEditorTabActiveActionBackground', "Opaque background color of tab actions on active editor tabs when the modern tab style is enabled.")); diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index 14f3607c4face2..cb8ca89167c04e 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -7,6 +7,31 @@ Modern UI uses the standard workbench color theme system. Theme authors can use The Modern UI colors below are experimental and require a build that supports them. Enable `workbench.experimental.modernUI` to use them in the main workbench. The shared `modernTab.*` and `modernEditorTab.*` colors also apply to the modern tab style in the Agents window. `statusBar.inactiveBackground` works in both classic and Modern UI layouts. +## Editor tab style + +`workbench.experimental.modernUIEditorTabStyle` chooses the editor tab treatment when `workbench.experimental.modernUI` is enabled: + +- `connected` (default): the active tab joins the editor surface, with an outside stroke and curved shoulders. The first tab has a straight left edge. +- `pill`: separate rounded tabs, without the connected stroke or shoulders. + +Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. High contrast themes keep their explicit selection and focus borders. + +Customize `modernEditorTab.connectedBorder` to change the active tab stroke, both shoulders, and the strip separator together, independently of editor-split borders. It defaults to `editorGroup.border`. Translucent strokes are composited over `editor.background` to avoid brighter overlaps; `#00000000` hides the decorative stroke. + +```json +{ + "workbench.experimental.modernUI": true, + "workbench.experimental.modernUIEditorTabStyle": "connected", + "workbench.colorCustomizations": { + "modernEditorTab.connectedBorder": "#5b8def" + } +} +``` + +The connected design uses `editor.background` for the active tab and `panel.background` for the strip and inactive tabs, so the active tab reads as part of the editor. Its hover fill is derived from `foreground` over `panel.background`. Existing tab foreground customizations continue to apply. The separate `modernEditorTab.*Background` colors in the table below control the pill treatment. + +## Colors + | Color ID | Purpose | Default | | --- | --- | --- | | `modernUI.shellBackground` | Background of the shell gutters around floating workbench surfaces in an active window | `titleBar.activeBackground` | @@ -22,6 +47,7 @@ The Modern UI colors below are experimental and require a build that supports th | `modernTab.hoverBackground` | Background of hovered Modern UI tabs | `list.hoverBackground` | | `modernTab.hoverForeground` | Foreground of hovered Modern UI tabs | `list.hoverForeground`, then `foreground` | | `modernEditorTab.activeBackground` | Background of active Modern UI editor tabs | `modernTab.activeBackground` | +| `modernEditorTab.connectedBorder` | Stroke around the active connected editor tab and along its strip separator | `editorGroup.border` | | `modernEditorTab.activeActionBackground` | Opaque background of actions on active Modern UI editor tabs | `modernEditorTab.activeBackground` composited over `editor.background` | | `modernEditorTab.activeForeground` | Foreground of active Modern UI editor tabs | `modernTab.activeForeground` | | `modernEditorTab.activeHoverBackground` | Background of active Modern UI editor tabs when hovered | `modernEditorTab.hoverBackground` | diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts index a6aff83eb23669..036881ec4fca5f 100644 --- a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -5,15 +5,15 @@ import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; -import { EDITOR_GROUP_BORDER } from '../../../common/theme.js'; +import { MODERN_EDITOR_TAB_CONNECTED_BORDER } from '../../../common/theme.js'; import './media/connectedEditorTabs.css'; registerThemingParticipant((theme, collector) => { - const border = theme.getColor(EDITOR_GROUP_BORDER); + const border = theme.getColor(MODERN_EDITOR_TAB_CONNECTED_BORDER); const background = theme.getColor(editorBackground); if (border && background) { // Resolve alpha once so the cap, shoulder and separator joins cannot double the stroke. const stroke = border.isTransparent() ? border : border.makeOpaque(background); - collector.addRule(`.monaco-workbench.modern-ui.modern-ui-tabs { --modern-ui-connected-tab-border: ${stroke}; }`); + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-border: ${stroke}; }`); } }); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 0911a67aad0ccd..2ff74703ca34cb 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -3,9 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Only the full Modern UI experiment joins editor tabs to the editor surface. - * The independently enabled modern tab treatment keeps its existing pills. */ -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { +/* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { --modern-ui-editor-tab-active-background: var(--vscode-editor-background); --modern-ui-editor-tab-unfocused-active-background: var(--vscode-editor-background); --modern-ui-editor-tab-active-hover-background: var(--vscode-editor-background); @@ -25,40 +24,40 @@ --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { background-color: var(--vscode-panel-background); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { padding-bottom: var(--vscode-strokeThickness); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { background-color: var(--vscode-panel-background); } /* Keep the separator underneath the active fill without changing the tab, * scrollbar, pinned-tab or drag-and-drop stacking order. */ -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { z-index: 0; background-color: var(--modern-ui-connected-tab-border); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(:first-child) { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(:first-child) { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size60) + var(--vscode-strokeThickness)); } /* Preserve the existing explicit selection and focus borders in high contrast. */ -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-active-border: transparent; @@ -73,8 +72,8 @@ } /* The clipped shadow covers the cap's continuing side border. */ -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { content: ''; position: absolute; bottom: calc(-1 * var(--vscode-strokeThickness)); @@ -86,28 +85,28 @@ pointer-events: none; } -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { right: 100%; border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); } -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before { content: none; } -.modern-ui.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { left: 100%; border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } -.modern-ui.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; } diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index 928b08c032db20..ff07189db74fba 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -9,7 +9,7 @@ import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../plat import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity } from '../../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { COMPACT_NOTIFICATION_ROW_HEIGHT, DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../../browser/parts/notifications/notificationsViewer.js'; @@ -56,6 +56,7 @@ interface IModernUIModule { const MODERN_UI_CLASS = 'modern-ui'; const MODERN_UI_COMPACT_CLASS = 'modern-ui-compact'; const MODERN_UI_TABS_CLASS = 'modern-ui-tabs'; +const MODERN_UI_CONNECTED_EDITOR_TABS_CLASS = 'modern-ui-connected-editor-tabs'; const MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS = 'modern-ui-notifications-dialogs'; const MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS = 'modern-ui-uppercase-view-headers'; @@ -143,7 +144,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri // A config change re-applies to every container (the global `update()` // covers all windows, including auxiliary ones). this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(LayoutSettings.MODERN_UI) || e.affectsConfiguration(LayoutSettings.MODERN_UI_DENSITY) || e.affectsConfiguration(LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI) || e.affectsConfiguration(LayoutSettings.MODERN_UI_DENSITY) || e.affectsConfiguration(LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS) || e.affectsConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE)) { this.update(); // Some modules change layout metrics, so a relayout is required once // their classes and corresponding layout values are updated. @@ -177,12 +178,16 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri return this.configurationService.getValue(LayoutSettings.MODERN_UI_DENSITY) === ModernUIDensity.Compact; } + private useConnectedEditorTabs(): boolean { + return this.configurationService.getValue(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE) === ModernUIEditorTabStyle.Connected; + } + private getLayoutAffectingState(): string { if (!this.isEnabled() || !this.hasLayoutAffectingModule) { return 'disabled'; } - return this.isCompact() ? ModernUIDensity.Compact : ModernUIDensity.Default; + return `${this.isCompact() ? ModernUIDensity.Compact : ModernUIDensity.Default}/${this.useConnectedEditorTabs() ? ModernUIEditorTabStyle.Connected : ModernUIEditorTabStyle.Pill}`; } private update(): void { @@ -201,6 +206,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri container.classList.toggle(MODERN_UI_CLASS, enabled); container.classList.toggle(MODERN_UI_COMPACT_CLASS, compact); container.classList.toggle(MODERN_UI_TABS_CLASS, enabled); + container.classList.toggle(MODERN_UI_CONNECTED_EDITOR_TABS_CLASS, enabled && this.useConnectedEditorTabs()); container.classList.toggle(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS, enabled); container.classList.toggle(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS, useUppercaseViewHeaders); } @@ -227,6 +233,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri container.classList.remove(MODERN_UI_CLASS); container.classList.remove(MODERN_UI_COMPACT_CLASS); container.classList.remove(MODERN_UI_TABS_CLASS); + container.classList.remove(MODERN_UI_CONNECTED_EDITOR_TABS_CLASS); container.classList.remove(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS); container.classList.remove(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS); } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 5317bae6fb0dcd..dd93cbb6f94099 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -28,7 +28,7 @@ import { foreground } from '../../../../../platform/theme/common/colors/baseColo import { Extensions as ThemeServiceExtensions, IThemingRegistry } from '../../../../../platform/theme/common/themeService.js'; import { EDITOR_BORDER, MODERN_ACTIVITY_BAR_BACKGROUND, MODERN_ACTIVITY_BAR_BORDER, MODERN_ACTIVITY_BAR_INACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_FOREGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, MODERN_PANEL_BORDER, MODERN_SASH_GRIP_FOREGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, MODERN_UI_INACTIVE_SHELL_BACKGROUND, MODERN_UI_SHELL_BACKGROUND, PANEL_SECTION_BORDER, PANEL_SECTION_HEADER_BORDER, SIDE_BAR_SECTION_HEADER_BORDER, SURFACE_BORDER, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BACKGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_INACTIVE_BACKGROUND } from '../../../../common/theme.js'; import { TestEnvironmentService, TestLayoutService } from '../../../../test/browser/workbenchTestServices.js'; -import { LayoutSettings, ModernUIDensity } from '../../../../services/layout/browser/layoutService.js'; +import { LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; import { PRESERVE_MERGED_WORKSPACE_NAME_CASE_CLASS, PRESERVE_WORKSPACE_NAME_CASE_CLASS, shouldPreserveWorkspaceNameCase } from '../../../files/browser/views/explorerView.js'; import { URI } from '../../../../../base/common/uri.js'; import { joinPath } from '../../../../../base/common/resources.js'; @@ -332,6 +332,61 @@ suite('ModernUIContribution', () => { }); }); + test('switches connected editor tabs across windows and cleans up on disable and disposal', async () => { + const configurationService = new TestConfigurationService({ + [LayoutSettings.MODERN_UI]: true, + [LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE]: ModernUIEditorTabStyle.Connected, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const layoutService = new ModernUITestLayoutService(); + store.add(layoutService.onDidAddContainerEmitter); + const contribution = store.add(new ModernUIContribution(configurationService, layoutService)); + const auxiliaryContainer = document.createElement('div'); + const getState = () => ({ + connected: layoutService.containers.map(container => container.classList.contains('modern-ui-connected-editor-tabs')), + modernTabs: layoutService.containers.map(container => container.classList.contains('modern-ui-tabs')), + layoutCount: layoutService.layoutCount, + }); + const update = async (key: string, value: boolean | ModernUIEditorTabStyle) => { + await configurationService.setUserConfiguration(key, value); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: setting => setting === key, + source: ConfigurationTarget.USER, + affectedKeys: new Set([key]), + change: { keys: [key], overrides: [] }, + }); + }; + + const startup = getState(); + layoutService.addContainer(auxiliaryContainer, store.add(new DisposableStore())); + const auxiliaryStartup = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Pill); + const pill = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Connected); + const connected = getState(); + await update(LayoutSettings.MODERN_UI, false); + const disabled = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Pill); + const changedWhileDisabled = getState(); + await update(LayoutSettings.MODERN_UI, true); + const reenabled = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Connected); + contribution.dispose(); + + assert.deepStrictEqual({ + startup, auxiliaryStartup, pill, connected, disabled, changedWhileDisabled, reenabled, disposed: getState(), + }, { + startup: { connected: [true], modernTabs: [true], layoutCount: 0 }, + auxiliaryStartup: { connected: [true, true], modernTabs: [true, true], layoutCount: 0 }, + pill: { connected: [false, false], modernTabs: [true, true], layoutCount: 1 }, + connected: { connected: [true, true], modernTabs: [true, true], layoutCount: 2 }, + disabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3 }, + changedWhileDisabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3 }, + reenabled: { connected: [false, false], modernTabs: [true, true], layoutCount: 4 }, + disposed: { connected: [false, false], modernTabs: [false, false], layoutCount: 5 }, + }); + }); + test('supports isolated notification and dialog presentation', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-notifications-dialogs nostatusbar'; @@ -1801,6 +1856,49 @@ suite('ModernUIContribution', () => { assert.deepStrictEqual(strokes, ['#333333', '#e5e5e5', 'rgba(0, 0, 0, 0)']); }); + test('customizes the connected tab stroke without changing editor split borders', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + appendElement(tabs, 'tab'); + const tab = appendElement(tabs, 'tab active'); + const fill = appendElement(tab, 'tab-fill'); + const targetWindow = getWindow(root); + const results = ['#2468ac', '#ff000080', '#00000000'].map(color => { + const theme = ColorThemeData.createUnloadedTheme('vs', { + [editorBackground]: '#ffffff', + 'editorGroup.border': '#123456', + 'modernEditorTab.connectedBorder': '#abcdef', + }); + theme.setCustomColors({ 'modernEditorTab.connectedBorder': color }); + style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return { + strokes: [ + targetWindow.getComputedStyle(fill).borderTopColor, + targetWindow.getComputedStyle(fill, '::before').borderRightColor, + targetWindow.getComputedStyle(fill, '::after').borderLeftColor, + targetWindow.getComputedStyle(row, '::after').backgroundColor, + ], + editorSplitBorder: theme.getColor('editorGroup.border')?.toString(), + }; + }); + + assert.deepStrictEqual(results, [ + { strokes: Array(4).fill('rgb(36, 104, 172)'), editorSplitBorder: '#123456' }, + { strokes: Array(4).fill('rgb(255, 126, 126)'), editorSplitBorder: '#123456' }, + { strokes: Array(4).fill('rgba(0, 0, 0, 0)'), editorSplitBorder: '#123456' }, + ]); + }); + test('paints connected tab strokes outside the fill without moving tab content', () => { const root = document.createElement('div'); root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -1826,10 +1924,10 @@ suite('ModernUIContribution', () => { name.textContent = 'main.ts'; const targetWindow = getWindow(root); - for (const modernUI of [true, false]) { + for (const classes of ['', 'modern-ui', 'modern-ui modern-ui-connected-editor-tabs', 'modern-ui-connected-editor-tabs']) { for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { - root.className = `monaco-workbench modern-ui-tabs ${modernUI ? 'modern-ui' : ''} ${theme}`; - const connected = modernUI && !theme.startsWith('hc-'); + root.className = `monaco-workbench modern-ui-tabs ${classes} ${theme}`; + const connected = classes === 'modern-ui modern-ui-connected-editor-tabs' && !theme.startsWith('hc-'); for (const activeGroup of [true, false]) { group.classList.toggle('active', activeGroup); for (const compact of [true, false]) { @@ -1856,7 +1954,7 @@ suite('ModernUIContribution', () => { labelBounds: labelBounds.toJSON(), fillExpansion: connected ? [1, 1, 1] : [0, 0, 0], topRadius: connected ? '5px' : '4px', - }, JSON.stringify({ modernUI, theme, activeGroup, compact })); + }, JSON.stringify({ classes, theme, activeGroup, compact })); } } } @@ -1865,7 +1963,7 @@ suite('ModernUIContribution', () => { test('adjusts connected shoulder radii for the outside stroke and omits the first outer shoulder', () => { const root = document.createElement('div'); - root.className = 'monaco-workbench modern-ui modern-ui-tabs'; + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size40', '4px'); root.style.setProperty('--vscode-cornerRadius-small', '4px'); root.style.setProperty('--vscode-strokeThickness', '1px'); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 6f4e681591b21d..dd4707a052a966 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -53,9 +53,15 @@ export const enum LayoutSettings { SHADOWS = 'workbench.shadows', MODERN_UI = 'workbench.experimental.modernUI', MODERN_UI_DENSITY = 'window.density.layout', + MODERN_UI_EDITOR_TAB_STYLE = 'workbench.experimental.modernUIEditorTabStyle', MODERN_UI_UPPERCASE_VIEW_HEADERS = 'workbench.experimental.modernUIUppercaseViewHeaders' } +export const enum ModernUIEditorTabStyle { + Connected = 'connected', + Pill = 'pill' +} + export const enum ModernUIDensity { Default = 'default', Compact = 'compact' 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 daecbf52d1519d..314ff2d4070216 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -54,7 +54,7 @@ import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../. import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { IOutlineService } from '../../../../services/outline/browser/outline.js'; -import { LayoutSettings } from '../../../../services/layout/browser/layoutService.js'; +import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; import { TestContextService } from '../../../common/workbenchTestServices.js'; import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; @@ -348,6 +348,7 @@ function createFixtureEditorTitleActions(store: DisposableStore, menuId: MenuId) export interface IEditorTabBarFixtureOptions { readonly modernUI: boolean; readonly partOptions?: Partial; + readonly editorTabStyle?: ModernUIEditorTabStyle; readonly editors?: IEditorSpec[]; readonly breadcrumbs?: { readonly filePath?: 'on' | 'off' | 'last'; @@ -422,6 +423,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: icons: options.breadcrumbs?.icons ?? true, }); configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, options.modernUI); + configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, options.editorTabStyle ?? ModernUIEditorTabStyle.Connected); const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService, @@ -512,6 +514,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: const groupContainer = $(isGroupActive ? '.editor-group-container.active' : '.editor-group-container'); const titleContainer = $('.title'); container.classList.toggle('modern-ui-tabs', options.modernUI); + container.classList.toggle('modern-ui-connected-editor-tabs', options.modernUI && (options.editorTabStyle ?? ModernUIEditorTabStyle.Connected) === ModernUIEditorTabStyle.Connected); titleContainer.classList.toggle('tabs', partOptions.showTabs === 'multiple'); titleContainer.classList.toggle('show-file-icons', partOptions.showIcons); @@ -789,6 +792,13 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ModernUIOff: defineThemedFixtureGroup(createFixtures(false, ['darkHighContrast'])), ModernUIOn: defineThemedFixtureGroup({ ...createFixtures(true, ['darkHighContrast']), + Pill: defineComponentFixture({ + render: render(true, { editorTabStyle: ModernUIEditorTabStyle.Pill }), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'Editor tabs remain separate rounded pills with no connecting shoulders or connected strip border. High contrast retains explicit selection and focus borders.', + ], + }), ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), ConnectedSurface: defineThemedFixtureGroup({ From 8732220816c1e0760a178c5c2a56fb08ddb6554f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 14:16:01 +0100 Subject: [PATCH 04/26] feat: enhance connected editor tabs with clipping and overflow handling - Added functionality to manage connected tab clipping and overflow edges in MultiEditorTabsControl. - Introduced new CSS styles for connected editor tabs to improve visual consistency and interaction. - Updated tests to validate the behavior of connected tabs, including clipping and overflow scenarios. - Refactored tab rendering logic to accommodate new connected tab features and ensure proper layout. --- .../lib/stylelint/vscode-known-variables.json | 1 + .../editor/media/multieditortabscontrol.css | 8 + .../parts/editor/multiEditorTabsControl.ts | 86 +++++++- .../browser/media/connectedEditorTabs.css | 130 ++++++++++-- .../editor/editorTabBar.fixture.css | 4 + .../editor/editorTabBar.fixture.ts | 61 +++++- .../editor/multiEditorTabsControl.test.ts | 200 +++++++++++++++++- 7 files changed, 454 insertions(+), 36 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index d93871b4bb57cc..513fbe96af8392 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1076,6 +1076,7 @@ "--model-hover-surface-background", "--modern-ui-activitybar-lane", "--modern-ui-connected-tab-border", + "--modern-ui-connected-tab-cap-radius", "--modern-ui-connected-tab-gutter", "--modern-ui-connected-tab-radius", "--modern-ui-connected-tab-shoulder-radius", diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index c1e576e5a9ca3a..72ad2119dd0399 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -123,6 +123,14 @@ outline-offset: -2px; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-connected-edge { + display: none; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge { + display: none; +} + /* Tab Background Color in/active group/tab */ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { background-color: var(--vscode-tab-unfocusedInactiveBackground); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 0ca5e0ba4d40fa..cea0083ec6a9dc 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -32,7 +32,7 @@ import { ResourcesDropHandler, DraggedEditorIdentifier, DraggedEditorGroupIdenti import { Color } from '../../../../base/common/color.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { MergeGroupMode, IMergeGroupOptions } from '../../../services/editor/common/editorGroupsService.js'; -import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow, ModifierKeyEmitter, $ } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow, ModifierKeyEmitter, $, isHTMLElement } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; import { IEditorGroupMenuIds, IEditorGroupsView, EditorServiceImpl, IEditorGroupView, IInternalEditorOpenOptions, IEditorPartsView, prepareMoveCopyEditors } from './editor.js'; import { CloseEditorTabAction, CloseOtherEditorTabsInGroupAction, UnpinEditorAction } from './editorActions.js'; @@ -111,8 +111,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { private stickyTabsBackground: HTMLElement | undefined; private tabsContainer: HTMLElement | undefined; private tabsScrollbar: ScrollableElement | undefined; + private connectedTabOverflowEdge: HTMLElement | undefined; private addTabContainer: HTMLElement | undefined; private tabSizingFixedDisposables: DisposableStore | undefined; + private connectedTabBounds: { tab: HTMLElement; overflowEdge: HTMLElement; fillLeft: number; fillRight: number; viewportLeft: number; viewportRight: number; clippingEdgeExtent: number; shoulderExtent: number } | undefined; private readonly closeEditorAction = this._register(this.instantiationService.createInstance(CloseEditorTabAction, CloseEditorTabAction.ID, CloseEditorTabAction.LABEL)); private readonly unpinEditorAction = this._register(this.instantiationService.createInstance(UnpinEditorAction, UnpinEditorAction.ID, UnpinEditorAction.LABEL)); @@ -270,6 +272,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.tabsScrollbar = this.createTabsScrollbar(this.tabsContainer); this.tabsAndActionsContainer.appendChild(this.tabsScrollbar.getDomNode()); this.tabsScrollbar.getDomNode().appendChild(this.stickyTabsBackground); + this.connectedTabOverflowEdge = $('.tab-connected-overflow-edge', { 'aria-hidden': true }); + this.tabsScrollbar.getDomNode().appendChild(this.connectedTabOverflowEdge); // Tabs Container listeners this.registerTabsContainerListeners(this.tabsContainer, this.tabsScrollbar); @@ -324,6 +328,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this._register(tabsScrollbar.onScroll(e => { if (e.scrollLeftChanged) { scrollable.scrollLeft = e.scrollLeft; + this.updateConnectedTabClipping(e.scrollLeft); } })); @@ -839,6 +844,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.forEachTab((editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => { this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar); }); + if (this.connectedTabBounds || this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black):not(.hc-light)')) { + this.clearConnectedTabClipping(); + this.layout(this.dimensions); + } } private updateEditorLabelScheduler = this._register(new RunOnceScheduler(() => this.doUpdateEditorLabels(), 0)); @@ -970,10 +979,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Gesture Support const gestureDisposable = Gesture.addTarget(tabContainer); - // Tab Fill (Modern UI pill background). A real element is used because the - // tab's ::before/::after pseudo-elements are reserved for drop-target indicators. + // Modern UI fill and shoulder masks. Real elements leave the tab's + // pseudo-elements available for drop-target indicators. const tabFillContainer = $('.tab-fill', { 'aria-hidden': true }); tabContainer.appendChild(tabFillContainer); + const tabConnectedEdgeContainer = $('.tab-connected-edge', { 'aria-hidden': true }); + tabContainer.appendChild(tabConnectedEdgeContainer); // Tab Border Top const tabBorderTopContainer = $('.tab-border-top-container'); @@ -2045,6 +2056,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.doLayoutTabsNonWrapping(options); } else { assertReturnsDefined(this.stickyTabsBackground).style.width = '0px'; + this.clearConnectedTabClipping(); } } @@ -2250,6 +2262,34 @@ export class MultiEditorTabsControl extends EditorTabsControl { } assertReturnsDefined(this.stickyTabsBackground).style.width = `${stickyTabsWidth}px`; + this.clearConnectedTabClipping(); + const activeTabFill = activeTab?.firstElementChild; + const overflowEdge = this.connectedTabOverflowEdge; + if (activeTab && isHTMLElement(activeTabFill) && overflowEdge && !activeTab.classList.contains('multi-selected') && !activeTabPositionStatic && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black):not(.hc-light)')) { + const scrollLeft = tabsScrollbar.getScrollPosition().scrollLeft; + const tabsBounds = tabsContainer.getBoundingClientRect(); + const fillBounds = activeTabFill.getBoundingClientRect(); + const scrollableBounds = tabsScrollbar.getDomNode().getBoundingClientRect(); + const targetWindow = getWindow(activeTabFill); + const fillStyle = targetWindow.getComputedStyle(activeTabFill); + const fillLeft = fillBounds.left - tabsBounds.left + scrollLeft; + const viewportRight = visibleTabsWidth - (this.addTabContainer?.offsetWidth ?? 0); + overflowEdge.style.top = `${fillBounds.top - scrollableBounds.top}px`; + overflowEdge.style.right = `${visibleTabsWidth - viewportRight}px`; + overflowEdge.style.bottom = `${scrollableBounds.bottom - fillBounds.bottom}px`; + overflowEdge.style.left = `${stickyTabsWidth}px`; + this.connectedTabBounds = { + tab: activeTab, + overflowEdge, + fillLeft, + fillRight: fillBounds.right - tabsBounds.left + scrollLeft, + viewportLeft: stickyTabsWidth, + viewportRight, + clippingEdgeExtent: Number.parseFloat(fillStyle.borderTopLeftRadius), + shoulderExtent: Number.parseFloat(targetWindow.getComputedStyle(activeTabFill, '::after').width), + }; + } + let activeTabPosX: number | undefined; let activeTabWidth: number | undefined; @@ -2264,6 +2304,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { width: visibleTabsWidth, scrollWidth: allTabsWidth }); + this.updateConnectedTabClipping(tabsScrollbar.getScrollPosition().scrollLeft); const dimensionsChanged = oldVisibleTabsWidth !== visibleTabsWidth || oldAllTabsWidth !== allTabsWidth; // Revealing the active tab is skipped under some conditions: @@ -2345,6 +2386,45 @@ export class MultiEditorTabsControl extends EditorTabsControl { } } + private clearConnectedTabClipping(): void { + if (this.connectedTabBounds) { + const { tab } = this.connectedTabBounds; + tab.classList.remove('connected-tab-left-edge', 'connected-tab-right-edge', 'connected-tab-left-clipped', 'connected-tab-right-clipped', 'connected-tab-hidden'); + this.connectedTabBounds = undefined; + } + + if (this.connectedTabOverflowEdge) { + this.connectedTabOverflowEdge.style.removeProperty('top'); + this.connectedTabOverflowEdge.style.removeProperty('right'); + this.connectedTabOverflowEdge.style.removeProperty('bottom'); + this.connectedTabOverflowEdge.style.removeProperty('left'); + this.connectedTabOverflowEdge.classList.remove('connected-tab-left-clipped', 'connected-tab-right-clipped'); + } + } + + private updateConnectedTabClipping(scrollLeft: number): void { + if (!this.connectedTabBounds) { + return; + } + + const { tab, overflowEdge, fillLeft, fillRight, viewportLeft, viewportRight, clippingEdgeExtent, shoulderExtent } = this.connectedTabBounds; + const visibleLeft = scrollLeft + viewportLeft; + const visibleRight = scrollLeft + viewportRight; + const visibleFillLeft = Math.max(fillLeft, visibleLeft); + const visibleFillRight = Math.min(fillRight, visibleRight); + const leftClipped = fillLeft < visibleLeft; + const rightClipped = fillRight > visibleRight; + const rightEdge = fillRight + shoulderExtent > visibleRight; + const hidden = visibleFillLeft + clippingEdgeExtent >= visibleFillRight; + tab.classList.toggle('connected-tab-left-edge', leftClipped); + tab.classList.toggle('connected-tab-right-edge', rightEdge); + tab.classList.toggle('connected-tab-left-clipped', leftClipped); + tab.classList.toggle('connected-tab-right-clipped', rightClipped); + tab.classList.toggle('connected-tab-hidden', hidden); + overflowEdge.classList.toggle('connected-tab-left-clipped', leftClipped && !hidden); + overflowEdge.classList.toggle('connected-tab-right-clipped', rightClipped && !hidden); + } + private updateTabsControlVisibility(): void { const tabsAndActionsContainer = assertReturnsDefined(this.tabsAndActionsContainer); tabsAndActionsContainer.classList.toggle('empty', !this.visible); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 2ff74703ca34cb..5781be276f9a86 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -5,21 +5,14 @@ /* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { - --modern-ui-editor-tab-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-unfocused-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-active-hover-background: var(--vscode-editor-background); - --modern-ui-editor-tab-unfocused-active-hover-background: var(--vscode-editor-background); --modern-ui-editor-tab-inactive-background: var(--vscode-panel-background); --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-panel-background); - --modern-ui-editor-tab-action-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-unfocused-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-active-hover-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--vscode-editor-background); --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-panel-background)); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); + --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40)); --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); } @@ -57,7 +50,14 @@ } /* Preserve the existing explicit selection and focus borders in high contrast. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top:not(.close-action-off) > .tab-actions, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top:not(.close-action-off) > .tab-actions { + border-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill { --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-active-border: transparent; @@ -66,14 +66,15 @@ inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 1; + background-color: var(--vscode-editor-background); border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); - border-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)) calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)) 0 0; + border-radius: var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) 0 0; box-shadow: none; } -/* The clipped shadow covers the cap's continuing side border. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { +/* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { content: ''; position: absolute; bottom: calc(-1 * var(--vscode-strokeThickness)); @@ -85,28 +86,121 @@ pointer-events: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before { right: 100%; border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):first-child > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-fill::after { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-left-edge > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-right-edge > .tab-fill::after { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge { + display: block; + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); + z-index: 8; + pointer-events: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { + position: absolute; + bottom: 0; + width: var(--vscode-strokeThickness); + height: calc(var(--modern-ui-connected-tab-shoulder-radius) - var(--vscode-strokeThickness) * 2); + box-sizing: border-box; + background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--vscode-editor-background) var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):not(:first-child) > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { + content: ''; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::before { + left: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { + right: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-left-edge > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-right-edge > .tab-connected-edge::after, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-connected-edge::after { content: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped { + display: block; + position: absolute; + z-index: 8; + pointer-events: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped { + background: linear-gradient(var(--vscode-panel-background), var(--vscode-panel-background)) left top / var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) no-repeat; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: var(--modern-ui-connected-tab-cap-radius); + box-sizing: border-box; + background-color: var(--vscode-editor-background); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { + left: 0; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-top-left-radius: var(--modern-ui-connected-tab-cap-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + right: 0; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-hidden > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-hidden > .tab-connected-edge { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { left: 100%; border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active) > .tab-fill { + bottom: calc(-1 * var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { + --modern-ui-editor-tab-action-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-unfocused-active-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--vscode-editor-background); border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).tab-actions-left > .tab-actions { border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css index b173ee30c69962..0adc39b446af4c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -7,6 +7,10 @@ background-color: var(--modern-ui-editor-tab-hover-background); } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.fixture-hover:not(.selected):not(.active) > .tab-fill { + bottom: calc(-1 * var(--vscode-strokeThickness)); +} + .modern-ui-tabs .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.fixture-hover:not(.selected):not(.active) .tab-label:not(.monaco-decoration-itemColor) a, .modern-ui-tabs .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.fixture-hover:not(.selected):not(.active) > .tab-actions .action-label { color: var(--modern-ui-editor-tab-hover-foreground) !important; 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 314ff2d4070216..9d225ce71c5152 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, Dimension } from '../../../../../base/browser/dom.js'; +import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { Action } from '../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; @@ -180,7 +180,7 @@ function duplicateNameEditorSpecs(): IEditorSpec[] { } /** A larger set of editors, useful for wrapping / scrollbar / label variants. */ -function manyEditorSpecs(): IEditorSpec[] { +function manyEditorSpecs(activeIndex = 0): IEditorSpec[] { const names = [ 'main.ts', 'index.ts', 'button.tsx', 'input.tsx', 'list.tsx', 'tree.tsx', 'model.ts', 'service.ts', 'view.ts', 'controller.ts', 'utils.ts', 'types.ts', @@ -189,7 +189,7 @@ function manyEditorSpecs(): IEditorSpec[] { return names.map((name, index) => ({ resource: file(`/project/src/module${index % 4}/${name}`), pinned: true, - active: index === 0, + active: index === activeIndex, dirty: index % 5 === 0, })); } @@ -368,6 +368,7 @@ export interface IEditorTabBarFixtureOptions { readonly forcedHoverTab?: number; readonly focusedTabAction?: number; readonly editorContents?: string; + readonly activeTabClipping?: 'left' | 'right' | 'right-shoulder'; } function createPartOptions(overrides?: Partial): IEditorPartOptions { @@ -591,6 +592,20 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: } } layout(); + if (options.activeTabClipping) { + disposableStore.add(scheduleAtNextAnimationFrame(getWindow(container), () => { + const tabsContainer = titleContainer.querySelector('.tabs-container'); + const activeTab = tabsContainer?.querySelector('.tab.active'); + if (!tabsContainer || !activeTab) { + throw new Error('The clipped tab fixture requires an active tab'); + } + tabsContainer.classList.add('scroll'); + tabsContainer.scrollLeft = options.activeTabClipping === 'left' + ? activeTab.offsetLeft + activeTab.offsetWidth / 2 + : activeTab.offsetLeft + (options.activeTabClipping === 'right' ? activeTab.offsetWidth / 2 : activeTab.offsetWidth) - tabsContainer.clientWidth; + tabsContainer.dispatchEvent(new UIEvent('scroll')); + })); + } } function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { @@ -761,13 +776,13 @@ function createThemeColorFixtures() { }; } -function renderConnectedSurface(stroke: boolean, firstTabActive = false): (ctx: ComponentFixtureContext) => void { +function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHoverTab?: number): (ctx: ComponentFixtureContext) => void { return render(true, { editors: [ - { resource: file('/project/README.md'), pinned: true, active: firstTabActive }, - { resource: file('/project/src/main.ts'), pinned: true, active: !firstTabActive }, - { resource: file('/project/src/styles.css'), pinned: true }, - { resource: file('/project/package.json'), pinned: true, dirty: true }, + { resource: file('/project/README.md'), pinned: true, active: activeTabIndex >= 0 }, + { resource: file('/project/src/main.ts'), pinned: true, active: activeTabIndex >= 1 }, + { resource: file('/project/src/styles.css'), pinned: true, active: activeTabIndex >= 2 }, + { resource: file('/project/package.json'), pinned: true, dirty: true, active: activeTabIndex >= 3 }, ], editorContents: [ 'import { createApp } from \'./app\';', @@ -780,6 +795,7 @@ function renderConnectedSurface(stroke: boolean, firstTabActive = false): (ctx: 'await app.start();', ].join('\n'), colorCustomizations: stroke ? undefined : { 'editorGroup.border': 'transparent' }, + forcedHoverTab, }); } @@ -802,22 +818,47 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), ConnectedSurface: defineThemedFixtureGroup({ + ClippedLeft: defineComponentFixture({ + render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), + expectedVisualDescriptions: ['The partially scrolled active tab keeps a stationary outside stroke with the same rounded upper corner as a fully visible tab and a continuous join to the separator. Its label remains naturally clipped by scrolling.'], + }), + ClippedRight: defineComponentFixture({ + render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right' }), + expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight right edge before the editor actions. Its top stroke, right edge and strip separator remain continuous without exposing clipped tab content.'], + }), + RightViewportEdge: defineComponentFixture({ + render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right-shoulder' }), + expectedVisualDescriptions: ['The rightmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped beneath the editor actions.'], + }), Stroke: defineComponentFixture({ render: renderConnectedSurface(true), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ 'The active main.ts tab joins the code editor with curved shoulders and no bottom divider. Inactive tabs sit on the panel-colored strip. A single subtle stroke follows the active tab into the strip separator. High contrast retains explicit focus and selection borders.', - 'The stroke remains uniform through the cap, shoulders and separator, including dark themes with translucent borders. There are no brighter overlaps at the tangent joins.', + 'The stroke remains uniform through the cap, shoulders and separator, including dark themes with translucent borders. There are no gaps, vertical protrusions or brighter overlaps at the tangent joins.', 'In standard themes, the concave shoulder radii are reduced by the outward stroke offset while the convex cap radii grow. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', ], }), + HoveredTab: defineComponentFixture({ + render: renderConnectedSurface(true, 1, 0), + expectedVisualDescriptions: [ + 'The hovered README.md pill sits one stroke closer to the strip separator than an ordinary pill, balancing its top and bottom whitespace.', + ], + }), FirstTabActive: defineComponentFixture({ - render: renderConnectedSurface(true, true), + render: renderConnectedSurface(true, 0), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ 'The first active tab has a straight left edge meeting the strip separator, with no clipped outer shoulder. The right shoulder still curves into the separator. High contrast retains explicit selection borders.', ], }), + LastTabActive: defineComponentFixture({ + render: renderConnectedSurface(true, 3), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'The last active tab has a straight right edge meeting the strip separator, with no outer shoulder. The left shoulder still curves into the separator. High contrast retains explicit selection borders.', + ], + }), WithoutStroke: defineComponentFixture({ render: renderConnectedSurface(false), expectedVisualDescriptions: [ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index b49ed6a34fc46b..3e89cf760d501a 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -21,6 +21,8 @@ import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { TestFileEditorInput, TestHostService, workbenchInstantiationService } from '../../workbenchTestServices.js'; +import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; suite('MultiEditorTabsControl', () => { @@ -30,6 +32,7 @@ suite('MultiEditorTabsControl', () => { let hostService: TestHostService; let control: MultiEditorTabsControl; let partOptions: IEditorPartOptions; + let model: EditorGroupModel; setup(() => { disposables = new DisposableStore(); @@ -46,7 +49,7 @@ suite('MultiEditorTabsControl', () => { hostService = instantiationService.get(IHostService) as TestHostService; - const model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); + model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); for (let i = 0; i < 2; i++) { const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); model.openEditor(editor, { pinned: true, active: i === 0 }); @@ -138,19 +141,206 @@ suite('MultiEditorTabsControl', () => { const oldOptions = partOptions; partOptions = { ...partOptions, tabHeight }; control.updateOptions(oldOptions, partOptions); - container.classList.remove('modern-ui', 'modern-ui-tabs'); + container.classList.remove('modern-ui', 'modern-ui-tabs', 'modern-ui-connected-editor-tabs'); const classic = await readHeight(); container.classList.add('modern-ui-tabs'); const sharedModern = await readHeight(); container.classList.add('modern-ui'); - heights.push({ tabHeight, classic, sharedModern, connected: await readHeight() }); + const pill = await readHeight(); + container.classList.add('modern-ui-connected-editor-tabs'); + heights.push({ tabHeight, classic, sharedModern, pill, connected: await readHeight() }); } assert.deepStrictEqual(heights, [ - { tabHeight: 'default', classic: 35, sharedModern: 32, connected: 33 }, - { tabHeight: 'compact', classic: 22, sharedModern: 28, connected: 29 }, + { tabHeight: 'default', classic: 35, sharedModern: 32, pill: 32, connected: 33 }, + { tabHeight: 'compact', classic: 22, sharedModern: 28, pill: 28, connected: 29 }, ]); }); + test('keeps the connected outline inside the visible scroll area', async () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-border: #333333;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160, tabSizingFixedMaxWidth: 160, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const layout = async (width: number) => { + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 200) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + }; + const tabs = container.querySelector('.tabs-container')!; + const scroll = (left: number) => { + tabs.classList.add('scroll'); + tabs.scrollLeft = left; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + }; + const [firstTab, secondTab] = tabs.querySelectorAll('.tab'); + const firstFill = firstTab.querySelector('.tab-fill')!; + const secondFill = secondTab.querySelector('.tab-fill')!; + const firstEdge = firstTab.querySelector('.tab-connected-edge')!; + const secondEdge = secondTab.querySelector('.tab-connected-edge')!; + const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; + await layout(240); + scroll(40); + const clippedLeft = { + edge: firstTab.classList.contains('connected-tab-left-edge'), + clipped: firstTab.classList.contains('connected-tab-left-clipped'), + fillOffset: firstFill.style.left, + edgeOffset: [overflowEdge.style.left, overflowEdge.style.right], + inset: overflowEdge.getBoundingClientRect().left - tabs.getBoundingClientRect().left, + stationaryParent: overflowEdge.parentElement === tabs.parentElement, + edgeOverlay: [ + mainWindow.getComputedStyle(firstFill, '::before').content, + mainWindow.getComputedStyle(overflowEdge).display, + mainWindow.getComputedStyle(overflowEdge).zIndex, + mainWindow.getComputedStyle(overflowEdge, '::before').width, + mainWindow.getComputedStyle(overflowEdge, '::before').borderTopLeftRadius, + mainWindow.getComputedStyle(overflowEdge, '::before').boxSizing, + mainWindow.getComputedStyle(overflowEdge, '::before').borderLeftWidth, + mainWindow.getComputedStyle(overflowEdge, '::before').borderTopWidth, + mainWindow.getComputedStyle(overflowEdge, '::before').backgroundColor, + ], + }; + model.setSelection(model.activeEditor!, [model.getEditorByIndex(1)!]); + control.updateEditorSelections(); + const multiSelected = { + clipping: overflowEdge.style.left, + edge: mainWindow.getComputedStyle(overflowEdge).display, + radius: mainWindow.getComputedStyle(firstFill).borderRadius, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + model.setSelection(model.activeEditor!, []); + control.updateEditorSelections(); + await layout(240); + const singleSelected = { + clipping: overflowEdge.style.left, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(400); + scroll(0); + const terminalOutline = { + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + }; + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true, active: false }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(400); + const normalOutline = { + left: mainWindow.getComputedStyle(secondFill).borderLeftWidth, + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + edge: mainWindow.getComputedStyle(secondEdge).display, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + leftMaskHeight: mainWindow.getComputedStyle(secondEdge, '::before').height, + leftMaskTop: mainWindow.getComputedStyle(secondEdge, '::before').borderTopWidth, + rightMaskHeight: mainWindow.getComputedStyle(secondEdge, '::after').height, + rightMaskTop: mainWindow.getComputedStyle(secondEdge, '::after').borderTopWidth, + }; + await layout(324); + scroll(0); + const rightShoulderAtViewport = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + }; + scroll(8); + const rightShoulderRevealed = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + }; + await layout(240); + scroll(0); + const overflowRightOffsets = []; + for (const position of [0, 1, 2, 3]) { + scroll(position); + overflowRightOffsets.push({ + right: overflowEdge.style.right, + inset: tabs.getBoundingClientRect().right - overflowEdge.getBoundingClientRect().right, + }); + } + const clippedRight = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + fillOffset: secondFill.style.right, + edgeOffset: [overflowEdge.style.left, overflowEdge.style.right], + overflowRightOffsets, + edgeOverlay: [ + mainWindow.getComputedStyle(secondFill, '::after').content, + mainWindow.getComputedStyle(overflowEdge).display, + mainWindow.getComputedStyle(overflowEdge).zIndex, + mainWindow.getComputedStyle(overflowEdge, '::after').width, + mainWindow.getComputedStyle(overflowEdge, '::after').borderTopRightRadius, + mainWindow.getComputedStyle(overflowEdge, '::after').boxSizing, + mainWindow.getComputedStyle(overflowEdge, '::after').borderRightWidth, + mainWindow.getComputedStyle(overflowEdge, '::after').borderTopWidth, + mainWindow.getComputedStyle(overflowEdge, '::after').backgroundColor, + ], + previousTabOverflow: firstEdge.style.left, + }; + model.openEditor(model.getEditorByIndex(0)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(100); + scroll(firstTab.offsetWidth - 5); + const hiddenAtFillEdge = [mainWindow.getComputedStyle(firstFill).display, mainWindow.getComputedStyle(overflowEdge).display]; + root.classList.add('hc-black'); + await layout(240); + scroll(40); + const highContrast = { + clipping: overflowEdge.style.left, + edge: mainWindow.getComputedStyle(overflowEdge).display, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + root.classList.remove('modern-ui-connected-editor-tabs'); + await layout(100); + assert.deepStrictEqual({ + clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, + reset: overflowEdge.style.left, + }, { + clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '5px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'] }, + multiSelected: { clipping: '', edge: 'none', radius: '4px', connectedClass: false }, + singleSelected: { clipping: '0px', connectedClass: true }, + terminalOutline: { right: '1px', rightShoulder: 'none', rightMask: 'none' }, + normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '6px', leftMaskTop: '0px', rightMaskHeight: '6px', rightMaskTop: '0px' }, + rightShoulderAtViewport: { edge: true, clipped: false, right: '1px', rightShoulder: 'none', rightMask: 'none', overflowEdge: 'none' }, + rightShoulderRevealed: { edge: false, clipped: false, rightShoulder: '""', rightMask: '""' }, + clippedRight: { + edge: true, + clipped: true, + fillOffset: '', + edgeOffset: ['0px', '0px'], + overflowRightOffsets: [ + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + ], + edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'], + previousTabOverflow: '', + }, + hiddenAtFillEdge: ['none', 'none'], + highContrast: { clipping: '', edge: 'none', connectedClass: false }, + reset: '', + }); + }); + test('Alt swaps the close action of the hovered tab only', () => { const actions = [tabActions()]; From c38e199a5dcebf8e4a9aa767e0749af629be2724 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 14:19:24 +0100 Subject: [PATCH 05/26] feat: enhance connected tab behavior with left-shoulder clipping and update tests --- .../parts/editor/multiEditorTabsControl.ts | 3 ++- .../browser/media/connectedEditorTabs.css | 5 ---- .../editor/editorTabBar.fixture.ts | 25 +++++++++++++++---- .../editor/multiEditorTabsControl.test.ts | 22 ++++++++++++++-- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index cea0083ec6a9dc..bf77ac5b7adc5c 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2414,9 +2414,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { const visibleFillRight = Math.min(fillRight, visibleRight); const leftClipped = fillLeft < visibleLeft; const rightClipped = fillRight > visibleRight; + const leftEdge = fillLeft - shoulderExtent < visibleLeft; const rightEdge = fillRight + shoulderExtent > visibleRight; const hidden = visibleFillLeft + clippingEdgeExtent >= visibleFillRight; - tab.classList.toggle('connected-tab-left-edge', leftClipped); + tab.classList.toggle('connected-tab-left-edge', leftEdge); tab.classList.toggle('connected-tab-right-edge', rightEdge); tab.classList.toggle('connected-tab-left-clipped', leftClipped); tab.classList.toggle('connected-tab-right-clipped', rightClipped); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 5781be276f9a86..6fdf02830e667d 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -150,10 +150,6 @@ pointer-events: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped { - background: linear-gradient(var(--vscode-panel-background), var(--vscode-panel-background)) left top / var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) no-repeat; -} - .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { content: ''; @@ -169,7 +165,6 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { left: 0; border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); - border-top-left-radius: var(--modern-ui-connected-tab-cap-radius); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { 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 9d225ce71c5152..e8be624bcd84e0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -368,7 +368,7 @@ export interface IEditorTabBarFixtureOptions { readonly forcedHoverTab?: number; readonly focusedTabAction?: number; readonly editorContents?: string; - readonly activeTabClipping?: 'left' | 'right' | 'right-shoulder'; + readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; } function createPartOptions(overrides?: Partial): IEditorPartOptions { @@ -600,9 +600,20 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: throw new Error('The clipped tab fixture requires an active tab'); } tabsContainer.classList.add('scroll'); - tabsContainer.scrollLeft = options.activeTabClipping === 'left' - ? activeTab.offsetLeft + activeTab.offsetWidth / 2 - : activeTab.offsetLeft + (options.activeTabClipping === 'right' ? activeTab.offsetWidth / 2 : activeTab.offsetWidth) - tabsContainer.clientWidth; + switch (options.activeTabClipping) { + case 'left': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2; + break; + case 'right': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2 - tabsContainer.clientWidth; + break; + case 'left-shoulder': + tabsContainer.scrollLeft = activeTab.offsetLeft - 4; + break; + case 'right-shoulder': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth - tabsContainer.clientWidth; + break; + } tabsContainer.dispatchEvent(new UIEvent('scroll')); })); } @@ -820,7 +831,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ConnectedSurface: defineThemedFixtureGroup({ ClippedLeft: defineComponentFixture({ render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), - expectedVisualDescriptions: ['The partially scrolled active tab keeps a stationary outside stroke with the same rounded upper corner as a fully visible tab and a continuous join to the separator. Its label remains naturally clipped by scrolling.'], + expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight left edge. Its top stroke, left edge and strip separator remain continuous without exposing clipped tab content.'], }), ClippedRight: defineComponentFixture({ render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right' }), @@ -830,6 +841,10 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right-shoulder' }), expectedVisualDescriptions: ['The rightmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped beneath the editor actions.'], }), + LeftViewportEdge: defineComponentFixture({ + render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'left-shoulder' }), + expectedVisualDescriptions: ['The leftmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped at the viewport boundary.'], + }), Stroke: defineComponentFixture({ render: renderConnectedSurface(true), additionalThemes: ['darkHighContrast'], diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 3e89cf760d501a..7831083cecedd3 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -266,6 +266,22 @@ suite('MultiEditorTabsControl', () => { rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, }; + scroll(156); + const leftShoulderAtViewport = { + edge: secondTab.classList.contains('connected-tab-left-edge'), + clipped: secondTab.classList.contains('connected-tab-left-clipped'), + left: mainWindow.getComputedStyle(secondFill).borderLeftWidth, + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + leftMask: mainWindow.getComputedStyle(secondEdge, '::before').content, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + }; + scroll(152); + const leftShoulderRevealed = { + edge: secondTab.classList.contains('connected-tab-left-edge'), + clipped: secondTab.classList.contains('connected-tab-left-clipped'), + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + leftMask: mainWindow.getComputedStyle(secondEdge, '::before').content, + }; await layout(240); scroll(0); const overflowRightOffsets = []; @@ -311,16 +327,18 @@ suite('MultiEditorTabsControl', () => { root.classList.remove('modern-ui-connected-editor-tabs'); await layout(100); assert.deepStrictEqual({ - clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, + clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, leftShoulderAtViewport, leftShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, reset: overflowEdge.style.left, }, { - clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '5px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'] }, + clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'] }, multiSelected: { clipping: '', edge: 'none', radius: '4px', connectedClass: false }, singleSelected: { clipping: '0px', connectedClass: true }, terminalOutline: { right: '1px', rightShoulder: 'none', rightMask: 'none' }, normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '6px', leftMaskTop: '0px', rightMaskHeight: '6px', rightMaskTop: '0px' }, rightShoulderAtViewport: { edge: true, clipped: false, right: '1px', rightShoulder: 'none', rightMask: 'none', overflowEdge: 'none' }, rightShoulderRevealed: { edge: false, clipped: false, rightShoulder: '""', rightMask: '""' }, + leftShoulderAtViewport: { edge: true, clipped: false, left: '1px', leftShoulder: 'none', leftMask: 'none', overflowEdge: 'none' }, + leftShoulderRevealed: { edge: false, clipped: false, leftShoulder: '""', leftMask: '""' }, clippedRight: { edge: true, clipped: true, From 31979affae3cfa6d5859a57f52fddf885bcdbaa4 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 15:23:33 +0100 Subject: [PATCH 06/26] feat: implement connected tab layout invalidation and enhance test coverage for connected tabs --- .../parts/editor/multiEditorTabsControl.ts | 6 + .../browser/media/connectedEditorTabs.css | 14 ++- .../browser/modernUI.contribution.test.ts | 106 ++++++++++++++++-- .../editor/editorTabBar.fixture.ts | 22 ++++ .../editor/multiEditorTabsControl.test.ts | 74 +++++++++++- 5 files changed, 211 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index bf77ac5b7adc5c..86666da80b35c2 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -844,6 +844,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.forEachTab((editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => { this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar); }); + this.invalidateConnectedTabLayout(); + } + + private invalidateConnectedTabLayout(): void { if (this.connectedTabBounds || this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black):not(.hc-light)')) { this.clearConnectedTabClipping(); this.layout(this.dimensions); @@ -878,10 +882,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { updateEditorDirty(editor: EditorInput): void { this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar)); + this.invalidateConnectedTabLayout(); } updateEditorCapabilities(editor: EditorInput): void { this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTab(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar)); + this.invalidateConnectedTabLayout(); } override updateOptions(oldOptions: IEditorPartOptions, newOptions: IEditorPartOptions): void { diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 6fdf02830e667d..16c9df6a425127 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -30,6 +30,12 @@ background-color: var(--vscode-panel-background); } +/* Occlude scrolling connected fills beneath sticky tabs while preserving the final separator row. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background { + bottom: var(--vscode-strokeThickness); + height: auto; +} + /* Keep the separator underneath the active fill without changing the tab, * scrollbar, pinned-tab or drag-and-drop stacking order. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { @@ -94,7 +100,9 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):first-child > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active:not(.multi-selected) > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-fill::after, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).last-in-row > .tab-fill::after { content: none; } @@ -138,7 +146,9 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-left-edge > .tab-connected-edge::before, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-right-edge > .tab-connected-edge::after, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-connected-edge::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active:not(.multi-selected) > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-connected-edge::after, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).last-in-row > .tab-connected-edge::after { content: none; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index dd93cbb6f94099..e1717256e9802d 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1977,8 +1977,9 @@ suite('ModernUIContribution', () => { const tabs = appendElement(title, 'tabs-container'); const firstTab = appendElement(tabs, 'tab active'); const firstFill = appendElement(firstTab, 'tab-fill'); - const nextTab = appendElement(tabs, 'tab'); - const nextFill = appendElement(nextTab, 'tab-fill'); + const middleTab = appendElement(tabs, 'tab'); + const middleFill = appendElement(middleTab, 'tab-fill'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); const targetWindow = getWindow(root); const getShoulders = (fill: HTMLElement) => [ targetWindow.getComputedStyle(fill, '::before').content, @@ -1987,14 +1988,14 @@ suite('ModernUIContribution', () => { const firstShoulders = getShoulders(firstFill); firstTab.classList.remove('active'); - nextTab.classList.add('active'); - const leftShoulderStyle = targetWindow.getComputedStyle(nextFill, '::before'); - const rightShoulderStyle = targetWindow.getComputedStyle(nextFill, '::after'); + middleTab.classList.add('active'); + const leftShoulderStyle = targetWindow.getComputedStyle(middleFill, '::before'); + const rightShoulderStyle = targetWindow.getComputedStyle(middleFill, '::after'); assert.deepStrictEqual({ firstTab: firstShoulders, - nextTab: { - shoulders: getShoulders(nextFill), + middleTab: { + shoulders: getShoulders(middleFill), radii: [leftShoulderStyle.borderBottomRightRadius, rightShoulderStyle.borderBottomLeftRadius], sizes: [ [leftShoulderStyle.width, leftShoulderStyle.height], @@ -2003,7 +2004,7 @@ suite('ModernUIContribution', () => { }, }, { firstTab: ['none', '""'], - nextTab: { + middleTab: { shoulders: ['""', '""'], radii: ['8px', '8px'], sizes: [['8px', '8px'], ['8px', '8px']], @@ -2011,6 +2012,95 @@ suite('ModernUIContribution', () => { }); }); + test('uses straight connected edges at wrapped row boundaries', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const rowEndTab = appendElement(tabs, 'tab active last-in-row'); + const rowEndFill = appendElement(rowEndTab, 'tab-fill'); + const rowEndEdge = appendElement(rowEndTab, 'tab-connected-edge'); + const rowStartTab = appendElement(tabs, 'tab'); + const rowStartFill = appendElement(rowStartTab, 'tab-fill'); + const rowStartEdge = appendElement(rowStartTab, 'tab-connected-edge'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const targetWindow = getWindow(root); + const getEdges = (fill: HTMLElement, edge: HTMLElement) => ({ + shoulders: [ + targetWindow.getComputedStyle(fill, '::before').content, + targetWindow.getComputedStyle(fill, '::after').content, + ], + masks: [ + targetWindow.getComputedStyle(edge, '::before').content, + targetWindow.getComputedStyle(edge, '::after').content, + ], + }); + + const rowEnd = getEdges(rowEndFill, rowEndEdge); + rowEndTab.classList.remove('active'); + rowStartTab.classList.add('active'); + + assert.deepStrictEqual({ + rowEnd, + rowStart: getEdges(rowStartFill, rowStartEdge), + }, { + rowEnd: { + shoulders: ['""', 'none'], + masks: ['""', 'none'], + }, + rowStart: { + shoulders: ['none', '""'], + masks: ['none', '""'], + }, + }); + }); + + test('extends the connected sticky mask to the strip separator', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabsAndActions = appendElement(title, 'tabs-and-actions-container'); + const scrollable = appendElement(tabsAndActions, 'monaco-scrollable-element'); + scrollable.style.position = 'relative'; + scrollable.style.width = '250px'; + scrollable.style.height = '33px'; + const stickyBackground = appendElement(scrollable, 'sticky-tabs-background'); + stickyBackground.style.width = '84px'; + const scrollableBounds = scrollable.getBoundingClientRect(); + const stickyBounds = stickyBackground.getBoundingClientRect(); + const stickyStyle = getWindow(root).getComputedStyle(stickyBackground); + + assert.deepStrictEqual({ + top: stickyStyle.top, + bottom: stickyStyle.bottom, + height: stickyBounds.height, + separatorHeight: scrollableBounds.bottom - stickyBounds.bottom, + }, { + top: '0px', + bottom: '1px', + height: 32, + separatorHeight: 1, + }); + }); + test('uses the registered modern tab colors', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; 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 e8be624bcd84e0..336510afedf921 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -194,6 +194,11 @@ function manyEditorSpecs(activeIndex = 0): IEditorSpec[] { })); } +/** Successive active opens keep the third editor at the start of the second visual row. */ +function wrappedRowStartEditorSpecs(): IEditorSpec[] { + return manyEditorSpecs().slice(0, 6).map((spec, index) => ({ ...spec, active: index <= 2 })); +} + /** Editors with dirty state to show modified indicators. */ function dirtyEditorSpecs(): IEditorSpec[] { return [ @@ -829,6 +834,23 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), ConnectedSurface: defineThemedFixtureGroup({ + WrappedRowStart: defineComponentFixture({ + render: render(true, { + partOptions: { wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }, + editors: wrappedRowStartEditorSpecs(), + width: 260, + }), + expectedVisualDescriptions: ['The active tab at the start of a wrapped row uses a straight left edge while its right shoulder flows into the row separator. The preceding row ends with an uninterrupted straight boundary.'], + }), + StickyViewport: defineComponentFixture({ + render: render(true, { + partOptions: { pinnedTabSizing: 'compact', editorActionsLocation: 'hidden' }, + editors: stickyEditorSpecs(), + width: 250, + activeTabClipping: 'left', + }), + expectedVisualDescriptions: ['Compact sticky tabs fully occlude the scrolling active tab and its connected gutter. The strip separator remains continuous beneath the sticky region.'], + }), ClippedLeft: defineComponentFixture({ render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight left edge. Its top stroke, left edge and strip separator remain continuous without exposing clipped tab content.'], diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 7831083cecedd3..486df5910466bd 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -15,7 +15,7 @@ import { TreeViewsDnDService } from '../../../../../editor/common/services/treeV import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { MultiEditorTabsControl } from '../../../../browser/parts/editor/multiEditorTabsControl.js'; -import { EditorsOrder, IEditorPartOptions } from '../../../../common/editor.js'; +import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IHostService } from '../../../../services/host/browser/host.js'; @@ -359,6 +359,78 @@ suite('MultiEditorTabsControl', () => { }); }); + test('refreshes connected clipping geometry after dirty width changes', async () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-border: #333333;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fit', tabActionReserveSpace: false, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true }); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + group.style.width = '180px'; + control.layout({ container: new Dimension(180, 33), available: new Dimension(180, 200) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + const tabs = container.querySelector('.tabs-container')!; + const [firstTab, activeTab] = tabs.querySelectorAll('.tab'); + const activeFill = activeTab.querySelector('.tab-fill')!; + const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; + const scroll = (left: number) => { + tabs.classList.add('scroll'); + tabs.scrollLeft = left; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + }; + const getLogicalFillRight = () => activeFill.getBoundingClientRect().right - tabs.getBoundingClientRect().left + tabs.scrollLeft; + scroll(0); + const cleanFillRight = getLogicalFillRight(); + const firstEditor = model.getEditorByIndex(0) as TestFileEditorInput; + firstEditor.setDirty(); + control.updateEditorDirty(firstEditor); + const dirtyFillRight = getLogicalFillRight(); + const invalidatedBeforeLayout = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + const shoulderExtent = Number.parseFloat(mainWindow.getComputedStyle(activeFill, '::after').width); + const targetVisibleRight = (cleanFillRight + dirtyFillRight) / 2 + shoulderExtent; + scroll(targetVisibleRight - tabs.clientWidth); + const visibleRight = tabs.scrollLeft + tabs.clientWidth; + const rightEdge = activeTab.classList.contains('connected-tab-right-edge'); + const currentGeometryNeedsEdge = dirtyFillRight + shoulderExtent > visibleRight; + const staleGeometryWouldNeedEdge = cleanFillRight + shoulderExtent > visibleRight; + firstEditor.capabilities = EditorInputCapabilities.CannotClose; + control.updateEditorCapabilities(firstEditor); + const capabilityUpdateInvalidated = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); + + assert.deepStrictEqual({ + firstTabDirty: firstTab.classList.contains('dirty'), + widthIncreased: dirtyFillRight > cleanFillRight, + invalidatedBeforeLayout, + rightEdge, + currentGeometryNeedsEdge, + staleGeometryWouldNeedEdge, + capabilityUpdateInvalidated, + }, { + firstTabDirty: true, + widthIncreased: true, + invalidatedBeforeLayout: true, + rightEdge: true, + currentGeometryNeedsEdge: true, + staleGeometryWouldNeedEdge: false, + capabilityUpdateInvalidated: true, + }); + }); + test('Alt swaps the close action of the hovered tab only', () => { const actions = [tabActions()]; From 07411a816626e05c886b8a548e85bf7dfd0d2cfc Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 16:28:07 +0100 Subject: [PATCH 07/26] feat: enhance connected tab layout and alignment with auxiliary window support --- .../browser/media/connectedEditorTabs.css | 12 +- .../modernUI/browser/modernUI.contribution.ts | 14 ++- .../browser/modernUI.contribution.test.ts | 107 ++++++++++++++++-- .../editor/editorTabBar.fixture.ts | 10 +- 4 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 16c9df6a425127..b2dddbb7b83511 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -198,7 +198,7 @@ bottom: calc(-1 * var(--vscode-strokeThickness)); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { --modern-ui-editor-tab-action-active-background: var(--vscode-editor-background); --modern-ui-editor-tab-action-unfocused-active-background: var(--vscode-editor-background); --modern-ui-editor-tab-action-active-hover-background: var(--vscode-editor-background); @@ -206,6 +206,14 @@ border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).tab-actions-left > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).tab-actions-left > .tab-actions { border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; } + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.tab-actions-left) > .tab-actions .actions-container { + justify-content: flex-end; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.tab-actions-left > .tab-actions .actions-container { + justify-content: flex-start; +} diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index ff07189db74fba..3058363e289e06 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { getWindow } from '../../../../base/browser/dom.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; @@ -136,6 +138,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, ) { super(); @@ -151,7 +154,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri const layoutAffectingState = this.getLayoutAffectingState(); if (layoutAffectingState !== this.layoutAffectingState) { this.layoutAffectingState = layoutAffectingState; - this.layoutService.layout(); + this.layout(); } } })); @@ -166,6 +169,15 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri this.update(); } + private layout(): void { + this.layoutService.layout(); + for (const container of this.layoutService.containers) { + if (container !== this.layoutService.mainContainer) { + this.auxiliaryWindowService.getWindow(getWindow(container).vscodeWindowId)?.layout(); + } + } + } + private isEnabled(): boolean { return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index e1717256e9802d..bb832342f9b429 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -16,6 +16,7 @@ import { Color } from '../../../../../base/common/color.js'; import { Emitter } from '../../../../../base/common/event.js'; import { combinedDisposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -42,6 +43,7 @@ import '../../../../browser/parts/media/paneCompositePart.css'; import '../../../../browser/parts/statusbar/media/statusbarpart.css'; import '../../../../browser/parts/titlebar/media/menubarControl.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; +import { IAuxiliaryWindow, IAuxiliaryWindowService } from '../../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import '../../../../browser/parts/notifications/media/notificationsCenter.css'; import '../../../../browser/parts/notifications/media/notificationsToasts.css'; @@ -77,6 +79,21 @@ class ModernUITestLayoutService extends TestLayoutService { } } +class ModernUITestAuxiliaryWindowService extends mock() { + + readonly auxiliaryWindow = new class extends mock() { + layoutCount = 0; + + override layout(): void { + this.layoutCount++; + } + }; + + override getWindow(): IAuxiliaryWindow { + return this.auxiliaryWindow; + } +} + function appendElement(parent: HTMLElement, className: string): HTMLElement { const element = document.createElement('div'); element.className = className; @@ -221,7 +238,8 @@ suite('ModernUIContribution', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new ModernUIContribution(configurationService, layoutService)); + const auxiliaryWindowService = new ModernUITestAuxiliaryWindowService(); + store.add(new ModernUIContribution(configurationService, layoutService, auxiliaryWindowService)); const pane = store.add(new ModernUITestPane()); const paneView = document.createElement('div'); paneView.classList.add('monaco-pane-view'); @@ -249,6 +267,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeight: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, paneHeaderInlineLineHeight: pane.draggableElement!.style.lineHeight, layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, }; await configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_DENSITY, ModernUIDensity.Default); @@ -264,6 +283,7 @@ suite('ModernUIContribution', () => { paneHeaderSize: pane.minimumSize, paneHeaderLineHeight: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, }; await configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, false); @@ -291,6 +311,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeightAfterToggle: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, paneHeaderInlineLineHeightAfterToggle: pane.draggableElement!.style.lineHeight, layoutCountAfterToggle: layoutService.layoutCount, + auxiliaryLayoutCountAfterToggle: auxiliaryWindowService.auxiliaryWindow.layoutCount, }, { startupState: { mainEnabled: true, @@ -307,6 +328,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeight: '28px', paneHeaderInlineLineHeight: '', layoutCount: 0, + auxiliaryLayoutCount: 0, }, defaultDensityState: { mainCompact: false, @@ -314,6 +336,7 @@ suite('ModernUIContribution', () => { paneHeaderSize: 28, paneHeaderLineHeight: '28px', layoutCount: 1, + auxiliaryLayoutCount: 1, }, mainEnabledAfterToggle: false, mainCompactAfterToggle: false, @@ -329,6 +352,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeightAfterToggle: '22px', paneHeaderInlineLineHeightAfterToggle: '', layoutCountAfterToggle: 2, + auxiliaryLayoutCountAfterToggle: 2, }); }); @@ -340,12 +364,14 @@ suite('ModernUIContribution', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - const contribution = store.add(new ModernUIContribution(configurationService, layoutService)); + const auxiliaryWindowService = new ModernUITestAuxiliaryWindowService(); + const contribution = store.add(new ModernUIContribution(configurationService, layoutService, auxiliaryWindowService)); const auxiliaryContainer = document.createElement('div'); const getState = () => ({ connected: layoutService.containers.map(container => container.classList.contains('modern-ui-connected-editor-tabs')), modernTabs: layoutService.containers.map(container => container.classList.contains('modern-ui-tabs')), layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, }); const update = async (key: string, value: boolean | ModernUIEditorTabStyle) => { await configurationService.setUserConfiguration(key, value); @@ -376,14 +402,14 @@ suite('ModernUIContribution', () => { assert.deepStrictEqual({ startup, auxiliaryStartup, pill, connected, disabled, changedWhileDisabled, reenabled, disposed: getState(), }, { - startup: { connected: [true], modernTabs: [true], layoutCount: 0 }, - auxiliaryStartup: { connected: [true, true], modernTabs: [true, true], layoutCount: 0 }, - pill: { connected: [false, false], modernTabs: [true, true], layoutCount: 1 }, - connected: { connected: [true, true], modernTabs: [true, true], layoutCount: 2 }, - disabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3 }, - changedWhileDisabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3 }, - reenabled: { connected: [false, false], modernTabs: [true, true], layoutCount: 4 }, - disposed: { connected: [false, false], modernTabs: [false, false], layoutCount: 5 }, + startup: { connected: [true], modernTabs: [true], layoutCount: 0, auxiliaryLayoutCount: 0 }, + auxiliaryStartup: { connected: [true, true], modernTabs: [true, true], layoutCount: 0, auxiliaryLayoutCount: 0 }, + pill: { connected: [false, false], modernTabs: [true, true], layoutCount: 1, auxiliaryLayoutCount: 1 }, + connected: { connected: [true, true], modernTabs: [true, true], layoutCount: 2, auxiliaryLayoutCount: 2 }, + disabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3, auxiliaryLayoutCount: 3 }, + changedWhileDisabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3, auxiliaryLayoutCount: 3 }, + reenabled: { connected: [false, false], modernTabs: [true, true], layoutCount: 4, auxiliaryLayoutCount: 4 }, + disposed: { connected: [false, false], modernTabs: [false, false], layoutCount: 5, auxiliaryLayoutCount: 5 }, }); }); @@ -669,7 +695,7 @@ suite('ModernUIContribution', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new ModernUIContribution(configurationService, layoutService)); + store.add(new ModernUIContribution(configurationService, layoutService, new ModernUITestAuxiliaryWindowService())); layoutService.mainContainer.classList.add('monaco-workbench'); const paneView = appendElement(layoutService.mainContainer, 'monaco-pane-view'); @@ -2101,6 +2127,65 @@ suite('ModernUIContribution', () => { }); }); + test('aligns connected actions to the tab edge and preserves high contrast pill radii', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + const createTabAction = (className: string) => { + const tab = appendElement(tabs, `tab active ${className}`); + appendElement(tab, 'tab-fill'); + const actions = appendElement(tab, 'tab-actions'); + const actionBar = appendElement(actions, 'monaco-action-bar'); + const actionsContainer = appendElement(actionBar, 'actions-container'); + const actionItem = appendElement(actionsContainer, 'action-item'); + const actionLabel = appendElement(actionItem, 'action-label codicon'); + actionLabel.tabIndex = 0; + return { actions, actionBar, actionsContainer, actionLabel }; + }; + const right = createTabAction(''); + const left = createTabAction('tab-actions-left'); + const targetWindow = getWindow(root); + const getActionStyle = (action: typeof right) => { + action.actionLabel.focus(); + return { + radius: targetWindow.getComputedStyle(action.actions).borderRadius, + alignment: targetWindow.getComputedStyle(action.actionsContainer).justifyContent, + targetWidth: targetWindow.getComputedStyle(action.actions).width, + actionBarWidth: targetWindow.getComputedStyle(action.actionBar).width, + }; + }; + + const connected = { + right: getActionStyle(right), + left: getActionStyle(left), + }; + root.classList.add('hc-black'); + const highContrast = { + right: getActionStyle(right), + left: getActionStyle(left), + }; + + assert.deepStrictEqual({ connected, highContrast }, { + connected: { + right: { radius: '0px 4px 0px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, + left: { radius: '4px 0px 0px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, + }, + highContrast: { + right: { radius: '0px 4px 4px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, + left: { radius: '4px 0px 0px 4px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, + }, + }); + }); + test('uses the registered modern tab colors', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; 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 336510afedf921..b4a2c4074718bb 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -792,7 +792,7 @@ function createThemeColorFixtures() { }; } -function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHoverTab?: number): (ctx: ComponentFixtureContext) => void { +function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHoverTab?: number, focusedTabAction?: number): (ctx: ComponentFixtureContext) => void { return render(true, { editors: [ { resource: file('/project/README.md'), pinned: true, active: activeTabIndex >= 0 }, @@ -812,6 +812,7 @@ function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHover ].join('\n'), colorCustomizations: stroke ? undefined : { 'editorGroup.border': 'transparent' }, forcedHoverTab, + focusedTabAction, }); } @@ -882,6 +883,13 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 'The hovered README.md pill sits one stroke closer to the strip separator than an ordinary pill, balancing its top and bottom whitespace.', ], }), + FocusedCloseAction: defineComponentFixture({ + render: renderConnectedSurface(true, 1, undefined, 1), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'The focused close action keeps its full interaction target while sitting close to the active tab edge. In high contrast, the action follows the complete pill corner instead of squaring its lower outside corner.', + ], + }), FirstTabActive: defineComponentFixture({ render: renderConnectedSurface(true, 0), additionalThemes: ['darkHighContrast'], From b56cc2d7f6c8ee277dd7e2006752bc805054ed37 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 16:46:42 +0100 Subject: [PATCH 08/26] feat: refactor connected tab styling and update related tests for improved surface handling --- .../lib/stylelint/vscode-known-variables.json | 3 +- src/vs/workbench/common/theme.ts | 2 - src/vs/workbench/contrib/modernUI/README.md | 15 +------ .../modernUI/browser/connectedEditorTabs.ts | 13 ------ .../browser/media/connectedEditorTabs.css | 38 ++++++++-------- .../browser/modernUI.contribution.test.ts | 45 +++++++++++-------- .../browser/modernTabColorCustomizations.ts | 16 ++++--- .../editor/editorTabBar.fixture.ts | 23 ++++------ .../editor/multiEditorTabsControl.test.ts | 8 ++-- 9 files changed, 69 insertions(+), 94 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 513fbe96af8392..a0a204d4ffb5dd 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -560,7 +560,6 @@ "--vscode-modernEditorTab-activeForeground", "--vscode-modernEditorTab-activeHoverActionBackground", "--vscode-modernEditorTab-activeHoverBackground", - "--vscode-modernEditorTab-connectedBorder", "--vscode-modernEditorTab-hoverActionBackground", "--vscode-modernEditorTab-hoverBackground", "--vscode-modernEditorTab-hoverForeground", @@ -1075,11 +1074,11 @@ "--pane-header-size", "--model-hover-surface-background", "--modern-ui-activitybar-lane", - "--modern-ui-connected-tab-border", "--modern-ui-connected-tab-cap-radius", "--modern-ui-connected-tab-gutter", "--modern-ui-connected-tab-radius", "--modern-ui-connected-tab-shoulder-radius", + "--modern-ui-connected-tab-surface", "--modern-ui-editor-border-color", "--modern-ui-editor-tab-action-active-background", "--modern-ui-editor-tab-action-active-hover-background", diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index 05e76023ec550f..fdb58dce364a99 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -714,8 +714,6 @@ export const MODERN_TAB_HOVER_FOREGROUND = registerColor('modernTab.hoverForegro // < --- Modern Editor Tabs --- > -export const MODERN_EDITOR_TAB_CONNECTED_BORDER = registerColor('modernEditorTab.connectedBorder', EDITOR_GROUP_BORDER, localize('modernEditorTabConnectedBorder', "Border color of the active editor tab and the tab strip separator when the connected modern editor tab style is enabled. Does not replace high contrast selection or focus borders.")); - export const MODERN_EDITOR_TAB_ACTIVE_BACKGROUND = registerColor('modernEditorTab.activeBackground', MODERN_TAB_ACTIVE_BACKGROUND, localize('modernEditorTabActiveBackground', "Background color of active editor tabs when the modern tab style is enabled.")); export const MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND = registerColor('modernEditorTab.activeActionBackground', opaque(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, editorBackground), localize('modernEditorTabActiveActionBackground', "Opaque background color of tab actions on active editor tabs when the modern tab style is enabled.")); diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index cb8ca89167c04e..8f7eaeead4f6c5 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -16,19 +16,7 @@ The Modern UI colors below are experimental and require a build that supports th Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. High contrast themes keep their explicit selection and focus borders. -Customize `modernEditorTab.connectedBorder` to change the active tab stroke, both shoulders, and the strip separator together, independently of editor-split borders. It defaults to `editorGroup.border`. Translucent strokes are composited over `editor.background` to avoid brighter overlaps; `#00000000` hides the decorative stroke. - -```json -{ - "workbench.experimental.modernUI": true, - "workbench.experimental.modernUIEditorTabStyle": "connected", - "workbench.colorCustomizations": { - "modernEditorTab.connectedBorder": "#5b8def" - } -} -``` - -The connected design uses `editor.background` for the active tab and `panel.background` for the strip and inactive tabs, so the active tab reads as part of the editor. Its hover fill is derived from `foreground` over `panel.background`. Existing tab foreground customizations continue to apply. The separate `modernEditorTab.*Background` colors in the table below control the pill treatment. +The connected design uses `modernEditorTab.activeBackground` for the active tab, its outside stroke, both shoulders, and the strip separator so they read as one surface. Translucent active backgrounds are composited over `editor.background` once to keep every join uniform. The strip and inactive tabs use `panel.background`, and their hover fill is derived from `foreground` over `panel.background`. Existing tab foreground customizations continue to apply. ## Colors @@ -47,7 +35,6 @@ The connected design uses `editor.background` for the active tab and `panel.back | `modernTab.hoverBackground` | Background of hovered Modern UI tabs | `list.hoverBackground` | | `modernTab.hoverForeground` | Foreground of hovered Modern UI tabs | `list.hoverForeground`, then `foreground` | | `modernEditorTab.activeBackground` | Background of active Modern UI editor tabs | `modernTab.activeBackground` | -| `modernEditorTab.connectedBorder` | Stroke around the active connected editor tab and along its strip separator | `editorGroup.border` | | `modernEditorTab.activeActionBackground` | Opaque background of actions on active Modern UI editor tabs | `modernEditorTab.activeBackground` composited over `editor.background` | | `modernEditorTab.activeForeground` | Foreground of active Modern UI editor tabs | `modernTab.activeForeground` | | `modernEditorTab.activeHoverBackground` | Background of active Modern UI editor tabs when hovered | `modernEditorTab.hoverBackground` | diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts index 036881ec4fca5f..31419d8db077b0 100644 --- a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -3,17 +3,4 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; -import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; -import { MODERN_EDITOR_TAB_CONNECTED_BORDER } from '../../../common/theme.js'; import './media/connectedEditorTabs.css'; - -registerThemingParticipant((theme, collector) => { - const border = theme.getColor(MODERN_EDITOR_TAB_CONNECTED_BORDER); - const background = theme.getColor(editorBackground); - if (border && background) { - // Resolve alpha once so the cap, shoulder and separator joins cannot double the stroke. - const stroke = border.isTransparent() ? border : border.makeOpaque(background); - collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-border: ${stroke}; }`); - } -}); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index b2dddbb7b83511..1c32ee685e729b 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -40,7 +40,7 @@ * scrollbar, pinned-tab or drag-and-drop stacking order. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { z-index: 0; - background-color: var(--modern-ui-connected-tab-border); + background-color: var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { @@ -64,16 +64,16 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill { - --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); - --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-surface); --modern-ui-editor-tab-active-border: transparent; --modern-ui-editor-tab-unfocused-active-border: transparent; top: calc(-1 * var(--vscode-strokeThickness)); inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 1; - background-color: var(--vscode-editor-background); - border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + background-color: var(--modern-ui-connected-tab-surface); + border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); border-radius: var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) 0 0; box-shadow: none; } @@ -87,16 +87,16 @@ width: var(--modern-ui-connected-tab-shoulder-radius); height: var(--modern-ui-connected-tab-shoulder-radius); box-sizing: border-box; - border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); clip-path: inset(0); pointer-events: none; } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before { right: 100%; - border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); - box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):first-child > .tab-fill::before, @@ -128,7 +128,7 @@ width: var(--vscode-strokeThickness); height: calc(var(--modern-ui-connected-tab-shoulder-radius) - var(--vscode-strokeThickness) * 2); box-sizing: border-box; - background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--vscode-editor-background) var(--vscode-strokeThickness)); + background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--modern-ui-connected-tab-surface) var(--vscode-strokeThickness)); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):not(:first-child) > .tab-connected-edge::before, @@ -168,18 +168,18 @@ bottom: 0; width: var(--modern-ui-connected-tab-cap-radius); box-sizing: border-box; - background-color: var(--vscode-editor-background); - border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + background-color: var(--modern-ui-connected-tab-surface); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { left: 0; - border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { right: 0; - border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-hidden > .tab-fill, @@ -189,9 +189,9 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { left: 100%; - border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); - box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--vscode-editor-background); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active) > .tab-fill { @@ -199,10 +199,10 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { - --modern-ui-editor-tab-action-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-unfocused-active-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-active-hover-background: var(--vscode-editor-background); - --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--vscode-editor-background); + --modern-ui-editor-tab-action-active-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-unfocused-active-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-active-hover-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--modern-ui-connected-tab-surface); border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index bb832342f9b429..8e20c63b24d287 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1865,26 +1865,29 @@ suite('ModernUIContribution', () => { }); }); - test('flattens connected tab strokes while preserving opaque and transparent borders', () => { - const strokes = [ - { background: '#1f1f1f', border: '#ffffff17' }, - { background: '#ffffff', border: '#e5e5e5' }, - { background: '#1f1f1f', border: '#00000000' }, - ].map(({ background, border }) => { + test('flattens modern tab backgrounds for the connected surface', () => { + const backgrounds = [ + { editor: '#1f1f1f', tab: '#ffffff17' }, + { editor: '#ffffff', tab: '#2468ac' }, + { editor: '#1f1f1f', tab: '#00000000' }, + ].map(({ editor, tab }) => { const theme = ColorThemeData.createUnloadedTheme('vs-dark', { - [editorBackground]: background, - 'editorGroup.border': border, + [editorBackground]: editor, + [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: tab, }); const css = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; - return /--modern-ui-connected-tab-border: (?[^;]+);/.exec(css)?.groups?.stroke; + return /--modern-ui-connected-tab-surface: (?[^;]+);/.exec(css)?.groups?.background; }); - assert.deepStrictEqual(strokes, ['#333333', '#e5e5e5', 'rgba(0, 0, 0, 0)']); + assert.deepStrictEqual(backgrounds, ['#333333', '#2468ac', '#1f1f1f']); }); - test('customizes the connected tab stroke without changing editor split borders', () => { + test('uses the modern tab background for the connected surface and stroke', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); const style = document.createElement('style'); @@ -1898,30 +1901,36 @@ suite('ModernUIContribution', () => { appendElement(tabs, 'tab'); const tab = appendElement(tabs, 'tab active'); const fill = appendElement(tab, 'tab-fill'); + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + action.focus(); const targetWindow = getWindow(root); const results = ['#2468ac', '#ff000080', '#00000000'].map(color => { const theme = ColorThemeData.createUnloadedTheme('vs', { [editorBackground]: '#ffffff', 'editorGroup.border': '#123456', - 'modernEditorTab.connectedBorder': '#abcdef', + [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: '#abcdef', }); - theme.setCustomColors({ 'modernEditorTab.connectedBorder': color }); + theme.setCustomColors({ [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: color }); style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; return { - strokes: [ + surface: [ + targetWindow.getComputedStyle(fill).backgroundColor, targetWindow.getComputedStyle(fill).borderTopColor, targetWindow.getComputedStyle(fill, '::before').borderRightColor, targetWindow.getComputedStyle(fill, '::after').borderLeftColor, targetWindow.getComputedStyle(row, '::after').backgroundColor, + targetWindow.getComputedStyle(actions).backgroundColor, ], editorSplitBorder: theme.getColor('editorGroup.border')?.toString(), }; }); assert.deepStrictEqual(results, [ - { strokes: Array(4).fill('rgb(36, 104, 172)'), editorSplitBorder: '#123456' }, - { strokes: Array(4).fill('rgb(255, 126, 126)'), editorSplitBorder: '#123456' }, - { strokes: Array(4).fill('rgba(0, 0, 0, 0)'), editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(36, 104, 172)'), editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 126, 126)'), editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), editorSplitBorder: '#123456' }, ]); }); @@ -1932,7 +1941,7 @@ suite('ModernUIContribution', () => { root.style.setProperty('--vscode-spacing-size60', '6px'); root.style.setProperty('--vscode-cornerRadius-small', '4px'); root.style.setProperty('--vscode-strokeThickness', '1px'); - root.style.setProperty('--modern-ui-connected-tab-border', '#333333'); + root.style.setProperty('--modern-ui-connected-tab-surface', '#333333'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 4cee816e815cd5..a8fbabd85ac553 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -32,7 +32,7 @@ function addColorVariable(declarations: string[], name: string, color: Color | u } } -function flattenActionBackground(color: Color, editorBackgroundColor: Color | undefined): Color { +function flattenTabBackground(color: Color, editorBackgroundColor: Color | undefined): Color { return editorBackgroundColor ? color.makeOpaque(editorBackgroundColor) : color; } @@ -51,12 +51,14 @@ registerThemingParticipant((theme, collector) => { const activeHoverBackground = resolveLegacyTabColor(theme, TAB_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND]); const unfocusedActiveHoverBackground = resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_HOVER_BACKGROUND); const editorBackgroundColor = theme.getColor(editorBackground); + const connectedActiveBackground = activeBackground ?? theme.getColor(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND); const hasModernActiveActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND); const hasModernHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND); const hasModernActiveHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND); addColorVariable(declarations, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); + addColorVariable(declarations, '--modern-ui-connected-tab-surface', connectedActiveBackground ? flattenTabBackground(connectedActiveBackground, editorBackgroundColor) : undefined); addColorVariable(declarations, '--modern-ui-editor-tab-inactive-background', inactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-background', unfocusedInactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-hover-background', hoverBackground); @@ -79,22 +81,22 @@ registerThemingParticipant((theme, collector) => { addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); if (activeBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-background', flattenActionBackground(activeBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-active-background', flattenTabBackground(activeBackground, editorBackgroundColor)); } if (unfocusedActiveBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-background', flattenActionBackground(unfocusedActiveBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-background', flattenTabBackground(unfocusedActiveBackground, editorBackgroundColor)); } if (hoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-hover-background', flattenActionBackground(hoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-hover-background', flattenTabBackground(hoverBackground, editorBackgroundColor)); } if (unfocusedHoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenActionBackground(unfocusedHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenTabBackground(unfocusedHoverBackground, editorBackgroundColor)); } if (activeHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-hover-background', flattenActionBackground(activeHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-active-hover-background', flattenTabBackground(activeHoverBackground, editorBackgroundColor)); } if (unfocusedActiveHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenActionBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenTabBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); } if (declarations.length > 0) { 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 b4a2c4074718bb..c007b4cd67d5e9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -792,7 +792,7 @@ function createThemeColorFixtures() { }; } -function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHoverTab?: number, focusedTabAction?: number): (ctx: ComponentFixtureContext) => void { +function renderConnectedSurface(activeTabIndex = 1, forcedHoverTab?: number, focusedTabAction?: number): (ctx: ComponentFixtureContext) => void { return render(true, { editors: [ { resource: file('/project/README.md'), pinned: true, active: activeTabIndex >= 0 }, @@ -810,7 +810,6 @@ function renderConnectedSurface(stroke: boolean, activeTabIndex = 1, forcedHover '', 'await app.start();', ].join('\n'), - colorCustomizations: stroke ? undefined : { 'editorGroup.border': 'transparent' }, forcedHoverTab, focusedTabAction, }); @@ -869,46 +868,40 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { expectedVisualDescriptions: ['The leftmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped at the viewport boundary.'], }), Stroke: defineComponentFixture({ - render: renderConnectedSurface(true), + render: renderConnectedSurface(), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ - 'The active main.ts tab joins the code editor with curved shoulders and no bottom divider. Inactive tabs sit on the panel-colored strip. A single subtle stroke follows the active tab into the strip separator. High contrast retains explicit focus and selection borders.', - 'The stroke remains uniform through the cap, shoulders and separator, including dark themes with translucent borders. There are no gaps, vertical protrusions or brighter overlaps at the tangent joins.', + 'The active main.ts tab uses the Modern UI active-tab background for its fill, outside stroke, curved shoulders and strip separator so they read as one continuous surface. Inactive tabs sit on the panel-colored strip. High contrast retains explicit focus and selection borders.', + 'The surface color remains uniform through the cap, shoulders and separator, including themes with translucent active-tab backgrounds. There are no gaps, vertical protrusions, darker seams or brighter overlaps at the tangent joins.', 'In standard themes, the concave shoulder radii are reduced by the outward stroke offset while the convex cap radii grow. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', ], }), HoveredTab: defineComponentFixture({ - render: renderConnectedSurface(true, 1, 0), + render: renderConnectedSurface(1, 0), expectedVisualDescriptions: [ 'The hovered README.md pill sits one stroke closer to the strip separator than an ordinary pill, balancing its top and bottom whitespace.', ], }), FocusedCloseAction: defineComponentFixture({ - render: renderConnectedSurface(true, 1, undefined, 1), + render: renderConnectedSurface(1, undefined, 1), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ 'The focused close action keeps its full interaction target while sitting close to the active tab edge. In high contrast, the action follows the complete pill corner instead of squaring its lower outside corner.', ], }), FirstTabActive: defineComponentFixture({ - render: renderConnectedSurface(true, 0), + render: renderConnectedSurface(0), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ 'The first active tab has a straight left edge meeting the strip separator, with no clipped outer shoulder. The right shoulder still curves into the separator. High contrast retains explicit selection borders.', ], }), LastTabActive: defineComponentFixture({ - render: renderConnectedSurface(true, 3), + render: renderConnectedSurface(3), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ 'The last active tab has a straight right edge meeting the strip separator, with no outer shoulder. The left shoulder still curves into the separator. High contrast retains explicit selection borders.', ], }), - WithoutStroke: defineComponentFixture({ - render: renderConnectedSurface(false), - expectedVisualDescriptions: [ - 'The active main.ts tab joins the code editor with curved shoulders. Only the contrast between the panel background and editor background distinguishes the surfaces; there is no decorative stroke.', - ], - }), }), }); diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 486df5910466bd..675d0664fbb5b4 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -158,7 +158,7 @@ suite('MultiEditorTabsControl', () => { test('keeps the connected outline inside the visible scroll area', async () => { const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); - root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-border: #333333;'; + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; mainWindow.document.body.appendChild(root); disposables.add(toDisposable(() => root.remove())); const editor = $('.part.editor'); @@ -330,7 +330,7 @@ suite('MultiEditorTabsControl', () => { clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, leftShoulderAtViewport, leftShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, reset: overflowEdge.style.left, }, { - clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'] }, + clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'] }, multiSelected: { clipping: '', edge: 'none', radius: '4px', connectedClass: false }, singleSelected: { clipping: '0px', connectedClass: true }, terminalOutline: { right: '1px', rightShoulder: 'none', rightMask: 'none' }, @@ -350,7 +350,7 @@ suite('MultiEditorTabsControl', () => { { right: '0px', inset: 0 }, { right: '0px', inset: 0 }, ], - edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(255, 255, 255)'], + edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'], previousTabOverflow: '', }, hiddenAtFillEdge: ['none', 'none'], @@ -361,7 +361,7 @@ suite('MultiEditorTabsControl', () => { test('refreshes connected clipping geometry after dirty width changes', async () => { const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); - root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-border: #333333;'; + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; mainWindow.document.body.appendChild(root); disposables.add(toDisposable(() => root.remove())); const editor = $('.part.editor'); From cf4eecd96c79f76969d1307147d488e85cc9747e Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 9 Sep 2026 16:48:59 +0100 Subject: [PATCH 09/26] feat: implement connected tab theming participant and update color variable handling for improved UI consistency --- .../contrib/modernUI/browser/connectedEditorTabs.ts | 12 ++++++++++++ .../themes/browser/modernTabColorCustomizations.ts | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts index 31419d8db077b0..64f6a862ecf3c6 100644 --- a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -3,4 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { MODERN_EDITOR_TAB_ACTIVE_BACKGROUND } from '../../../common/theme.js'; import './media/connectedEditorTabs.css'; + +registerThemingParticipant((theme, collector) => { + const background = theme.getColor(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND); + const editorBackgroundColor = theme.getColor(editorBackground); + if (background) { + const surface = editorBackgroundColor ? background.makeOpaque(editorBackgroundColor) : background; + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-surface: ${surface}; }`); + } +}); diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index a8fbabd85ac553..02d400f392f15a 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -51,14 +51,13 @@ registerThemingParticipant((theme, collector) => { const activeHoverBackground = resolveLegacyTabColor(theme, TAB_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND]); const unfocusedActiveHoverBackground = resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_HOVER_BACKGROUND); const editorBackgroundColor = theme.getColor(editorBackground); - const connectedActiveBackground = activeBackground ?? theme.getColor(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND); const hasModernActiveActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND); const hasModernHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND); const hasModernActiveHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND); addColorVariable(declarations, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); - addColorVariable(declarations, '--modern-ui-connected-tab-surface', connectedActiveBackground ? flattenTabBackground(connectedActiveBackground, editorBackgroundColor) : undefined); + addColorVariable(declarations, '--modern-ui-connected-tab-surface', activeBackground ? flattenTabBackground(activeBackground, editorBackgroundColor) : undefined); addColorVariable(declarations, '--modern-ui-editor-tab-inactive-background', inactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-background', unfocusedInactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-hover-background', hoverBackground); From baa2298327ae356b2be4c82a0611a01c3573028e Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 10:57:33 -0700 Subject: [PATCH 10/26] feat: strengthen connected editor tab hierarchy Align the selected tab with the editor surface, move the remaining strip to the editor header background, and remove gutters at tab and row boundaries. This makes the active document well unambiguous and follows the adjacency treatment from Visual Studio and Edge.\n\nRefs #331104 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/modernUI/README.md | 2 +- .../modernUI/browser/connectedEditorTabs.ts | 7 ++---- .../browser/media/connectedEditorTabs.css | 18 +++++++------ .../browser/modernUI.contribution.test.ts | 16 ++++++------ .../browser/modernTabColorCustomizations.ts | 1 - .../editor/editorTabBar.fixture.ts | 10 ++++++-- .../editor/multiEditorTabsControl.test.ts | 25 +++++++++++++++++++ 7 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index 8f7eaeead4f6c5..0c43576f91f23f 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -16,7 +16,7 @@ The Modern UI colors below are experimental and require a build that supports th Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. High contrast themes keep their explicit selection and focus borders. -The connected design uses `modernEditorTab.activeBackground` for the active tab, its outside stroke, both shoulders, and the strip separator so they read as one surface. Translucent active backgrounds are composited over `editor.background` once to keep every join uniform. The strip and inactive tabs use `panel.background`, and their hover fill is derived from `foreground` over `panel.background`. Existing tab foreground customizations continue to apply. +The connected design uses `editor.background` for the active tab, its outside stroke, both shoulders, and the strip separator so the selected tab and editor body read as one document well. The strip and inactive tabs use `editorGroupHeader.tabsBackground`, and their hover fill is derived from `foreground` over that background. Existing tab foreground customizations continue to apply. `modernEditorTab.activeBackground` remains available to the pill style. ## Colors diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts index 64f6a862ecf3c6..b72b3edb7966a6 100644 --- a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -5,14 +5,11 @@ import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; -import { MODERN_EDITOR_TAB_ACTIVE_BACKGROUND } from '../../../common/theme.js'; import './media/connectedEditorTabs.css'; registerThemingParticipant((theme, collector) => { - const background = theme.getColor(MODERN_EDITOR_TAB_ACTIVE_BACKGROUND); - const editorBackgroundColor = theme.getColor(editorBackground); + const background = theme.getColor(editorBackground); if (background) { - const surface = editorBackgroundColor ? background.makeOpaque(editorBackgroundColor) : background; - collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-surface: ${surface}; }`); + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-surface: ${background}; }`); } }); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 1c32ee685e729b..9640aa00373a43 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -5,9 +5,9 @@ /* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { - --modern-ui-editor-tab-inactive-background: var(--vscode-panel-background); - --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-panel-background); - --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-panel-background)); + --modern-ui-editor-tab-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); + --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); + --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editorGroupHeader-tabsBackground)); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); @@ -18,16 +18,20 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { - background-color: var(--vscode-panel-background); + background-color: var(--vscode-editorGroupHeader-tabsBackground); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { padding-bottom: var(--vscode-strokeThickness); } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-fill { + inset-inline: 0; +} + .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { - background-color: var(--vscode-panel-background); + background-color: var(--vscode-editorGroupHeader-tabsBackground); } /* Occlude scrolling connected fills beneath sticky tabs while preserving the final separator row. */ @@ -69,7 +73,7 @@ --modern-ui-editor-tab-active-border: transparent; --modern-ui-editor-tab-unfocused-active-border: transparent; top: calc(-1 * var(--vscode-strokeThickness)); - inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); + inset-inline: 0; bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 1; background-color: var(--modern-ui-connected-tab-surface); @@ -115,7 +119,7 @@ display: block; position: absolute; top: calc(-1 * var(--vscode-strokeThickness)); - inset-inline: calc(var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); + inset-inline: 0; bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 8; pointer-events: none; diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 8e20c63b24d287..f7ca83434e0395 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1865,7 +1865,7 @@ suite('ModernUIContribution', () => { }); }); - test('flattens modern tab backgrounds for the connected surface', () => { + test('uses the editor background for the connected surface', () => { const backgrounds = [ { editor: '#1f1f1f', tab: '#ffffff17' }, { editor: '#ffffff', tab: '#2468ac' }, @@ -1879,10 +1879,10 @@ suite('ModernUIContribution', () => { return /--modern-ui-connected-tab-surface: (?[^;]+);/.exec(css)?.groups?.background; }); - assert.deepStrictEqual(backgrounds, ['#333333', '#2468ac', '#1f1f1f']); + assert.deepStrictEqual(backgrounds, ['#1f1f1f', '#ffffff', '#1f1f1f']); }); - test('uses the modern tab background for the connected surface and stroke', () => { + test('uses the editor background for the connected surface and the editor group header background for the strip', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -1910,6 +1910,7 @@ suite('ModernUIContribution', () => { const theme = ColorThemeData.createUnloadedTheme('vs', { [editorBackground]: '#ffffff', 'editorGroup.border': '#123456', + 'editorGroupHeader.tabsBackground': '#eeeeee', [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: '#abcdef', }); theme.setCustomColors({ [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: color }); @@ -1923,14 +1924,15 @@ suite('ModernUIContribution', () => { targetWindow.getComputedStyle(row, '::after').backgroundColor, targetWindow.getComputedStyle(actions).backgroundColor, ], + strip: targetWindow.getComputedStyle(row).backgroundColor, editorSplitBorder: theme.getColor('editorGroup.border')?.toString(), }; }); assert.deepStrictEqual(results, [ - { surface: Array(6).fill('rgb(36, 104, 172)'), editorSplitBorder: '#123456' }, - { surface: Array(6).fill('rgb(255, 126, 126)'), editorSplitBorder: '#123456' }, - { surface: Array(6).fill('rgb(255, 255, 255)'), editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, ]); }); @@ -1987,7 +1989,7 @@ suite('ModernUIContribution', () => { }, { tabBounds: tabBounds.toJSON(), labelBounds: labelBounds.toJSON(), - fillExpansion: connected ? [1, 1, 1] : [0, 0, 0], + fillExpansion: connected ? [1, 0, 0] : [0, 0, 0], topRadius: connected ? '5px' : '4px', }, JSON.stringify({ classes, theme, activeGroup, compact })); } diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 02d400f392f15a..7aa3fc7e35439a 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -57,7 +57,6 @@ registerThemingParticipant((theme, collector) => { addColorVariable(declarations, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); - addColorVariable(declarations, '--modern-ui-connected-tab-surface', activeBackground ? flattenTabBackground(activeBackground, editorBackgroundColor) : undefined); addColorVariable(declarations, '--modern-ui-editor-tab-inactive-background', inactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-background', unfocusedInactiveBackground); addColorVariable(declarations, '--modern-ui-editor-tab-hover-background', hoverBackground); 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 c007b4cd67d5e9..e62b50c0546bbb 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -871,8 +871,8 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { render: renderConnectedSurface(), additionalThemes: ['darkHighContrast'], expectedVisualDescriptions: [ - 'The active main.ts tab uses the Modern UI active-tab background for its fill, outside stroke, curved shoulders and strip separator so they read as one continuous surface. Inactive tabs sit on the panel-colored strip. High contrast retains explicit focus and selection borders.', - 'The surface color remains uniform through the cap, shoulders and separator, including themes with translucent active-tab backgrounds. There are no gaps, vertical protrusions, darker seams or brighter overlaps at the tangent joins.', + 'The active main.ts tab uses the editor background for its fill, outside stroke, curved shoulders and strip separator so the tab and editor body read as one continuous document well. Inactive tabs sit on the editor-group header strip. High contrast retains explicit focus and selection borders.', + 'The editor surface color remains uniform through the cap, shoulders and separator. There are no gaps, vertical protrusions, darker seams or brighter overlaps at the tangent joins.', 'In standard themes, the concave shoulder radii are reduced by the outward stroke offset while the convex cap radii grow. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', ], }), @@ -896,6 +896,12 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 'The first active tab has a straight left edge meeting the strip separator, with no clipped outer shoulder. The right shoulder still curves into the separator. High contrast retains explicit selection borders.', ], }), + FirstTabActiveAdjacentHover: defineComponentFixture({ + render: renderConnectedSurface(0, 1), + expectedVisualDescriptions: [ + 'The first active tab is flush with the editor body left edge. The adjacent hovered tab begins immediately at the active tab boundary without an inter-tab gutter, while the active tab shoulder remains visible over the shared boundary.', + ], + }), LastTabActive: defineComponentFixture({ render: renderConnectedSurface(3), additionalThemes: ['darkHighContrast'], diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 675d0664fbb5b4..d1334f67a1bef2 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -156,6 +156,31 @@ suite('MultiEditorTabsControl', () => { ]); }); + test('connected tabs fill row edges without inter-tab gutters', () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + + const [activeTab, inactiveTab] = container.querySelectorAll('.tabs-container > .tab'); + const activeFillStyle = mainWindow.getComputedStyle(activeTab.querySelector('.tab-fill')!); + const inactiveFillStyle = mainWindow.getComputedStyle(inactiveTab.querySelector('.tab-fill')!); + + assert.deepStrictEqual({ + active: { left: activeFillStyle.left, right: activeFillStyle.right }, + inactive: { left: inactiveFillStyle.left, right: inactiveFillStyle.right }, + }, { + active: { left: '0px', right: '0px' }, + inactive: { left: '0px', right: '0px' }, + }); + }); + test('keeps the connected outline inside the visible scroll area', async () => { const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; From 9372860e9d8e654670a33083128e042797f90e84 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 11:37:23 -0700 Subject: [PATCH 11/26] style: connect editor tabs across the full strip Remove the shared pill gutters from connected editor tabs so active and inactive fills align with every strip boundary. Preserve row-specific spacing for wrapped and separate pinned rows, and keep the active surface overlapping the document well by one stroke. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/modernUI/README.md | 2 +- .../browser/media/connectedEditorTabs.css | 19 +++++++++++++------ .../browser/modernUI.contribution.test.ts | 2 +- .../editor/editorTabBar.fixture.css | 2 +- .../editor/editorTabBar.fixture.ts | 2 +- .../editor/multiEditorTabsControl.test.ts | 12 ++++++++---- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index 0c43576f91f23f..bb59f9394c1ade 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -11,7 +11,7 @@ The Modern UI colors below are experimental and require a build that supports th `workbench.experimental.modernUIEditorTabStyle` chooses the editor tab treatment when `workbench.experimental.modernUI` is enabled: -- `connected` (default): the active tab joins the editor surface, with an outside stroke and curved shoulders. The first tab has a straight left edge. +- `connected` (default): tabs span the full strip height without gaps. The active tab joins the editor surface with an outside stroke and curved shoulders, and the first tab has a straight left edge. - `pill`: separate rounded tabs, without the connected stroke or shoulders. Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. High contrast themes keep their explicit selection and focus borders. diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 9640aa00373a43..692f5564327414 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -15,10 +15,12 @@ --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40)); --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size40); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { background-color: var(--vscode-editorGroupHeader-tabsBackground); + padding-left: 0; } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { @@ -27,6 +29,8 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-fill { inset-inline: 0; + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, @@ -49,14 +53,17 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size60); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(:first-child) { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size60) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20); } /* Preserve the existing explicit selection and focus borders in high contrast. */ @@ -72,9 +79,9 @@ --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-surface); --modern-ui-editor-tab-active-border: transparent; --modern-ui-editor-tab-unfocused-active-border: transparent; - top: calc(-1 * var(--vscode-strokeThickness)); + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); inset-inline: 0; - bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter) - var(--vscode-strokeThickness)); z-index: 1; background-color: var(--modern-ui-connected-tab-surface); border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); @@ -87,7 +94,7 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { content: ''; position: absolute; - bottom: calc(-1 * var(--vscode-strokeThickness)); + bottom: 0; width: var(--modern-ui-connected-tab-shoulder-radius); height: var(--modern-ui-connected-tab-shoulder-radius); box-sizing: border-box; @@ -118,9 +125,9 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge { display: block; position: absolute; - top: calc(-1 * var(--vscode-strokeThickness)); + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); inset-inline: 0; - bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter) - var(--vscode-strokeThickness)); z-index: 8; pointer-events: none; } @@ -199,7 +206,7 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active) > .tab-fill { - bottom: calc(-1 * var(--vscode-strokeThickness)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index f7ca83434e0395..1eb52c5d5b8c5e 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1989,7 +1989,7 @@ suite('ModernUIContribution', () => { }, { tabBounds: tabBounds.toJSON(), labelBounds: labelBounds.toJSON(), - fillExpansion: connected ? [1, 0, 0] : [0, 0, 0], + fillExpansion: [0, 0, 0], topRadius: connected ? '5px' : '4px', }, JSON.stringify({ classes, theme, activeGroup, compact })); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css index 0adc39b446af4c..c5663826c7b670 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -8,7 +8,7 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.fixture-hover:not(.selected):not(.active) > .tab-fill { - bottom: calc(-1 * var(--vscode-strokeThickness)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); } .modern-ui-tabs .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.fixture-hover:not(.selected):not(.active) .tab-label:not(.monaco-decoration-itemColor) a, 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 e62b50c0546bbb..d89ed82e0107c6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -899,7 +899,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FirstTabActiveAdjacentHover: defineComponentFixture({ render: renderConnectedSurface(0, 1), expectedVisualDescriptions: [ - 'The first active tab is flush with the editor body left edge. The adjacent hovered tab begins immediately at the active tab boundary without an inter-tab gutter, while the active tab shoulder remains visible over the shared boundary.', + 'The first active tab is flush with the editor body left edge and the tab strip top edge, and overlaps the body by one stroke so no seam appears below the cap or shoulder. The adjacent hovered tab begins immediately at the active tab boundary and spans the full strip height without an inter-tab, upper, or lower gutter, while the active tab shoulder remains visible over the shared boundary.', ], }), LastTabActive: defineComponentFixture({ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index d1334f67a1bef2..3b1c593333e742 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -158,6 +158,7 @@ suite('MultiEditorTabsControl', () => { test('connected tabs fill row edges without inter-tab gutters', () => { const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size40: 4px; --vscode-strokeThickness: 1px;'; mainWindow.document.body.appendChild(root); disposables.add(toDisposable(() => root.remove())); const editor = $('.part.editor'); @@ -171,13 +172,16 @@ suite('MultiEditorTabsControl', () => { const [activeTab, inactiveTab] = container.querySelectorAll('.tabs-container > .tab'); const activeFillStyle = mainWindow.getComputedStyle(activeTab.querySelector('.tab-fill')!); const inactiveFillStyle = mainWindow.getComputedStyle(inactiveTab.querySelector('.tab-fill')!); + const rowStyle = mainWindow.getComputedStyle(container.querySelector('.tabs-and-actions-container')!); assert.deepStrictEqual({ - active: { left: activeFillStyle.left, right: activeFillStyle.right }, - inactive: { left: inactiveFillStyle.left, right: inactiveFillStyle.right }, + active: { top: activeFillStyle.top, left: activeFillStyle.left, right: activeFillStyle.right, bottom: activeFillStyle.bottom }, + inactive: { top: inactiveFillStyle.top, left: inactiveFillStyle.left, right: inactiveFillStyle.right, bottom: inactiveFillStyle.bottom }, + rowPaddingLeft: rowStyle.paddingLeft, }, { - active: { left: '0px', right: '0px' }, - inactive: { left: '0px', right: '0px' }, + active: { top: '-4px', left: '0px', right: '0px', bottom: '-6px' }, + inactive: { top: '-4px', left: '0px', right: '0px', bottom: '-5px' }, + rowPaddingLeft: '0px', }); }); From b46b8820e136fc47992685056a7138c49d23b440 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 17:50:33 -0700 Subject: [PATCH 12/26] feat: refine connected document tab geometry and readability Preserve filename identity and recover icons under compression, keep wrapped row heights consistent, and join the selected tab to a single HC document-well outline. Refine default strip palettes and cover bundled themes and viewport-edge states.\n\nRefs #331104 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 7 + build/rspack/rspack.serve-out.config.mts | 2 +- .../theme-defaults/themes/2026-dark.json | 4 +- .../theme-defaults/themes/2026-light.json | 4 +- .../theme-defaults/themes/dark_modern.json | 4 +- extensions/theme-defaults/themes/dark_vs.json | 3 + .../theme-defaults/themes/light_modern.json | 6 +- .../theme-defaults/themes/light_vs.json | 3 + src/vs/workbench/browser/labels.ts | 1 + .../parts/editor/multiEditorTabsControl.ts | 109 +++++- src/vs/workbench/contrib/modernUI/README.md | 18 +- .../browser/media/connectedEditorTabs.css | 300 ++++++++++++--- .../contrib/modernUI/browser/media/tabs.css | 21 +- .../browser/modernUI.contribution.test.ts | 200 +++++++++- .../editor/editorTabBar.fixture.css | 4 +- .../editor/editorTabBar.fixture.ts | 124 +++++- .../browser/componentFixtures/fixtureUtils.ts | 25 +- .../editor/multiEditorTabsControl.test.ts | 356 +++++++++++++++++- 18 files changed, 1075 insertions(+), 116 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a0a204d4ffb5dd..061e856ff28e74 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1028,6 +1028,8 @@ "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", + "--connected-tab-min-name-width", + "--connected-tab-min-width", "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", @@ -1074,11 +1076,15 @@ "--pane-header-size", "--model-hover-surface-background", "--modern-ui-activitybar-lane", + "--modern-ui-connected-tab-action-background", + "--modern-ui-connected-tab-border", "--modern-ui-connected-tab-cap-radius", "--modern-ui-connected-tab-gutter", "--modern-ui-connected-tab-radius", "--modern-ui-connected-tab-shoulder-radius", "--modern-ui-connected-tab-surface", + "--modern-ui-connected-tab-top-inset", + "--modern-ui-connected-well-border", "--modern-ui-editor-border-color", "--modern-ui-editor-tab-action-active-background", "--modern-ui-editor-tab-action-active-hover-background", @@ -1129,6 +1135,7 @@ "--modern-ui-notifications-block-start-inset", "--modern-ui-notifications-inline-inset", "--modern-ui-shell-background", + "--modern-ui-tab-action-padding", "--modern-ui-tab-active-background", "--modern-ui-tab-hover-background", "--search-editor-query-layout-offset", diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 58b6a7beacce27..ca410bdd944916 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -110,7 +110,7 @@ export default { // Built-in color theme JSON files use JSONC (comments / trailing // commas), so import them as raw strings and let VS Code's // JSON parser handle them. - test: /[\\/]extensions[\\/]theme-defaults[\\/]themes[\\/].*\.json$/, + test: /[\\/]extensions[\\/]theme-[^\\/]+[\\/]themes[\\/].*\.json$/, type: 'asset/source', }, ], diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 50ffbbf5cd6003..fd533bf1680abe 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -194,7 +194,7 @@ "statusBarItem.prominentHoverBackground": "#3994BC", "tab.activeBackground": "#121314", "tab.activeForeground": "#bfbfbf", - "tab.inactiveBackground": "#191A1B", + "tab.inactiveBackground": "#202122", "tab.inactiveForeground": "#8C8C8C", "tab.border": "#2A2B2C", "tab.lastPinnedBorder": "#2A2B2C", @@ -205,7 +205,7 @@ "tab.unfocusedActiveForeground": "#8C8C8C", "tab.unfocusedInactiveBackground": "#191A1B", "tab.unfocusedInactiveForeground": "#444444", - "editorGroupHeader.tabsBackground": "#191A1B", + "editorGroupHeader.tabsBackground": "#202122", "tab.activeBorder": "#121314", "editorGroupHeader.tabsBorder": "#2A2B2C", "breadcrumb.foreground": "#8C8C8C", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 13ddd867d8d5fe..45dc4f1f026cf5 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -201,7 +201,7 @@ "toolbar.activeBackground": "#D6D6D8", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", - "tab.inactiveBackground": "#FAFAFD", + "tab.inactiveBackground": "#EAEAEA", "tab.inactiveForeground": "#606060", "tab.border": "#F0F1F2", "tab.lastPinnedBorder": "#F0F1F2", @@ -212,7 +212,7 @@ "tab.unfocusedActiveForeground": "#606060", "tab.unfocusedInactiveBackground": "#FAFAFD", "tab.unfocusedInactiveForeground": "#BBBBBB", - "editorGroupHeader.tabsBackground": "#FAFAFD", + "editorGroupHeader.tabsBackground": "#EAEAEA", "tab.activeBorder": "#FFFFFF", "editorGroupHeader.tabsBorder": "#F0F1F2", "breadcrumb.foreground": "#606060", diff --git a/extensions/theme-defaults/themes/dark_modern.json b/extensions/theme-defaults/themes/dark_modern.json index 26f4f2a474a94a..67688754a90f32 100644 --- a/extensions/theme-defaults/themes/dark_modern.json +++ b/extensions/theme-defaults/themes/dark_modern.json @@ -34,7 +34,7 @@ "editor.findMatchBackground": "#9E6A03", "editor.foreground": "#CCCCCC", "editorGroup.border": "#FFFFFF17", - "editorGroupHeader.tabsBackground": "#181818", + "editorGroupHeader.tabsBackground": "#2B2B2B", "editorGroupHeader.tabsBorder": "#2B2B2B", "editorGutter.addedBackground": "#2EA043", "editorGutter.deletedBackground": "#F85149", @@ -107,7 +107,7 @@ "tab.selectedBorderTop": "#6caddf", "tab.border": "#2B2B2B", "tab.hoverBackground": "#1F1F1F", - "tab.inactiveBackground": "#181818", + "tab.inactiveBackground": "#2B2B2B", "tab.inactiveForeground": "#9D9D9D", "tab.unfocusedActiveBorder": "#1F1F1F", "tab.unfocusedActiveBorderTop": "#2B2B2B", diff --git a/extensions/theme-defaults/themes/dark_vs.json b/extensions/theme-defaults/themes/dark_vs.json index 0a0376f37d714b..7b9c6d4a99b6fa 100644 --- a/extensions/theme-defaults/themes/dark_vs.json +++ b/extensions/theme-defaults/themes/dark_vs.json @@ -5,6 +5,7 @@ "checkbox.border": "#6B6B6B", "editor.background": "#1E1E1E", "editor.foreground": "#D4D4D4", + "editorGroupHeader.tabsBackground": "#303031", "editor.inactiveSelectionBackground": "#3A3D41", "editorIndentGuide.background1": "#404040", "editorIndentGuide.activeBackground1": "#707070", @@ -27,6 +28,8 @@ "sideBarSectionHeader.background": "#0000", "sideBarSectionHeader.border": "#ccc3", "tab.selectedBackground": "#37373D", + "tab.inactiveBackground": "#303031", + "tab.inactiveForeground": "#A6A6A6", "tab.selectedForeground": "#FFFFFF", "tab.lastPinnedBorder": "#ccc3", "list.activeSelectionIconForeground": "#FFF", diff --git a/extensions/theme-defaults/themes/light_modern.json b/extensions/theme-defaults/themes/light_modern.json index 51d6f26cab5501..d76ab0458cb76c 100644 --- a/extensions/theme-defaults/themes/light_modern.json +++ b/extensions/theme-defaults/themes/light_modern.json @@ -34,7 +34,7 @@ "editor.inactiveSelectionBackground": "#E5EBF1", "editor.selectionHighlightBackground": "#ADD6FF80", "editorGroup.border": "#E5E5E5", - "editorGroupHeader.tabsBackground": "#F8F8F8", + "editorGroupHeader.tabsBackground": "#E5E5E5", "editorGroupHeader.tabsBorder": "#E5E5E5", "editorGutter.addedBackground": "#2EA043", "editorGutter.deletedBackground": "#F85149", @@ -124,8 +124,8 @@ "tab.selectedBorderTop": "#68a3da", "tab.border": "#E5E5E5", "tab.hoverBackground": "#FFFFFF", - "tab.inactiveBackground": "#F8F8F8", - "tab.inactiveForeground": "#868686", + "tab.inactiveBackground": "#E5E5E5", + "tab.inactiveForeground": "#616161", "tab.lastPinnedBorder": "#D4D4D4", "tab.unfocusedActiveBorder": "#F8F8F8", "tab.unfocusedActiveBorderTop": "#E5E5E5", diff --git a/extensions/theme-defaults/themes/light_vs.json b/extensions/theme-defaults/themes/light_vs.json index 991bb3b0773588..3c5363c5a500ca 100644 --- a/extensions/theme-defaults/themes/light_vs.json +++ b/extensions/theme-defaults/themes/light_vs.json @@ -5,6 +5,7 @@ "checkbox.border": "#919191", "editor.background": "#FFFFFF", "editor.foreground": "#000000", + "editorGroupHeader.tabsBackground": "#E8E8E8", "editor.inactiveSelectionBackground": "#E5EBF1", "editorIndentGuide.background1": "#D3D3D3", "editorIndentGuide.activeBackground1": "#939393", @@ -26,6 +27,8 @@ "sideBarSectionHeader.background": "#0000", "sideBarSectionHeader.border": "#61616130", "tab.selectedForeground": "#333333", + "tab.inactiveBackground": "#E8E8E8", + "tab.inactiveForeground": "#616161", "tab.selectedBackground": "#E4E6F1", "tab.lastPinnedBorder": "#61616130", "notebook.cellBorderColor": "#E8E8E8", diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index e00a96d56f3e0e..d81722d63affa7 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -636,6 +636,7 @@ class ResourceLabelWidget extends IconLabel { strikethrough: this.options?.strikethrough, matches: this.options?.matches, descriptionMatches: this.options?.descriptionMatches, + suffix: this.options?.suffix, extraClasses: [], separator: this.options?.separator, domId: this.options?.domId, diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 86666da80b35c2..456f3d9f150878 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -24,7 +24,7 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { IDisposable, dispose, DisposableStore, combinedDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; -import { getOrSet } from '../../../../base/common/map.js'; +import { getOrSet, LRUCache } from '../../../../base/common/map.js'; import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { TAB_INACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_BORDER, EDITOR_DRAG_AND_DROP_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_ACTIVE_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, EDITOR_GROUP_HEADER_TABS_BACKGROUND, WORKBENCH_BACKGROUND, TAB_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_ACTIVE_MODIFIED_BORDER, TAB_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_ACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_HOVER_FOREGROUND, TAB_UNFOCUSED_HOVER_FOREGROUND, EDITOR_GROUP_HEADER_TABS_BORDER, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BORDER_TOP } from '../../../common/theme.js'; import { activeContrastBorder, contrastBorder, editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; @@ -112,6 +112,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { private tabsContainer: HTMLElement | undefined; private tabsScrollbar: ScrollableElement | undefined; private connectedTabOverflowEdge: HTMLElement | undefined; + private connectedTabLabels = false; + private connectedTabMeasureContext: CanvasRenderingContext2D | undefined; + private readonly connectedTabTextWidths = new LRUCache(256); private addTabContainer: HTMLElement | undefined; private tabSizingFixedDisposables: DisposableStore | undefined; private connectedTabBounds: { tab: HTMLElement; overflowEdge: HTMLElement; fillLeft: number; fillRight: number; viewportLeft: number; viewportRight: number; clippingEdgeExtent: number; shoulderExtent: number } | undefined; @@ -273,6 +276,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.tabsAndActionsContainer.appendChild(this.tabsScrollbar.getDomNode()); this.tabsScrollbar.getDomNode().appendChild(this.stickyTabsBackground); this.connectedTabOverflowEdge = $('.tab-connected-overflow-edge', { 'aria-hidden': true }); + this.connectedTabOverflowEdge.appendChild($('.tab-connected-overflow-right')); this.tabsScrollbar.getDomNode().appendChild(this.connectedTabOverflowEdge); // Tabs Container listeners @@ -848,7 +852,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { } private invalidateConnectedTabLayout(): void { - if (this.connectedTabBounds || this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black):not(.hc-light)')) { + if (this.connectedTabBounds || this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')) { this.clearConnectedTabClipping(); this.layout(this.dimensions); } @@ -1821,6 +1825,14 @@ export class MultiEditorTabsControl extends EditorTabsControl { } // Label + let suffix: string | undefined; + if (name && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') && !(options.pinnedTabSizing === 'compact' && this.tabsModel.isSticky(tabIndex))) { + const extensionIndex = name.lastIndexOf('.'); + if (extensionIndex > 0 && extensionIndex < name.length - 1) { + suffix = name.substring(extensionIndex); + name = name.substring(0, extensionIndex); + } + } tabLabelWidget.setResource( { name, description, resource: EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.BOTH }) }, { @@ -1835,6 +1847,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { icon: editor.getIcon(), hideIcon: options.showIcons === false, namePrefix, + suffix, } ); @@ -2051,6 +2064,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { } private doLayoutTabs(dimensions: IEditorTitleControlDimensions, options?: IMultiEditorTabsControlLayoutOptions): void { + const connected = Boolean(this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')); + this.parent.classList.toggle('connected-tabs-labels', connected); + if (connected !== this.connectedTabLabels) { + this.connectedTabLabels = connected; + this.forEachTab((editor, index, tab, widget, label) => this.redrawTabLabel(editor, index, tab, widget, label)); + } + this.layoutConnectedTabLabels(connected); // Always first layout tabs with wrapping support even if wrapping // is disabled. The result indicates if tabs wrap and if not, we @@ -2058,6 +2078,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { // if wrapping is enabled in settings, there are cases where // wrapping is disabled (e.g. due to space constraints) const tabsWrapMultiLine = this.doLayoutTabsWrapping(dimensions); + const tabs = Array.from(assertReturnsDefined(this.tabsContainer).children).filter(isHTMLElement); + const bottom = tabs.at(-1)?.offsetTop; + const upperTabBar = this.parent.classList.contains('two-tab-bars') && this.parent.firstElementChild === this.tabsAndActionsContainer; + for (const tab of tabs) { + tab.classList.toggle('connected-tab-upper-row', connected && (upperTabBar || tab.offsetTop !== bottom)); + } if (!tabsWrapMultiLine) { this.doLayoutTabsNonWrapping(options); } else { @@ -2066,6 +2092,66 @@ export class MultiEditorTabsControl extends EditorTabsControl { } } + private layoutConnectedTabLabels(connected: boolean): void { + const tabs = Array.from(assertReturnsDefined(this.tabsContainer).children).filter(isHTMLElement); + // Restore intrinsic icon widths before measuring so wider tabs can recover after a resize. + for (const tab of tabs) { + tab.classList.remove('connected-tab-narrow'); + } + const minimumWidths = new Map(); + for (const [index, tab] of tabs.entries()) { + if (!connected || tab.classList.contains('sticky-compact')) { + tab.style.removeProperty('--connected-tab-min-width'); + tab.style.removeProperty('--connected-tab-min-name-width'); + continue; + } + const label = assertReturnsDefined(this.tabResourceLabels.get(index)).element; + // IconLabel owns these private nodes; use their rendered fonts for text measurement. + // eslint-disable-next-line no-restricted-syntax + const name = label.querySelector('.label-name')!; + // eslint-disable-next-line no-restricted-syntax + const suffix = label.querySelector('.label-suffix'); + const style = getWindow(tab).getComputedStyle(tab); + const prefix = this.groupsView.partOptions.showTabIndex ? `${this.toEditorIndex(index) + 1}: ` : ''; + const firstNameCharacter = Array.from((name.textContent ?? '').substring(prefix.length))[0] ?? ''; + // The ellipsis belongs to the overflow container, not the label anchor. + // Measure each separately: their fonts can differ, and suffixes cannot kern across elements. + const minimumNameWidth = this.measureConnectedTabText(`${prefix}${firstNameCharacter}`, name) + + this.measureConnectedTabText('…', name.parentElement!); + const nameWidth = minimumNameWidth + (suffix ? this.measureConnectedTabText(suffix.textContent ?? '', suffix) : 0); + const badge = label.classList.contains('monaco-decoration-badge') ? getWindow(label).getComputedStyle(label, '::after') : undefined; + const badgeWidth = badge ? (parseFloat(badge.width) || 0) + (parseFloat(badge.marginLeft) || 0) + (parseFloat(badge.marginRight) || 0) : 0; + // Allow for canvas and DOM text metrics rounding in either direction. + const minimum = Math.ceil(nameWidth + badgeWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)) + 2; + tab.style.setProperty('--connected-tab-min-name-width', `${Math.ceil(minimumNameWidth)}px`); + minimumWidths.set(tab, minimum); + } + for (const [tab, minimum] of minimumWidths) { + if (tab.style.getPropertyValue('--connected-tab-min-width') !== `${minimum}px`) { + tab.style.setProperty('--connected-tab-min-width', `${minimum}px`); + } + } + const narrowTabs = Array.from(minimumWidths, ([tab, minimum]) => ({ tab, narrow: tab.offsetWidth < minimum + 22 })); + for (const { tab, narrow } of narrowTabs) { + tab.classList.toggle('connected-tab-narrow', narrow); + } + } + + private measureConnectedTabText(text: string, element: HTMLElement): number { + const style = getWindow(element).getComputedStyle(element); + const letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing; + const key = `${style.font}/${letterSpacing}/${text}`; + let width = this.connectedTabTextWidths.get(key); + if (width === undefined) { + const context = this.connectedTabMeasureContext ??= assertReturnsDefined($('canvas').getContext('2d')); + context.font = style.font; + context.letterSpacing = letterSpacing; + width = context.measureText(text).width; + this.connectedTabTextWidths.set(key, width); + } + return width; + } + private doLayoutTabsWrapping(dimensions: IEditorTitleControlDimensions): boolean { const [tabsAndActionsContainer, tabsContainer, editorToolbarContainer, tabsScrollbar] = assertReturnsAllDefined(this.tabsAndActionsContainer, this.tabsContainer, this.editorActionsToolbarContainer, this.tabsScrollbar); @@ -2236,13 +2322,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { let stickyTabsWidth = 0; if (this.tabsModel.stickyCount > 0) { const stickyTabWidth = this.getStickyTabWidth(this.groupsView.partOptions.pinnedTabSizing); - stickyTabsWidth = this.tabsModel.stickyCount * stickyTabWidth; for (let tabIndex = 0; tabIndex < this.tabsModel.stickyCount; tabIndex++) { const tab = this.getTabAtIndex(tabIndex); if (tab) { - tab.style.left = `${tabIndex * stickyTabWidth}px`; + tab.style.left = `${stickyTabsWidth}px`; } + stickyTabsWidth += this.connectedTabLabels && stickyTabWidth > 0 && tab ? tab.offsetWidth : stickyTabWidth; } } @@ -2271,7 +2357,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.clearConnectedTabClipping(); const activeTabFill = activeTab?.firstElementChild; const overflowEdge = this.connectedTabOverflowEdge; - if (activeTab && isHTMLElement(activeTabFill) && overflowEdge && !activeTab.classList.contains('multi-selected') && !activeTabPositionStatic && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black):not(.hc-light)')) { + if (activeTab && !activeTab.classList.contains('connected-tab-upper-row') && isHTMLElement(activeTabFill) && overflowEdge && !activeTabPositionStatic && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')) { const scrollLeft = tabsScrollbar.getScrollPosition().scrollLeft; const tabsBounds = tabsContainer.getBoundingClientRect(); const fillBounds = activeTabFill.getBoundingClientRect(); @@ -2327,8 +2413,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Reveal the active one const tabsContainerScrollPosX = tabsScrollbar.getScrollPosition().scrollLeft; - const activeTabFits = activeTabWidth <= availableTabsContainerWidth; + const activeTabShoulderWidth = this.connectedTabBounds?.shoulderExtent ?? 0; + const activeTabFits = activeTabWidth + activeTabShoulderWidth <= availableTabsContainerWidth; const adjustedActiveTabPosX = activeTabPosX - stickyTabsWidth; + // Integer offset widths can leave a fractional shoulder clipped after revealing a tab. + const activeTabRight = this.connectedTabBounds + ? Math.ceil(this.connectedTabBounds.fillRight + activeTabShoulderWidth) - stickyTabsWidth + : adjustedActiveTabPosX + activeTabWidth; // // Synopsis @@ -2351,9 +2442,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { // [-- Sticky Tabs Width --] // // - if (activeTabFits && tabsContainerScrollPosX + availableTabsContainerWidth < adjustedActiveTabPosX + activeTabWidth) { + if (activeTabFits && tabsContainerScrollPosX + availableTabsContainerWidth < activeTabRight) { tabsScrollbar.setScrollPosition({ - scrollLeft: tabsContainerScrollPosX + ((adjustedActiveTabPosX + activeTabWidth) /* right corner of tab */ - (tabsContainerScrollPosX + availableTabsContainerWidth) /* right corner of view port */) + scrollLeft: tabsContainerScrollPosX + (activeTabRight - (tabsContainerScrollPosX + availableTabsContainerWidth)) }); } @@ -2429,7 +2520,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { tab.classList.toggle('connected-tab-right-clipped', rightClipped); tab.classList.toggle('connected-tab-hidden', hidden); overflowEdge.classList.toggle('connected-tab-left-clipped', leftClipped && !hidden); - overflowEdge.classList.toggle('connected-tab-right-clipped', rightClipped && !hidden); + overflowEdge.classList.toggle('connected-tab-right-clipped', rightEdge && !hidden); } private updateTabsControlVisibility(): void { diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index bb59f9394c1ade..e622f4ee9e0cc5 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -11,12 +11,24 @@ The Modern UI colors below are experimental and require a build that supports th `workbench.experimental.modernUIEditorTabStyle` chooses the editor tab treatment when `workbench.experimental.modernUI` is enabled: -- `connected` (default): tabs span the full strip height without gaps. The active tab joins the editor surface with an outside stroke and curved shoulders, and the first tab has a straight left edge. +- `connected` (default): the bottom tab row spans the strip height without gaps. Its active tab joins the editor surface with an outside stroke and curved shoulders, and the first tab has a straight left edge. Upper wrapped rows and a separate pinned row retain the original rounded pills. - `pill`: separate rounded tabs, without the connected stroke or shoulders. -Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. High contrast themes keep their explicit selection and focus borders. +Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. -The connected design uses `editor.background` for the active tab, its outside stroke, both shoulders, and the strip separator so the selected tab and editor body read as one document well. The strip and inactive tabs use `editorGroupHeader.tabsBackground`, and their hover fill is derived from `foreground` over that background. Existing tab foreground customizations continue to apply. `modernEditorTab.activeBackground` remains available to the pill style. +Connected tabs preserve at least the first basename character, an ellipsis, the extension, any decoration badge, and the action column when shrinking. File icons collapse first and return when the editor is widened; full names remain available in the hover and accessible label. Default `fit` sizing remains content-sized: the measured minimum is a compression safeguard, not a preferred tab width. Active close actions and dirty indicators remain visible, while clean inactive close actions appear on hover or keyboard focus. The final tab and the right viewport boundary keep an inset curved shoulder instead of a straight clipped edge. Explicit compact pinned tabs retain their icon-only sizing. + +The top cap and bottom shoulders share the same control radius plus the outside stroke (5px with the default tokens), including at clipped viewport edges. Upper-row pills retain the original control radius. Wrapped fills occupy equal-height row hit boxes (28px normally, 24px compact), with spacing only between rows and no extra gutter below the final row. Hover does not change their geometry. + +Automatic reveal includes the complete shoulder and rounds fractional layout bounds outward so the selected action is not clipped. Manual scrolling can still move part of the selected tab, including its action, offscreen. In that case a stationary cap and shoulder finish the visible outline; their stroke is aligned with the document separator, and the clipping mask falls back to `editor.background` when the theme does not define a tab-strip background. + +The connected design uses `editor.background` for the active tab on every row, its action area, outside stroke, both shoulders, and the strip separator so the selected tab and editor body read as one document well. Upper wrapped and separate pinned rows change only the tab shape, not its selected background. The strip and inactive tabs use `editorGroupHeader.tabsBackground`, and their hover fill is derived from `foreground` over that background. Existing tab foreground customizations continue to apply. `modernEditorTab.activeBackground` remains available to the pill style. + +The default palettes give `editorGroupHeader.tabsBackground` a restrained neutral fill using existing palette colors: Dark 2026 uses `#202122`, Light 2026 uses `#EAEAEA`, Dark Modern uses `#2B2B2B`, Light Modern uses `#E5E5E5`, the classic dark themes use `#303031`, and the classic light themes use `#E8E8E8`. `tab.inactiveBackground` matches the strip. These are theme-level values, so classic tabs also receive the updated inactive fill. Dark+/Light+ inherit the change from their Visual Studio base themes; HC and the other bundled background palettes are unchanged. No new color ID or cross-component token dependency is introduced. + +Connected tabs use `tab.inactiveForeground` rather than dimming the general foreground to 50% opacity. The default palettes pair these fills with readable inactive text (at least 4.5:1), including in inactive editor groups. Light Modern and the classic light themes use the existing neutral `#616161`; the classic dark themes use `#A6A6A6`. Explicit legacy foreground customizations still take precedence. HC styling and the original pill label defaults are unchanged. + +In high contrast, the connected boundary uses `focusBorder` for the active editor group and `contrastBorder` for other groups. It follows the selected tab and continues around the breadcrumbs and document body, rather than outlining only the tab. The surrounding editor-area card stroke is transparent in HC, including compact-layout corner strokes, so it does not create a second frame. Keyboard focus and multi-selection indicators remain visible. The same one-stroke geometry is present in all themes: the body frame is transparent in standard themes, and its inside paint layer does not change editor dimensions when switching themes or active groups. ## Colors diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 692f5564327414..96d1d4d04f60ca 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -4,6 +4,53 @@ *--------------------------------------------------------------------------------------------*/ /* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ +/* In HC the connected well owns the frame; retain the outer card's layout space, not its stroke. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor:not(.modal-editor-part) { + border-color: transparent !important; + --modern-ui-floating-card-stroke-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container { + --modern-ui-connected-tab-border: var(--modern-ui-connected-tab-surface); + --modern-ui-connected-well-border: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container { + --modern-ui-connected-tab-border: var(--vscode-contrastBorder); + --modern-ui-connected-well-border: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container.active { + --modern-ui-connected-tab-border: var(--vscode-focusBorder); +} + +/* Reserve the same inside stroke in every theme without changing the editor's layout box. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs ~ .editor-container, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs { + position: relative; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs ~ .editor-container::after, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs::after { + content: ''; + position: absolute; + inset: 0; + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-well-border); + border-top: 0; + pointer-events: none; + z-index: 10; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs::after { + border-bottom: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:focus-visible > .tab-fill { + outline: var(--vscode-strokeThickness) dashed var(--vscode-focusBorder); + outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); +} + .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { --modern-ui-editor-tab-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); @@ -13,9 +60,10 @@ --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); - --modern-ui-connected-tab-shoulder-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-spacing-size40)); + --modern-ui-connected-tab-shoulder-radius: var(--modern-ui-connected-tab-cap-radius); --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size40); + --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size280) + var(--modern-ui-connected-tab-shoulder-radius)); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { @@ -27,7 +75,7 @@ padding-bottom: var(--vscode-strokeThickness); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-fill { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-fill { inset-inline: 0; top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); @@ -48,14 +96,24 @@ * scrollbar, pinned-tab or drag-and-drop stacking order. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { z-index: 0; - background-color: var(--modern-ui-connected-tab-surface); + background-color: var(--modern-ui-connected-tab-border); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { - --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-gutter: var(--vscode-spacing-size20); --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20); } +/* Wrapped fills occupy their row's hit box, without an extra gutter below the final row. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping > .monaco-scrollable-element { + padding-bottom: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container { + padding-bottom: 0; + row-gap: var(--vscode-spacing-size20); +} + .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size60); @@ -74,9 +132,9 @@ border-color: transparent; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill { - --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-surface); - --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-surface); +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { + --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); --modern-ui-editor-tab-active-border: transparent; --modern-ui-editor-tab-unfocused-active-border: transparent; top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); @@ -84,45 +142,43 @@ bottom: calc(-1 * var(--modern-ui-connected-tab-gutter) - var(--vscode-strokeThickness)); z-index: 1; background-color: var(--modern-ui-connected-tab-surface); - border-inline: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-color: transparent; border-radius: var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) 0 0; box-shadow: none; } /* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { content: ''; position: absolute; bottom: 0; width: var(--modern-ui-connected-tab-shoulder-radius); height: var(--modern-ui-connected-tab-shoulder-radius); box-sizing: border-box; - border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); clip-path: inset(0); pointer-events: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { right: 100%; - border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):first-child > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active:not(.multi-selected) > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-fill::after, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).last-in-row > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill::before { content: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-left-edge > .tab-fill::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-right-edge > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-fill::before { content: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge { display: block; position: absolute; top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); @@ -132,8 +188,8 @@ pointer-events: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { position: absolute; bottom: 0; width: var(--vscode-strokeThickness); @@ -142,37 +198,34 @@ background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--modern-ui-connected-tab-surface) var(--vscode-strokeThickness)); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):not(:first-child) > .tab-connected-edge::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(:first-child) > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { content: ''; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::before { left: 0; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-connected-edge::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { right: 0; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-left-edge > .tab-connected-edge::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-right-edge > .tab-connected-edge::after, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active:not(.multi-selected) > .tab-connected-edge::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected):last-child > .tab-connected-edge::after, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).last-in-row > .tab-connected-edge::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-connected-edge::before { content: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped { display: block; position: absolute; z-index: 8; pointer-events: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { content: ''; position: absolute; top: 0; @@ -180,36 +233,33 @@ width: var(--modern-ui-connected-tab-cap-radius); box-sizing: border-box; background-color: var(--modern-ui-connected-tab-surface); - border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { left: 0; - border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { right: 0; - border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-hidden > .tab-fill, -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).connected-tab-hidden > .tab-connected-edge { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-hidden > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-hidden > .tab-connected-edge { display: none; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { left: 100%; - border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-surface); + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active) > .tab-fill { - bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); -} - -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected) > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { + --modern-ui-connected-tab-action-background: var(--modern-ui-connected-tab-surface); --modern-ui-editor-tab-action-active-background: var(--modern-ui-connected-tab-surface); --modern-ui-editor-tab-action-unfocused-active-background: var(--modern-ui-connected-tab-surface); --modern-ui-editor-tab-action-active-hover-background: var(--modern-ui-connected-tab-surface); @@ -217,7 +267,7 @@ border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.multi-selected).tab-actions-left > .tab-actions { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; } @@ -228,3 +278,153 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.tab-actions-left > .tab-actions .actions-container { justify-content: flex-start; } + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { + right: var(--modern-ui-connected-tab-shoulder-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact).tab-actions-left > .tab-actions { + left: var(--modern-ui-connected-tab-shoulder-radius); +} + +/* Keep the complete identity and action column; only the basename can ellipsize. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:is(.sizing-fixed, .sizing-shrink):not(.sticky-compact) { + min-width: max(var(--connected-tab-min-width, 80px), var(--tab-sizing-fixed-min-width, 0px)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) > .tab-label > .monaco-icon-label-container { + display: flex; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-name-container { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:is(.sizing-fixed, .sizing-shrink):not(.sticky-compact) .monaco-icon-name-container { + min-width: var(--connected-tab-min-name-width, 0); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-description-container { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-label.monaco-decoration-badge::after { + flex-shrink: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-suffix-container { + flex-shrink: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .label-suffix { + opacity: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow > .tab-label::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow .monaco-icon-label-iconpath, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow .monaco-icon-description-container { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.dirty):not(:hover):not(.sticky):not(:focus-within) > .tab-actions .action-label { + opacity: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.close-action-off) > .tab-actions .action-label, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.dirty > .tab-actions .action-label { + opacity: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.close-action-off) > .tab-actions { + pointer-events: auto; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-actions::before { + content: none; +} + +/* Terminal shoulders turn inside the available slot rather than being cut off. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:is(:last-child, .last-in-row, .connected-tab-right-edge) > :is(.tab-fill, .tab-connected-edge) { + right: var(--modern-ui-connected-tab-shoulder-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + width: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--modern-ui-connected-tab-cap-radius)); + background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)); + border: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right { + position: absolute; + /* Match the ordinary fill's inner border edge, not its reserved bottom stroke. */ + inset: 0 0 var(--vscode-strokeThickness); + z-index: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right::before { + content: ''; + position: absolute; + top: 0; + bottom: var(--modern-ui-connected-tab-shoulder-radius); + right: var(--modern-ui-connected-tab-shoulder-radius); + width: var(--modern-ui-connected-tab-cap-radius); + box-sizing: border-box; + background: var(--modern-ui-connected-tab-surface); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-top-right-radius: var(--modern-ui-connected-tab-cap-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--vscode-strokeThickness)); + height: var(--modern-ui-connected-tab-shoulder-radius); + box-sizing: border-box; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); + clip-path: inset(0 0 0 calc(-1 * var(--vscode-strokeThickness))); +} + +/* Rows above the document retain the original control-tier pills. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row { + --modern-ui-editor-tab-hover-background: var(--vscode-modernEditorTab-hoverBackground); + --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill { + inset: 0 var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small); + border-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.connected-tab-upper-row > .tab-fill { + inset-block: calc(-1 * var(--vscode-spacing-size20)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row.active > .tab-fill { + border-color: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill::after { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-connected-edge { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row.active > .tab-actions { + border-radius: var(--vscode-cornerRadius-small); +} diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 6b9620922401fb..8327533709823c 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -49,6 +49,11 @@ --modern-ui-editor-tabs-border: transparent; } +/* Keep connected labels readable without overriding explicit legacy color customizations. */ +.modern-ui-tabs.monaco-workbench:where(.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black, .hc-light)) { + --modern-ui-editor-tab-inactive-foreground: var(--vscode-tab-inactiveForeground); +} + .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { background-color: transparent !important; cursor: default; @@ -168,16 +173,16 @@ /* Persistent dirty/pin indicators always reserve their overlay column. Clean tabs reserve * the transient close/unpin action only when requested (https://github.com/microsoft/vscode/issues/329605). */ -.modern-ui-tabs .part.editor .tab-actions-reserve-space .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off), +.modern-ui-tabs .part.editor :is(.tab-actions-reserve-space, .connected-tabs-labels) .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off), .modern-ui-tabs .part.editor .tabs-container > .tab.dirty:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off.dirty-border-top), .modern-ui-tabs .part.editor .tabs-container > .tab.sticky:not(.sticky-compact):not(.pinned-action-off):not(.tab-actions-left) { - padding-right: var(--vscode-spacing-size280) !important; + padding-right: var(--modern-ui-tab-action-padding, var(--vscode-spacing-size280)) !important; } -.modern-ui-tabs .part.editor .tab-actions-reserve-space .tabs-container > .tab.tab-actions-left:not(.sticky-compact):not(.close-action-off), +.modern-ui-tabs .part.editor :is(.tab-actions-reserve-space, .connected-tabs-labels) .tabs-container > .tab.tab-actions-left:not(.sticky-compact):not(.close-action-off), .modern-ui-tabs .part.editor .tabs-container > .tab.dirty.tab-actions-left:not(.sticky-compact):not(.close-action-off.dirty-border-top), .modern-ui-tabs .part.editor .tabs-container > .tab.sticky.tab-actions-left:not(.sticky-compact):not(.pinned-action-off) { - padding-left: var(--vscode-spacing-size280) !important; + padding-left: var(--modern-ui-tab-action-padding, var(--vscode-spacing-size280)) !important; padding-right: var(--vscode-spacing-size80) !important; } @@ -767,7 +772,7 @@ :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover > .tab-actions, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions:focus-within { - background-color: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)) !important; + background-color: var(--modern-ui-connected-tab-action-background, var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background))) !important; } :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions .action-label { @@ -816,7 +821,8 @@ outline: none !important; } -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench.modern-ui-connected-editor-tabs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.selected:not(.active):not(:focus) > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .modern-ui-editor-tab.active:not(:focus) > .modern-ui-editor-tab-fill { border: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder) !important; } @@ -826,7 +832,8 @@ border: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder) !important; } -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:focus > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:focus > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench.modern-ui-connected-editor-tabs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):focus > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .modern-ui-editor-tab:focus > .modern-ui-editor-tab-fill { border: var(--vscode-strokeThickness) solid var(--vscode-focusBorder) !important; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 1eb52c5d5b8c5e..9815e4545d921c 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1936,6 +1936,105 @@ suite('ModernUIContribution', () => { ]); }); + test('uses only the connected well frame in HC without changing the outer card geometry', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs floating-panels'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = '--vscode-editor-border: #123456; --vscode-editorGroup-border: #123456; --vscode-strokeThickness: 1px; --vscode-cornerRadius-large: 8px;'; + const grid = appendElement(root, 'monaco-grid-view'); + const editor = appendElement(grid, 'part editor floating-editor-outer-left floating-editor-outer-top'); + editor.style.cssText = 'width: 500px; height: 350px;'; + const content = appendElement(editor, 'content'); + const geometry = () => [editor, content].map(element => { + const bounds = element.getBoundingClientRect(); + return [bounds.x, bounds.y, bounds.width, bounds.height]; + }); + const baseline = geometry(); + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); + root.classList.add(theme); + for (const connected of [false, true]) { + root.classList.toggle('modern-ui-connected-editor-tabs', connected); + for (const compact of [false, true]) { + root.classList.toggle('modern-ui-compact', compact); + const style = getWindow(editor).getComputedStyle(editor); + const singleFrame = connected && theme.startsWith('hc-'); + assert.deepStrictEqual({ + geometry: geometry(), + border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], + compactStroke: compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, + }, { + geometry: baseline, + border: ['1px', '1px', singleFrame || compact ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], + compactStroke: compact ? (singleFrame ? 'transparent' : '#123456') : undefined, + }, `${theme}, connected: ${connected}, compact: ${compact}`); + } + } + } + }); + + test('connects HC group borders without moving content when themes or active groups change', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = 'width: 500px; --vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-cornerRadius-small: 4px; --vscode-strokeThickness: 1px;'; + const themeStyle = document.createElement('style'); + root.appendChild(themeStyle); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const tab = appendElement(tabs, 'tab active'); + tab.style.width = '120px'; + const fill = appendElement(tab, 'tab-fill'); + const { label, name } = createEditorTabLabel(tab); + name.textContent = 'main.ts'; + const breadcrumbs = appendElement(title, 'breadcrumbs-below-tabs'); + breadcrumbs.style.height = '22px'; + const body = appendElement(group, 'editor-container'); + body.style.height = '240px'; + const targetWindow = getWindow(root); + const geometry = () => [row, tab, fill, label, breadcrumbs, body].map(element => { + const bounds = element.getBoundingClientRect(); + return [bounds.x, bounds.y, bounds.width, bounds.height]; + }); + let baseline: number[][] | undefined; + for (const themeType of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); + root.classList.add(themeType); + const theme = ColorThemeData.createUnloadedTheme(themeType, { + [editorBackground]: '#333333', + focusBorder: '#ffaa00', + contrastBorder: '#888888', + }); + themeStyle.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + baseline ??= geometry(); + for (const active of [true, false]) { + group.classList.toggle('active', active); + const highContrast = themeType.startsWith('hc-'); + const border = highContrast ? (active ? 'rgb(255, 170, 0)' : 'rgb(136, 136, 136)') : 'rgb(51, 51, 51)'; + const frame = targetWindow.getComputedStyle(body, '::after'); + const breadcrumbFrame = targetWindow.getComputedStyle(breadcrumbs, '::after'); + assert.deepStrictEqual({ + geometry: geometry(), + cap: [targetWindow.getComputedStyle(fill).borderTopColor, targetWindow.getComputedStyle(fill).borderLeftColor], + shoulder: targetWindow.getComputedStyle(fill, '::after').borderLeftColor, + separator: targetWindow.getComputedStyle(row, '::after').backgroundColor, + frame: [frame.borderLeftWidth, frame.borderBottomWidth, frame.borderTopWidth, frame.borderLeftColor, frame.pointerEvents], + breadcrumbs: [breadcrumbFrame.borderLeftWidth, breadcrumbFrame.borderBottomWidth, breadcrumbFrame.borderLeftColor], + }, { + geometry: baseline, + cap: [border, border], + shoulder: border, + separator: border, + frame: ['1px', '1px', '0px', highContrast ? border : 'rgba(0, 0, 0, 0)', 'none'], + breadcrumbs: ['1px', '0px', highContrast ? border : 'rgba(0, 0, 0, 0)'], + }, `${themeType}, active group: ${active}`); + } + } + }); + test('paints connected tab strokes outside the fill without moving tab content', () => { const root = document.createElement('div'); root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -1964,7 +2063,7 @@ suite('ModernUIContribution', () => { for (const classes of ['', 'modern-ui', 'modern-ui modern-ui-connected-editor-tabs', 'modern-ui-connected-editor-tabs']) { for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { root.className = `monaco-workbench modern-ui-tabs ${classes} ${theme}`; - const connected = classes === 'modern-ui modern-ui-connected-editor-tabs' && !theme.startsWith('hc-'); + const connected = classes === 'modern-ui modern-ui-connected-editor-tabs'; for (const activeGroup of [true, false]) { group.classList.toggle('active', activeGroup); for (const compact of [true, false]) { @@ -1989,7 +2088,7 @@ suite('ModernUIContribution', () => { }, { tabBounds: tabBounds.toJSON(), labelBounds: labelBounds.toJSON(), - fillExpansion: [0, 0, 0], + fillExpansion: [0, 0, connected ? -5 : 0], topRadius: connected ? '5px' : '4px', }, JSON.stringify({ classes, theme, activeGroup, compact })); } @@ -1998,7 +2097,7 @@ suite('ModernUIContribution', () => { } }); - test('adjusts connected shoulder radii for the outside stroke and omits the first outer shoulder', () => { + test('matches connected cap and shoulder radii for the outside stroke and omits the first outer shoulder', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size40', '4px'); @@ -2033,6 +2132,7 @@ suite('ModernUIContribution', () => { firstTab: firstShoulders, middleTab: { shoulders: getShoulders(middleFill), + capRadii: [targetWindow.getComputedStyle(middleFill).borderTopLeftRadius, targetWindow.getComputedStyle(middleFill).borderTopRightRadius], radii: [leftShoulderStyle.borderBottomRightRadius, rightShoulderStyle.borderBottomLeftRadius], sizes: [ [leftShoulderStyle.width, leftShoulderStyle.height], @@ -2043,13 +2143,14 @@ suite('ModernUIContribution', () => { firstTab: ['none', '""'], middleTab: { shoulders: ['""', '""'], - radii: ['8px', '8px'], - sizes: [['8px', '8px'], ['8px', '8px']], + capRadii: ['5px', '5px'], + radii: ['5px', '5px'], + sizes: [['5px', '5px'], ['5px', '5px']], }, }); }); - test('uses straight connected edges at wrapped row boundaries', () => { + test('keeps the right shoulder curved and the left edge straight at row boundaries', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -2093,8 +2194,8 @@ suite('ModernUIContribution', () => { rowStart: getEdges(rowStartFill, rowStartEdge), }, { rowEnd: { - shoulders: ['""', 'none'], - masks: ['""', 'none'], + shoulders: ['""', '""'], + masks: ['""', '""'], }, rowStart: { shoulders: ['none', '""'], @@ -2138,7 +2239,7 @@ suite('ModernUIContribution', () => { }); }); - test('aligns connected actions to the tab edge and preserves high contrast pill radii', () => { + test('aligns connected actions to the tab edge in every theme', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size20', '2px'); @@ -2191,8 +2292,8 @@ suite('ModernUIContribution', () => { left: { radius: '4px 0px 0px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, }, highContrast: { - right: { radius: '0px 4px 4px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, - left: { radius: '4px 0px 0px 4px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, + right: { radius: '0px 4px 0px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, + left: { radius: '4px 0px 0px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, }, }); }); @@ -2375,6 +2476,83 @@ suite('ModernUIContribution', () => { }); }); + test('uses readable theme foregrounds for connected inactive tabs in either editor group', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const themeStyle = document.createElement('style'); + root.appendChild(themeStyle); + const content = appendElement(appendElement(root, 'part editor'), 'content'); + const labels = [true, false].map(active => { + const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(appendElement(title, 'tabs-and-actions-container'), 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + const fill = appendElement(tab, 'tab-fill'); + return { fill, ...createEditorTabLabel(tab) }; + }); + const targetWindow = getWindow(root); + const palettes = [ + { background: '#202122', inactive: '#8c8c8c', foreground: '#bfbfbf' }, + { background: '#eaeaea', inactive: '#606060', foreground: '#202020' }, + { background: '#2b2b2b', inactive: '#9d9d9d', foreground: '#cccccc' }, + { background: '#e5e5e5', inactive: '#616161', foreground: '#3b3b3b' }, + { background: '#303031', inactive: '#a6a6a6', foreground: '#cccccc' }, + { background: '#e8e8e8', inactive: '#616161', foreground: '#616161' }, + ]; + for (const palette of palettes) { + const theme = ColorThemeData.createUnloadedTheme('vs', { + [foreground]: palette.foreground, + [TAB_INACTIVE_FOREGROUND]: palette.inactive, + 'editorGroupHeader.tabsBackground': palette.background, + }); + themeStyle.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + assert.deepStrictEqual(labels.map(label => { + const background = Color.Format.CSS.parse(targetWindow.getComputedStyle(label.fill).backgroundColor)!; + const text = Color.Format.CSS.parse(targetWindow.getComputedStyle(label.name).color)!; + return { + foreground: text.toString(), + background: background.toString(), + readable: text.blend(background).getContrastRatio(background) >= 4.5, + }; + }), Array(2).fill({ foreground: palette.inactive, background: palette.background, readable: true })); + } + }); + + test('preserves pill and HC label defaults and explicit connected foreground customizations', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const theme = ColorThemeData.createUnloadedTheme('vs-dark', { + [foreground]: '#cccccc', + [TAB_INACTIVE_FOREGROUND]: '#9d9d9d', + }); + const updateTheme = () => style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + updateTheme(); + const probe = appendElement(root, ''); + probe.style.color = 'var(--modern-ui-editor-tab-inactive-foreground)'; + const color = () => getWindow(root).getComputedStyle(probe).color; + const pill = color(); + root.classList.add('modern-ui', 'modern-ui-connected-editor-tabs'); + const connected = color(); + const highContrast = ['hc-black', 'hc-light'].map(themeClass => { + root.classList.add(themeClass); + const result = color(); + root.classList.remove(themeClass); + return result; + }); + theme.setCustomColors({ [TAB_INACTIVE_FOREGROUND]: '#123456', 'tab.unfocusedInactiveForeground': '#654321' }); + updateTheme(); + const custom = color(); + probe.style.color = 'var(--modern-ui-editor-tab-unfocused-inactive-foreground)'; + assert.deepStrictEqual({ connected, highContrast, custom, unfocusedCustom: color() }, { + connected: 'rgb(157, 157, 157)', + highContrast: [pill, pill], + custom: 'rgb(18, 52, 86)', + unfocusedCustom: 'rgb(101, 67, 33)', + }); + }); + test('keeps clean editor tabs compact while reserving persistent indicators', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css index c5663826c7b670..f466022eba202a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -7,8 +7,8 @@ background-color: var(--modern-ui-editor-tab-hover-background); } -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.fixture-hover:not(.selected):not(.active) > .tab-fill { - bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.fixture-hover:not(.active):not(.dirty):not(.sticky):not(:focus-within) > .tab-actions .action-label { + opacity: 1; } .modern-ui-tabs .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab.fixture-hover:not(.selected):not(.active) .tab-label:not(.monaco-decoration-itemColor) a, 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 d89ed82e0107c6..1fb11b886e2238 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -792,7 +792,7 @@ function createThemeColorFixtures() { }; } -function renderConnectedSurface(activeTabIndex = 1, forcedHoverTab?: number, focusedTabAction?: number): (ctx: ComponentFixtureContext) => void { +function renderConnectedSurface(activeTabIndex = 1, forcedHoverTab?: number, focusedTabAction?: number, options: Omit = {}): (ctx: ComponentFixtureContext) => void { return render(true, { editors: [ { resource: file('/project/README.md'), pinned: true, active: activeTabIndex >= 0 }, @@ -812,9 +812,20 @@ function renderConnectedSurface(activeTabIndex = 1, forcedHoverTab?: number, foc ].join('\n'), forcedHoverTab, focusedTabAction, + ...options, }); } +function renderWrappedConnectedSurface(activeTabIndex: number, forcedHoverTab?: number, tabHeight: IEditorPartOptions['tabHeight'] = 'default'): (ctx: ComponentFixtureContext) => void { + return renderConnectedSurface(activeTabIndex, forcedHoverTab, undefined, { + width: 820, + editors: manyEditorSpecs().slice(0, 10).map((spec, index) => ({ ...spec, active: index <= activeTabIndex })), + partOptions: { wrapTabs: true, tabHeight, editorActionsLocation: 'hidden' }, + }); +} + +const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['dark2026', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; + export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FileIconThemes: defineThemedFixtureGroup({ Seti: defineComponentFixture({ fileIconTheme: 'vs-seti', render: render(false, {}) }), @@ -834,15 +845,89 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), ConnectedSurface: defineThemedFixtureGroup({ + MinimumIdentity: defineComponentFixture({ + render: renderConnectedSurface(3, undefined, undefined, { + width: 380, + partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 30, tabSizingFixedMaxWidth: 30, editorActionsLocation: 'hidden' }, + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['Narrow tabs drop their file icon slot before truncating the basename with a real ellipsis. Each extension stays readable; package.json retains its M badge and dirty action. Clean inactive actions stay hidden, while the active close or dirty action remains visible.'], + }), + MinimumIdentityHovered: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, + render: renderConnectedSurface(3, 0, undefined, { + width: 380, + partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 30, tabSizingFixedMaxWidth: 30, editorActionsLocation: 'hidden' }, + }), + expectedVisualDescriptions: ['Hover reveals the inactive close without covering its ellipsized basename or extension.'], + }), + NarrowWindow: defineComponentFixture({ + render: renderConnectedSurface(0, undefined, undefined, { + width: 420, + editors: manyEditorSpecs().slice(0, 10), + partOptions: { tabSizing: 'shrink', editorActionsLocation: 'hidden' }, + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['A narrow editor with ten open tabs collapses file icon slots to give filenames more room. Basenames ellipsize while extensions and active close or dirty indicators remain visible. Tabs scroll rather than shrink below their readable minimum. This is a deliberate width-pressure scenario, not the default sizing.'], + }), + UpperWrappedPills: defineComponentFixture({ + render: renderWrappedConnectedSurface(0), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active tab in the upper wrapped row is a rounded pill with no shoulders, using the same background as the document well, including behind its close action. Only tabs in the bottom row can connect to the document.'], + }), + BottomWrappedConnected: defineComponentFixture({ + render: renderWrappedConnectedSurface(9), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The selected tab in the bottom wrapped row connects directly to the document well with curved shoulders and no bottom gap, just like a single row. The adjacent inactive tab also reaches the well boundary. Upper-row tabs retain separate rounded pills.'], + }), + UpperWrappedHover: defineComponentFixture({ + render: renderWrappedConnectedSurface(9, 1), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The hovered inactive tab in the upper wrapped row stays a control-tier pill within its row, without extending into or behind the selected tab below. Its close button is visible. The bottom selected tab remains connected to the document well without a gap.'], + }), + UpperWrappedHoverCompact: defineComponentFixture({ + render: renderWrappedConnectedSurface(9, 1, 'compact'), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['At compact tab height, the hovered inactive upper-row pill and its close button stay clear of the selected tab below. The bottom selected tab still joins the document well without a gap.'], + }), + UpperPinnedPills: defineComponentFixture({ + render: renderConnectedSurface(0, undefined, undefined, { + partOptions: { pinnedTabsOnSeparateRow: true }, + editors: [ + { resource: file('/project/README.md'), sticky: true, pinned: true, active: true }, + { resource: file('/project/src/main.ts'), pinned: true }, + { resource: file('/project/package.json'), pinned: true, dirty: true }, + ], + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active pinned tab in the separate upper strip remains a rounded pill with the document well background, not a disconnected tab-shaped well.'], + }), + InactiveGroup: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { active: false }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The selected tab and document well share a neutral contrast border in an inactive HC editor group, distinct from the focus-colored border of the active group. Standard themes have no prominent well border.'], + }), + Breadcrumbs: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { breadcrumbs: {} }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The connected outline continues down both sides of the breadcrumbs and around the document body, without a line beneath the selected tab.'], + }), + MultiSelect: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { editors: multiSelectEditorSpecs() }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active tab remains connected to its document well during multi-selection. Other selected tabs retain their explicit HC selection outlines.'], + }), WrappedRowStart: defineComponentFixture({ render: render(true, { partOptions: { wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }, editors: wrappedRowStartEditorSpecs(), width: 260, }), - expectedVisualDescriptions: ['The active tab at the start of a wrapped row uses a straight left edge while its right shoulder flows into the row separator. The preceding row ends with an uninterrupted straight boundary.'], + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active tab at the start of an upper wrapped row is a rounded pill. Upper rows stay separate from the document well; the strip separator sits beneath the final row only.'], }), StickyViewport: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, render: render(true, { partOptions: { pinnedTabSizing: 'compact', editorActionsLocation: 'hidden' }, editors: stickyEditorSpecs(), @@ -853,60 +938,71 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { }), ClippedLeft: defineComponentFixture({ render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight left edge. Its top stroke, left edge and strip separator remain continuous without exposing clipped tab content.'], }), ClippedRight: defineComponentFixture({ render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right' }), - expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight right edge before the editor actions. Its top stroke, right edge and strip separator remain continuous without exposing clipped tab content.'], + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['This deliberate manual-scroll stress case cuts through the selected tab, leaving its action offscreen. The stationary cap and bottom-right shoulder form one continuous outline into the document boundary without a stepped stroke or leaking clipped content.'], }), RightViewportEdge: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, + render: render(true, { editors: manyEditorSpecs(5), width: 248 }), + expectedVisualDescriptions: ['The fully visible active tab preserves its filename and dirty/close action before the editor toolbar. Its inset cap joins a complete curved bottom-right shoulder on the document boundary, including in both high-contrast themes.'], + }), + RightShoulderAtViewport: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right-shoulder' }), - expectedVisualDescriptions: ['The rightmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped beneath the editor actions.'], + expectedVisualDescriptions: ['A manual scroll ending at the selected tab edge leaves no room for the outside shoulder. The stationary cap and shoulder turn inward with one continuous outline.'], }), LeftViewportEdge: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'left-shoulder' }), expectedVisualDescriptions: ['The leftmost visible active tab uses a continuous straight edge when there is no room for its full shoulder. No part of the shoulder is clipped at the viewport boundary.'], }), Stroke: defineComponentFixture({ render: renderConnectedSurface(), - additionalThemes: ['darkHighContrast'], + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: [ - 'The active main.ts tab uses the editor background for its fill, outside stroke, curved shoulders and strip separator so the tab and editor body read as one continuous document well. Inactive tabs sit on the editor-group header strip. High contrast retains explicit focus and selection borders.', + 'The active main.ts tab and editor body read as one continuous document well. In high contrast a single focus-colored outline follows the selected tab, its shoulders, and the entire document body; standard themes retain the subtle editor-colored boundary.', 'The editor surface color remains uniform through the cap, shoulders and separator. There are no gaps, vertical protrusions, darker seams or brighter overlaps at the tangent joins.', - 'In standard themes, the concave shoulder radii are reduced by the outward stroke offset while the convex cap radii grow. The lower gutter reserves an extra pixel for the separator so the visible gap matches the upper gutter.', + 'The same stroke geometry is reserved in every theme. Tabs span the strip height with no upper or lower gutters.', ], }), HoveredTab: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, render: renderConnectedSurface(1, 0), expectedVisualDescriptions: [ - 'The hovered README.md pill sits one stroke closer to the strip separator than an ordinary pill, balancing its top and bottom whitespace.', + 'The hovered README.md tab spans the strip height and meets the connected shoulder without an upper or lower gutter.', ], }), FocusedCloseAction: defineComponentFixture({ render: renderConnectedSurface(1, undefined, 1), - additionalThemes: ['darkHighContrast'], + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: [ - 'The focused close action keeps its full interaction target while sitting close to the active tab edge. In high contrast, the action follows the complete pill corner instead of squaring its lower outside corner.', + 'The focused close action keeps its full interaction target and visible keyboard focus indicator inside the connected outline in every theme.', ], }), FirstTabActive: defineComponentFixture({ render: renderConnectedSurface(0), - additionalThemes: ['darkHighContrast'], + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: [ - 'The first active tab has a straight left edge meeting the strip separator, with no clipped outer shoulder. The right shoulder still curves into the separator. High contrast retains explicit selection borders.', + 'The first active tab left edge continues directly down the document well, without an outer shoulder. The right shoulder joins the well top border. In HC the entire boundary uses the active group accent.', ], }), FirstTabActiveAdjacentHover: defineComponentFixture({ render: renderConnectedSurface(0, 1), + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: [ 'The first active tab is flush with the editor body left edge and the tab strip top edge, and overlaps the body by one stroke so no seam appears below the cap or shoulder. The adjacent hovered tab begins immediately at the active tab boundary and spans the full strip height without an inter-tab, upper, or lower gutter, while the active tab shoulder remains visible over the shared boundary.', ], }), LastTabActive: defineComponentFixture({ render: renderConnectedSurface(3), - additionalThemes: ['darkHighContrast'], + additionalThemes: connectedSurfaceThemes, expectedVisualDescriptions: [ - 'The last active tab has a straight right edge meeting the strip separator, with no outer shoulder. The left shoulder still curves into the separator. High contrast retains explicit selection borders.', + 'The last active tab keeps a curved right shoulder meeting the strip separator. Both shoulders use the same stroke-adjusted radius as the top cap. In HC the accent continues around the document well.', ], }), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 1e87ac505028d5..c4040612e2ef17 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -266,6 +266,7 @@ class NullStorageService implements IStorageService { // comments and trailing commas the way it does in the real product. /* eslint-disable local/code-import-patterns */ import dark_2026 from '../../../../../../extensions/theme-defaults/themes/2026-dark.json' with { type: 'json' }; +import light_2026 from '../../../../../../extensions/theme-defaults/themes/2026-light.json' with { type: 'json' }; import dark_modern from '../../../../../../extensions/theme-defaults/themes/dark_modern.json' with { type: 'json' }; import dark_plus from '../../../../../../extensions/theme-defaults/themes/dark_plus.json' with { type: 'json' }; import dark_vs from '../../../../../../extensions/theme-defaults/themes/dark_vs.json' with { type: 'json' }; @@ -275,6 +276,11 @@ import light_2026 from '../../../../../../extensions/theme-defaults/themes/2026- import light_modern from '../../../../../../extensions/theme-defaults/themes/light_modern.json' with { type: 'json' }; import light_plus from '../../../../../../extensions/theme-defaults/themes/light_plus.json' with { type: 'json' }; import light_vs from '../../../../../../extensions/theme-defaults/themes/light_vs.json' with { type: 'json' }; +import abyss from '../../../../../../extensions/theme-abyss/themes/abyss-color-theme.json' with { type: 'json' }; +import monokai from '../../../../../../extensions/theme-monokai/themes/monokai-color-theme.json' with { type: 'json' }; +import quietlight from '../../../../../../extensions/theme-quietlight/themes/quietlight-color-theme.json' with { type: 'json' }; +import solarized_dark from '../../../../../../extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json' with { type: 'json' }; +import solarized_light from '../../../../../../extensions/theme-solarized-light/themes/solarized-light-color-theme.json' with { type: 'json' }; /* eslint-enable local/code-import-patterns */ function toThemeJsonText(theme: string | object): string { @@ -301,6 +307,11 @@ const themeJsonModules: Record = { '/extensions/theme-defaults/themes/light_modern.json': toThemeJsonText(light_modern), '/extensions/theme-defaults/themes/light_plus.json': toThemeJsonText(light_plus), '/extensions/theme-defaults/themes/light_vs.json': toThemeJsonText(light_vs), + '/extensions/theme-abyss/themes/abyss-color-theme.json': toThemeJsonText(abyss), + '/extensions/theme-monokai/themes/monokai-color-theme.json': toThemeJsonText(monokai), + '/extensions/theme-quietlight/themes/quietlight-color-theme.json': toThemeJsonText(quietlight), + '/extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json': toThemeJsonText(solarized_dark), + '/extensions/theme-solarized-light/themes/solarized-light-color-theme.json': toThemeJsonText(solarized_light), }; const fixtureExtensionResourceLoaderService = new class implements IExtensionResourceLoaderService { @@ -327,12 +338,12 @@ const fixtureExtensionResourceLoaderService = new class implements IExtensionRes getExtensionGalleryResourceURL(): Promise { return Promise.resolve(undefined); } }; -function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector): ColorThemeData { +function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector, extensionName = 'theme-defaults'): ColorThemeData { const location = URI.parse(`file://${themePath}`); return ColorThemeData.fromExtensionTheme( { id: themePath, path: themePath, uiTheme, _watch: false }, location, - ExtensionData.fromName('vscode', 'theme-defaults', true) + ExtensionData.fromName('vscode', extensionName, true) ); } @@ -366,9 +377,19 @@ type ComponentFixtureThemeVariant = { const darkThemeVariant = { label: 'Dark', background: 'dark', theme: darkTheme } as const satisfies ComponentFixtureThemeVariant; const lightThemeVariant = { label: 'Light', background: 'light', theme: lightTheme } as const satisfies ComponentFixtureThemeVariant; const additionalThemeVariants = { + dark2026: { label: 'Dark2026', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-dark.json', ThemeTypeSelector.VS_DARK) }, light2026: { label: 'Light2026', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-light.json', ThemeTypeSelector.VS) }, + darkPlus: { label: 'DarkPlus', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_plus.json', ThemeTypeSelector.VS_DARK) }, + lightPlus: { label: 'LightPlus', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/light_plus.json', ThemeTypeSelector.VS) }, + visualStudioDark: { label: 'VisualStudioDark', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_vs.json', ThemeTypeSelector.VS_DARK) }, + visualStudioLight: { label: 'VisualStudioLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/light_vs.json', ThemeTypeSelector.VS) }, darkHighContrast: { label: 'DarkHighContrast', background: 'dark', theme: darkHighContrastTheme }, lightHighContrast: { label: 'LightHighContrast', background: 'light', theme: lightHighContrastTheme }, + abyss: { label: 'Abyss', background: 'dark', theme: createBuiltInTheme('/extensions/theme-abyss/themes/abyss-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-abyss') }, + monokai: { label: 'Monokai', background: 'dark', theme: createBuiltInTheme('/extensions/theme-monokai/themes/monokai-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-monokai') }, + quietLight: { label: 'QuietLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-quietlight/themes/quietlight-color-theme.json', ThemeTypeSelector.VS, 'theme-quietlight') }, + solarizedDark: { label: 'SolarizedDark', background: 'dark', theme: createBuiltInTheme('/extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-solarized-dark') }, + solarizedLight: { label: 'SolarizedLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-solarized-light/themes/solarized-light-color-theme.json', ThemeTypeSelector.VS, 'theme-solarized-light') }, } as const satisfies Record; export type ComponentFixtureAdditionalTheme = keyof typeof additionalThemeVariants; diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 3b1c593333e742..443e1bdfc65810 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -51,7 +51,9 @@ suite('MultiEditorTabsControl', () => { model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); for (let i = 0; i < 2; i++) { - const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + const editor = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return `file${i}.txt`; } + }(URI.file(`/path/file${i}.txt`), 'testEditorInput')); model.openEditor(editor, { pinned: true, active: i === 0 }); } @@ -128,6 +130,311 @@ suite('MultiEditorTabsControl', () => { mainWindow.dispatchEvent(new KeyboardEvent(pressed ? EventType.KEY_DOWN : EventType.KEY_UP, { key: 'Alt', altKey: pressed })); } + function connectedGroup(): HTMLElement { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size160: 16px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-fontSize-body1: 13px; --vscode-fontWeight-regular: 400;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + return group; + } + + async function layoutConnectedGroup(group: HTMLElement, width: number): Promise { + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 300) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + } + + test('connected minimum width preserves basename ellipsis extension badge and action', async () => { + const group = connectedGroup(); + const badgeStyle = document.createElement('style'); + badgeStyle.textContent = '.connected-tabs-labels .monaco-decoration-badge::after { content: "M"; width: 10px; margin: 0 5px; }'; + group.appendChild(badgeStyle); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, editorActionsLocation: 'hidden', hasIcons: true, showTabIndex: true }; + control.updateOptions(oldOptions, partOptions); + const tab = container.querySelector('.tab')!; + const label = tab.querySelector('.tab-label')!; + label.classList.add('monaco-decoration-badge'); + await layoutConnectedGroup(group, 200); + // Resource labels are redrawn on entry to the connected mode. + label.classList.add('monaco-decoration-badge'); + await layoutConnectedGroup(group, 200); + const name = tab.querySelector('.label-name')!; + const nameContainer = name.parentElement!; + const suffix = tab.querySelector('.label-suffix')!; + const action = tab.querySelector('.tab-actions')!; + const context = document.createElement('canvas').getContext('2d')!; + context.font = mainWindow.getComputedStyle(name).font; + assert.deepStrictEqual({ + minimum: tab.offsetWidth >= Math.ceil(context.measureText('1: f….txt').width) + 20 + 34, + narrow: tab.classList.contains('connected-tab-narrow'), + iconHidden: mainWindow.getComputedStyle(label, '::before').display, + basename: name.textContent, + extension: suffix.textContent, + ellipsis: mainWindow.getComputedStyle(nameContainer).textOverflow, + basenameVisible: nameContainer.clientWidth >= context.measureText('1: f…').width, + extensionBeforeAction: suffix.getBoundingClientRect().right <= action.getBoundingClientRect().left, + fullAriaLabel: tab.getAttribute('aria-label')?.includes('file0.txt'), + }, { + minimum: true, narrow: true, iconHidden: 'none', basename: '1: file0', extension: '.txt', + ellipsis: 'ellipsis', basenameVisible: true, extensionBeforeAction: true, fullAriaLabel: true, + }); + name.style.fontSize = '13px'; + nameContainer.style.fontSize = '20px'; + await layoutConnectedGroup(group, 200); + context.font = mainWindow.getComputedStyle(name).font; + const firstCharacterWidth = context.measureText('1: f').width; + context.font = mainWindow.getComputedStyle(nameContainer).font; + assert.ok(nameContainer.clientWidth >= firstCharacterWidth + context.measureText('…').width, JSON.stringify({ + message: 'reserve the ellipsis using its container font', + actual: nameContainer.clientWidth, + required: firstCharacterWidth + context.measureText('…').width, + minimum: tab.style.getPropertyValue('--connected-tab-min-width'), + tabWidth: tab.offsetWidth, + })); + group.closest('.monaco-workbench')!.classList.remove('modern-ui-connected-editor-tabs'); + await layoutConnectedGroup(group, 200); + assert.deepStrictEqual([name.textContent, suffix.textContent, tab.style.getPropertyValue('--connected-tab-min-width')], ['1: file0.txt', '', '']); + }); + + test('connected shrink tabs collapse and restore icons as the editor width changes', async () => { + const group = connectedGroup(); + const iconStyle = document.createElement('style'); + iconStyle.textContent = '.connected-tabs-labels .tab-label::before { content: ""; }'; + group.appendChild(iconStyle); + for (let i = 2; i < 10; i++) { + const editor = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return `file${i}.txt`; } + }(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: false }); + } + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'shrink', hasIcons: true, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const states = []; + for (const width of [1200, 420, 1200, 420]) { + await layoutConnectedGroup(group, width); + const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')); + states.push({ + collapsedIcons: tabs.filter(tab => tab.classList.contains('connected-tab-narrow')).length, + trimmedNames: tabs.filter(tab => { + const name = tab.querySelector('.monaco-icon-name-container')!; + return name.scrollWidth > name.clientWidth; + }).length, + preservedExtensions: tabs.every(tab => { + const suffix = tab.querySelector('.label-suffix')!; + const action = tab.querySelector('.tab-actions')!; + return suffix.textContent === '.txt' && suffix.getBoundingClientRect().right <= action.getBoundingClientRect().left; + }), + }); + } + assert.deepStrictEqual(states, [ + { collapsedIcons: 0, trimmedNames: 0, preservedExtensions: true }, + { collapsedIcons: 10, trimmedNames: 10, preservedExtensions: true }, + { collapsedIcons: 0, trimmedNames: 0, preservedExtensions: true }, + { collapsedIcons: 10, trimmedNames: 10, preservedExtensions: true }, + ]); + }); + + test('connected actions keep active and dirty visible but inactive clean quiet', async () => { + const group = connectedGroup(); + container.classList.add('tab-actions-reserve-space'); + await layoutConnectedGroup(group, 400); + const tabs = Array.from(container.querySelectorAll('.tab')); + const opacity = () => tabs.map(tab => mainWindow.getComputedStyle(tab.querySelector('.action-label')!).opacity); + const clean = opacity(); + tabs[1].classList.add('dirty'); + const dirty = opacity(); + tabs[1].classList.remove('dirty'); + const action = tabs[1].querySelector('.action-label')!; + action.tabIndex = 0; + action.focus(); + const focused = opacity(); + action.blur(); + assert.deepStrictEqual({ clean, dirty, focused }, { clean: ['1', '0'], dirty: ['1', '1'], focused: ['1', '1'] }); + }); + + test('reveals the active tab with its right shoulder outside the label and action', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160.25, tabSizingFixedMaxWidth: 160.25, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true, active: false }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 240); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + control.layout({ container: new Dimension(240, 33), available: new Dimension(240, 300) }, { forceRevealActiveTab: true }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + const tab = container.querySelectorAll('.tabs-container > .tab')[1]; + const fill = tab.querySelector('.tab-fill')!; + const action = tab.querySelector('.tab-actions')!; + const viewport = container.querySelector('.monaco-scrollable-element')!; + const shoulderWidth = Number.parseFloat(mainWindow.getComputedStyle(fill, '::after').width); + assert.deepStrictEqual({ + labelBeforeAction: tab.querySelector('.monaco-icon-label-container')!.getBoundingClientRect().right <= action.getBoundingClientRect().left, + shoulderVisible: fill.getBoundingClientRect().right + shoulderWidth <= viewport.getBoundingClientRect().right, + clipped: tab.classList.contains('connected-tab-right-edge'), + }, { + labelBeforeAction: true, + shoulderVisible: true, + clipped: false, + }); + }); + + test('connected sticky offsets respect content minimums instead of the classic fixed width', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, pinnedTabSizing: 'shrink', editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const longExtension = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return 'example.dockerignore'; } + }(URI.file('/path/example.dockerignore'), 'testEditorInput')); + model.openEditor(longExtension, { index: 0, pinned: true, sticky: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + for (const editor of model.getEditors(EditorsOrder.SEQUENTIAL)) { + model.stick(editor); + control.stickEditor(editor); + } + await layoutConnectedGroup(group, 400); + const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')); + assert.deepStrictEqual({ + contentMinimum: tabs[0].offsetWidth > 80, + offsets: tabs.map(tab => tab.style.left), + }, { contentMinimum: true, offsets: ['0px', `${tabs[0].offsetWidth}px`, `${tabs[0].offsetWidth + tabs[1].offsetWidth}px`] }); + }); + + test('only the bottom wrapped row joins the document and upper row resets when unwrapped', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + await layoutConnectedGroup(group, 150); + const tabs = Array.from(container.querySelectorAll('.tab')); + const wrapped = tabs.map(tab => tab.classList.contains('connected-tab-upper-row')); + const fill = tabs[0].querySelector('.tab-fill')!; + const upper = { inset: mainWindow.getComputedStyle(fill).top, shoulder: mainWindow.getComputedStyle(fill, '::after').content }; + await layoutConnectedGroup(group, 400); + const unwrapped = tabs.map(tab => tab.classList.contains('connected-tab-upper-row')); + assert.deepStrictEqual({ wrapped, upper, unwrapped }, { wrapped: [true, false], upper: { inset: '-2px', shoulder: 'none' }, unwrapped: [false, false] }); + }); + + test('selected wrapped tabs and focused actions use the document surface on every row', async () => { + const group = connectedGroup(); + const root = group.closest('.monaco-workbench')!; + group.style.setProperty('--modern-ui-connected-tab-surface', '#123456'); + group.style.setProperty('--vscode-editorGroupHeader-tabsBackground', '#654321'); + group.style.setProperty('--modern-ui-editor-tab-active-background', '#654321'); + group.style.setProperty('--vscode-modernEditorTab-activeActionBackground', '#654321'); + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const measurements = []; + const expected = []; + for (const activeIndex of [0, 1]) { + model.openEditor(model.getEditorByIndex(activeIndex)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 150); + const tab = container.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const actions = tab.querySelector('.tab-actions')!; + const action = actions.querySelector('.action-label')!; + action.tabIndex = 0; + for (const theme of ['vs', 'vs-dark', 'hc-black', 'hc-light']) { + root.classList.add(theme); + for (const activeGroup of [true, false]) { + group.classList.toggle('active', activeGroup); + action.focus(); + measurements.push({ + activeIndex, theme, activeGroup, + actionFocused: mainWindow.document.activeElement === action, + upperRow: tab.classList.contains('connected-tab-upper-row'), + fill: mainWindow.getComputedStyle(fill).backgroundColor, + actions: mainWindow.getComputedStyle(actions).backgroundColor, + }); + expected.push({ activeIndex, theme, activeGroup, actionFocused: true, upperRow: activeIndex === 0, fill: 'rgb(18, 52, 86)', actions: 'rgb(18, 52, 86)' }); + } + root.classList.remove(theme); + } + action.blur(); + } + assert.deepStrictEqual(measurements, expected); + }); + + test('wrapped fills have equal visible heights and the bottom tab reaches the document', async () => { + const group = connectedGroup(); + group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const measurements = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabHeight, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + container.classList.toggle('compact-height', tabHeight === 'compact'); + await layoutConnectedGroup(group, 150); + const strip = container.querySelector('.tabs-and-actions-container')!; + const tab = strip.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const fillStyle = mainWindow.getComputedStyle(fill); + const clippingBottom = Math.min(...Array.from(strip.querySelectorAll('.tabs-container, .monaco-scrollable-element'), element => element.getBoundingClientRect().bottom)); + const fills = Array.from(strip.querySelectorAll('.tab-fill'), element => element.getBoundingClientRect()); + measurements.push({ + tabHeight, + stripHeight: strip.getBoundingClientRect().height, + wrapping: strip.classList.contains('wrapping'), + upperRow: tab.classList.contains('connected-tab-upper-row'), + gap: strip.getBoundingClientRect().bottom - (fill.getBoundingClientRect().bottom - parseFloat(fillStyle.borderBottomWidth)), + clippingGap: strip.getBoundingClientRect().bottom - clippingBottom, + bottomRadius: fillStyle.borderBottomRightRadius, + shoulder: mainWindow.getComputedStyle(fill, '::after').content, + visibleHeights: fills.map(rect => Math.min(rect.bottom, clippingBottom) - rect.top), + rowGap: fills[1].top - fills[0].bottom, + }); + } + assert.deepStrictEqual(measurements, [ + { tabHeight: 'default', stripHeight: 60, wrapping: true, upperRow: false, gap: 0, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [28, 28], rowGap: 2 }, + { tabHeight: 'compact', stripHeight: 52, wrapping: true, upperRow: false, gap: 0, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [24, 24], rowGap: 2 }, + ]); + }); + + test('three wrapped rows retain equal tab heights without a fixed strip height', async () => { + const group = connectedGroup(); + group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + const editor = disposables.add(new TestFileEditorInput(URI.file('/path/third.ts'), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const measurements = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabHeight, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + container.classList.toggle('compact-height', tabHeight === 'compact'); + await layoutConnectedGroup(group, 150); + const strip = container.querySelector('.tabs-and-actions-container')!.getBoundingClientRect(); + const fills = Array.from(container.querySelectorAll('.tab-fill'), fill => fill.getBoundingClientRect()); + measurements.push({ + tabHeight, + stripHeight: strip.height, + visibleHeights: fills.map(fill => Math.min(fill.bottom, strip.bottom) - fill.top), + rowGaps: fills.slice(1).map((fill, index) => fill.top - fills[index].bottom), + }); + } + assert.deepStrictEqual(measurements, [ + { tabHeight: 'default', stripHeight: 90, visibleHeights: [28, 28, 28], rowGaps: [2, 2] }, + { tabHeight: 'compact', stripHeight: 78, visibleHeights: [24, 24, 24], rowGaps: [2, 2] }, + ]); + }); + test('connected tabs reserve separator height without changing classic or shared modern tabs', async () => { const readHeight = async () => { control.layout({ container: Dimension.None, available: Dimension.None }); @@ -240,6 +547,8 @@ suite('MultiEditorTabsControl', () => { }; model.setSelection(model.activeEditor!, [model.getEditorByIndex(1)!]); control.updateEditorSelections(); + await layout(240); + scroll(40); const multiSelected = { clipping: overflowEdge.style.left, edge: mainWindow.getComputedStyle(overflowEdge).display, @@ -353,18 +662,43 @@ suite('MultiEditorTabsControl', () => { edge: mainWindow.getComputedStyle(overflowEdge).display, connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), }; + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const highContrastRight = []; + root.style.setProperty('--vscode-focusBorder', '#f38518'); + for (const theme of ['hc-black', 'hc-light']) { + root.classList.remove('hc-black', 'hc-light'); + root.classList.add(theme); + root.style.setProperty('--vscode-editor-background', theme === 'hc-black' ? '#000000' : '#ffffff'); + for (const width of [240, 324]) { + await layout(width); + scroll(0); + const outline = overflowEdge.querySelector('.tab-connected-overflow-right')!; + const cap = mainWindow.getComputedStyle(outline, '::before'); + const shoulder = mainWindow.getComputedStyle(outline, '::after'); + const capRight = outline.getBoundingClientRect().right - parseFloat(cap.right); + const shoulderLeft = outline.getBoundingClientRect().right - parseFloat(shoulder.right) - parseFloat(shoulder.width); + highContrastRight.push({ + theme, width, + mask: mainWindow.getComputedStyle(overflowEdge, '::after').backgroundColor, + capMeetsShoulder: parseFloat(cap.bottom) === parseFloat(shoulder.height), + strokesAlign: capRight - parseFloat(cap.borderRightWidth) === shoulderLeft, + baselineAligns: outline.getBoundingClientRect().bottom === tabs.getBoundingClientRect().bottom, + }); + } + } root.classList.remove('modern-ui-connected-editor-tabs'); await layout(100); assert.deepStrictEqual({ - clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, leftShoulderAtViewport, leftShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, + clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, leftShoulderAtViewport, leftShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, highContrastRight, reset: overflowEdge.style.left, }, { clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'] }, - multiSelected: { clipping: '', edge: 'none', radius: '4px', connectedClass: false }, + multiSelected: { clipping: '0px', edge: 'block', radius: '5px 5px 0px 0px', connectedClass: true }, singleSelected: { clipping: '0px', connectedClass: true }, - terminalOutline: { right: '1px', rightShoulder: 'none', rightMask: 'none' }, - normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '6px', leftMaskTop: '0px', rightMaskHeight: '6px', rightMaskTop: '0px' }, - rightShoulderAtViewport: { edge: true, clipped: false, right: '1px', rightShoulder: 'none', rightMask: 'none', overflowEdge: 'none' }, + terminalOutline: { right: '1px', rightShoulder: '""', rightMask: '""' }, + normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '3px', leftMaskTop: '0px', rightMaskHeight: '3px', rightMaskTop: '0px' }, + rightShoulderAtViewport: { edge: true, clipped: false, right: '1px', rightShoulder: '""', rightMask: '""', overflowEdge: 'block' }, rightShoulderRevealed: { edge: false, clipped: false, rightShoulder: '""', rightMask: '""' }, leftShoulderAtViewport: { edge: true, clipped: false, left: '1px', leftShoulder: 'none', leftMask: 'none', overflowEdge: 'none' }, leftShoulderRevealed: { edge: false, clipped: false, leftShoulder: '""', leftMask: '""' }, @@ -379,11 +713,17 @@ suite('MultiEditorTabsControl', () => { { right: '0px', inset: 0 }, { right: '0px', inset: 0 }, ], - edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'], + edgeOverlay: ['""', 'block', '8', '10px', '0px', 'border-box', '0px', '0px', 'rgb(255, 255, 255)'], previousTabOverflow: '', }, hiddenAtFillEdge: ['none', 'none'], - highContrast: { clipping: '', edge: 'none', connectedClass: false }, + highContrast: { clipping: '0px', edge: 'block', connectedClass: true }, + highContrastRight: [ + { theme: 'hc-black', width: 240, mask: 'rgb(0, 0, 0)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-black', width: 324, mask: 'rgb(0, 0, 0)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-light', width: 240, mask: 'rgb(255, 255, 255)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-light', width: 324, mask: 'rgb(255, 255, 255)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + ], reset: '', }); }); From 5eec3505fd128de23889b236ef90e4f74b10dc81 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 18:18:03 -0700 Subject: [PATCH 13/26] fix: preserve connected tab selection and HC editor frames Use native scroll coordinates after tab removal clamps the viewport. Suppress the HC editor card only when the effective editor part options show multiple tabs, preserving single and hidden-tab frames. Refs #331104 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/parts/editor/editorPart.ts | 3 + .../parts/editor/multiEditorTabsControl.ts | 3 +- src/vs/workbench/contrib/modernUI/README.md | 2 +- .../browser/media/connectedEditorTabs.css | 2 +- .../browser/modernUI.contribution.test.ts | 37 ++++++++----- .../editor/editorTabBar.fixture.ts | 1 + .../test/browser/parts/editor/editor.test.ts | 22 ++++++++ .../editor/multiEditorTabsControl.test.ts | 55 +++++++++++++++++++ 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index a9086636fb1890..a74bb28980c6e9 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1061,6 +1061,9 @@ export class EditorPart extends Part implements IEditorPart, // Container this.element = parent; + const updateEditorTabsClass = () => parent.classList.toggle('editor-tabs-multiple', this.partOptions.showTabs === 'multiple'); + updateEditorTabsClass(); + this._register(this.onDidChangeEditorPartOptions(updateEditorTabsClass)); if (this.windowId !== mainWindow.vscodeWindowId) { this.container.classList.add('auxiliary'); } diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 456f3d9f150878..886551910a3979 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2358,7 +2358,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { const activeTabFill = activeTab?.firstElementChild; const overflowEdge = this.connectedTabOverflowEdge; if (activeTab && !activeTab.classList.contains('connected-tab-upper-row') && isHTMLElement(activeTabFill) && overflowEdge && !activeTabPositionStatic && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')) { - const scrollLeft = tabsScrollbar.getScrollPosition().scrollLeft; + // DOM bounds reflect native scroll clamping before the custom scrollbar dimensions update. + const scrollLeft = tabsContainer.scrollLeft; const tabsBounds = tabsContainer.getBoundingClientRect(); const fillBounds = activeTabFill.getBoundingClientRect(); const scrollableBounds = tabsScrollbar.getDomNode().getBoundingClientRect(); diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index e622f4ee9e0cc5..3d0e0b5dd5c5f5 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -28,7 +28,7 @@ The default palettes give `editorGroupHeader.tabsBackground` a restrained neutra Connected tabs use `tab.inactiveForeground` rather than dimming the general foreground to 50% opacity. The default palettes pair these fills with readable inactive text (at least 4.5:1), including in inactive editor groups. Light Modern and the classic light themes use the existing neutral `#616161`; the classic dark themes use `#A6A6A6`. Explicit legacy foreground customizations still take precedence. HC styling and the original pill label defaults are unchanged. -In high contrast, the connected boundary uses `focusBorder` for the active editor group and `contrastBorder` for other groups. It follows the selected tab and continues around the breadcrumbs and document body, rather than outlining only the tab. The surrounding editor-area card stroke is transparent in HC, including compact-layout corner strokes, so it does not create a second frame. Keyboard focus and multi-selection indicators remain visible. The same one-stroke geometry is present in all themes: the body frame is transparent in standard themes, and its inside paint layer does not change editor dimensions when switching themes or active groups. +In high contrast, the connected boundary uses `focusBorder` for the active editor group and `contrastBorder` for other groups. It follows the selected tab and continues around the breadcrumbs and document body, rather than outlining only the tab. With multiple tabs, the surrounding editor-area card stroke is transparent in HC, including compact-layout corner strokes, so it does not create a second frame. Single and hidden-tab modes retain their original editor-card outline. Keyboard focus and multi-selection indicators remain visible. The same one-stroke geometry is present in all themes: the body frame is transparent in standard themes, and its inside paint layer does not change editor dimensions when switching themes or active groups. ## Colors diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 96d1d4d04f60ca..970093402c16d1 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -5,7 +5,7 @@ /* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ /* In HC the connected well owns the frame; retain the outer card's layout space, not its stroke. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor:not(.modal-editor-part) { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor.editor-tabs-multiple:not(.modal-editor-part) { border-color: transparent !important; --modern-ui-floating-card-stroke-color: transparent; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 9815e4545d921c..376aa7cb0dd586 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1936,14 +1936,17 @@ suite('ModernUIContribution', () => { ]); }); - test('uses only the connected well frame in HC without changing the outer card geometry', () => { + test('preserves a single HC frame in every tab mode without changing the outer card geometry', () => { const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs floating-panels'); store.add(toDisposable(() => root.remove())); - root.style.cssText = '--vscode-editor-border: #123456; --vscode-editorGroup-border: #123456; --vscode-strokeThickness: 1px; --vscode-cornerRadius-large: 8px;'; + root.style.cssText = '--vscode-editor-border: #123456; --vscode-editorGroup-border: #123456; --vscode-focusBorder: #ffaa00; --vscode-strokeThickness: 1px; --vscode-cornerRadius-large: 8px;'; const grid = appendElement(root, 'monaco-grid-view'); const editor = appendElement(grid, 'part editor floating-editor-outer-left floating-editor-outer-top'); editor.style.cssText = 'width: 500px; height: 350px;'; const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title'); + const body = appendElement(group, 'editor-container'); const geometry = () => [editor, content].map(element => { const bounds = element.getBoundingClientRect(); return [bounds.x, bounds.y, bounds.width, bounds.height]; @@ -1956,17 +1959,25 @@ suite('ModernUIContribution', () => { root.classList.toggle('modern-ui-connected-editor-tabs', connected); for (const compact of [false, true]) { root.classList.toggle('modern-ui-compact', compact); - const style = getWindow(editor).getComputedStyle(editor); - const singleFrame = connected && theme.startsWith('hc-'); - assert.deepStrictEqual({ - geometry: geometry(), - border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], - compactStroke: compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, - }, { - geometry: baseline, - border: ['1px', '1px', singleFrame || compact ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], - compactStroke: compact ? (singleFrame ? 'transparent' : '#123456') : undefined, - }, `${theme}, connected: ${connected}, compact: ${compact}`); + for (const showTabs of ['multiple', 'single', 'none', 'multiple']) { + editor.classList.toggle('editor-tabs-multiple', showTabs === 'multiple'); + title.classList.toggle('tabs', showTabs === 'multiple'); + const style = getWindow(editor).getComputedStyle(editor); + const wellFrame = getWindow(body).getComputedStyle(body, '::after'); + const connectedFrame = connected && showTabs === 'multiple'; + const singleFrame = connectedFrame && theme.startsWith('hc-'); + assert.deepStrictEqual({ + geometry: geometry(), + border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], + compactStroke: compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, + wellFrame: [wellFrame.content, wellFrame.borderLeftWidth, singleFrame ? wellFrame.borderLeftColor : undefined], + }, { + geometry: baseline, + border: ['1px', '1px', singleFrame || compact ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], + compactStroke: compact ? (singleFrame ? 'transparent' : '#123456') : undefined, + wellFrame: [connectedFrame ? '""' : 'none', connectedFrame ? '1px' : '0px', singleFrame ? 'rgb(255, 170, 0)' : undefined], + }, `${theme}, connected: ${connected}, compact: ${compact}, showTabs: ${showTabs}`); + } } } } 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 1fb11b886e2238..a5026dddb50ef5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -516,6 +516,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: // Recreate the ancestor chain the tab-bar CSS is scoped to; the fixture container already // carries `.monaco-workbench` + theme classes. const editorPart = $('.part.editor'); + editorPart.classList.toggle('editor-tabs-multiple', partOptions.showTabs === 'multiple'); const content = $('.content'); const groupContainer = $(isGroupActive ? '.editor-group-container.active' : '.editor-group-container'); const titleContainer = $('.title'); diff --git a/src/vs/workbench/test/browser/parts/editor/editor.test.ts b/src/vs/workbench/test/browser/parts/editor/editor.test.ts index e0e4b0428cbc19..c7839ada9978c9 100644 --- a/src/vs/workbench/test/browser/parts/editor/editor.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editor.test.ts @@ -427,6 +427,28 @@ suite('Workbench editor utils', () => { assert.strictEqual(part.partOptions.tabActionReserveSpace, false); }); + test('editor tab mode class follows configured and enforced part options', async () => { + const configuredInstantiationService = workbenchInstantiationService({ + configurationService: () => { + const configurationService = new TestConfigurationService({ + workbench: { editor: { showTabs: 'single' } } + }); + disposables.add(configurationService.onDidChangeConfigurationEmitter); + return configurationService; + } + }, disposables); + const part = await createEditorPart(configuredInstantiationService, disposables); + const hasMultipleTabs = () => part.getContainer()!.classList.contains('editor-tabs-multiple'); + const states = [hasMultipleTabs()]; + for (const showTabs of ['multiple', 'none', 'single'] as const) { + const override = disposables.add(part.enforcePartOptions({ showTabs })); + states.push(hasMultipleTabs()); + override.dispose(); + states.push(hasMultipleTabs()); + } + assert.deepStrictEqual(states, [false, true, false, false, false, false, false]); + }); + test('whenEditorClosed (single editor)', async function () { return testWhenEditorClosed(false, false, toResource.call(this, '/path/index.txt')); }); diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 443e1bdfc65810..591aa77756d6c4 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -291,6 +291,61 @@ suite('MultiEditorTabsControl', () => { }); }); + test('keeps replacement connected tabs visible after closing rightmost scrolled tabs', async () => { + const group = connectedGroup(); + const root = group.closest('.monaco-workbench')!; + root.classList.add('hc-black'); + root.style.setProperty('--vscode-focusBorder', '#ffaa00'); + root.style.setProperty('--modern-ui-connected-tab-surface', '#333333'); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160, tabSizingFixedMaxWidth: 160, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + for (let i = 2; i < 5; i++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: true }); + } + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 200); + const tabs = container.querySelector('.tabs-container')!; + const results = []; + for (let count = 5; count > 2; count--) { + tabs.classList.add('scroll'); + tabs.scrollLeft = tabs.scrollWidth - tabs.clientWidth; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + tabs.classList.remove('scroll'); + const oldScrollLeft = tabs.scrollLeft; + const closedEditor = model.getEditorByIndex(count - 1)!; + model.closeEditor(closedEditor); + control.closeEditor(closedEditor); + const clampedBeforeLayout = tabs.scrollLeft < oldScrollLeft; + await layoutConnectedGroup(group, 200); + const activeTab = tabs.querySelector('.tab.active')!; + const fill = activeTab.querySelector('.tab-fill')!; + const fillStyle = mainWindow.getComputedStyle(fill); + const edge = activeTab.querySelector('.tab-connected-edge')!; + results.push({ + clampedBeforeLayout, + activeIndex: model.indexOf(model.activeEditor!), + scrollLeft: tabs.scrollLeft, + fillLeft: fill.getBoundingClientRect().left - tabs.getBoundingClientRect().left + tabs.scrollLeft, + hidden: activeTab.classList.contains('connected-tab-hidden'), + fillDisplay: fillStyle.display, + outlineDisplay: mainWindow.getComputedStyle(edge).display, + outlineColor: fillStyle.borderTopColor, + }); + } + assert.deepStrictEqual(results, [4, 3, 2].map(count => ({ + clampedBeforeLayout: true, + activeIndex: count - 1, + scrollLeft: count * 160 - 200, + fillLeft: (count - 1) * 160, + hidden: false, + fillDisplay: 'block', + outlineDisplay: 'block', + outlineColor: 'rgb(255, 170, 0)', + }))); + }); + test('connected sticky offsets respect content minimums instead of the classic fixed width', async () => { const group = connectedGroup(); const oldOptions = partOptions; From 69c8fa450e65afbb253833bcc8694814e5ecb308 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 19:09:56 -0700 Subject: [PATCH 14/26] fix: address connected tab review feedback Distinguish active multi-selection in high contrast with an inset stroke that stays inside the tab strip. Restore explicit pill-color fixtures and test intrinsic content-only badge sizing. Refs #331104 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 5 +++ .../browser/modernUI.contribution.test.ts | 38 +++++++++++++++++++ .../editor/editorTabBar.fixture.ts | 3 +- .../editor/multiEditorTabsControl.test.ts | 10 +++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 970093402c16d1..b08e7c450e8204 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -46,6 +46,11 @@ border-bottom: 0; } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected:not(:focus) > .tab-fill { + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); +} + .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:focus-visible > .tab-fill { outline: var(--vscode-strokeThickness) dashed var(--vscode-focusBorder); outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 376aa7cb0dd586..cb030699ab6e5e 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2108,6 +2108,44 @@ suite('ModernUIContribution', () => { } }); + test('distinguishes active multi-selected connected tabs in high contrast without moving the well', () => { + const root = document.createElement('div'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-contrastActiveBorder', '#ff0000'); + root.style.setProperty('--vscode-focusBorder', '#00ff00'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab active'); + tab.style.width = '120px'; + tab.style.height = '32px'; + const fill = appendElement(tab, 'tab-fill'); + const targetWindow = getWindow(root); + + for (const theme of ['hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs ${theme}`; + tab.className = 'tab active'; + const bounds = fill.getBoundingClientRect().toJSON(); + const ordinaryOutline = targetWindow.getComputedStyle(fill).outlineStyle; + tab.classList.add('selected', 'multi-selected'); + const selectedStyle = targetWindow.getComputedStyle(fill); + assert.deepStrictEqual({ + ordinaryOutline, + outline: selectedStyle.outlineStyle, + color: selectedStyle.outlineColor, + width: selectedStyle.outlineWidth, + offset: selectedStyle.outlineOffset, + bounds: fill.getBoundingClientRect().toJSON(), + }, { ordinaryOutline: 'none', outline: 'solid', color: 'rgb(255, 0, 0)', width: '1px', offset: '-3px', bounds }, theme); + } + }); + test('matches connected cap and shoulder radii for the outside stroke and omits the first outer shoulder', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; 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 a5026dddb50ef5..b92d01da9542e4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -779,6 +779,7 @@ function renderThemeColors(options: Omit { test('connected minimum width preserves basename ellipsis extension badge and action', async () => { const group = connectedGroup(); const badgeStyle = document.createElement('style'); - badgeStyle.textContent = '.connected-tabs-labels .monaco-decoration-badge::after { content: "M"; width: 10px; margin: 0 5px; }'; + badgeStyle.textContent = '.connected-tabs-labels .monaco-decoration-badge::after { content: "WM"; margin: 0 5px; }'; group.appendChild(badgeStyle); const oldOptions = partOptions; partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, editorActionsLocation: 'hidden', hasIcons: true, showTabIndex: true }; @@ -171,9 +171,13 @@ suite('MultiEditorTabsControl', () => { const suffix = tab.querySelector('.label-suffix')!; const action = tab.querySelector('.tab-actions')!; const context = document.createElement('canvas').getContext('2d')!; + const badge = mainWindow.getComputedStyle(label, '::after'); + context.font = badge.font; + const badgeTextWidth = context.measureText('WM').width; context.font = mainWindow.getComputedStyle(name).font; assert.deepStrictEqual({ - minimum: tab.offsetWidth >= Math.ceil(context.measureText('1: f….txt').width) + 20 + 34, + minimum: tab.offsetWidth >= Math.ceil(context.measureText('1: f….txt').width + badgeTextWidth) + 10 + 34, + intrinsicBadgeWidth: Math.abs(parseFloat(badge.width) - badgeTextWidth) < 1, narrow: tab.classList.contains('connected-tab-narrow'), iconHidden: mainWindow.getComputedStyle(label, '::before').display, basename: name.textContent, @@ -183,7 +187,7 @@ suite('MultiEditorTabsControl', () => { extensionBeforeAction: suffix.getBoundingClientRect().right <= action.getBoundingClientRect().left, fullAriaLabel: tab.getAttribute('aria-label')?.includes('file0.txt'), }, { - minimum: true, narrow: true, iconHidden: 'none', basename: '1: file0', extension: '.txt', + minimum: true, intrinsicBadgeWidth: true, narrow: true, iconHidden: 'none', basename: '1: file0', extension: '.txt', ellipsis: 'ellipsis', basenameVisible: true, extensionBeforeAction: true, fullAriaLabel: true, }); name.style.fontSize = '13px'; From 2f1ba66d75cb24d6a2e031e330526464d16db255 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 20:11:28 -0700 Subject: [PATCH 15/26] fix: isolate component fixture styles by theme identity Prevent additional tab themes from leaking colors into later fixtures sharing the same light or dark scheme. Cover theming participants and nested editor variables in both installation orders; retain the existing screenshot baselines. Refs #335605 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../componentFixtures/fixtureUtilsCss.test.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts index 84d6f9d00561e7..ceca5d6e0492dd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts @@ -24,7 +24,7 @@ suite('Component fixture theme CSS', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const variants: readonly ThemeVariant[] = [ { - id: ThemeTypeSelector.VS_DARK, + id: `${ThemeTypeSelector.VS_DARK} fixture-dark-modern`, colors: { 'editor.lineHighlightBackground': '#242526' }, expected: { border: '0px none rgb(0, 0, 0)', background: 'rgb(36, 37, 38)' }, }, @@ -48,6 +48,16 @@ suite('Component fixture theme CSS', () => { colors: { 'editor.lineHighlightBackground': '#00000000', 'editor.lineHighlightBorder': '#0f4a85' }, expected: { border: '1px solid rgb(15, 74, 133)', background: 'rgba(0, 0, 0, 0)' }, }, + { + id: `${ThemeTypeSelector.VS_DARK} fixture-solarized-dark`, + colors: { 'editor.lineHighlightBackground': '#002b36' }, + expected: { border: '0px none rgb(0, 0, 0)', background: 'rgb(0, 43, 54)' }, + }, + { + id: `${ThemeTypeSelector.VS} fixture-solarized-light`, + colors: { 'editor.lineHighlightBackground': '#fdf6e3' }, + expected: { border: '0px none rgb(0, 0, 0)', background: 'rgb(253, 246, 227)' }, + }, ]; for (const reverse of [false, true]) { @@ -86,6 +96,36 @@ suite('Component fixture theme CSS', () => { fixtures: expectedStyles.slice(0, index + 1), }))); }); + + test(`isolates color variables on nested editor theme classes in ${reverse ? 'reverse' : 'forward'} theme installation order`, () => { + const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; + disposables.add(toDisposable(() => { mainWindow.document.adoptedStyleSheets = originalStyleSheets; })); + const host = mainWindow.document.body.appendChild($('div')); + disposables.add(toDisposable(() => host.remove())); + const orderedVariants = reverse ? [...variants].reverse() : variants; + const editors: HTMLElement[] = []; + const actual = []; + + for (const variant of orderedVariants) { + const theme = ColorThemeData.createLoadedEmptyTheme(variant.id, variant.id); + theme.setCustomColors(variant.colors); + mainWindow.document.adoptedStyleSheets = [ + ...mainWindow.document.adoptedStyleSheets, + getThemeStyleSheet(theme), + ]; + const root = host.appendChild($('.monaco-workbench')); + root.classList.add(...theme.classNames); + const editor = root.appendChild($('.monaco-editor')); + editor.classList.add(theme.classNames[0]); + editor.style.backgroundColor = 'var(--vscode-editor-lineHighlightBackground)'; + editors.push(editor); + actual.push(editors.map(element => mainWindow.getComputedStyle(element).backgroundColor)); + } + + assert.deepStrictEqual(actual, orderedVariants.map((_, index) => + orderedVariants.slice(0, index + 1).map(variant => variant.expected.background) + )); + }); } test('preserves theme variables on fixture and nested editor roots', () => { From a5888482cbb2af614230dff0cf05bfcead5d8ca2 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 20:43:46 -0700 Subject: [PATCH 16/26] test: refresh baselines after fixture theme isolation Verify workbench-only theming does not paint nested editor scrollers. Accept 30 visually reviewed Ubuntu baselines that remove the old fixture-only background artifacts; the original Solarized contamination remains rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../componentFixtures/fixtureUtilsCss.test.ts | 35 +++++++++++++++++++ .../blocks-ci-screenshots.md | 18 +++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts index ceca5d6e0492dd..bdc738a538f54e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts @@ -10,6 +10,7 @@ import { toDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../../../editor/browser/viewParts/currentLineHighlight/currentLineHighlight.js'; import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; import type { IColorCustomizations } from '../../../services/themes/common/workbenchThemeService.js'; import { getThemeStyleSheet } from './fixtureUtilsCss.js'; @@ -60,6 +61,40 @@ suite('Component fixture theme CSS', () => { }, ]; + test('does not apply workbench backgrounds to nested editor theme classes', () => { + const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; + disposables.add(toDisposable(() => { mainWindow.document.adoptedStyleSheets = originalStyleSheets; })); + disposables.add(registerThemingParticipant((_theme, collector) => { + collector.addRule('.monaco-workbench { background-color: var(--vscode-editor-background); }'); + })); + const host = mainWindow.document.body.appendChild($('div')); + disposables.add(toDisposable(() => host.remove())); + + const actual = variants.map((variant, index) => { + const id = `${variant.selector} workbench-background-${index}`; + const theme = ColorThemeData.createLoadedEmptyTheme(id, id); + theme.setCustomColors({ 'editor.background': '#123456' }); + mainWindow.document.adoptedStyleSheets = [ + ...mainWindow.document.adoptedStyleSheets, + getThemeStyleSheet(theme), + ]; + const root = host.appendChild($('.monaco-workbench')); + root.classList.add(...theme.classNames); + const editor = root.appendChild($('.monaco-editor')); + editor.classList.add(variant.selector); + const scrollable = editor.appendChild($('.monaco-scrollable-element')); + scrollable.classList.add(variant.selector); + + return [root, editor, scrollable].map(element => mainWindow.getComputedStyle(element).backgroundColor); + }); + + assert.deepStrictEqual(actual, variants.map(() => [ + 'rgb(18, 52, 86)', + 'rgba(0, 0, 0, 0)', + 'rgba(0, 0, 0, 0)', + ])); + }); + for (const reverse of [false, true]) { test(`isolates active-line styles in ${reverse ? 'reverse' : 'forward'} theme installation order`, () => { const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 6fb3b1bf77f167..d382a06b9cec2f 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -64,7 +64,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/54642db46b8d9ada83801284ca684eeaaf42371d2eaf31d3a3debbb59c607ba4) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b4e8657854d7c63ac44292848fae4e64ec344ecb8449ffa6833d5fe8a345c249) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/83f74ecd73ce774dc3810cda0e41da17d236d5142cd154d049379069ae0845c9) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/06dc8aea06facbdf19836000c6f4b6dfcfbe82824373160d39f89c5fcdf52267) @@ -349,49 +349,49 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/732fb18e20a0195448372de10a0feaf319dee84c5c8b67ed61ba359cd0b54701) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4fac14822d0c68ad8c6ad48277c6418ee064b246daa641e648e8760c43774cc8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/21881ffa51a4e8b25c2213de9264579b61b1ace56bab8dccfbe22c6826b32c9f) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/25b550c4d7efc59532a118c2824c526453fbd7d62449c8f96e7a37bc3abea92a) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ea10f9ac59db66a3ec2dabb3eca1eb996606441bdb6ce6521aad407770e2b026) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a18059740311926cd18d6a19dfbea87edd4c3bbc082e28c988c2493ca6557714) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8f5bbc02ab6ec03493f6b0ed5d4744d64bcd0f4f0394206a50d09f76b0343877) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1b0accc9ffb55e46c1fdc489868fcc5bc334eb3e4f6f73326c8e2435d2bd462) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bf0919c2615a13a619f44f455cdf76d4c1adeeb621324f79fb97a37a63b8d297) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/9e66591c00f7a7d609d365b76410af29a44656195ef0c6a9a9dd13350039c854) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3e9b02be0ea5ceb14f784a5d7e3b3d07ef613f0ff1ccee5de2f9f924b0435576) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a7c390efcf01c763f5cc93d6b5a4cc1a1c9468d28e8cd88653ba315ebb4e273d) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/79e5a5254684fd526f5968e87aad79f95fb1a8a20fba325816bd0ea75090065b) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/544be3f91e33c38aee9aee2a931f438a6548d483e89b0d8035973eded3f36443) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/180cb6633f8c78d01b6fb57393d0c6b836d75f85145d51bf6803314f62aaf9ca) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/5fb4e4324d4219f7db7d7f6297002032b8648c81d2b28a5e1b9cf6aaa00eab3a) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5fdd65e8749eeae857aef2953338bc2b389efe5f6dc1e1d56f07f27c5bcb77fc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f72b4375545d0487aaeac0c9cd6497ab461beb62c9c4d4be91f4ac1f3f556e15) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e10afdd8382309d3424318b87ea7ec26d5aa834226197a53bd127aef6f29bea0) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/aab03c7e94c4e749bab8f227b8c8454e853bf45cae3143896d49e37febf4abb9) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1196696330713fa5d98d22701e7edb1c26ca73f964564aaed16b2c91af34f53b) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1085f7d7fab0c2f872fcd61c3b514ce45e2433790eed8d0fb95874e93e4e27ae) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5f7250cb45995c66b45ceac4f8f239ae248321006dc921110011be7679ed1841) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/980fa555083b7c0fdaed41b0d1ec2e0d8bb805578280a8932006e8b95cfa7ded) #### sessions/chat/view/chatView/AssistantResponseBackground/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8df2150dd9f449dd7268d9b898c4a331c65657fb88886d10ed5f59536b0faa3) From 8a2ee605de62a318f0eedad44f3621a0190900a5 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 10 Sep 2026 21:03:39 -0700 Subject: [PATCH 17/26] test: load editor styles in fixture isolation regression The full test suite loads Monaco CSS, so editor roots correctly use the editor background rather than transparency. Load those styles explicitly and use distinct workbench/editor colors to keep the regression independent of test discovery order while still checking nested scrollers remain transparent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/componentFixtures/fixtureUtilsCss.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts index bdc738a538f54e..608bc52d24e8bb 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts @@ -9,6 +9,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { toDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../../../editor/browser/viewParts/currentLineHighlight/currentLineHighlight.js'; +import '../../../../editor/browser/widget/codeEditor/editor.css'; import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; @@ -65,7 +66,7 @@ suite('Component fixture theme CSS', () => { const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; disposables.add(toDisposable(() => { mainWindow.document.adoptedStyleSheets = originalStyleSheets; })); disposables.add(registerThemingParticipant((_theme, collector) => { - collector.addRule('.monaco-workbench { background-color: var(--vscode-editor-background); }'); + collector.addRule('.monaco-workbench { background-color: #654321; }'); })); const host = mainWindow.document.body.appendChild($('div')); disposables.add(toDisposable(() => host.remove())); @@ -89,9 +90,9 @@ suite('Component fixture theme CSS', () => { }); assert.deepStrictEqual(actual, variants.map(() => [ + 'rgb(101, 67, 33)', 'rgb(18, 52, 86)', 'rgba(0, 0, 0, 0)', - 'rgba(0, 0, 0, 0)', ])); }); From 38460f5326962e36eaed4ce30246e9e6244db677 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 11:12:18 -0700 Subject: [PATCH 18/26] fix: improve Quiet Light document tab contrast Separate the inactive strip from the selected document surface while preserving readable inactive, hover, and warning labels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../theme-quietlight/themes/quietlight-color-theme.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extensions/theme-quietlight/themes/quietlight-color-theme.json b/extensions/theme-quietlight/themes/quietlight-color-theme.json index ab87b0c23db132..253cfb58fe32e6 100644 --- a/extensions/theme-quietlight/themes/quietlight-color-theme.json +++ b/extensions/theme-quietlight/themes/quietlight-color-theme.json @@ -495,6 +495,7 @@ "list.activeSelectionBackground": "#c4d9b1", "list.inactiveSelectionBackground": "#d3dbcd", "list.highlightForeground": "#9769dc", + "list.warningForeground": "#765500", "selection.background": "#C9D0D9", "editor.background": "#F5F5F5", "editorWhitespace.foreground": "#AAAAAA", @@ -520,6 +521,11 @@ "peekViewResult.background": "#F2F8FC", "peekView.border": "#705697", "peekViewResult.matchHighlightBackground": "#93C6D6", + "editorGroupHeader.tabsBackground": "#E4E4E4", + "tab.inactiveBackground": "#E4E4E4", + "tab.inactiveForeground": "#54494B", + "tab.unfocusedInactiveForeground": "#54494B", + "tab.hoverBackground": "#F2F2F2", "tab.lastPinnedBorder": "#c9d0d9", "statusBar.background": "#705697", "statusBar.inactiveBackground": "#715D91", @@ -548,6 +554,7 @@ "editorIndentGuide.background": "#aaaaaa60", "editorIndentGuide.activeBackground": "#777777b0", // modern ui + "modernEditorTab.hoverForeground": "#54494B", "surface.border": "#00000000", "modernSash.gripForeground": "#705697", }, From c9f79f2d5750384f4b47db3b0b5d4b45ec3c39de Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 11:17:50 -0700 Subject: [PATCH 19/26] test: retain Dark Modern tab coverage after rebase Keep an explicit Dark Modern fixture variant now that upstream uses Dark 2026 for the default Dark fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/componentFixtures/editor/editorTabBar.fixture.ts | 2 +- src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 b92d01da9542e4..1e0dd8b6b84f60 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -826,7 +826,7 @@ function renderWrappedConnectedSurface(activeTabIndex: number, forcedHoverTab?: }); } -const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['dark2026', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; +const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'dark2026', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FileIconThemes: defineThemedFixtureGroup({ diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index c4040612e2ef17..741aca803ed47b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -377,6 +377,7 @@ type ComponentFixtureThemeVariant = { const darkThemeVariant = { label: 'Dark', background: 'dark', theme: darkTheme } as const satisfies ComponentFixtureThemeVariant; const lightThemeVariant = { label: 'Light', background: 'light', theme: lightTheme } as const satisfies ComponentFixtureThemeVariant; const additionalThemeVariants = { + darkModern: { label: 'DarkModern', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK) }, dark2026: { label: 'Dark2026', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-dark.json', ThemeTypeSelector.VS_DARK) }, light2026: { label: 'Light2026', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-light.json', ThemeTypeSelector.VS) }, darkPlus: { label: 'DarkPlus', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_plus.json', ThemeTypeSelector.VS_DARK) }, From a59629c75d135eaef97b973078d52f06ca86eb35 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 11:33:57 -0700 Subject: [PATCH 20/26] fix: address connected tab review feedback Use the existing editor frame color variable without escalating the cascade, and cover floating and non-floating HC layouts. Remove the duplicated Dark 2026 fixture while retaining all fifteen distinct themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 3 +- .../browser/modernUI.contribution.test.ts | 55 ++++++++++--------- .../editor/editorTabBar.fixture.ts | 2 +- .../browser/componentFixtures/fixtureUtils.ts | 1 - 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index b08e7c450e8204..bc0fe73a7a0f36 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -6,7 +6,8 @@ /* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ /* In HC the connected well owns the frame; retain the outer card's layout space, not its stroke. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor.editor-tabs-multiple:not(.modal-editor-part) { - border-color: transparent !important; + border-color: transparent; + --modern-ui-editor-border-color: transparent; --modern-ui-floating-card-stroke-color: transparent; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index cb030699ab6e5e..76f6b20af85cfc 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1951,32 +1951,35 @@ suite('ModernUIContribution', () => { const bounds = element.getBoundingClientRect(); return [bounds.x, bounds.y, bounds.width, bounds.height]; }); - const baseline = geometry(); - for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { - root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); - root.classList.add(theme); - for (const connected of [false, true]) { - root.classList.toggle('modern-ui-connected-editor-tabs', connected); - for (const compact of [false, true]) { - root.classList.toggle('modern-ui-compact', compact); - for (const showTabs of ['multiple', 'single', 'none', 'multiple']) { - editor.classList.toggle('editor-tabs-multiple', showTabs === 'multiple'); - title.classList.toggle('tabs', showTabs === 'multiple'); - const style = getWindow(editor).getComputedStyle(editor); - const wellFrame = getWindow(body).getComputedStyle(body, '::after'); - const connectedFrame = connected && showTabs === 'multiple'; - const singleFrame = connectedFrame && theme.startsWith('hc-'); - assert.deepStrictEqual({ - geometry: geometry(), - border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], - compactStroke: compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, - wellFrame: [wellFrame.content, wellFrame.borderLeftWidth, singleFrame ? wellFrame.borderLeftColor : undefined], - }, { - geometry: baseline, - border: ['1px', '1px', singleFrame || compact ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], - compactStroke: compact ? (singleFrame ? 'transparent' : '#123456') : undefined, - wellFrame: [connectedFrame ? '""' : 'none', connectedFrame ? '1px' : '0px', singleFrame ? 'rgb(255, 170, 0)' : undefined], - }, `${theme}, connected: ${connected}, compact: ${compact}, showTabs: ${showTabs}`); + for (const floating of [false, true]) { + root.classList.toggle('floating-panels', floating); + const baseline = geometry(); + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); + root.classList.add(theme); + for (const connected of [false, true]) { + root.classList.toggle('modern-ui-connected-editor-tabs', connected); + for (const compact of [false, true]) { + root.classList.toggle('modern-ui-compact', compact); + for (const showTabs of ['multiple', 'single', 'none', 'multiple']) { + editor.classList.toggle('editor-tabs-multiple', showTabs === 'multiple'); + title.classList.toggle('tabs', showTabs === 'multiple'); + const style = getWindow(editor).getComputedStyle(editor); + const wellFrame = getWindow(body).getComputedStyle(body, '::after'); + const connectedFrame = connected && showTabs === 'multiple'; + const singleFrame = connectedFrame && theme.startsWith('hc-'); + assert.deepStrictEqual({ + geometry: geometry(), + border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], + compactStroke: floating && compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, + wellFrame: [wellFrame.content, wellFrame.borderLeftWidth, singleFrame ? wellFrame.borderLeftColor : undefined], + }, { + geometry: baseline, + border: ['1px', '1px', singleFrame || (floating && compact) ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], + compactStroke: floating && compact ? (singleFrame ? 'transparent' : '#123456') : undefined, + wellFrame: [connectedFrame ? '""' : 'none', connectedFrame ? '1px' : '0px', singleFrame ? 'rgb(255, 170, 0)' : undefined], + }, `${theme}, floating: ${floating}, connected: ${connected}, compact: ${compact}, showTabs: ${showTabs}`); + } } } } 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 1e0dd8b6b84f60..ee84e4381c9800 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -826,7 +826,7 @@ function renderWrappedConnectedSurface(activeTabIndex: number, forcedHoverTab?: }); } -const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'dark2026', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; +const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FileIconThemes: defineThemedFixtureGroup({ diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 741aca803ed47b..e2216831b04f61 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -378,7 +378,6 @@ const darkThemeVariant = { label: 'Dark', background: 'dark', theme: darkTheme } const lightThemeVariant = { label: 'Light', background: 'light', theme: lightTheme } as const satisfies ComponentFixtureThemeVariant; const additionalThemeVariants = { darkModern: { label: 'DarkModern', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK) }, - dark2026: { label: 'Dark2026', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-dark.json', ThemeTypeSelector.VS_DARK) }, light2026: { label: 'Light2026', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-light.json', ThemeTypeSelector.VS) }, darkPlus: { label: 'DarkPlus', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_plus.json', ThemeTypeSelector.VS_DARK) }, lightPlus: { label: 'LightPlus', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/light_plus.json', ThemeTypeSelector.VS) }, From 2d2c140c9fe5864c41644ec39fca8d784398e1ce Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 11:48:44 -0700 Subject: [PATCH 21/26] fix: fully reveal connected tab left shoulders Budget both connected shoulders using outward-rounded bounds when checking whether a tab fits. Preserve the flush first-tab and row-start exception. Cover leftward reveal, narrow fitting tabs, oversized tabs and first-tab alignment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 10 ++-- .../editor/multiEditorTabsControl.test.ts | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 886551910a3979..206972c4e9811c 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2415,12 +2415,16 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Reveal the active one const tabsContainerScrollPosX = tabsScrollbar.getScrollPosition().scrollLeft; const activeTabShoulderWidth = this.connectedTabBounds?.shoulderExtent ?? 0; - const activeTabFits = activeTabWidth + activeTabShoulderWidth <= availableTabsContainerWidth; - const adjustedActiveTabPosX = activeTabPosX - stickyTabsWidth; - // Integer offset widths can leave a fractional shoulder clipped after revealing a tab. + const previousTab = this.connectedTabBounds?.tab.previousElementSibling; + const activeTabLeftShoulderWidth = previousTab && !previousTab.classList.contains('last-in-row') ? activeTabShoulderWidth : 0; + // Round outwards so fractional bounds cannot leave either shoulder clipped. + const adjustedActiveTabPosX = (this.connectedTabBounds + ? Math.floor(this.connectedTabBounds.fillLeft - activeTabLeftShoulderWidth) + : activeTabPosX) - stickyTabsWidth; const activeTabRight = this.connectedTabBounds ? Math.ceil(this.connectedTabBounds.fillRight + activeTabShoulderWidth) - stickyTabsWidth : adjustedActiveTabPosX + activeTabWidth; + const activeTabFits = activeTabRight - adjustedActiveTabPosX <= availableTabsContainerWidth; // // Synopsis diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index cc517584ed869b..c5438e6c2caaf6 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -295,6 +295,54 @@ suite('MultiEditorTabsControl', () => { }); }); + test('reveals the left shoulder of non-first tabs and keeps the first tab flush', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160.25, tabSizingFixedMaxWidth: 160.25, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + for (let i = 2; i < 4; i++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: false }); + } + const reveal = async (index: number, width: number) => { + model.openEditor(model.getEditorByIndex(index)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 300) }, { forceRevealActiveTab: true }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + }; + const results = []; + for (const { width, from } of [{ width: 240, from: 3 }, { width: 167, from: 0 }, { width: 120, from: 0 }]) { + await reveal(from, width); + await reveal(1, width); + const tab = container.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const viewport = container.querySelector('.monaco-scrollable-element')!.getBoundingClientRect(); + const shoulder = mainWindow.getComputedStyle(fill, '::before'); + const shoulderWidth = Number.parseFloat(shoulder.width); + results.push({ + width, + leftShoulderVisible: shoulder.content !== 'none' && fill.getBoundingClientRect().left - shoulderWidth >= viewport.left, + rightShoulderVisible: fill.getBoundingClientRect().right + shoulderWidth <= viewport.right, + }); + } + await reveal(0, 240); + const firstFill = container.querySelector('.tab.active > .tab-fill')!; + assert.deepStrictEqual({ + results, + firstTabFlush: firstFill.getBoundingClientRect().left === container.querySelector('.monaco-scrollable-element')!.getBoundingClientRect().left, + firstShoulder: mainWindow.getComputedStyle(firstFill, '::before').content, + }, { + results: [ + { width: 240, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 167, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 120, leftShoulderVisible: true, rightShoulderVisible: false }, + ], + firstTabFlush: true, + firstShoulder: 'none', + }); + }); + test('keeps replacement connected tabs visible after closing rightmost scrolled tabs', async () => { const group = connectedGroup(); const root = group.closest('.monaco-workbench')!; From 3954cf3ec7a0b7b6c5a53220a1c61ab061580b5c Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 12:00:51 -0700 Subject: [PATCH 22/26] fix: only protect resource-backed tab extensions Keep arbitrary dotted editor titles in the ellipsizing name container instead of interpreting their trailing text as a file extension. Preserve actual filename extensions and full accessible names, including dotfiles and resource-less titles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 14 +++---- .../editor/multiEditorTabsControl.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 206972c4e9811c..ad9b340f3eba65 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -38,7 +38,7 @@ import { IEditorGroupMenuIds, IEditorGroupsView, EditorServiceImpl, IEditorGroup import { CloseEditorTabAction, CloseOtherEditorTabsInGroupAction, UnpinEditorAction } from './editorActions.js'; import { assertReturnsAllDefined, assertReturnsDefined } from '../../../../base/common/types.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { basenameOrAuthority } from '../../../../base/common/resources.js'; +import { basename, basenameOrAuthority, extname } from '../../../../base/common/resources.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IPath, win32, posix } from '../../../../base/common/path.js'; @@ -1825,12 +1825,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { } // Label + const resource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); let suffix: string | undefined; - if (name && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') && !(options.pinnedTabSizing === 'compact' && this.tabsModel.isSticky(tabIndex))) { - const extensionIndex = name.lastIndexOf('.'); - if (extensionIndex > 0 && extensionIndex < name.length - 1) { - suffix = name.substring(extensionIndex); - name = name.substring(0, extensionIndex); + if (name && resource && name === basename(resource) && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') && !(options.pinnedTabSizing === 'compact' && this.tabsModel.isSticky(tabIndex))) { + const extension = extname(resource); + if (extension.length > 1) { + suffix = extension; + name = name.substring(0, name.length - extension.length); } } tabLabelWidget.setResource( @@ -1852,7 +1853,6 @@ export class MultiEditorTabsControl extends EditorTabsControl { ); // Tests helper - const resource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); if (resource) { tabContainer.setAttribute('data-resource-name', basenameOrAuthority(resource)); } else { diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index c5438e6c2caaf6..11936e5a03a45d 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -248,6 +248,43 @@ suite('MultiEditorTabsControl', () => { ]); }); + test('protects extensions only when the tab title matches the resource filename', async () => { + const group = connectedGroup(); + const cases = [ + { resource: URI.file('/path/archive.tar.gz'), name: 'archive.tar.gz' }, + { resource: URI.file('/path/.env'), name: '.env' }, + { resource: URI.file('/path/file.'), name: 'file.' }, + { resource: URI.file('/path/notes.md'), name: 'Release 1.2 notes and announcements' }, + { resource: URI.from({ scheme: 'test', path: '/views/123' }), name: 'Example.org documentation' }, + { resource: undefined, name: 'Release 1.2 notes and announcements' }, + ]; + const results = []; + for (const { resource, name } of cases) { + const editor = disposables.add(new class extends EditorInput { + override get typeId(): string { return 'testEditorTitle'; } + override get resource(): URI | undefined { return resource; } + override getName(): string { return name; } + }()); + model.openEditor(editor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 240); + const tab = container.querySelector('.tab.active')!; + results.push({ + name: tab.querySelector('.label-name')?.textContent, + suffix: tab.querySelector('.label-suffix')?.textContent ?? '', + accessibleName: tab.getAttribute('aria-label')?.includes(name), + }); + } + assert.deepStrictEqual(results, [ + { name: 'archive.tar', suffix: '.gz', accessibleName: true }, + { name: '.env', suffix: '', accessibleName: true }, + { name: 'file.', suffix: '', accessibleName: true }, + { name: 'Release 1.2 notes and announcements', suffix: '', accessibleName: true }, + { name: 'Example.org documentation', suffix: '', accessibleName: true }, + { name: 'Release 1.2 notes and announcements', suffix: '', accessibleName: true }, + ]); + }); + test('connected actions keep active and dirty visible but inactive clean quiet', async () => { const group = connectedGroup(); container.classList.add('tab-actions-reserve-space'); From d6ecd437931b090890b9aa51e93ddbe3a5baf958 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 12:36:31 -0700 Subject: [PATCH 23/26] test: refresh isolated fixture theme baselines Update the 21 CI-provided hashes reviewed against old/new images. Theme isolation corrects response/input backgrounds and corner blending without changing dimensions or layout. All2161 fixtures rendered successfully and43 Playwright tests passed in the source-head run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../blocks-ci-screenshots.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index d382a06b9cec2f..f1e9dae9caeb32 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -61,7 +61,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/76078321fd9d2d371dc7a3323b86a06b97b8e03fbbd16f317868f18fbb6492d9) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/54642db46b8d9ada83801284ca684eeaaf42371d2eaf31d3a3debbb59c607ba4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0397d7c9ef011597c08f9698e14e5e71c73dbb7a676bd109f36e32e4d8a9e70c) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/83f74ecd73ce774dc3810cda0e41da17d236d5142cd154d049379069ae0845c9) @@ -193,16 +193,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/cdd864a3f817d8ae61c744ba12886716491dd00dc5e843a2ce57ffdb7966f7a5) #### chat/input/chatInput/CompactWithProviderIcon/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/6ff82bb6b1bcada3647122effff6bbe80a0ee7cedf83793fad51841e134831d4) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5420938bfb0c50f3646821eafe3be501feefc16cfc875bf9fcab144110d531e0) #### chat/input/chatInput/CompactWithProviderIcon/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/c94aa60de4f8f90b7387d893a83a19174e8fa1a47c5ee22ec539ce24f281d708) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bba07d0a3b7fe2144fac0b644dff2df16ae078db3ab40f3b63e04f13a28c0e6b) #### chat/input/chatInput/CopilotHarnessCompactPickers/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3638ba13a3dc2e73b9844fd1597db2377f1e75924433dfe310929952c329981f) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a60627a95ee1f8f61317e9794a0709eed94d91cc08d926c8d232ad12b1beb382) #### chat/input/chatInput/CopilotHarnessCompactPickers/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/93b3168016d9fd08171b397ef60f96d83ee96f0dfff9f1ecbefef7a098c9c5c1) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a089ab63eea3fc3abcf9191242bd0d6c72de80735727ecbeb3cc0d86033ad0b8) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/85ebae9e820e7c35589bd7d88483a37d8a1a63f385255e5f9b2fd54883ee447e) @@ -289,10 +289,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/dbcdd1e5ca6920f0f0b5fd6a97126465912cfa12d26007b125e831636f3c11c8) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/eb9fd939b61a2af6c9dcf90876b628ae3054b12e3526ae8834ddff6dff08986a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/d2fcbbf2c1f507f7bdb4292ed9ee4b43d899f05b7c24131cd7447e196ce3cf96) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/400740638fe5be5917dcd29cf8f5446ea36f0a32ba0dbd00a88d3327bd8adb07) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/b323ba1cc92f7280d3f71b09f28b83f937c659c923577c486fb00ad40c08d998) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/cc1638d294072922eb21dd4f90435744db32b35e7bbdcc2f5cbcbbaf07ee1e36) @@ -340,70 +340,70 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071) #### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/19c5632954e24fe84bf7555cba12e76d30866344f3aed9cda2233f7bc81e370d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/631be336e1f478b7d82b4ba31febba1e6839de8e4afd47514478e236fbd828ce) #### sessions/chat/input/chatInput/ResponsiveModelResizeCycleMinimal/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f3ee97730daf5f5503761f2f8adaa3bf30ab6d3a008eee1def5af4ce06c096bb) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6e211515e25865a3b9943b74e8650d543d7268533f33bfe2dbd3a078eaa5d1d6) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/732fb18e20a0195448372de10a0feaf319dee84c5c8b67ed61ba359cd0b54701) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/042756bd56dc109cd337ef40cbd7247b66c264500f9b87b579e68dab2f191c74) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/21881ffa51a4e8b25c2213de9264579b61b1ace56bab8dccfbe22c6826b32c9f) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/25b550c4d7efc59532a118c2824c526453fbd7d62449c8f96e7a37bc3abea92a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bdcdb933fb2ecb569a4608156bcb030411d3599c36faab6f9ac6ae1865a73bab) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a18059740311926cd18d6a19dfbea87edd4c3bbc082e28c988c2493ca6557714) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8f5bbc02ab6ec03493f6b0ed5d4744d64bcd0f4f0394206a50d09f76b0343877) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/921331f62f86ceb74eb99fed82a387599544848d905baf999d2f14b2492bf488) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/bf0919c2615a13a619f44f455cdf76d4c1adeeb621324f79fb97a37a63b8d297) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9e66591c00f7a7d609d365b76410af29a44656195ef0c6a9a9dd13350039c854) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/5ea903a7d7305da21222f196dafec116acfac4299f3c95c2b0667e1b5660b778) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a7c390efcf01c763f5cc93d6b5a4cc1a1c9468d28e8cd88653ba315ebb4e273d) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/79e5a5254684fd526f5968e87aad79f95fb1a8a20fba325816bd0ea75090065b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/68829b012b4598ae02f24619b999e6b260d696106576885556c27318d3ffa89b) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/180cb6633f8c78d01b6fb57393d0c6b836d75f85145d51bf6803314f62aaf9ca) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5fb4e4324d4219f7db7d7f6297002032b8648c81d2b28a5e1b9cf6aaa00eab3a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/2a071cdefaf8de6d0673fad8f9ab6e5566a537d4fb50da63e4e224eeee73674d) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/f72b4375545d0487aaeac0c9cd6497ab461beb62c9c4d4be91f4ac1f3f556e15) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/e10afdd8382309d3424318b87ea7ec26d5aa834226197a53bd127aef6f29bea0) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ee92a358a6339834736ac9f6b82d81c4356fd83b6279c5472371365376d03c90) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1196696330713fa5d98d22701e7edb1c26ca73f964564aaed16b2c91af34f53b) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/1085f7d7fab0c2f872fcd61c3b514ce45e2433790eed8d0fb95874e93e4e27ae) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/7e7d1d479eddc999036f8dfdf70a2b7f2176cfbc8f09ef547ae127f0bcd65d12) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light ![screenshot](https://hediet-screenshots.azurewebsites.net/images/980fa555083b7c0fdaed41b0d1ec2e0d8bb805578280a8932006e8b95cfa7ded) #### sessions/chat/view/chatView/AssistantResponseBackground/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8df2150dd9f449dd7268d9b898c4a331c65657fb88886d10ed5f59536b0faa3) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/81471b44a645d5635dc136f1d187fd4b247548a2cae51352304c9e092eaa735e) #### sessions/chat/view/chatView/AssistantResponseBackground/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/49f279636f94d045f38f2847adedbc6c3924d6f0976f05e9a4c198cb2f56b500) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dfdc75e2e76cf535efe031bbad677c4ecf76d39c8e67fc7debc6a8c163351fdb) #### sessions/chat/view/chatView/AssistantResponsePlain/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/32fe53cb7c7a126fb4ae57c43779b46740067bff81e036f67c16218b9cc27661) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6c706c030457ab8652059584e430dfa41237b409e8a000abe63215fe97f17a6f) #### sessions/chat/view/chatView/AssistantResponsePlain/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/972906133ac5e7f19b869699e61454ed66c25d822c0234140a0e863f9960e904) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/c28458ae356242437a0b80f123f1643a07402cd60ace9bb67066181408dc4217) #### sessions/chat/view/chatView/CheckpointControlsBackground/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/980d7d707ada56bad3e16303036ead3074447ce96a8c9d7fa459ae21b13ae6f3) From 0304d551655c94b8d21ae5bfb203d4a04e30484d Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 13:29:41 -0700 Subject: [PATCH 24/26] fix: preserve fixture scopes after upstream rebase Keep upstream nested-root color variable handling while limiting workbench theming participants to their fixture root. Adapt isolation regressions and restore two inline-chat baselines to verified upstream values. Validated 104 targeted tests and 33 connected fixture checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/componentFixtures/fixtureUtilsCss.test.ts | 8 ++++---- .../test/browser/componentFixtures/fixtureUtilsCss.ts | 2 +- test/componentFixtures/blocks-ci-screenshots.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts index 608bc52d24e8bb..6007340c115472 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts @@ -71,8 +71,8 @@ suite('Component fixture theme CSS', () => { const host = mainWindow.document.body.appendChild($('div')); disposables.add(toDisposable(() => host.remove())); - const actual = variants.map((variant, index) => { - const id = `${variant.selector} workbench-background-${index}`; + const actual = variants.map(variant => { + const id = `${variant.id} workbench-background`; const theme = ColorThemeData.createLoadedEmptyTheme(id, id); theme.setCustomColors({ 'editor.background': '#123456' }); mainWindow.document.adoptedStyleSheets = [ @@ -82,9 +82,9 @@ suite('Component fixture theme CSS', () => { const root = host.appendChild($('.monaco-workbench')); root.classList.add(...theme.classNames); const editor = root.appendChild($('.monaco-editor')); - editor.classList.add(variant.selector); + editor.classList.add(theme.classNames[0]); const scrollable = editor.appendChild($('.monaco-scrollable-element')); - scrollable.classList.add(variant.selector); + scrollable.classList.add(theme.classNames[0]); return [root, editor, scrollable].map(element => mainWindow.getComputedStyle(element).backgroundColor); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts index bf40a72b3f74d9..d44e04e7e9897f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts @@ -217,7 +217,7 @@ export function getThemeStyleSheet(theme: ColorThemeData): CSSStyleSheet { const css = generateColorThemeCSS( theme, scopeSelector, - [createScopedThemingParticipant(scopeSelector, '.monaco-workbench', themingParticipants)], + [createScopedThemingParticipant(themeScopeSelector, '.monaco-workbench', themingParticipants)], mockEnvironmentService ); sheet.replaceSync(css.code); diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index f1e9dae9caeb32..7d1f32fb5811e9 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -289,10 +289,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/dbcdd1e5ca6920f0f0b5fd6a97126465912cfa12d26007b125e831636f3c11c8) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d2fcbbf2c1f507f7bdb4292ed9ee4b43d899f05b7c24131cd7447e196ce3cf96) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/eb9fd939b61a2af6c9dcf90876b628ae3054b12e3526ae8834ddff6dff08986a) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b323ba1cc92f7280d3f71b09f28b83f937c659c923577c486fb00ad40c08d998) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/400740638fe5be5917dcd29cf8f5446ea36f0a32ba0dbd00a88d3327bd8adb07) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/cc1638d294072922eb21dd4f90435744db32b35e7bbdcc2f5cbcbbaf07ee1e36) From e3da5bd4e8b6a5f0cac8329da2c994c77ecc7dd9 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 13:34:45 -0700 Subject: [PATCH 25/26] fix: remove duplicate theme import after rebase Retain upstream's Light 2026 import and remove the duplicate introduced by replaying the theme-gallery commit. Client type checking passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index e2216831b04f61..43824144d410cc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -266,7 +266,6 @@ class NullStorageService implements IStorageService { // comments and trailing commas the way it does in the real product. /* eslint-disable local/code-import-patterns */ import dark_2026 from '../../../../../../extensions/theme-defaults/themes/2026-dark.json' with { type: 'json' }; -import light_2026 from '../../../../../../extensions/theme-defaults/themes/2026-light.json' with { type: 'json' }; import dark_modern from '../../../../../../extensions/theme-defaults/themes/dark_modern.json' with { type: 'json' }; import dark_plus from '../../../../../../extensions/theme-defaults/themes/dark_plus.json' with { type: 'json' }; import dark_vs from '../../../../../../extensions/theme-defaults/themes/dark_vs.json' with { type: 'json' }; From 9782d67b17909300fc031fdeba718a617e368a79 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Fri, 11 Sep 2026 14:18:20 -0700 Subject: [PATCH 26/26] fix: unify connected tab hover surfaces and address review feedback Keep upper-row action masks on the pill hover surface, preserve high-contrast empty and sticky boundaries, and correct row-end and sticky layering. Preserve complete initial filename graphemes and suffix styling, and prevent hidden close slots from intercepting pointer input. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 2 + .../base/browser/ui/iconLabel/iconlabel.css | 3 + .../browser/ui/iconLabel/iconLabel.test.ts | 42 ++++ .../parts/editor/multiEditorTabsControl.ts | 7 +- .../modernUI/browser/connectedEditorTabs.ts | 12 + .../browser/media/connectedEditorTabs.css | 41 +++- .../browser/modernUI.contribution.test.ts | 216 ++++++++++++++++++ .../editor/multiEditorTabsControl.test.ts | 46 ++++ 8 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 src/vs/base/test/browser/ui/iconLabel/iconLabel.test.ts diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 061e856ff28e74..c04dc7d8efc468 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1082,8 +1082,10 @@ "--modern-ui-connected-tab-gutter", "--modern-ui-connected-tab-radius", "--modern-ui-connected-tab-shoulder-radius", + "--modern-ui-connected-tab-strip-background", "--modern-ui-connected-tab-surface", "--modern-ui-connected-tab-top-inset", + "--modern-ui-connected-tab-upper-hover-background", "--modern-ui-connected-well-border", "--modern-ui-editor-border-color", "--modern-ui-editor-tab-action-active-background", diff --git a/src/vs/base/browser/ui/iconLabel/iconlabel.css b/src/vs/base/browser/ui/iconLabel/iconlabel.css index e82cd75fcfcd6c..84f4f31f5d321b 100644 --- a/src/vs/base/browser/ui/iconLabel/iconlabel.css +++ b/src/vs/base/browser/ui/iconLabel/iconlabel.css @@ -79,11 +79,13 @@ } .monaco-icon-label.bold > .monaco-icon-label-container > .monaco-icon-name-container > .label-name, +.monaco-icon-label.bold > .monaco-icon-label-container > .monaco-icon-suffix-container > .label-suffix, .monaco-icon-label.bold > .monaco-icon-label-container > .monaco-icon-description-container > .label-description { font-weight: bold; } .monaco-icon-label.italic > .monaco-icon-label-container > .monaco-icon-name-container > .label-name, +.monaco-icon-label.italic > .monaco-icon-label-container > .monaco-icon-suffix-container > .label-suffix, .monaco-icon-label.italic > .monaco-icon-label-container > .monaco-icon-description-container > .label-description { font-style: italic; } @@ -94,6 +96,7 @@ } .monaco-icon-label.strikethrough > .monaco-icon-label-container > .monaco-icon-name-container > .label-name, +.monaco-icon-label.strikethrough > .monaco-icon-label-container > .monaco-icon-suffix-container > .label-suffix, .monaco-icon-label.strikethrough > .monaco-icon-label-container > .monaco-icon-description-container > .label-description { text-decoration: line-through; } diff --git a/src/vs/base/test/browser/ui/iconLabel/iconLabel.test.ts b/src/vs/base/test/browser/ui/iconLabel/iconLabel.test.ts new file mode 100644 index 00000000000000..50d8f2dee8cd9d --- /dev/null +++ b/src/vs/base/test/browser/ui/iconLabel/iconLabel.test.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { $ } from '../../../../browser/dom.js'; +import { IconLabel } from '../../../../browser/ui/iconLabel/iconLabel.js'; +import { mainWindow } from '../../../../browser/window.js'; +import { toDisposable } from '../../../../common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; + +suite('IconLabel', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const supportHighlights of [false, true]) { + test(`name, suffix and description share state styles (highlights: ${supportHighlights})`, () => { + const container = $('.icon-label-test'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + const label = disposables.add(new IconLabel(container, { supportHighlights, supportDescriptionHighlights: supportHighlights })); + const states = []; + for (const options of [{ italic: true }, { strikethrough: true }, { bold: true }, {}]) { + label.setLabel('filename', 'description', { ...options, suffix: '.ts' }); + states.push(['.label-name', '.label-suffix', '.label-description'].map(selector => { + const style = mainWindow.getComputedStyle(container.querySelector(selector)!); + return { + italic: style.fontStyle === 'italic', + strikethrough: style.textDecorationLine === 'line-through', + bold: Number(style.fontWeight) >= 600, + }; + })); + } + assert.deepStrictEqual(states, [ + Array(3).fill({ italic: true, strikethrough: false, bold: false }), + Array(3).fill({ italic: false, strikethrough: true, bold: false }), + Array(3).fill({ italic: false, strikethrough: false, bold: true }), + Array(3).fill({ italic: false, strikethrough: false, bold: false }), + ]); + }); + } +}); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index ad9b340f3eba65..5b0453c1188d17 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -58,6 +58,7 @@ import { IReadonlyEditorGroupModel } from '../../../common/editor/editorGroupMod import { IHostService } from '../../../services/host/browser/host.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; +import { nextCharLength } from '../../../../base/common/strings.js'; interface IEditorInputLabel { readonly editor: EditorInput; @@ -2113,10 +2114,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { const suffix = label.querySelector('.label-suffix'); const style = getWindow(tab).getComputedStyle(tab); const prefix = this.groupsView.partOptions.showTabIndex ? `${this.toEditorIndex(index) + 1}: ` : ''; - const firstNameCharacter = Array.from((name.textContent ?? '').substring(prefix.length))[0] ?? ''; + const nameText = (name.textContent ?? '').substring(prefix.length); + const firstGraphemeOffset = nameText.startsWith('.') ? 1 : 0; + const firstNameGrapheme = nameText.substring(0, firstGraphemeOffset + nextCharLength(nameText, firstGraphemeOffset)); // The ellipsis belongs to the overflow container, not the label anchor. // Measure each separately: their fonts can differ, and suffixes cannot kern across elements. - const minimumNameWidth = this.measureConnectedTabText(`${prefix}${firstNameCharacter}`, name) + const minimumNameWidth = this.measureConnectedTabText(`${prefix}${firstNameGrapheme}`, name) + this.measureConnectedTabText('…', name.parentElement!); const nameWidth = minimumNameWidth + (suffix ? this.measureConnectedTabText(suffix.textContent ?? '', suffix) : 0); const badge = label.classList.contains('monaco-decoration-badge') ? getWindow(label).getComputedStyle(label, '::after') : undefined; diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts index b72b3edb7966a6..df9d6fd0ba8507 100644 --- a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -5,6 +5,7 @@ import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { EDITOR_GROUP_HEADER_TABS_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND } from '../../../common/theme.js'; import './media/connectedEditorTabs.css'; registerThemingParticipant((theme, collector) => { @@ -12,4 +13,15 @@ registerThemingParticipant((theme, collector) => { if (background) { collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-surface: ${background}; }`); } + + const stripColor = theme.getColor(EDITOR_GROUP_HEADER_TABS_BACKGROUND) ?? background; + const stripBackground = background && stripColor ? stripColor.makeOpaque(background) : stripColor; + if (stripBackground) { + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-strip-background: ${stripBackground}; }`); + } + const hoverBackground = theme.getColor(MODERN_EDITOR_TAB_HOVER_BACKGROUND); + if (stripBackground && hoverBackground) { + // Flatten against the strip, not the document, so the action mask and pill paint one surface. + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-upper-hover-background: ${hoverBackground.makeOpaque(stripBackground)}; }`); + } }); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index bc0fe73a7a0f36..55933caa1a4cb5 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -25,6 +25,20 @@ --modern-ui-connected-tab-border: var(--vscode-focusBorder); } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor.editor-tabs-multiple:not(.modal-editor-part) > .content.empty .editor-group-container.empty { + position: relative; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor.editor-tabs-multiple:not(.modal-editor-part) > .content.empty .editor-group-container.empty::after { + content: ''; + position: absolute; + inset: 0; + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-well-border); + box-sizing: border-box; + pointer-events: none; + z-index: 10; +} + /* Reserve the same inside stroke in every theme without changing the editor's layout box. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs ~ .editor-container, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs { @@ -58,12 +72,13 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { - --modern-ui-editor-tab-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); - --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); - --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editorGroupHeader-tabsBackground)); + --modern-ui-editor-tab-inactive-background: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)); + --modern-ui-editor-tab-unfocused-inactive-background: var(--modern-ui-editor-tab-inactive-background); + --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground))); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-connected-tab-action-background: var(--modern-ui-editor-tab-inactive-background); --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); --modern-ui-connected-tab-shoulder-radius: var(--modern-ui-connected-tab-cap-radius); @@ -73,7 +88,7 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { - background-color: var(--vscode-editorGroupHeader-tabsBackground); + background-color: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)); padding-left: 0; } @@ -89,7 +104,11 @@ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { - background-color: var(--vscode-editorGroupHeader-tabsBackground); + background-color: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container:not(.disable-sticky-tabs) > .tab.active.sticky:not(.connected-tab-upper-row):is(.sticky-compact, .sticky-shrink) { + z-index: 9; } /* Occlude scrolling connected fills beneath sticky tabs while preserving the final separator row. */ @@ -342,6 +361,10 @@ opacity: 0; } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.dirty):not(:hover):not(.sticky):not(:focus-within) > .tab-actions { + pointer-events: none; +} + .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.close-action-off) > .tab-actions .action-label, .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.dirty > .tab-actions .action-label { opacity: 1; @@ -356,13 +379,13 @@ } /* Terminal shoulders turn inside the available slot rather than being cut off. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:is(:last-child, .last-in-row, .connected-tab-right-edge) > :is(.tab-fill, .tab-connected-edge) { +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row):is(:last-child, .last-in-row, .connected-tab-right-edge) > :is(.tab-fill, .tab-connected-edge) { right: var(--modern-ui-connected-tab-shoulder-radius); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { width: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--modern-ui-connected-tab-cap-radius)); - background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)); + background: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background))); border: 0; } @@ -404,8 +427,10 @@ /* Rows above the document retain the original control-tier pills. */ .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row { - --modern-ui-editor-tab-hover-background: var(--vscode-modernEditorTab-hoverBackground); + --modern-ui-editor-tab-hover-background: var(--modern-ui-connected-tab-upper-hover-background); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill { diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 76f6b20af85cfc..3768d813f39334 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -1936,6 +1936,222 @@ suite('ModernUIContribution', () => { ]); }); + test('matches upper-row tab action backgrounds to the composited pill surface', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container wrapping'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab connected-tab-upper-row'); + const fill = appendElement(tab, 'tab-fill'); + const hoverProbe = appendElement(tab, 'hover-color-probe'); + hoverProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-hover-background)'; + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + action.focus(); + const targetWindow = getWindow(root); + + for (const { themeId, strip, hover, expected } of [ + { themeId: 'vs-dark', strip: '#202020', hover: '#ffffff80', expected: 'rgb(143, 143, 143)' }, + { themeId: 'vs', strip: '#eeeeee', hover: '#00000080', expected: 'rgb(118, 118, 118)' }, + { themeId: 'vs', strip: '#eeeeee', hover: '#2468ac', expected: 'rgb(36, 104, 172)' }, + { themeId: 'vs-dark', strip: '#202020', hover: '#ffffff00', expected: 'rgb(32, 32, 32)' }, + { themeId: 'hc-black', strip: '#000000', hover: '#ffffff', expected: 'rgb(255, 255, 255)' }, + { themeId: 'hc-light', strip: '#ffffff', hover: '#000000', expected: 'rgb(0, 0, 0)' }, + ]) { + const theme = ColorThemeData.createUnloadedTheme(themeId, { + [editorBackground]: '#112233', + 'editorGroupHeader.tabsBackground': strip, + [MODERN_EDITOR_TAB_HOVER_BACKGROUND]: hover, + }); + style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + const highContrast = themeId.startsWith('hc-'); + for (const compact of [false, true]) { + root.className = `monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs ${themeId}`; + title.classList.toggle('compact-height', compact); + for (const focusedGroup of [false, true]) { + group.classList.toggle('active', focusedGroup); + for (const actionsLeft of [false, true]) { + tab.classList.toggle('tab-actions-left', actionsLeft); + for (const active of [false, true]) { + tab.classList.toggle('active', active); + const stripColor = targetWindow.getComputedStyle(row).backgroundColor; + assert.deepStrictEqual({ + fill: targetWindow.getComputedStyle(fill).backgroundColor, + hover: targetWindow.getComputedStyle(hoverProbe).backgroundColor, + actions: targetWindow.getComputedStyle(actions).backgroundColor, + }, { + fill: active ? 'rgb(17, 34, 51)' : highContrast ? 'rgba(0, 0, 0, 0)' : stripColor, + hover: expected, + actions: active ? 'rgb(17, 34, 51)' : highContrast ? stripColor : expected, + }, `${themeId}, ${hover}, compact: ${compact}, focused group: ${focusedGroup}, actions left: ${actionsLeft}, active: ${active}`); + } + } + } + } + } + }); + + test('composites translucent connected strip colors only once', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + const fill = appendElement(tab, 'tab-fill'); + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + const stickyMask = appendElement(row, 'sticky-tabs-background'); + const addTab = appendElement(tabs, 'tabs-bar-add-tab'); + const targetWindow = getWindow(root); + for (const [strip, expected] of [ + ['#ffffff80', 'rgb(143, 143, 143)'], + ['#ffffff00', 'rgb(32, 32, 32)'], + ['#abcdef', 'rgb(171, 205, 239)'], + [undefined, 'rgb(32, 32, 32)'], + ]) { + for (const themeId of strip ? ['vs-dark', 'vs', 'hc-black', 'hc-light'] : ['hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs ${themeId}`; + const theme = ColorThemeData.createUnloadedTheme(themeId, { + [editorBackground]: '#202020', + ...(strip ? { 'editorGroupHeader.tabsBackground': strip } : {}), + }); + style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + action.focus(); + const highContrast = themeId.startsWith('hc-'); + assert.deepStrictEqual({ + strip: [row, stickyMask, addTab].map(element => targetWindow.getComputedStyle(element).backgroundColor), + fill: targetWindow.getComputedStyle(fill).backgroundColor, + actionMask: highContrast ? targetWindow.getComputedStyle(actions).backgroundColor : undefined, + }, { + strip: [expected, expected, expected], + fill: highContrast ? 'rgba(0, 0, 0, 0)' : expected, + actionMask: highContrast ? expected : undefined, + }, `${themeId}, ${strip}`); + } + } + }); + + test('retains the empty root connected editor frame in high contrast', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = '--vscode-strokeThickness: 1px; --vscode-focusBorder: #ffaa00; --vscode-contrastBorder: #00aaff;'; + const editor = appendElement(root, 'part editor editor-tabs-multiple'); + const content = appendElement(editor, 'content empty'); + const group = appendElement(content, 'editor-group-container empty active'); + const title = appendElement(group, 'title tabs'); + const body = appendElement(group, 'editor-container'); + const targetWindow = getWindow(root); + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs ${theme}`; + for (const floating of [false, true]) { + root.classList.toggle('floating-panels', floating); + for (const empty of [false, true, false]) { + content.classList.toggle('empty', empty); + group.classList.toggle('empty', empty); + const frame = targetWindow.getComputedStyle(group, '::after'); + const hasFrame = empty && theme.startsWith('hc-'); + assert.deepStrictEqual({ + frame: frame.content, + border: hasFrame ? [frame.borderTopWidth, frame.borderTopColor, frame.pointerEvents] : undefined, + hiddenHosts: empty ? [title, body].map(element => targetWindow.getComputedStyle(element).display) : undefined, + }, { + frame: hasFrame ? '""' : 'none', + border: hasFrame ? ['1px', 'rgb(255, 170, 0)', 'none'] : undefined, + hiddenHosts: empty ? ['none', 'none'] : undefined, + }, `${theme}, floating: ${floating}, empty: ${empty}`); + } + } + } + }); + + test('keeps upper-row terminal pills symmetric in wrapped and separate pinned rows', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-cornerRadius-small: 4px; --vscode-strokeThickness: 1px;'; + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab active connected-tab-upper-row'); + const fill = appendElement(tab, 'tab-fill'); + for (const pinnedRow of [false, true]) { + title.classList.toggle('two-tab-bars', pinnedRow); + row.classList.toggle('wrapping', !pinnedRow); + for (const rowEnd of ['', 'last-in-row', 'connected-tab-right-edge']) { + tab.className = `tab active connected-tab-upper-row ${rowEnd}`; + const style = getWindow(fill).getComputedStyle(fill); + assert.deepStrictEqual([style.left, style.right], ['2px', '2px'], `pinned row: ${pinnedRow}, row end: ${rowEnd}`); + } + } + }); + + test('hidden connected close slots do not intercept pointer input', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs tab-actions-reserve-space'); + const tabs = appendElement(title, 'tabs-container'); + const tab = appendElement(tabs, 'tab sizing-fit'); + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + const targetWindow = getWindow(root); + for (const state of ['', 'active', 'dirty', 'sticky', 'focus', '']) { + action.blur(); + tab.className = `tab sizing-fit ${state === 'focus' ? '' : state}`; + if (state === 'focus') { + action.focus(); + } + assert.deepStrictEqual([targetWindow.getComputedStyle(actions).pointerEvents, targetWindow.getComputedStyle(action).pointerEvents], + Array(2).fill(state ? 'auto' : 'none'), state || 'hidden'); + } + }); + + test('raises active connected sticky shoulders above peers but below scrollbars', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const scrollable = appendElement(row, 'monaco-scrollable-element'); + const scrollbar = appendElement(scrollable, 'scrollbar'); + const tabs = appendElement(scrollable, 'tabs-container'); + const active = appendElement(tabs, 'tab'); + const peer = appendElement(tabs, 'tab'); + const targetWindow = getWindow(root); + for (const sizing of ['fit', 'shrink', 'fixed']) { + for (const sticky of ['compact', 'shrink']) { + active.className = `tab active sticky sizing-${sizing} sticky-${sticky}`; + peer.className = `tab sticky sizing-${sizing} sticky-${sticky}`; + for (const disabled of [false, true]) { + tabs.classList.toggle('disable-sticky-tabs', disabled); + assert.deepStrictEqual([active, peer, scrollbar].map(element => targetWindow.getComputedStyle(element).zIndex), + [disabled ? 'auto' : '9', disabled ? 'auto' : '8', '11']); + } + } + } + }); + test('preserves a single HC frame in every tab mode without changing the outer card geometry', () => { const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs floating-panels'); store.add(toDisposable(() => root.remove())); diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 11936e5a03a45d..97b5400cb6b5c7 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -208,6 +208,52 @@ suite('MultiEditorTabsControl', () => { assert.deepStrictEqual([name.textContent, suffix.textContent, tab.style.getPropertyValue('--connected-tab-min-width')], ['1: file0.txt', '', '']); }); + for (const showTabIndex of [false, true]) { + test(`connected compression preserves initial graphemes and dotfile prefixes (index: ${showTabIndex})`, async () => { + const group = connectedGroup(); + const cases = [ + { name: 'e\u0301xample.txt', initial: 'e\u0301' }, + { name: '\u0915\u093fname.txt', initial: '\u0915\u093f' }, + { name: '😀example.txt', initial: '😀' }, + { name: '👍🏽example.txt', initial: '👍🏽' }, + { name: '👩🏽‍💻example.txt', initial: '👩🏽‍💻' }, + { name: '🇫🇷example.txt', initial: '🇫🇷' }, + { name: '.env', initial: '.e' }, + { name: '.gitignore', initial: '.g' }, + { name: '.👩🏽‍💻example.txt', initial: '.👩🏽‍💻' }, + ]; + for (const { name } of cases) { + const editor = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return name; } + }(URI.file(`/path/${name}`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: false, index: model.count }); + } + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const oldOptions = partOptions; + partOptions = { ...partOptions, showTabIndex, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, editorActionsLocation: 'hidden', hasIcons: false }; + control.updateOptions(oldOptions, partOptions); + await layoutConnectedGroup(group, 260); + const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')).slice(2); + const context = $('canvas').getContext('2d')!; + const results = cases.map(({ name, initial }, index) => { + const tab = tabs[index]; + const labelName = tab.querySelector('.label-name')!; + const nameContainer = labelName.parentElement!; + const prefix = showTabIndex ? `${index + 3}: ` : ''; + context.font = mainWindow.getComputedStyle(labelName).font; + const initialWidth = context.measureText(`${prefix}${initial}`).width; + context.font = mainWindow.getComputedStyle(nameContainer).font; + const minimumNameWidth = Math.ceil(initialWidth + context.measureText('…').width); + return { + name, + measuredInitial: parseFloat(tab.style.getPropertyValue('--connected-tab-min-name-width')) === minimumNameWidth, + initialVisible: nameContainer.clientWidth >= minimumNameWidth, + }; + }); + assert.deepStrictEqual(results, cases.map(({ name }) => ({ name, measuredInitial: true, initialVisible: true }))); + }); + } + test('connected shrink tabs collapse and restore icons as the editor width changes', async () => { const group = connectedGroup(); const iconStyle = document.createElement('style');