Merge pull request #294881 from microsoft/mrleemurray/psychological-pink-finch

Add compact layout support for Activity Bar
This commit is contained in:
Lee Murray
2026-02-12 13:11:47 +00:00
committed by GitHub
9 changed files with 383 additions and 22 deletions

View File

@@ -903,6 +903,9 @@
"--vscode-window-inactiveBorder"
],
"others": [
"--activity-bar-action-height",
"--activity-bar-icon-size",
"--activity-bar-width",
"--editor-font-size",
"--background-dark",
"--background-light",

View File

@@ -41,7 +41,14 @@ import { SwitchCompositeViewAction } from '../compositeBarActions.js';
export class ActivitybarPart extends Part {
static readonly ACTION_HEIGHT = 32;
static readonly ACTION_HEIGHT = 48;
static readonly COMPACT_ACTION_HEIGHT = 32;
static readonly ACTIVITYBAR_WIDTH = 48;
static readonly COMPACT_ACTIVITYBAR_WIDTH = 36;
static readonly ICON_SIZE = 24;
static readonly COMPACT_ICON_SIZE = 16;
static readonly pinnedViewContainersKey = 'workbench.activity.pinnedViewlets2';
static readonly placeholderViewContainersKey = 'workbench.activity.placeholderViewlets';
@@ -49,8 +56,8 @@ export class ActivitybarPart extends Part {
//#region IView
readonly minimumWidth: number = 36;
readonly maximumWidth: number = 36;
get minimumWidth(): number { return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; }
get maximumWidth(): number { return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; }
readonly minimumHeight: number = 0;
readonly maximumHeight: number = Number.POSITIVE_INFINITY;
@@ -58,6 +65,7 @@ export class ActivitybarPart extends Part {
private readonly compositeBar = this._register(new MutableDisposable<PaneCompositeBar>());
private content: HTMLElement | undefined;
private _isCompact: boolean;
constructor(
private readonly paneCompositePart: IPaneCompositePart,
@@ -65,11 +73,50 @@ export class ActivitybarPart extends Part {
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
@IThemeService themeService: IThemeService,
@IStorageService storageService: IStorageService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) {
super(Parts.ACTIVITYBAR_PART, { hasTitle: false }, themeService, storageService, layoutService);
this._isCompact = this.configurationService.getValue<boolean>(LayoutSettings.ACTIVITY_BAR_COMPACT) ?? false;
this._register(this.configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT)) {
this._isCompact = this.configurationService.getValue<boolean>(LayoutSettings.ACTIVITY_BAR_COMPACT) ?? false;
this.updateCompactStyle();
this.recreateCompositeBar();
this._onDidChange.fire(undefined); // Signal grid that size constraints changed
}
}));
}
private updateCompactStyle(): void {
if (this.element) {
this.element.classList.toggle('compact', this._isCompact);
this.element.style.setProperty('--activity-bar-width', `${this.minimumWidth}px`);
this.element.style.setProperty('--activity-bar-action-height', `${this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT}px`);
this.element.style.setProperty('--activity-bar-icon-size', `${this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE}px`);
}
}
private recreateCompositeBar(): void {
if (!this.content || !this.compositeBar.value) {
return;
}
this.compositeBar.clear();
clearNode(this.content);
this.compositeBar.value = this.createCompositeBar();
this.compositeBar.value.create(this.content);
if (this.dimension) {
this.layout(this.dimension.width, this.dimension.height);
}
}
private createCompositeBar(): PaneCompositeBar {
const actionHeight = this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT;
const iconSize = this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE;
return this.instantiationService.createInstance(ActivityBarCompositeBar, {
partContainerClass: 'activitybar',
pinnedViewContainersKey: ActivitybarPart.pinnedViewContainersKey,
@@ -77,7 +124,7 @@ export class ActivitybarPart extends Part {
viewContainersWorkspaceStateKey: ActivitybarPart.viewContainersWorkspaceStateKey,
orientation: ActionsOrientation.VERTICAL,
icon: true,
iconSize: 16,
iconSize,
activityHoverOptions: {
position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT,
},
@@ -95,7 +142,7 @@ export class ActivitybarPart extends Part {
dragAndDropBorder: theme.getColor(ACTIVITY_BAR_DRAG_AND_DROP_BORDER),
activeBackgroundColor: undefined, inactiveBackgroundColor: undefined, activeBorderBottomColor: undefined,
}),
overflowActionSize: ActivitybarPart.ACTION_HEIGHT,
overflowActionSize: actionHeight,
}, Parts.ACTIVITYBAR_PART, this.paneCompositePart, true);
}
@@ -103,6 +150,8 @@ export class ActivitybarPart extends Part {
this.element = parent;
this.content = append(this.element, $('.content'));
this.updateCompactStyle();
if (this.layoutService.isVisible(Parts.ACTIVITYBAR_PART)) {
this.show();
}
@@ -358,7 +407,7 @@ export class ActivityBarCompositeBar extends PaneCompositeBar {
}
if (this.globalCompositeBar) {
if (this.options.orientation === ActionsOrientation.VERTICAL) {
height -= (this.globalCompositeBar.size() * ActivitybarPart.ACTION_HEIGHT);
height -= (this.globalCompositeBar.size() * this.options.overflowActionSize);
} else {
width -= this.globalCompositeBar.element.clientWidth;
}

View File

@@ -12,7 +12,7 @@
.monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after {
position: absolute;
content: '';
width: 36px;
width: var(--activity-bar-width, 48px);
height: 2px;
display: none;
background-color: transparent;
@@ -46,8 +46,8 @@
z-index: 1;
display: flex;
overflow: hidden;
width: 36px;
height: 32px;
width: var(--activity-bar-width, 48px);
height: var(--activity-bar-action-height, 48px);
margin-right: 0;
box-sizing: border-box;
@@ -55,12 +55,12 @@
.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) {
font-size: 15px;
line-height: 32px;
padding: 0 0 0 36px;
line-height: var(--activity-bar-action-height, 48px);
padding: 0 0 0 var(--activity-bar-width, 48px);
}
.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon {
font-size: 16px;
font-size: var(--activity-bar-icon-size, 24px);
align-items: center;
justify-content: center;
}
@@ -157,31 +157,50 @@
.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content {
position: absolute;
top: 17px;
right: 6px;
top: 24px;
right: 8px;
font-size: 9px;
font-weight: 600;
min-width: 8px;
height: 16px;
line-height: 16px;
padding: 0 4px;
border-radius: 20px;
text-align: center;
}
.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content {
top: 17px;
right: 6px;
min-width: 9px;
height: 13px;
line-height: 13px;
padding: 0 2px;
border-radius: 13px;
text-align: center;
border: none !important;
}
.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay {
position: absolute;
font-weight: 600;
font-size: 9px;
line-height: 10px;
top: 24px;
right: 6px;
padding: 2px 3px;
border-radius: 7px;
background-color: var(--vscode-profileBadge-background);
color: var(--vscode-profileBadge-foreground);
border: 2px solid var(--vscode-activityBar-background);
}
.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay {
font-size: 8px;
line-height: 8px;
top: 14px;
right: 2px;
padding: 2px 2px;
border-radius: 6px;
background-color: var(--vscode-profileBadge-background);
color: var(--vscode-profileBadge-foreground);
border: 2px solid var(--vscode-activityBar-background);
}
.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:active .profile-text-overlay,

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
.monaco-workbench .part.activitybar {
width: 36px;
width: var(--activity-bar-width, 48px);
height: 100%;
}

View File

@@ -403,9 +403,9 @@ export class PaneCompositeBar extends Disposable {
classNames = [iconId, 'uri-icon'];
createCSSRule(iconClass, `
mask: ${cssUrl} no-repeat 50% 50%;
mask-size: ${this.options.iconSize}px;
mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px);
-webkit-mask: ${cssUrl} no-repeat 50% 50%;
-webkit-mask-size: ${this.options.iconSize}px;
-webkit-mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px);
mask-origin: padding;
-webkit-mask-origin: padding;
`);

View File

@@ -627,6 +627,11 @@ const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Con
'default': false,
'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarAutoHide' }, "Controls whether the Activity Bar is automatically hidden when there is only one view container to show. This applies to the Primary and Secondary Side Bars when {0} is set to {1} or {2}.", '`#workbench.activityBar.location#`', '`top`', '`bottom`'),
},
[LayoutSettings.ACTIVITY_BAR_COMPACT]: {
'type': 'boolean',
'default': false,
'markdownDescription': localize({ comment: ['This is the description for a setting'], key: 'activityBarCompact' }, "Controls whether the Activity Bar uses a compact layout with smaller icons and reduced width. This setting only applies when {0} is set to {1}.", '`#workbench.activityBar.location#`', '`default`'),
},
'workbench.activityBar.iconClickBehavior': {
'type': 'string',
'enum': ['toggle', 'focus'],

View File

@@ -43,6 +43,7 @@ export const enum ZenModeSettings {
export const enum LayoutSettings {
ACTIVITY_BAR_LOCATION = 'workbench.activityBar.location',
ACTIVITY_BAR_AUTO_HIDE = 'workbench.activityBar.autoHide',
ACTIVITY_BAR_COMPACT = 'workbench.activityBar.compact',
EDITOR_TABS_MODE = 'workbench.editor.showTabs',
EDITOR_ACTIONS_LOCATION = 'workbench.editor.editorActionsLocation',
COMMAND_CENTER = 'window.commandCenter',

View File

@@ -214,7 +214,7 @@ suite('KeybindingsEditorModel', () => {
});
test('filter by command id', async () => {
const id = 'workbench.action.increaseViewSize';
const id = 'workbench.action.view.size.' + uuid.generateUuid();
registerCommandWithTitle(id, 'some title');
prepareKeybindingService();

View File

@@ -0,0 +1,284 @@
/*---------------------------------------------------------------------------------------------
* 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 { DisposableStore } from '../../../../../base/common/lifecycle.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js';
import { TestStorageService } from '../../../common/workbenchTestServices.js';
import { TestLayoutService } from '../../workbenchTestServices.js';
import { ActivitybarPart } from '../../../../browser/parts/activitybar/activitybarPart.js';
import { IViewSize } from '../../../../../base/browser/ui/grid/grid.js';
import { LayoutSettings, Parts } from '../../../../services/layout/browser/layoutService.js';
import { mainWindow } from '../../../../../base/browser/window.js';
import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js';
import { IPaneCompositePart } from '../../../../browser/parts/paneCompositePart.js';
import { Event, Emitter } from '../../../../../base/common/event.js';
import { IPaneComposite } from '../../../../common/panecomposite.js';
import { PaneCompositeDescriptor } from '../../../../browser/panecomposite.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
class StubPaneCompositePart implements IPaneCompositePart {
declare readonly _serviceBrand: undefined;
readonly partId = Parts.SIDEBAR_PART;
element: HTMLElement = undefined!;
minimumWidth = 0;
maximumWidth = 0;
minimumHeight = 0;
maximumHeight = 0;
onDidChange = Event.None;
onDidPaneCompositeOpen = new Emitter<IPaneComposite>().event;
onDidPaneCompositeClose = new Emitter<IPaneComposite>().event;
openPaneComposite(): Promise<IPaneComposite | undefined> { return Promise.resolve(undefined); }
getPaneComposites(): PaneCompositeDescriptor[] { return []; }
getPaneComposite(): PaneCompositeDescriptor | undefined { return undefined; }
getActivePaneComposite(): IPaneComposite | undefined { return undefined; }
getProgressIndicator() { return undefined; }
hideActivePaneComposite(): void { }
getLastActivePaneCompositeId(): string { return ''; }
getPinnedPaneCompositeIds(): string[] { return []; }
getVisiblePaneCompositeIds(): string[] { return []; }
getPaneCompositeIds(): string[] { return []; }
layout(): void { }
dispose(): void { }
}
suite('ActivitybarPart', () => {
const disposables = new DisposableStore();
let fixture: HTMLElement;
const fixtureId = 'activitybar-part-fixture';
setup(() => {
fixture = document.createElement('div');
fixture.id = fixtureId;
mainWindow.document.body.appendChild(fixture);
});
teardown(() => {
fixture.remove();
disposables.clear();
});
function createActivitybarPart(compact: boolean): { part: ActivitybarPart; configService: TestConfigurationService } {
const configService = new TestConfigurationService({
[LayoutSettings.ACTIVITY_BAR_COMPACT]: compact,
});
const storageService = disposables.add(new TestStorageService());
const themeService = new TestThemeService();
const layoutService = new TestLayoutService();
// Override isVisible to return false so that create() does not call show()
// and attempt to instantiate the composite bar (which requires a full DI setup).
layoutService.isVisible = (_part: Parts) => false;
// Stub instantiation service—createCompositeBar is only called in show(),
// which we skip in unit tests focused on dimensions / style behaviour.
const stubInstantiationService = { createInstance: () => { throw new Error('not expected'); } } as unknown as IInstantiationService;
const part = disposables.add(new ActivitybarPart(
new StubPaneCompositePart(),
stubInstantiationService,
layoutService,
themeService,
storageService,
configService,
));
return { part, configService };
}
function fireConfigChange(configService: TestConfigurationService, key: string): void {
configService.onDidChangeConfigurationEmitter.fire({
affectsConfiguration: (k: string) => k === key,
} satisfies Partial<IConfigurationChangeEvent> as unknown as IConfigurationChangeEvent);
}
// --- Static constants ---------------------------------------------------
test('default constants match original (pre-compact) dimensions', () => {
assert.deepStrictEqual(
{
width: ActivitybarPart.ACTIVITYBAR_WIDTH,
actionHeight: ActivitybarPart.ACTION_HEIGHT,
iconSize: ActivitybarPart.ICON_SIZE,
},
{
width: 48,
actionHeight: 48,
iconSize: 24,
}
);
});
test('compact constants match reduced dimensions', () => {
assert.deepStrictEqual(
{
width: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH,
actionHeight: ActivitybarPart.COMPACT_ACTION_HEIGHT,
iconSize: ActivitybarPart.COMPACT_ICON_SIZE,
},
{
width: 36,
actionHeight: 32,
iconSize: 16,
}
);
});
// --- Dimension getters --------------------------------------------------
test('default mode returns default width constraints', () => {
const { part } = createActivitybarPart(false);
assert.deepStrictEqual(
{ min: part.minimumWidth, max: part.maximumWidth },
{ min: ActivitybarPart.ACTIVITYBAR_WIDTH, max: ActivitybarPart.ACTIVITYBAR_WIDTH }
);
});
test('compact mode returns compact width constraints', () => {
const { part } = createActivitybarPart(true);
assert.deepStrictEqual(
{ min: part.minimumWidth, max: part.maximumWidth },
{ min: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH, max: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH }
);
});
test('height constraints are unbounded', () => {
const { part } = createActivitybarPart(false);
assert.strictEqual(part.minimumHeight, 0);
assert.strictEqual(part.maximumHeight, Number.POSITIVE_INFINITY);
});
// --- Configuration change: dimension update ----------------------------
test('toggling compact via config changes width constraints', () => {
const { part, configService } = createActivitybarPart(false);
// Initially default
assert.strictEqual(part.minimumWidth, ActivitybarPart.ACTIVITYBAR_WIDTH);
// Switch to compact
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.deepStrictEqual(
{ min: part.minimumWidth, max: part.maximumWidth },
{ min: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH, max: ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH }
);
// Switch back to default
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.deepStrictEqual(
{ min: part.minimumWidth, max: part.maximumWidth },
{ min: ActivitybarPart.ACTIVITYBAR_WIDTH, max: ActivitybarPart.ACTIVITYBAR_WIDTH }
);
});
// --- onDidChange fires for grid ----------------------------------------
test('fires onDidChange(undefined) when compact setting changes', () => {
const { part, configService } = createActivitybarPart(false);
const events: (IViewSize | undefined)[] = [];
disposables.add(part.onDidChange(e => events.push(e)));
// Toggle to compact
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.strictEqual(events.length, 1);
assert.strictEqual(events[0], undefined, 'should fire undefined to signal constraint change');
// Toggle back
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.strictEqual(events.length, 2);
assert.strictEqual(events[1], undefined);
});
test('does not fire onDidChange for unrelated config changes', () => {
const { part, configService } = createActivitybarPart(false);
const events: (IViewSize | undefined)[] = [];
disposables.add(part.onDidChange(e => events.push(e)));
fireConfigChange(configService, 'editor.fontSize');
assert.strictEqual(events.length, 0);
});
// --- CSS custom properties on element -----------------------------------
test('updateCompactStyle sets correct CSS custom properties in default mode', () => {
const { part } = createActivitybarPart(false);
const el = document.createElement('div');
fixture.appendChild(el);
part.create(el);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.ACTION_HEIGHT}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.ICON_SIZE}px`);
assert.strictEqual(el.classList.contains('compact'), false);
});
test('updateCompactStyle sets correct CSS custom properties in compact mode', () => {
const { part } = createActivitybarPart(true);
const el = document.createElement('div');
fixture.appendChild(el);
part.create(el);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.COMPACT_ACTION_HEIGHT}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.COMPACT_ICON_SIZE}px`);
assert.strictEqual(el.classList.contains('compact'), true);
});
test('toggling compact updates CSS custom properties on element', () => {
const { part, configService } = createActivitybarPart(false);
const el = document.createElement('div');
fixture.appendChild(el);
part.create(el);
// Default state
assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`);
assert.strictEqual(el.classList.contains('compact'), false);
// Switch to compact
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, true);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.COMPACT_ACTION_HEIGHT}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.COMPACT_ICON_SIZE}px`);
assert.strictEqual(el.classList.contains('compact'), true);
// Switch back
configService.setUserConfiguration(LayoutSettings.ACTIVITY_BAR_COMPACT, false);
fireConfigChange(configService, LayoutSettings.ACTIVITY_BAR_COMPACT);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.ACTIVITYBAR_WIDTH}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.ACTION_HEIGHT}px`);
assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.ICON_SIZE}px`);
assert.strictEqual(el.classList.contains('compact'), false);
});
// --- toJSON ------------------------------------------------------------
test('toJSON returns correct part type', () => {
const { part } = createActivitybarPart(false);
assert.deepStrictEqual(part.toJSON(), { type: Parts.ACTIVITYBAR_PART });
});
ensureNoDisposablesAreLeakedInTestSuite();
});