On a real project the name lost the width fight and the trailing text read "MyGame.Runtime MyGame.Runtime" — the directory and the project were the same string, printed twice. - The detail text now has a shrink factor of 1000 against the name's 1, plus a 45% cap, so it gives up its width first and the name is the last thing to ellipsize. - A directory that already begins with the project name says everything the project name would, so only one of the two is shown. - verticalTabs.showDirectory became never / duplicates / always, defaulting to duplicates: width goes to a directory only when two open tabs share a file name, which is the usual reason to want it. Turning showProject off as well gives the name the whole row. - The trailing text is composed in the extension now rather than the webview, so the deduplication is unit-testable and there is one implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
11 KiB
JavaScript
321 lines
11 KiB
JavaScript
// @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);
|
|
|
|
if (row.detail) {
|
|
const detail = document.createElement('span');
|
|
detail.className = 'detail';
|
|
detail.textContent = row.detail;
|
|
div.appendChild(detail);
|
|
}
|
|
|
|
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' });
|
|
})();
|