Draw the tab list as a webview, VS-style

A tree view row cannot be styled: no border, no spacing, and a long file
name gets middle-ellipsized in a narrow strip — which on a real project left
"ClientDogSurger...nionBehaviour.cs" and hid the project text entirely.
Drawing the rows means controlling all of it.

- A coloured left border per project instead of a tinted icon, so the file
  icon slot is free and the name gets the width. The border uses
  var(--vscode-verticalTabs-projectN): VS Code injects every contributed
  theme colour into a webview, so overrides in colorCustomizations still
  apply and nothing is hardcoded.
- The name is split into stem and extension, and only the stem ellipsizes,
  so ".cs" survives on a long name in a narrow view.
- Configurable row spacing and border width, a rule under the pinned block,
  a dirty dot that the close button replaces on hover, italics for preview
  and for tabs that cannot be focused.
- Selection, arrow/Home/End/Enter/Delete keys, middle-click to close, and an
  HTML context menu, since contributes.menus does not reach webview rows.
- Clicking a webview row posts nothing rather than asking for an open that
  would silently do nothing.

Actions moved to actions.ts so the wiring, the view and the tests share one
implementation; extension.ts is now just registration. The per-row commands
are gone — rows act through webview messages — and the palette keeps
close / closeOthers / togglePin acting on the active editor.

Verified in a browser harness at side-bar width: all twelve project colours
resolve, rows stay a consistent 24px, and click, pin, close, middle-click,
keyboard and both context-menu variants post the right intents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 18:34:26 +02:00
co-authored by Claude Opus 5
parent 2e9a43f78b
commit cf168b2e75
8 changed files with 981 additions and 425 deletions
+252
View File
@@ -0,0 +1,252 @@
/* Vertical Tabs — the tab list */
:root {
--row-gap: 2px;
--border-width: 3px;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
color: var(--vscode-foreground);
background: var(--vscode-sideBar-background, transparent);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
#list {
height: 100%;
padding: 3px 0;
overflow-x: hidden;
overflow-y: auto;
outline: none;
}
/* --- a row -------------------------------------------------------------- */
.tab {
display: flex;
align-items: center;
gap: 5px;
min-height: 24px;
margin: 0 2px var(--row-gap);
padding: 2px 4px 2px 5px;
border-left: var(--border-width) solid var(--tab-color, transparent);
border-radius: 0 3px 3px 0;
cursor: pointer;
user-select: none;
/* the close button only takes space while it is visible */
overflow: hidden;
}
.tab:hover {
background: var(--vscode-list-hoverBackground);
}
.tab.active {
color: var(--vscode-list-activeSelectionForeground);
background: var(--vscode-list-activeSelectionBackground);
}
.tab.selected:not(.active) {
background: var(--vscode-list-inactiveSelectionBackground);
}
#list:focus-visible .tab.selected {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.tab.inert {
cursor: default;
}
.tab.inert .name {
font-style: italic;
opacity: 0.75;
}
/* --- the file name ------------------------------------------------------ */
.name {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: baseline;
white-space: nowrap;
}
/* The stem shrinks and ellipsizes; the extension never does, so ".cs" stays
readable on a long name in a narrow strip. */
.stem {
overflow: hidden;
text-overflow: ellipsis;
}
.ext {
flex: 0 0 auto;
opacity: 0.8;
}
.tab.preview .name {
font-style: italic;
}
.dir {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
color: var(--vscode-descriptionForeground);
font-size: 0.9em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- markers and buttons ------------------------------------------------ */
.marker {
flex: 0 0 auto;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-icon-foreground);
border-radius: 3px;
opacity: 0.85;
}
.marker svg {
width: 12px;
height: 12px;
fill: currentColor;
}
button.marker {
padding: 0;
background: none;
border: none;
cursor: pointer;
opacity: 0;
}
.tab:hover button.marker,
.tab.active button.marker,
button.marker.on,
button.marker:focus-visible {
opacity: 1;
}
button.marker:hover {
background: var(--vscode-toolbar-hoverBackground);
}
button.marker:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
/* An unsaved file shows a dot, which the close button replaces on hover. */
.dirty {
flex: 0 0 auto;
width: 16px;
display: flex;
justify-content: center;
}
.dirty::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.tab:hover .dirty,
.tab.active .dirty {
display: none;
}
.tab:not(.is-dirty) .dirty {
display: none;
}
.tab:not(:hover):not(.active) button.close {
display: none;
}
/* --- the pinned block --------------------------------------------------- */
.separator {
height: 1px;
margin: 4px 6px 5px;
background: var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border));
opacity: 0.6;
}
/* --- context menu ------------------------------------------------------- */
#menu {
position: fixed;
z-index: 20;
min-width: 180px;
padding: 4px;
color: var(--vscode-menu-foreground, var(--vscode-foreground));
background: var(--vscode-menu-background, var(--vscode-editorWidget-background));
border: 1px solid var(--vscode-menu-border, var(--vscode-editorWidget-border));
border-radius: 4px;
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36));
}
#menu[hidden] {
display: none;
}
#menu button {
display: block;
width: 100%;
padding: 4px 8px;
color: inherit;
background: none;
border: none;
border-radius: 2px;
font-family: inherit;
font-size: inherit;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
#menu button:hover,
#menu button:focus-visible {
color: var(--vscode-menu-selectionForeground, inherit);
background: var(--vscode-menu-selectionBackground, var(--vscode-list-hoverBackground));
outline: none;
}
#menu .menu-separator {
height: 1px;
margin: 4px 2px;
background: var(--vscode-menu-separatorBackground, var(--vscode-panel-border));
}
/* --- empty state -------------------------------------------------------- */
#empty {
padding: 10px 12px;
color: var(--vscode-descriptionForeground);
}
#empty[hidden] {
display: none;
}
+330
View File
@@ -0,0 +1,330 @@
// @ts-check
/**
* Vertical Tabs — the tab list.
*
* The extension owns the tab state; this renders it and posts back intents. Nothing
* here is authoritative, so a stale row cannot act on the wrong tab: every message
* carries the row id and the extension resolves it against live tab state.
*/
(function () {
'use strict';
const vscode = acquireVsCodeApi();
/** @type {{rows: any[], showProject: boolean, showDirectory: boolean, showColumn: boolean}} */
let data = { rows: [], showProject: false, showDirectory: false, showColumn: false };
let selectedId = (vscode.getState() || {}).selectedId;
const el = {
list: /** @type {HTMLElement} */ (document.getElementById('list')),
empty: /** @type {HTMLElement} */ (document.getElementById('empty')),
menu: /** @type {HTMLElement} */ (document.getElementById('menu')),
};
const ICONS = {
close: '<svg viewBox="0 0 16 16"><path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.708.708L7.293 8l-3.647 3.646.708.708L8 8.707z"/></svg>',
pin: '<svg viewBox="0 0 16 16"><path d="M10.5 1.5l4 4-2 1-1.5 1.5.5 3.5-1 1-3-3-3.5 3.5-1-1L6.5 9l-3-3 1-1 3.5.5L9.5 4l1-2.5z"/></svg>',
};
function saveState() {
vscode.setState({ selectedId });
}
/** Splits `Foo.Bar.cs` into stem and extension so the extension never ellipsizes. */
function splitName(label) {
const dot = label.lastIndexOf('.');
return dot > 0 ? [label.slice(0, dot), label.slice(dot)] : [label, ''];
}
function iconButton(className, svg, title, onClick) {
const button = document.createElement('button');
button.className = `marker ${className}`;
button.innerHTML = svg;
button.title = title;
button.tabIndex = -1;
button.addEventListener('click', event => {
event.stopPropagation();
onClick();
});
return button;
}
function rowElement(row) {
const div = document.createElement('div');
div.className = 'tab';
div.dataset.id = row.id;
div.setAttribute('role', 'option');
if (row.isActive) {
div.classList.add('active');
}
if (row.id === selectedId) {
div.classList.add('selected');
}
if (row.isDirty) {
div.classList.add('is-dirty');
}
if (row.isPreview) {
div.classList.add('preview');
}
if (!row.canActivate) {
div.classList.add('inert');
}
// The border colour is the contributed theme colour, so a user override in
// workbench.colorCustomizations still applies.
if (row.colorIndex > 0) {
div.style.setProperty('--tab-color', `var(--vscode-verticalTabs-project${row.colorIndex})`);
} else if (row.uri) {
div.style.setProperty('--tab-color', 'var(--vscode-verticalTabs-noProject)');
}
div.appendChild(iconButton('pin', ICONS.pin, row.isPinned ? 'Unpin' : 'Pin',
() => vscode.postMessage({ type: 'togglePin', id: row.id })));
if (row.isPinned) {
/** @type {HTMLElement} */ (div.querySelector('button.pin')).classList.add('on');
}
const name = document.createElement('span');
name.className = 'name';
const [stem, ext] = splitName(row.label);
const stemSpan = document.createElement('span');
stemSpan.className = 'stem';
stemSpan.textContent = stem;
name.appendChild(stemSpan);
if (ext) {
const extSpan = document.createElement('span');
extSpan.className = 'ext';
extSpan.textContent = ext;
name.appendChild(extSpan);
}
div.appendChild(name);
const trailing = [];
if (data.showDirectory && row.dir) {
trailing.push(row.dir);
}
if (data.showProject && row.project) {
trailing.push(row.project);
}
if (data.showColumn && row.column) {
trailing.push(`#${row.column}`);
}
if (trailing.length > 0) {
const dir = document.createElement('span');
dir.className = 'dir';
dir.textContent = trailing.join(' ');
div.appendChild(dir);
}
const dirty = document.createElement('span');
dirty.className = 'marker dirty';
div.appendChild(dirty);
div.appendChild(iconButton('close', ICONS.close, 'Close',
() => vscode.postMessage({ type: 'close', id: row.id })));
div.title = row.tooltip;
return div;
}
function render() {
el.list.textContent = '';
el.empty.hidden = data.rows.length > 0;
const fragment = document.createDocumentFragment();
let previousPinned;
for (const row of data.rows) {
// A rule under the pinned block, the way a tab bar separates them.
if (previousPinned === true && !row.isPinned) {
const rule = document.createElement('div');
rule.className = 'separator';
fragment.appendChild(rule);
}
previousPinned = row.isPinned;
fragment.appendChild(rowElement(row));
}
el.list.appendChild(fragment);
}
// -----------------------------------------------------------------------
// Selection and keyboard
// -----------------------------------------------------------------------
function select(id, reveal) {
selectedId = id;
saveState();
for (const node of el.list.querySelectorAll('.tab')) {
node.classList.toggle('selected', node.dataset.id === id);
}
if (reveal) {
const node = el.list.querySelector(`.tab[data-id="${CSS.escape(id)}"]`);
if (node) {
node.scrollIntoView({ block: 'nearest' });
}
}
}
function move(delta) {
if (data.rows.length === 0) {
return;
}
const current = data.rows.findIndex(row => row.id === selectedId);
const next = Math.max(0, Math.min(data.rows.length - 1,
current < 0 ? 0 : current + delta));
select(data.rows[next].id, true);
}
el.list.addEventListener('click', event => {
const row = /** @type {HTMLElement} */ (event.target).closest('.tab');
if (!row) {
return;
}
select(row.dataset.id, false);
// A webview tab has no resource to re-open, so asking would silently do nothing.
if (!row.classList.contains('inert')) {
vscode.postMessage({ type: 'open', id: row.dataset.id });
}
});
// Middle click closes, the way it does on a tab bar.
el.list.addEventListener('auxclick', event => {
if (event.button !== 1) {
return;
}
const row = /** @type {HTMLElement} */ (event.target).closest('.tab');
if (row) {
event.preventDefault();
vscode.postMessage({ type: 'close', id: row.dataset.id });
}
});
el.list.addEventListener('keydown', event => {
switch (event.key) {
case 'ArrowDown': move(1); break;
case 'ArrowUp': move(-1); break;
case 'Home': move(-data.rows.length); break;
case 'End': move(data.rows.length); break;
case 'Enter':
if (selectedId) {
vscode.postMessage({ type: 'open', id: selectedId });
}
break;
case 'Delete':
if (selectedId) {
vscode.postMessage({ type: 'close', id: selectedId });
}
break;
default:
return;
}
event.preventDefault();
});
// -----------------------------------------------------------------------
// Context menu
// -----------------------------------------------------------------------
const MENU = [
{ label: 'Close', type: 'close' },
{ label: 'Close Others', type: 'closeOthers' },
{ separator: true },
{ label: 'Pin / Unpin', type: 'togglePin' },
{ separator: true },
{ label: 'Copy Path', type: 'copyPath', needsFile: true },
{ label: 'Reveal in Explorer View', type: 'reveal', needsFile: true },
];
function openMenu(row, x, y) {
el.menu.textContent = '';
const hasFile = !!row.uri;
for (const item of MENU) {
if (item.separator) {
const rule = document.createElement('div');
rule.className = 'menu-separator';
el.menu.appendChild(rule);
continue;
}
if (item.needsFile && !hasFile) {
continue;
}
const button = document.createElement('button');
button.textContent = item.label;
button.addEventListener('click', () => {
closeMenu();
vscode.postMessage({ type: item.type, id: row.id });
});
el.menu.appendChild(button);
}
el.menu.hidden = false;
// Keep it inside the view.
const box = el.menu.getBoundingClientRect();
el.menu.style.left = `${Math.max(0, Math.min(x, window.innerWidth - box.width - 2))}px`;
el.menu.style.top = `${Math.max(0, Math.min(y, window.innerHeight - box.height - 2))}px`;
const first = el.menu.querySelector('button');
if (first) {
/** @type {HTMLElement} */ (first).focus();
}
}
function closeMenu() {
el.menu.hidden = true;
}
el.list.addEventListener('contextmenu', event => {
const node = /** @type {HTMLElement} */ (event.target).closest('.tab');
if (!node) {
return;
}
const row = data.rows.find(candidate => candidate.id === node.dataset.id);
if (!row) {
return;
}
event.preventDefault();
select(row.id, false);
openMenu(row, event.clientX, event.clientY);
});
document.addEventListener('click', event => {
if (!el.menu.hidden && !el.menu.contains(/** @type {Node} */ (event.target))) {
closeMenu();
}
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !el.menu.hidden) {
closeMenu();
el.list.focus();
}
});
// -----------------------------------------------------------------------
// Boot
// -----------------------------------------------------------------------
window.addEventListener('message', event => {
const message = event.data;
if (message.type !== 'rows') {
return;
}
data = message;
if (typeof data.rowSpacing === 'number') {
document.documentElement.style.setProperty('--row-gap', `${data.rowSpacing}px`);
}
if (typeof data.borderWidth === 'number') {
document.documentElement.style.setProperty('--border-width', `${data.borderWidth}px`);
}
// Follow the active tab unless the user has selected something else that is
// still open.
const active = data.rows.find(row => row.isActive);
if (!data.rows.some(row => row.id === selectedId) || (active && message.followActive)) {
selectedId = active ? active.id : undefined;
saveState();
}
render();
if (selectedId) {
select(selectedId, true);
}
});
el.list.tabIndex = 0;
vscode.postMessage({ type: 'ready' });
})();
+24 -115
View File
@@ -28,69 +28,41 @@
{
"id": "verticalTabs.tabs",
"name": "Tabs",
"type": "webview",
"contextualTitle": "Tabs",
"visibility": "visible"
}
]
},
"viewsWelcome": [
{
"view": "verticalTabs.tabs",
"contents": "No editors are open."
}
],
"commands": [
{
"command": "verticalTabs.open",
"title": "Open Tab",
"category": "Tabs"
},
{
"command": "verticalTabs.close",
"title": "Close",
"category": "Tabs",
"icon": "$(close)"
"title": "Close Active Tab",
"category": "Tabs"
},
{
"command": "verticalTabs.closeOthers",
"title": "Close Others",
"title": "Close Other Tabs",
"category": "Tabs"
},
{
"command": "verticalTabs.pin",
"title": "Pin",
"category": "Tabs",
"icon": "$(pin)"
},
{
"command": "verticalTabs.unpin",
"title": "Unpin",
"category": "Tabs",
"icon": "$(pinned)"
},
{
"command": "verticalTabs.copyPath",
"title": "Copy Path",
"category": "Tabs"
},
{
"command": "verticalTabs.revealInExplorer",
"title": "Reveal in Explorer View",
"command": "verticalTabs.togglePin",
"title": "Pin or Unpin Active Tab",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderOpen",
"title": "Sort by Open Order",
"title": "Sort Tabs by Open Order",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderProject",
"title": "Sort by Project",
"title": "Sort Tabs by Project",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderName",
"title": "Sort by File Name",
"title": "Sort Tabs by File Name",
"category": "Tabs"
}
],
@@ -111,83 +83,6 @@
"when": "view == verticalTabs.tabs",
"group": "1_order@3"
}
],
"view/item/context": [
{
"command": "verticalTabs.pin",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
"group": "inline@1"
},
{
"command": "verticalTabs.unpin",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
"group": "inline@1"
},
{
"command": "verticalTabs.close",
"when": "view == verticalTabs.tabs",
"group": "inline@2"
},
{
"command": "verticalTabs.pin",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
"group": "1_state@1"
},
{
"command": "verticalTabs.unpin",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
"group": "1_state@1"
},
{
"command": "verticalTabs.close",
"when": "view == verticalTabs.tabs",
"group": "2_close@1"
},
{
"command": "verticalTabs.closeOthers",
"when": "view == verticalTabs.tabs",
"group": "2_close@2"
},
{
"command": "verticalTabs.copyPath",
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
"group": "3_path@1"
},
{
"command": "verticalTabs.revealInExplorer",
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
"group": "3_path@2"
}
],
"commandPalette": [
{
"command": "verticalTabs.open",
"when": "false"
},
{
"command": "verticalTabs.close",
"when": "false"
},
{
"command": "verticalTabs.closeOthers",
"when": "false"
},
{
"command": "verticalTabs.pin",
"when": "false"
},
{
"command": "verticalTabs.unpin",
"when": "false"
},
{
"command": "verticalTabs.copyPath",
"when": "false"
},
{
"command": "verticalTabs.revealInExplorer",
"when": "false"
}
]
},
"configuration": {
@@ -226,7 +121,7 @@
"verticalTabs.colorByProject": {
"type": "boolean",
"default": true,
"description": "Colour each tab's icon by its containing project."
"description": "Colour each row's left border by its containing project."
},
"verticalTabs.allGroups": {
"type": "boolean",
@@ -251,6 +146,20 @@
"build.gradle"
],
"description": "File names or extensions that mark a project root. The nearest match above a file names its project."
},
"verticalTabs.rowSpacing": {
"type": "number",
"default": 2,
"minimum": 0,
"maximum": 10,
"description": "Pixels of vertical space between rows."
},
"verticalTabs.borderWidth": {
"type": "number",
"default": 3,
"minimum": 0,
"maximum": 8,
"description": "Width in pixels of the project colour border on the left of each row."
}
}
},
+88
View File
@@ -0,0 +1,88 @@
import * as vscode from 'vscode';
import { canActivate, tabUri } from './tabs';
/**
* What the list can do to a tab.
*
* Everything here takes a live `vscode.Tab`. The rows the view draws are snapshots, and
* acting on stale state silently does the wrong thing (or nothing at all) — an earlier
* version compared a snapshot's `isPinned` and so never unpinned anything.
*/
/**
* Brings a tab to the front by re-opening its resource in its own group. There is no API
* to activate a tab directly, which is also why webview tabs cannot be focused: they
* have no resource to re-open.
*/
export async function openTab(tab: vscode.Tab): Promise<void> {
const uri = tabUri(tab);
if (!uri) {
return;
}
const column = tab.group.viewColumn ?? vscode.ViewColumn.Active;
const input = tab.input;
if (input instanceof vscode.TabInputNotebook) {
const notebook = await vscode.workspace.openNotebookDocument(uri);
await vscode.window.showNotebookDocument(notebook, { viewColumn: column, preview: false });
return;
}
if (input instanceof vscode.TabInputTextDiff) {
await vscode.commands.executeCommand('vscode.diff', input.original, input.modified,
tab.label, { viewColumn: column, preview: false });
return;
}
if (input instanceof vscode.TabInputCustom) {
await vscode.commands.executeCommand('vscode.openWith', uri, input.viewType,
{ viewColumn: column, preview: false });
return;
}
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false });
}
export async function closeTab(tab: vscode.Tab): Promise<void> {
await vscode.window.tabGroups.close(tab, true);
}
/**
* VS Code can only pin the *active* editor, so pinning another tab means focusing it
* first. The focus lands where the user clicked anyway, which is what they expect.
*/
export async function togglePin(tab: vscode.Tab): Promise<void> {
const pinned = !tab.isPinned;
if (!tab.isActive) {
if (!canActivate(tab)) {
void vscode.window.showInformationMessage(
`Cannot pin “${tab.label}”: this kind of tab has to be focused first.`);
return;
}
await openTab(tab);
}
await vscode.commands.executeCommand(
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
}
/** Closes everything except `tab`, keeping pinned tabs the way a tab bar does. */
export async function closeOthers(tab: vscode.Tab): Promise<void> {
const others = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.filter(other => other !== tab && !other.isPinned);
if (others.length > 0) {
await vscode.window.tabGroups.close(others, true);
}
}
export async function copyPath(tab: vscode.Tab): Promise<void> {
const uri = tabUri(tab);
if (uri) {
await vscode.env.clipboard.writeText(uri.fsPath);
}
}
export async function revealInExplorer(tab: vscode.Tab): Promise<void> {
const uri = tabUri(tab);
if (uri) {
await vscode.commands.executeCommand('revealInExplorer', uri);
}
}
+25 -282
View File
@@ -1,254 +1,40 @@
import * as vscode from 'vscode';
import { clearProjectCache, projectColor, projectOf } from './projects';
import { Order, TabEntry, buildEntries, canActivate, describeEntry, tabUri } from './tabs';
const VIEW_ID = 'verticalTabs.tabs';
import { closeOthers, closeTab, copyPath, openTab, revealInExplorer, togglePin } from './actions';
import { clearProjectCache } from './projects';
import { Order } from './tabs';
import { TabsView } from './view';
function config() {
return vscode.workspace.getConfiguration('verticalTabs');
}
/**
* The open tabs as a flat list.
*
* A `TreeDataProvider` with only root items renders as a list — there is no separate
* list API — so nothing here has children.
*/
class TabsProvider implements vscode.TreeDataProvider<TabEntry> {
private readonly changed = new vscode.EventEmitter<void>();
readonly onDidChangeTreeData = this.changed.event;
private entries: TabEntry[] = [];
refresh(): void {
this.changed.fire();
}
/** The entry for a tab, so commands can act on the current list. */
find(predicate: (entry: TabEntry) => boolean): TabEntry | undefined {
return this.entries.find(predicate);
}
get all(): readonly TabEntry[] {
return this.entries;
}
async getChildren(element?: TabEntry): Promise<TabEntry[]> {
if (element) {
return []; // flat list
}
const cfg = config();
const groups = cfg.get<boolean>('allGroups', true)
? vscode.window.tabGroups.all
: [vscode.window.tabGroups.activeTabGroup];
// Resolve every project once, then build the list synchronously.
const projects = new Map<string, string | undefined>();
if (cfg.get<boolean>('showProject', true) || cfg.get<boolean>('colorByProject', true) ||
cfg.get<Order>('order', 'open') === 'project') {
const uris = new Map<string, vscode.Uri>();
for (const group of groups) {
for (const tab of group.tabs) {
const uri = tabUri(tab);
if (uri) {
uris.set(uri.toString(), uri);
}
}
}
await Promise.all([...uris].map(async ([key, uri]) => {
projects.set(key, await projectOf(uri));
}));
}
this.entries = buildEntries(groups, projects, {
order: cfg.get<Order>('order', 'open'),
pinnedFirst: cfg.get<boolean>('pinnedFirst', true),
});
return this.entries;
}
getParent(): TabEntry | undefined {
return undefined; // required for TreeView.reveal on a flat list
}
getTreeItem(entry: TabEntry): vscode.TreeItem {
const cfg = config();
const item = new vscode.TreeItem(entry.label);
item.id = entry.id;
item.description = describeEntry(entry, {
showProject: cfg.get<boolean>('showProject', true),
showDirectory: cfg.get<boolean>('showDirectory', true),
showColumn: vscode.window.tabGroups.all.length > 1 && cfg.get<boolean>('allGroups', true),
});
// resourceUri gives the file icon and the theme's file-name colouring; the
// project colour rides on the icon instead so the two do not fight.
if (entry.uri) {
item.resourceUri = entry.uri;
}
if (cfg.get<boolean>('colorByProject', true) && entry.uri) {
item.iconPath = new vscode.ThemeIcon(
entry.isDirty ? 'circle-filled' : 'circle-outline', projectColor(entry.project));
} else if (entry.isDirty) {
item.iconPath = new vscode.ThemeIcon('circle-filled');
}
// Tokens have to be unambiguous as substrings: a `when` clause matching
// /pinned/ would also match "unpinned", showing both pin and unpin at once.
const state = [
entry.isPinned ? 'pin:on' : 'pin:off',
entry.uri ? 'file' : 'other',
entry.canActivate ? 'activatable' : 'inert',
];
item.contextValue = state.join(' ');
const tooltip = new vscode.MarkdownString();
tooltip.appendMarkdown(`**${entry.label}**${entry.isDirty ? ' — unsaved' : ''}\n\n`);
if (entry.uri) {
tooltip.appendMarkdown(`${vscode.workspace.asRelativePath(entry.uri, true)}\n\n`);
}
if (entry.project) {
tooltip.appendMarkdown(`Project: ${entry.project}\n\n`);
}
if (!entry.canActivate) {
tooltip.appendMarkdown('_This kind of tab cannot be focused from here._');
}
item.tooltip = tooltip;
if (entry.canActivate) {
item.command = {
command: 'verticalTabs.open',
title: 'Open Tab',
arguments: [entry],
};
}
return item;
}
dispose(): void {
this.changed.dispose();
}
}
/**
* Brings a tab to the front by re-opening its resource in its own group. There is no
* API to activate a tab directly.
*
* Everything below takes a live `vscode.Tab` rather than a `TabEntry`: an entry is a
* snapshot taken when the row was drawn, and acting on stale state silently does the
* wrong thing (or nothing).
*/
async function openTab(tab: vscode.Tab): Promise<void> {
const uri = tabUri(tab);
if (!uri) {
return;
}
const column = tab.group.viewColumn ?? vscode.ViewColumn.Active;
const input = tab.input;
if (input instanceof vscode.TabInputNotebook) {
const notebook = await vscode.workspace.openNotebookDocument(uri);
await vscode.window.showNotebookDocument(notebook, { viewColumn: column, preview: false });
return;
}
if (input instanceof vscode.TabInputTextDiff) {
await vscode.commands.executeCommand('vscode.diff', input.original, input.modified,
tab.label, { viewColumn: column, preview: false });
return;
}
if (input instanceof vscode.TabInputCustom) {
await vscode.commands.executeCommand('vscode.openWith', uri, input.viewType,
{ viewColumn: column, preview: false });
return;
}
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false });
}
/**
* VS Code can only pin the *active* editor, so pinning another tab means focusing it
* first. The focus lands where the user clicked anyway, which is what they expect.
*/
async function togglePin(tab: vscode.Tab): Promise<void> {
const pinned = !tab.isPinned;
if (!tab.isActive) {
if (!canActivate(tab)) {
void vscode.window.showInformationMessage(
`Cannot pin “${tab.label}”: this kind of tab has to be focused first.`);
return;
}
await openTab(tab);
}
// Both the pin and unpin commands land here: the two exist only so the inline
// button's icon can reflect the current state.
await vscode.commands.executeCommand(
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
}
export function activate(context: vscode.ExtensionContext): void {
const provider = new TabsProvider();
const view = vscode.window.createTreeView(VIEW_ID, {
treeDataProvider: provider,
showCollapseAll: false,
const view = new TabsView(context.extensionUri, {
open: openTab,
close: closeTab,
closeOthers,
togglePin,
copyPath,
reveal: revealInExplorer,
});
const updateTitle = () => {
const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
view.description = count > 0 ? String(count) : undefined;
};
const refresh = () => void view.refresh();
/**
* Keeps the list's selection on whatever tab is active.
*
* The entries hold a snapshot of `isActive`, and after a refresh the tree has not
* necessarily asked for children yet — so rebuild the list first and match against
* the live active tab rather than a stale flag.
*/
const followActiveTab = async () => {
if (!view.visible) {
return;
}
const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab;
if (!activeTab) {
return;
}
const entries = await provider.getChildren();
const active = entries.find(entry => entry.tab === activeTab);
if (active && !view.selection.some(entry => entry.id === active.id)) {
try {
await view.reveal(active, { select: true, focus: false });
} catch {
// reveal throws if the item is gone; the next refresh will sort it out.
}
/** The tab a palette command acts on. */
const activeTab = () => vscode.window.tabGroups.activeTabGroup.activeTab;
const onActiveTab = (action: (tab: vscode.Tab) => Promise<void>) => async () => {
const tab = activeTab();
if (tab) {
await action(tab);
}
};
const refresh = () => {
provider.refresh();
updateTitle();
void followActiveTab();
};
/**
* The tab a command should act on: the row it was invoked from, else the row selected
* in the list, else whatever editor is active. Never depends on the tree having been
* rendered.
*/
const tabOf = (arg: unknown): vscode.Tab | undefined => {
const entry = arg as TabEntry | undefined;
return entry?.tab ?? view.selection[0]?.tab ??
vscode.window.tabGroups.activeTabGroup.activeTab;
};
const liveTabs = (): vscode.Tab[] =>
vscode.window.tabGroups.all.flatMap(group => group.tabs);
context.subscriptions.push(
view,
provider,
vscode.window.registerWebviewViewProvider(TabsView.viewId, view,
{ webviewOptions: { retainContextWhenHidden: true } }),
vscode.window.tabGroups.onDidChangeTabs(refresh),
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
vscode.window.onDidChangeActiveTextEditor(() => void followActiveTab()),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
clearProjectCache();
refresh();
@@ -262,60 +48,17 @@ export function activate(context: vscode.ExtensionContext): void {
}
}),
vscode.commands.registerCommand('verticalTabs.open', (arg?: unknown) => {
const tab = tabOf(arg);
return tab ? openTab(tab) : undefined;
}),
vscode.commands.registerCommand('verticalTabs.close', async (arg?: unknown) => {
const tab = tabOf(arg);
if (tab) {
await vscode.window.tabGroups.close(tab, true);
}
}),
vscode.commands.registerCommand('verticalTabs.closeOthers', async (arg?: unknown) => {
const tab = tabOf(arg);
if (!tab) {
return;
}
const others = liveTabs().filter(other => other !== tab && !other.isPinned);
if (others.length > 0) {
await vscode.window.tabGroups.close(others, true);
}
}),
vscode.commands.registerCommand('verticalTabs.pin', (arg?: unknown) => {
const tab = tabOf(arg);
return tab ? togglePin(tab) : undefined;
}),
vscode.commands.registerCommand('verticalTabs.unpin', (arg?: unknown) => {
const tab = tabOf(arg);
return tab ? togglePin(tab) : undefined;
}),
vscode.commands.registerCommand('verticalTabs.copyPath', async (arg?: unknown) => {
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
if (uri) {
await vscode.env.clipboard.writeText(uri.fsPath);
}
}),
vscode.commands.registerCommand('verticalTabs.revealInExplorer', async (arg?: unknown) => {
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
if (uri) {
await vscode.commands.executeCommand('revealInExplorer', uri);
}
}),
// Palette / keybinding entry points. The rows have their own buttons and context
// menu inside the webview, so these act on the active editor.
vscode.commands.registerCommand('verticalTabs.close', onActiveTab(closeTab)),
vscode.commands.registerCommand('verticalTabs.closeOthers', onActiveTab(closeOthers)),
vscode.commands.registerCommand('verticalTabs.togglePin', onActiveTab(togglePin)),
...(['open', 'project', 'name'] as Order[]).map(order =>
vscode.commands.registerCommand(
`verticalTabs.setOrder${order[0].toUpperCase()}${order.slice(1)}`,
() => config().update('order', order, vscode.ConfigurationTarget.Global))),
view.onDidChangeVisibility(event => {
if (event.visible) {
refresh();
}
}),
);
updateTitle();
}
export function deactivate(): void {
+6 -5
View File
@@ -83,20 +83,21 @@ export function clearProjectCache(): void {
}
/**
* Stable colour for a project name.
* Stable palette slot for a project name, 1..PALETTE_SIZE, or 0 for no project.
*
* Hashing the name rather than assigning in discovery order keeps a project the same
* colour across sessions and between windows, which is the point of colour coding.
* The webview turns the slot into `var(--vscode-verticalTabs-projectN)`, so the colours
* stay the contributed theme colours and remain overridable.
*/
export function projectColor(project: string | undefined): vscode.ThemeColor {
export function projectColorIndex(project: string | undefined): number {
if (!project) {
return new vscode.ThemeColor('verticalTabs.noProject');
return 0;
}
let hash = 0;
for (let i = 0; i < project.length; i++) {
// djb2, kept in 32 bits.
hash = ((hash << 5) - hash + project.charCodeAt(i)) | 0;
}
const index = Math.abs(hash) % PALETTE_SIZE + 1;
return new vscode.ThemeColor(`verticalTabs.project${index}`);
return Math.abs(hash) % PALETTE_SIZE + 1;
}
+24 -23
View File
@@ -2,7 +2,8 @@ import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { projectColor, projectOf } from '../../projects';
import { closeOthers, closeTab, togglePin } from '../../actions';
import { projectColorIndex, projectOf } from '../../projects';
import { TabEntry, buildEntries, describeEntry, sortEntries, tabUri } from '../../tabs';
/** Files in three different projects of the test solution. */
@@ -66,18 +67,18 @@ suite('Project resolution', () => {
});
test('project colours are stable and spread across the palette', () => {
const first = projectColor('Nerfed.Runtime');
assert.deepStrictEqual(projectColor('Nerfed.Runtime'), first,
const first = projectColorIndex('Nerfed.Runtime');
assert.strictEqual(projectColorIndex('Nerfed.Runtime'), first,
'the same project must always get the same colour');
assert.notDeepStrictEqual(projectColor('Nerfed.Editor'), first,
assert.notStrictEqual(projectColorIndex('Nerfed.Editor'), first,
'these two projects should not collide');
assert.ok(first >= 1 && first <= 12, `palette slot out of range: ${first}`);
const colours = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
.map(name => JSON.stringify(projectColor(`Project.${name}`))));
assert.ok(colours.size >= 5, `12 projects only used ${colours.size} colours`);
const slots = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
.map(name => projectColorIndex(`Project.${name}`)));
assert.ok(slots.size >= 6, `12 projects only used ${slots.size} slots`);
assert.deepStrictEqual(projectColor(undefined),
new vscode.ThemeColor('verticalTabs.noProject'));
assert.strictEqual(projectColorIndex(undefined), 0, 'no project means slot 0');
});
});
@@ -148,27 +149,27 @@ suite('Tab list', () => {
['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']);
});
test('the pin command pins the tab it is given, not just the active one', async () => {
test('togglePin pins and unpins a tab that is not the active one', async () => {
for (const file of FILES) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
const list = await entries();
const target = list.find(e => e.label === path.basename(FILES[0]));
const target = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(target, 'target tab not in the list');
assert.ok(!target.isPinned);
assert.ok(!target.tab.isPinned);
await vscode.commands.executeCommand('verticalTabs.pin', target);
await togglePin(target.tab);
await sleep(400);
assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label),
[path.basename(FILES[0])]);
const pinned = (await entries()).filter(e => e.isPinned).map(e => e.label);
assert.deepStrictEqual(pinned, [path.basename(FILES[0])]);
await vscode.commands.executeCommand('verticalTabs.unpin', target);
// The second call has to read the *live* pinned state, not the stale snapshot in
// `target` — getting that wrong made unpin a no-op.
await togglePin(target.tab);
await sleep(400);
assert.deepStrictEqual((await entries()).filter(e => e.isPinned), []);
assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label), []);
});
test('close closes the given tab, and close others closes the rest', async () => {
@@ -180,14 +181,14 @@ suite('Tab list', () => {
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
assert.ok(first);
await vscode.commands.executeCommand('verticalTabs.close', first);
await closeTab(first.tab);
await sleep(300);
assert.deepStrictEqual((await entries()).map(e => e.label),
[path.basename(FILES[0]), path.basename(FILES[2])]);
const keep = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(keep);
await vscode.commands.executeCommand('verticalTabs.closeOthers', keep);
await closeOthers(keep.tab);
await sleep(300);
assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]);
});
@@ -202,12 +203,12 @@ suite('Tab list', () => {
// Pin one tab, then close others from a different one.
const pin = (await entries()).find(e => e.label === path.basename(FILES[1]));
assert.ok(pin);
await vscode.commands.executeCommand('verticalTabs.pin', pin);
await togglePin(pin.tab);
await sleep(400);
const from = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(from, 'the tab to keep is gone');
await vscode.commands.executeCommand('verticalTabs.closeOthers', from);
await closeOthers(from.tab);
await sleep(400);
const left = (await entries()).map(e => e.label).sort();
+232
View File
@@ -0,0 +1,232 @@
import * as vscode from 'vscode';
import { projectColorIndex, projectOf } from './projects';
import { Order, TabEntry, buildEntries, tabUri } from './tabs';
/** One row, as the webview needs it. */
interface Row {
id: string;
label: string;
dir: string;
project: string;
/** Palette slot, 0 for no project. */
colorIndex: number;
uri: boolean;
isActive: boolean;
isDirty: boolean;
isPinned: boolean;
isPreview: boolean;
canActivate: boolean;
column: number | undefined;
tooltip: string;
}
function nonce(): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < 32; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
function config() {
return vscode.workspace.getConfiguration('verticalTabs');
}
/**
* The tab list, as a webview.
*
* A tree view cannot be styled — no borders, no row spacing, and a long file name is
* ellipsized in the middle of a narrow strip. This draws the rows itself: a coloured
* left border per project, controlled spacing, and a file extension that never
* ellipsizes away.
*/
export class TabsView implements vscode.WebviewViewProvider {
static readonly viewId = 'verticalTabs.tabs';
private view: vscode.WebviewView | undefined;
/** Row id -> the tab it stands for, rebuilt on every post. */
private tabs = new Map<string, vscode.Tab>();
private entries: TabEntry[] = [];
private ready = false;
private queued = false;
constructor(
private readonly extensionUri: vscode.Uri,
private readonly actions: {
open(tab: vscode.Tab): Promise<void>;
close(tab: vscode.Tab): Promise<void>;
closeOthers(tab: vscode.Tab): Promise<void>;
togglePin(tab: vscode.Tab): Promise<void>;
copyPath(tab: vscode.Tab): Promise<void>;
reveal(tab: vscode.Tab): Promise<void>;
},
) { }
get visible(): boolean {
return this.view?.visible ?? false;
}
resolveWebviewView(view: vscode.WebviewView): void {
this.view = view;
this.ready = false;
view.webview.options = {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'media')],
};
view.webview.html = this.html(view.webview);
view.webview.onDidReceiveMessage(message => void this.receive(message));
view.onDidChangeVisibility(() => {
if (view.visible) {
void this.refresh();
}
});
view.onDidDispose(() => {
this.view = undefined;
this.ready = false;
});
}
/** Rebuilds the list and pushes it to the webview. */
async refresh(followActive = true): Promise<void> {
if (!this.view) {
return;
}
if (!this.ready) {
this.queued = true;
return;
}
const cfg = config();
const groups = cfg.get<boolean>('allGroups', true)
? vscode.window.tabGroups.all
: [vscode.window.tabGroups.activeTabGroup];
const projects = new Map<string, string | undefined>();
const uris = new Map<string, vscode.Uri>();
for (const group of groups) {
for (const tab of group.tabs) {
const uri = tabUri(tab);
if (uri) {
uris.set(uri.toString(), uri);
}
}
}
await Promise.all([...uris].map(async ([key, uri]) => {
projects.set(key, await projectOf(uri));
}));
this.entries = buildEntries(groups, projects, {
order: cfg.get<Order>('order', 'open'),
pinnedFirst: cfg.get<boolean>('pinnedFirst', true),
});
this.tabs = new Map(this.entries.map(entry => [entry.id, entry.tab]));
const showColumn = cfg.get<boolean>('allGroups', true) &&
vscode.window.tabGroups.all.length > 1;
void this.view.webview.postMessage({
type: 'rows',
followActive,
showProject: cfg.get<boolean>('showProject', true),
showDirectory: cfg.get<boolean>('showDirectory', true),
showColumn,
rowSpacing: cfg.get<number>('rowSpacing', 2),
borderWidth: cfg.get<number>('borderWidth', 3),
rows: this.entries.map((entry): Row => ({
id: entry.id,
label: entry.label,
dir: entry.dir,
project: entry.project ?? '',
colorIndex: cfg.get<boolean>('colorByProject', true)
? projectColorIndex(entry.project)
: 0,
uri: entry.uri !== undefined,
isActive: entry.isActive,
isDirty: entry.isDirty,
isPinned: entry.isPinned,
isPreview: entry.isPreview,
canActivate: entry.canActivate,
column: entry.column,
tooltip: this.tooltip(entry),
})),
});
const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
this.view.description = count > 0 ? String(count) : undefined;
}
private tooltip(entry: TabEntry): string {
const lines: string[] = [entry.label];
if (entry.uri) {
lines.push(vscode.workspace.asRelativePath(entry.uri, true));
}
if (entry.project) {
lines.push(`Project: ${entry.project}`);
}
if (entry.isDirty) {
lines.push('Unsaved changes');
}
if (!entry.canActivate) {
lines.push('This kind of tab cannot be focused from here.');
}
return lines.join('\n');
}
private async receive(message: { type: string; id?: string }): Promise<void> {
if (message.type === 'ready') {
this.ready = true;
if (this.queued) {
this.queued = false;
}
await this.refresh();
return;
}
// Resolve against live tab state: the row was drawn from a snapshot.
const tab = message.id ? this.tabs.get(message.id) : undefined;
if (!tab) {
return;
}
// A tab that has since been closed is no longer in any group.
if (!vscode.window.tabGroups.all.some(group => group.tabs.includes(tab))) {
await this.refresh();
return;
}
switch (message.type) {
case 'open': return this.actions.open(tab);
case 'close': return this.actions.close(tab);
case 'closeOthers': return this.actions.closeOthers(tab);
case 'togglePin': return this.actions.togglePin(tab);
case 'copyPath': return this.actions.copyPath(tab);
case 'reveal': return this.actions.reveal(tab);
}
}
private html(webview: vscode.Webview): string {
const asset = (name: string) =>
webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'media', name));
const token = nonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ` +
`style-src ${webview.cspSource}; script-src 'nonce-${token}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${asset('tabs.css')}" rel="stylesheet">
<title>Tabs</title>
</head>
<body>
<div id="list" role="listbox" aria-label="Open editors" tabindex="0"></div>
<div id="empty" hidden>No editors are open.</div>
<div id="menu" role="menu" hidden></div>
<script nonce="${token}" src="${asset('tabs.js')}"></script>
</body>
</html>`;
}
}