Files
maxandClaude Opus 5 925e55d619 Give the file name the row, and stop repeating the project
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>
2026-09-07 18:42:14 +02:00

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' });
})();