From aa2804f8280072b4126ec7bd3688d76818dc0af9 Mon Sep 17 00:00:00 2001 From: na2co3 Date: Sun, 20 Sep 2026 00:24:40 +0900 Subject: [PATCH 1/4] Modern UI: Keep connected editor tab widths stable at the right edge Reserve the terminal shoulder outside the active tab fill instead of shrinking the fill at the right edge. --- .../browser/media/connectedEditorTabs.css | 10 +++++--- .../browser/modernUI.contribution.test.ts | 8 +++++-- .../editor/multiEditorTabsControl.test.ts | 24 +++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 55933caa1a4cb5..597790ba3f4dc5 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -378,9 +378,13 @@ content: none; } -/* Terminal shoulders turn inside the available slot rather than being cut off. */ -.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row):is(:last-child, .last-in-row, .connected-tab-right-edge) > :is(.tab-fill, .tab-connected-edge) { - right: var(--modern-ui-connected-tab-shoulder-radius); +/* Terminal shoulders keep additional space rather than being cut off. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row):is(:last-child, .last-in-row) { + margin-right: var(--modern-ui-connected-tab-shoulder-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { + margin-right: calc(var(--last-tab-margin-right) + var(--modern-ui-connected-tab-shoulder-radius)) !important; } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { 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 c62e0add89f24f..e045309322c363 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 @@ -2273,7 +2273,7 @@ suite('ModernUIContribution', () => { } }); - test('paints connected tab strokes outside the fill without moving tab content', () => { + test('reserves the connected terminal shoulder without moving tab content', () => { const root = document.createElement('div'); root.style.setProperty('--vscode-spacing-size20', '2px'); root.style.setProperty('--vscode-spacing-size40', '4px'); @@ -2313,6 +2313,8 @@ suite('ModernUIContribution', () => { tab.classList.add('active'); const activeFillBounds = fill.getBoundingClientRect(); const activeFillStyle = targetWindow.getComputedStyle(fill); + const shoulderWidth = targetWindow.getComputedStyle(fill, '::after').width; + const activeTabStyle = targetWindow.getComputedStyle(tab); assert.deepStrictEqual({ tabBounds: tab.getBoundingClientRect().toJSON(), @@ -2322,11 +2324,13 @@ suite('ModernUIContribution', () => { fillBounds.left - activeFillBounds.left, activeFillBounds.right - fillBounds.right, ], + marginRight: activeTabStyle.marginRight, topRadius: activeFillStyle.borderTopLeftRadius, }, { tabBounds: tabBounds.toJSON(), labelBounds: labelBounds.toJSON(), - fillExpansion: [0, 0, connected ? -5 : 0], + fillExpansion: [0, 0, 0], + marginRight: connected ? shoulderWidth : '0px', topRadius: connected ? '5px' : '4px', }, JSON.stringify({ classes, theme, activeGroup, compact })); } 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 97b5400cb6b5c7..25ee9e79f68f97 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -518,6 +518,30 @@ suite('MultiEditorTabsControl', () => { assert.deepStrictEqual({ wrapped, upper, unwrapped }, { wrapped: [true, false], upper: { inset: '-2px', shoulder: 'none' }, unwrapped: [false, false] }); }); + test('connected wrapped last tab adds its shoulder to the editor actions margin', 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 tabsAndActionsContainer = container.querySelector('.tabs-and-actions-container')!; + const tabsContainer = container.querySelector('.tabs-container')!; + tabsContainer.style.setProperty('--last-tab-margin-right', '17px'); + tabsContainer.style.setProperty('--modern-ui-connected-tab-shoulder-radius', '5px'); + const lastTab = tabsContainer.querySelector('.tab:last-child')!; + + assert.deepStrictEqual({ + wrapping: tabsAndActionsContainer.classList.contains('wrapping'), + active: lastTab.classList.contains('active'), + margin: mainWindow.getComputedStyle(lastTab).marginRight, + }, { + wrapping: true, + active: false, + margin: '22px', + }); + }); + test('selected wrapped tabs and focused actions use the document surface on every row', async () => { const group = connectedGroup(); const root = group.closest('.monaco-workbench')!; From 58c9df50fada5caf2a0de8b846d38ae3a2142e65 Mon Sep 17 00:00:00 2001 From: na2co3 Date: Sun, 20 Sep 2026 00:25:57 +0900 Subject: [PATCH 2/4] test: account for the connected tab shoulder in layout expectations Update connected-tab layout test inputs and expectations for the additional 5px terminal shoulder space. --- .../browser/parts/editor/multiEditorTabsControl.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 25ee9e79f68f97..2449ee8f8a25ef 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -395,7 +395,7 @@ suite('MultiEditorTabsControl', () => { await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); }; const results = []; - for (const { width, from } of [{ width: 240, from: 3 }, { width: 167, from: 0 }, { width: 120, from: 0 }]) { + for (const { width, from } of [{ width: 240, from: 3 }, { width: 172, from: 0 }, { width: 120, from: 0 }]) { await reveal(from, width); await reveal(1, width); const tab = container.querySelector('.tab.active')!; @@ -418,7 +418,7 @@ suite('MultiEditorTabsControl', () => { }, { results: [ { width: 240, leftShoulderVisible: true, rightShoulderVisible: true }, - { width: 167, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 172, leftShoulderVisible: true, rightShoulderVisible: true }, { width: 120, leftShoulderVisible: true, rightShoulderVisible: false }, ], firstTabFlush: true, @@ -472,7 +472,7 @@ suite('MultiEditorTabsControl', () => { assert.deepStrictEqual(results, [4, 3, 2].map(count => ({ clampedBeforeLayout: true, activeIndex: count - 1, - scrollLeft: count * 160 - 200, + scrollLeft: count * 160 - 200 + 5, fillLeft: (count - 1) * 160, hidden: false, fillDisplay: 'block', From ad552e3d4f2387b5072142c9e84520482129402a Mon Sep 17 00:00:00 2001 From: na2co3 Date: Sun, 20 Sep 2026 00:35:49 +0900 Subject: [PATCH 3/4] test: verify clipping invalidation without dirty fill-width changes Update the connected clipping regression test to assert invalidation and rebuild directly since `connected-tab-right-edge` invalidation no longer changes the fill bounds. --- .../editor/multiEditorTabsControl.test.ts | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) 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 2449ee8f8a25ef..f1a1b16a5b8545 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -942,7 +942,7 @@ suite('MultiEditorTabsControl', () => { }); }); - test('refreshes connected clipping geometry after dirty width changes', async () => { + test('invalidates connected clipping geometry after dirty and capability changes', async () => { const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; mainWindow.document.body.appendChild(root); @@ -967,49 +967,33 @@ suite('MultiEditorTabsControl', () => { const tabs = container.querySelector('.tabs-container')!; const [firstTab, activeTab] = tabs.querySelectorAll('.tab'); - const activeFill = activeTab.querySelector('.tab-fill')!; const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; const scroll = (left: number) => { tabs.classList.add('scroll'); tabs.scrollLeft = left; tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); }; - const getLogicalFillRight = () => activeFill.getBoundingClientRect().right - tabs.getBoundingClientRect().left + tabs.scrollLeft; scroll(0); - const cleanFillRight = getLogicalFillRight(); const firstEditor = model.getEditorByIndex(0) as TestFileEditorInput; firstEditor.setDirty(); control.updateEditorDirty(firstEditor); - const dirtyFillRight = getLogicalFillRight(); const invalidatedBeforeLayout = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); - const shoulderExtent = Number.parseFloat(mainWindow.getComputedStyle(activeFill, '::after').width); - const targetVisibleRight = (cleanFillRight + dirtyFillRight) / 2 + shoulderExtent; - scroll(targetVisibleRight - tabs.clientWidth); - const visibleRight = tabs.scrollLeft + tabs.clientWidth; - const rightEdge = activeTab.classList.contains('connected-tab-right-edge'); - const currentGeometryNeedsEdge = dirtyFillRight + shoulderExtent > visibleRight; - const staleGeometryWouldNeedEdge = cleanFillRight + shoulderExtent > visibleRight; + const rebuiltAfterLayout = overflowEdge.style.left !== ''; firstEditor.capabilities = EditorInputCapabilities.CannotClose; control.updateEditorCapabilities(firstEditor); const capabilityUpdateInvalidated = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); assert.deepStrictEqual({ firstTabDirty: firstTab.classList.contains('dirty'), - widthIncreased: dirtyFillRight > cleanFillRight, invalidatedBeforeLayout, - rightEdge, - currentGeometryNeedsEdge, - staleGeometryWouldNeedEdge, + rebuiltAfterLayout, capabilityUpdateInvalidated, }, { firstTabDirty: true, - widthIncreased: true, invalidatedBeforeLayout: true, - rightEdge: true, - currentGeometryNeedsEdge: true, - staleGeometryWouldNeedEdge: false, + rebuiltAfterLayout: true, capabilityUpdateInvalidated: true, }); }); From d846b1e8cd63d5d173658cef423c336119c06e87 Mon Sep 17 00:00:00 2001 From: na2co3 Date: Sun, 20 Sep 2026 12:19:44 +0900 Subject: [PATCH 4/4] refactor: remove !important from the wrapped tab margin override Remove `!important` from the Modern UI wrapped-tab margin rule and align the Modern UI selector with the base wrapped-tab rule in multieditortabscontrol.css. --- .../contrib/modernUI/browser/media/connectedEditorTabs.css | 2 +- src/vs/workbench/contrib/modernUI/browser/media/tabs.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 597790ba3f4dc5..923634e83abcca 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -384,7 +384,7 @@ } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { - margin-right: calc(var(--last-tab-margin-right) + var(--modern-ui-connected-tab-shoulder-radius)) !important; + margin-right: calc(var(--last-tab-margin-right) + var(--modern-ui-connected-tab-shoulder-radius)); } .modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 8327533709823c..1046eee3480cb2 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -82,8 +82,8 @@ padding-left: var(--vscode-spacing-size80) !important; } -.modern-ui-tabs .part.editor .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { - margin-right: calc(var(--last-tab-margin-right) + var(--vscode-spacing-size40)) !important; +.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { + margin-right: calc(var(--last-tab-margin-right) + var(--vscode-spacing-size40)); } .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container > .tab {