diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 923634e83abcc..c1357eeaf6f53 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -435,6 +435,7 @@ --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-tab-action-padding: calc(var(--vscode-spacing-size280) + var(--modern-ui-connected-tab-shoulder-radius) + var(--vscode-spacing-size20)); } .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 { @@ -447,6 +448,14 @@ inset-block: calc(-1 * var(--vscode-spacing-size20)); } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { + right: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--vscode-spacing-size20)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact).tab-actions-left > .tab-actions { + left: calc(var(--modern-ui-connected-tab-shoulder-radius) + 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); } 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 f466022eba202..e09af4300f217 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -20,6 +20,10 @@ background-color: var(--modern-ui-editor-tab-active-hover-background); } +.monaco-workbench .monaco-action-bar:not(.vertical) .action-label.fixture-action-hover:not(.disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} + .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.fixture-hover:not(.close-action-off) > .tab-actions { background-color: var(--modern-ui-editor-tab-action-active-hover-background); } 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 ee84e4381c980..e722aa3b62840 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -371,6 +371,7 @@ export interface IEditorTabBarFixtureOptions { readonly headerMenuIds?: IEditorGroupMenuIds; readonly colorCustomizations?: Readonly>; readonly forcedHoverTab?: number; + readonly forcedHoverTabAction?: number; readonly focusedTabAction?: number; readonly editorContents?: string; readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; @@ -591,6 +592,9 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: if (options.forcedHoverTab !== undefined) { tabs[options.forcedHoverTab]?.classList.add('fixture-hover'); } + if (options.forcedHoverTabAction !== undefined) { + tabs[options.forcedHoverTabAction]?.querySelector('.tab-actions .action-label')?.classList.add('fixture-action-hover'); + } if (options.focusedTabAction !== undefined) { const action = tabs[options.focusedTabAction]?.querySelector('.tab-actions .action-label'); if (action) { @@ -826,6 +830,15 @@ function renderWrappedConnectedSurface(activeTabIndex: number, forcedHoverTab?: }); } +function renderWrappedConnectedCloseActionHover(activeTabIndex: number): (ctx: ComponentFixtureContext) => void { + return renderConnectedSurface(activeTabIndex, activeTabIndex, undefined, { + width: 820, + editors: manyEditorSpecs().slice(0, 10).map((spec, index) => ({ ...spec, dirty: false, active: index <= activeTabIndex })), + partOptions: { wrapTabs: true, editorActionsLocation: 'hidden' }, + forcedHoverTabAction: activeTabIndex, + }); +} + const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { @@ -863,6 +876,34 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { }), expectedVisualDescriptions: ['Hover reveals the inactive close without covering its ellipsized basename or extension.'], }), + SingleTab: defineComponentFixture({ + render: renderConnectedSurface(0, undefined, undefined, { + editors: [{ resource: file('/project/README.md'), pinned: true, active: true }], + partOptions: { editorActionsLocation: 'hidden' }, + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The single connected tab retains the same close-button spacing and visible cap width as an active tab beside another tab. Its terminal shoulder turns into the document well without crowding the action.'], + }), + SingleTabCloseActionHovered: defineComponentFixture({ + render: renderConnectedSurface(0, 0, undefined, { + editors: [{ resource: file('/project/README.md'), pinned: true, active: true }], + partOptions: { editorActionsLocation: 'hidden' }, + forcedHoverTabAction: 0, + }), + expectedVisualDescriptions: ['The close action hover background has even inset spacing on every side and remains separated from the single tab terminal shoulder.'], + }), + LastTabCloseActionHovered: defineComponentFixture({ + render: renderConnectedSurface(3, 3, undefined, { + editors: [ + { resource: file('/project/README.md'), pinned: true }, + { resource: file('/project/src/app/main.ts'), pinned: true }, + { resource: file('/project/src/app/styles.css'), pinned: true }, + { resource: file('/project/package.json'), pinned: true, active: true }, + ], + forcedHoverTabAction: 3, + }), + expectedVisualDescriptions: ['The last tab close action hover background has the same even inset spacing as the single-tab state and remains separated from the terminal shoulder.'], + }), NarrowWindow: defineComponentFixture({ render: renderConnectedSurface(0, undefined, undefined, { width: 420, @@ -882,6 +923,14 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 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.'], }), + UpperWrappedCloseActionHovered: defineComponentFixture({ + render: renderWrappedConnectedCloseActionHover(0), + expectedVisualDescriptions: ['The upper-row pill close action hover background has even inset spacing on every side and remains separated from the pill edge.'], + }), + BottomWrappedCloseActionHovered: defineComponentFixture({ + render: renderWrappedConnectedCloseActionHover(9), + expectedVisualDescriptions: ['The bottom-row connected tab close action hover background has even inset spacing on every side and remains separated from the terminal shoulder.'], + }), UpperWrappedHover: defineComponentFixture({ render: renderWrappedConnectedSurface(9, 1), additionalThemes: connectedSurfaceThemes, 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 f1a1b16a5b854..ef44228758d84 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -349,6 +349,91 @@ suite('MultiEditorTabsControl', () => { assert.deepStrictEqual({ clean, dirty, focused }, { clean: ['1', '0'], dirty: ['1', '1'], focused: ['1', '1'] }); }); + test('connected close actions keep consistent spacing across terminal and wrapped tabs', async () => { + const group = connectedGroup(); + const measure = () => { + const tab = container.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const action = tab.querySelector('.action-label')!; + const label = tab.querySelector('.monaco-icon-label-container')!; + const fillBounds = fill.getBoundingClientRect(); + const actionBounds = action.getBoundingClientRect(); + const actionStyle = mainWindow.getComputedStyle(action); + return { + top: actionBounds.top - fillBounds.top, + right: fillBounds.right - actionBounds.right, + left: actionBounds.left - label.getBoundingClientRect().right, + width: fillBounds.width, + padding: [actionStyle.paddingTop, actionStyle.paddingRight, actionStyle.paddingBottom, actionStyle.paddingLeft], + }; + }; + + await layoutConnectedGroup(group, 400); + const multiple = measure(); + + const secondEditor = model.getEditorByIndex(1)!; + model.closeEditor(secondEditor); + control.closeEditor(secondEditor); + await layoutConnectedGroup(group, 400); + const single = measure(); + + model.openEditor(secondEditor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + await layoutConnectedGroup(group, 150); + const wrappedBottom = measure(); + + model.openEditor(model.getEditorByIndex(0)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 150); + const wrappedUpper = measure(); + const measurements = [multiple, single, wrappedBottom, wrappedUpper]; + + const oldWrappedOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: false, tabSizing: 'fit', tabActionLocation: 'left' }; + control.updateOptions(oldWrappedOptions, partOptions); + await layoutConnectedGroup(group, 400); + const leftMultiple = measure(); + + model.closeEditor(secondEditor); + control.closeEditor(secondEditor); + await layoutConnectedGroup(group, 400); + const leftSingle = measure(); + + assert.deepStrictEqual({ + single: { + top: single.top === multiple.top, + right: single.right === multiple.right, + left: single.left === multiple.left, + width: single.width === multiple.width, + }, + wrapped: { + top: wrappedBottom.top === wrappedUpper.top, + right: wrappedBottom.right === wrappedUpper.right, + left: wrappedBottom.left === wrappedUpper.left, + }, + horizontal: { + right: measurements.every(measurement => measurement.right === multiple.right), + left: measurements.every(measurement => measurement.left === multiple.left), + }, + leftAction: { + top: leftSingle.top === leftMultiple.top, + right: leftSingle.right === leftMultiple.right, + left: leftSingle.left === leftMultiple.left, + width: leftSingle.width === leftMultiple.width, + }, + actionPadding: measurements.every(measurement => new Set(measurement.padding).size === 1 && measurement.padding[0] === multiple.padding[0]), + }, { + single: { top: true, right: true, left: true, width: true }, + wrapped: { top: true, right: true, left: true }, + horizontal: { right: true, left: true }, + leftAction: { top: true, right: true, left: true, width: true }, + actionPadding: true, + }); + }); + test('reveals the active tab with its right shoulder outside the label and action', async () => { const group = connectedGroup(); const oldOptions = partOptions;