diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 55933caa1a4cb..7c928216ba5ca 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -144,7 +144,7 @@ --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.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.two-tab-bars > .tabs-and-actions-container:not(.wrapping):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); } @@ -162,9 +162,6 @@ --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); @@ -178,7 +175,7 @@ .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; + bottom: calc(-1 * var(--vscode-strokeThickness)); width: var(--modern-ui-connected-tab-shoulder-radius); height: var(--modern-ui-connected-tab-shoulder-radius); box-sizing: border-box; @@ -208,7 +205,7 @@ 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)); + bottom: calc(-1 * var(--modern-ui-connected-tab-gutter)); z-index: 8; pointer-events: none; } @@ -391,8 +388,7 @@ .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); + inset: 0 0; z-index: 1; } 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 97b5400cb6b5c..9a097088eebf5 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -583,7 +583,7 @@ suite('MultiEditorTabsControl', () => { stripHeight: strip.getBoundingClientRect().height, wrapping: strip.classList.contains('wrapping'), upperRow: tab.classList.contains('connected-tab-upper-row'), - gap: strip.getBoundingClientRect().bottom - (fill.getBoundingClientRect().bottom - parseFloat(fillStyle.borderBottomWidth)), + gap: strip.getBoundingClientRect().bottom - fill.getBoundingClientRect().bottom, clippingGap: strip.getBoundingClientRect().bottom - clippingBottom, bottomRadius: fillStyle.borderBottomRightRadius, shoulder: mainWindow.getComputedStyle(fill, '::after').content, @@ -668,15 +668,18 @@ suite('MultiEditorTabsControl', () => { const [activeTab, inactiveTab] = container.querySelectorAll('.tabs-container > .tab'); const activeFillStyle = mainWindow.getComputedStyle(activeTab.querySelector('.tab-fill')!); + const activeEdgeStyle = mainWindow.getComputedStyle(activeTab.querySelector('.tab-connected-edge')!); 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 }, + activeEdgeBottom: activeEdgeStyle.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' }, + active: { top: '-4px', left: '0px', right: '0px', bottom: '-5px' }, + activeEdgeBottom: '-5px', inactive: { top: '-4px', left: '0px', right: '0px', bottom: '-5px' }, rowPaddingLeft: '0px', }); diff --git a/src/vs/workbench/test/browser/parts/editor/multiRowEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiRowEditorTabsControl.test.ts new file mode 100644 index 0000000000000..77643c1f29446 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/editor/multiRowEditorTabsControl.test.ts @@ -0,0 +1,154 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { $, Dimension, scheduleAtNextAnimationFrame, ModifierKeyEmitter } 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'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; +import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; +import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; +import { MultiRowEditorControl } from '../../../../browser/parts/editor/multiRowEditorTabsControl.js'; +import { EditorsOrder, IEditorPartOptions } from '../../../../common/editor.js'; +import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { TestFileEditorInput, workbenchInstantiationService } from '../../workbenchTestServices.js'; +import '../../../../contrib/modernUI/browser/media/tabs.css'; +import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; + +suite('MultiRowEditorControl', () => { + + let disposables: DisposableStore; + + let container: HTMLElement; + let control: MultiRowEditorControl; + let partOptions: IEditorPartOptions; + let model: EditorGroupModel; + + setup(() => { + disposables = new DisposableStore(); + partOptions = { ...DEFAULT_EDITOR_PART_OPTIONS }; + + // Reset the shared modifier-key emitter after each test to avoid leaked Alt state. + disposables.add(toDisposable(() => ModifierKeyEmitter.disposeInstance())); + + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); + instantiationService.stub(INotebookDocumentService, new NotebookDocumentWorkbenchService()); + + model = disposables.add(instantiationService.createInstance(EditorGroupModel, undefined)); + for (let i = 0; i < 2; 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: i === 0 }); + } + + const groupView = new class extends mock() { + override get id() { return model.id; } + override get count() { return model.count; } + override get stickyCount() { return model.stickyCount; } + override get activeEditor() { return model.activeEditor; } + override get activeEditorPane() { return undefined; } + override get selectedEditors() { return model.selectedEditors; } + override get ariaLabel() { return 'Editor Group 1'; } + override get groupsView(): IEditorGroupsView { return groupsView; } + override getEditorByIndex(index: number) { return model.getEditorByIndex(index); } + override getIndexOfEditor(editor: EditorInput) { return model.indexOf(editor); } + override getEditors(order: EditorsOrder, options?: { excludeSticky?: boolean }) { return model.getEditors(order, options); } + override isActive(editor: EditorInput) { return model.isActive(editor); } + override isPinned(editorOrIndex: EditorInput | number) { return model.isPinned(editorOrIndex); } + override isSticky(editorOrIndex: EditorInput | number) { return model.isSticky(editorOrIndex); } + override isSelected(editorOrIndex: EditorInput | number) { return model.isSelected(editorOrIndex); } + override createEditorActions() { return { actions: { primary: [], secondary: [] }, onDidChange: Event.None }; } + override relayout() { } + override readonly onDidActiveEditorChange = Event.None; + }; + + const groupsView = new class extends mock() { + override get partOptions() { return partOptions; } + override get activeGroup(): IEditorGroupView { return groupView; } + override get groups(): IEditorGroupView[] { return [groupView]; } + override readonly onDidChangeEditorPartOptions = Event.None; + override readonly onDidVisibilityChange = Event.None; + }; + + const editorPartsView = new class extends mock() { + override get count() { return 1; } + override getGroup() { return groupView; } + }; + + container = $('.title.tabs'); + mainWindow.document.body.appendChild(container); + + control = disposables.add(instantiationService.createInstance(MultiRowEditorControl, container, editorPartsView, groupsView, groupView, model, undefined, false, false)); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + }); + + teardown(() => { + container.remove(); + disposables.dispose(); + }); + + 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; + } + + test('wrapped unsticky tabs keep row bottoms aligned and avoid vertical scrolling with sticky tabs on a separate row', async () => { + const group = connectedGroup(); + group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + model.stick(model.getEditorByIndex(0)!); + const unstickyEditor = disposables.add(new TestFileEditorInput(URI.file('/path/unsticky.ts'), 'testEditorInput')); + model.openEditor(unstickyEditor, { pinned: true, active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + const oldOptions = partOptions; + partOptions = { ...partOptions, pinnedTabsOnSeparateRow: true, wrapTabs: true, tabSizing: 'fixed', tabSizingFixedMinWidth: 120, tabSizingFixedMaxWidth: 120, editorActionsLocation: 'hidden' }; + control.updateOptions(oldOptions, partOptions); + group.style.width = '150px'; + control.layout({ container: new Dimension(150, 33), available: new Dimension(150, 300) }); + await new Promise(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve()))); + + const rows = Array.from(container.querySelectorAll('.tabs-and-actions-container')); + const unstickyRow = rows[1]; + const unstickyTabs = unstickyRow.querySelector('.tabs-container')!; + unstickyTabs.classList.add('scroll'); + const unstickyFills = unstickyRow.querySelectorAll('.tab-fill'); + const unstickyFill = unstickyFills[unstickyFills.length - 1]; + const unstickyEdge = unstickyRow.querySelector('.tab.active > .tab-connected-edge')!; + assert.deepStrictEqual({ + pinnedTabsOnSeparateRow: partOptions.pinnedTabsOnSeparateRow, + rowCount: rows.length, + unstickyWrapping: unstickyRow.classList.contains('wrapping'), + unstickyFillBottomGap: unstickyRow.getBoundingClientRect().bottom - unstickyFill.getBoundingClientRect().bottom, + unstickyEdgeBottom: mainWindow.getComputedStyle(unstickyEdge).bottom, + unstickyScrollHeightGap: unstickyTabs.clientHeight - unstickyTabs.scrollHeight, + }, { + pinnedTabsOnSeparateRow: true, + rowCount: 2, + unstickyWrapping: true, + unstickyFillBottomGap: 0, + unstickyEdgeBottom: '-2px', + unstickyScrollHeightGap: 0, + }); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +});