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:
+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' });
|
||||
})();
|
||||
Reference in New Issue
Block a user