Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

@na2co3-ftw na2co3 (na2co3-ftw) Sep 19, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test.

--modern-ui-connected-tab-gutter: calc(var(--vscode-spacing-size60) + var(--vscode-strokeThickness));
--modern-ui-connected-tab-top-inset: var(--vscode-spacing-size20);
}
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -668,15 +668,18 @@ suite('MultiEditorTabsControl', () => {

const [activeTab, inactiveTab] = container.querySelectorAll<HTMLElement>('.tabs-container > .tab');
const activeFillStyle = mainWindow.getComputedStyle(activeTab.querySelector<HTMLElement>('.tab-fill')!);
const activeEdgeStyle = mainWindow.getComputedStyle(activeTab.querySelector<HTMLElement>('.tab-connected-edge')!);
const inactiveFillStyle = mainWindow.getComputedStyle(inactiveTab.querySelector<HTMLElement>('.tab-fill')!);
const rowStyle = mainWindow.getComputedStyle(container.querySelector<HTMLElement>('.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',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IEditorGroupView>() {
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<IEditorGroupsView>() {
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<IEditorPartsView>() {
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<void>(resolve => disposables.add(scheduleAtNextAnimationFrame(mainWindow, () => resolve())));

const rows = Array.from(container.querySelectorAll<HTMLElement>('.tabs-and-actions-container'));
const unstickyRow = rows[1];
const unstickyTabs = unstickyRow.querySelector<HTMLElement>('.tabs-container')!;
unstickyTabs.classList.add('scroll');
const unstickyFills = unstickyRow.querySelectorAll<HTMLElement>('.tab-fill');
const unstickyFill = unstickyFills[unstickyFills.length - 1];
const unstickyEdge = unstickyRow.querySelector<HTMLElement>('.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();
});