From 19f802def12946481ba51c8fa2da777a66329be4 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Wed, 30 Sep 2026 17:54:02 -0700 Subject: [PATCH 1/4] editor: align connected tab geometry across wrapped states Share separator, shoulder, and action geometry across single-row, wrapped, and pinned tabs while preserving pill and legacy behavior. Add regression coverage and fixture matrices for themes, tab styles, action locations, and pinned layouts.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 2 + .../browser/media/connectedEditorTabs.css | 77 ++++++++--- .../browser/modernUI.contribution.test.ts | 69 +++++++--- .../editor/editorTabBar.fixture.css | 10 ++ .../editor/editorTabBar.fixture.ts | 80 ++++++++++- .../editor/multiEditorTabsControl.test.ts | 128 ++++++++++++++++-- 6 files changed, 307 insertions(+), 59 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index d315c8f77e69e6..b9fa3763b0b333 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2104,6 +2104,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { tab.classList.toggle('connected-tab-upper-row', connected && (upperTabBar || tab.offsetTop !== bottom)); tab.classList.toggle('connected-tab-top-row', connected && topTabBar && tab.offsetTop === top); }); + this.parent.classList.toggle('connected-tabs-wrapping', connected && tabsWrapMultiLine); if (!tabsWrapMultiLine) { this.doLayoutTabsNonWrapping(options); } else { @@ -2378,6 +2379,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { tabsContainer.classList.remove('disable-sticky-tabs'); } assertReturnsDefined(this.stickyTabsBackground).style.width = `${stickyTabsWidth}px`; + this.connectedTabOverflowEdge?.classList.toggle('connected-tab-adjacent-sticky', stickyTabsWidth > 0); this.clearConnectedTabClipping(); const activeTabFill = activeTab?.firstElementChild; diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 27a566d256b49a..646d6dc0edae7e 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -87,7 +87,7 @@ --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-unfocused-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-radius: var(--vscode-cornerRadius-medium, 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); --modern-ui-connected-tab-action-inset: calc((var(--editor-group-tab-height) - var(--vscode-spacing-size200)) / 2 + var(--vscode-spacing-size40)); @@ -132,6 +132,10 @@ padding-bottom: var(--vscode-strokeThickness); } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container .tabs-container > .tab.connected-tab-upper-row { + border-block-width: 0; +} + /* Bottom rows occupy the strip directly so their edges cannot round apart from compensating pill gutters at fractional zoom. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.connected-tab-upper-row), .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab { @@ -154,10 +158,12 @@ z-index: 9; } -/* Occlude scrolling connected fills beneath sticky tabs while preserving the final separator row. */ +/* Occlude scrolling fills completely; this layer owns the separator beneath sticky tabs. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background { - bottom: var(--vscode-strokeThickness); + bottom: 0; height: auto; + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + box-sizing: border-box; } /* Keep the separator underneath the active fill without changing the tab, @@ -167,16 +173,26 @@ background-color: var(--modern-ui-connected-tab-border); } -/* Wrapped fills occupy their row's hit box, without an extra gutter below the final row. */ +/* Wrapped scrolling does not add the pill-style bottom gutter. ScrollableElement writes overflow + * inline, so the connected shoulders must explicitly escape both clipping ancestors. */ .modern-ui-tabs.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; + overflow: visible !important; +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { + padding-top: var(--vscode-spacing-size20); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.connected-tabs-wrapping { + overflow: visible; } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container { --modern-ui-connected-tab-action-inset: calc((var(--editor-group-tab-height) - var(--vscode-spacing-size200)) / 2 + var(--vscode-spacing-size20)); --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset)); - padding-bottom: 0; row-gap: var(--vscode-spacing-size20); + overflow: visible !important; } /* Preserve the existing explicit selection and focus borders in high contrast. */ @@ -229,6 +245,11 @@ 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-current-background); } +.modern-ui-tabs.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-tabs.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 { + border-bottom-color: transparent; +} + .modern-ui-tabs.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-tabs.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; @@ -257,7 +278,7 @@ position: absolute; bottom: 0; width: var(--vscode-strokeThickness); - height: calc(var(--modern-ui-connected-tab-shoulder-radius) - var(--vscode-strokeThickness) * 2); + height: calc(var(--modern-ui-connected-tab-shoulder-radius) - var(--vscode-strokeThickness)); box-sizing: border-box; background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--modern-ui-connected-tab-current-background) var(--vscode-strokeThickness)); } @@ -317,6 +338,11 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab-strip .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-tabs.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-adjacent-sticky.connected-tab-left-clipped::before { + border-left: 0; } .modern-ui-tabs.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 { @@ -541,37 +567,40 @@ 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-active-hover-background); } -/* Rows above the document retain the original control-tier pills. */ +/* Rows above the document retain separate rounded pills. */ .modern-ui-tabs.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(--modern-ui-editor-tab-custom-hover-background, var(--modern-ui-connected-tab-upper-hover-background)); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-custom-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-unfocused-hover-background); -} - -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row { - --modern-ui-connected-tab-action-inset: calc((var(--editor-group-tab-height) - var(--vscode-spacing-size200)) / 2); --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset) + var(--vscode-spacing-size20)); } .modern-ui-tabs.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; + border-radius: var(--modern-ui-connected-tab-radius); + border: var(--vscode-strokeThickness) solid transparent; + background-clip: padding-box; } .modern-ui-tabs.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)); - inset-inline: 0; + inset-inline: var(--vscode-strokeThickness); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab:first-child.connected-tab-upper-row > .tab-fill { + left: var(--vscode-spacing-size20); +} + +.modern-ui-tabs.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.last-in-row > .tab-fill { + right: var(--vscode-spacing-size20); } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-top-row:not(.connected-tab-upper-row) > .tab-fill { border-top-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:first-child:not(.connected-tab-upper-row) > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab:first-child > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab > .tab-fill { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab:not(.connected-tab-upper-row) > .tab-fill { left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; @@ -579,11 +608,19 @@ border-left-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:first-child:not(.connected-tab-upper-row) > .tab-fill { + left: 0; + border-top-left-radius: var(--vscode-cornerRadius-large); + border-bottom-left-radius: 0; + border-left-width: 0; + border-left-color: transparent; +} + +.modern-ui-tabs.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: var(--modern-ui-connected-tab-action-inset); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.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: var(--modern-ui-connected-tab-action-inset); } @@ -601,5 +638,5 @@ } .modern-ui-tabs.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); + border-radius: var(--modern-ui-connected-tab-radius); } 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 b9a14a128a4f9c..82579a3f78af18 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 @@ -2511,7 +2511,7 @@ suite('ModernUIContribution', () => { } }); - test('removes wrapped upper-row gutters while preserving separate pinned-row pills', () => { + test('preserves upper-row pill gutters for wrapped and 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;'; @@ -2529,7 +2529,11 @@ suite('ModernUIContribution', () => { 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], pinnedRow ? ['2px', '2px'] : ['0px', '0px'], `pinned row: ${pinnedRow}, row end: ${rowEnd}`); + assert.deepStrictEqual( + [style.left, style.right], + pinnedRow ? ['2px', '2px'] : ['2px', rowEnd === 'last-in-row' ? '2px' : '1px'], + `pinned row: ${pinnedRow}, row end: ${rowEnd}` + ); } } }); @@ -2848,7 +2852,9 @@ suite('ModernUIContribution', () => { 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-cornerRadius-medium', '6px'); root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--vscode-focusBorder', '#ffaa00'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); @@ -2890,19 +2896,20 @@ suite('ModernUIContribution', () => { firstTab: ['none', '""'], middleTab: { shoulders: ['""', '""'], - capRadii: ['5px', '5px'], + capRadii: ['7px', '7px'], backgroundClip: 'padding-box', - radii: ['5px', '5px'], - sizes: [['5px', '5px'], ['5px', '5px']], + radii: ['7px', '7px'], + sizes: [['7px', '7px'], ['7px', '7px']], }, }); }); - test('uses direct strip geometry for both connected and upper wrapped rows', () => { + test('shares connected geometry between single-row and bottom wrapped tabs', () => { 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'); + root.style.setProperty('--vscode-cornerRadius-medium', '6px'); root.style.setProperty('--vscode-strokeThickness', '1px'); document.body.appendChild(root); store.add(toDisposable(() => root.remove())); @@ -2910,6 +2917,13 @@ suite('ModernUIContribution', () => { const editor = appendElement(root, 'part editor'); const content = appendElement(editor, 'content'); const group = appendElement(content, 'editor-group-container active'); + const singleTitle = appendElement(group, 'title tabs'); + const singleRow = appendElement(singleTitle, 'tabs-and-actions-container'); + const singleTabs = appendElement(singleRow, 'tabs-container'); + const inactiveSingleTab = appendElement(singleTabs, 'tab'); + const inactiveSingleFill = appendElement(inactiveSingleTab, 'tab-fill'); + const activeSingleTab = appendElement(singleTabs, 'tab active'); + const activeSingleFill = appendElement(activeSingleTab, 'tab-fill'); const title = appendElement(group, 'title tabs'); const row = appendElement(title, 'tabs-and-actions-container wrapping'); const tabs = appendElement(row, 'tabs-container'); @@ -2932,19 +2946,36 @@ suite('ModernUIContribution', () => { assert.deepStrictEqual({ rowPaddingTop: targetWindow.getComputedStyle(row).paddingTop, + separatorSlots: [targetWindow.getComputedStyle(singleTabs).paddingBottom, targetWindow.getComputedStyle(tabs).paddingBottom], + separatorOffsets: [targetWindow.getComputedStyle(singleRow, '::after').bottom, targetWindow.getComputedStyle(row, '::after').bottom], topFrame: { borderColors: [targetWindow.getComputedStyle(upperFill).borderTopColor, targetWindow.getComputedStyle(upperFill).borderLeftColor], borderTopLeftRadius: targetWindow.getComputedStyle(upperFill).borderTopLeftRadius, + borderWidths: [targetWindow.getComputedStyle(upperFill).borderTopWidth, targetWindow.getComputedStyle(upperFill).borderLeftWidth], }, upper: geometry(upperTab, upperFill), - inactiveBottom: geometry(inactiveBottomTab, inactiveBottomFill), - activeBottom: geometry(activeBottomTab, activeBottomFill), + inactive: { + single: geometry(inactiveSingleTab, inactiveSingleFill), + wrappedBottom: geometry(inactiveBottomTab, inactiveBottomFill), + }, + active: { + single: geometry(activeSingleTab, activeSingleFill), + wrappedBottom: geometry(activeBottomTab, activeBottomFill), + }, }, { - rowPaddingTop: '0px', - topFrame: { borderColors: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'], borderTopLeftRadius: '0px' }, - upper: { tabBorders: ['2px', '2px'], fillInsets: ['-2px', '-2px'], fillInlineStart: '0px' }, - inactiveBottom: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-1px'], fillInlineStart: '0px' }, - activeBottom: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-2px'], fillInlineStart: '0px' }, + rowPaddingTop: '2px', + separatorSlots: ['1px', '1px'], + separatorOffsets: ['0px', '0px'], + topFrame: { borderColors: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'], borderTopLeftRadius: '6px', borderWidths: ['1px', '1px'] }, + upper: { tabBorders: ['2px', '2px'], fillInsets: ['-2px', '-2px'], fillInlineStart: '2px' }, + inactive: { + single: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-1px'], fillInlineStart: '0px' }, + wrappedBottom: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-1px'], fillInlineStart: '0px' }, + }, + active: { + single: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-2px'], fillInlineStart: '0px' }, + wrappedBottom: { tabBorders: ['0px', '0px'], fillInsets: ['0px', '-2px'], fillInlineStart: '0px' }, + }, }); }); @@ -3037,16 +3068,18 @@ suite('ModernUIContribution', () => { }); }); - test('extends the connected sticky mask to the strip separator', () => { + test('extends the connected sticky mask through its owned 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'); + root.style.setProperty('--modern-ui-connected-tab-border', '#123456'); 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'); + group.style.setProperty('--modern-ui-connected-tab-border', '#123456'); const title = appendElement(group, 'title tabs'); const tabsAndActions = appendElement(title, 'tabs-and-actions-container'); const scrollable = appendElement(tabsAndActions, 'monaco-scrollable-element'); @@ -3064,11 +3097,13 @@ suite('ModernUIContribution', () => { bottom: stickyStyle.bottom, height: stickyBounds.height, separatorHeight: scrollableBounds.bottom - stickyBounds.bottom, + borderBottomWidth: stickyStyle.borderBottomWidth, }, { top: '0px', - bottom: '1px', - height: 32, - separatorHeight: 1, + bottom: '0px', + height: 33, + separatorHeight: 0, + borderBottomWidth: '1px', }); }); 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 e09af4300f2175..bab96a6dfaf52b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -5,6 +5,11 @@ .modern-ui-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 { background-color: var(--modern-ui-editor-tab-hover-background); + border-bottom-color: var(--modern-ui-editor-tab-hover-border); +} + +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor .tabs-container > .tab.fixture-hover:not(.active):not(.selected) > .tab-fill { + border: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); } .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 { @@ -24,6 +29,11 @@ background-color: var(--vscode-toolbar-hoverBackground); } +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor .action-label.fixture-action-hover:not(.disabled) { + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + .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 77f2a597bf9577..16f863e9bcc5bc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -839,7 +839,49 @@ function renderWrappedConnectedCloseActionHover(activeTabIndex: number): (ctx: C }); } +function renderPinnedActionComparison(editorTabStyle: ModernUIEditorTabStyle, layout: 'inline' | 'wrapped' | 'separate', tabActionLocation: 'left' | 'right' = 'right'): (ctx: ComponentFixtureContext) => void { + const wrapped = layout === 'wrapped'; + const editors = (wrapped ? manyEditorSpecs().slice(0, 10) : defaultEditorSpecs().slice(0, 3)).map((spec, index, all) => ({ + ...spec, + sticky: index === 0, + pinned: true, + dirty: false, + active: index === (wrapped ? all.length - 1 : 1), + })); + return render(true, { + editorTabStyle, + width: 820, + editors, + partOptions: { + editorActionsLocation: 'hidden', + pinnedTabsOnSeparateRow: layout === 'separate', + pinnedTabSizing: 'normal', + tabActionLocation, + tabActionUnpinVisibility: true, + tabSizing: wrapped ? 'fixed' : 'fit', + tabSizingFixedMinWidth: 120, + tabSizingFixedMaxWidth: 120, + wrapTabs: wrapped, + }, + forcedHoverTabAction: 0, + }); +} + +const pinnedActionExpectedVisualDescriptions = [ + 'The hovered Unpin action and the active tab Close action use equal 20px targets with matching outer-edge and vertical clearance inside their tab fills.', + 'Pinning changes the action glyph and persistence, not the action target or edge clearance. This remains true for inline, wrapped, and separate pinned rows.', +]; + const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; +const pinnedActionThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkHighContrast', 'lightHighContrast']; + +function definePinnedActionFixture(editorTabStyle: ModernUIEditorTabStyle, layout: 'inline' | 'wrapped' | 'separate', tabActionLocation: 'left' | 'right' = 'right') { + return defineComponentFixture({ + render: renderPinnedActionComparison(editorTabStyle, layout, tabActionLocation), + additionalThemes: pinnedActionThemes, + expectedVisualDescriptions: pinnedActionExpectedVisualDescriptions, + }); +} export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FileIconThemes: defineThemedFixtureGroup({ @@ -885,6 +927,16 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ], }), }), + PinnedActions: defineThemedFixtureGroup({ + ConnectedInline: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'inline'), + ConnectedWrapped: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'wrapped'), + ConnectedSeparateRow: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'separate'), + ConnectedActionLeft: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'inline', 'left'), + PillInline: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'inline'), + PillWrapped: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'wrapped'), + PillSeparateRow: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'separate'), + PillActionLeft: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'inline', 'left'), + }), ConnectedSurface: defineThemedFixtureGroup({ MinimumIdentity: defineComponentFixture({ render: renderConnectedSurface(3, undefined, undefined, { @@ -900,7 +952,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { width: 380, partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 30, tabSizingFixedMaxWidth: 30, editorActionsLocation: 'hidden' }, }), - expectedVisualDescriptions: ['Hover reveals the inactive close without covering its ellipsized basename or extension.'], + expectedVisualDescriptions: ['Hover reveals the inactive close without covering its ellipsized basename or extension. The first tab follows the editor frame’s large upper-left radius, including in both high-contrast themes.'], }), SingleTab: defineComponentFixture({ render: renderConnectedSurface(0, undefined, undefined, { @@ -942,12 +994,23 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 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.'], + 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. Its 2px top inset matches the outer inline gutter and inter-row spacing. Only tabs in the bottom row can connect to the document.'], + }), + UpperWrappedSelectedAndHover: defineComponentFixture({ + render: renderWrappedConnectedSurface(1, 0), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: [ + 'The selected and hovered upper-row pills have the same outer footprint and a 4px visible background gap, composed from a 2px outer gap plus one transparent border stroke on each pill.', + 'In high contrast, the leftmost hovered pill remains inset from the editor frame and follows its rounded upper-left corner without painting beyond it.', + ], }), 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.'], + expectedVisualDescriptions: [ + 'The selected tab in the bottom wrapped row inherits the single-row connected-tab fill, shoulder, and edge geometry. Only upper wrapped rows use pill-specific styling.', + 'The row-owned separator starts immediately after the bottom tab edge, so it meets the selected shoulders at their tangent instead of crossing through their curved stroke.', + ], }), UpperWrappedCloseActionHovered: defineComponentFixture({ render: renderWrappedConnectedCloseActionHover(0), @@ -955,7 +1018,10 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { }), 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.'], + expectedVisualDescriptions: [ + 'The bottom-row connected tab close action hover background has even inset spacing on every side and remains separated from the terminal shoulder.', + 'In standard themes, the well separator stops cleanly at the selected tab instead of tracing visible arcs around its shoulders.', + ], }), UpperWrappedHover: defineComponentFixture({ render: renderWrappedConnectedSurface(9, 1), @@ -977,7 +1043,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ], }), 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.'], + expectedVisualDescriptions: ['The active pinned tab in the separate upper strip uses the full 32px tab height, matching the connected row while retaining rounded pill geometry.'], }), InactiveGroup: defineComponentFixture({ render: renderConnectedSurface(1, undefined, undefined, { active: false }), @@ -1011,12 +1077,12 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 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.'], + expectedVisualDescriptions: ['Compact sticky tabs fully occlude the scrolling active tab and its connected gutter with a clean separator. The adjacent clipped active cap has a rounded top-left edge without a vertical seam or content leaking beneath the sticky icons.'], }), 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.'], + expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a rounded upper-left cap. 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' }), 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 892db65d4f27e9..9819b503800162 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -156,7 +156,7 @@ suite('MultiEditorTabsControl', () => { 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-size200: 20px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-fontSize-body1: 13px; --vscode-fontWeight-regular: 400;'; + 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-size200: 20px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-cornerRadius-medium: 6px; --vscode-cornerRadius-large: 8px; --vscode-fontSize-body1: 13px; --vscode-fontWeight-regular: 400;'; mainWindow.document.body.appendChild(root); disposables.add(toDisposable(() => root.remove())); const editor = $('.part.editor.editor-tabs-multiple'); @@ -522,6 +522,77 @@ suite('MultiEditorTabsControl', () => { }); }); + test('pin and close actions share geometry across tab styles and wrapping', async () => { + const group = connectedGroup(); + const stickyEditor = model.getEditorByIndex(0)!; + const activeEditor = model.getEditorByIndex(1)!; + model.stick(stickyEditor); + control.stickEditor(stickyEditor); + model.openEditor(activeEditor, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const measurements = []; + + for (const editorTabStyle of ['connected', 'pill'] as const) { + container.classList.toggle('modern-ui-connected-editor-tabs', editorTabStyle === 'connected'); + for (const tabActionLocation of ['right', 'left'] as const) { + for (const wrapTabs of [false, true]) { + const oldOptions = partOptions; + partOptions = { + ...partOptions, + editorActionsLocation: 'hidden', + pinnedTabSizing: 'normal', + tabActionLocation, + tabActionUnpinVisibility: true, + tabSizing: wrapTabs ? 'fixed' : 'fit', + tabSizingFixedMinWidth: 120, + tabSizingFixedMaxWidth: 120, + wrapTabs, + }; + control.updateOptions(oldOptions, partOptions); + await layoutConnectedGroup(group, wrapTabs ? 150 : 400); + const stickyTab = container.querySelector('.tab.sticky')!; + const activeTab = container.querySelector('.tab.active:not(.sticky)')!; + const measure = (tab: HTMLElement) => { + const fill = tab.querySelector('.tab-fill')!.getBoundingClientRect(); + const action = tab.querySelector('.action-label')!; + const actionBounds = action.getBoundingClientRect(); + const tabBounds = tab.getBoundingClientRect(); + return { + target: [actionBounds.width, actionBounds.height], + clearance: [ + actionBounds.top - Math.max(fill.top, tabBounds.top), + tabActionLocation === 'right' ? fill.right - actionBounds.right : actionBounds.left - fill.left, + Math.min(fill.bottom, tabBounds.bottom) - actionBounds.bottom, + ], + label: action.getAttribute('aria-label'), + }; + }; + const pin = measure(stickyTab); + const close = measure(activeTab); + measurements.push({ + editorTabStyle, + tabActionLocation, + wrapTabs, + targetsMatch: pin.target[0] === close.target[0] && pin.target[1] === close.target[1], + clearanceMatches: pin.clearance.every((value, index) => Math.abs(value - close.clearance[index]) <= 1), + labels: [pin.label, close.label], + }); + } + } + } + + assert.deepStrictEqual(measurements, [ + ...['connected', 'pill'].flatMap(editorTabStyle => ['right', 'left'].flatMap(tabActionLocation => [false, true].map(wrapTabs => ({ + editorTabStyle, + tabActionLocation, + wrapTabs, + targetsMatch: true, + clearanceMatches: true, + labels: ['Unpin Editor', 'Close'], + })))), + ]); + }); + test('close hover targets have equal vertical and trailing clearance at both tab densities', async () => { const group = connectedGroup(); group.style.setProperty('--vscode-editorGroupHeader-tabsBorder', '#333333'); @@ -637,7 +708,7 @@ suite('MultiEditorTabsControl', () => { }, { results: [ { width: 240, leftShoulderVisible: true, rightShoulderVisible: true }, - { width: 172, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 172, leftShoulderVisible: true, rightShoulderVisible: false }, { width: 120, leftShoulderVisible: true, rightShoulderVisible: false }, ], firstBorderInset: '0px', @@ -693,7 +764,7 @@ suite('MultiEditorTabsControl', () => { assert.deepStrictEqual(results, [4, 3, 2].map(count => ({ clampedBeforeLayout: true, activeIndex: count - 1, - scrollLeft: count * 160 - 200 + 5, + scrollLeft: count * 160 - 200 + 7, fillLeft: (count - 1) * 160, hidden: false, fillDisplay: 'block', @@ -725,6 +796,21 @@ suite('MultiEditorTabsControl', () => { }, { contentMinimum: true, offsets: ['0px', `${tabs[0].offsetWidth}px`, `${tabs[0].offsetWidth + tabs[1].offsetWidth}px`] }); }); + test('marks the connected clipping edge when it follows compact sticky tabs', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, pinnedTabSizing: 'compact', editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const firstEditor = model.getEditorByIndex(0)!; + model.stick(firstEditor); + control.stickEditor(firstEditor); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 400); + const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; + assert.strictEqual(overflowEdge.classList.contains('connected-tab-adjacent-sticky'), true); + }); + test('only the bottom wrapped row joins the document and upper row resets when unwrapped', async () => { const group = connectedGroup(); const oldOptions = partOptions; @@ -972,9 +1058,12 @@ suite('MultiEditorTabsControl', () => { const surface = fill.getBoundingClientRect(); const style = mainWindow.getComputedStyle(fill); const rowStart = index === 0 || tabs[index - 1].offsetTop !== tab.offsetTop; + const rowEnd = tab.classList.contains('last-in-row'); const upper = tab.classList.contains('connected-tab-upper-row'); const active = index === activeIndex; - const radius = active && !upper ? '5px' : '4px'; + const radius = active && !upper ? '7px' : upper ? '6px' : '4px'; + const roundLeft = !rowStart || upper; + const topLeftRadius = index === 0 && !upper ? '8px' : roundLeft ? radius : '0px'; const context = { compact, wrapTabs, activeIndex, index }; actual.push({ ...context, @@ -984,8 +1073,8 @@ suite('MultiEditorTabsControl', () => { }); expected.push({ ...context, - insets: [0, 0, 0], - corners: [rowStart ? '0px' : radius, radius, active && !upper ? '0px' : '4px', rowStart || active && !upper ? '0px' : '4px'], + insets: [upper ? index === 0 ? 2 : 1 : 0, upper ? rowEnd ? 2 : 1 : 0, 0], + corners: [topLeftRadius, radius, active && !upper ? '0px' : upper ? '6px' : '4px', roundLeft && !(active && !upper) ? upper ? '6px' : '4px' : '0px'], leftBorder: rowStart ? 'rgba(0, 0, 0, 0)' : undefined, }); } @@ -1099,24 +1188,32 @@ suite('MultiEditorTabsControl', () => { const tab = strip.querySelector('.tab.active')!; const fill = tab.querySelector('.tab-fill')!; const fillStyle = mainWindow.getComputedStyle(fill); + const stripStyle = mainWindow.getComputedStyle(strip, '::after'); + const stripBounds = strip.getBoundingClientRect(); + const tabBounds = tab.getBoundingClientRect(); + const separatorTop = stripBounds.bottom - parseFloat(stripStyle.bottom) - parseFloat(stripStyle.height); 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()); + const tabs = Array.from(strip.querySelectorAll('.tab'), 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)), + gap: strip.getBoundingClientRect().bottom - fill.getBoundingClientRect().bottom, 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), + tabFillHeights: fills.map((rect, index) => Math.min(rect.bottom, tabs[index].bottom) - rect.top), rowGap: fills[1].top - fills[0].bottom, + overflow: Array.from(strip.querySelectorAll('.tabs-container, .monaco-scrollable-element'), element => mainWindow.getComputedStyle(element).overflow), + separatorOffset: separatorTop - tabBounds.bottom, + connectionOverlap: fill.getBoundingClientRect().bottom - stripBounds.bottom, }); } assert.deepStrictEqual(measurements, [ - { tabHeight: 'default', stripHeight: 58, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [28, 28], rowGap: 2 }, - { tabHeight: 'compact', stripHeight: 50, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [24, 24], rowGap: 2 }, + { tabHeight: 'default', stripHeight: 61, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', tabFillHeights: [28, 28], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, + { tabHeight: 'compact', stripHeight: 53, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', tabFillHeights: [24, 24], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, ]); }); @@ -1135,16 +1232,17 @@ suite('MultiEditorTabsControl', () => { await layoutConnectedGroup(group, 150); const strip = container.querySelector('.tabs-and-actions-container')!.getBoundingClientRect(); const fills = Array.from(container.querySelectorAll('.tab-fill'), fill => fill.getBoundingClientRect()); + const tabs = Array.from(container.querySelectorAll('.tab'), tab => tab.getBoundingClientRect()); measurements.push({ tabHeight, stripHeight: strip.height, - visibleHeights: fills.map(fill => Math.min(fill.bottom, strip.bottom) - fill.top), + tabFillHeights: fills.map((fill, index) => Math.min(fill.bottom, tabs[index].bottom) - fill.top), rowGaps: fills.slice(1).map((fill, index) => fill.top - fills[index].bottom), }); } assert.deepStrictEqual(measurements, [ - { tabHeight: 'default', stripHeight: 88, visibleHeights: [28, 28, 28], rowGaps: [2, 2] }, - { tabHeight: 'compact', stripHeight: 76, visibleHeights: [24, 24, 24], rowGaps: [2, 2] }, + { tabHeight: 'default', stripHeight: 91, tabFillHeights: [28, 28, 28], rowGaps: [2, 2] }, + { tabHeight: 'compact', stripHeight: 79, tabFillHeights: [24, 24, 24], rowGaps: [2, 2] }, ]); }); @@ -1442,11 +1540,11 @@ suite('MultiEditorTabsControl', () => { }, { clippedHover: { hovered: true, background: 'rgb(101, 67, 33)' }, clippedHoverReset: { hovered: false, background: 'rgb(51, 51, 51)' }, - 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)'] }, + clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '5px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'] }, multiSelected: { clipping: '0px', edge: 'block', radius: '0px 5px 0px 0px', connectedClass: true }, singleSelected: { clipping: '0px', connectedClass: true }, terminalOutline: { right: '1px', rightShoulder: '""', rightMask: '""' }, - normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '3px', leftMaskTop: '0px', rightMaskHeight: '3px', rightMaskTop: '0px' }, + normalOutline: { left: '1px', right: '1px', leftShoulder: '""', rightShoulder: '""', edge: 'block', overflowEdge: 'none', leftMaskHeight: '4px', leftMaskTop: '0px', rightMaskHeight: '4px', 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' }, From e5dd104e84b3031e5b649332b2e89b9aabf5c2c3 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 09:56:20 -0700 Subject: [PATCH 2/4] editor: curate tab layout fixtures Reduce editor-tab fixtures to distinct layout and configuration contracts, keep expanded theme coverage on two reference scenarios, and wait for wrapped layouts to settle deterministically. Also align iconless tab spacing when the file icon theme is disabled.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/modernUI/browser/media/tabs.css | 1 + .../browser/modernUI.contribution.test.ts | 25 + .../editor/editorTabBar.fixture.css | 47 -- .../editor/editorTabBar.fixture.ts | 659 +++--------------- 4 files changed, 110 insertions(+), 622 deletions(-) delete mode 100644 src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 3feb7745e5f302..cb4768e68bb005 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -78,6 +78,7 @@ --tab-border-top-color: transparent !important; } +.modern-ui-tabs.monaco-workbench:not(.file-icons-enabled) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left), .modern-ui-tabs .part.editor > .content .editor-group-container > .title.tabs:not(.show-file-icons) .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left) { padding-left: var(--vscode-spacing-size80) !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 82579a3f78af18..b84f897224db26 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 @@ -3485,6 +3485,31 @@ suite('ModernUIContribution', () => { }); }); + test('uses iconless leading spacing when the file icon theme is disabled', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui-tabs'); + root.style.cssText = '--vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px;'; + 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 show-file-icons'); + const tabs = appendElement(title, 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + const targetWindow = getWindow(root); + + const fileIconThemeNone = targetWindow.getComputedStyle(tab).paddingLeft; + root.classList.add('file-icons-enabled'); + const fileIconsEnabled = targetWindow.getComputedStyle(tab).paddingLeft; + title.classList.remove('show-file-icons'); + const iconsSettingDisabled = targetWindow.getComputedStyle(tab).paddingLeft; + + assert.deepStrictEqual({ fileIconThemeNone, fileIconsEnabled, iconsSettingDisabled }, { + fileIconThemeNone: '8px', + fileIconsEnabled: '6px', + iconsSettingDisabled: '8px', + }); + }); + test('persists reserved tab actions and hides unreserved actions in contrast themes', () => { const theme = ColorThemeData.createUnloadedTheme('vs-dark'); theme.setCustomColors({ [activeContrastBorder]: '#FFFFFF' }); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css deleted file mode 100644 index bab96a6dfaf52b..00000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ /dev/null @@ -1,47 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.modern-ui-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 { - background-color: var(--modern-ui-editor-tab-hover-background); - border-bottom-color: var(--modern-ui-editor-tab-hover-border); -} - -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor .tabs-container > .tab.fixture-hover:not(.active):not(.selected) > .tab-fill { - border: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); -} - -.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, -.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; -} - -.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.active.fixture-hover > .tab-fill { - 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); -} - -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor .action-label.fixture-action-hover:not(.disabled) { - outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.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); -} - -.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.fixture-hover:not(.tab-actions-left):not(.close-action-off) > .tab-actions::before { - background-image: linear-gradient(to right, transparent, var(--modern-ui-editor-tab-action-active-hover-background)); -} - -.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.fixture-hover.tab-actions-left:not(.close-action-off) > .tab-actions::before { - background-image: linear-gradient(to left, transparent, 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 16f863e9bcc5bc..e40d03ecd1a518 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -21,8 +21,6 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { listErrorForeground, listWarningForeground } from '../../../../../platform/theme/common/colors/listColors.js'; -import { isDark } from '../../../../../platform/theme/common/theme.js'; -import { asCssVariableName } from '../../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; @@ -36,16 +34,6 @@ import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js' import { EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND, EDITOR_GROUP_HEADER_TABS_BACKGROUND, - 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, } from '../../../../common/theme.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { BreadcrumbsService, IBreadcrumbsService } from '../../../../browser/parts/editor/breadcrumbs.js'; @@ -60,7 +48,6 @@ import { workbenchInstantiationService } from '../../workbenchTestServices.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'; // ============================================================================ // Fixture editor input @@ -165,20 +152,6 @@ function defaultEditorSpecs(): IEditorSpec[] { ]; } -function nestedActiveEditorSpecs(): IEditorSpec[] { - return defaultEditorSpecs().map((spec, index) => ({ ...spec, active: index === 0 })); -} - -/** Two editors sharing a name but living in different folders (to show descriptions). */ -function duplicateNameEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/src/app/index.ts'), pinned: true, active: true }, - { resource: file('/project/src/lib/index.ts'), pinned: true }, - { resource: file('/project/src/lib/util/index.ts'), pinned: true }, - { resource: file('/project/tests/index.ts'), pinned: true }, - ]; -} - /** A larger set of editors, useful for wrapping / scrollbar / label variants. */ function manyEditorSpecs(activeIndex = 0): IEditorSpec[] { const names = [ @@ -194,36 +167,6 @@ 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 [ - { resource: file('/project/src/app/main.ts'), pinned: true, dirty: true, active: true }, - { resource: file('/project/src/app/index.ts'), pinned: true, dirty: true }, - { resource: file('/project/README.md'), pinned: true }, - { resource: file('/project/package.json'), pinned: true, dirty: true }, - ]; -} - -/** - * A mix of clean, dirty and sticky tabs used to show `tabActionReserveSpace`: - * clean tabs collapse to the compact width when the column is not reserved, - * while the dirty and sticky tabs keep their persistent-indicator column. - */ -function reserveSpaceEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), sticky: true, pinned: true }, - { resource: file('/project/src/app/index.ts'), pinned: true }, - { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true }, - { resource: file('/project/package.json'), icon: ThemeIcon.fromId(Codicon.json.id), pinned: true, dirty: true, active: true }, - { resource: file('/project/src/app/components/button.tsx'), pinned: true }, - ]; -} - /** Sticky (pinned) editors to show the sticky tab styling. */ function stickyEditorSpecs(): IEditorSpec[] { return [ @@ -235,14 +178,6 @@ function stickyEditorSpecs(): IEditorSpec[] { ]; } -function allStickyEditorSpecs(): IEditorSpec[] { - return stickyEditorSpecs().map((spec, index) => ({ ...spec, sticky: true, active: index === 0 })); -} - -function allUnstickyEditorSpecs(): IEditorSpec[] { - return stickyEditorSpecs().map((spec, index) => ({ ...spec, sticky: false, active: index === 0 })); -} - /** Editors with several tabs in the multi-selection (active + additional selected). */ function multiSelectEditorSpecs(): IEditorSpec[] { return [ @@ -264,35 +199,6 @@ function longLabelEditorSpecs(): IEditorSpec[] { ]; } -/** A single dirty, pinned editor for the single-tab control. */ -function singleDirtyEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), pinned: true, dirty: true, active: true }, - ]; -} - -function cannotCloseEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, active: true }, - { resource: file('/project/src/app/main.ts'), pinned: true }, - { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true }, - ]; -} - -function cannotCloseDirtyEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, dirty: true, active: true }, - { resource: file('/project/src/app/main.ts'), pinned: true }, - ]; -} - -function cannotCloseStickyEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/Changes'), capabilities: EditorInputCapabilities.CannotClose, pinned: true, sticky: true, active: true }, - { resource: file('/project/src/app/main.ts'), pinned: true }, - ]; -} - // ============================================================================ // File decorations // ============================================================================ @@ -363,16 +269,11 @@ export interface IEditorTabBarFixtureOptions { /** Whether this group is the active group. Inactive groups exercise the * `alwaysShowEditorActions` filtering and unfocused tab styling. */ readonly active?: boolean; - readonly dropTargetBetweenTabs?: boolean; readonly showHeader?: boolean; readonly useModernUITabs?: boolean; readonly reserveHeaderSpace?: boolean; readonly headerWidth?: number; 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'; } @@ -418,10 +319,6 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: const isGroupActive = options.active ?? true; const partOptions = createPartOptions(options.partOptions); - for (const [colorId, color] of Object.entries(options.colorCustomizations ?? {})) { - container.style.setProperty(asCssVariableName(colorId), color); - } - const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration('breadcrumbs', { enabled: Boolean(options.breadcrumbs), @@ -584,23 +481,6 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: titleControl.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); titleControl.setActive(isGroupActive); - const tabs = titleContainer.querySelectorAll('.tabs-container > .tab'); - if (options.dropTargetBetweenTabs) { - tabs[1]?.classList.add('drop-target-left'); - tabs[2]?.classList.add('drop-target-right'); - } - 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) { - ctx.focus(action); - } - } layout(); if (options.activeTabClipping) { disposableStore.add(scheduleAtNextAnimationFrame(getWindow(container), () => { @@ -636,517 +516,146 @@ function render(modernUI: boolean, options: Omit> { - const dark = isDark(theme.type); - return { - [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: dark ? '#164E63' : '#BAE6FD', - [MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND]: dark ? '#0E3747' : '#7DD3FC', - [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND]: dark ? '#CFFAFE' : '#0C4A6E', - [MODERN_EDITOR_TAB_INACTIVE_BACKGROUND]: dark ? '#1E293B' : '#E2E8F0', - [MODERN_EDITOR_TAB_HOVER_BACKGROUND]: dark ? '#7C2D12' : '#FED7AA', - [MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND]: dark ? '#5A1F0C' : '#FDBA74', - [MODERN_EDITOR_TAB_HOVER_FOREGROUND]: dark ? '#FFEDD5' : '#7C2D12', - [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND]: dark ? '#6B21A8' : '#E9D5FF', - [MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND]: dark ? '#4C1678' : '#D8B4FE', - [MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND]: dark ? '#166534' : '#BBF7D0', - }; -} - -function renderThemeColors(options: Omit): (ctx: ComponentFixtureContext) => void { - return ctx => { - ctx.container.classList.add('modern-ui'); - renderEditorTabBarFixture(ctx, { - ...options, - modernUI: true, - editorTabStyle: ModernUIEditorTabStyle.Pill, - colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), - }); +function renderWrappedLayout(tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => Promise { + const renderFixture = render(true, { partOptions: { wrapTabs: true, tabHeight }, editors: manyEditorSpecs(), width: 520 }); + return async ctx => { + renderFixture(ctx); + let previousLayout: string | undefined; + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise(resolve => ctx.disposableStore.add(scheduleAtNextAnimationFrame(getWindow(ctx.container), () => resolve()))); + const strip = ctx.container.querySelector('.tabs-and-actions-container'); + const tabs = Array.from(ctx.container.querySelectorAll('.tabs-container > .tab')); + if (!strip || !tabs.length) { + throw new Error('Wrapped tab fixture did not render its tab strip'); + } + const layout = JSON.stringify({ + height: strip.offsetHeight, + tabs: tabs.map(tab => [tab.offsetLeft, tab.offsetTop, tab.offsetWidth, tab.offsetHeight]), + }); + if (layout === previousLayout) { + return; + } + previousLayout = layout; + } + throw new Error('Wrapped tab fixture did not reach a stable layout'); }; } -function createThemeColorFixtures() { +function createLayoutFixtures() { return { - TabStates: defineComponentFixture({ render: renderThemeColors({ forcedHoverTab: 2, focusedTabAction: 2 }) }), - ActiveAction: defineComponentFixture({ render: renderThemeColors({ focusedTabAction: 3 }) }), - ActiveHover: defineComponentFixture({ render: renderThemeColors({ forcedHoverTab: 3, focusedTabAction: 3 }) }), - SelectedAction: defineComponentFixture({ render: renderThemeColors({ editors: multiSelectEditorSpecs(), focusedTabAction: 0 }) }), + CompactDensity: defineComponentFixture({ + render: render(true, { partOptions: { tabHeight: 'compact' } }), + expectedVisualDescriptions: ['Compact density preserves the same tab structure with shorter hitboxes and centered actions.'], + }), + WrappedDefault: defineComponentFixture({ + render: renderWrappedLayout('default'), + additionalThemes: extendedTabThemes, + expectedVisualDescriptions: ['Default-density tabs wrap into equal-height rows. Upper connected rows remain separate pills and the bottom selected tab joins the editor.'], + }), + WrappedCompact: defineComponentFixture({ + render: renderWrappedLayout('compact'), + expectedVisualDescriptions: ['Compact tabs wrap into equal-height rows while labels and actions remain vertically centered.'], + }), + PinnedSeparateRow: defineComponentFixture({ + render: renderPinnedSeparateRow(), + expectedVisualDescriptions: ['The separate pinned row uses full-height pills. Unpin and Close retain equal targets and edge clearance.'], + }), + PinnedCompact: defineComponentFixture({ + render: render(true, { partOptions: { pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }), + expectedVisualDescriptions: ['Compact pinned tabs remain distinct from the normal active tab and preserve their icon-only hit targets.'], + }), + CloseHidden: defineComponentFixture({ + render: render(true, { partOptions: { tabActionCloseVisibility: false } }), + expectedVisualDescriptions: ['Hiding Close actions removes their reserved controls without changing tab height or modified indicators.'], + }), + ActionLeft: defineComponentFixture({ + render: render(true, { partOptions: { tabActionLocation: 'left' } }), + expectedVisualDescriptions: ['Leading tab actions mirror the default trailing-action spacing without changing label alignment.'], + }), + ModifiedMultiSelect: defineComponentFixture({ + render: render(true, { editors: multiSelectEditorSpecs() }), + expectedVisualDescriptions: ['Modified and multi-selected tabs retain their indicators, selected boundaries, and active document connection.'], + }), + LongNamesFit: defineComponentFixture({ + render: render(true, { partOptions: { tabSizing: 'fit' }, editors: longLabelEditorSpecs(), width: 520 }), + expectedVisualDescriptions: ['Fit-sized long labels use their natural widths and scroll rather than overlap actions.'], + }), + LongNamesShrink: defineComponentFixture({ + render: render(true, { partOptions: { tabSizing: 'shrink' }, editors: longLabelEditorSpecs(), width: 520 }), + expectedVisualDescriptions: ['Shrink-sized long labels ellipsize while preserving extensions and action targets.'], + }), + LongNamesFixed: defineComponentFixture({ + render: render(true, { partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120 }, editors: longLabelEditorSpecs(), width: 520 }), + expectedVisualDescriptions: ['Fixed-size long labels use equal tab widths, real ellipses, and stable action columns.'], + }), }; } -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 }, - { 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\';', - '', - 'const app = createApp({', - '\ttheme: \'system\',', - '\trestoreSession: true,', - '});', - '', - 'await app.start();', - ].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' }, - }); -} - -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, - }); -} - -function renderPinnedActionComparison(editorTabStyle: ModernUIEditorTabStyle, layout: 'inline' | 'wrapped' | 'separate', tabActionLocation: 'left' | 'right' = 'right'): (ctx: ComponentFixtureContext) => void { - const wrapped = layout === 'wrapped'; - const editors = (wrapped ? manyEditorSpecs().slice(0, 10) : defaultEditorSpecs().slice(0, 3)).map((spec, index, all) => ({ +function renderPinnedSeparateRow(): (ctx: ComponentFixtureContext) => void { + const editors = defaultEditorSpecs().slice(0, 3).map((spec, index) => ({ ...spec, sticky: index === 0, pinned: true, dirty: false, - active: index === (wrapped ? all.length - 1 : 1), + active: index === 1, })); return render(true, { - editorTabStyle, - width: 820, editors, partOptions: { editorActionsLocation: 'hidden', - pinnedTabsOnSeparateRow: layout === 'separate', + pinnedTabsOnSeparateRow: true, pinnedTabSizing: 'normal', - tabActionLocation, tabActionUnpinVisibility: true, - tabSizing: wrapped ? 'fixed' : 'fit', - tabSizingFixedMinWidth: 120, - tabSizingFixedMaxWidth: 120, - wrapTabs: wrapped, }, - forcedHoverTabAction: 0, }); } -const pinnedActionExpectedVisualDescriptions = [ - 'The hovered Unpin action and the active tab Close action use equal 20px targets with matching outer-edge and vertical clearance inside their tab fills.', - 'Pinning changes the action glyph and persistence, not the action target or edge clearance. This remains true for inline, wrapped, and separate pinned rows.', -]; - -const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; -const pinnedActionThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkHighContrast', 'lightHighContrast']; - -function definePinnedActionFixture(editorTabStyle: ModernUIEditorTabStyle, layout: 'inline' | 'wrapped' | 'separate', tabActionLocation: 'left' | 'right' = 'right') { - return defineComponentFixture({ - render: renderPinnedActionComparison(editorTabStyle, layout, tabActionLocation), - additionalThemes: pinnedActionThemes, - expectedVisualDescriptions: pinnedActionExpectedVisualDescriptions, - }); -} +const extendedTabThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', '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, {}) }), - Minimal: defineComponentFixture({ fileIconTheme: 'vs-minimal', render: render(false, {}) }), - None: defineComponentFixture({ fileIconTheme: 'none', render: render(false, {}) }), - }), - 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.', - ], + Minimal: defineComponentFixture({ fileIconTheme: 'vs-minimal', render: render(true, {}) }), + None: defineComponentFixture({ + fileIconTheme: 'none', + render: render(true, {}), + expectedVisualDescriptions: ['Tabs without a file icon theme use the same balanced iconless leading spacing as the Show Icons disabled setting.'], }), - ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), + LayoutSettings: defineThemedFixtureGroup(createLayoutFixtures()), TabStyleCompatibility: defineThemedFixtureGroup({ Legacy: defineComponentFixture({ render: render(false, {}), - themes: ['dark'], - additionalThemes: ['darkModern'], expectedVisualDescriptions: [ 'With Modern UI disabled, the legacy tab strip and inactive tabs retain the theme legacy background instead of adopting the connected-tab strip color.', ], }), Connected: defineComponentFixture({ render: render(true, {}), - themes: ['dark'], - additionalThemes: ['darkModern'], + additionalThemes: extendedTabThemes, expectedVisualDescriptions: [ 'Connected tabs use the dedicated connected strip color while the active tab remains joined to the editor surface.', ], }), Pill: defineComponentFixture({ render: render(true, { editorTabStyle: ModernUIEditorTabStyle.Pill }), - themes: ['dark'], - additionalThemes: ['darkModern'], expectedVisualDescriptions: [ 'Pill tabs retain their transparent modern surface and separate rounded geometry instead of adopting the connected-tab strip color.', ], }), }), - PinnedActions: defineThemedFixtureGroup({ - ConnectedInline: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'inline'), - ConnectedWrapped: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'wrapped'), - ConnectedSeparateRow: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'separate'), - ConnectedActionLeft: definePinnedActionFixture(ModernUIEditorTabStyle.Connected, 'inline', 'left'), - PillInline: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'inline'), - PillWrapped: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'wrapped'), - PillSeparateRow: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'separate'), - PillActionLeft: definePinnedActionFixture(ModernUIEditorTabStyle.Pill, 'inline', 'left'), - }), - 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. The first tab follows the editor frame’s large upper-left radius, including in both high-contrast themes.'], - }), - 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, - 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. Its 2px top inset matches the outer inline gutter and inter-row spacing. Only tabs in the bottom row can connect to the document.'], - }), - UpperWrappedSelectedAndHover: defineComponentFixture({ - render: renderWrappedConnectedSurface(1, 0), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: [ - 'The selected and hovered upper-row pills have the same outer footprint and a 4px visible background gap, composed from a 2px outer gap plus one transparent border stroke on each pill.', - 'In high contrast, the leftmost hovered pill remains inset from the editor frame and follows its rounded upper-left corner without painting beyond it.', - ], - }), - BottomWrappedConnected: defineComponentFixture({ - render: renderWrappedConnectedSurface(9), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: [ - 'The selected tab in the bottom wrapped row inherits the single-row connected-tab fill, shoulder, and edge geometry. Only upper wrapped rows use pill-specific styling.', - 'The row-owned separator starts immediately after the bottom tab edge, so it meets the selected shoulders at their tangent instead of crossing through their curved stroke.', - ], - }), - 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.', - 'In standard themes, the well separator stops cleanly at the selected tab instead of tracing visible arcs around its shoulders.', - ], - }), - 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 uses the full 32px tab height, matching the connected row while retaining rounded pill geometry.'], - }), - 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 retains its connected document well during multi-selection. All selected tabs, including the active tab, have an explicit HC selection stroke; keyboard focus remains distinct.'], - }), - WrappedRowStart: defineComponentFixture({ - render: render(true, { - partOptions: { wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }, - editors: wrappedRowStartEditorSpecs(), - width: 260, - }), - 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.'], - }), + ConnectedStress: defineThemedFixtureGroup({ StickyViewport: defineComponentFixture({ - additionalThemes: connectedSurfaceThemes, 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 with a clean separator. The adjacent clipped active cap has a rounded top-left edge without a vertical seam or content leaking beneath the sticky icons.'], + expectedVisualDescriptions: ['Compact pinned tabs fully occlude scrolled content. The adjacent selected cap remains rounded with no seam or content leakage.'], }), - ClippedLeft: defineComponentFixture({ + ClippedViewport: 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 rounded upper-left cap. 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' }), - 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: ['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: connectedSurfaceThemes, - expectedVisualDescriptions: [ - '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.', - '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 tab spans the strip height and meets the connected shoulder without an upper or lower gutter.', - ], - }), - FocusedCloseAction: defineComponentFixture({ - render: renderConnectedSurface(1, undefined, 1), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: [ - '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: connectedSurfaceThemes, - expectedVisualDescriptions: [ - '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: connectedSurfaceThemes, - expectedVisualDescriptions: [ - '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.', - ], + expectedVisualDescriptions: ['A partially scrolled selected tab closes its stationary outside stroke with a rounded cap and continuous separator.'], }), }), }); From 3addb96fd1db7ff2ad6b7c0fdd7776d2ff6f1ec9 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 10:46:25 -0700 Subject: [PATCH 3/4] editor: fix wrapped tab state ownership Aggregate wrapped state across multi-row tab controls and let the controller own overflow instead of CSS overrides. Reorganize editor tab fixtures, update Playwright fixture paths, and add regressions for pinned-row relayouts.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/editorHeader.fixture.ts | 6 +- .../parts/editor/multiEditorTabsControl.ts | 10 +- .../browser/media/connectedEditorTabs.css | 5 +- ...ditorTabBar.fixture.ts => tabs.fixture.ts} | 176 +++++++++++------- .../editor/multiEditorTabsControl.test.ts | 35 ++++ .../playwright/tests/editorTabBar.spec.ts | 4 +- 6 files changed, 163 insertions(+), 73 deletions(-) rename src/vs/workbench/test/browser/componentFixtures/editor/{editorTabBar.fixture.ts => tabs.fixture.ts} (87%) diff --git a/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts b/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts index bf9b6e3471ee51..37ba4b9d16711f 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editorHeader.fixture.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize2 } from '../../../../../nls.js'; import { MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; -import { renderEditorTabBarFixture } from '../../../../../workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.js'; +import { renderEditorTabsFixture } from '../../../../../workbench/test/browser/componentFixtures/editor/tabs.fixture.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; const primaryMenu = MenuId.for('sessions.fixture.editorHeaderPrimary'); @@ -86,7 +86,7 @@ MenuRegistry.appendMenuItem(addTabMenu, { function renderHeader(ctx: ComponentFixtureContext, breadcrumbs: boolean, primaryAction: boolean, secondaryAction = false, layoutActions = false, showTabs: 'multiple' | 'single' | 'none' = 'multiple', addTab = false, tabHeight: 'default' | 'compact' = 'default', headerWidth?: number, reserveHeaderSpace = false): void { ctx.container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); - renderEditorTabBarFixture(ctx, { + renderEditorTabsFixture(ctx, { modernUI: true, partOptions: { showTabs, tabHeight }, breadcrumbs: breadcrumbs ? { filePath: 'on', icons: true } : undefined, @@ -105,7 +105,7 @@ function renderHeader(ctx: ComponentFixtureContext, breadcrumbs: boolean, primar function renderConnectedCard(ctx: ComponentFixtureContext, secondTabActive = false): void { ctx.container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); - renderEditorTabBarFixture(ctx, { + renderEditorTabsFixture(ctx, { modernUI: true, width: 358, editors: [ diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index b9fa3763b0b333..c37dbcd77547cf 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2104,7 +2104,15 @@ export class MultiEditorTabsControl extends EditorTabsControl { tab.classList.toggle('connected-tab-upper-row', connected && (upperTabBar || tab.offsetTop !== bottom)); tab.classList.toggle('connected-tab-top-row', connected && topTabBar && tab.offsetTop === top); }); - this.parent.classList.toggle('connected-tabs-wrapping', connected && tabsWrapMultiLine); + const connectedTabsWrapping = connected && Array.from(this.parent.children).some(element => + isHTMLElement(element) && + element.classList.contains('tabs-and-actions-container') && + element.classList.contains('wrapping') + ); + this.parent.classList.toggle('connected-tabs-wrapping', connectedTabsWrapping); + const overflow = connected && tabsWrapMultiLine ? 'visible' : 'hidden'; + assertReturnsDefined(this.tabsContainer).style.overflow = overflow; + assertReturnsDefined(this.tabsScrollbar).getDomNode().style.overflow = overflow; if (!tabsWrapMultiLine) { this.doLayoutTabsNonWrapping(options); } else { diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 646d6dc0edae7e..cea416ec6d5741 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -173,11 +173,9 @@ background-color: var(--modern-ui-connected-tab-border); } -/* Wrapped scrolling does not add the pill-style bottom gutter. ScrollableElement writes overflow - * inline, so the connected shoulders must explicitly escape both clipping ancestors. */ +/* Wrapped scrolling does not add the pill-style bottom gutter. */ .modern-ui-tabs.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; - overflow: visible !important; } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { @@ -192,7 +190,6 @@ --modern-ui-connected-tab-action-inset: calc((var(--editor-group-tab-height) - var(--vscode-spacing-size200)) / 2 + var(--vscode-spacing-size20)); --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset)); row-gap: var(--vscode-spacing-size20); - overflow: visible !important; } /* Preserve the existing explicit selection and focus borders in high contrast. */ diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts similarity index 87% rename from src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts rename to src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts index e40d03ecd1a518..66d0115a703225 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -256,7 +256,7 @@ function createFixtureEditorTitleActions(store: DisposableStore, menuId: MenuId) // Rendering // ============================================================================ -export interface IEditorTabBarFixtureOptions { +export interface IEditorTabsFixtureOptions { readonly modernUI: boolean; readonly partOptions?: Partial; readonly editorTabStyle?: ModernUIEditorTabStyle; @@ -312,7 +312,7 @@ function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposable } } -export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: IEditorTabBarFixtureOptions): void { +export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: IEditorTabsFixtureOptions): void { const { container, disposableStore, theme, fileIconTheme } = ctx; const width = options.width ?? 820; @@ -509,15 +509,25 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: } } -function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { +function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { return (ctx: ComponentFixtureContext) => { ctx.container.classList.toggle('modern-ui', modernUI); - renderEditorTabBarFixture(ctx, { ...options, modernUI }); + renderEditorTabsFixture(ctx, { ...options, modernUI }); }; } -function renderWrappedLayout(tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => Promise { - const renderFixture = render(true, { partOptions: { wrapTabs: true, tabHeight }, editors: manyEditorSpecs(), width: 520 }); +function renderDensityLayout(layout: 'singleRow' | 'wrapped' | 'pinnedSeparateRow', tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => Promise | void { + const wrapped = layout === 'wrapped'; + const renderFixture = layout === 'pinnedSeparateRow' + ? renderPinnedSeparateRow(tabHeight) + : render(true, { + partOptions: { wrapTabs: wrapped, tabHeight }, + editors: wrapped ? manyEditorSpecs() : undefined, + width: wrapped ? 520 : undefined, + }); + if (!wrapped) { + return renderFixture; + } return async ctx => { renderFixture(ctx); let previousLayout: string | undefined; @@ -541,57 +551,84 @@ function renderWrappedLayout(tabHeight: IEditorPartOptions['tabHeight']): (ctx: }; } -function createLayoutFixtures() { +function createDensityFixtures() { return { - CompactDensity: defineComponentFixture({ - render: render(true, { partOptions: { tabHeight: 'compact' } }), - expectedVisualDescriptions: ['Compact density preserves the same tab structure with shorter hitboxes and centered actions.'], - }), - WrappedDefault: defineComponentFixture({ - render: renderWrappedLayout('default'), - additionalThemes: extendedTabThemes, - expectedVisualDescriptions: ['Default-density tabs wrap into equal-height rows. Upper connected rows remain separate pills and the bottom selected tab joins the editor.'], - }), - WrappedCompact: defineComponentFixture({ - render: renderWrappedLayout('compact'), - expectedVisualDescriptions: ['Compact tabs wrap into equal-height rows while labels and actions remain vertically centered.'], - }), - PinnedSeparateRow: defineComponentFixture({ - render: renderPinnedSeparateRow(), - expectedVisualDescriptions: ['The separate pinned row uses full-height pills. Unpin and Close retain equal targets and edge clearance.'], + PinnedSeparateRow: defineThemedFixtureGroup({ + Default: defineComponentFixture({ + render: renderDensityLayout('pinnedSeparateRow', 'default'), + expectedVisualDescriptions: ['Default-density pinned and ordinary rows keep equal action targets and balanced edge clearance.'], + }), + Compact: defineComponentFixture({ + render: renderDensityLayout('pinnedSeparateRow', 'compact'), + expectedVisualDescriptions: ['Compact-density pinned and ordinary rows stay aligned with centered actions.'], + }), }), - PinnedCompact: defineComponentFixture({ - render: render(true, { partOptions: { pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }), - expectedVisualDescriptions: ['Compact pinned tabs remain distinct from the normal active tab and preserve their icon-only hit targets.'], + SingleRow: defineThemedFixtureGroup({ + Default: defineComponentFixture({ + render: renderDensityLayout('singleRow', 'default'), + expectedVisualDescriptions: ['Default-density tabs preserve their standard hitbox height and centered actions.'], + }), + Compact: defineComponentFixture({ + render: renderDensityLayout('singleRow', 'compact'), + expectedVisualDescriptions: ['Compact-density tabs preserve the same structure with shorter hitboxes and centered actions.'], + }), }), - CloseHidden: defineComponentFixture({ - render: render(true, { partOptions: { tabActionCloseVisibility: false } }), - expectedVisualDescriptions: ['Hiding Close actions removes their reserved controls without changing tab height or modified indicators.'], + Wrapped: defineThemedFixtureGroup({ + Default: defineComponentFixture({ + render: renderDensityLayout('wrapped', 'default'), + additionalThemes: extendedTabThemes, + expectedVisualDescriptions: ['Default-density tabs wrap into equal-height rows. Upper connected rows remain separate pills and the bottom selected tab joins the editor.'], + }), + Compact: defineComponentFixture({ + render: renderDensityLayout('wrapped', 'compact'), + expectedVisualDescriptions: ['Compact-density tabs wrap into equal-height rows while labels and actions remain vertically centered.'], + }), }), - ActionLeft: defineComponentFixture({ + }; +} + +function createLayoutFixtures() { + return { + ActionsLeading: defineComponentFixture({ render: render(true, { partOptions: { tabActionLocation: 'left' } }), expectedVisualDescriptions: ['Leading tab actions mirror the default trailing-action spacing without changing label alignment.'], }), - ModifiedMultiSelect: defineComponentFixture({ - render: render(true, { editors: multiSelectEditorSpecs() }), - expectedVisualDescriptions: ['Modified and multi-selected tabs retain their indicators, selected boundaries, and active document connection.'], + CloseActionsHidden: defineComponentFixture({ + render: render(true, { partOptions: { tabActionCloseVisibility: false } }), + expectedVisualDescriptions: ['Hiding Close actions removes their reserved controls without changing tab height or modified indicators.'], }), LongNamesFit: defineComponentFixture({ render: render(true, { partOptions: { tabSizing: 'fit' }, editors: longLabelEditorSpecs(), width: 520 }), expectedVisualDescriptions: ['Fit-sized long labels use their natural widths and scroll rather than overlap actions.'], }), + LongNamesFixed: defineComponentFixture({ + render: render(true, { partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120 }, editors: longLabelEditorSpecs(), width: 520 }), + expectedVisualDescriptions: ['Fixed-size long labels use equal tab widths, real ellipses, and stable action columns.'], + }), LongNamesShrink: defineComponentFixture({ render: render(true, { partOptions: { tabSizing: 'shrink' }, editors: longLabelEditorSpecs(), width: 520 }), expectedVisualDescriptions: ['Shrink-sized long labels ellipsize while preserving extensions and action targets.'], }), - LongNamesFixed: defineComponentFixture({ - render: render(true, { partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120 }, editors: longLabelEditorSpecs(), width: 520 }), - expectedVisualDescriptions: ['Fixed-size long labels use equal tab widths, real ellipses, and stable action columns.'], + ModifiedAndMultiSelected: defineComponentFixture({ + render: render(true, { editors: multiSelectEditorSpecs() }), + expectedVisualDescriptions: ['Modified and multi-selected tabs retain their indicators, selected boundaries, and active document connection.'], + }), + PinnedIconOnly: defineComponentFixture({ + render: render(true, { partOptions: { pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }), + expectedVisualDescriptions: ['Compact pinned tabs remain distinct from the normal active tab and preserve their icon-only hit targets.'], + }), + SingleEditor: defineComponentFixture({ + render: render(true, { + editors: [{ resource: file('/project/README.md'), pinned: true, active: true }], + partOptions: { editorActionsLocation: 'hidden' }, + }), + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['A single editor retains balanced label and Close action spacing without crowding either connected shoulder.'], }), }; } -function renderPinnedSeparateRow(): (ctx: ComponentFixtureContext) => void { +function renderPinnedSeparateRow(tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => void { const editors = defaultEditorSpecs().slice(0, 3).map((spec, index) => ({ ...spec, sticky: index === 0, @@ -605,6 +642,7 @@ function renderPinnedSeparateRow(): (ctx: ComponentFixtureContext) => void { editorActionsLocation: 'hidden', pinnedTabsOnSeparateRow: true, pinnedTabSizing: 'normal', + tabHeight, tabActionUnpinVisibility: true, }, }); @@ -612,23 +650,42 @@ function renderPinnedSeparateRow(): (ctx: ComponentFixtureContext) => void { const extendedTabThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; -export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { - FileIconThemes: defineThemedFixtureGroup({ - Minimal: defineComponentFixture({ fileIconTheme: 'vs-minimal', render: render(true, {}) }), - None: defineComponentFixture({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { + Density: defineThemedFixtureGroup(createDensityFixtures()), + FileIcons: defineThemedFixtureGroup({ + Disabled: defineComponentFixture({ fileIconTheme: 'none', render: render(true, {}), expectedVisualDescriptions: ['Tabs without a file icon theme use the same balanced iconless leading spacing as the Show Icons disabled setting.'], }), + Minimal: defineComponentFixture({ fileIconTheme: 'vs-minimal', render: render(true, {}) }), }), - LayoutSettings: defineThemedFixtureGroup(createLayoutFixtures()), - TabStyleCompatibility: defineThemedFixtureGroup({ - Legacy: defineComponentFixture({ - render: render(false, {}), - expectedVisualDescriptions: [ - 'With Modern UI disabled, the legacy tab strip and inactive tabs retain the theme legacy background instead of adopting the connected-tab strip color.', - ], + Layout: defineThemedFixtureGroup(createLayoutFixtures()), + Scrolling: defineThemedFixtureGroup({ + Breadcrumbs: defineComponentFixture({ + render: render(true, { + partOptions: { titleScrollbarVisibility: 'visible' }, + breadcrumbs: {}, + editors: manyEditorSpecs(5), + width: 360, + }), + expectedVisualDescriptions: ['The horizontal tab scrollbar, connected strip separator, and breadcrumb border remain distinct and aligned while the active tab is revealed within an overflowing strip.'], }), + ClippedActiveTab: defineComponentFixture({ + render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), + expectedVisualDescriptions: ['A partially scrolled selected tab closes its stationary outside stroke with a rounded cap and continuous separator.'], + }), + StickyPinnedTabs: defineComponentFixture({ + render: render(true, { + partOptions: { pinnedTabSizing: 'compact', editorActionsLocation: 'hidden' }, + editors: stickyEditorSpecs(), + width: 250, + activeTabClipping: 'left', + }), + expectedVisualDescriptions: ['Compact pinned tabs fully occlude scrolled content. The adjacent selected cap remains rounded with no seam or content leakage.'], + }), + }), + TabStyles: defineThemedFixtureGroup({ Connected: defineComponentFixture({ render: render(true, {}), additionalThemes: extendedTabThemes, @@ -636,26 +693,19 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { 'Connected tabs use the dedicated connected strip color while the active tab remains joined to the editor surface.', ], }), + Legacy: defineComponentFixture({ + render: render(false, {}), + additionalThemes: extendedTabThemes, + expectedVisualDescriptions: [ + 'With Modern UI disabled, the legacy tab strip and inactive tabs retain the theme legacy background instead of adopting the connected-tab strip color.', + ], + }), Pill: defineComponentFixture({ render: render(true, { editorTabStyle: ModernUIEditorTabStyle.Pill }), + additionalThemes: extendedTabThemes, expectedVisualDescriptions: [ 'Pill tabs retain their transparent modern surface and separate rounded geometry instead of adopting the connected-tab strip color.', ], }), }), - ConnectedStress: defineThemedFixtureGroup({ - StickyViewport: defineComponentFixture({ - render: render(true, { - partOptions: { pinnedTabSizing: 'compact', editorActionsLocation: 'hidden' }, - editors: stickyEditorSpecs(), - width: 250, - activeTabClipping: 'left', - }), - expectedVisualDescriptions: ['Compact pinned tabs fully occlude scrolled content. The adjacent selected cap remains rounded with no seam or content leakage.'], - }), - ClippedViewport: defineComponentFixture({ - render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), - expectedVisualDescriptions: ['A partially scrolled selected tab closes its stationary outside stroke with a rounded cap and continuous separator.'], - }), - }), }); 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 9819b503800162..c4dceb2c9d59c1 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -912,6 +912,41 @@ suite('MultiEditorTabsControl', () => { }); }); + test('connected wrapping state aggregates across separate pinned and unpinned rows', async () => { + const group = connectedGroup(); + control.dispose(); + container.replaceChildren(); + for (let index = model.count; index < 6; index++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/aggregate-${index}.ts`), 'testEditorInput')); + model.openEditor(editor, { pinned: true }); + } + const oldOptions = partOptions; + partOptions = { ...partOptions, pinnedTabsOnSeparateRow: true, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + const multiRowControl = disposables.add(instantiationService.createInstance(MultiRowEditorControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); + multiRowControl.updateOptions(oldOptions, partOptions); + const stickyEditor = model.getEditorByIndex(0)!; + model.stick(stickyEditor); + multiRowControl.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 150, multiRowControl); + const tabBars = Array.from(container.querySelectorAll('.tabs-and-actions-container')); + const initiallyWrapped = { + pinned: tabBars[0].classList.contains('wrapping'), + unpinned: tabBars[1].classList.contains('wrapping'), + aggregate: container.classList.contains('connected-tabs-wrapping'), + }; + + multiRowControl.updateEditorDirty(stickyEditor); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + assert.deepStrictEqual({ + initiallyWrapped, + afterPinnedOnlyLayout: container.classList.contains('connected-tabs-wrapping'), + }, { + initiallyWrapped: { pinned: false, unpinned: true, aggregate: true }, + afterPinnedOnlyLayout: true, + }); + }); + test('connected wrapped last tab adds its shoulder to the editor actions margin', async () => { const group = connectedGroup(); const oldOptions = partOptions; diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 5da36891240179..37102bf383608a 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -10,7 +10,7 @@ test('Dark Modern keeps legacy, connected, and pill tab surfaces distinct', asyn test.setTimeout(60_000); const surfaceColors = async (style: 'Legacy' | 'Connected' | 'Pill') => { - await openFixture(page, `editor/editorTabBar/editorTabBar/TabStyleCompatibility/${style}/DarkModern`, '.tabs-container > .tab.active'); + await openFixture(page, `editor/tabs/TabStyles/${style}/DarkModern`, '.tabs-container > .tab.active'); return page.locator('.editor-group-container > .title.tabs').evaluate(title => { if (!(title instanceof HTMLElement)) { throw new Error('Expected an editor title element'); @@ -46,7 +46,7 @@ test('Dark Modern keeps legacy, connected, and pill tab surfaces distinct', asyn for (const theme of ['DarkHighContrast', 'LightHighContrast']) { test(`connected tab actions respect disabled hover state in ${theme}`, async ({ page }) => { - await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedSurface/SingleTab/${theme}`, '.tabs-container > .tab'); + await openFixture(page, `editor/tabs/Layout/SingleEditor/${theme}`, '.tabs-container > .tab'); const action = page.locator('.tab-actions .action-label'); await action.hover(); From b0b4e1c5586d09a8048b33a5007a3e2f91a796a5 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 11:50:19 -0700 Subject: [PATCH 4/4] editor: include tab padding in wrap constraints Measure the complete wrapped title against available height so Connected tab padding cannot overflow constrained editor groups. Cover both sides of the boundary with a regression test.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 2 +- .../editor/multiEditorTabsControl.test.ts | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index c37dbcd77547cf..55e31a16835591 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2253,7 +2253,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Tabs wrap multiline: remove wrapping under certain size constraint conditions if (tabsWrapMultiLine) { if ( - (tabsContainer.offsetHeight > dimensions.available.height) || // if height exceeds available height + (tabsAndActionsContainer.offsetHeight > dimensions.available.height) || // if the complete wrapped title exceeds available height (allTabsWidth === visibleTabsWidth && tabsContainer.offsetHeight === this.tabHeight) || // if wrapping is not needed anymore (!lastTabFitsWrapped()) // if last tab does not fit anymore ) { 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 c4dceb2c9d59c1..51021561ab8185 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -1252,6 +1252,34 @@ suite('MultiEditorTabsControl', () => { ]); }); + test('wrapped connected tabs include title padding in the available height constraint', 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 strip = container.querySelector('.tabs-and-actions-container')!; + const tabs = container.querySelector('.tabs-container')!; + const wrappedHeights = { strip: strip.offsetHeight, tabs: tabs.offsetHeight }; + + control.layout({ container: new Dimension(150, 33), available: new Dimension(150, wrappedHeights.tabs) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + const constrainedByTabsHeight = strip.classList.contains('wrapping'); + + control.layout({ container: new Dimension(150, 33), available: new Dimension(150, wrappedHeights.strip) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + assert.deepStrictEqual({ + wrappedHeights, + constrainedByTabsHeight, + fitsAtFullStripHeight: strip.classList.contains('wrapping'), + }, { + wrappedHeights: { strip: 61, tabs: 59 }, + constrainedByTabsHeight: false, + fitsAtFullStripHeight: true, + }); + }); + 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');