Skip to content

feat(editor): Chrome-like grouped tabs#315366

Open
mostafa wants to merge 27 commits intomicrosoft:mainfrom
mostafa:feat/chrome-tab-groups
Open

feat(editor): Chrome-like grouped tabs#315366
mostafa wants to merge 27 commits intomicrosoft:mainfrom
mostafa:feat/chrome-tab-groups

Conversation

@mostafa
Copy link
Copy Markdown

@mostafa mostafa commented May 8, 2026

Summary

Implements Chrome-style tab grouping for VS Code's multi-editor tab bar, as requested in #100335.

  • Named, color-coded (preset + custom hex/RGB), collapsible tab groups rendered as standalone header elements in the tab bar
  • Full drag-and-drop support: reorder groups, move tabs between groups, and boundary absorption when dropping adjacent to a group
  • Context menus on both individual tabs (Add/Remove/Move to Group, Dissolve, Rename, Recolor) and group headers (Collapse/Expand, Rename, Change Color, Ungroup, Close Group)
  • Active tab remains visible when its group is collapsed until another tab outside the group is clicked
  • Tab group state persists across sessions via serialization
  • Multi-selection aware: context menu operations apply to all selected tabs
  • Random color assignment on group creation

Architecture

  • Model: EditorGroupModel extended with IEditorTabGroup[], managing contiguous editor ranges with proper index maintenance on all mutations (move, insert, remove, close)
  • UI: MultiEditorTabsControl renders .tab-group-header elements interleaved with tabs, with index-skipping logic to maintain correct model-to-DOM mapping
  • Delegation: FilteredEditorGroupModel (Sticky/Unsticky) properly delegates all tab group operations to the underlying model
  • Commands: 8 new commands registered in editorCommands.ts; 6 have menu contributions in EditorTitleContext (collapse/expand are header-only interactions)

Files changed

File Purpose
editorGroupModel.ts Core data model: tab group CRUD, move, index maintenance
filteredEditorGroupModel.ts Delegation for sticky/unsticky filtered views
multiEditorTabsControl.ts DOM rendering, DnD, context menus, group headers
editorCommands.ts 8 new tab group commands (6 menu items + 2 header-only)
editor.contribution.ts Menu registrations
editor.ts Default options and validation
common/editor.ts GroupModelChangeKind enum, settings interfaces
workbench.contribution.ts User-facing setting registration
media/tabgroups.css Styling for group headers, colors, collapse
common/theme.ts (no additions - kept clean)
vscode-known-variables.json --tab-group-color CSS variable

Test plan

  • Create a tab group from selected tabs via context menu
  • Collapse/expand group by clicking header; verify active tab stays visible when collapsed
  • Rename and recolor group via header context menu (including custom hex input)
  • Drag-and-drop: reorder groups, move tabs into/out of groups, boundary absorption
  • Multi-select tabs and use "Remove from Tab Group" - all selected tabs removed
  • "Move to Tab Group..." shows existing groups and "New Group" option
  • "Close Group" closes all editors in the group
  • Reload window - tab groups persist correctly
  • Verify no regressions with sticky/pinned tabs (groups only apply to non-sticky tabs)

Fixes #100335

Observed behavior and known limitations

Some notes from my testing and development that may help during review:

  1. Motivation: I tried several tab-grouping extensions from the marketplace, which led me to Implement Google Chrome-like grouped tabs #100335. This implementation follows Chrome's tab grouping model as closely as possible within VS Code's architecture.
  2. Editor layout and group scope: Tab groups are scoped to a single editor group (panel). Changing the editor layout (e.g. splitting) does not carry tab groups across panels. This is consistent with how VS Code's editor model works, where each EditorGroupModel owns its own set of editors. If cross-panel grouping is desired, that would be a separate effort.
  3. No collapse/expand animations: I intentionally left out animations for collapse and expand. They tend to add visual noise and unnecessary re-rendering in a productivity tool. If the team prefers animated transitions, I am happy to add them in a follow-up.
  4. Session persistence: Tab groups survive window reloads and editor restarts. I observed a single case of lost state early in development, which I could not reproduce afterwards and attribute to an intermediate build state.
  5. Context menu style: The tab group context menus use VS Code's native context menu infrastructure rather than appearing as an inline popover beneath the tab (as Chrome does). This keeps the UX consistent with the rest of the editor.
  6. Color palette: Groups are assigned a random color from the preset palette on creation. Users can also enter custom #hex or rgb() values through the "Change Color" picker. I don't have a strong preference for the specific preset colors I chose. If the team has established color conventions or wants to align with a particular palette, I am open to changing them.

Tab groups (all in expanded state)

Screenshot 2026-05-08 at 22 48 45

Context menu on the tab group

Screenshot 2026-05-08 at 22 48 57

Context menu on individual tabs

Screenshot 2026-05-08 at 22 49 06

Active tab in a collapsed tab group stays visible

Screenshot 2026-05-08 at 22 49 24

mostafa added 19 commits May 8, 2026 20:06
Introduce Chrome-style tab grouping at the model layer:

- Define IEditorTabGroup interface (id, name, color, collapsed, startIndex, count)
- Extend EditorGroupModel with tabGroups field and CRUD operations
  (create, add, remove, dissolve, collapse, expand, rename, recolor)
- Add GroupModelChangeKind.TAB_GROUP_* event variants
- Extend IReadonlyEditorGroupModel with tabGroups accessor
- Add IEditorPartTabGroupsConfiguration for settings
- Implement index maintenance in splice() for insert/remove
- Serialize/deserialize tab groups in ISerializedEditorGroupModel

Tab groups enforce contiguity and only apply to non-sticky tabs,
mirroring Chrome's behavior where pinned tabs cannot be grouped.

Refs: microsoft#100335
Add the visual layer for tab groups in the multi-tab editor control:

- Create tabgroups.css with badge styles (8 color variants),
  colored bottom borders for grouped tabs, and collapse visibility
- Render group badge on the first tab of each group (click to
  collapse/expand, drop to add editors to the group)
- Apply in-tab-group and tab-group-collapsed CSS classes per tab
- Register TAB_GROUP_HEADER_BACKGROUND/FOREGROUND theme tokens
- Badge approach preserves DOM index alignment (no extra children
  in tabsContainer) so getTabAtIndex() remains correct

Refs: microsoft#100335
Wire up the interaction and configuration layers:

- Register 7 commands: addToNewTabGroup, removeFromTabGroup,
  dissolveTabGroup, collapseTabGroup, expandTabGroup,
  renameTabGroup, recolorTabGroup
- Add tab group entries to EditorTitleContext menu (group '4_tabgroup')
  with sticky-tab precondition guards
- Add tabGroups default to DEFAULT_EDITOR_PART_OPTIONS with verifier
- Register workbench.editor.tabGroups.enabled and
  workbench.editor.tabGroups.collapseOnSwitch settings

Refs: microsoft#100335
Refactor from inline badge (inside first tab) to a standalone
.tab-group-header element inserted directly into the tabs container
before each group's first tab. This matches Chrome's grouped-tabs UX
where the group chip is a visually separate element in the tab bar.

Key changes:
- getTabAtIndex, doWithTab now skip non-.tab children when resolving
  model indices to DOM elements
- handleOpenedEditors/handleClosedEditors count only .tab children
- computeDropTarget skips headers when finding sibling tabs
- Wrap-layout iteration skips headers
- CSS rewritten for standalone header styling with color variants
- FilteredEditorGroupModel implements tabGroups/getTabGroupForEditor
- Tab group mutation methods exposed as optional on the readonly interface
Two bugs fixed:

1. Collapse/expand: FilteredEditorGroupModel (used by multi-row tab
   controls) did not expose collapseTabGroup/expandTabGroup/addToTabGroup,
   so optional-chaining calls resolved to undefined. Added concrete
   delegating methods.

2. Rename/recolor not updating UI: MultiEditorTabsControl had no
   listener for TAB_GROUP_* model change events, so visual updates only
   happened on the next unrelated redraw. Added onDidModelChange
   subscription that calls redrawTabGroups() for all tab-group event
   kinds.
Right-clicking the standalone tab group header now shows a dedicated
context menu with: Expand/Collapse, Rename, Change Color, and Ungroup.
Actions operate directly on the target group by ID rather than relying
on the active editor's group membership, so they work correctly even
when the group is collapsed.

Also exposed renameTabGroup, recolorTabGroup, and dissolveTabGroup as
optional methods on IReadonlyEditorGroupModel and delegated them
through FilteredEditorGroupModel.
Tab group headers are now draggable. Dropping a group header onto
another group header moves the entire group (all its tabs) to that
position. Dropping onto the empty area of the tab bar moves the group
to the end.

Implementation:
- Added moveTabGroup(groupId, toIndex) to EditorGroupModel that
  bulk-relocates editors and recalculates all group startIndex values
- Made .tab-group-header elements draggable with dragstart/dragend
- dragover/drop on group headers detect group-reorder vs editor-add
- Container-level drop handler supports moving group to end
- Model change listener now calls full redraw() (not just
  redrawTabGroups) so tab labels update after editor reordering
moveEditor() was directly splicing the editors array without updating
tab group startIndex/count, causing dropped tabs to appear outside
their target group until a manual redraw. Add adjustTabGroupsForMove()
to maintain group boundaries on every move, fire TAB_GROUP_CHANGED so
the UI redraws immediately, and call redrawTabGroups() at the end of
the control's moveEditor to prevent stale group styling.

Also adds includeInTabGroup() for position-preserving membership,
a "Move to Tab Group..." context menu command, removes the collapsed
arrow icon, and replaces unsafe `as any` casts with a typed helper.
The previous moveTabGroup used fragile relative index arithmetic that
miscalculated other groups' startIndex after a splice, corrupting tab
order. Replace with a snapshot-based approach: record each group's
first editor before mutation, splice the array, then rebuild all
startIndex values via indexOf.

Also fix the "move to end" target index to account for sticky tabs,
and allow dropping a group header onto any tab position (not just
other headers) for more flexible reordering.
When dropping a tab at the start or end of a tab group, the
adjustTabGroupsForInsert logic would push the group rather than
expand it. Add a post-move boundary check in onDrop that calls
includeInTabGroup when the editor lands adjacent to a group.
- Context menu "Remove from Tab Group" now works on the right-clicked
  tab (using resolveCommandsContext) and supports multi-selection.
- "Move to Tab Group..." also uses resolved context for right-click.
- Dragging a tab out of a group no longer re-absorbs it at the
  boundary; the post-move absorption only triggers when the drop
  target was originally within a group.
The "Remove from Tab Group" context menu action now removes all
selected editors in addition to the right-clicked one.
Previously, removing an editor from the middle of a group just
decremented count, causing the last editor to silently fall out of the
group. Now the removed editor is spliced to the end of the group range
before shrinking, maintaining contiguity for all remaining members.
New tab groups now get a random color from the preset palette instead
of always defaulting to blue. The "Change Color" picker in both the
context menu and command palette now offers a "Custom (#hex)..." option
that accepts arbitrary #hex or rgb() values. Custom colors are applied
via inline styles on the header and the tab border CSS variable.
Use color-mix(in srgb) for the background opacity instead of appending
hex alpha digits, which only worked for 6-digit hex and broke for
rgb() or short hex values.
Custom colors now mix 30% white into the text color via color-mix(),
ensuring readability on dark backgrounds. The header uses
display: inline-block with text-overflow: ellipsis so long group
names are properly truncated at 150px instead of overflowing.
Closes all editors belonging to the group after dissolving it.
Complements the existing "Ungroup Tabs" which only removes
the grouping without closing editors.
When a tab group is collapsed, the currently active editor's tab
remains visible until the user clicks another tab outside the group.
This matches Chrome's behavior where the focused tab stays accessible
even after collapsing its group.
- Fix createTabGroup index re-derivation bug: after sorting editor
  indices, the re-derivation loop now correctly uses the sorted
  editor-index pairs instead of the original unsorted editors array.
- Remove redundant TAB_GROUP_CHANGED event from moveEditor to avoid
  double-redraw (the control's moveEditor already calls redrawTabGroups).
- Remove unused TAB_GROUP_HEADER_BACKGROUND/FOREGROUND theme tokens
  that were registered but never referenced.
- Remove unused collapseOnSwitch setting that was declared but never
  implemented.
Copilot AI review requested due to automatic review settings May 8, 2026 20:59
@mostafa
Copy link
Copy Markdown
Author

mostafa commented May 8, 2026

@microsoft-github-policy-service agree company="Tiger Data"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds Chrome-style tab grouping to the VS Code workbench editor tab strip by extending the editor group model with persisted tab-group metadata, rendering group headers in the multi-editor tabs UI, and wiring up commands/menus and configuration.

Changes:

  • Extend EditorGroupModel with tab-group data structures, mutation APIs, change events, and (de)serialization.
  • Render tab-group headers and per-tab group styling in MultiEditorTabsControl, including drag-and-drop interactions and header context menus.
  • Register new tab-group commands and Editor Title context menu items, plus a new workbench.editor.tabGroups.enabled setting and editor option plumbing.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/vs/workbench/common/theme.ts Minor whitespace change near tab-related colors.
src/vs/workbench/common/editor/filteredEditorGroupModel.ts Adds delegation for tab-group APIs on filtered models (sticky/unsticky).
src/vs/workbench/common/editor/editorGroupModel.ts Implements tab-group model, events, and persistence within editor groups.
src/vs/workbench/common/editor.ts Adds new GroupModelChangeKind values and editor options surface for tab groups.
src/vs/workbench/browser/workbench.contribution.ts Registers workbench.editor.tabGroups.enabled user setting.
src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Renders group headers, applies group styling, and adds DnD/context menu behavior.
src/vs/workbench/browser/parts/editor/media/tabgroups.css New styling for group headers and collapsed-group tab hiding.
src/vs/workbench/browser/parts/editor/editorCommands.ts Adds command registrations for creating/managing tab groups.
src/vs/workbench/browser/parts/editor/editor.ts Adds default options + validation for the new tabGroups editor part option.
src/vs/workbench/browser/parts/editor/editor.contribution.ts Adds Editor Title context menu items for tab-group operations.
build/lib/stylelint/vscode-known-variables.json Adds --tab-group-color to the known CSS variables list.

Comment thread src/vs/workbench/common/editor/editorGroupModel.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts
Comment thread src/vs/workbench/browser/parts/editor/editor.contribution.ts Outdated
- Fix adjustTabGroupsForInsert boundary: change <= to < so inserting at
  group end does not auto-expand the group
- Re-sort _tabGroups after moveTabGroup rebuilds indices
- Remove editors from existing groups in createTabGroup to prevent overlap
- Create ITabGroupMutations interface and move mutable methods off
  IReadonlyEditorGroupModel for proper separation of concerns
- Adjust tabGroups/getTabGroupForEditor in StickyEditorGroupModel (returns
  empty) and UnstickyEditorGroupModel (translates indices by -stickyCount)
- Add role=button, tabIndex, aria-expanded, aria-label, and keyboard
  support (Enter/Space) to group headers
- Localize group header tooltip text
- Gate redrawTabGroups and tab group model change listener on
  tabGroups.enabled setting
- Add config.workbench.editor.tabGroups.enabled when clause to all tab
  group context menu items
- Cache tab elements array for O(1) index lookup in getTabAtIndex,
  getTabElementCount, and getLastTabElement
- Add comprehensive unit tests for tab group model operations
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.

Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/theme.ts Outdated
@mostafa
Copy link
Copy Markdown
Author

mostafa commented May 8, 2026

@copilot apply changes based on the comments in this thread

mostafa and others added 2 commits May 9, 2026 09:55
- Fix potential infinite loop in handleClosedEditors: pop from
  tabElementsCache when removing tab elements so the count decreases
- Clean up stale group headers and tab classes when tabGroups.enabled
  is toggled off at runtime
- Capture group.id in event handlers and re-fetch current group state
  to avoid stale references from UnstickyEditorGroupModel
- Convert relative startIndex to absolute index (add stickyCount) when
  dropping a group header in the unsticky tab bar
- Preserve group membership in adjustTabGroupsForMove when an editor
  moves within the same group (no shrink+expand)
- Enforce adjacency in includeInTabGroup: only allow absorption at
  startIndex-1 or startIndex+count to prevent non-contiguous groups
- Emit EDITOR_MOVE events in removeFromTabGroup when the internal
  reorder changes tab positions (keeps Open Editors view in sync)
- Emit EDITOR_MOVE events in moveTabGroup for each relocated editor
  so extension host and other listeners update correctly
- Remove extra blank line in theme.ts
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts Outdated
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/common/editor/editorGroupModel.ts
Comment thread src/vs/workbench/browser/parts/editor/editorCommands.ts Outdated
Comment thread src/vs/workbench/browser/parts/editor/editor.contribution.ts
mostafa and others added 3 commits May 9, 2026 11:09
- getTabElementCount/getLastTabElement: fall back to DOM query when
  tabElementsCache is empty (feature disabled or pre-first-redraw)
- Container/header group drop: only add stickyCount when tabsModel is
  UnstickyEditorGroupModel to avoid double-counting sticky tabs
- includeInTabGroup: add isSticky guard to prevent sticky editors from
  being absorbed into tab groups
- createTabGroup: return undefined instead of throwing when no valid
  editors provided (consistent with no-op pattern)
- Deserialization: sort restored tab groups by startIndex and drop
  overlapping ranges to enforce invariants
- Quick pick label: use template literal to keep icon outside localize
  call per hygiene rules
- PR description: clarify collapse/expand are header-only interactions
  (6 menu items, not 8)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Google Chrome-like grouped tabs

3 participants