// @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: '', pin: '', }; 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' }); })();