diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index e8ce86275e355..0db7b869d720e 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1027,6 +1027,8 @@ "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", + "--connected-tab-min-name-width", + "--connected-tab-min-width", "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", @@ -1073,6 +1075,15 @@ "--pane-header-size", "--model-hover-surface-background", "--modern-ui-activitybar-lane", + "--modern-ui-connected-tab-action-background", + "--modern-ui-connected-tab-border", + "--modern-ui-connected-tab-cap-radius", + "--modern-ui-connected-tab-gutter", + "--modern-ui-connected-tab-radius", + "--modern-ui-connected-tab-shoulder-radius", + "--modern-ui-connected-tab-surface", + "--modern-ui-connected-tab-top-inset", + "--modern-ui-connected-well-border", "--modern-ui-editor-border-color", "--modern-ui-editor-tab-action-active-background", "--modern-ui-editor-tab-action-active-hover-background", @@ -1123,6 +1134,7 @@ "--modern-ui-notifications-block-start-inset", "--modern-ui-notifications-inline-inset", "--modern-ui-shell-background", + "--modern-ui-tab-action-padding", "--modern-ui-tab-active-background", "--modern-ui-tab-hover-background", "--search-editor-query-layout-offset", diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 58b6a7beacce2..ca410bdd94491 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -110,7 +110,7 @@ export default { // Built-in color theme JSON files use JSONC (comments / trailing // commas), so import them as raw strings and let VS Code's // JSON parser handle them. - test: /[\\/]extensions[\\/]theme-defaults[\\/]themes[\\/].*\.json$/, + test: /[\\/]extensions[\\/]theme-[^\\/]+[\\/]themes[\\/].*\.json$/, type: 'asset/source', }, ], diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 50ffbbf5cd600..fd533bf1680ab 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -194,7 +194,7 @@ "statusBarItem.prominentHoverBackground": "#3994BC", "tab.activeBackground": "#121314", "tab.activeForeground": "#bfbfbf", - "tab.inactiveBackground": "#191A1B", + "tab.inactiveBackground": "#202122", "tab.inactiveForeground": "#8C8C8C", "tab.border": "#2A2B2C", "tab.lastPinnedBorder": "#2A2B2C", @@ -205,7 +205,7 @@ "tab.unfocusedActiveForeground": "#8C8C8C", "tab.unfocusedInactiveBackground": "#191A1B", "tab.unfocusedInactiveForeground": "#444444", - "editorGroupHeader.tabsBackground": "#191A1B", + "editorGroupHeader.tabsBackground": "#202122", "tab.activeBorder": "#121314", "editorGroupHeader.tabsBorder": "#2A2B2C", "breadcrumb.foreground": "#8C8C8C", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 13ddd867d8d5f..45dc4f1f026cf 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -201,7 +201,7 @@ "toolbar.activeBackground": "#D6D6D8", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", - "tab.inactiveBackground": "#FAFAFD", + "tab.inactiveBackground": "#EAEAEA", "tab.inactiveForeground": "#606060", "tab.border": "#F0F1F2", "tab.lastPinnedBorder": "#F0F1F2", @@ -212,7 +212,7 @@ "tab.unfocusedActiveForeground": "#606060", "tab.unfocusedInactiveBackground": "#FAFAFD", "tab.unfocusedInactiveForeground": "#BBBBBB", - "editorGroupHeader.tabsBackground": "#FAFAFD", + "editorGroupHeader.tabsBackground": "#EAEAEA", "tab.activeBorder": "#FFFFFF", "editorGroupHeader.tabsBorder": "#F0F1F2", "breadcrumb.foreground": "#606060", diff --git a/extensions/theme-defaults/themes/dark_modern.json b/extensions/theme-defaults/themes/dark_modern.json index 26f4f2a474a94..67688754a90f3 100644 --- a/extensions/theme-defaults/themes/dark_modern.json +++ b/extensions/theme-defaults/themes/dark_modern.json @@ -34,7 +34,7 @@ "editor.findMatchBackground": "#9E6A03", "editor.foreground": "#CCCCCC", "editorGroup.border": "#FFFFFF17", - "editorGroupHeader.tabsBackground": "#181818", + "editorGroupHeader.tabsBackground": "#2B2B2B", "editorGroupHeader.tabsBorder": "#2B2B2B", "editorGutter.addedBackground": "#2EA043", "editorGutter.deletedBackground": "#F85149", @@ -107,7 +107,7 @@ "tab.selectedBorderTop": "#6caddf", "tab.border": "#2B2B2B", "tab.hoverBackground": "#1F1F1F", - "tab.inactiveBackground": "#181818", + "tab.inactiveBackground": "#2B2B2B", "tab.inactiveForeground": "#9D9D9D", "tab.unfocusedActiveBorder": "#1F1F1F", "tab.unfocusedActiveBorderTop": "#2B2B2B", diff --git a/extensions/theme-defaults/themes/dark_vs.json b/extensions/theme-defaults/themes/dark_vs.json index 0a0376f37d714..7b9c6d4a99b6f 100644 --- a/extensions/theme-defaults/themes/dark_vs.json +++ b/extensions/theme-defaults/themes/dark_vs.json @@ -5,6 +5,7 @@ "checkbox.border": "#6B6B6B", "editor.background": "#1E1E1E", "editor.foreground": "#D4D4D4", + "editorGroupHeader.tabsBackground": "#303031", "editor.inactiveSelectionBackground": "#3A3D41", "editorIndentGuide.background1": "#404040", "editorIndentGuide.activeBackground1": "#707070", @@ -27,6 +28,8 @@ "sideBarSectionHeader.background": "#0000", "sideBarSectionHeader.border": "#ccc3", "tab.selectedBackground": "#37373D", + "tab.inactiveBackground": "#303031", + "tab.inactiveForeground": "#A6A6A6", "tab.selectedForeground": "#FFFFFF", "tab.lastPinnedBorder": "#ccc3", "list.activeSelectionIconForeground": "#FFF", diff --git a/extensions/theme-defaults/themes/light_modern.json b/extensions/theme-defaults/themes/light_modern.json index 51d6f26cab550..d76ab0458cb76 100644 --- a/extensions/theme-defaults/themes/light_modern.json +++ b/extensions/theme-defaults/themes/light_modern.json @@ -34,7 +34,7 @@ "editor.inactiveSelectionBackground": "#E5EBF1", "editor.selectionHighlightBackground": "#ADD6FF80", "editorGroup.border": "#E5E5E5", - "editorGroupHeader.tabsBackground": "#F8F8F8", + "editorGroupHeader.tabsBackground": "#E5E5E5", "editorGroupHeader.tabsBorder": "#E5E5E5", "editorGutter.addedBackground": "#2EA043", "editorGutter.deletedBackground": "#F85149", @@ -124,8 +124,8 @@ "tab.selectedBorderTop": "#68a3da", "tab.border": "#E5E5E5", "tab.hoverBackground": "#FFFFFF", - "tab.inactiveBackground": "#F8F8F8", - "tab.inactiveForeground": "#868686", + "tab.inactiveBackground": "#E5E5E5", + "tab.inactiveForeground": "#616161", "tab.lastPinnedBorder": "#D4D4D4", "tab.unfocusedActiveBorder": "#F8F8F8", "tab.unfocusedActiveBorderTop": "#E5E5E5", diff --git a/extensions/theme-defaults/themes/light_vs.json b/extensions/theme-defaults/themes/light_vs.json index 991bb3b077358..3c5363c5a500c 100644 --- a/extensions/theme-defaults/themes/light_vs.json +++ b/extensions/theme-defaults/themes/light_vs.json @@ -5,6 +5,7 @@ "checkbox.border": "#919191", "editor.background": "#FFFFFF", "editor.foreground": "#000000", + "editorGroupHeader.tabsBackground": "#E8E8E8", "editor.inactiveSelectionBackground": "#E5EBF1", "editorIndentGuide.background1": "#D3D3D3", "editorIndentGuide.activeBackground1": "#939393", @@ -26,6 +27,8 @@ "sideBarSectionHeader.background": "#0000", "sideBarSectionHeader.border": "#61616130", "tab.selectedForeground": "#333333", + "tab.inactiveBackground": "#E8E8E8", + "tab.inactiveForeground": "#616161", "tab.selectedBackground": "#E4E6F1", "tab.lastPinnedBorder": "#61616130", "notebook.cellBorderColor": "#E8E8E8", diff --git a/extensions/theme-quietlight/themes/quietlight-color-theme.json b/extensions/theme-quietlight/themes/quietlight-color-theme.json index ab87b0c23db13..253cfb58fe32e 100644 --- a/extensions/theme-quietlight/themes/quietlight-color-theme.json +++ b/extensions/theme-quietlight/themes/quietlight-color-theme.json @@ -495,6 +495,7 @@ "list.activeSelectionBackground": "#c4d9b1", "list.inactiveSelectionBackground": "#d3dbcd", "list.highlightForeground": "#9769dc", + "list.warningForeground": "#765500", "selection.background": "#C9D0D9", "editor.background": "#F5F5F5", "editorWhitespace.foreground": "#AAAAAA", @@ -520,6 +521,11 @@ "peekViewResult.background": "#F2F8FC", "peekView.border": "#705697", "peekViewResult.matchHighlightBackground": "#93C6D6", + "editorGroupHeader.tabsBackground": "#E4E4E4", + "tab.inactiveBackground": "#E4E4E4", + "tab.inactiveForeground": "#54494B", + "tab.unfocusedInactiveForeground": "#54494B", + "tab.hoverBackground": "#F2F2F2", "tab.lastPinnedBorder": "#c9d0d9", "statusBar.background": "#705697", "statusBar.inactiveBackground": "#715D91", @@ -548,6 +554,7 @@ "editorIndentGuide.background": "#aaaaaa60", "editorIndentGuide.activeBackground": "#777777b0", // modern ui + "modernEditorTab.hoverForeground": "#54494B", "surface.border": "#00000000", "modernSash.gripForeground": "#705697", }, diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index e00a96d56f3e0..d81722d63affa 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -636,6 +636,7 @@ class ResourceLabelWidget extends IconLabel { strikethrough: this.options?.strikethrough, matches: this.options?.matches, descriptionMatches: this.options?.descriptionMatches, + suffix: this.options?.suffix, extraClasses: [], separator: this.options?.separator, domId: this.options?.domId, diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index a9086636fb189..a74bb28980c6e 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1061,6 +1061,9 @@ export class EditorPart extends Part implements IEditorPart, // Container this.element = parent; + const updateEditorTabsClass = () => parent.classList.toggle('editor-tabs-multiple', this.partOptions.showTabs === 'multiple'); + updateEditorTabsClass(); + this._register(this.onDidChangeEditorPartOptions(updateEditorTabsClass)); if (this.windowId !== mainWindow.vscodeWindowId) { this.container.classList.add('auxiliary'); } diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index c26072ed3d7fd..6e0590648871f 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -629,7 +629,9 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC // hosts using modern UI tabs opt into the same aligned height. const usesModernMultiTabHeight = this.parent.classList.contains('tabs') && this.parent.closest('.modern-ui-tabs'); if (usesModernMultiTabHeight || this.useModernUITabs) { - return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.modernUICompact : EditorTabsControl.EDITOR_TAB_HEIGHT.modernUI; + const height = isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.modernUICompact : EditorTabsControl.EDITOR_TAB_HEIGHT.modernUI; + // Connected tabs reserve one extra pixel for the separator below the gutter. + return height + (usesModernMultiTabHeight && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') ? 1 : 0); } return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.compact : EditorTabsControl.EDITOR_TAB_HEIGHT.normal; } diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index c1e576e5a9ca3..72ad2119dd039 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -123,6 +123,14 @@ outline-offset: -2px; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-connected-edge { + display: none; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge { + display: none; +} + /* Tab Background Color in/active group/tab */ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { background-color: var(--vscode-tab-unfocusedInactiveBackground); diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 0ca5e0ba4d40f..ad9b340f3eba6 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -24,7 +24,7 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { IDisposable, dispose, DisposableStore, combinedDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; -import { getOrSet } from '../../../../base/common/map.js'; +import { getOrSet, LRUCache } from '../../../../base/common/map.js'; import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { TAB_INACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND, TAB_BORDER, EDITOR_DRAG_AND_DROP_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_ACTIVE_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, EDITOR_GROUP_HEADER_TABS_BACKGROUND, WORKBENCH_BACKGROUND, TAB_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_ACTIVE_MODIFIED_BORDER, TAB_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_ACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_MODIFIED_BORDER, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_HOVER_FOREGROUND, TAB_UNFOCUSED_HOVER_FOREGROUND, EDITOR_GROUP_HEADER_TABS_BORDER, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BORDER_TOP } from '../../../common/theme.js'; import { activeContrastBorder, contrastBorder, editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; @@ -32,13 +32,13 @@ import { ResourcesDropHandler, DraggedEditorIdentifier, DraggedEditorGroupIdenti import { Color } from '../../../../base/common/color.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { MergeGroupMode, IMergeGroupOptions } from '../../../services/editor/common/editorGroupsService.js'; -import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow, ModifierKeyEmitter, $ } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow, ModifierKeyEmitter, $, isHTMLElement } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; import { IEditorGroupMenuIds, IEditorGroupsView, EditorServiceImpl, IEditorGroupView, IInternalEditorOpenOptions, IEditorPartsView, prepareMoveCopyEditors } from './editor.js'; import { CloseEditorTabAction, CloseOtherEditorTabsInGroupAction, UnpinEditorAction } from './editorActions.js'; import { assertReturnsAllDefined, assertReturnsDefined } from '../../../../base/common/types.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { basenameOrAuthority } from '../../../../base/common/resources.js'; +import { basename, basenameOrAuthority, extname } from '../../../../base/common/resources.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { IPathService } from '../../../services/path/common/pathService.js'; import { IPath, win32, posix } from '../../../../base/common/path.js'; @@ -111,8 +111,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { private stickyTabsBackground: HTMLElement | undefined; private tabsContainer: HTMLElement | undefined; private tabsScrollbar: ScrollableElement | undefined; + private connectedTabOverflowEdge: HTMLElement | undefined; + private connectedTabLabels = false; + private connectedTabMeasureContext: CanvasRenderingContext2D | undefined; + private readonly connectedTabTextWidths = new LRUCache(256); private addTabContainer: HTMLElement | undefined; private tabSizingFixedDisposables: DisposableStore | undefined; + private connectedTabBounds: { tab: HTMLElement; overflowEdge: HTMLElement; fillLeft: number; fillRight: number; viewportLeft: number; viewportRight: number; clippingEdgeExtent: number; shoulderExtent: number } | undefined; private readonly closeEditorAction = this._register(this.instantiationService.createInstance(CloseEditorTabAction, CloseEditorTabAction.ID, CloseEditorTabAction.LABEL)); private readonly unpinEditorAction = this._register(this.instantiationService.createInstance(UnpinEditorAction, UnpinEditorAction.ID, UnpinEditorAction.LABEL)); @@ -270,6 +275,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.tabsScrollbar = this.createTabsScrollbar(this.tabsContainer); this.tabsAndActionsContainer.appendChild(this.tabsScrollbar.getDomNode()); this.tabsScrollbar.getDomNode().appendChild(this.stickyTabsBackground); + this.connectedTabOverflowEdge = $('.tab-connected-overflow-edge', { 'aria-hidden': true }); + this.connectedTabOverflowEdge.appendChild($('.tab-connected-overflow-right')); + this.tabsScrollbar.getDomNode().appendChild(this.connectedTabOverflowEdge); // Tabs Container listeners this.registerTabsContainerListeners(this.tabsContainer, this.tabsScrollbar); @@ -324,6 +332,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { this._register(tabsScrollbar.onScroll(e => { if (e.scrollLeftChanged) { scrollable.scrollLeft = e.scrollLeft; + this.updateConnectedTabClipping(e.scrollLeft); } })); @@ -839,6 +848,14 @@ export class MultiEditorTabsControl extends EditorTabsControl { this.forEachTab((editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => { this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar); }); + this.invalidateConnectedTabLayout(); + } + + private invalidateConnectedTabLayout(): void { + if (this.connectedTabBounds || this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')) { + this.clearConnectedTabClipping(); + this.layout(this.dimensions); + } } private updateEditorLabelScheduler = this._register(new RunOnceScheduler(() => this.doUpdateEditorLabels(), 0)); @@ -869,10 +886,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { updateEditorDirty(editor: EditorInput): void { this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTabSelectedActiveAndDirty(this.groupsView.activeGroup === this.groupView, editor, tabContainer, tabActionBar)); + this.invalidateConnectedTabLayout(); } updateEditorCapabilities(editor: EditorInput): void { this.withTab(editor, (editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar) => this.redrawTab(editor, tabIndex, tabContainer, tabLabelWidget, tabLabel, tabActionBar)); + this.invalidateConnectedTabLayout(); } override updateOptions(oldOptions: IEditorPartOptions, newOptions: IEditorPartOptions): void { @@ -970,10 +989,12 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Gesture Support const gestureDisposable = Gesture.addTarget(tabContainer); - // Tab Fill (Modern UI pill background). A real element is used because the - // tab's ::before/::after pseudo-elements are reserved for drop-target indicators. + // Modern UI fill and shoulder masks. Real elements leave the tab's + // pseudo-elements available for drop-target indicators. const tabFillContainer = $('.tab-fill', { 'aria-hidden': true }); tabContainer.appendChild(tabFillContainer); + const tabConnectedEdgeContainer = $('.tab-connected-edge', { 'aria-hidden': true }); + tabContainer.appendChild(tabConnectedEdgeContainer); // Tab Border Top const tabBorderTopContainer = $('.tab-border-top-container'); @@ -1804,6 +1825,15 @@ export class MultiEditorTabsControl extends EditorTabsControl { } // Label + const resource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); + let suffix: string | undefined; + if (name && resource && name === basename(resource) && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs') && !(options.pinnedTabSizing === 'compact' && this.tabsModel.isSticky(tabIndex))) { + const extension = extname(resource); + if (extension.length > 1) { + suffix = extension; + name = name.substring(0, name.length - extension.length); + } + } tabLabelWidget.setResource( { name, description, resource: EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.BOTH }) }, { @@ -1818,11 +1848,11 @@ export class MultiEditorTabsControl extends EditorTabsControl { icon: editor.getIcon(), hideIcon: options.showIcons === false, namePrefix, + suffix, } ); // Tests helper - const resource = EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); if (resource) { tabContainer.setAttribute('data-resource-name', basenameOrAuthority(resource)); } else { @@ -2034,6 +2064,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { } private doLayoutTabs(dimensions: IEditorTitleControlDimensions, options?: IMultiEditorTabsControlLayoutOptions): void { + const connected = Boolean(this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')); + this.parent.classList.toggle('connected-tabs-labels', connected); + if (connected !== this.connectedTabLabels) { + this.connectedTabLabels = connected; + this.forEachTab((editor, index, tab, widget, label) => this.redrawTabLabel(editor, index, tab, widget, label)); + } + this.layoutConnectedTabLabels(connected); // Always first layout tabs with wrapping support even if wrapping // is disabled. The result indicates if tabs wrap and if not, we @@ -2041,13 +2078,80 @@ export class MultiEditorTabsControl extends EditorTabsControl { // if wrapping is enabled in settings, there are cases where // wrapping is disabled (e.g. due to space constraints) const tabsWrapMultiLine = this.doLayoutTabsWrapping(dimensions); + const tabs = Array.from(assertReturnsDefined(this.tabsContainer).children).filter(isHTMLElement); + const bottom = tabs.at(-1)?.offsetTop; + const upperTabBar = this.parent.classList.contains('two-tab-bars') && this.parent.firstElementChild === this.tabsAndActionsContainer; + for (const tab of tabs) { + tab.classList.toggle('connected-tab-upper-row', connected && (upperTabBar || tab.offsetTop !== bottom)); + } if (!tabsWrapMultiLine) { this.doLayoutTabsNonWrapping(options); } else { assertReturnsDefined(this.stickyTabsBackground).style.width = '0px'; + this.clearConnectedTabClipping(); } } + private layoutConnectedTabLabels(connected: boolean): void { + const tabs = Array.from(assertReturnsDefined(this.tabsContainer).children).filter(isHTMLElement); + // Restore intrinsic icon widths before measuring so wider tabs can recover after a resize. + for (const tab of tabs) { + tab.classList.remove('connected-tab-narrow'); + } + const minimumWidths = new Map(); + for (const [index, tab] of tabs.entries()) { + if (!connected || tab.classList.contains('sticky-compact')) { + tab.style.removeProperty('--connected-tab-min-width'); + tab.style.removeProperty('--connected-tab-min-name-width'); + continue; + } + const label = assertReturnsDefined(this.tabResourceLabels.get(index)).element; + // IconLabel owns these private nodes; use their rendered fonts for text measurement. + // eslint-disable-next-line no-restricted-syntax + const name = label.querySelector('.label-name')!; + // eslint-disable-next-line no-restricted-syntax + const suffix = label.querySelector('.label-suffix'); + const style = getWindow(tab).getComputedStyle(tab); + const prefix = this.groupsView.partOptions.showTabIndex ? `${this.toEditorIndex(index) + 1}: ` : ''; + const firstNameCharacter = Array.from((name.textContent ?? '').substring(prefix.length))[0] ?? ''; + // The ellipsis belongs to the overflow container, not the label anchor. + // Measure each separately: their fonts can differ, and suffixes cannot kern across elements. + const minimumNameWidth = this.measureConnectedTabText(`${prefix}${firstNameCharacter}`, name) + + this.measureConnectedTabText('…', name.parentElement!); + const nameWidth = minimumNameWidth + (suffix ? this.measureConnectedTabText(suffix.textContent ?? '', suffix) : 0); + const badge = label.classList.contains('monaco-decoration-badge') ? getWindow(label).getComputedStyle(label, '::after') : undefined; + const badgeWidth = badge ? (parseFloat(badge.width) || 0) + (parseFloat(badge.marginLeft) || 0) + (parseFloat(badge.marginRight) || 0) : 0; + // Allow for canvas and DOM text metrics rounding in either direction. + const minimum = Math.ceil(nameWidth + badgeWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)) + 2; + tab.style.setProperty('--connected-tab-min-name-width', `${Math.ceil(minimumNameWidth)}px`); + minimumWidths.set(tab, minimum); + } + for (const [tab, minimum] of minimumWidths) { + if (tab.style.getPropertyValue('--connected-tab-min-width') !== `${minimum}px`) { + tab.style.setProperty('--connected-tab-min-width', `${minimum}px`); + } + } + const narrowTabs = Array.from(minimumWidths, ([tab, minimum]) => ({ tab, narrow: tab.offsetWidth < minimum + 22 })); + for (const { tab, narrow } of narrowTabs) { + tab.classList.toggle('connected-tab-narrow', narrow); + } + } + + private measureConnectedTabText(text: string, element: HTMLElement): number { + const style = getWindow(element).getComputedStyle(element); + const letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing; + const key = `${style.font}/${letterSpacing}/${text}`; + let width = this.connectedTabTextWidths.get(key); + if (width === undefined) { + const context = this.connectedTabMeasureContext ??= assertReturnsDefined($('canvas').getContext('2d')); + context.font = style.font; + context.letterSpacing = letterSpacing; + width = context.measureText(text).width; + this.connectedTabTextWidths.set(key, width); + } + return width; + } + private doLayoutTabsWrapping(dimensions: IEditorTitleControlDimensions): boolean { const [tabsAndActionsContainer, tabsContainer, editorToolbarContainer, tabsScrollbar] = assertReturnsAllDefined(this.tabsAndActionsContainer, this.tabsContainer, this.editorActionsToolbarContainer, this.tabsScrollbar); @@ -2218,13 +2322,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { let stickyTabsWidth = 0; if (this.tabsModel.stickyCount > 0) { const stickyTabWidth = this.getStickyTabWidth(this.groupsView.partOptions.pinnedTabSizing); - stickyTabsWidth = this.tabsModel.stickyCount * stickyTabWidth; for (let tabIndex = 0; tabIndex < this.tabsModel.stickyCount; tabIndex++) { const tab = this.getTabAtIndex(tabIndex); if (tab) { - tab.style.left = `${tabIndex * stickyTabWidth}px`; + tab.style.left = `${stickyTabsWidth}px`; } + stickyTabsWidth += this.connectedTabLabels && stickyTabWidth > 0 && tab ? tab.offsetWidth : stickyTabWidth; } } @@ -2250,6 +2354,35 @@ export class MultiEditorTabsControl extends EditorTabsControl { } assertReturnsDefined(this.stickyTabsBackground).style.width = `${stickyTabsWidth}px`; + this.clearConnectedTabClipping(); + const activeTabFill = activeTab?.firstElementChild; + const overflowEdge = this.connectedTabOverflowEdge; + if (activeTab && !activeTab.classList.contains('connected-tab-upper-row') && isHTMLElement(activeTabFill) && overflowEdge && !activeTabPositionStatic && this.parent.closest('.modern-ui.modern-ui-connected-editor-tabs')) { + // DOM bounds reflect native scroll clamping before the custom scrollbar dimensions update. + const scrollLeft = tabsContainer.scrollLeft; + const tabsBounds = tabsContainer.getBoundingClientRect(); + const fillBounds = activeTabFill.getBoundingClientRect(); + const scrollableBounds = tabsScrollbar.getDomNode().getBoundingClientRect(); + const targetWindow = getWindow(activeTabFill); + const fillStyle = targetWindow.getComputedStyle(activeTabFill); + const fillLeft = fillBounds.left - tabsBounds.left + scrollLeft; + const viewportRight = visibleTabsWidth - (this.addTabContainer?.offsetWidth ?? 0); + overflowEdge.style.top = `${fillBounds.top - scrollableBounds.top}px`; + overflowEdge.style.right = `${visibleTabsWidth - viewportRight}px`; + overflowEdge.style.bottom = `${scrollableBounds.bottom - fillBounds.bottom}px`; + overflowEdge.style.left = `${stickyTabsWidth}px`; + this.connectedTabBounds = { + tab: activeTab, + overflowEdge, + fillLeft, + fillRight: fillBounds.right - tabsBounds.left + scrollLeft, + viewportLeft: stickyTabsWidth, + viewportRight, + clippingEdgeExtent: Number.parseFloat(fillStyle.borderTopLeftRadius), + shoulderExtent: Number.parseFloat(targetWindow.getComputedStyle(activeTabFill, '::after').width), + }; + } + let activeTabPosX: number | undefined; let activeTabWidth: number | undefined; @@ -2264,6 +2397,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { width: visibleTabsWidth, scrollWidth: allTabsWidth }); + this.updateConnectedTabClipping(tabsScrollbar.getScrollPosition().scrollLeft); const dimensionsChanged = oldVisibleTabsWidth !== visibleTabsWidth || oldAllTabsWidth !== allTabsWidth; // Revealing the active tab is skipped under some conditions: @@ -2280,8 +2414,17 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Reveal the active one const tabsContainerScrollPosX = tabsScrollbar.getScrollPosition().scrollLeft; - const activeTabFits = activeTabWidth <= availableTabsContainerWidth; - const adjustedActiveTabPosX = activeTabPosX - stickyTabsWidth; + const activeTabShoulderWidth = this.connectedTabBounds?.shoulderExtent ?? 0; + const previousTab = this.connectedTabBounds?.tab.previousElementSibling; + const activeTabLeftShoulderWidth = previousTab && !previousTab.classList.contains('last-in-row') ? activeTabShoulderWidth : 0; + // Round outwards so fractional bounds cannot leave either shoulder clipped. + const adjustedActiveTabPosX = (this.connectedTabBounds + ? Math.floor(this.connectedTabBounds.fillLeft - activeTabLeftShoulderWidth) + : activeTabPosX) - stickyTabsWidth; + const activeTabRight = this.connectedTabBounds + ? Math.ceil(this.connectedTabBounds.fillRight + activeTabShoulderWidth) - stickyTabsWidth + : adjustedActiveTabPosX + activeTabWidth; + const activeTabFits = activeTabRight - adjustedActiveTabPosX <= availableTabsContainerWidth; // // Synopsis @@ -2304,9 +2447,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { // [-- Sticky Tabs Width --] // // - if (activeTabFits && tabsContainerScrollPosX + availableTabsContainerWidth < adjustedActiveTabPosX + activeTabWidth) { + if (activeTabFits && tabsContainerScrollPosX + availableTabsContainerWidth < activeTabRight) { tabsScrollbar.setScrollPosition({ - scrollLeft: tabsContainerScrollPosX + ((adjustedActiveTabPosX + activeTabWidth) /* right corner of tab */ - (tabsContainerScrollPosX + availableTabsContainerWidth) /* right corner of view port */) + scrollLeft: tabsContainerScrollPosX + (activeTabRight - (tabsContainerScrollPosX + availableTabsContainerWidth)) }); } @@ -2345,6 +2488,46 @@ export class MultiEditorTabsControl extends EditorTabsControl { } } + private clearConnectedTabClipping(): void { + if (this.connectedTabBounds) { + const { tab } = this.connectedTabBounds; + tab.classList.remove('connected-tab-left-edge', 'connected-tab-right-edge', 'connected-tab-left-clipped', 'connected-tab-right-clipped', 'connected-tab-hidden'); + this.connectedTabBounds = undefined; + } + + if (this.connectedTabOverflowEdge) { + this.connectedTabOverflowEdge.style.removeProperty('top'); + this.connectedTabOverflowEdge.style.removeProperty('right'); + this.connectedTabOverflowEdge.style.removeProperty('bottom'); + this.connectedTabOverflowEdge.style.removeProperty('left'); + this.connectedTabOverflowEdge.classList.remove('connected-tab-left-clipped', 'connected-tab-right-clipped'); + } + } + + private updateConnectedTabClipping(scrollLeft: number): void { + if (!this.connectedTabBounds) { + return; + } + + const { tab, overflowEdge, fillLeft, fillRight, viewportLeft, viewportRight, clippingEdgeExtent, shoulderExtent } = this.connectedTabBounds; + const visibleLeft = scrollLeft + viewportLeft; + const visibleRight = scrollLeft + viewportRight; + const visibleFillLeft = Math.max(fillLeft, visibleLeft); + const visibleFillRight = Math.min(fillRight, visibleRight); + const leftClipped = fillLeft < visibleLeft; + const rightClipped = fillRight > visibleRight; + const leftEdge = fillLeft - shoulderExtent < visibleLeft; + const rightEdge = fillRight + shoulderExtent > visibleRight; + const hidden = visibleFillLeft + clippingEdgeExtent >= visibleFillRight; + tab.classList.toggle('connected-tab-left-edge', leftEdge); + tab.classList.toggle('connected-tab-right-edge', rightEdge); + tab.classList.toggle('connected-tab-left-clipped', leftClipped); + tab.classList.toggle('connected-tab-right-clipped', rightClipped); + tab.classList.toggle('connected-tab-hidden', hidden); + overflowEdge.classList.toggle('connected-tab-left-clipped', leftClipped && !hidden); + overflowEdge.classList.toggle('connected-tab-right-clipped', rightEdge && !hidden); + } + private updateTabsControlVisibility(): void { const tabsAndActionsContainer = assertReturnsDefined(this.tabsAndActionsContainer); tabsAndActionsContainer.classList.toggle('empty', !this.visible); diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index e813c53fc5437..049d0a749c2b4 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -15,7 +15,7 @@ import { NotificationsPosition, NotificationsSettings } from '../common/notifica import { ACCOUNTS_AVATAR_SETTING } from '../services/authentication/common/authentication.js'; import { CustomEditorLabelService } from '../services/editor/common/customEditorLabelService.js'; import { MOUSE_BACK_FORWARD_NAVIGATION_SETTING } from '../services/history/common/history.js'; -import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings, ModernUIDensity } from '../services/layout/browser/layoutService.js'; +import { ActivityBarPosition, EditorActionsLocation, EditorTabsMode, LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../services/layout/browser/layoutService.js'; import { defaultWindowTitle, defaultWindowTitleSeparator } from './parts/titlebar/windowTitle.js'; const registry = Registry.as(ConfigurationExtensions.Configuration); @@ -843,6 +843,18 @@ const registry = Registry.as(ConfigurationExtensions.Con 'tags': ['experimental'], 'markdownDescription': localize({ key: 'modernUIUppercaseViewHeaders', comment: ['{0} is a placeholder for a setting identifier.'] }, "Controls whether view headers, side bar titles, and panel tabs use uppercase text when {0} is enabled.", '`#workbench.experimental.modernUI#`'), }, + [LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE]: { + 'type': 'string', + 'enum': [ModernUIEditorTabStyle.Connected, ModernUIEditorTabStyle.Pill], + 'enumDescriptions': [ + localize('modernUIEditorTabStyle.connected', "Connect the active tab to the editor surface with an outside border and curved shoulders."), + localize('modernUIEditorTabStyle.pill', "Show editor tabs as separate rounded pills."), + ], + 'default': ModernUIEditorTabStyle.Connected, + 'tags': ['experimental'], + 'markdownDescription': localize({ key: 'modernUIEditorTabStyle', comment: ['{0} is a placeholder for a setting identifier.'] }, "Controls the editor tab style when {0} is enabled. High contrast themes retain explicit selection and focus borders.", '`#workbench.experimental.modernUI#`'), + agentsWindow: { default: ModernUIEditorTabStyle.Pill, readOnly: true }, + }, } }); diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index 14f3607c4face..3d0e0b5dd5c5f 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -7,6 +7,31 @@ Modern UI uses the standard workbench color theme system. Theme authors can use The Modern UI colors below are experimental and require a build that supports them. Enable `workbench.experimental.modernUI` to use them in the main workbench. The shared `modernTab.*` and `modernEditorTab.*` colors also apply to the modern tab style in the Agents window. `statusBar.inactiveBackground` works in both classic and Modern UI layouts. +## Editor tab style + +`workbench.experimental.modernUIEditorTabStyle` chooses the editor tab treatment when `workbench.experimental.modernUI` is enabled: + +- `connected` (default): the bottom tab row spans the strip height without gaps. Its active tab joins the editor surface with an outside stroke and curved shoulders, and the first tab has a straight left edge. Upper wrapped rows and a separate pinned row retain the original rounded pills. +- `pill`: separate rounded tabs, without the connected stroke or shoulders. + +Changes apply immediately, including in auxiliary editor windows. This setting does not enable Modern UI by itself and does not change the Agents window's pill tabs. + +Connected tabs preserve at least the first basename character, an ellipsis, the extension, any decoration badge, and the action column when shrinking. File icons collapse first and return when the editor is widened; full names remain available in the hover and accessible label. Default `fit` sizing remains content-sized: the measured minimum is a compression safeguard, not a preferred tab width. Active close actions and dirty indicators remain visible, while clean inactive close actions appear on hover or keyboard focus. The final tab and the right viewport boundary keep an inset curved shoulder instead of a straight clipped edge. Explicit compact pinned tabs retain their icon-only sizing. + +The top cap and bottom shoulders share the same control radius plus the outside stroke (5px with the default tokens), including at clipped viewport edges. Upper-row pills retain the original control radius. Wrapped fills occupy equal-height row hit boxes (28px normally, 24px compact), with spacing only between rows and no extra gutter below the final row. Hover does not change their geometry. + +Automatic reveal includes the complete shoulder and rounds fractional layout bounds outward so the selected action is not clipped. Manual scrolling can still move part of the selected tab, including its action, offscreen. In that case a stationary cap and shoulder finish the visible outline; their stroke is aligned with the document separator, and the clipping mask falls back to `editor.background` when the theme does not define a tab-strip background. + +The connected design uses `editor.background` for the active tab on every row, its action area, outside stroke, both shoulders, and the strip separator so the selected tab and editor body read as one document well. Upper wrapped and separate pinned rows change only the tab shape, not its selected background. The strip and inactive tabs use `editorGroupHeader.tabsBackground`, and their hover fill is derived from `foreground` over that background. Existing tab foreground customizations continue to apply. `modernEditorTab.activeBackground` remains available to the pill style. + +The default palettes give `editorGroupHeader.tabsBackground` a restrained neutral fill using existing palette colors: Dark 2026 uses `#202122`, Light 2026 uses `#EAEAEA`, Dark Modern uses `#2B2B2B`, Light Modern uses `#E5E5E5`, the classic dark themes use `#303031`, and the classic light themes use `#E8E8E8`. `tab.inactiveBackground` matches the strip. These are theme-level values, so classic tabs also receive the updated inactive fill. Dark+/Light+ inherit the change from their Visual Studio base themes; HC and the other bundled background palettes are unchanged. No new color ID or cross-component token dependency is introduced. + +Connected tabs use `tab.inactiveForeground` rather than dimming the general foreground to 50% opacity. The default palettes pair these fills with readable inactive text (at least 4.5:1), including in inactive editor groups. Light Modern and the classic light themes use the existing neutral `#616161`; the classic dark themes use `#A6A6A6`. Explicit legacy foreground customizations still take precedence. HC styling and the original pill label defaults are unchanged. + +In high contrast, the connected boundary uses `focusBorder` for the active editor group and `contrastBorder` for other groups. It follows the selected tab and continues around the breadcrumbs and document body, rather than outlining only the tab. With multiple tabs, the surrounding editor-area card stroke is transparent in HC, including compact-layout corner strokes, so it does not create a second frame. Single and hidden-tab modes retain their original editor-card outline. Keyboard focus and multi-selection indicators remain visible. The same one-stroke geometry is present in all themes: the body frame is transparent in standard themes, and its inside paint layer does not change editor dimensions when switching themes or active groups. + +## Colors + | Color ID | Purpose | Default | | --- | --- | --- | | `modernUI.shellBackground` | Background of the shell gutters around floating workbench surfaces in an active window | `titleBar.activeBackground` | diff --git a/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts new file mode 100644 index 0000000000000..b72b3edb7966a --- /dev/null +++ b/src/vs/workbench/contrib/modernUI/browser/connectedEditorTabs.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import './media/connectedEditorTabs.css'; + +registerThemingParticipant((theme, collector) => { + const background = theme.getColor(editorBackground); + if (background) { + collector.addRule(`.monaco-workbench.modern-ui.modern-ui-connected-editor-tabs { --modern-ui-connected-tab-surface: ${background}; }`); + } +}); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css new file mode 100644 index 0000000000000..bc0fe73a7a0f3 --- /dev/null +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -0,0 +1,436 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* The connected tab setting is scoped to Modern UI; other modern tabs retain their pills. */ +/* In HC the connected well owns the frame; retain the outer card's layout space, not its stroke. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor.editor-tabs-multiple:not(.modal-editor-part) { + border-color: transparent; + --modern-ui-editor-border-color: transparent; + --modern-ui-floating-card-stroke-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container { + --modern-ui-connected-tab-border: var(--modern-ui-connected-tab-surface); + --modern-ui-connected-well-border: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container { + --modern-ui-connected-tab-border: var(--vscode-contrastBorder); + --modern-ui-connected-well-border: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container.active { + --modern-ui-connected-tab-border: var(--vscode-focusBorder); +} + +/* Reserve the same inside stroke in every theme without changing the editor's layout box. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs ~ .editor-container, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs { + position: relative; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs ~ .editor-container::after, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs::after { + content: ''; + position: absolute; + inset: 0; + box-sizing: border-box; + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-well-border); + border-top: 0; + pointer-events: none; + z-index: 10; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .breadcrumbs-below-tabs::after { + border-bottom: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected:not(:focus) > .tab-fill { + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:focus-visible > .tab-fill { + outline: var(--vscode-strokeThickness) dashed var(--vscode-focusBorder); + outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { + --modern-ui-editor-tab-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); + --modern-ui-editor-tab-unfocused-inactive-background: var(--vscode-editorGroupHeader-tabsBackground); + --modern-ui-editor-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editorGroupHeader-tabsBackground)); + --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); + --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); + --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-shoulder-radius: var(--modern-ui-connected-tab-cap-radius); + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size40) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size40); + --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size280) + var(--modern-ui-connected-tab-shoulder-radius)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container { + background-color: var(--vscode-editorGroupHeader-tabsBackground); + padding-left: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container { + padding-bottom: var(--vscode-strokeThickness); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-fill { + inset-inline: 0; + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { + background-color: var(--vscode-editorGroupHeader-tabsBackground); +} + +/* Occlude scrolling connected fills beneath sticky tabs while preserving the final separator row. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background { + bottom: var(--vscode-strokeThickness); + height: auto; +} + +/* Keep the separator underneath the active fill without changing the tab, + * scrollbar, pinned-tab or drag-and-drop stacking order. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container::after { + z-index: 0; + background-color: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping { + --modern-ui-connected-tab-gutter: var(--vscode-spacing-size20); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20); +} + +/* Wrapped fills occupy their row's hit box, without an extra gutter below the final row. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping > .monaco-scrollable-element { + padding-bottom: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container { + padding-bottom: 0; + row-gap: var(--vscode-spacing-size20); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:first-child { + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size60); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(:first-child) { + --modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size60) + var(--vscode-strokeThickness)); + --modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20); +} + +/* Preserve the existing explicit selection and focus borders in high contrast. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top:not(.close-action-off) > .tab-actions, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top:not(.close-action-off) > .tab-actions { + border-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill { + --modern-ui-editor-tab-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-unfocused-active-border-top: var(--modern-ui-connected-tab-border); + --modern-ui-editor-tab-active-border: transparent; + --modern-ui-editor-tab-unfocused-active-border: transparent; + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); + inset-inline: 0; + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter) - var(--vscode-strokeThickness)); + z-index: 1; + background-color: var(--modern-ui-connected-tab-surface); + border: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-color: transparent; + border-radius: var(--modern-ui-connected-tab-cap-radius) var(--modern-ui-connected-tab-cap-radius) 0 0; + box-shadow: none; +} + +/* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { + content: ''; + position: absolute; + bottom: 0; + width: var(--modern-ui-connected-tab-shoulder-radius); + height: var(--modern-ui-connected-tab-shoulder-radius); + box-sizing: border-box; + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + clip-path: inset(0); + pointer-events: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before { + right: 100%; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-right-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill::before { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-fill::before { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge { + display: block; + position: absolute; + top: calc(-1 * var(--modern-ui-connected-tab-top-inset)); + inset-inline: 0; + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter) - var(--vscode-strokeThickness)); + z-index: 8; + pointer-events: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { + position: absolute; + bottom: 0; + width: var(--vscode-strokeThickness); + height: calc(var(--modern-ui-connected-tab-shoulder-radius) - var(--vscode-strokeThickness) * 2); + box-sizing: border-box; + background: linear-gradient(to bottom, transparent var(--vscode-strokeThickness), var(--modern-ui-connected-tab-surface) var(--vscode-strokeThickness)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(:first-child) > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { + content: ''; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::before { + left: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-connected-edge::after { + right: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-connected-edge::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-connected-edge::before { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped { + display: block; + position: absolute; + z-index: 8; + pointer-events: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: var(--modern-ui-connected-tab-cap-radius); + box-sizing: border-box; + background-color: var(--modern-ui-connected-tab-surface); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-left-clipped::before { + left: 0; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + right: 0; + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-hidden > .tab-fill, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-hidden > .tab-connected-edge { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { + left: 100%; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { + --modern-ui-connected-tab-action-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-active-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-unfocused-active-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-active-hover-background: var(--modern-ui-connected-tab-surface); + --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--modern-ui-connected-tab-surface); + border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-actions-left > .tab-actions { + border-radius: var(--modern-ui-connected-tab-radius) 0 0 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.tab-actions-left) > .tab-actions .actions-container { + justify-content: flex-end; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.tab-actions-left > .tab-actions .actions-container { + justify-content: flex-start; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { + right: var(--modern-ui-connected-tab-shoulder-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact).tab-actions-left > .tab-actions { + left: var(--modern-ui-connected-tab-shoulder-radius); +} + +/* Keep the complete identity and action column; only the basename can ellipsize. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:is(.sizing-fixed, .sizing-shrink):not(.sticky-compact) { + min-width: max(var(--connected-tab-min-width, 80px), var(--tab-sizing-fixed-min-width, 0px)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) > .tab-label > .monaco-icon-label-container { + display: flex; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-name-container { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:is(.sizing-fixed, .sizing-shrink):not(.sticky-compact) .monaco-icon-name-container { + min-width: var(--connected-tab-min-name-width, 0); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-description-container { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-label.monaco-decoration-badge::after { + flex-shrink: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .monaco-icon-suffix-container { + flex-shrink: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.sticky-compact) .label-suffix { + opacity: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow > .tab-label::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow .monaco-icon-label-iconpath, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-narrow .monaco-icon-description-container { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.dirty):not(:hover):not(.sticky):not(:focus-within) > .tab-actions .action-label { + opacity: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.close-action-off) > .tab-actions .action-label, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.dirty > .tab-actions .action-label { + opacity: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.close-action-off) > .tab-actions { + pointer-events: auto; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-actions::before { + content: none; +} + +/* Terminal shoulders turn inside the available slot rather than being cut off. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:is(:last-child, .last-in-row, .connected-tab-right-edge) > :is(.tab-fill, .tab-connected-edge) { + right: var(--modern-ui-connected-tab-shoulder-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge.connected-tab-right-clipped::after { + width: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--modern-ui-connected-tab-cap-radius)); + background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)); + border: 0; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right { + position: absolute; + /* Match the ordinary fill's inner border edge, not its reserved bottom stroke. */ + inset: 0 0 var(--vscode-strokeThickness); + z-index: 1; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right::before { + content: ''; + position: absolute; + top: 0; + bottom: var(--modern-ui-connected-tab-shoulder-radius); + right: var(--modern-ui-connected-tab-shoulder-radius); + width: var(--modern-ui-connected-tab-cap-radius); + box-sizing: border-box; + background: var(--modern-ui-connected-tab-surface); + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-right: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-top-right-radius: var(--modern-ui-connected-tab-cap-radius); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .connected-tab-right-clipped > .tab-connected-overflow-right::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: calc(var(--modern-ui-connected-tab-shoulder-radius) + var(--vscode-strokeThickness)); + height: var(--modern-ui-connected-tab-shoulder-radius); + box-sizing: border-box; + border-left: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-connected-tab-border); + border-bottom-left-radius: var(--modern-ui-connected-tab-shoulder-radius); + box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / -2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-surface); + clip-path: inset(0 0 0 calc(-1 * var(--vscode-strokeThickness))); +} + +/* Rows above the document retain the original control-tier pills. */ +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row { + --modern-ui-editor-tab-hover-background: var(--vscode-modernEditorTab-hoverBackground); + --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-hover-background); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill { + inset: 0 var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small); + border-color: transparent; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.connected-tab-upper-row > .tab-fill { + inset-block: calc(-1 * var(--vscode-spacing-size20)); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row.active > .tab-fill { + border-color: var(--modern-ui-connected-tab-border); +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill::before, +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-fill::after { + content: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-connected-edge { + display: none; +} + +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row.active > .tab-actions { + border-radius: var(--vscode-cornerRadius-small); +} diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 6b9620922401f..8327533709823 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -49,6 +49,11 @@ --modern-ui-editor-tabs-border: transparent; } +/* Keep connected labels readable without overriding explicit legacy color customizations. */ +.modern-ui-tabs.monaco-workbench:where(.modern-ui.modern-ui-connected-editor-tabs:not(.hc-black, .hc-light)) { + --modern-ui-editor-tab-inactive-foreground: var(--vscode-tab-inactiveForeground); +} + .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { background-color: transparent !important; cursor: default; @@ -168,16 +173,16 @@ /* Persistent dirty/pin indicators always reserve their overlay column. Clean tabs reserve * the transient close/unpin action only when requested (https://github.com/microsoft/vscode/issues/329605). */ -.modern-ui-tabs .part.editor .tab-actions-reserve-space .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off), +.modern-ui-tabs .part.editor :is(.tab-actions-reserve-space, .connected-tabs-labels) .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off), .modern-ui-tabs .part.editor .tabs-container > .tab.dirty:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off.dirty-border-top), .modern-ui-tabs .part.editor .tabs-container > .tab.sticky:not(.sticky-compact):not(.pinned-action-off):not(.tab-actions-left) { - padding-right: var(--vscode-spacing-size280) !important; + padding-right: var(--modern-ui-tab-action-padding, var(--vscode-spacing-size280)) !important; } -.modern-ui-tabs .part.editor .tab-actions-reserve-space .tabs-container > .tab.tab-actions-left:not(.sticky-compact):not(.close-action-off), +.modern-ui-tabs .part.editor :is(.tab-actions-reserve-space, .connected-tabs-labels) .tabs-container > .tab.tab-actions-left:not(.sticky-compact):not(.close-action-off), .modern-ui-tabs .part.editor .tabs-container > .tab.dirty.tab-actions-left:not(.sticky-compact):not(.close-action-off.dirty-border-top), .modern-ui-tabs .part.editor .tabs-container > .tab.sticky.tab-actions-left:not(.sticky-compact):not(.pinned-action-off) { - padding-left: var(--vscode-spacing-size280) !important; + padding-left: var(--modern-ui-tab-action-padding, var(--vscode-spacing-size280)) !important; padding-right: var(--vscode-spacing-size80) !important; } @@ -767,7 +772,7 @@ :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover > .tab-actions, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions:focus-within { - background-color: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)) !important; + background-color: var(--modern-ui-connected-tab-action-background, var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background))) !important; } :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions .action-label { @@ -816,7 +821,8 @@ outline: none !important; } -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench.modern-ui-connected-editor-tabs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.selected:not(.active):not(:focus) > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .modern-ui-editor-tab.active:not(:focus) > .modern-ui-editor-tab-fill { border: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder) !important; } @@ -826,7 +832,8 @@ border: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder) !important; } -:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:focus > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:focus > .tab-fill, +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench.modern-ui-connected-editor-tabs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):focus > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .modern-ui-editor-tab:focus > .modern-ui-editor-tab-fill { border: var(--vscode-strokeThickness) solid var(--vscode-focusBorder) !important; } diff --git a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts index 3fd55793b50ce..3058363e289e0 100644 --- a/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts +++ b/src/vs/workbench/contrib/modernUI/browser/modernUI.contribution.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { getWindow } from '../../../../base/browser/dom.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity } from '../../../services/layout/browser/layoutService.js'; +import { IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; +import { IWorkbenchLayoutService, LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { COMPACT_NOTIFICATION_ROW_HEIGHT, DEFAULT_NOTIFICATION_ROW_HEIGHT, setNotificationRowHeight } from '../../../browser/parts/notifications/notificationsViewer.js'; @@ -36,6 +38,7 @@ import './media/sashHandles.css'; import './media/shadows.css'; import './media/statusBar.css'; import './media/tabs.css'; +import './connectedEditorTabs.js'; import './media/titlebar.css'; import '../../../services/themes/browser/modernTabColorCustomizations.js'; @@ -55,6 +58,7 @@ interface IModernUIModule { const MODERN_UI_CLASS = 'modern-ui'; const MODERN_UI_COMPACT_CLASS = 'modern-ui-compact'; const MODERN_UI_TABS_CLASS = 'modern-ui-tabs'; +const MODERN_UI_CONNECTED_EDITOR_TABS_CLASS = 'modern-ui-connected-editor-tabs'; const MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS = 'modern-ui-notifications-dialogs'; const MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS = 'modern-ui-uppercase-view-headers'; @@ -134,6 +138,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, ) { super(); @@ -142,14 +147,14 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri // A config change re-applies to every container (the global `update()` // covers all windows, including auxiliary ones). this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(LayoutSettings.MODERN_UI) || e.affectsConfiguration(LayoutSettings.MODERN_UI_DENSITY) || e.affectsConfiguration(LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS)) { + if (e.affectsConfiguration(LayoutSettings.MODERN_UI) || e.affectsConfiguration(LayoutSettings.MODERN_UI_DENSITY) || e.affectsConfiguration(LayoutSettings.MODERN_UI_UPPERCASE_VIEW_HEADERS) || e.affectsConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE)) { this.update(); // Some modules change layout metrics, so a relayout is required once // their classes and corresponding layout values are updated. const layoutAffectingState = this.getLayoutAffectingState(); if (layoutAffectingState !== this.layoutAffectingState) { this.layoutAffectingState = layoutAffectingState; - this.layoutService.layout(); + this.layout(); } } })); @@ -164,6 +169,15 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri this.update(); } + private layout(): void { + this.layoutService.layout(); + for (const container of this.layoutService.containers) { + if (container !== this.layoutService.mainContainer) { + this.auxiliaryWindowService.getWindow(getWindow(container).vscodeWindowId)?.layout(); + } + } + } + private isEnabled(): boolean { return this.configurationService.getValue(LayoutSettings.MODERN_UI) === true; } @@ -176,12 +190,16 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri return this.configurationService.getValue(LayoutSettings.MODERN_UI_DENSITY) === ModernUIDensity.Compact; } + private useConnectedEditorTabs(): boolean { + return this.configurationService.getValue(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE) === ModernUIEditorTabStyle.Connected; + } + private getLayoutAffectingState(): string { if (!this.isEnabled() || !this.hasLayoutAffectingModule) { return 'disabled'; } - return this.isCompact() ? ModernUIDensity.Compact : ModernUIDensity.Default; + return `${this.isCompact() ? ModernUIDensity.Compact : ModernUIDensity.Default}/${this.useConnectedEditorTabs() ? ModernUIEditorTabStyle.Connected : ModernUIEditorTabStyle.Pill}`; } private update(): void { @@ -200,6 +218,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri container.classList.toggle(MODERN_UI_CLASS, enabled); container.classList.toggle(MODERN_UI_COMPACT_CLASS, compact); container.classList.toggle(MODERN_UI_TABS_CLASS, enabled); + container.classList.toggle(MODERN_UI_CONNECTED_EDITOR_TABS_CLASS, enabled && this.useConnectedEditorTabs()); container.classList.toggle(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS, enabled); container.classList.toggle(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS, useUppercaseViewHeaders); } @@ -226,6 +245,7 @@ export class ModernUIContribution extends Disposable implements IWorkbenchContri container.classList.remove(MODERN_UI_CLASS); container.classList.remove(MODERN_UI_COMPACT_CLASS); container.classList.remove(MODERN_UI_TABS_CLASS); + container.classList.remove(MODERN_UI_CONNECTED_EDITOR_TABS_CLASS); container.classList.remove(MODERN_UI_NOTIFICATIONS_DIALOGS_CLASS); container.classList.remove(MODERN_UI_UPPERCASE_VIEW_HEADERS_CLASS); } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 4f9bbfee6623e..76f6b20af85cf 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -16,6 +16,7 @@ import { Color } from '../../../../../base/common/color.js'; import { Emitter } from '../../../../../base/common/event.js'; import { combinedDisposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { isIMenuItem, isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -28,7 +29,7 @@ import { foreground } from '../../../../../platform/theme/common/colors/baseColo import { Extensions as ThemeServiceExtensions, IThemingRegistry } from '../../../../../platform/theme/common/themeService.js'; import { EDITOR_BORDER, MODERN_ACTIVITY_BAR_BACKGROUND, MODERN_ACTIVITY_BAR_BORDER, MODERN_ACTIVITY_BAR_INACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_FOREGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, MODERN_PANEL_BORDER, MODERN_SASH_GRIP_FOREGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, MODERN_UI_INACTIVE_SHELL_BACKGROUND, MODERN_UI_SHELL_BACKGROUND, PANEL_SECTION_BORDER, PANEL_SECTION_HEADER_BORDER, SIDE_BAR_SECTION_HEADER_BORDER, SURFACE_BORDER, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BACKGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_INACTIVE_BACKGROUND } from '../../../../common/theme.js'; import { TestEnvironmentService, TestLayoutService } from '../../../../test/browser/workbenchTestServices.js'; -import { LayoutSettings, ModernUIDensity } from '../../../../services/layout/browser/layoutService.js'; +import { LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; import { PRESERVE_MERGED_WORKSPACE_NAME_CASE_CLASS, PRESERVE_WORKSPACE_NAME_CASE_CLASS, shouldPreserveWorkspaceNameCase } from '../../../files/browser/views/explorerView.js'; import { URI } from '../../../../../base/common/uri.js'; import { joinPath } from '../../../../../base/common/resources.js'; @@ -42,6 +43,7 @@ import '../../../../browser/parts/media/paneCompositePart.css'; import '../../../../browser/parts/statusbar/media/statusbarpart.css'; import '../../../../browser/parts/titlebar/media/menubarControl.css'; import { ModernUIContribution } from '../../browser/modernUI.contribution.js'; +import { IAuxiliaryWindow, IAuxiliaryWindowService } from '../../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import '../../../../browser/parts/notifications/media/notificationsCenter.css'; import '../../../../browser/parts/notifications/media/notificationsToasts.css'; @@ -77,6 +79,21 @@ class ModernUITestLayoutService extends TestLayoutService { } } +class ModernUITestAuxiliaryWindowService extends mock() { + + readonly auxiliaryWindow = new class extends mock() { + layoutCount = 0; + + override layout(): void { + this.layoutCount++; + } + }; + + override getWindow(): IAuxiliaryWindow { + return this.auxiliaryWindow; + } +} + function appendElement(parent: HTMLElement, className: string): HTMLElement { const element = document.createElement('div'); element.className = className; @@ -221,7 +238,8 @@ suite('ModernUIContribution', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new ModernUIContribution(configurationService, layoutService)); + const auxiliaryWindowService = new ModernUITestAuxiliaryWindowService(); + store.add(new ModernUIContribution(configurationService, layoutService, auxiliaryWindowService)); const pane = store.add(new ModernUITestPane()); const paneView = document.createElement('div'); paneView.classList.add('monaco-pane-view'); @@ -249,6 +267,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeight: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, paneHeaderInlineLineHeight: pane.draggableElement!.style.lineHeight, layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, }; await configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_DENSITY, ModernUIDensity.Default); @@ -264,6 +283,7 @@ suite('ModernUIContribution', () => { paneHeaderSize: pane.minimumSize, paneHeaderLineHeight: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, }; await configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, false); @@ -291,6 +311,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeightAfterToggle: getWindow(pane.draggableElement!).getComputedStyle(pane.draggableElement!).lineHeight, paneHeaderInlineLineHeightAfterToggle: pane.draggableElement!.style.lineHeight, layoutCountAfterToggle: layoutService.layoutCount, + auxiliaryLayoutCountAfterToggle: auxiliaryWindowService.auxiliaryWindow.layoutCount, }, { startupState: { mainEnabled: true, @@ -307,6 +328,7 @@ suite('ModernUIContribution', () => { paneHeaderLineHeight: '28px', paneHeaderInlineLineHeight: '', layoutCount: 0, + auxiliaryLayoutCount: 0, }, defaultDensityState: { mainCompact: false, @@ -314,6 +336,7 @@ suite('ModernUIContribution', () => { paneHeaderSize: 28, paneHeaderLineHeight: '28px', layoutCount: 1, + auxiliaryLayoutCount: 1, }, mainEnabledAfterToggle: false, mainCompactAfterToggle: false, @@ -329,6 +352,64 @@ suite('ModernUIContribution', () => { paneHeaderLineHeightAfterToggle: '22px', paneHeaderInlineLineHeightAfterToggle: '', layoutCountAfterToggle: 2, + auxiliaryLayoutCountAfterToggle: 2, + }); + }); + + test('switches connected editor tabs across windows and cleans up on disable and disposal', async () => { + const configurationService = new TestConfigurationService({ + [LayoutSettings.MODERN_UI]: true, + [LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE]: ModernUIEditorTabStyle.Connected, + }); + store.add(configurationService.onDidChangeConfigurationEmitter); + const layoutService = new ModernUITestLayoutService(); + store.add(layoutService.onDidAddContainerEmitter); + const auxiliaryWindowService = new ModernUITestAuxiliaryWindowService(); + const contribution = store.add(new ModernUIContribution(configurationService, layoutService, auxiliaryWindowService)); + const auxiliaryContainer = document.createElement('div'); + const getState = () => ({ + connected: layoutService.containers.map(container => container.classList.contains('modern-ui-connected-editor-tabs')), + modernTabs: layoutService.containers.map(container => container.classList.contains('modern-ui-tabs')), + layoutCount: layoutService.layoutCount, + auxiliaryLayoutCount: auxiliaryWindowService.auxiliaryWindow.layoutCount, + }); + const update = async (key: string, value: boolean | ModernUIEditorTabStyle) => { + await configurationService.setUserConfiguration(key, value); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: setting => setting === key, + source: ConfigurationTarget.USER, + affectedKeys: new Set([key]), + change: { keys: [key], overrides: [] }, + }); + }; + + const startup = getState(); + layoutService.addContainer(auxiliaryContainer, store.add(new DisposableStore())); + const auxiliaryStartup = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Pill); + const pill = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Connected); + const connected = getState(); + await update(LayoutSettings.MODERN_UI, false); + const disabled = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Pill); + const changedWhileDisabled = getState(); + await update(LayoutSettings.MODERN_UI, true); + const reenabled = getState(); + await update(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, ModernUIEditorTabStyle.Connected); + contribution.dispose(); + + assert.deepStrictEqual({ + startup, auxiliaryStartup, pill, connected, disabled, changedWhileDisabled, reenabled, disposed: getState(), + }, { + startup: { connected: [true], modernTabs: [true], layoutCount: 0, auxiliaryLayoutCount: 0 }, + auxiliaryStartup: { connected: [true, true], modernTabs: [true, true], layoutCount: 0, auxiliaryLayoutCount: 0 }, + pill: { connected: [false, false], modernTabs: [true, true], layoutCount: 1, auxiliaryLayoutCount: 1 }, + connected: { connected: [true, true], modernTabs: [true, true], layoutCount: 2, auxiliaryLayoutCount: 2 }, + disabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3, auxiliaryLayoutCount: 3 }, + changedWhileDisabled: { connected: [false, false], modernTabs: [false, false], layoutCount: 3, auxiliaryLayoutCount: 3 }, + reenabled: { connected: [false, false], modernTabs: [true, true], layoutCount: 4, auxiliaryLayoutCount: 4 }, + disposed: { connected: [false, false], modernTabs: [false, false], layoutCount: 5, auxiliaryLayoutCount: 5 }, }); }); @@ -614,7 +695,7 @@ suite('ModernUIContribution', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const layoutService = new ModernUITestLayoutService(); store.add(layoutService.onDidAddContainerEmitter); - store.add(new ModernUIContribution(configurationService, layoutService)); + store.add(new ModernUIContribution(configurationService, layoutService, new ModernUITestAuxiliaryWindowService())); layoutService.mainContainer.classList.add('monaco-workbench'); const paneView = appendElement(layoutService.mainContainer, 'monaco-pane-view'); @@ -1784,6 +1865,491 @@ suite('ModernUIContribution', () => { }); }); + test('uses the editor background for the connected surface', () => { + const backgrounds = [ + { editor: '#1f1f1f', tab: '#ffffff17' }, + { editor: '#ffffff', tab: '#2468ac' }, + { editor: '#1f1f1f', tab: '#00000000' }, + ].map(({ editor, tab }) => { + const theme = ColorThemeData.createUnloadedTheme('vs-dark', { + [editorBackground]: editor, + [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: tab, + }); + const css = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return /--modern-ui-connected-tab-surface: (?[^;]+);/.exec(css)?.groups?.background; + }); + + assert.deepStrictEqual(backgrounds, ['#1f1f1f', '#ffffff', '#1f1f1f']); + }); + + test('uses the editor background for the connected surface and the editor group header background for the strip', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + appendElement(tabs, 'tab'); + const tab = appendElement(tabs, 'tab active'); + const fill = appendElement(tab, 'tab-fill'); + const actions = appendElement(tab, 'tab-actions'); + const action = appendElement(actions, 'action-label'); + action.tabIndex = 0; + action.focus(); + const targetWindow = getWindow(root); + const results = ['#2468ac', '#ff000080', '#00000000'].map(color => { + const theme = ColorThemeData.createUnloadedTheme('vs', { + [editorBackground]: '#ffffff', + 'editorGroup.border': '#123456', + 'editorGroupHeader.tabsBackground': '#eeeeee', + [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: '#abcdef', + }); + theme.setCustomColors({ [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: color }); + style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return { + surface: [ + targetWindow.getComputedStyle(fill).backgroundColor, + targetWindow.getComputedStyle(fill).borderTopColor, + targetWindow.getComputedStyle(fill, '::before').borderRightColor, + targetWindow.getComputedStyle(fill, '::after').borderLeftColor, + targetWindow.getComputedStyle(row, '::after').backgroundColor, + targetWindow.getComputedStyle(actions).backgroundColor, + ], + strip: targetWindow.getComputedStyle(row).backgroundColor, + editorSplitBorder: theme.getColor('editorGroup.border')?.toString(), + }; + }); + + assert.deepStrictEqual(results, [ + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, + { surface: Array(6).fill('rgb(255, 255, 255)'), strip: 'rgb(238, 238, 238)', editorSplitBorder: '#123456' }, + ]); + }); + + test('preserves a single HC frame in every tab mode without changing the outer card geometry', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs floating-panels'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = '--vscode-editor-border: #123456; --vscode-editorGroup-border: #123456; --vscode-focusBorder: #ffaa00; --vscode-strokeThickness: 1px; --vscode-cornerRadius-large: 8px;'; + const grid = appendElement(root, 'monaco-grid-view'); + const editor = appendElement(grid, 'part editor floating-editor-outer-left floating-editor-outer-top'); + editor.style.cssText = 'width: 500px; height: 350px;'; + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title'); + const body = appendElement(group, 'editor-container'); + const geometry = () => [editor, content].map(element => { + const bounds = element.getBoundingClientRect(); + return [bounds.x, bounds.y, bounds.width, bounds.height]; + }); + for (const floating of [false, true]) { + root.classList.toggle('floating-panels', floating); + const baseline = geometry(); + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); + root.classList.add(theme); + for (const connected of [false, true]) { + root.classList.toggle('modern-ui-connected-editor-tabs', connected); + for (const compact of [false, true]) { + root.classList.toggle('modern-ui-compact', compact); + for (const showTabs of ['multiple', 'single', 'none', 'multiple']) { + editor.classList.toggle('editor-tabs-multiple', showTabs === 'multiple'); + title.classList.toggle('tabs', showTabs === 'multiple'); + const style = getWindow(editor).getComputedStyle(editor); + const wellFrame = getWindow(body).getComputedStyle(body, '::after'); + const connectedFrame = connected && showTabs === 'multiple'; + const singleFrame = connectedFrame && theme.startsWith('hc-'); + assert.deepStrictEqual({ + geometry: geometry(), + border: [style.borderTopWidth, style.borderLeftWidth, style.borderColor], + compactStroke: floating && compact ? style.getPropertyValue('--modern-ui-floating-card-stroke-color').trim() : undefined, + wellFrame: [wellFrame.content, wellFrame.borderLeftWidth, singleFrame ? wellFrame.borderLeftColor : undefined], + }, { + geometry: baseline, + border: ['1px', '1px', singleFrame || (floating && compact) ? 'rgba(0, 0, 0, 0)' : 'rgb(18, 52, 86)'], + compactStroke: floating && compact ? (singleFrame ? 'transparent' : '#123456') : undefined, + wellFrame: [connectedFrame ? '""' : 'none', connectedFrame ? '1px' : '0px', singleFrame ? 'rgb(255, 170, 0)' : undefined], + }, `${theme}, floating: ${floating}, connected: ${connected}, compact: ${compact}, showTabs: ${showTabs}`); + } + } + } + } + } + }); + + test('connects HC group borders without moving content when themes or active groups change', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + root.style.cssText = 'width: 500px; --vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-cornerRadius-small: 4px; --vscode-strokeThickness: 1px;'; + const themeStyle = document.createElement('style'); + root.appendChild(themeStyle); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const tab = appendElement(tabs, 'tab active'); + tab.style.width = '120px'; + const fill = appendElement(tab, 'tab-fill'); + const { label, name } = createEditorTabLabel(tab); + name.textContent = 'main.ts'; + const breadcrumbs = appendElement(title, 'breadcrumbs-below-tabs'); + breadcrumbs.style.height = '22px'; + const body = appendElement(group, 'editor-container'); + body.style.height = '240px'; + const targetWindow = getWindow(root); + const geometry = () => [row, tab, fill, label, breadcrumbs, body].map(element => { + const bounds = element.getBoundingClientRect(); + return [bounds.x, bounds.y, bounds.width, bounds.height]; + }); + let baseline: number[][] | undefined; + for (const themeType of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.classList.remove('vs-dark', 'vs', 'hc-black', 'hc-light'); + root.classList.add(themeType); + const theme = ColorThemeData.createUnloadedTheme(themeType, { + [editorBackground]: '#333333', + focusBorder: '#ffaa00', + contrastBorder: '#888888', + }); + themeStyle.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + baseline ??= geometry(); + for (const active of [true, false]) { + group.classList.toggle('active', active); + const highContrast = themeType.startsWith('hc-'); + const border = highContrast ? (active ? 'rgb(255, 170, 0)' : 'rgb(136, 136, 136)') : 'rgb(51, 51, 51)'; + const frame = targetWindow.getComputedStyle(body, '::after'); + const breadcrumbFrame = targetWindow.getComputedStyle(breadcrumbs, '::after'); + assert.deepStrictEqual({ + geometry: geometry(), + cap: [targetWindow.getComputedStyle(fill).borderTopColor, targetWindow.getComputedStyle(fill).borderLeftColor], + shoulder: targetWindow.getComputedStyle(fill, '::after').borderLeftColor, + separator: targetWindow.getComputedStyle(row, '::after').backgroundColor, + frame: [frame.borderLeftWidth, frame.borderBottomWidth, frame.borderTopWidth, frame.borderLeftColor, frame.pointerEvents], + breadcrumbs: [breadcrumbFrame.borderLeftWidth, breadcrumbFrame.borderBottomWidth, breadcrumbFrame.borderLeftColor], + }, { + geometry: baseline, + cap: [border, border], + shoulder: border, + separator: border, + frame: ['1px', '1px', '0px', highContrast ? border : 'rgba(0, 0, 0, 0)', 'none'], + breadcrumbs: ['1px', '0px', highContrast ? border : 'rgba(0, 0, 0, 0)'], + }, `${themeType}, active group: ${active}`); + } + } + }); + + test('paints connected tab strokes outside the fill without moving tab content', () => { + const root = document.createElement('div'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-spacing-size60', '6px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--modern-ui-connected-tab-surface', '#333333'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + tab.style.position = 'relative'; + tab.style.width = '120px'; + const fill = appendElement(tab, 'tab-fill'); + const { label, name } = createEditorTabLabel(tab); + name.textContent = 'main.ts'; + const targetWindow = getWindow(root); + + for (const classes of ['', 'modern-ui', 'modern-ui modern-ui-connected-editor-tabs', 'modern-ui-connected-editor-tabs']) { + for (const theme of ['vs-dark', 'vs', 'hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui-tabs ${classes} ${theme}`; + const connected = classes === 'modern-ui modern-ui-connected-editor-tabs'; + for (const activeGroup of [true, false]) { + group.classList.toggle('active', activeGroup); + for (const compact of [true, false]) { + title.classList.toggle('compact-height', compact); + tab.classList.remove('active'); + const tabBounds = tab.getBoundingClientRect(); + const labelBounds = label.getBoundingClientRect(); + const fillBounds = fill.getBoundingClientRect(); + tab.classList.add('active'); + const activeFillBounds = fill.getBoundingClientRect(); + const activeFillStyle = targetWindow.getComputedStyle(fill); + + assert.deepStrictEqual({ + tabBounds: tab.getBoundingClientRect().toJSON(), + labelBounds: label.getBoundingClientRect().toJSON(), + fillExpansion: [ + fillBounds.top - activeFillBounds.top, + fillBounds.left - activeFillBounds.left, + activeFillBounds.right - fillBounds.right, + ], + topRadius: activeFillStyle.borderTopLeftRadius, + }, { + tabBounds: tabBounds.toJSON(), + labelBounds: labelBounds.toJSON(), + fillExpansion: [0, 0, connected ? -5 : 0], + topRadius: connected ? '5px' : '4px', + }, JSON.stringify({ classes, theme, activeGroup, compact })); + } + } + } + } + }); + + test('distinguishes active multi-selected connected tabs in high contrast without moving the well', () => { + const root = document.createElement('div'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-contrastActiveBorder', '#ff0000'); + root.style.setProperty('--vscode-focusBorder', '#00ff00'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const row = appendElement(title, 'tabs-and-actions-container'); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, 'tab active'); + tab.style.width = '120px'; + tab.style.height = '32px'; + const fill = appendElement(tab, 'tab-fill'); + const targetWindow = getWindow(root); + + for (const theme of ['hc-black', 'hc-light']) { + root.className = `monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs ${theme}`; + tab.className = 'tab active'; + const bounds = fill.getBoundingClientRect().toJSON(); + const ordinaryOutline = targetWindow.getComputedStyle(fill).outlineStyle; + tab.classList.add('selected', 'multi-selected'); + const selectedStyle = targetWindow.getComputedStyle(fill); + assert.deepStrictEqual({ + ordinaryOutline, + outline: selectedStyle.outlineStyle, + color: selectedStyle.outlineColor, + width: selectedStyle.outlineWidth, + offset: selectedStyle.outlineOffset, + bounds: fill.getBoundingClientRect().toJSON(), + }, { ordinaryOutline: 'none', outline: 'solid', color: 'rgb(255, 0, 0)', width: '1px', offset: '-3px', bounds }, theme); + } + }); + + test('matches connected cap and shoulder radii for the outside stroke and omits the first outer shoulder', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + const firstTab = appendElement(tabs, 'tab active'); + const firstFill = appendElement(firstTab, 'tab-fill'); + const middleTab = appendElement(tabs, 'tab'); + const middleFill = appendElement(middleTab, 'tab-fill'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const targetWindow = getWindow(root); + const getShoulders = (fill: HTMLElement) => [ + targetWindow.getComputedStyle(fill, '::before').content, + targetWindow.getComputedStyle(fill, '::after').content, + ]; + + const firstShoulders = getShoulders(firstFill); + firstTab.classList.remove('active'); + middleTab.classList.add('active'); + const leftShoulderStyle = targetWindow.getComputedStyle(middleFill, '::before'); + const rightShoulderStyle = targetWindow.getComputedStyle(middleFill, '::after'); + + assert.deepStrictEqual({ + firstTab: firstShoulders, + middleTab: { + shoulders: getShoulders(middleFill), + capRadii: [targetWindow.getComputedStyle(middleFill).borderTopLeftRadius, targetWindow.getComputedStyle(middleFill).borderTopRightRadius], + radii: [leftShoulderStyle.borderBottomRightRadius, rightShoulderStyle.borderBottomLeftRadius], + sizes: [ + [leftShoulderStyle.width, leftShoulderStyle.height], + [rightShoulderStyle.width, rightShoulderStyle.height], + ], + }, + }, { + firstTab: ['none', '""'], + middleTab: { + shoulders: ['""', '""'], + capRadii: ['5px', '5px'], + radii: ['5px', '5px'], + sizes: [['5px', '5px'], ['5px', '5px']], + }, + }); + }); + + test('keeps the right shoulder curved and the left edge straight at row boundaries', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size40', '4px'); + root.style.setProperty('--vscode-strokeThickness', '1px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const rowEndTab = appendElement(tabs, 'tab active last-in-row'); + const rowEndFill = appendElement(rowEndTab, 'tab-fill'); + const rowEndEdge = appendElement(rowEndTab, 'tab-connected-edge'); + const rowStartTab = appendElement(tabs, 'tab'); + const rowStartFill = appendElement(rowStartTab, 'tab-fill'); + const rowStartEdge = appendElement(rowStartTab, 'tab-connected-edge'); + appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const targetWindow = getWindow(root); + const getEdges = (fill: HTMLElement, edge: HTMLElement) => ({ + shoulders: [ + targetWindow.getComputedStyle(fill, '::before').content, + targetWindow.getComputedStyle(fill, '::after').content, + ], + masks: [ + targetWindow.getComputedStyle(edge, '::before').content, + targetWindow.getComputedStyle(edge, '::after').content, + ], + }); + + const rowEnd = getEdges(rowEndFill, rowEndEdge); + rowEndTab.classList.remove('active'); + rowStartTab.classList.add('active'); + + assert.deepStrictEqual({ + rowEnd, + rowStart: getEdges(rowStartFill, rowStartEdge), + }, { + rowEnd: { + shoulders: ['""', '""'], + masks: ['""', '""'], + }, + rowStart: { + shoulders: ['none', '""'], + masks: ['none', '""'], + }, + }); + }); + + test('extends the connected sticky mask to the strip separator', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-strokeThickness', '1px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabsAndActions = appendElement(title, 'tabs-and-actions-container'); + const scrollable = appendElement(tabsAndActions, 'monaco-scrollable-element'); + scrollable.style.position = 'relative'; + scrollable.style.width = '250px'; + scrollable.style.height = '33px'; + const stickyBackground = appendElement(scrollable, 'sticky-tabs-background'); + stickyBackground.style.width = '84px'; + const scrollableBounds = scrollable.getBoundingClientRect(); + const stickyBounds = stickyBackground.getBoundingClientRect(); + const stickyStyle = getWindow(root).getComputedStyle(stickyBackground); + + assert.deepStrictEqual({ + top: stickyStyle.top, + bottom: stickyStyle.bottom, + height: stickyBounds.height, + separatorHeight: scrollableBounds.bottom - stickyBounds.bottom, + }, { + top: '0px', + bottom: '1px', + height: 32, + separatorHeight: 1, + }); + }); + + test('aligns connected actions to the tab edge in every theme', () => { + const root = document.createElement('div'); + root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; + root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-cornerRadius-small', '4px'); + document.body.appendChild(root); + store.add(toDisposable(() => root.remove())); + + const editor = appendElement(root, 'part editor'); + const content = appendElement(editor, 'content'); + const group = appendElement(content, 'editor-group-container active'); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(title, 'tabs-container'); + const createTabAction = (className: string) => { + const tab = appendElement(tabs, `tab active ${className}`); + appendElement(tab, 'tab-fill'); + const actions = appendElement(tab, 'tab-actions'); + const actionBar = appendElement(actions, 'monaco-action-bar'); + const actionsContainer = appendElement(actionBar, 'actions-container'); + const actionItem = appendElement(actionsContainer, 'action-item'); + const actionLabel = appendElement(actionItem, 'action-label codicon'); + actionLabel.tabIndex = 0; + return { actions, actionBar, actionsContainer, actionLabel }; + }; + const right = createTabAction(''); + const left = createTabAction('tab-actions-left'); + const targetWindow = getWindow(root); + const getActionStyle = (action: typeof right) => { + action.actionLabel.focus(); + return { + radius: targetWindow.getComputedStyle(action.actions).borderRadius, + alignment: targetWindow.getComputedStyle(action.actionsContainer).justifyContent, + targetWidth: targetWindow.getComputedStyle(action.actions).width, + actionBarWidth: targetWindow.getComputedStyle(action.actionBar).width, + }; + }; + + const connected = { + right: getActionStyle(right), + left: getActionStyle(left), + }; + root.classList.add('hc-black'); + const highContrast = { + right: getActionStyle(right), + left: getActionStyle(left), + }; + + assert.deepStrictEqual({ connected, highContrast }, { + connected: { + right: { radius: '0px 4px 0px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, + left: { radius: '4px 0px 0px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, + }, + highContrast: { + right: { radius: '0px 4px 0px 0px', alignment: 'flex-end', targetWidth: '24px', actionBarWidth: '24px' }, + left: { radius: '4px 0px 0px', alignment: 'flex-start', targetWidth: '24px', actionBarWidth: '24px' }, + }, + }); + }); + test('uses the registered modern tab colors', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; @@ -1962,6 +2528,83 @@ suite('ModernUIContribution', () => { }); }); + test('uses readable theme foregrounds for connected inactive tabs in either editor group', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const themeStyle = document.createElement('style'); + root.appendChild(themeStyle); + const content = appendElement(appendElement(root, 'part editor'), 'content'); + const labels = [true, false].map(active => { + const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(appendElement(title, 'tabs-and-actions-container'), 'tabs-container'); + const tab = appendElement(tabs, 'tab'); + const fill = appendElement(tab, 'tab-fill'); + return { fill, ...createEditorTabLabel(tab) }; + }); + const targetWindow = getWindow(root); + const palettes = [ + { background: '#202122', inactive: '#8c8c8c', foreground: '#bfbfbf' }, + { background: '#eaeaea', inactive: '#606060', foreground: '#202020' }, + { background: '#2b2b2b', inactive: '#9d9d9d', foreground: '#cccccc' }, + { background: '#e5e5e5', inactive: '#616161', foreground: '#3b3b3b' }, + { background: '#303031', inactive: '#a6a6a6', foreground: '#cccccc' }, + { background: '#e8e8e8', inactive: '#616161', foreground: '#616161' }, + ]; + for (const palette of palettes) { + const theme = ColorThemeData.createUnloadedTheme('vs', { + [foreground]: palette.foreground, + [TAB_INACTIVE_FOREGROUND]: palette.inactive, + 'editorGroupHeader.tabsBackground': palette.background, + }); + themeStyle.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + assert.deepStrictEqual(labels.map(label => { + const background = Color.Format.CSS.parse(targetWindow.getComputedStyle(label.fill).backgroundColor)!; + const text = Color.Format.CSS.parse(targetWindow.getComputedStyle(label.name).color)!; + return { + foreground: text.toString(), + background: background.toString(), + readable: text.blend(background).getContrastRatio(background) >= 4.5, + }; + }), Array(2).fill({ foreground: palette.inactive, background: palette.background, readable: true })); + } + }); + + test('preserves pill and HC label defaults and explicit connected foreground customizations', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const theme = ColorThemeData.createUnloadedTheme('vs-dark', { + [foreground]: '#cccccc', + [TAB_INACTIVE_FOREGROUND]: '#9d9d9d', + }); + const updateTheme = () => style.textContent = generateColorThemeCSS(theme, '.monaco-workbench', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + updateTheme(); + const probe = appendElement(root, ''); + probe.style.color = 'var(--modern-ui-editor-tab-inactive-foreground)'; + const color = () => getWindow(root).getComputedStyle(probe).color; + const pill = color(); + root.classList.add('modern-ui', 'modern-ui-connected-editor-tabs'); + const connected = color(); + const highContrast = ['hc-black', 'hc-light'].map(themeClass => { + root.classList.add(themeClass); + const result = color(); + root.classList.remove(themeClass); + return result; + }); + theme.setCustomColors({ [TAB_INACTIVE_FOREGROUND]: '#123456', 'tab.unfocusedInactiveForeground': '#654321' }); + updateTheme(); + const custom = color(); + probe.style.color = 'var(--modern-ui-editor-tab-unfocused-inactive-foreground)'; + assert.deepStrictEqual({ connected, highContrast, custom, unfocusedCustom: color() }, { + connected: 'rgb(157, 157, 157)', + highContrast: [pill, pill], + custom: 'rgb(18, 52, 86)', + unfocusedCustom: 'rgb(101, 67, 33)', + }); + }); + test('keeps clean editor tabs compact while reserving persistent indicators', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui-tabs'; diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 6f4e681591b21..dd4707a052a96 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -53,9 +53,15 @@ export const enum LayoutSettings { SHADOWS = 'workbench.shadows', MODERN_UI = 'workbench.experimental.modernUI', MODERN_UI_DENSITY = 'window.density.layout', + MODERN_UI_EDITOR_TAB_STYLE = 'workbench.experimental.modernUIEditorTabStyle', MODERN_UI_UPPERCASE_VIEW_HEADERS = 'workbench.experimental.modernUIUppercaseViewHeaders' } +export const enum ModernUIEditorTabStyle { + Connected = 'connected', + Pill = 'pill' +} + export const enum ModernUIDensity { Default = 'default', Compact = 'compact' diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 4cee816e815cd..7aa3fc7e35439 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -32,7 +32,7 @@ function addColorVariable(declarations: string[], name: string, color: Color | u } } -function flattenActionBackground(color: Color, editorBackgroundColor: Color | undefined): Color { +function flattenTabBackground(color: Color, editorBackgroundColor: Color | undefined): Color { return editorBackgroundColor ? color.makeOpaque(editorBackgroundColor) : color; } @@ -79,22 +79,22 @@ registerThemingParticipant((theme, collector) => { addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); if (activeBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-background', flattenActionBackground(activeBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-active-background', flattenTabBackground(activeBackground, editorBackgroundColor)); } if (unfocusedActiveBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-background', flattenActionBackground(unfocusedActiveBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-background', flattenTabBackground(unfocusedActiveBackground, editorBackgroundColor)); } if (hoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-hover-background', flattenActionBackground(hoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-hover-background', flattenTabBackground(hoverBackground, editorBackgroundColor)); } if (unfocusedHoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenActionBackground(unfocusedHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenTabBackground(unfocusedHoverBackground, editorBackgroundColor)); } if (activeHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-hover-background', flattenActionBackground(activeHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-active-hover-background', flattenTabBackground(activeHoverBackground, editorBackgroundColor)); } if (unfocusedActiveHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenActionBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); + addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenTabBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); } if (declarations.length > 0) { diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css index b173ee30c6996..f466022eba202 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.css @@ -7,6 +7,10 @@ background-color: var(--modern-ui-editor-tab-hover-background); } +.modern-ui.modern-ui-connected-editor-tabs.monaco-workbench .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; diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 74619a7f1d512..ee84e4381c980 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, Dimension } from '../../../../../base/browser/dom.js'; +import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { Action } from '../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; @@ -29,6 +29,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActions, Verbosity } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; @@ -53,11 +54,12 @@ import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../. import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { IOutlineService } from '../../../../services/outline/browser/outline.js'; -import { LayoutSettings } from '../../../../services/layout/browser/layoutService.js'; +import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; import { TestContextService } from '../../../common/workbenchTestServices.js'; import { workbenchInstantiationService } from '../../workbenchTestServices.js'; -import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; import './editorTabBar.fixture.css'; // ============================================================================ @@ -178,7 +180,7 @@ function duplicateNameEditorSpecs(): IEditorSpec[] { } /** A larger set of editors, useful for wrapping / scrollbar / label variants. */ -function manyEditorSpecs(): IEditorSpec[] { +function manyEditorSpecs(activeIndex = 0): IEditorSpec[] { const names = [ 'main.ts', 'index.ts', 'button.tsx', 'input.tsx', 'list.tsx', 'tree.tsx', 'model.ts', 'service.ts', 'view.ts', 'controller.ts', 'utils.ts', 'types.ts', @@ -187,11 +189,16 @@ function manyEditorSpecs(): IEditorSpec[] { return names.map((name, index) => ({ resource: file(`/project/src/module${index % 4}/${name}`), pinned: true, - active: index === 0, + active: index === activeIndex, dirty: index % 5 === 0, })); } +/** 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 [ @@ -346,6 +353,7 @@ function createFixtureEditorTitleActions(store: DisposableStore, menuId: MenuId) export interface IEditorTabBarFixtureOptions { readonly modernUI: boolean; readonly partOptions?: Partial; + readonly editorTabStyle?: ModernUIEditorTabStyle; readonly editors?: IEditorSpec[]; readonly breadcrumbs?: { readonly filePath?: 'on' | 'off' | 'last'; @@ -364,6 +372,8 @@ export interface IEditorTabBarFixtureOptions { readonly colorCustomizations?: Readonly>; readonly forcedHoverTab?: number; readonly focusedTabAction?: number; + readonly editorContents?: string; + readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; } function createPartOptions(overrides?: Partial): IEditorPartOptions { @@ -419,6 +429,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: icons: options.breadcrumbs?.icons ?? true, }); configurationService.setUserConfiguration(LayoutSettings.MODERN_UI, options.modernUI); + configurationService.setUserConfiguration(LayoutSettings.MODERN_UI_EDITOR_TAB_STYLE, options.editorTabStyle ?? ModernUIEditorTabStyle.Connected); const instantiationService = workbenchInstantiationService({ configurationService: () => configurationService, @@ -505,10 +516,12 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: // Recreate the ancestor chain the tab-bar CSS is scoped to; the fixture container already // carries `.monaco-workbench` + theme classes. const editorPart = $('.part.editor'); + editorPart.classList.toggle('editor-tabs-multiple', partOptions.showTabs === 'multiple'); const content = $('.content'); const groupContainer = $(isGroupActive ? '.editor-group-container.active' : '.editor-group-container'); const titleContainer = $('.title'); 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); @@ -519,7 +532,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: const editorContainer = $('.editor-container'); editorContainer.style.height = '96px'; - editorContainer.style.opacity = '0.6'; + editorContainer.style.backgroundColor = 'var(--vscode-editor-background)'; editorPart.appendChild(content); content.appendChild(groupContainer); @@ -527,6 +540,23 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: groupContainer.appendChild(editorContainer); container.appendChild(editorPart); + if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { + editorContainer.style.height = '240px'; + const editorServices = createEditorServices(disposableStore, { colorTheme: theme }); + const textModel = disposableStore.add(createTextModel(editorServices, options.editorContents, model.activeEditor.resource, 'typescript')); + const editor = disposableStore.add(editorServices.createInstance(CodeEditorWidget, editorContainer, { + readOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbers: 'on', + folding: false, + renderLineHighlight: 'none', + padding: { top: 16 }, + }, { contributions: [] })); + editor.setModel(textModel); + editor.layout(new Dimension(width, 240)); + } + container.style.width = `${width}px`; groupContainer.style.width = `${width}px`; @@ -568,16 +598,50 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: } } layout(); + if (options.activeTabClipping) { + disposableStore.add(scheduleAtNextAnimationFrame(getWindow(container), () => { + const tabsContainer = titleContainer.querySelector('.tabs-container'); + const activeTab = tabsContainer?.querySelector('.tab.active'); + if (!tabsContainer || !activeTab) { + throw new Error('The clipped tab fixture requires an active tab'); + } + tabsContainer.classList.add('scroll'); + 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) => renderEditorTabBarFixture(ctx, { ...options, modernUI }); + return (ctx: ComponentFixtureContext) => { + ctx.container.classList.toggle('modern-ui', modernUI); + renderEditorTabBarFixture(ctx, { ...options, modernUI }); + }; } function createFixtures(modernUI: boolean, additionalThemes: readonly ComponentFixtureAdditionalTheme[] = []) { return { // Baseline: multiple tabs with mixed sticky / pinned / preview / dirty state. - Default: defineComponentFixture({ render: render(modernUI, {}), additionalThemes }), + Default: defineComponentFixture({ + render: render(modernUI, {}), + additionalThemes, + expectedVisualDescriptions: modernUI ? [ + 'Inactive editor tabs and the tab strip use the panel background. The active tab flows into the editor with curved shoulders and an open lower edge. A subtle stroke separates the surfaces. High contrast retains explicit selection borders.', + ] : [], + }), // showTabs ShowTabsSingle: defineComponentFixture({ render: render(modernUI, { partOptions: { showTabs: 'single' }, breadcrumbs: {} }), additionalThemes }), @@ -710,11 +774,15 @@ function getModernEditorTabColorCustomizations(theme: ComponentFixtureContext['t } function renderThemeColors(options: Omit): (ctx: ComponentFixtureContext) => void { - return ctx => renderEditorTabBarFixture(ctx, { - ...options, - modernUI: true, - colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), - }); + return ctx => { + ctx.container.classList.add('modern-ui'); + renderEditorTabBarFixture(ctx, { + ...options, + modernUI: true, + editorTabStyle: ModernUIEditorTabStyle.Pill, + colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), + }); + }; } function createThemeColorFixtures() { @@ -726,6 +794,40 @@ function createThemeColorFixtures() { }; } +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' }, + }); +} + +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, {}) }), @@ -735,6 +837,175 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ModernUIOff: defineThemedFixtureGroup(createFixtures(false, ['darkHighContrast'])), ModernUIOn: defineThemedFixtureGroup({ ...createFixtures(true, ['darkHighContrast']), + Pill: defineComponentFixture({ + render: render(true, { editorTabStyle: ModernUIEditorTabStyle.Pill }), + additionalThemes: ['darkHighContrast'], + expectedVisualDescriptions: [ + 'Editor tabs remain separate rounded pills with no connecting shoulders or connected strip border. High contrast retains explicit selection and focus borders.', + ], + }), ThemeColors: defineThemedFixtureGroup(createThemeColorFixtures()), }), + ConnectedSurface: defineThemedFixtureGroup({ + MinimumIdentity: defineComponentFixture({ + render: renderConnectedSurface(3, undefined, undefined, { + width: 380, + partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 30, tabSizingFixedMaxWidth: 30, editorActionsLocation: 'hidden' }, + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['Narrow tabs drop their file icon slot before truncating the basename with a real ellipsis. Each extension stays readable; package.json retains its M badge and dirty action. Clean inactive actions stay hidden, while the active close or dirty action remains visible.'], + }), + MinimumIdentityHovered: defineComponentFixture({ + additionalThemes: connectedSurfaceThemes, + render: renderConnectedSurface(3, 0, undefined, { + width: 380, + partOptions: { tabSizing: 'fixed', tabSizingFixedMinWidth: 30, tabSizingFixedMaxWidth: 30, editorActionsLocation: 'hidden' }, + }), + expectedVisualDescriptions: ['Hover reveals the inactive close without covering its ellipsized basename or extension.'], + }), + NarrowWindow: defineComponentFixture({ + render: renderConnectedSurface(0, undefined, undefined, { + width: 420, + editors: manyEditorSpecs().slice(0, 10), + partOptions: { tabSizing: 'shrink', editorActionsLocation: 'hidden' }, + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['A narrow editor with ten open tabs collapses file icon slots to give filenames more room. Basenames ellipsize while extensions and active close or dirty indicators remain visible. Tabs scroll rather than shrink below their readable minimum. This is a deliberate width-pressure scenario, not the default sizing.'], + }), + UpperWrappedPills: defineComponentFixture({ + render: renderWrappedConnectedSurface(0), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active tab in the upper wrapped row is a rounded pill with no shoulders, using the same background as the document well, including behind its close action. Only tabs in the bottom row can connect to the document.'], + }), + BottomWrappedConnected: defineComponentFixture({ + render: renderWrappedConnectedSurface(9), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The selected tab in the bottom wrapped row connects directly to the document well with curved shoulders and no bottom gap, just like a single row. The adjacent inactive tab also reaches the well boundary. Upper-row tabs retain separate rounded pills.'], + }), + UpperWrappedHover: defineComponentFixture({ + render: renderWrappedConnectedSurface(9, 1), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The hovered inactive tab in the upper wrapped row stays a control-tier pill within its row, without extending into or behind the selected tab below. Its close button is visible. The bottom selected tab remains connected to the document well without a gap.'], + }), + UpperWrappedHoverCompact: defineComponentFixture({ + render: renderWrappedConnectedSurface(9, 1, 'compact'), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['At compact tab height, the hovered inactive upper-row pill and its close button stay clear of the selected tab below. The bottom selected tab still joins the document well without a gap.'], + }), + UpperPinnedPills: defineComponentFixture({ + render: renderConnectedSurface(0, undefined, undefined, { + partOptions: { pinnedTabsOnSeparateRow: true }, + editors: [ + { resource: file('/project/README.md'), sticky: true, pinned: true, active: true }, + { resource: file('/project/src/main.ts'), pinned: true }, + { resource: file('/project/package.json'), pinned: true, dirty: true }, + ], + }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active pinned tab in the separate upper strip remains a rounded pill with the document well background, not a disconnected tab-shaped well.'], + }), + InactiveGroup: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { active: false }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The selected tab and document well share a neutral contrast border in an inactive HC editor group, distinct from the focus-colored border of the active group. Standard themes have no prominent well border.'], + }), + Breadcrumbs: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { breadcrumbs: {} }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The connected outline continues down both sides of the breadcrumbs and around the document body, without a line beneath the selected tab.'], + }), + MultiSelect: defineComponentFixture({ + render: renderConnectedSurface(1, undefined, undefined, { editors: multiSelectEditorSpecs() }), + additionalThemes: connectedSurfaceThemes, + expectedVisualDescriptions: ['The active tab 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/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index cd9d5dc342c28..dd990fd69cef6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -266,6 +266,7 @@ class NullStorageService implements IStorageService { // comments and trailing commas the way it does in the real product. /* eslint-disable local/code-import-patterns */ import dark_2026 from '../../../../../../extensions/theme-defaults/themes/2026-dark.json' with { type: 'json' }; +import light_2026 from '../../../../../../extensions/theme-defaults/themes/2026-light.json' with { type: 'json' }; import dark_modern from '../../../../../../extensions/theme-defaults/themes/dark_modern.json' with { type: 'json' }; import dark_plus from '../../../../../../extensions/theme-defaults/themes/dark_plus.json' with { type: 'json' }; import dark_vs from '../../../../../../extensions/theme-defaults/themes/dark_vs.json' with { type: 'json' }; @@ -274,6 +275,11 @@ import hc_light from '../../../../../../extensions/theme-defaults/themes/hc_ligh import light_modern from '../../../../../../extensions/theme-defaults/themes/light_modern.json' with { type: 'json' }; import light_plus from '../../../../../../extensions/theme-defaults/themes/light_plus.json' with { type: 'json' }; import light_vs from '../../../../../../extensions/theme-defaults/themes/light_vs.json' with { type: 'json' }; +import abyss from '../../../../../../extensions/theme-abyss/themes/abyss-color-theme.json' with { type: 'json' }; +import monokai from '../../../../../../extensions/theme-monokai/themes/monokai-color-theme.json' with { type: 'json' }; +import quietlight from '../../../../../../extensions/theme-quietlight/themes/quietlight-color-theme.json' with { type: 'json' }; +import solarized_dark from '../../../../../../extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json' with { type: 'json' }; +import solarized_light from '../../../../../../extensions/theme-solarized-light/themes/solarized-light-color-theme.json' with { type: 'json' }; /* eslint-enable local/code-import-patterns */ function toThemeJsonText(theme: string | object): string { @@ -291,6 +297,7 @@ const fileIconThemeResourceUrls = new Set(Object.values(fileIconThemeResources). const themeJsonModules: Record = { '/extensions/theme-defaults/themes/2026-dark.json': toThemeJsonText(dark_2026), + '/extensions/theme-defaults/themes/2026-light.json': toThemeJsonText(light_2026), '/extensions/theme-defaults/themes/dark_modern.json': toThemeJsonText(dark_modern), '/extensions/theme-defaults/themes/dark_plus.json': toThemeJsonText(dark_plus), '/extensions/theme-defaults/themes/dark_vs.json': toThemeJsonText(dark_vs), @@ -299,6 +306,11 @@ const themeJsonModules: Record = { '/extensions/theme-defaults/themes/light_modern.json': toThemeJsonText(light_modern), '/extensions/theme-defaults/themes/light_plus.json': toThemeJsonText(light_plus), '/extensions/theme-defaults/themes/light_vs.json': toThemeJsonText(light_vs), + '/extensions/theme-abyss/themes/abyss-color-theme.json': toThemeJsonText(abyss), + '/extensions/theme-monokai/themes/monokai-color-theme.json': toThemeJsonText(monokai), + '/extensions/theme-quietlight/themes/quietlight-color-theme.json': toThemeJsonText(quietlight), + '/extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json': toThemeJsonText(solarized_dark), + '/extensions/theme-solarized-light/themes/solarized-light-color-theme.json': toThemeJsonText(solarized_light), }; const fixtureExtensionResourceLoaderService = new class implements IExtensionResourceLoaderService { @@ -325,12 +337,12 @@ const fixtureExtensionResourceLoaderService = new class implements IExtensionRes getExtensionGalleryResourceURL(): Promise { return Promise.resolve(undefined); } }; -function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector): ColorThemeData { +function createBuiltInTheme(themePath: string, uiTheme: ThemeTypeSelector, extensionName = 'theme-defaults'): ColorThemeData { const location = URI.parse(`file://${themePath}`); return ColorThemeData.fromExtensionTheme( { id: themePath, path: themePath, uiTheme, _watch: false }, location, - ExtensionData.fromName('vscode', 'theme-defaults', true) + ExtensionData.fromName('vscode', extensionName, true) ); } @@ -364,8 +376,19 @@ type ComponentFixtureThemeVariant = { const darkThemeVariant = { label: 'Dark', background: 'dark', theme: darkTheme } as const satisfies ComponentFixtureThemeVariant; const lightThemeVariant = { label: 'Light', background: 'light', theme: lightTheme } as const satisfies ComponentFixtureThemeVariant; const additionalThemeVariants = { + darkModern: { label: 'DarkModern', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_modern.json', ThemeTypeSelector.VS_DARK) }, + light2026: { label: 'Light2026', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/2026-light.json', ThemeTypeSelector.VS) }, + darkPlus: { label: 'DarkPlus', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_plus.json', ThemeTypeSelector.VS_DARK) }, + lightPlus: { label: 'LightPlus', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/light_plus.json', ThemeTypeSelector.VS) }, + visualStudioDark: { label: 'VisualStudioDark', background: 'dark', theme: createBuiltInTheme('/extensions/theme-defaults/themes/dark_vs.json', ThemeTypeSelector.VS_DARK) }, + visualStudioLight: { label: 'VisualStudioLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-defaults/themes/light_vs.json', ThemeTypeSelector.VS) }, darkHighContrast: { label: 'DarkHighContrast', background: 'dark', theme: darkHighContrastTheme }, lightHighContrast: { label: 'LightHighContrast', background: 'light', theme: lightHighContrastTheme }, + abyss: { label: 'Abyss', background: 'dark', theme: createBuiltInTheme('/extensions/theme-abyss/themes/abyss-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-abyss') }, + monokai: { label: 'Monokai', background: 'dark', theme: createBuiltInTheme('/extensions/theme-monokai/themes/monokai-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-monokai') }, + quietLight: { label: 'QuietLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-quietlight/themes/quietlight-color-theme.json', ThemeTypeSelector.VS, 'theme-quietlight') }, + solarizedDark: { label: 'SolarizedDark', background: 'dark', theme: createBuiltInTheme('/extensions/theme-solarized-dark/themes/solarized-dark-color-theme.json', ThemeTypeSelector.VS_DARK, 'theme-solarized-dark') }, + solarizedLight: { label: 'SolarizedLight', background: 'light', theme: createBuiltInTheme('/extensions/theme-solarized-light/themes/solarized-light-color-theme.json', ThemeTypeSelector.VS, 'theme-solarized-light') }, } as const satisfies Record; export type ComponentFixtureAdditionalTheme = keyof typeof additionalThemeVariants; diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts index 0a8b916a98842..9b04ad8739061 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.test.ts @@ -9,7 +9,9 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { toDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../../../editor/browser/viewParts/currentLineHighlight/currentLineHighlight.js'; +import '../../../../editor/browser/widget/codeEditor/editor.css'; import { ThemeTypeSelector } from '../../../../platform/theme/common/theme.js'; +import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { ColorThemeData } from '../../../services/themes/common/colorThemeData.js'; import type { IColorCustomizations } from '../../../services/themes/common/workbenchThemeService.js'; import { getThemeStyleSheet } from './fixtureUtilsCss.js'; @@ -43,8 +45,52 @@ suite('Component fixture theme CSS', () => { colors: { 'editor.lineHighlightBackground': '#00000000', 'editor.lineHighlightBorder': '#0f4a85' }, expected: { border: '1px solid rgb(15, 74, 133)', background: 'rgba(0, 0, 0, 0)' }, }, + { + selector: ThemeTypeSelector.VS_DARK, + colors: { 'editor.lineHighlightBackground': '#002b36' }, + expected: { border: '0px none rgb(0, 0, 0)', background: 'rgb(0, 43, 54)' }, + }, + { + selector: ThemeTypeSelector.VS, + colors: { 'editor.lineHighlightBackground': '#fdf6e3' }, + expected: { border: '0px none rgb(0, 0, 0)', background: 'rgb(253, 246, 227)' }, + }, ]; + test('does not apply workbench backgrounds to nested editor theme classes', () => { + const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; + disposables.add(toDisposable(() => { mainWindow.document.adoptedStyleSheets = originalStyleSheets; })); + disposables.add(registerThemingParticipant((_theme, collector) => { + collector.addRule('.monaco-workbench { background-color: #654321; }'); + })); + const host = mainWindow.document.body.appendChild($('div')); + disposables.add(toDisposable(() => host.remove())); + + const actual = variants.map((variant, index) => { + const id = `${variant.selector} workbench-background-${index}`; + const theme = ColorThemeData.createLoadedEmptyTheme(id, id); + theme.setCustomColors({ 'editor.background': '#123456' }); + mainWindow.document.adoptedStyleSheets = [ + ...mainWindow.document.adoptedStyleSheets, + getThemeStyleSheet(theme), + ]; + const root = host.appendChild($('.monaco-workbench')); + root.classList.add(...theme.classNames); + const editor = root.appendChild($('.monaco-editor')); + editor.classList.add(variant.selector); + const scrollable = editor.appendChild($('.monaco-scrollable-element')); + scrollable.classList.add(variant.selector); + + return [root, editor, scrollable].map(element => mainWindow.getComputedStyle(element).backgroundColor); + }); + + assert.deepStrictEqual(actual, variants.map(() => [ + 'rgb(101, 67, 33)', + 'rgb(18, 52, 86)', + 'rgba(0, 0, 0, 0)', + ])); + }); + for (const reverse of [false, true]) { test(`isolates active-line styles in ${reverse ? 'reverse' : 'forward'} theme installation order`, () => { const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; @@ -56,12 +102,13 @@ suite('Component fixture theme CSS', () => { const actual = []; const orderedVariants = reverse ? [...variants].reverse() : variants; const expectedStyles = orderedVariants.map(variant => ({ - selector: variant.selector, + selector: `${variant.selector} fixture-${variants.indexOf(variant)}`, ...resolveStyle(host, variant.expected), })); for (const variant of orderedVariants) { - const theme = ColorThemeData.createLoadedEmptyTheme(variant.selector, variant.selector); + const id = `${variant.selector} fixture-${variants.indexOf(variant)}`; + const theme = ColorThemeData.createLoadedEmptyTheme(id, id); theme.setCustomColors(variant.colors); mainWindow.document.adoptedStyleSheets = [ ...mainWindow.document.adoptedStyleSheets, @@ -69,7 +116,7 @@ suite('Component fixture theme CSS', () => { ]; const root = host.appendChild($('.monaco-workbench')); root.classList.add(...theme.classNames); - lines.set(variant.selector, createCurrentLine(root)); + lines.set(id, createCurrentLine(root)); actual.push({ outside: getCurrentLineStyle(outsideLine), fixtures: [...lines].map(([selector, line]) => ({ selector, ...getCurrentLineStyle(line) })), @@ -81,6 +128,37 @@ suite('Component fixture theme CSS', () => { fixtures: expectedStyles.slice(0, index + 1), }))); }); + + test(`isolates color variables on nested editor theme classes in ${reverse ? 'reverse' : 'forward'} theme installation order`, () => { + const originalStyleSheets = [...mainWindow.document.adoptedStyleSheets]; + disposables.add(toDisposable(() => { mainWindow.document.adoptedStyleSheets = originalStyleSheets; })); + const host = mainWindow.document.body.appendChild($('div')); + disposables.add(toDisposable(() => host.remove())); + const orderedVariants = reverse ? [...variants].reverse() : variants; + const editors: HTMLElement[] = []; + const actual = []; + + for (const variant of orderedVariants) { + const id = `${variant.selector} fixture-${variants.indexOf(variant)}`; + const theme = ColorThemeData.createLoadedEmptyTheme(id, id); + theme.setCustomColors(variant.colors); + mainWindow.document.adoptedStyleSheets = [ + ...mainWindow.document.adoptedStyleSheets, + getThemeStyleSheet(theme), + ]; + const root = host.appendChild($('.monaco-workbench')); + root.classList.add(...theme.classNames); + const editor = root.appendChild($('.monaco-editor')); + editor.classList.add(variant.selector); + editor.style.backgroundColor = 'var(--vscode-editor-lineHighlightBackground)'; + editors.push(editor); + actual.push(editors.map(element => mainWindow.getComputedStyle(element).backgroundColor)); + } + + assert.deepStrictEqual(actual, orderedVariants.map((_, index) => + orderedVariants.slice(0, index + 1).map(variant => variant.expected.background) + )); + }); } }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts index 1d2a039742e0f..3c4b13981b7f1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtilsCss.ts @@ -209,7 +209,7 @@ export function getThemeStyleSheet(theme: ColorThemeData): CSSStyleSheet { return cachedStyleSheet; } - const scopeSelector = '.' + theme.classNames[0]; + const scopeSelector = theme.classNames.map(className => `.${CSS.escape(className)}`).join(''); const themingParticipants = themingRegistry.getThemingParticipants(); const sheet = new CSSStyleSheet(); const css = generateColorThemeCSS( diff --git a/src/vs/workbench/test/browser/parts/editor/editor.test.ts b/src/vs/workbench/test/browser/parts/editor/editor.test.ts index e0e4b0428cbc1..c7839ada9978c 100644 --- a/src/vs/workbench/test/browser/parts/editor/editor.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editor.test.ts @@ -427,6 +427,28 @@ suite('Workbench editor utils', () => { assert.strictEqual(part.partOptions.tabActionReserveSpace, false); }); + test('editor tab mode class follows configured and enforced part options', async () => { + const configuredInstantiationService = workbenchInstantiationService({ + configurationService: () => { + const configurationService = new TestConfigurationService({ + workbench: { editor: { showTabs: 'single' } } + }); + disposables.add(configurationService.onDidChangeConfigurationEmitter); + return configurationService; + } + }, disposables); + const part = await createEditorPart(configuredInstantiationService, disposables); + const hasMultipleTabs = () => part.getContainer()!.classList.contains('editor-tabs-multiple'); + const states = [hasMultipleTabs()]; + for (const showTabs of ['multiple', 'none', 'single'] as const) { + const override = disposables.add(part.enforcePartOptions({ showTabs })); + states.push(hasMultipleTabs()); + override.dispose(); + states.push(hasMultipleTabs()); + } + assert.deepStrictEqual(states, [false, true, false, false, false, false, false]); + }); + test('whenEditorClosed (single editor)', async function () { return testWhenEditorClosed(false, false, toResource.call(this, '/path/index.txt')); }); diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 2205b40ce05bb..11936e5a03a45 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { $, EventType, ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; +import { $, Dimension, EventType, ModifierKeyEmitter, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { Event } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -15,12 +15,14 @@ import { TreeViewsDnDService } from '../../../../../editor/common/services/treeV import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { MultiEditorTabsControl } from '../../../../browser/parts/editor/multiEditorTabsControl.js'; -import { EditorsOrder } from '../../../../common/editor.js'; +import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { TestFileEditorInput, TestHostService, workbenchInstantiationService } from '../../workbenchTestServices.js'; +import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; suite('MultiEditorTabsControl', () => { @@ -28,9 +30,13 @@ suite('MultiEditorTabsControl', () => { let container: HTMLElement; let hostService: TestHostService; + let control: MultiEditorTabsControl; + let partOptions: IEditorPartOptions; + let model: EditorGroupModel; setup(() => { disposables = new DisposableStore(); + partOptions = { ...DEFAULT_EDITOR_PART_OPTIONS }; // The tabs control resolves the shared modifier key emitter on creation, // so dispose it again to keep each test independent of the Alt state that @@ -43,9 +49,11 @@ suite('MultiEditorTabsControl', () => { hostService = instantiationService.get(IHostService) as TestHostService; - const model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); + model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); for (let i = 0; i < 2; i++) { - const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + const editor = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return `file${i}.txt`; } + }(URI.file(`/path/file${i}.txt`), 'testEditorInput')); model.openEditor(editor, { pinned: true, active: i === 0 }); } @@ -71,7 +79,7 @@ suite('MultiEditorTabsControl', () => { }; const groupsView = new class extends mock() { - override get partOptions() { return DEFAULT_EDITOR_PART_OPTIONS; } + override get partOptions() { return partOptions; } override get activeGroup(): IEditorGroupView { return groupView; } override get groups(): IEditorGroupView[] { return [groupView]; } override readonly onDidChangeEditorPartOptions = Event.None; @@ -86,7 +94,7 @@ suite('MultiEditorTabsControl', () => { container = $('.title.tabs'); mainWindow.document.body.appendChild(container); - const control = disposables.add(instantiationService.createInstance(MultiEditorTabsControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); + control = disposables.add(instantiationService.createInstance(MultiEditorTabsControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); }); @@ -122,6 +130,820 @@ suite('MultiEditorTabsControl', () => { mainWindow.dispatchEvent(new KeyboardEvent(pressed ? EventType.KEY_DOWN : EventType.KEY_UP, { key: 'Alt', altKey: pressed })); } + function connectedGroup(): HTMLElement { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size160: 16px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-fontSize-body1: 13px; --vscode-fontWeight-regular: 400;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + return group; + } + + async function layoutConnectedGroup(group: HTMLElement, width: number): Promise { + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 300) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + } + + test('connected minimum width preserves basename ellipsis extension badge and action', async () => { + const group = connectedGroup(); + const badgeStyle = document.createElement('style'); + badgeStyle.textContent = '.connected-tabs-labels .monaco-decoration-badge::after { content: "WM"; margin: 0 5px; }'; + group.appendChild(badgeStyle); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, editorActionsLocation: 'hidden', hasIcons: true, showTabIndex: true }; + control.updateOptions(oldOptions, partOptions); + const tab = container.querySelector('.tab')!; + const label = tab.querySelector('.tab-label')!; + label.classList.add('monaco-decoration-badge'); + await layoutConnectedGroup(group, 200); + // Resource labels are redrawn on entry to the connected mode. + label.classList.add('monaco-decoration-badge'); + await layoutConnectedGroup(group, 200); + const name = tab.querySelector('.label-name')!; + const nameContainer = name.parentElement!; + const suffix = tab.querySelector('.label-suffix')!; + const action = tab.querySelector('.tab-actions')!; + const context = document.createElement('canvas').getContext('2d')!; + const badge = mainWindow.getComputedStyle(label, '::after'); + context.font = badge.font; + const badgeTextWidth = context.measureText('WM').width; + context.font = mainWindow.getComputedStyle(name).font; + assert.deepStrictEqual({ + minimum: tab.offsetWidth >= Math.ceil(context.measureText('1: f….txt').width + badgeTextWidth) + 10 + 34, + intrinsicBadgeWidth: Math.abs(parseFloat(badge.width) - badgeTextWidth) < 1, + narrow: tab.classList.contains('connected-tab-narrow'), + iconHidden: mainWindow.getComputedStyle(label, '::before').display, + basename: name.textContent, + extension: suffix.textContent, + ellipsis: mainWindow.getComputedStyle(nameContainer).textOverflow, + basenameVisible: nameContainer.clientWidth >= context.measureText('1: f…').width, + extensionBeforeAction: suffix.getBoundingClientRect().right <= action.getBoundingClientRect().left, + fullAriaLabel: tab.getAttribute('aria-label')?.includes('file0.txt'), + }, { + minimum: true, intrinsicBadgeWidth: true, narrow: true, iconHidden: 'none', basename: '1: file0', extension: '.txt', + ellipsis: 'ellipsis', basenameVisible: true, extensionBeforeAction: true, fullAriaLabel: true, + }); + name.style.fontSize = '13px'; + nameContainer.style.fontSize = '20px'; + await layoutConnectedGroup(group, 200); + context.font = mainWindow.getComputedStyle(name).font; + const firstCharacterWidth = context.measureText('1: f').width; + context.font = mainWindow.getComputedStyle(nameContainer).font; + assert.ok(nameContainer.clientWidth >= firstCharacterWidth + context.measureText('…').width, JSON.stringify({ + message: 'reserve the ellipsis using its container font', + actual: nameContainer.clientWidth, + required: firstCharacterWidth + context.measureText('…').width, + minimum: tab.style.getPropertyValue('--connected-tab-min-width'), + tabWidth: tab.offsetWidth, + })); + group.closest('.monaco-workbench')!.classList.remove('modern-ui-connected-editor-tabs'); + await layoutConnectedGroup(group, 200); + assert.deepStrictEqual([name.textContent, suffix.textContent, tab.style.getPropertyValue('--connected-tab-min-width')], ['1: file0.txt', '', '']); + }); + + test('connected shrink tabs collapse and restore icons as the editor width changes', async () => { + const group = connectedGroup(); + const iconStyle = document.createElement('style'); + iconStyle.textContent = '.connected-tabs-labels .tab-label::before { content: ""; }'; + group.appendChild(iconStyle); + for (let i = 2; i < 10; i++) { + const editor = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return `file${i}.txt`; } + }(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: false }); + } + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'shrink', hasIcons: true, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const states = []; + for (const width of [1200, 420, 1200, 420]) { + await layoutConnectedGroup(group, width); + const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')); + states.push({ + collapsedIcons: tabs.filter(tab => tab.classList.contains('connected-tab-narrow')).length, + trimmedNames: tabs.filter(tab => { + const name = tab.querySelector('.monaco-icon-name-container')!; + return name.scrollWidth > name.clientWidth; + }).length, + preservedExtensions: tabs.every(tab => { + const suffix = tab.querySelector('.label-suffix')!; + const action = tab.querySelector('.tab-actions')!; + return suffix.textContent === '.txt' && suffix.getBoundingClientRect().right <= action.getBoundingClientRect().left; + }), + }); + } + assert.deepStrictEqual(states, [ + { collapsedIcons: 0, trimmedNames: 0, preservedExtensions: true }, + { collapsedIcons: 10, trimmedNames: 10, preservedExtensions: true }, + { collapsedIcons: 0, trimmedNames: 0, preservedExtensions: true }, + { collapsedIcons: 10, trimmedNames: 10, preservedExtensions: true }, + ]); + }); + + test('protects extensions only when the tab title matches the resource filename', async () => { + const group = connectedGroup(); + const cases = [ + { resource: URI.file('/path/archive.tar.gz'), name: 'archive.tar.gz' }, + { resource: URI.file('/path/.env'), name: '.env' }, + { resource: URI.file('/path/file.'), name: 'file.' }, + { resource: URI.file('/path/notes.md'), name: 'Release 1.2 notes and announcements' }, + { resource: URI.from({ scheme: 'test', path: '/views/123' }), name: 'Example.org documentation' }, + { resource: undefined, name: 'Release 1.2 notes and announcements' }, + ]; + const results = []; + for (const { resource, name } of cases) { + const editor = disposables.add(new class extends EditorInput { + override get typeId(): string { return 'testEditorTitle'; } + override get resource(): URI | undefined { return resource; } + override getName(): string { return name; } + }()); + model.openEditor(editor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 240); + const tab = container.querySelector('.tab.active')!; + results.push({ + name: tab.querySelector('.label-name')?.textContent, + suffix: tab.querySelector('.label-suffix')?.textContent ?? '', + accessibleName: tab.getAttribute('aria-label')?.includes(name), + }); + } + assert.deepStrictEqual(results, [ + { name: 'archive.tar', suffix: '.gz', accessibleName: true }, + { name: '.env', suffix: '', accessibleName: true }, + { name: 'file.', suffix: '', accessibleName: true }, + { name: 'Release 1.2 notes and announcements', suffix: '', accessibleName: true }, + { name: 'Example.org documentation', suffix: '', accessibleName: true }, + { name: 'Release 1.2 notes and announcements', suffix: '', accessibleName: true }, + ]); + }); + + test('connected actions keep active and dirty visible but inactive clean quiet', async () => { + const group = connectedGroup(); + container.classList.add('tab-actions-reserve-space'); + await layoutConnectedGroup(group, 400); + const tabs = Array.from(container.querySelectorAll('.tab')); + const opacity = () => tabs.map(tab => mainWindow.getComputedStyle(tab.querySelector('.action-label')!).opacity); + const clean = opacity(); + tabs[1].classList.add('dirty'); + const dirty = opacity(); + tabs[1].classList.remove('dirty'); + const action = tabs[1].querySelector('.action-label')!; + action.tabIndex = 0; + action.focus(); + const focused = opacity(); + action.blur(); + assert.deepStrictEqual({ clean, dirty, focused }, { clean: ['1', '0'], dirty: ['1', '1'], focused: ['1', '1'] }); + }); + + test('reveals the active tab with its right shoulder outside the label and action', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160.25, tabSizingFixedMaxWidth: 160.25, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true, active: false }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 240); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + control.layout({ container: new Dimension(240, 33), available: new Dimension(240, 300) }, { forceRevealActiveTab: true }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + const tab = container.querySelectorAll('.tabs-container > .tab')[1]; + const fill = tab.querySelector('.tab-fill')!; + const action = tab.querySelector('.tab-actions')!; + const viewport = container.querySelector('.monaco-scrollable-element')!; + const shoulderWidth = Number.parseFloat(mainWindow.getComputedStyle(fill, '::after').width); + assert.deepStrictEqual({ + labelBeforeAction: tab.querySelector('.monaco-icon-label-container')!.getBoundingClientRect().right <= action.getBoundingClientRect().left, + shoulderVisible: fill.getBoundingClientRect().right + shoulderWidth <= viewport.getBoundingClientRect().right, + clipped: tab.classList.contains('connected-tab-right-edge'), + }, { + labelBeforeAction: true, + shoulderVisible: true, + clipped: false, + }); + }); + + test('reveals the left shoulder of non-first tabs and keeps the first tab flush', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160.25, tabSizingFixedMaxWidth: 160.25, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + for (let i = 2; i < 4; i++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: false }); + } + const reveal = async (index: number, width: number) => { + model.openEditor(model.getEditorByIndex(index)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 300) }, { forceRevealActiveTab: true }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + }; + const results = []; + for (const { width, from } of [{ width: 240, from: 3 }, { width: 167, from: 0 }, { width: 120, from: 0 }]) { + await reveal(from, width); + await reveal(1, width); + const tab = container.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const viewport = container.querySelector('.monaco-scrollable-element')!.getBoundingClientRect(); + const shoulder = mainWindow.getComputedStyle(fill, '::before'); + const shoulderWidth = Number.parseFloat(shoulder.width); + results.push({ + width, + leftShoulderVisible: shoulder.content !== 'none' && fill.getBoundingClientRect().left - shoulderWidth >= viewport.left, + rightShoulderVisible: fill.getBoundingClientRect().right + shoulderWidth <= viewport.right, + }); + } + await reveal(0, 240); + const firstFill = container.querySelector('.tab.active > .tab-fill')!; + assert.deepStrictEqual({ + results, + firstTabFlush: firstFill.getBoundingClientRect().left === container.querySelector('.monaco-scrollable-element')!.getBoundingClientRect().left, + firstShoulder: mainWindow.getComputedStyle(firstFill, '::before').content, + }, { + results: [ + { width: 240, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 167, leftShoulderVisible: true, rightShoulderVisible: true }, + { width: 120, leftShoulderVisible: true, rightShoulderVisible: false }, + ], + firstTabFlush: true, + firstShoulder: 'none', + }); + }); + + test('keeps replacement connected tabs visible after closing rightmost scrolled tabs', async () => { + const group = connectedGroup(); + const root = group.closest('.monaco-workbench')!; + root.classList.add('hc-black'); + root.style.setProperty('--vscode-focusBorder', '#ffaa00'); + root.style.setProperty('--modern-ui-connected-tab-surface', '#333333'); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160, tabSizingFixedMaxWidth: 160, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + for (let i = 2; i < 5; i++) { + const editor = disposables.add(new TestFileEditorInput(URI.file(`/path/file${i}.txt`), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: true }); + } + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 200); + const tabs = container.querySelector('.tabs-container')!; + const results = []; + for (let count = 5; count > 2; count--) { + tabs.classList.add('scroll'); + tabs.scrollLeft = tabs.scrollWidth - tabs.clientWidth; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + tabs.classList.remove('scroll'); + const oldScrollLeft = tabs.scrollLeft; + const closedEditor = model.getEditorByIndex(count - 1)!; + model.closeEditor(closedEditor); + control.closeEditor(closedEditor); + const clampedBeforeLayout = tabs.scrollLeft < oldScrollLeft; + await layoutConnectedGroup(group, 200); + const activeTab = tabs.querySelector('.tab.active')!; + const fill = activeTab.querySelector('.tab-fill')!; + const fillStyle = mainWindow.getComputedStyle(fill); + const edge = activeTab.querySelector('.tab-connected-edge')!; + results.push({ + clampedBeforeLayout, + activeIndex: model.indexOf(model.activeEditor!), + scrollLeft: tabs.scrollLeft, + fillLeft: fill.getBoundingClientRect().left - tabs.getBoundingClientRect().left + tabs.scrollLeft, + hidden: activeTab.classList.contains('connected-tab-hidden'), + fillDisplay: fillStyle.display, + outlineDisplay: mainWindow.getComputedStyle(edge).display, + outlineColor: fillStyle.borderTopColor, + }); + } + assert.deepStrictEqual(results, [4, 3, 2].map(count => ({ + clampedBeforeLayout: true, + activeIndex: count - 1, + scrollLeft: count * 160 - 200, + fillLeft: (count - 1) * 160, + hidden: false, + fillDisplay: 'block', + outlineDisplay: 'block', + outlineColor: 'rgb(255, 170, 0)', + }))); + }); + + test('connected sticky offsets respect content minimums instead of the classic fixed width', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 20, tabSizingFixedMaxWidth: 20, pinnedTabSizing: 'shrink', editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const longExtension = disposables.add(new class extends TestFileEditorInput { + override getName(): string { return 'example.dockerignore'; } + }(URI.file('/path/example.dockerignore'), 'testEditorInput')); + model.openEditor(longExtension, { index: 0, pinned: true, sticky: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + for (const editor of model.getEditors(EditorsOrder.SEQUENTIAL)) { + model.stick(editor); + control.stickEditor(editor); + } + await layoutConnectedGroup(group, 400); + const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')); + assert.deepStrictEqual({ + contentMinimum: tabs[0].offsetWidth > 80, + offsets: tabs.map(tab => tab.style.left), + }, { contentMinimum: true, offsets: ['0px', `${tabs[0].offsetWidth}px`, `${tabs[0].offsetWidth + tabs[1].offsetWidth}px`] }); + }); + + test('only the bottom wrapped row joins the document and upper row resets when unwrapped', async () => { + const group = connectedGroup(); + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + await layoutConnectedGroup(group, 150); + const tabs = Array.from(container.querySelectorAll('.tab')); + const wrapped = tabs.map(tab => tab.classList.contains('connected-tab-upper-row')); + const fill = tabs[0].querySelector('.tab-fill')!; + const upper = { inset: mainWindow.getComputedStyle(fill).top, shoulder: mainWindow.getComputedStyle(fill, '::after').content }; + await layoutConnectedGroup(group, 400); + const unwrapped = tabs.map(tab => tab.classList.contains('connected-tab-upper-row')); + assert.deepStrictEqual({ wrapped, upper, unwrapped }, { wrapped: [true, false], upper: { inset: '-2px', shoulder: 'none' }, unwrapped: [false, false] }); + }); + + test('selected wrapped tabs and focused actions use the document surface on every row', async () => { + const group = connectedGroup(); + const root = group.closest('.monaco-workbench')!; + group.style.setProperty('--modern-ui-connected-tab-surface', '#123456'); + group.style.setProperty('--vscode-editorGroupHeader-tabsBackground', '#654321'); + group.style.setProperty('--modern-ui-editor-tab-active-background', '#654321'); + group.style.setProperty('--vscode-modernEditorTab-activeActionBackground', '#654321'); + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const measurements = []; + const expected = []; + for (const activeIndex of [0, 1]) { + model.openEditor(model.getEditorByIndex(activeIndex)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, 150); + const tab = container.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const actions = tab.querySelector('.tab-actions')!; + const action = actions.querySelector('.action-label')!; + action.tabIndex = 0; + for (const theme of ['vs', 'vs-dark', 'hc-black', 'hc-light']) { + root.classList.add(theme); + for (const activeGroup of [true, false]) { + group.classList.toggle('active', activeGroup); + action.focus(); + measurements.push({ + activeIndex, theme, activeGroup, + actionFocused: mainWindow.document.activeElement === action, + upperRow: tab.classList.contains('connected-tab-upper-row'), + fill: mainWindow.getComputedStyle(fill).backgroundColor, + actions: mainWindow.getComputedStyle(actions).backgroundColor, + }); + expected.push({ activeIndex, theme, activeGroup, actionFocused: true, upperRow: activeIndex === 0, fill: 'rgb(18, 52, 86)', actions: 'rgb(18, 52, 86)' }); + } + root.classList.remove(theme); + } + action.blur(); + } + assert.deepStrictEqual(measurements, expected); + }); + + test('wrapped fills have equal visible heights and the bottom tab reaches the document', async () => { + const group = connectedGroup(); + group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const measurements = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabHeight, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + container.classList.toggle('compact-height', tabHeight === 'compact'); + await layoutConnectedGroup(group, 150); + const strip = container.querySelector('.tabs-and-actions-container')!; + const tab = strip.querySelector('.tab.active')!; + const fill = tab.querySelector('.tab-fill')!; + const fillStyle = mainWindow.getComputedStyle(fill); + const clippingBottom = Math.min(...Array.from(strip.querySelectorAll('.tabs-container, .monaco-scrollable-element'), element => element.getBoundingClientRect().bottom)); + const fills = Array.from(strip.querySelectorAll('.tab-fill'), element => element.getBoundingClientRect()); + measurements.push({ + tabHeight, + stripHeight: strip.getBoundingClientRect().height, + wrapping: strip.classList.contains('wrapping'), + upperRow: tab.classList.contains('connected-tab-upper-row'), + gap: strip.getBoundingClientRect().bottom - (fill.getBoundingClientRect().bottom - parseFloat(fillStyle.borderBottomWidth)), + clippingGap: strip.getBoundingClientRect().bottom - clippingBottom, + bottomRadius: fillStyle.borderBottomRightRadius, + shoulder: mainWindow.getComputedStyle(fill, '::after').content, + visibleHeights: fills.map(rect => Math.min(rect.bottom, clippingBottom) - rect.top), + rowGap: fills[1].top - fills[0].bottom, + }); + } + assert.deepStrictEqual(measurements, [ + { tabHeight: 'default', stripHeight: 60, wrapping: true, upperRow: false, gap: 0, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [28, 28], rowGap: 2 }, + { tabHeight: 'compact', stripHeight: 52, wrapping: true, upperRow: false, gap: 0, clippingGap: 0, bottomRadius: '0px', shoulder: '""', visibleHeights: [24, 24], rowGap: 2 }, + ]); + }); + + test('three wrapped rows retain equal tab heights without a fixed strip height', async () => { + const group = connectedGroup(); + group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + const editor = disposables.add(new TestFileEditorInput(URI.file('/path/third.ts'), 'testEditorInput')); + model.openEditor(editor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const measurements = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, wrapTabs: true, tabHeight, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + container.classList.toggle('compact-height', tabHeight === 'compact'); + await layoutConnectedGroup(group, 150); + const strip = container.querySelector('.tabs-and-actions-container')!.getBoundingClientRect(); + const fills = Array.from(container.querySelectorAll('.tab-fill'), fill => fill.getBoundingClientRect()); + measurements.push({ + tabHeight, + stripHeight: strip.height, + visibleHeights: fills.map(fill => Math.min(fill.bottom, strip.bottom) - fill.top), + rowGaps: fills.slice(1).map((fill, index) => fill.top - fills[index].bottom), + }); + } + assert.deepStrictEqual(measurements, [ + { tabHeight: 'default', stripHeight: 90, visibleHeights: [28, 28, 28], rowGaps: [2, 2] }, + { tabHeight: 'compact', stripHeight: 78, visibleHeights: [24, 24, 24], rowGaps: [2, 2] }, + ]); + }); + + test('connected tabs reserve separator height without changing classic or shared modern tabs', async () => { + const readHeight = async () => { + control.layout({ container: Dimension.None, available: Dimension.None }); + await new Promise(resolve => { + disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve())); + }); + return control.getHeight(); + }; + const heights = []; + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, tabHeight }; + control.updateOptions(oldOptions, partOptions); + container.classList.remove('modern-ui', 'modern-ui-tabs', 'modern-ui-connected-editor-tabs'); + const classic = await readHeight(); + container.classList.add('modern-ui-tabs'); + const sharedModern = await readHeight(); + container.classList.add('modern-ui'); + const pill = await readHeight(); + container.classList.add('modern-ui-connected-editor-tabs'); + heights.push({ tabHeight, classic, sharedModern, pill, connected: await readHeight() }); + } + assert.deepStrictEqual(heights, [ + { tabHeight: 'default', classic: 35, sharedModern: 32, pill: 32, connected: 33 }, + { tabHeight: 'compact', classic: 22, sharedModern: 28, pill: 28, connected: 29 }, + ]); + }); + + test('connected tabs fill row edges without inter-tab gutters', () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size40: 4px; --vscode-strokeThickness: 1px;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + + const [activeTab, inactiveTab] = container.querySelectorAll('.tabs-container > .tab'); + const activeFillStyle = mainWindow.getComputedStyle(activeTab.querySelector('.tab-fill')!); + const inactiveFillStyle = mainWindow.getComputedStyle(inactiveTab.querySelector('.tab-fill')!); + const rowStyle = mainWindow.getComputedStyle(container.querySelector('.tabs-and-actions-container')!); + + assert.deepStrictEqual({ + active: { top: activeFillStyle.top, left: activeFillStyle.left, right: activeFillStyle.right, bottom: activeFillStyle.bottom }, + inactive: { top: inactiveFillStyle.top, left: inactiveFillStyle.left, right: inactiveFillStyle.right, bottom: inactiveFillStyle.bottom }, + rowPaddingLeft: rowStyle.paddingLeft, + }, { + active: { top: '-4px', left: '0px', right: '0px', bottom: '-6px' }, + inactive: { top: '-4px', left: '0px', right: '0px', bottom: '-5px' }, + rowPaddingLeft: '0px', + }); + }); + + test('keeps the connected outline inside the visible scroll area', async () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fixed', tabSizingFixedMinWidth: 160, tabSizingFixedMaxWidth: 160, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const layout = async (width: number) => { + group.style.width = `${width}px`; + control.layout({ container: new Dimension(width, 33), available: new Dimension(width, 200) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + }; + const tabs = container.querySelector('.tabs-container')!; + const scroll = (left: number) => { + tabs.classList.add('scroll'); + tabs.scrollLeft = left; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + }; + const [firstTab, secondTab] = tabs.querySelectorAll('.tab'); + const firstFill = firstTab.querySelector('.tab-fill')!; + const secondFill = secondTab.querySelector('.tab-fill')!; + const firstEdge = firstTab.querySelector('.tab-connected-edge')!; + const secondEdge = secondTab.querySelector('.tab-connected-edge')!; + const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; + await layout(240); + scroll(40); + const clippedLeft = { + edge: firstTab.classList.contains('connected-tab-left-edge'), + clipped: firstTab.classList.contains('connected-tab-left-clipped'), + fillOffset: firstFill.style.left, + edgeOffset: [overflowEdge.style.left, overflowEdge.style.right], + inset: overflowEdge.getBoundingClientRect().left - tabs.getBoundingClientRect().left, + stationaryParent: overflowEdge.parentElement === tabs.parentElement, + edgeOverlay: [ + mainWindow.getComputedStyle(firstFill, '::before').content, + mainWindow.getComputedStyle(overflowEdge).display, + mainWindow.getComputedStyle(overflowEdge).zIndex, + mainWindow.getComputedStyle(overflowEdge, '::before').width, + mainWindow.getComputedStyle(overflowEdge, '::before').borderTopLeftRadius, + mainWindow.getComputedStyle(overflowEdge, '::before').boxSizing, + mainWindow.getComputedStyle(overflowEdge, '::before').borderLeftWidth, + mainWindow.getComputedStyle(overflowEdge, '::before').borderTopWidth, + mainWindow.getComputedStyle(overflowEdge, '::before').backgroundColor, + ], + }; + model.setSelection(model.activeEditor!, [model.getEditorByIndex(1)!]); + control.updateEditorSelections(); + await layout(240); + scroll(40); + const multiSelected = { + clipping: overflowEdge.style.left, + edge: mainWindow.getComputedStyle(overflowEdge).display, + radius: mainWindow.getComputedStyle(firstFill).borderRadius, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + model.setSelection(model.activeEditor!, []); + control.updateEditorSelections(); + await layout(240); + const singleSelected = { + clipping: overflowEdge.style.left, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(400); + scroll(0); + const terminalOutline = { + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + }; + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true, active: false }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(400); + const normalOutline = { + left: mainWindow.getComputedStyle(secondFill).borderLeftWidth, + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + edge: mainWindow.getComputedStyle(secondEdge).display, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + leftMaskHeight: mainWindow.getComputedStyle(secondEdge, '::before').height, + leftMaskTop: mainWindow.getComputedStyle(secondEdge, '::before').borderTopWidth, + rightMaskHeight: mainWindow.getComputedStyle(secondEdge, '::after').height, + rightMaskTop: mainWindow.getComputedStyle(secondEdge, '::after').borderTopWidth, + }; + await layout(324); + scroll(0); + const rightShoulderAtViewport = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + right: mainWindow.getComputedStyle(secondFill).borderRightWidth, + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + }; + scroll(8); + const rightShoulderRevealed = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + rightShoulder: mainWindow.getComputedStyle(secondFill, '::after').content, + rightMask: mainWindow.getComputedStyle(secondEdge, '::after').content, + }; + scroll(156); + const leftShoulderAtViewport = { + edge: secondTab.classList.contains('connected-tab-left-edge'), + clipped: secondTab.classList.contains('connected-tab-left-clipped'), + left: mainWindow.getComputedStyle(secondFill).borderLeftWidth, + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + leftMask: mainWindow.getComputedStyle(secondEdge, '::before').content, + overflowEdge: mainWindow.getComputedStyle(overflowEdge).display, + }; + scroll(152); + const leftShoulderRevealed = { + edge: secondTab.classList.contains('connected-tab-left-edge'), + clipped: secondTab.classList.contains('connected-tab-left-clipped'), + leftShoulder: mainWindow.getComputedStyle(secondFill, '::before').content, + leftMask: mainWindow.getComputedStyle(secondEdge, '::before').content, + }; + await layout(240); + scroll(0); + const overflowRightOffsets = []; + for (const position of [0, 1, 2, 3]) { + scroll(position); + overflowRightOffsets.push({ + right: overflowEdge.style.right, + inset: tabs.getBoundingClientRect().right - overflowEdge.getBoundingClientRect().right, + }); + } + const clippedRight = { + edge: secondTab.classList.contains('connected-tab-right-edge'), + clipped: secondTab.classList.contains('connected-tab-right-clipped'), + fillOffset: secondFill.style.right, + edgeOffset: [overflowEdge.style.left, overflowEdge.style.right], + overflowRightOffsets, + edgeOverlay: [ + mainWindow.getComputedStyle(secondFill, '::after').content, + mainWindow.getComputedStyle(overflowEdge).display, + mainWindow.getComputedStyle(overflowEdge).zIndex, + mainWindow.getComputedStyle(overflowEdge, '::after').width, + mainWindow.getComputedStyle(overflowEdge, '::after').borderTopRightRadius, + mainWindow.getComputedStyle(overflowEdge, '::after').boxSizing, + mainWindow.getComputedStyle(overflowEdge, '::after').borderRightWidth, + mainWindow.getComputedStyle(overflowEdge, '::after').borderTopWidth, + mainWindow.getComputedStyle(overflowEdge, '::after').backgroundColor, + ], + previousTabOverflow: firstEdge.style.left, + }; + model.openEditor(model.getEditorByIndex(0)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layout(100); + scroll(firstTab.offsetWidth - 5); + const hiddenAtFillEdge = [mainWindow.getComputedStyle(firstFill).display, mainWindow.getComputedStyle(overflowEdge).display]; + root.classList.add('hc-black'); + await layout(240); + scroll(40); + const highContrast = { + clipping: overflowEdge.style.left, + edge: mainWindow.getComputedStyle(overflowEdge).display, + connectedClass: firstTab.classList.contains('connected-tab-left-clipped'), + }; + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const highContrastRight = []; + root.style.setProperty('--vscode-focusBorder', '#f38518'); + for (const theme of ['hc-black', 'hc-light']) { + root.classList.remove('hc-black', 'hc-light'); + root.classList.add(theme); + root.style.setProperty('--vscode-editor-background', theme === 'hc-black' ? '#000000' : '#ffffff'); + for (const width of [240, 324]) { + await layout(width); + scroll(0); + const outline = overflowEdge.querySelector('.tab-connected-overflow-right')!; + const cap = mainWindow.getComputedStyle(outline, '::before'); + const shoulder = mainWindow.getComputedStyle(outline, '::after'); + const capRight = outline.getBoundingClientRect().right - parseFloat(cap.right); + const shoulderLeft = outline.getBoundingClientRect().right - parseFloat(shoulder.right) - parseFloat(shoulder.width); + highContrastRight.push({ + theme, width, + mask: mainWindow.getComputedStyle(overflowEdge, '::after').backgroundColor, + capMeetsShoulder: parseFloat(cap.bottom) === parseFloat(shoulder.height), + strokesAlign: capRight - parseFloat(cap.borderRightWidth) === shoulderLeft, + baselineAligns: outline.getBoundingClientRect().bottom === tabs.getBoundingClientRect().bottom, + }); + } + } + root.classList.remove('modern-ui-connected-editor-tabs'); + await layout(100); + assert.deepStrictEqual({ + clippedLeft, multiSelected, singleSelected, terminalOutline, normalOutline, rightShoulderAtViewport, rightShoulderRevealed, leftShoulderAtViewport, leftShoulderRevealed, clippedRight, hiddenAtFillEdge, highContrast, highContrastRight, + reset: overflowEdge.style.left, + }, { + clippedLeft: { edge: true, clipped: true, fillOffset: '', edgeOffset: ['0px', '0px'], inset: 0, stationaryParent: true, edgeOverlay: ['none', 'block', '8', '5px', '0px', 'border-box', '1px', '1px', 'rgb(51, 51, 51)'] }, + multiSelected: { clipping: '0px', edge: 'block', radius: '5px 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' }, + rightShoulderAtViewport: { edge: true, clipped: false, right: '1px', rightShoulder: '""', rightMask: '""', overflowEdge: 'block' }, + rightShoulderRevealed: { edge: false, clipped: false, rightShoulder: '""', rightMask: '""' }, + leftShoulderAtViewport: { edge: true, clipped: false, left: '1px', leftShoulder: 'none', leftMask: 'none', overflowEdge: 'none' }, + leftShoulderRevealed: { edge: false, clipped: false, leftShoulder: '""', leftMask: '""' }, + clippedRight: { + edge: true, + clipped: true, + fillOffset: '', + edgeOffset: ['0px', '0px'], + overflowRightOffsets: [ + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + { right: '0px', inset: 0 }, + ], + edgeOverlay: ['""', 'block', '8', '10px', '0px', 'border-box', '0px', '0px', 'rgb(255, 255, 255)'], + previousTabOverflow: '', + }, + hiddenAtFillEdge: ['none', 'none'], + highContrast: { clipping: '0px', edge: 'block', connectedClass: true }, + highContrastRight: [ + { theme: 'hc-black', width: 240, mask: 'rgb(0, 0, 0)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-black', width: 324, mask: 'rgb(0, 0, 0)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-light', width: 240, mask: 'rgb(255, 255, 255)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + { theme: 'hc-light', width: 324, mask: 'rgb(255, 255, 255)', capMeetsShoulder: true, strokesAlign: true, baselineAligns: true }, + ], + reset: '', + }); + }); + + test('refreshes connected clipping geometry after dirty width changes', async () => { + const root = $('.monaco-workbench.modern-ui.modern-ui-tabs.modern-ui-connected-editor-tabs'); + root.style.cssText = '--vscode-spacing-size20: 2px; --vscode-spacing-size40: 4px; --vscode-spacing-size60: 6px; --vscode-spacing-size80: 8px; --vscode-spacing-size280: 28px; --vscode-strokeThickness: 1px; --vscode-cornerRadius-small: 4px; --vscode-editor-background: #ffffff; --modern-ui-connected-tab-surface: #333333;'; + mainWindow.document.body.appendChild(root); + disposables.add(toDisposable(() => root.remove())); + const editor = $('.part.editor'); + const content = $('.content'); + const group = $('.editor-group-container.active'); + root.appendChild(editor); + editor.appendChild(content); + content.appendChild(group); + group.appendChild(container); + const oldOptions = partOptions; + partOptions = { ...partOptions, tabSizing: 'fit', tabActionReserveSpace: false, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + const thirdEditor = disposables.add(new TestFileEditorInput(URI.file('/path/file2.txt'), 'testEditorInput')); + model.openEditor(thirdEditor, { pinned: true }); + model.openEditor(model.getEditorByIndex(1)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + group.style.width = '180px'; + control.layout({ container: new Dimension(180, 33), available: new Dimension(180, 200) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + const tabs = container.querySelector('.tabs-container')!; + const [firstTab, activeTab] = tabs.querySelectorAll('.tab'); + const activeFill = activeTab.querySelector('.tab-fill')!; + const overflowEdge = container.querySelector('.tab-connected-overflow-edge')!; + const scroll = (left: number) => { + tabs.classList.add('scroll'); + tabs.scrollLeft = left; + tabs.dispatchEvent(new UIEvent(EventType.SCROLL)); + }; + const getLogicalFillRight = () => activeFill.getBoundingClientRect().right - tabs.getBoundingClientRect().left + tabs.scrollLeft; + scroll(0); + const cleanFillRight = getLogicalFillRight(); + const firstEditor = model.getEditorByIndex(0) as TestFileEditorInput; + firstEditor.setDirty(); + control.updateEditorDirty(firstEditor); + const dirtyFillRight = getLogicalFillRight(); + const invalidatedBeforeLayout = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + const shoulderExtent = Number.parseFloat(mainWindow.getComputedStyle(activeFill, '::after').width); + const targetVisibleRight = (cleanFillRight + dirtyFillRight) / 2 + shoulderExtent; + scroll(targetVisibleRight - tabs.clientWidth); + const visibleRight = tabs.scrollLeft + tabs.clientWidth; + const rightEdge = activeTab.classList.contains('connected-tab-right-edge'); + const currentGeometryNeedsEdge = dirtyFillRight + shoulderExtent > visibleRight; + const staleGeometryWouldNeedEdge = cleanFillRight + shoulderExtent > visibleRight; + firstEditor.capabilities = EditorInputCapabilities.CannotClose; + control.updateEditorCapabilities(firstEditor); + const capabilityUpdateInvalidated = overflowEdge.style.left === '' && !activeTab.classList.contains('connected-tab-right-edge'); + + assert.deepStrictEqual({ + firstTabDirty: firstTab.classList.contains('dirty'), + widthIncreased: dirtyFillRight > cleanFillRight, + invalidatedBeforeLayout, + rightEdge, + currentGeometryNeedsEdge, + staleGeometryWouldNeedEdge, + capabilityUpdateInvalidated, + }, { + firstTabDirty: true, + widthIncreased: true, + invalidatedBeforeLayout: true, + rightEdge: true, + currentGeometryNeedsEdge: true, + staleGeometryWouldNeedEdge: false, + capabilityUpdateInvalidated: true, + }); + }); + test('Alt swaps the close action of the hovered tab only', () => { const actions = [tabActions()]; diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 81128562c4b75..b08ecb19149f7 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -64,7 +64,7 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/54642db46b8d9ada83801284ca684eeaaf42371d2eaf31d3a3debbb59c607ba4) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b4e8657854d7c63ac44292848fae4e64ec344ecb8449ffa6833d5fe8a345c249) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/83f74ecd73ce774dc3810cda0e41da17d236d5142cd154d049379069ae0845c9) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/06dc8aea06facbdf19836000c6f4b6dfcfbe82824373160d39f89c5fcdf52267) @@ -304,49 +304,49 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/732fb18e20a0195448372de10a0feaf319dee84c5c8b67ed61ba359cd0b54701) #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4fac14822d0c68ad8c6ad48277c6418ee064b246daa641e648e8760c43774cc8) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/21881ffa51a4e8b25c2213de9264579b61b1ace56bab8dccfbe22c6826b32c9f) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/25b550c4d7efc59532a118c2824c526453fbd7d62449c8f96e7a37bc3abea92a) #### sessions/chat/newWidget/newChatWidget/NewSessionAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/ea10f9ac59db66a3ec2dabb3eca1eb996606441bdb6ce6521aad407770e2b026) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a18059740311926cd18d6a19dfbea87edd4c3bbc082e28c988c2493ca6557714) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/8f5bbc02ab6ec03493f6b0ed5d4744d64bcd0f4f0394206a50d09f76b0343877) #### sessions/chat/newWidget/newChatWidget/NewSessionChatBackground/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b1b0accc9ffb55e46c1fdc489868fcc5bc334eb3e4f6f73326c8e2435d2bd462) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/bf0919c2615a13a619f44f455cdf76d4c1adeeb621324f79fb97a37a63b8d297) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/9e66591c00f7a7d609d365b76410af29a44656195ef0c6a9a9dd13350039c854) #### sessions/chat/newWidget/newChatWidget/NewSessionCompactAutoModel/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/3e9b02be0ea5ceb14f784a5d7e3b3d07ef613f0ff1ccee5de2f9f924b0435576) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a7c390efcf01c763f5cc93d6b5a4cc1a1c9468d28e8cd88653ba315ebb4e273d) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/79e5a5254684fd526f5968e87aad79f95fb1a8a20fba325816bd0ea75090065b) #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/544be3f91e33c38aee9aee2a931f438a6548d483e89b0d8035973eded3f36443) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/180cb6633f8c78d01b6fb57393d0c6b836d75f85145d51bf6803314f62aaf9ca) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/5fb4e4324d4219f7db7d7f6297002032b8648c81d2b28a5e1b9cf6aaa00eab3a) #### sessions/chat/newWidget/newChatWidget/NewSessionPhoneAttachedContext/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5fdd65e8749eeae857aef2953338bc2b389efe5f6dc1e1d56f07f27c5bcb77fc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/f72b4375545d0487aaeac0c9cd6497ab461beb62c9c4d4be91f4ac1f3f556e15) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/e10afdd8382309d3424318b87ea7ec26d5aa834226197a53bd127aef6f29bea0) #### sessions/chat/newWidget/newChatWidget/NewSessionRemoteWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/aab03c7e94c4e749bab8f227b8c8454e853bf45cae3143896d49e37febf4abb9) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1196696330713fa5d98d22701e7edb1c26ca73f964564aaed16b2c91af34f53b) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/1085f7d7fab0c2f872fcd61c3b514ce45e2433790eed8d0fb95874e93e4e27ae) #### sessions/chat/newWidget/newChatWidget/NewSessionWorkspacePicker/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5f7250cb45995c66b45ceac4f8f239ae248321006dc921110011be7679ed1841) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/980fa555083b7c0fdaed41b0d1ec2e0d8bb805578280a8932006e8b95cfa7ded) #### sessions/chat/view/chatView/AssistantResponseBackground/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/d8df2150dd9f449dd7268d9b898c4a331c65657fb88886d10ed5f59536b0faa3)