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:
+252
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user