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