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 bf9b6e3471ee5..37ba4b9d16711 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 d315c8f77e69e..55e31a1683559 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2104,6 +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); }); + 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 { @@ -2244,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 ) { @@ -2378,6 +2387,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 27a566d256b49..cea416ec6d574 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,15 +173,22 @@ 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. */ .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; } +.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); } @@ -229,6 +242,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 +275,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 +335,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 +564,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 +605,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 +635,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/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 3feb7745e5f30..cb4768e68bb00 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 b9a14a128a4f9..b84f897224db2 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', }); }); @@ -3450,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 e09af4300f217..0000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ /dev/null @@ -1,37 +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); -} - -.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); -} - -.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 deleted file mode 100644 index 77f2a597bf957..0000000000000 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ /dev/null @@ -1,1086 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; -import { Action } from '../../../../../base/common/actions.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { basename, dirname } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { localize } from '../../../../../nls.js'; -import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; -import { MenuService } from '../../../../../platform/actions/common/menuService.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -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'; -import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; -import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; -import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; -import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { EditorInput } from '../../../../common/editor/editorInput.js'; -import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActions, Verbosity } from '../../../../common/editor.js'; -import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; -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'; -import { EditorTitleControl } from '../../../../browser/parts/editor/editorTitleControl.js'; -import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; -import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; -import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; -import { IOutlineService } from '../../../../services/outline/browser/outline.js'; -import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; -import { TestContextService } from '../../../common/workbenchTestServices.js'; -import { workbenchInstantiationService } from '../../workbenchTestServices.js'; -import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; -import '../../../../contrib/modernUI/browser/media/tabs.css'; -import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; -import './editorTabBar.fixture.css'; - -// ============================================================================ -// Fixture editor input -// ============================================================================ - -interface IFixtureEditorInputOptions { - readonly typeId?: string; - readonly dirty?: boolean; - readonly capabilities?: EditorInputCapabilities; - readonly icon?: ThemeIcon | URI; -} - -/** - * A lightweight {@link EditorInput} used purely to populate the tab bar for - * screenshot fixtures. It never resolves a real editor pane; it only provides - * the label, description (folder path), icon and dirty state that the tab bar - * renders. - */ -class FixtureEditorInput extends EditorInput { - - constructor( - readonly resource: URI, - private readonly _options: IFixtureEditorInputOptions = {} - ) { - super(); - } - - override get typeId(): string { return this._options.typeId ?? 'workbench.editors.fixtureEditorInput'; } - override get editorId(): string | undefined { return this.typeId; } - - override get capabilities(): EditorInputCapabilities { - return this._options.capabilities ?? EditorInputCapabilities.None; - } - - override getName(): string { - return basename(this.resource); - } - - /** - * Returns a distinct parent-folder label per {@link Verbosity}, matching how - * real resource editor inputs vary their description. `MultiEditorTabsControl` - * maps `labelFormat` (short/medium/long) to a verbosity, so distinct values - * here are what make the label-format fixtures differ. - */ - override getDescription(verbosity: Verbosity = Verbosity.MEDIUM): string | undefined { - const parent = dirname(this.resource); - if (parent.path === '/' || parent.path === '.' || parent.path === '') { - return undefined; - } - switch (verbosity) { - case Verbosity.SHORT: - return basename(parent); // containing folder name - case Verbosity.LONG: - return parent.path; // full absolute path - case Verbosity.MEDIUM: - default: - return parent.path.replace(/^\//, ''); // path relative to root - } - } - - override getIcon(): ThemeIcon | URI | undefined { - return this._options.icon; - } - - override isDirty(): boolean { - return !!this._options.dirty; - } -} - -// ============================================================================ -// Editor specs used to populate the group model -// ============================================================================ - -interface IEditorSpec { - readonly resource: URI; - readonly typeId?: string; - readonly dirty?: boolean; - readonly icon?: ThemeIcon | URI; - readonly capabilities?: EditorInputCapabilities; - readonly pinned?: boolean; - readonly sticky?: boolean; - readonly active?: boolean; - /** Include this editor in the multi-selection (the active editor is always selected). */ - readonly selected?: boolean; -} - -function file(path: string): URI { - return URI.file(path); -} - -/** A varied set of editors: different input kinds, file names and folder paths. */ -function defaultEditorSpecs(): 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: URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }), typeId: 'workbench.editors.untitledFixture', icon: ThemeIcon.fromId(Codicon.file.id), pinned: false /* preview */ }, - { resource: file('/project/.vscode/settings.json'), icon: ThemeIcon.fromId(Codicon.settingsGear.id), pinned: true }, - { resource: file('/project/src/app/components/button.tsx'), pinned: true }, - { resource: file('/project/tests/app/main.test.ts'), pinned: true }, - ]; -} - -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 = [ - 'main.ts', 'index.ts', 'button.tsx', 'input.tsx', 'list.tsx', 'tree.tsx', - 'model.ts', 'service.ts', 'view.ts', 'controller.ts', 'utils.ts', 'types.ts', - 'app.css', 'theme.css', 'README.md', 'package.json', - ]; - return names.map((name, index) => ({ - resource: file(`/project/src/module${index % 4}/${name}`), - pinned: true, - active: index === activeIndex, - dirty: index % 5 === 0, - })); -} - -/** 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 [ - { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), sticky: true, pinned: true }, - { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), sticky: true, pinned: true }, - { resource: file('/project/package.json'), icon: ThemeIcon.fromId(Codicon.json.id), sticky: true, pinned: true }, - { resource: file('/project/src/app/index.ts'), pinned: true, active: true }, - { resource: file('/project/src/app/components/button.tsx'), pinned: true }, - ]; -} - -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 [ - { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), pinned: true, selected: true }, - { resource: file('/project/src/app/index.ts'), pinned: true }, - { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true, selected: true }, - { resource: file('/project/package.json'), icon: ThemeIcon.fromId(Codicon.json.id), pinned: true, dirty: true, active: true, selected: true }, - { resource: file('/project/src/app/components/button.tsx'), pinned: true }, - { resource: file('/project/tests/app/main.test.ts'), pinned: true, selected: true }, - ]; -} - -/** Editors with very long names/paths to exercise tab-label truncation and ellipsis. */ -function longLabelEditorSpecs(): IEditorSpec[] { - return [ - { resource: file('/project/src/features/authentication/providers/veryLongAuthenticationProviderImplementation.ts'), pinned: true, active: true }, - { resource: file('/project/src/features/authentication/providers/anotherExtremelyLongProviderFactoryModule.ts'), pinned: true }, - { resource: file('/project/documentation/architecture/decisions/0001-use-a-really-long-descriptive-file-name.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true }, - ]; -} - -/** 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 -// ============================================================================ - -/** - * Deterministic file decorations (badge letter + color) keyed by resource path. - * These drive the resource-label badges/colors that the `decorations` setting - * toggles — dirty state alone only affects the separate modified-tab indicator. - */ -const FIXTURE_DECORATIONS = new Map([ - ['/project/package.json', { weight: 10, letter: 'M', color: listWarningForeground, tooltip: 'Modified', bubble: false }], - ['/project/src/app/main.ts', { weight: 20, letter: '2', color: listErrorForeground, tooltip: '2 problems', bubble: false }], - ['/project/src/app/index.ts', { weight: 20, letter: 'U', color: listWarningForeground, tooltip: 'Untracked', bubble: false }], -]); - -function registerFixtureDecorations(decorationsService: IDecorationsService, store: DisposableStore): void { - const provider: IDecorationsProvider = { - label: 'Fixture Decorations', - onDidChange: Event.None, - provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined { - return FIXTURE_DECORATIONS.get(uri.path); - }, - }; - store.add(decorationsService.registerDecorationsProvider(provider)); -} - -// ============================================================================ -// Editor-title toolbar actions -// ============================================================================ - -function createFixtureEditorTitleActions(store: DisposableStore, menuId: MenuId): IToolbarActions { - if (menuId !== MenuId.EditorTitle) { - return { primary: [], secondary: [] }; - } - - return { - primary: [ - store.add(new Action( - 'fixture.splitEditorRight', - localize('fixtureSplitEditorRight', "Split Editor Right"), - ThemeIcon.asClassName(Codicon.splitHorizontal) - )) - ], - secondary: [ - store.add(new Action( - 'fixture.openEditor', - localize('fixtureOpenEditor', "Open Editor..."), - ThemeIcon.asClassName(Codicon.goToFile) - )) - ] - }; -} - -// ============================================================================ -// Rendering -// ============================================================================ - -export interface IEditorTabBarFixtureOptions { - readonly modernUI: boolean; - readonly partOptions?: Partial; - readonly editorTabStyle?: ModernUIEditorTabStyle; - readonly editors?: IEditorSpec[]; - readonly breadcrumbs?: { - readonly filePath?: 'on' | 'off' | 'last'; - readonly icons?: boolean; - }; - readonly width?: number; - /** 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'; -} - -function createPartOptions(overrides?: Partial): IEditorPartOptions { - return { - ...DEFAULT_EDITOR_PART_OPTIONS, - hasIcons: true, - ...overrides, - }; -} - -function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposableStore: DisposableStore): void { - // Open sticky editors first so their indices stay at the front. - const ordered = [...specs].sort((a, b) => (a.sticky === b.sticky) ? 0 : a.sticky ? -1 : 1); - const inputBySpec = new Map(); - for (const spec of ordered) { - const input = disposableStore.add(new FixtureEditorInput(spec.resource, { - typeId: spec.typeId, - dirty: spec.dirty, - icon: spec.icon, - capabilities: spec.capabilities, - })); - inputBySpec.set(spec, input); - model.openEditor(input, { - pinned: spec.pinned ?? true, - sticky: spec.sticky, - active: spec.active, - }); - } - - // Apply multi-selection: the active editor plus any additionally selected ones. - const inactiveSelected = ordered.filter(spec => spec.selected && !spec.active).map(spec => inputBySpec.get(spec)!); - if (inactiveSelected.length && model.activeEditor) { - model.setSelection(model.activeEditor, inactiveSelected); - } -} - -export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: IEditorTabBarFixtureOptions): void { - const { container, disposableStore, theme, fileIconTheme } = ctx; - - const width = options.width ?? 820; - 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), - filePath: options.breadcrumbs?.filePath ?? 'on', - symbolPath: 'off', - icons: options.breadcrumbs?.icons ?? true, - }); - configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, options.modernUI); - configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, options.editorTabStyle ?? ModernUIEditorTabStyle.Connected); - - const instantiationService = workbenchInstantiationService({ - configurationService: () => configurationService, - }, disposableStore); - - // Feed the fixture's themes to the shared theme service so tab-bar theme lookups resolve. - const themeService = instantiationService.get(IThemeService) as TestThemeService; - themeService.setTheme(theme); - themeService.setFileIconTheme(fileIconTheme); - - // Services the base workbench harness does not stub but the tab bar needs. - instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); - instantiationService.stub(INotebookDocumentService, new NotebookDocumentWorkbenchService()); - - const contextKeyService = disposableStore.add(instantiationService.createInstance(ContextKeyService)); - instantiationService.stub(IContextKeyService, contextKeyService); - - if (options.headerMenuIds) { - instantiationService.stub(IMenuService, disposableStore.add(instantiationService.createInstance(MenuService))); - } - - if (options.breadcrumbs) { - instantiationService.stub(IBreadcrumbsService, new BreadcrumbsService()); - instantiationService.stub(IOutlineService, new class extends mock() { }()); - instantiationService.stub(IWorkspaceContextService, new TestContextService(testWorkspace(file('/project')))); - } - - // Real decorations service + provider so resource labels get deterministic badges/colors - // (the `decorations` setting then has something to toggle). - const decorationsService = disposableStore.add(instantiationService.createInstance(DecorationsService)); - instantiationService.stub(IDecorationsService, decorationsService); - registerFixtureDecorations(decorationsService, disposableStore); - - // Real editor group model populated with the fixture editors. - const model = disposableStore.add(instantiationService.createInstance(EditorGroupModel, undefined)); - populateModel(model, options.editors ?? defaultEditorSpecs(), disposableStore); - - const createEditorActions = (disposables: DisposableStore, menuId: MenuId) => { - return { actions: createFixtureEditorTitleActions(disposables, menuId), onDidChange: Event.None }; - }; - - // Lightweight stand-ins for the production `EditorGroupView` / `EditorPart` views. - const groupView = new class extends mock() { - relayoutFn: () => void = () => { }; - override get id() { return model.id; } - override get count() { return model.count; } - override get stickyCount() { return model.stickyCount; } - override get activeEditor() { return model.activeEditor; } - override get activeEditorPane() { return undefined; } - override get selectedEditors() { return model.selectedEditors; } - override get ariaLabel() { return 'Editor Group 1'; } - override get groupsView(): IEditorGroupsView { return groupsView; } - override getEditorByIndex(index: number) { return model.getEditorByIndex(index); } - override getIndexOfEditor(editor: EditorInput) { return model.indexOf(editor); } - override getEditors(order: EditorsOrder, opts?: { excludeSticky?: boolean }) { return model.getEditors(order, opts); } - override isActive(editor: EditorInput) { return model.isActive(editor); } - override isPinned(editorOrIndex: EditorInput | number) { return model.isPinned(editorOrIndex); } - override isSticky(editorOrIndex: EditorInput | number) { return model.isSticky(editorOrIndex); } - override isSelected(editorOrIndex: EditorInput | number) { return model.isSelected(editorOrIndex); } - override createEditorActions(disposables: DisposableStore, menuId = MenuId.EditorTitle) { return createEditorActions(disposables, menuId); } - override relayout() { this.relayoutFn(); } - override readonly onDidActiveEditorChange = Event.None; - }; - - // Separate reference returned as the active group when this group is inactive, so that - // `groupsView.activeGroup === groupView` is false and inactive-group behavior is exercised. - const otherActiveGroup = new class extends mock() { - override focus() { } - }; - - const groupsView = new class extends mock() { - override get partOptions() { return partOptions; } - override get activeGroup(): IEditorGroupView { return isGroupActive ? groupView : otherActiveGroup; } - override get groups(): IEditorGroupView[] { return [groupView]; } - override readonly onDidChangeEditorPartOptions = Event.None; - override readonly onDidVisibilityChange = Event.None; - }; - - const editorPartsView = new class extends mock() { - override get count() { return 1; } - override getGroup() { return groupView; } - }; - - // Recreate the ancestor chain the tab-bar CSS is scoped to; the fixture container already - // carries `.monaco-workbench` + theme classes. - const editorPart = $('.part.editor'); - editorPart.classList.toggle('editor-tabs-multiple', partOptions.showTabs === 'multiple'); - const content = $('.content'); - const groupContainer = $(isGroupActive ? '.editor-group-container.active' : '.editor-group-container'); - const titleContainer = $('.title'); - container.classList.toggle('modern-ui-tabs', options.modernUI); - container.classList.toggle('modern-ui-connected-editor-tabs', options.modernUI && (options.editorTabStyle ?? ModernUIEditorTabStyle.Connected) === ModernUIEditorTabStyle.Connected); - titleContainer.classList.toggle('tabs', partOptions.showTabs === 'multiple'); - titleContainer.classList.toggle('show-file-icons', partOptions.showIcons); - - const headerBackground = theme.getColor(partOptions.showTabs === 'multiple' ? EDITOR_GROUP_HEADER_TABS_BACKGROUND : EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND); - if (headerBackground) { - titleContainer.style.backgroundColor = headerBackground.toString(); - } - - const editorContainer = $('.editor-container'); - editorContainer.style.height = '96px'; - editorContainer.style.backgroundColor = 'var(--vscode-editor-background)'; - - editorPart.appendChild(content); - content.appendChild(groupContainer); - groupContainer.appendChild(titleContainer); - groupContainer.appendChild(editorContainer); - container.appendChild(editorPart); - - if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { - editorContainer.style.height = '240px'; - const editorServices = createEditorServices(disposableStore, { colorTheme: theme }); - const textModel = disposableStore.add(createTextModel(editorServices, options.editorContents, model.activeEditor.resource, 'typescript')); - const editor = disposableStore.add(editorServices.createInstance(CodeEditorWidget, editorContainer, { - readOnly: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - lineNumbers: 'on', - folding: false, - renderLineHighlight: 'none', - padding: { top: 16 }, - }, { contributions: [] })); - editor.setModel(textModel); - editor.layout(new Dimension(width, 240)); - } - - container.style.width = `${width}px`; - groupContainer.style.width = `${width}px`; - - const titleControl = disposableStore.add(instantiationService.createInstance( - EditorTitleControl, - titleContainer, - editorPartsView, - groupsView, - groupView, - model, - options.headerMenuIds, - options.showHeader ?? false, - options.reserveHeaderSpace ? () => true : undefined, - options.useModernUITabs ?? false, - )); - - const layout = () => { - titleControl.layout({ - container: new Dimension(width, titleControl.getHeight().total), - available: new Dimension(width, 200), - }, options.headerWidth); - }; - groupView.relayoutFn = layout; - - 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), () => { - const tabsContainer = titleContainer.querySelector('.tabs-container'); - const activeTab = tabsContainer?.querySelector('.tab.active'); - if (!tabsContainer || !activeTab) { - throw new Error('The clipped tab fixture requires an active tab'); - } - tabsContainer.classList.add('scroll'); - switch (options.activeTabClipping) { - case 'left': - tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2; - break; - case 'right': - tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2 - tabsContainer.clientWidth; - break; - case 'left-shoulder': - tabsContainer.scrollLeft = activeTab.offsetLeft - 4; - break; - case 'right-shoulder': - tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth - tabsContainer.clientWidth; - break; - } - tabsContainer.dispatchEvent(new UIEvent('scroll')); - })); - } -} - -function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { - return (ctx: ComponentFixtureContext) => { - ctx.container.classList.toggle('modern-ui', modernUI); - renderEditorTabBarFixture(ctx, { ...options, modernUI }); - }; -} - -function createFixtures(modernUI: boolean, additionalThemes: readonly ComponentFixtureAdditionalTheme[] = []) { - return { - // Baseline: multiple tabs with mixed sticky / pinned / preview / dirty state. - Default: defineComponentFixture({ - render: render(modernUI, {}), - additionalThemes, - expectedVisualDescriptions: modernUI ? [ - 'Inactive editor tabs and the tab strip use the panel background. The active tab flows into the editor with curved shoulders and an open lower edge. A subtle stroke separates the surfaces. High contrast retains explicit selection borders.', - ] : [], - }), - - // showTabs - ShowTabsSingle: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single' }, breadcrumbs: {} }), additionalThemes }), - ShowTabsSingleCompact: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single', tabHeight: 'compact' }, breadcrumbs: {} }), additionalThemes }), - ShowTabsNone: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'none' } }) }), - - // pinnedTabsOnSeparateRow - PinnedTabsOnSeparateRowAllPinned: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabsOnSeparateRow: true }, editors: allStickyEditorSpecs() }) }), - PinnedTabsOnSeparateRowAllUnpinned: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabsOnSeparateRow: true }, editors: allUnstickyEditorSpecs() }) }), - PinnedTabsOnSeparateRowMixed: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabsOnSeparateRow: true }, editors: stickyEditorSpecs() }), additionalThemes }), - - // breadcrumbs - BreadcrumbsFilePathLast: defineComponentFixture({ render: render(modernUI, { breadcrumbs: { filePath: 'last' }, editors: nestedActiveEditorSpecs() }) }), - BreadcrumbsIconsOff: defineComponentFixture({ render: render(modernUI, { breadcrumbs: { icons: false } }) }), - - // tabSizing - TabSizingShrink: defineComponentFixture({ render: render(modernUI, { partOptions: { tabSizing: 'shrink' }, editors: manyEditorSpecs() }) }), - TabSizingFixed: defineComponentFixture({ render: render(modernUI, { partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 60, tabSizingFixedMaxWidth: 120 }, editors: manyEditorSpecs() }) }), - - // tabHeight - TabHeightCompact: defineComponentFixture({ render: render(modernUI, { partOptions: { tabHeight: 'compact' } }) }), - - // wrapTabs - WrapTabs: defineComponentFixture({ render: render(modernUI, { partOptions: { wrapTabs: true }, editors: manyEditorSpecs(), width: 520 }) }), - - // tabActionLocation - TabActionLocationLeft: defineComponentFixture({ render: render(modernUI, { partOptions: { tabActionLocation: 'left' } }) }), - - // tabActionCloseVisibility - TabActionCloseHidden: defineComponentFixture({ render: render(modernUI, { partOptions: { tabActionCloseVisibility: false } }) }), - - // tabActionUnpinVisibility (with sticky/compact tabs where the unpin action shows) - TabActionUnpinHidden: defineComponentFixture({ render: render(modernUI, { partOptions: { tabActionUnpinVisibility: false, pinnedTabSizing: 'normal' }, editors: stickyEditorSpecs() }) }), - - // tabActionReserveSpace (Modern UI: reserved by default; when disabled clean tabs go compact while dirty/sticky still reserve their indicator column) - TabActionReserveSpaceOn: defineComponentFixture({ render: render(modernUI, { partOptions: { tabActionReserveSpace: true }, editors: reserveSpaceEditorSpecs() }) }), - TabActionReserveSpaceOff: defineComponentFixture({ render: render(modernUI, { partOptions: { tabActionReserveSpace: false }, editors: reserveSpaceEditorSpecs() }) }), - - // showTabIndex - ShowTabIndex: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabIndex: true } }) }), - - // highlightModifiedTabs - HighlightModifiedTabs: defineComponentFixture({ render: render(modernUI, { partOptions: { highlightModifiedTabs: true }, editors: dirtyEditorSpecs() }) }), - - // labelFormat - LabelFormatShort: defineComponentFixture({ render: render(modernUI, { partOptions: { labelFormat: 'short' }, editors: duplicateNameEditorSpecs() }) }), - LabelFormatMedium: defineComponentFixture({ render: render(modernUI, { partOptions: { labelFormat: 'medium' }, editors: duplicateNameEditorSpecs() }) }), - LabelFormatLong: defineComponentFixture({ render: render(modernUI, { partOptions: { labelFormat: 'long' }, editors: duplicateNameEditorSpecs() }) }), - - // showIcons - ShowIconsOff: defineComponentFixture({ render: render(modernUI, { partOptions: { showIcons: false } }) }), - - // decorations (file-decoration badges + colors) - DecorationsOff: defineComponentFixture({ render: render(modernUI, { partOptions: { decorations: { badges: false, colors: false } } }) }), - - // pinnedTabSizing - PinnedTabSizingCompact: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }) }), - PinnedTabSizingShrink: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabSizing: 'shrink' }, editors: stickyEditorSpecs() }) }), - - // titleScrollbarSizing - TitleScrollbarLarge: defineComponentFixture({ render: render(modernUI, { partOptions: { titleScrollbarSizing: 'large' }, editors: manyEditorSpecs(), width: 520 }) }), - - // titleScrollbarVisibility (always-visible scrollbar with overflowing tabs) - TitleScrollbarVisible: defineComponentFixture({ render: render(modernUI, { partOptions: { titleScrollbarVisibility: 'visible' }, editors: manyEditorSpecs(), width: 520 }) }), - - // editorActionsLocation - EditorActionsDefault: defineComponentFixture({ render: render(modernUI, { partOptions: { editorActionsLocation: 'default' } }) }), - EditorActionsTitleBar: defineComponentFixture({ render: render(modernUI, { partOptions: { editorActionsLocation: 'titleBar' } }) }), - EditorActionsHidden: defineComponentFixture({ render: render(modernUI, { partOptions: { editorActionsLocation: 'hidden' } }) }), - - // alwaysShowEditorActions - AlwaysShowEditorActionsActiveGroup: defineComponentFixture({ render: render(modernUI, { partOptions: { alwaysShowEditorActions: true }, active: true }) }), - AlwaysShowEditorActionsInactiveGroup: defineComponentFixture({ render: render(modernUI, { partOptions: { alwaysShowEditorActions: true }, active: false }) }), - - // --- UI states / edge cases (not tied to a single setting) --- - - // Active and inactive group styling. - ActiveGroup: defineComponentFixture({ render: render(modernUI, { active: true }) }), - InactiveGroup: defineComponentFixture({ render: render(modernUI, { active: false }), additionalThemes }), - - // Multi-selection: several tabs in the selected state at once. - MultiSelect: defineComponentFixture({ render: render(modernUI, { editors: multiSelectEditorSpecs() }), additionalThemes }), - - // Inactive group with dirty editors: exercises the unfocused modified-border color path. - InactiveGroupDirty: defineComponentFixture({ render: render(modernUI, { editors: dirtyEditorSpecs(), active: false }) }), - - // Very long labels: tab-label truncation / ellipsis with shrinking tabs. - LongLabelsShrink: defineComponentFixture({ render: render(modernUI, { partOptions: { tabSizing: 'shrink' }, editors: longLabelEditorSpecs(), width: 520 }) }), - - // Drag-and-drop insertion indicator between two tabs. - DropTargetBetweenTabs: defineComponentFixture({ render: render(modernUI, { dropTargetBetweenTabs: true }), additionalThemes }), - - // --- Notable setting combinations --- - - // Sticky compact tabs with icons disabled: the sticky tab falls back to the - // first letter of the name instead of an icon. - StickyCompactNoIcons: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabSizing: 'compact', showIcons: false }, editors: stickyEditorSpecs() }) }), - - // Single-tab mode with a dirty editor: the single tab control renders the dirty dot. - SingleTabDirty: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single' }, editors: singleDirtyEditorSpecs() }) }), - - // Protected editors hide close affordances while ordinary neighboring tabs remain closeable. - CannotCloseActive: defineComponentFixture({ render: render(modernUI, { editors: cannotCloseEditorSpecs() }), additionalThemes }), - - // Protected dirty editors retain the modified indicator without exposing a close action. - CannotCloseDirty: defineComponentFixture({ render: render(modernUI, { editors: cannotCloseDirtyEditorSpecs() }), additionalThemes }), - - // Sticky protected editors retain the Unpin affordance because unpinning does not close them. - CannotCloseSticky: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabSizing: 'normal', tabActionUnpinVisibility: true }, editors: cannotCloseStickyEditorSpecs() }), additionalThemes }), - - // Pinned tabs on a separate row combined with compact pinned sizing. - PinnedSeparateRowCompact: defineComponentFixture({ render: render(modernUI, { partOptions: { pinnedTabsOnSeparateRow: true, pinnedTabSizing: 'compact' }, editors: stickyEditorSpecs() }) }), - }; -} - -function getModernEditorTabColorCustomizations(theme: ComponentFixtureContext['theme']): Readonly> { - 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 createThemeColorFixtures() { - 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 }) }), - }; -} - -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, - }); -} - -const connectedSurfaceThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; - -export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { - FileIconThemes: defineThemedFixtureGroup({ - 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.', - ], - }), - ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), - }), - 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'], - 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.', - ], - }), - }), - 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.'], - }), - 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. Only tabs in the bottom row can connect to the document.'], - }), - BottomWrappedConnected: defineComponentFixture({ - render: renderWrappedConnectedSurface(9), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The selected tab in the bottom wrapped row connects directly to the document well with curved shoulders and no bottom gap, just like a single row. The adjacent inactive tab also reaches the well boundary. Upper-row tabs retain separate rounded pills.'], - }), - UpperWrappedCloseActionHovered: defineComponentFixture({ - render: renderWrappedConnectedCloseActionHover(0), - expectedVisualDescriptions: ['The upper-row pill close action hover background has even inset spacing on every side and remains separated from the pill edge.'], - }), - BottomWrappedCloseActionHovered: defineComponentFixture({ - render: renderWrappedConnectedCloseActionHover(9), - expectedVisualDescriptions: ['The bottom-row connected tab close action hover background has even inset spacing on every side and remains separated from the terminal shoulder.'], - }), - UpperWrappedHover: defineComponentFixture({ - render: renderWrappedConnectedSurface(9, 1), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The hovered inactive tab in the upper wrapped row stays a control-tier pill within its row, without extending into or behind the selected tab below. Its close button is visible. The bottom selected tab remains connected to the document well without a gap.'], - }), - UpperWrappedHoverCompact: defineComponentFixture({ - render: renderWrappedConnectedSurface(9, 1, 'compact'), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['At compact tab height, the hovered inactive upper-row pill and its close button stay clear of the selected tab below. The bottom selected tab still joins the document well without a gap.'], - }), - UpperPinnedPills: defineComponentFixture({ - render: renderConnectedSurface(0, undefined, undefined, { - partOptions: { pinnedTabsOnSeparateRow: true }, - editors: [ - { resource: file('/project/README.md'), sticky: true, pinned: true, active: true }, - { resource: file('/project/src/main.ts'), pinned: true }, - { resource: file('/project/package.json'), pinned: true, dirty: true }, - ], - }), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The active pinned tab in the separate upper strip remains a rounded pill with the document well background, not a disconnected tab-shaped well.'], - }), - InactiveGroup: defineComponentFixture({ - render: renderConnectedSurface(1, undefined, undefined, { active: false }), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The selected tab and document well share a neutral contrast border in an inactive HC editor group, distinct from the focus-colored border of the active group. Standard themes have no prominent well border.'], - }), - Breadcrumbs: defineComponentFixture({ - render: renderConnectedSurface(1, undefined, undefined, { breadcrumbs: {} }), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The connected outline continues down both sides of the breadcrumbs and around the document body, without a line beneath the selected tab.'], - }), - MultiSelect: defineComponentFixture({ - render: renderConnectedSurface(1, undefined, undefined, { editors: multiSelectEditorSpecs() }), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The active tab 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.'], - }), - 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. The strip separator remains continuous beneath the sticky region.'], - }), - ClippedLeft: defineComponentFixture({ - render: render(true, { editors: manyEditorSpecs(), width: 360, activeTabClipping: 'left' }), - additionalThemes: connectedSurfaceThemes, - expectedVisualDescriptions: ['The partially scrolled active tab closes its stationary outside stroke with a straight left edge. Its top stroke, left edge and strip separator remain continuous without exposing clipped tab content.'], - }), - ClippedRight: defineComponentFixture({ - render: render(true, { editors: manyEditorSpecs(5), width: 248, activeTabClipping: 'right' }), - 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.', - ], - }), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts new file mode 100644 index 0000000000000..66d0115a70322 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -0,0 +1,711 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { localize } from '../../../../../nls.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { MenuService } from '../../../../../platform/actions/common/menuService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +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 { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; +import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; +import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActions, Verbosity } from '../../../../common/editor.js'; +import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; +import { + EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND, + EDITOR_GROUP_HEADER_TABS_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'; +import { EditorTitleControl } from '../../../../browser/parts/editor/editorTitleControl.js'; +import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; +import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { IOutlineService } from '../../../../services/outline/browser/outline.js'; +import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; +import { TestContextService } from '../../../common/workbenchTestServices.js'; +import { workbenchInstantiationService } from '../../workbenchTestServices.js'; +import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; + +// ============================================================================ +// Fixture editor input +// ============================================================================ + +interface IFixtureEditorInputOptions { + readonly typeId?: string; + readonly dirty?: boolean; + readonly capabilities?: EditorInputCapabilities; + readonly icon?: ThemeIcon | URI; +} + +/** + * A lightweight {@link EditorInput} used purely to populate the tab bar for + * screenshot fixtures. It never resolves a real editor pane; it only provides + * the label, description (folder path), icon and dirty state that the tab bar + * renders. + */ +class FixtureEditorInput extends EditorInput { + + constructor( + readonly resource: URI, + private readonly _options: IFixtureEditorInputOptions = {} + ) { + super(); + } + + override get typeId(): string { return this._options.typeId ?? 'workbench.editors.fixtureEditorInput'; } + override get editorId(): string | undefined { return this.typeId; } + + override get capabilities(): EditorInputCapabilities { + return this._options.capabilities ?? EditorInputCapabilities.None; + } + + override getName(): string { + return basename(this.resource); + } + + /** + * Returns a distinct parent-folder label per {@link Verbosity}, matching how + * real resource editor inputs vary their description. `MultiEditorTabsControl` + * maps `labelFormat` (short/medium/long) to a verbosity, so distinct values + * here are what make the label-format fixtures differ. + */ + override getDescription(verbosity: Verbosity = Verbosity.MEDIUM): string | undefined { + const parent = dirname(this.resource); + if (parent.path === '/' || parent.path === '.' || parent.path === '') { + return undefined; + } + switch (verbosity) { + case Verbosity.SHORT: + return basename(parent); // containing folder name + case Verbosity.LONG: + return parent.path; // full absolute path + case Verbosity.MEDIUM: + default: + return parent.path.replace(/^\//, ''); // path relative to root + } + } + + override getIcon(): ThemeIcon | URI | undefined { + return this._options.icon; + } + + override isDirty(): boolean { + return !!this._options.dirty; + } +} + +// ============================================================================ +// Editor specs used to populate the group model +// ============================================================================ + +interface IEditorSpec { + readonly resource: URI; + readonly typeId?: string; + readonly dirty?: boolean; + readonly icon?: ThemeIcon | URI; + readonly capabilities?: EditorInputCapabilities; + readonly pinned?: boolean; + readonly sticky?: boolean; + readonly active?: boolean; + /** Include this editor in the multi-selection (the active editor is always selected). */ + readonly selected?: boolean; +} + +function file(path: string): URI { + return URI.file(path); +} + +/** A varied set of editors: different input kinds, file names and folder paths. */ +function defaultEditorSpecs(): 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: URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }), typeId: 'workbench.editors.untitledFixture', icon: ThemeIcon.fromId(Codicon.file.id), pinned: false /* preview */ }, + { resource: file('/project/.vscode/settings.json'), icon: ThemeIcon.fromId(Codicon.settingsGear.id), pinned: true }, + { resource: file('/project/src/app/components/button.tsx'), pinned: true }, + { resource: file('/project/tests/app/main.test.ts'), pinned: true }, + ]; +} + +/** A larger set of editors, useful for wrapping / scrollbar / label variants. */ +function manyEditorSpecs(activeIndex = 0): IEditorSpec[] { + const names = [ + 'main.ts', 'index.ts', 'button.tsx', 'input.tsx', 'list.tsx', 'tree.tsx', + 'model.ts', 'service.ts', 'view.ts', 'controller.ts', 'utils.ts', 'types.ts', + 'app.css', 'theme.css', 'README.md', 'package.json', + ]; + return names.map((name, index) => ({ + resource: file(`/project/src/module${index % 4}/${name}`), + pinned: true, + active: index === activeIndex, + dirty: index % 5 === 0, + })); +} + +/** Sticky (pinned) editors to show the sticky tab styling. */ +function stickyEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), sticky: true, pinned: true }, + { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), sticky: true, pinned: true }, + { resource: file('/project/package.json'), icon: ThemeIcon.fromId(Codicon.json.id), sticky: true, pinned: true }, + { resource: file('/project/src/app/index.ts'), pinned: true, active: true }, + { resource: file('/project/src/app/components/button.tsx'), pinned: true }, + ]; +} + +/** Editors with several tabs in the multi-selection (active + additional selected). */ +function multiSelectEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/src/app/main.ts'), icon: ThemeIcon.fromId(Codicon.symbolFile.id), pinned: true, selected: true }, + { resource: file('/project/src/app/index.ts'), pinned: true }, + { resource: file('/project/README.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true, selected: true }, + { resource: file('/project/package.json'), icon: ThemeIcon.fromId(Codicon.json.id), pinned: true, dirty: true, active: true, selected: true }, + { resource: file('/project/src/app/components/button.tsx'), pinned: true }, + { resource: file('/project/tests/app/main.test.ts'), pinned: true, selected: true }, + ]; +} + +/** Editors with very long names/paths to exercise tab-label truncation and ellipsis. */ +function longLabelEditorSpecs(): IEditorSpec[] { + return [ + { resource: file('/project/src/features/authentication/providers/veryLongAuthenticationProviderImplementation.ts'), pinned: true, active: true }, + { resource: file('/project/src/features/authentication/providers/anotherExtremelyLongProviderFactoryModule.ts'), pinned: true }, + { resource: file('/project/documentation/architecture/decisions/0001-use-a-really-long-descriptive-file-name.md'), icon: ThemeIcon.fromId(Codicon.markdown.id), pinned: true }, + ]; +} + +// ============================================================================ +// File decorations +// ============================================================================ + +/** + * Deterministic file decorations (badge letter + color) keyed by resource path. + * These drive the resource-label badges/colors that the `decorations` setting + * toggles — dirty state alone only affects the separate modified-tab indicator. + */ +const FIXTURE_DECORATIONS = new Map([ + ['/project/package.json', { weight: 10, letter: 'M', color: listWarningForeground, tooltip: 'Modified', bubble: false }], + ['/project/src/app/main.ts', { weight: 20, letter: '2', color: listErrorForeground, tooltip: '2 problems', bubble: false }], + ['/project/src/app/index.ts', { weight: 20, letter: 'U', color: listWarningForeground, tooltip: 'Untracked', bubble: false }], +]); + +function registerFixtureDecorations(decorationsService: IDecorationsService, store: DisposableStore): void { + const provider: IDecorationsProvider = { + label: 'Fixture Decorations', + onDidChange: Event.None, + provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined { + return FIXTURE_DECORATIONS.get(uri.path); + }, + }; + store.add(decorationsService.registerDecorationsProvider(provider)); +} + +// ============================================================================ +// Editor-title toolbar actions +// ============================================================================ + +function createFixtureEditorTitleActions(store: DisposableStore, menuId: MenuId): IToolbarActions { + if (menuId !== MenuId.EditorTitle) { + return { primary: [], secondary: [] }; + } + + return { + primary: [ + store.add(new Action( + 'fixture.splitEditorRight', + localize('fixtureSplitEditorRight', "Split Editor Right"), + ThemeIcon.asClassName(Codicon.splitHorizontal) + )) + ], + secondary: [ + store.add(new Action( + 'fixture.openEditor', + localize('fixtureOpenEditor', "Open Editor..."), + ThemeIcon.asClassName(Codicon.goToFile) + )) + ] + }; +} + +// ============================================================================ +// Rendering +// ============================================================================ + +export interface IEditorTabsFixtureOptions { + readonly modernUI: boolean; + readonly partOptions?: Partial; + readonly editorTabStyle?: ModernUIEditorTabStyle; + readonly editors?: IEditorSpec[]; + readonly breadcrumbs?: { + readonly filePath?: 'on' | 'off' | 'last'; + readonly icons?: boolean; + }; + readonly width?: number; + /** Whether this group is the active group. Inactive groups exercise the + * `alwaysShowEditorActions` filtering and unfocused tab styling. */ + readonly active?: boolean; + readonly showHeader?: boolean; + readonly useModernUITabs?: boolean; + readonly reserveHeaderSpace?: boolean; + readonly headerWidth?: number; + readonly headerMenuIds?: IEditorGroupMenuIds; + readonly editorContents?: string; + readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; +} + +function createPartOptions(overrides?: Partial): IEditorPartOptions { + return { + ...DEFAULT_EDITOR_PART_OPTIONS, + hasIcons: true, + ...overrides, + }; +} + +function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposableStore: DisposableStore): void { + // Open sticky editors first so their indices stay at the front. + const ordered = [...specs].sort((a, b) => (a.sticky === b.sticky) ? 0 : a.sticky ? -1 : 1); + const inputBySpec = new Map(); + for (const spec of ordered) { + const input = disposableStore.add(new FixtureEditorInput(spec.resource, { + typeId: spec.typeId, + dirty: spec.dirty, + icon: spec.icon, + capabilities: spec.capabilities, + })); + inputBySpec.set(spec, input); + model.openEditor(input, { + pinned: spec.pinned ?? true, + sticky: spec.sticky, + active: spec.active, + }); + } + + // Apply multi-selection: the active editor plus any additionally selected ones. + const inactiveSelected = ordered.filter(spec => spec.selected && !spec.active).map(spec => inputBySpec.get(spec)!); + if (inactiveSelected.length && model.activeEditor) { + model.setSelection(model.activeEditor, inactiveSelected); + } +} + +export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: IEditorTabsFixtureOptions): void { + const { container, disposableStore, theme, fileIconTheme } = ctx; + + const width = options.width ?? 820; + const isGroupActive = options.active ?? true; + const partOptions = createPartOptions(options.partOptions); + + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration('breadcrumbs', { + enabled: Boolean(options.breadcrumbs), + filePath: options.breadcrumbs?.filePath ?? 'on', + symbolPath: 'off', + icons: options.breadcrumbs?.icons ?? true, + }); + configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, options.modernUI); + configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, options.editorTabStyle ?? ModernUIEditorTabStyle.Connected); + + const instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService, + }, disposableStore); + + // Feed the fixture's themes to the shared theme service so tab-bar theme lookups resolve. + const themeService = instantiationService.get(IThemeService) as TestThemeService; + themeService.setTheme(theme); + themeService.setFileIconTheme(fileIconTheme); + + // Services the base workbench harness does not stub but the tab bar needs. + instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); + instantiationService.stub(INotebookDocumentService, new NotebookDocumentWorkbenchService()); + + const contextKeyService = disposableStore.add(instantiationService.createInstance(ContextKeyService)); + instantiationService.stub(IContextKeyService, contextKeyService); + + if (options.headerMenuIds) { + instantiationService.stub(IMenuService, disposableStore.add(instantiationService.createInstance(MenuService))); + } + + if (options.breadcrumbs) { + instantiationService.stub(IBreadcrumbsService, new BreadcrumbsService()); + instantiationService.stub(IOutlineService, new class extends mock() { }()); + instantiationService.stub(IWorkspaceContextService, new TestContextService(testWorkspace(file('/project')))); + } + + // Real decorations service + provider so resource labels get deterministic badges/colors + // (the `decorations` setting then has something to toggle). + const decorationsService = disposableStore.add(instantiationService.createInstance(DecorationsService)); + instantiationService.stub(IDecorationsService, decorationsService); + registerFixtureDecorations(decorationsService, disposableStore); + + // Real editor group model populated with the fixture editors. + const model = disposableStore.add(instantiationService.createInstance(EditorGroupModel, undefined)); + populateModel(model, options.editors ?? defaultEditorSpecs(), disposableStore); + + const createEditorActions = (disposables: DisposableStore, menuId: MenuId) => { + return { actions: createFixtureEditorTitleActions(disposables, menuId), onDidChange: Event.None }; + }; + + // Lightweight stand-ins for the production `EditorGroupView` / `EditorPart` views. + const groupView = new class extends mock() { + relayoutFn: () => void = () => { }; + override get id() { return model.id; } + override get count() { return model.count; } + override get stickyCount() { return model.stickyCount; } + override get activeEditor() { return model.activeEditor; } + override get activeEditorPane() { return undefined; } + override get selectedEditors() { return model.selectedEditors; } + override get ariaLabel() { return 'Editor Group 1'; } + override get groupsView(): IEditorGroupsView { return groupsView; } + override getEditorByIndex(index: number) { return model.getEditorByIndex(index); } + override getIndexOfEditor(editor: EditorInput) { return model.indexOf(editor); } + override getEditors(order: EditorsOrder, opts?: { excludeSticky?: boolean }) { return model.getEditors(order, opts); } + override isActive(editor: EditorInput) { return model.isActive(editor); } + override isPinned(editorOrIndex: EditorInput | number) { return model.isPinned(editorOrIndex); } + override isSticky(editorOrIndex: EditorInput | number) { return model.isSticky(editorOrIndex); } + override isSelected(editorOrIndex: EditorInput | number) { return model.isSelected(editorOrIndex); } + override createEditorActions(disposables: DisposableStore, menuId = MenuId.EditorTitle) { return createEditorActions(disposables, menuId); } + override relayout() { this.relayoutFn(); } + override readonly onDidActiveEditorChange = Event.None; + }; + + // Separate reference returned as the active group when this group is inactive, so that + // `groupsView.activeGroup === groupView` is false and inactive-group behavior is exercised. + const otherActiveGroup = new class extends mock() { + override focus() { } + }; + + const groupsView = new class extends mock() { + override get partOptions() { return partOptions; } + override get activeGroup(): IEditorGroupView { return isGroupActive ? groupView : otherActiveGroup; } + override get groups(): IEditorGroupView[] { return [groupView]; } + override readonly onDidChangeEditorPartOptions = Event.None; + override readonly onDidVisibilityChange = Event.None; + }; + + const editorPartsView = new class extends mock() { + override get count() { return 1; } + override getGroup() { return groupView; } + }; + + // Recreate the ancestor chain the tab-bar CSS is scoped to; the fixture container already + // carries `.monaco-workbench` + theme classes. + const editorPart = $('.part.editor'); + editorPart.classList.toggle('editor-tabs-multiple', partOptions.showTabs === 'multiple'); + const content = $('.content'); + const groupContainer = $(isGroupActive ? '.editor-group-container.active' : '.editor-group-container'); + const titleContainer = $('.title'); + container.classList.toggle('modern-ui-tabs', options.modernUI); + container.classList.toggle('modern-ui-connected-editor-tabs', options.modernUI && (options.editorTabStyle ?? ModernUIEditorTabStyle.Connected) === ModernUIEditorTabStyle.Connected); + titleContainer.classList.toggle('tabs', partOptions.showTabs === 'multiple'); + titleContainer.classList.toggle('show-file-icons', partOptions.showIcons); + + const headerBackground = theme.getColor(partOptions.showTabs === 'multiple' ? EDITOR_GROUP_HEADER_TABS_BACKGROUND : EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND); + if (headerBackground) { + titleContainer.style.backgroundColor = headerBackground.toString(); + } + + const editorContainer = $('.editor-container'); + editorContainer.style.height = '96px'; + editorContainer.style.backgroundColor = 'var(--vscode-editor-background)'; + + editorPart.appendChild(content); + content.appendChild(groupContainer); + groupContainer.appendChild(titleContainer); + groupContainer.appendChild(editorContainer); + container.appendChild(editorPart); + + if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { + editorContainer.style.height = '240px'; + const editorServices = createEditorServices(disposableStore, { colorTheme: theme }); + const textModel = disposableStore.add(createTextModel(editorServices, options.editorContents, model.activeEditor.resource, 'typescript')); + const editor = disposableStore.add(editorServices.createInstance(CodeEditorWidget, editorContainer, { + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: 'on', + folding: false, + renderLineHighlight: 'none', + padding: { top: 16 }, + }, { contributions: [] })); + editor.setModel(textModel); + editor.layout(new Dimension(width, 240)); + } + + container.style.width = `${width}px`; + groupContainer.style.width = `${width}px`; + + const titleControl = disposableStore.add(instantiationService.createInstance( + EditorTitleControl, + titleContainer, + editorPartsView, + groupsView, + groupView, + model, + options.headerMenuIds, + options.showHeader ?? false, + options.reserveHeaderSpace ? () => true : undefined, + options.useModernUITabs ?? false, + )); + + const layout = () => { + titleControl.layout({ + container: new Dimension(width, titleControl.getHeight().total), + available: new Dimension(width, 200), + }, options.headerWidth); + }; + groupView.relayoutFn = layout; + + titleControl.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + titleControl.setActive(isGroupActive); + layout(); + if (options.activeTabClipping) { + disposableStore.add(scheduleAtNextAnimationFrame(getWindow(container), () => { + const tabsContainer = titleContainer.querySelector('.tabs-container'); + const activeTab = tabsContainer?.querySelector('.tab.active'); + if (!tabsContainer || !activeTab) { + throw new Error('The clipped tab fixture requires an active tab'); + } + tabsContainer.classList.add('scroll'); + switch (options.activeTabClipping) { + case 'left': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2; + break; + case 'right': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth / 2 - tabsContainer.clientWidth; + break; + case 'left-shoulder': + tabsContainer.scrollLeft = activeTab.offsetLeft - 4; + break; + case 'right-shoulder': + tabsContainer.scrollLeft = activeTab.offsetLeft + activeTab.offsetWidth - tabsContainer.clientWidth; + break; + } + tabsContainer.dispatchEvent(new UIEvent('scroll')); + })); + } +} + +function render(modernUI: boolean, options: Omit): (ctx: ComponentFixtureContext) => void { + return (ctx: ComponentFixtureContext) => { + ctx.container.classList.toggle('modern-ui', modernUI); + renderEditorTabsFixture(ctx, { ...options, modernUI }); + }; +} + +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; + 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 createDensityFixtures() { + return { + 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.'], + }), + }), + 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.'], + }), + }), + 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.'], + }), + }), + }; +} + +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.'], + }), + 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.'], + }), + 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(tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => void { + const editors = defaultEditorSpecs().slice(0, 3).map((spec, index) => ({ + ...spec, + sticky: index === 0, + pinned: true, + dirty: false, + active: index === 1, + })); + return render(true, { + editors, + partOptions: { + editorActionsLocation: 'hidden', + pinnedTabsOnSeparateRow: true, + pinnedTabSizing: 'normal', + tabHeight, + tabActionUnpinVisibility: true, + }, + }); +} + +const extendedTabThemes: readonly ComponentFixtureAdditionalTheme[] = ['darkModern', 'light2026', 'darkPlus', 'lightPlus', 'visualStudioDark', 'visualStudioLight', 'darkHighContrast', 'lightHighContrast', 'abyss', 'monokai', 'quietLight', 'solarizedDark', 'solarizedLight']; + +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, {}) }), + }), + 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, + expectedVisualDescriptions: [ + '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.', + ], + }), + }), +}); 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 892db65d4f27e..51021561ab818 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; @@ -826,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; @@ -972,9 +1093,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 +1108,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,27 +1223,63 @@ 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 }, ]); }); + 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'); @@ -1135,16 +1295,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 +1603,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' }, diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 5da3689124017..37102bf383608 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();