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' });
|
||||||
|
})();
|
||||||
+24
-115
@@ -28,69 +28,41 @@
|
|||||||
{
|
{
|
||||||
"id": "verticalTabs.tabs",
|
"id": "verticalTabs.tabs",
|
||||||
"name": "Tabs",
|
"name": "Tabs",
|
||||||
|
"type": "webview",
|
||||||
"contextualTitle": "Tabs",
|
"contextualTitle": "Tabs",
|
||||||
"visibility": "visible"
|
"visibility": "visible"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"viewsWelcome": [
|
|
||||||
{
|
|
||||||
"view": "verticalTabs.tabs",
|
|
||||||
"contents": "No editors are open."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
|
||||||
"command": "verticalTabs.open",
|
|
||||||
"title": "Open Tab",
|
|
||||||
"category": "Tabs"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.close",
|
"command": "verticalTabs.close",
|
||||||
"title": "Close",
|
"title": "Close Active Tab",
|
||||||
"category": "Tabs",
|
"category": "Tabs"
|
||||||
"icon": "$(close)"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.closeOthers",
|
"command": "verticalTabs.closeOthers",
|
||||||
"title": "Close Others",
|
"title": "Close Other Tabs",
|
||||||
"category": "Tabs"
|
"category": "Tabs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.pin",
|
"command": "verticalTabs.togglePin",
|
||||||
"title": "Pin",
|
"title": "Pin or Unpin Active Tab",
|
||||||
"category": "Tabs",
|
|
||||||
"icon": "$(pin)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.unpin",
|
|
||||||
"title": "Unpin",
|
|
||||||
"category": "Tabs",
|
|
||||||
"icon": "$(pinned)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.copyPath",
|
|
||||||
"title": "Copy Path",
|
|
||||||
"category": "Tabs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.revealInExplorer",
|
|
||||||
"title": "Reveal in Explorer View",
|
|
||||||
"category": "Tabs"
|
"category": "Tabs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.setOrderOpen",
|
"command": "verticalTabs.setOrderOpen",
|
||||||
"title": "Sort by Open Order",
|
"title": "Sort Tabs by Open Order",
|
||||||
"category": "Tabs"
|
"category": "Tabs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.setOrderProject",
|
"command": "verticalTabs.setOrderProject",
|
||||||
"title": "Sort by Project",
|
"title": "Sort Tabs by Project",
|
||||||
"category": "Tabs"
|
"category": "Tabs"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "verticalTabs.setOrderName",
|
"command": "verticalTabs.setOrderName",
|
||||||
"title": "Sort by File Name",
|
"title": "Sort Tabs by File Name",
|
||||||
"category": "Tabs"
|
"category": "Tabs"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -111,83 +83,6 @@
|
|||||||
"when": "view == verticalTabs.tabs",
|
"when": "view == verticalTabs.tabs",
|
||||||
"group": "1_order@3"
|
"group": "1_order@3"
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"view/item/context": [
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.pin",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
|
|
||||||
"group": "inline@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.unpin",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
|
|
||||||
"group": "inline@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.close",
|
|
||||||
"when": "view == verticalTabs.tabs",
|
|
||||||
"group": "inline@2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.pin",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
|
|
||||||
"group": "1_state@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.unpin",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
|
|
||||||
"group": "1_state@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.close",
|
|
||||||
"when": "view == verticalTabs.tabs",
|
|
||||||
"group": "2_close@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.closeOthers",
|
|
||||||
"when": "view == verticalTabs.tabs",
|
|
||||||
"group": "2_close@2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.copyPath",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
|
|
||||||
"group": "3_path@1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.revealInExplorer",
|
|
||||||
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
|
|
||||||
"group": "3_path@2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"commandPalette": [
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.open",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.close",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.closeOthers",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.pin",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.unpin",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.copyPath",
|
|
||||||
"when": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "verticalTabs.revealInExplorer",
|
|
||||||
"when": "false"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configuration": {
|
"configuration": {
|
||||||
@@ -226,7 +121,7 @@
|
|||||||
"verticalTabs.colorByProject": {
|
"verticalTabs.colorByProject": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Colour each tab's icon by its containing project."
|
"description": "Colour each row's left border by its containing project."
|
||||||
},
|
},
|
||||||
"verticalTabs.allGroups": {
|
"verticalTabs.allGroups": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -251,6 +146,20 @@
|
|||||||
"build.gradle"
|
"build.gradle"
|
||||||
],
|
],
|
||||||
"description": "File names or extensions that mark a project root. The nearest match above a file names its project."
|
"description": "File names or extensions that mark a project root. The nearest match above a file names its project."
|
||||||
|
},
|
||||||
|
"verticalTabs.rowSpacing": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 2,
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 10,
|
||||||
|
"description": "Pixels of vertical space between rows."
|
||||||
|
},
|
||||||
|
"verticalTabs.borderWidth": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 3,
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 8,
|
||||||
|
"description": "Width in pixels of the project colour border on the left of each row."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { canActivate, tabUri } from './tabs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the list can do to a tab.
|
||||||
|
*
|
||||||
|
* Everything here takes a live `vscode.Tab`. The rows the view draws are snapshots, and
|
||||||
|
* acting on stale state silently does the wrong thing (or nothing at all) — an earlier
|
||||||
|
* version compared a snapshot's `isPinned` and so never unpinned anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings a tab to the front by re-opening its resource in its own group. There is no API
|
||||||
|
* to activate a tab directly, which is also why webview tabs cannot be focused: they
|
||||||
|
* have no resource to re-open.
|
||||||
|
*/
|
||||||
|
export async function openTab(tab: vscode.Tab): Promise<void> {
|
||||||
|
const uri = tabUri(tab);
|
||||||
|
if (!uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const column = tab.group.viewColumn ?? vscode.ViewColumn.Active;
|
||||||
|
const input = tab.input;
|
||||||
|
|
||||||
|
if (input instanceof vscode.TabInputNotebook) {
|
||||||
|
const notebook = await vscode.workspace.openNotebookDocument(uri);
|
||||||
|
await vscode.window.showNotebookDocument(notebook, { viewColumn: column, preview: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input instanceof vscode.TabInputTextDiff) {
|
||||||
|
await vscode.commands.executeCommand('vscode.diff', input.original, input.modified,
|
||||||
|
tab.label, { viewColumn: column, preview: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input instanceof vscode.TabInputCustom) {
|
||||||
|
await vscode.commands.executeCommand('vscode.openWith', uri, input.viewType,
|
||||||
|
{ viewColumn: column, preview: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeTab(tab: vscode.Tab): Promise<void> {
|
||||||
|
await vscode.window.tabGroups.close(tab, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code can only pin the *active* editor, so pinning another tab means focusing it
|
||||||
|
* first. The focus lands where the user clicked anyway, which is what they expect.
|
||||||
|
*/
|
||||||
|
export async function togglePin(tab: vscode.Tab): Promise<void> {
|
||||||
|
const pinned = !tab.isPinned;
|
||||||
|
if (!tab.isActive) {
|
||||||
|
if (!canActivate(tab)) {
|
||||||
|
void vscode.window.showInformationMessage(
|
||||||
|
`Cannot pin “${tab.label}”: this kind of tab has to be focused first.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openTab(tab);
|
||||||
|
}
|
||||||
|
await vscode.commands.executeCommand(
|
||||||
|
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes everything except `tab`, keeping pinned tabs the way a tab bar does. */
|
||||||
|
export async function closeOthers(tab: vscode.Tab): Promise<void> {
|
||||||
|
const others = vscode.window.tabGroups.all
|
||||||
|
.flatMap(group => group.tabs)
|
||||||
|
.filter(other => other !== tab && !other.isPinned);
|
||||||
|
if (others.length > 0) {
|
||||||
|
await vscode.window.tabGroups.close(others, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPath(tab: vscode.Tab): Promise<void> {
|
||||||
|
const uri = tabUri(tab);
|
||||||
|
if (uri) {
|
||||||
|
await vscode.env.clipboard.writeText(uri.fsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revealInExplorer(tab: vscode.Tab): Promise<void> {
|
||||||
|
const uri = tabUri(tab);
|
||||||
|
if (uri) {
|
||||||
|
await vscode.commands.executeCommand('revealInExplorer', uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-282
@@ -1,254 +1,40 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { clearProjectCache, projectColor, projectOf } from './projects';
|
import { closeOthers, closeTab, copyPath, openTab, revealInExplorer, togglePin } from './actions';
|
||||||
import { Order, TabEntry, buildEntries, canActivate, describeEntry, tabUri } from './tabs';
|
import { clearProjectCache } from './projects';
|
||||||
|
import { Order } from './tabs';
|
||||||
const VIEW_ID = 'verticalTabs.tabs';
|
import { TabsView } from './view';
|
||||||
|
|
||||||
function config() {
|
function config() {
|
||||||
return vscode.workspace.getConfiguration('verticalTabs');
|
return vscode.workspace.getConfiguration('verticalTabs');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The open tabs as a flat list.
|
|
||||||
*
|
|
||||||
* A `TreeDataProvider` with only root items renders as a list — there is no separate
|
|
||||||
* list API — so nothing here has children.
|
|
||||||
*/
|
|
||||||
class TabsProvider implements vscode.TreeDataProvider<TabEntry> {
|
|
||||||
private readonly changed = new vscode.EventEmitter<void>();
|
|
||||||
readonly onDidChangeTreeData = this.changed.event;
|
|
||||||
private entries: TabEntry[] = [];
|
|
||||||
|
|
||||||
refresh(): void {
|
|
||||||
this.changed.fire();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The entry for a tab, so commands can act on the current list. */
|
|
||||||
find(predicate: (entry: TabEntry) => boolean): TabEntry | undefined {
|
|
||||||
return this.entries.find(predicate);
|
|
||||||
}
|
|
||||||
|
|
||||||
get all(): readonly TabEntry[] {
|
|
||||||
return this.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getChildren(element?: TabEntry): Promise<TabEntry[]> {
|
|
||||||
if (element) {
|
|
||||||
return []; // flat list
|
|
||||||
}
|
|
||||||
const cfg = config();
|
|
||||||
const groups = cfg.get<boolean>('allGroups', true)
|
|
||||||
? vscode.window.tabGroups.all
|
|
||||||
: [vscode.window.tabGroups.activeTabGroup];
|
|
||||||
|
|
||||||
// Resolve every project once, then build the list synchronously.
|
|
||||||
const projects = new Map<string, string | undefined>();
|
|
||||||
if (cfg.get<boolean>('showProject', true) || cfg.get<boolean>('colorByProject', true) ||
|
|
||||||
cfg.get<Order>('order', 'open') === 'project') {
|
|
||||||
const uris = new Map<string, vscode.Uri>();
|
|
||||||
for (const group of groups) {
|
|
||||||
for (const tab of group.tabs) {
|
|
||||||
const uri = tabUri(tab);
|
|
||||||
if (uri) {
|
|
||||||
uris.set(uri.toString(), uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all([...uris].map(async ([key, uri]) => {
|
|
||||||
projects.set(key, await projectOf(uri));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.entries = buildEntries(groups, projects, {
|
|
||||||
order: cfg.get<Order>('order', 'open'),
|
|
||||||
pinnedFirst: cfg.get<boolean>('pinnedFirst', true),
|
|
||||||
});
|
|
||||||
return this.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): TabEntry | undefined {
|
|
||||||
return undefined; // required for TreeView.reveal on a flat list
|
|
||||||
}
|
|
||||||
|
|
||||||
getTreeItem(entry: TabEntry): vscode.TreeItem {
|
|
||||||
const cfg = config();
|
|
||||||
const item = new vscode.TreeItem(entry.label);
|
|
||||||
item.id = entry.id;
|
|
||||||
item.description = describeEntry(entry, {
|
|
||||||
showProject: cfg.get<boolean>('showProject', true),
|
|
||||||
showDirectory: cfg.get<boolean>('showDirectory', true),
|
|
||||||
showColumn: vscode.window.tabGroups.all.length > 1 && cfg.get<boolean>('allGroups', true),
|
|
||||||
});
|
|
||||||
|
|
||||||
// resourceUri gives the file icon and the theme's file-name colouring; the
|
|
||||||
// project colour rides on the icon instead so the two do not fight.
|
|
||||||
if (entry.uri) {
|
|
||||||
item.resourceUri = entry.uri;
|
|
||||||
}
|
|
||||||
if (cfg.get<boolean>('colorByProject', true) && entry.uri) {
|
|
||||||
item.iconPath = new vscode.ThemeIcon(
|
|
||||||
entry.isDirty ? 'circle-filled' : 'circle-outline', projectColor(entry.project));
|
|
||||||
} else if (entry.isDirty) {
|
|
||||||
item.iconPath = new vscode.ThemeIcon('circle-filled');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tokens have to be unambiguous as substrings: a `when` clause matching
|
|
||||||
// /pinned/ would also match "unpinned", showing both pin and unpin at once.
|
|
||||||
const state = [
|
|
||||||
entry.isPinned ? 'pin:on' : 'pin:off',
|
|
||||||
entry.uri ? 'file' : 'other',
|
|
||||||
entry.canActivate ? 'activatable' : 'inert',
|
|
||||||
];
|
|
||||||
item.contextValue = state.join(' ');
|
|
||||||
|
|
||||||
const tooltip = new vscode.MarkdownString();
|
|
||||||
tooltip.appendMarkdown(`**${entry.label}**${entry.isDirty ? ' — unsaved' : ''}\n\n`);
|
|
||||||
if (entry.uri) {
|
|
||||||
tooltip.appendMarkdown(`${vscode.workspace.asRelativePath(entry.uri, true)}\n\n`);
|
|
||||||
}
|
|
||||||
if (entry.project) {
|
|
||||||
tooltip.appendMarkdown(`Project: ${entry.project}\n\n`);
|
|
||||||
}
|
|
||||||
if (!entry.canActivate) {
|
|
||||||
tooltip.appendMarkdown('_This kind of tab cannot be focused from here._');
|
|
||||||
}
|
|
||||||
item.tooltip = tooltip;
|
|
||||||
|
|
||||||
if (entry.canActivate) {
|
|
||||||
item.command = {
|
|
||||||
command: 'verticalTabs.open',
|
|
||||||
title: 'Open Tab',
|
|
||||||
arguments: [entry],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.changed.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brings a tab to the front by re-opening its resource in its own group. There is no
|
|
||||||
* API to activate a tab directly.
|
|
||||||
*
|
|
||||||
* Everything below takes a live `vscode.Tab` rather than a `TabEntry`: an entry is a
|
|
||||||
* snapshot taken when the row was drawn, and acting on stale state silently does the
|
|
||||||
* wrong thing (or nothing).
|
|
||||||
*/
|
|
||||||
async function openTab(tab: vscode.Tab): Promise<void> {
|
|
||||||
const uri = tabUri(tab);
|
|
||||||
if (!uri) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const column = tab.group.viewColumn ?? vscode.ViewColumn.Active;
|
|
||||||
const input = tab.input;
|
|
||||||
|
|
||||||
if (input instanceof vscode.TabInputNotebook) {
|
|
||||||
const notebook = await vscode.workspace.openNotebookDocument(uri);
|
|
||||||
await vscode.window.showNotebookDocument(notebook, { viewColumn: column, preview: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (input instanceof vscode.TabInputTextDiff) {
|
|
||||||
await vscode.commands.executeCommand('vscode.diff', input.original, input.modified,
|
|
||||||
tab.label, { viewColumn: column, preview: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (input instanceof vscode.TabInputCustom) {
|
|
||||||
await vscode.commands.executeCommand('vscode.openWith', uri, input.viewType,
|
|
||||||
{ viewColumn: column, preview: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const doc = await vscode.workspace.openTextDocument(uri);
|
|
||||||
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* VS Code can only pin the *active* editor, so pinning another tab means focusing it
|
|
||||||
* first. The focus lands where the user clicked anyway, which is what they expect.
|
|
||||||
*/
|
|
||||||
async function togglePin(tab: vscode.Tab): Promise<void> {
|
|
||||||
const pinned = !tab.isPinned;
|
|
||||||
if (!tab.isActive) {
|
|
||||||
if (!canActivate(tab)) {
|
|
||||||
void vscode.window.showInformationMessage(
|
|
||||||
`Cannot pin “${tab.label}”: this kind of tab has to be focused first.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await openTab(tab);
|
|
||||||
}
|
|
||||||
// Both the pin and unpin commands land here: the two exist only so the inline
|
|
||||||
// button's icon can reflect the current state.
|
|
||||||
await vscode.commands.executeCommand(
|
|
||||||
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const provider = new TabsProvider();
|
const view = new TabsView(context.extensionUri, {
|
||||||
const view = vscode.window.createTreeView(VIEW_ID, {
|
open: openTab,
|
||||||
treeDataProvider: provider,
|
close: closeTab,
|
||||||
showCollapseAll: false,
|
closeOthers,
|
||||||
|
togglePin,
|
||||||
|
copyPath,
|
||||||
|
reveal: revealInExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateTitle = () => {
|
const refresh = () => void view.refresh();
|
||||||
const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
|
|
||||||
view.description = count > 0 ? String(count) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/** The tab a palette command acts on. */
|
||||||
* Keeps the list's selection on whatever tab is active.
|
const activeTab = () => vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||||
*
|
const onActiveTab = (action: (tab: vscode.Tab) => Promise<void>) => async () => {
|
||||||
* The entries hold a snapshot of `isActive`, and after a refresh the tree has not
|
const tab = activeTab();
|
||||||
* necessarily asked for children yet — so rebuild the list first and match against
|
if (tab) {
|
||||||
* the live active tab rather than a stale flag.
|
await action(tab);
|
||||||
*/
|
|
||||||
const followActiveTab = async () => {
|
|
||||||
if (!view.visible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab;
|
|
||||||
if (!activeTab) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entries = await provider.getChildren();
|
|
||||||
const active = entries.find(entry => entry.tab === activeTab);
|
|
||||||
if (active && !view.selection.some(entry => entry.id === active.id)) {
|
|
||||||
try {
|
|
||||||
await view.reveal(active, { select: true, focus: false });
|
|
||||||
} catch {
|
|
||||||
// reveal throws if the item is gone; the next refresh will sort it out.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
provider.refresh();
|
|
||||||
updateTitle();
|
|
||||||
void followActiveTab();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tab a command should act on: the row it was invoked from, else the row selected
|
|
||||||
* in the list, else whatever editor is active. Never depends on the tree having been
|
|
||||||
* rendered.
|
|
||||||
*/
|
|
||||||
const tabOf = (arg: unknown): vscode.Tab | undefined => {
|
|
||||||
const entry = arg as TabEntry | undefined;
|
|
||||||
return entry?.tab ?? view.selection[0]?.tab ??
|
|
||||||
vscode.window.tabGroups.activeTabGroup.activeTab;
|
|
||||||
};
|
|
||||||
|
|
||||||
const liveTabs = (): vscode.Tab[] =>
|
|
||||||
vscode.window.tabGroups.all.flatMap(group => group.tabs);
|
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
view,
|
vscode.window.registerWebviewViewProvider(TabsView.viewId, view,
|
||||||
provider,
|
{ webviewOptions: { retainContextWhenHidden: true } }),
|
||||||
|
|
||||||
vscode.window.tabGroups.onDidChangeTabs(refresh),
|
vscode.window.tabGroups.onDidChangeTabs(refresh),
|
||||||
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
|
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
|
||||||
vscode.window.onDidChangeActiveTextEditor(() => void followActiveTab()),
|
|
||||||
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
||||||
clearProjectCache();
|
clearProjectCache();
|
||||||
refresh();
|
refresh();
|
||||||
@@ -262,60 +48,17 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
vscode.commands.registerCommand('verticalTabs.open', (arg?: unknown) => {
|
// Palette / keybinding entry points. The rows have their own buttons and context
|
||||||
const tab = tabOf(arg);
|
// menu inside the webview, so these act on the active editor.
|
||||||
return tab ? openTab(tab) : undefined;
|
vscode.commands.registerCommand('verticalTabs.close', onActiveTab(closeTab)),
|
||||||
}),
|
vscode.commands.registerCommand('verticalTabs.closeOthers', onActiveTab(closeOthers)),
|
||||||
vscode.commands.registerCommand('verticalTabs.close', async (arg?: unknown) => {
|
vscode.commands.registerCommand('verticalTabs.togglePin', onActiveTab(togglePin)),
|
||||||
const tab = tabOf(arg);
|
|
||||||
if (tab) {
|
|
||||||
await vscode.window.tabGroups.close(tab, true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('verticalTabs.closeOthers', async (arg?: unknown) => {
|
|
||||||
const tab = tabOf(arg);
|
|
||||||
if (!tab) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const others = liveTabs().filter(other => other !== tab && !other.isPinned);
|
|
||||||
if (others.length > 0) {
|
|
||||||
await vscode.window.tabGroups.close(others, true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('verticalTabs.pin', (arg?: unknown) => {
|
|
||||||
const tab = tabOf(arg);
|
|
||||||
return tab ? togglePin(tab) : undefined;
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('verticalTabs.unpin', (arg?: unknown) => {
|
|
||||||
const tab = tabOf(arg);
|
|
||||||
return tab ? togglePin(tab) : undefined;
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('verticalTabs.copyPath', async (arg?: unknown) => {
|
|
||||||
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
|
|
||||||
if (uri) {
|
|
||||||
await vscode.env.clipboard.writeText(uri.fsPath);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
vscode.commands.registerCommand('verticalTabs.revealInExplorer', async (arg?: unknown) => {
|
|
||||||
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
|
|
||||||
if (uri) {
|
|
||||||
await vscode.commands.executeCommand('revealInExplorer', uri);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
...(['open', 'project', 'name'] as Order[]).map(order =>
|
...(['open', 'project', 'name'] as Order[]).map(order =>
|
||||||
vscode.commands.registerCommand(
|
vscode.commands.registerCommand(
|
||||||
`verticalTabs.setOrder${order[0].toUpperCase()}${order.slice(1)}`,
|
`verticalTabs.setOrder${order[0].toUpperCase()}${order.slice(1)}`,
|
||||||
() => config().update('order', order, vscode.ConfigurationTarget.Global))),
|
() => config().update('order', order, vscode.ConfigurationTarget.Global))),
|
||||||
|
|
||||||
view.onDidChangeVisibility(event => {
|
|
||||||
if (event.visible) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
updateTitle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate(): void {
|
export function deactivate(): void {
|
||||||
|
|||||||
+6
-5
@@ -83,20 +83,21 @@ export function clearProjectCache(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable colour for a project name.
|
* Stable palette slot for a project name, 1..PALETTE_SIZE, or 0 for no project.
|
||||||
*
|
*
|
||||||
* Hashing the name rather than assigning in discovery order keeps a project the same
|
* Hashing the name rather than assigning in discovery order keeps a project the same
|
||||||
* colour across sessions and between windows, which is the point of colour coding.
|
* colour across sessions and between windows, which is the point of colour coding.
|
||||||
|
* The webview turns the slot into `var(--vscode-verticalTabs-projectN)`, so the colours
|
||||||
|
* stay the contributed theme colours and remain overridable.
|
||||||
*/
|
*/
|
||||||
export function projectColor(project: string | undefined): vscode.ThemeColor {
|
export function projectColorIndex(project: string | undefined): number {
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return new vscode.ThemeColor('verticalTabs.noProject');
|
return 0;
|
||||||
}
|
}
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < project.length; i++) {
|
for (let i = 0; i < project.length; i++) {
|
||||||
// djb2, kept in 32 bits.
|
// djb2, kept in 32 bits.
|
||||||
hash = ((hash << 5) - hash + project.charCodeAt(i)) | 0;
|
hash = ((hash << 5) - hash + project.charCodeAt(i)) | 0;
|
||||||
}
|
}
|
||||||
const index = Math.abs(hash) % PALETTE_SIZE + 1;
|
return Math.abs(hash) % PALETTE_SIZE + 1;
|
||||||
return new vscode.ThemeColor(`verticalTabs.project${index}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-23
@@ -2,7 +2,8 @@ import * as assert from 'assert';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
import { projectColor, projectOf } from '../../projects';
|
import { closeOthers, closeTab, togglePin } from '../../actions';
|
||||||
|
import { projectColorIndex, projectOf } from '../../projects';
|
||||||
import { TabEntry, buildEntries, describeEntry, sortEntries, tabUri } from '../../tabs';
|
import { TabEntry, buildEntries, describeEntry, sortEntries, tabUri } from '../../tabs';
|
||||||
|
|
||||||
/** Files in three different projects of the test solution. */
|
/** Files in three different projects of the test solution. */
|
||||||
@@ -66,18 +67,18 @@ suite('Project resolution', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('project colours are stable and spread across the palette', () => {
|
test('project colours are stable and spread across the palette', () => {
|
||||||
const first = projectColor('Nerfed.Runtime');
|
const first = projectColorIndex('Nerfed.Runtime');
|
||||||
assert.deepStrictEqual(projectColor('Nerfed.Runtime'), first,
|
assert.strictEqual(projectColorIndex('Nerfed.Runtime'), first,
|
||||||
'the same project must always get the same colour');
|
'the same project must always get the same colour');
|
||||||
assert.notDeepStrictEqual(projectColor('Nerfed.Editor'), first,
|
assert.notStrictEqual(projectColorIndex('Nerfed.Editor'), first,
|
||||||
'these two projects should not collide');
|
'these two projects should not collide');
|
||||||
|
assert.ok(first >= 1 && first <= 12, `palette slot out of range: ${first}`);
|
||||||
|
|
||||||
const colours = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
const slots = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
||||||
.map(name => JSON.stringify(projectColor(`Project.${name}`))));
|
.map(name => projectColorIndex(`Project.${name}`)));
|
||||||
assert.ok(colours.size >= 5, `12 projects only used ${colours.size} colours`);
|
assert.ok(slots.size >= 6, `12 projects only used ${slots.size} slots`);
|
||||||
|
|
||||||
assert.deepStrictEqual(projectColor(undefined),
|
assert.strictEqual(projectColorIndex(undefined), 0, 'no project means slot 0');
|
||||||
new vscode.ThemeColor('verticalTabs.noProject'));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,27 +149,27 @@ suite('Tab list', () => {
|
|||||||
['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']);
|
['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the pin command pins the tab it is given, not just the active one', async () => {
|
test('togglePin pins and unpins a tab that is not the active one', async () => {
|
||||||
for (const file of FILES) {
|
for (const file of FILES) {
|
||||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||||
{ preview: false });
|
{ preview: false });
|
||||||
}
|
}
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
|
|
||||||
const list = await entries();
|
const target = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
||||||
const target = list.find(e => e.label === path.basename(FILES[0]));
|
|
||||||
assert.ok(target, 'target tab not in the list');
|
assert.ok(target, 'target tab not in the list');
|
||||||
assert.ok(!target.isPinned);
|
assert.ok(!target.tab.isPinned);
|
||||||
|
|
||||||
await vscode.commands.executeCommand('verticalTabs.pin', target);
|
await togglePin(target.tab);
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
|
assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label),
|
||||||
|
[path.basename(FILES[0])]);
|
||||||
|
|
||||||
const pinned = (await entries()).filter(e => e.isPinned).map(e => e.label);
|
// The second call has to read the *live* pinned state, not the stale snapshot in
|
||||||
assert.deepStrictEqual(pinned, [path.basename(FILES[0])]);
|
// `target` — getting that wrong made unpin a no-op.
|
||||||
|
await togglePin(target.tab);
|
||||||
await vscode.commands.executeCommand('verticalTabs.unpin', target);
|
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
assert.deepStrictEqual((await entries()).filter(e => e.isPinned), []);
|
assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('close closes the given tab, and close others closes the rest', async () => {
|
test('close closes the given tab, and close others closes the rest', async () => {
|
||||||
@@ -180,14 +181,14 @@ suite('Tab list', () => {
|
|||||||
|
|
||||||
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
||||||
assert.ok(first);
|
assert.ok(first);
|
||||||
await vscode.commands.executeCommand('verticalTabs.close', first);
|
await closeTab(first.tab);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
assert.deepStrictEqual((await entries()).map(e => e.label),
|
assert.deepStrictEqual((await entries()).map(e => e.label),
|
||||||
[path.basename(FILES[0]), path.basename(FILES[2])]);
|
[path.basename(FILES[0]), path.basename(FILES[2])]);
|
||||||
|
|
||||||
const keep = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
const keep = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
||||||
assert.ok(keep);
|
assert.ok(keep);
|
||||||
await vscode.commands.executeCommand('verticalTabs.closeOthers', keep);
|
await closeOthers(keep.tab);
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]);
|
assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]);
|
||||||
});
|
});
|
||||||
@@ -202,12 +203,12 @@ suite('Tab list', () => {
|
|||||||
// Pin one tab, then close others from a different one.
|
// Pin one tab, then close others from a different one.
|
||||||
const pin = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
const pin = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
||||||
assert.ok(pin);
|
assert.ok(pin);
|
||||||
await vscode.commands.executeCommand('verticalTabs.pin', pin);
|
await togglePin(pin.tab);
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
|
|
||||||
const from = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
const from = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
||||||
assert.ok(from, 'the tab to keep is gone');
|
assert.ok(from, 'the tab to keep is gone');
|
||||||
await vscode.commands.executeCommand('verticalTabs.closeOthers', from);
|
await closeOthers(from.tab);
|
||||||
await sleep(400);
|
await sleep(400);
|
||||||
|
|
||||||
const left = (await entries()).map(e => e.label).sort();
|
const left = (await entries()).map(e => e.label).sort();
|
||||||
|
|||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { projectColorIndex, projectOf } from './projects';
|
||||||
|
import { Order, TabEntry, buildEntries, tabUri } from './tabs';
|
||||||
|
|
||||||
|
/** One row, as the webview needs it. */
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
dir: string;
|
||||||
|
project: string;
|
||||||
|
/** Palette slot, 0 for no project. */
|
||||||
|
colorIndex: number;
|
||||||
|
uri: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
isPreview: boolean;
|
||||||
|
canActivate: boolean;
|
||||||
|
column: number | undefined;
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonce(): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function config() {
|
||||||
|
return vscode.workspace.getConfiguration('verticalTabs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab list, as a webview.
|
||||||
|
*
|
||||||
|
* A tree view cannot be styled — no borders, no row spacing, and a long file name is
|
||||||
|
* ellipsized in the middle of a narrow strip. This draws the rows itself: a coloured
|
||||||
|
* left border per project, controlled spacing, and a file extension that never
|
||||||
|
* ellipsizes away.
|
||||||
|
*/
|
||||||
|
export class TabsView implements vscode.WebviewViewProvider {
|
||||||
|
static readonly viewId = 'verticalTabs.tabs';
|
||||||
|
|
||||||
|
private view: vscode.WebviewView | undefined;
|
||||||
|
/** Row id -> the tab it stands for, rebuilt on every post. */
|
||||||
|
private tabs = new Map<string, vscode.Tab>();
|
||||||
|
private entries: TabEntry[] = [];
|
||||||
|
private ready = false;
|
||||||
|
private queued = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly extensionUri: vscode.Uri,
|
||||||
|
private readonly actions: {
|
||||||
|
open(tab: vscode.Tab): Promise<void>;
|
||||||
|
close(tab: vscode.Tab): Promise<void>;
|
||||||
|
closeOthers(tab: vscode.Tab): Promise<void>;
|
||||||
|
togglePin(tab: vscode.Tab): Promise<void>;
|
||||||
|
copyPath(tab: vscode.Tab): Promise<void>;
|
||||||
|
reveal(tab: vscode.Tab): Promise<void>;
|
||||||
|
},
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get visible(): boolean {
|
||||||
|
return this.view?.visible ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveWebviewView(view: vscode.WebviewView): void {
|
||||||
|
this.view = view;
|
||||||
|
this.ready = false;
|
||||||
|
view.webview.options = {
|
||||||
|
enableScripts: true,
|
||||||
|
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'media')],
|
||||||
|
};
|
||||||
|
view.webview.html = this.html(view.webview);
|
||||||
|
|
||||||
|
view.webview.onDidReceiveMessage(message => void this.receive(message));
|
||||||
|
view.onDidChangeVisibility(() => {
|
||||||
|
if (view.visible) {
|
||||||
|
void this.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
view.onDidDispose(() => {
|
||||||
|
this.view = undefined;
|
||||||
|
this.ready = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the list and pushes it to the webview. */
|
||||||
|
async refresh(followActive = true): Promise<void> {
|
||||||
|
if (!this.view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.ready) {
|
||||||
|
this.queued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = config();
|
||||||
|
const groups = cfg.get<boolean>('allGroups', true)
|
||||||
|
? vscode.window.tabGroups.all
|
||||||
|
: [vscode.window.tabGroups.activeTabGroup];
|
||||||
|
|
||||||
|
const projects = new Map<string, string | undefined>();
|
||||||
|
const uris = new Map<string, vscode.Uri>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const tab of group.tabs) {
|
||||||
|
const uri = tabUri(tab);
|
||||||
|
if (uri) {
|
||||||
|
uris.set(uri.toString(), uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([...uris].map(async ([key, uri]) => {
|
||||||
|
projects.set(key, await projectOf(uri));
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.entries = buildEntries(groups, projects, {
|
||||||
|
order: cfg.get<Order>('order', 'open'),
|
||||||
|
pinnedFirst: cfg.get<boolean>('pinnedFirst', true),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tabs = new Map(this.entries.map(entry => [entry.id, entry.tab]));
|
||||||
|
|
||||||
|
const showColumn = cfg.get<boolean>('allGroups', true) &&
|
||||||
|
vscode.window.tabGroups.all.length > 1;
|
||||||
|
|
||||||
|
void this.view.webview.postMessage({
|
||||||
|
type: 'rows',
|
||||||
|
followActive,
|
||||||
|
showProject: cfg.get<boolean>('showProject', true),
|
||||||
|
showDirectory: cfg.get<boolean>('showDirectory', true),
|
||||||
|
showColumn,
|
||||||
|
rowSpacing: cfg.get<number>('rowSpacing', 2),
|
||||||
|
borderWidth: cfg.get<number>('borderWidth', 3),
|
||||||
|
rows: this.entries.map((entry): Row => ({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.label,
|
||||||
|
dir: entry.dir,
|
||||||
|
project: entry.project ?? '',
|
||||||
|
colorIndex: cfg.get<boolean>('colorByProject', true)
|
||||||
|
? projectColorIndex(entry.project)
|
||||||
|
: 0,
|
||||||
|
uri: entry.uri !== undefined,
|
||||||
|
isActive: entry.isActive,
|
||||||
|
isDirty: entry.isDirty,
|
||||||
|
isPinned: entry.isPinned,
|
||||||
|
isPreview: entry.isPreview,
|
||||||
|
canActivate: entry.canActivate,
|
||||||
|
column: entry.column,
|
||||||
|
tooltip: this.tooltip(entry),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
|
||||||
|
this.view.description = count > 0 ? String(count) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tooltip(entry: TabEntry): string {
|
||||||
|
const lines: string[] = [entry.label];
|
||||||
|
if (entry.uri) {
|
||||||
|
lines.push(vscode.workspace.asRelativePath(entry.uri, true));
|
||||||
|
}
|
||||||
|
if (entry.project) {
|
||||||
|
lines.push(`Project: ${entry.project}`);
|
||||||
|
}
|
||||||
|
if (entry.isDirty) {
|
||||||
|
lines.push('Unsaved changes');
|
||||||
|
}
|
||||||
|
if (!entry.canActivate) {
|
||||||
|
lines.push('This kind of tab cannot be focused from here.');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async receive(message: { type: string; id?: string }): Promise<void> {
|
||||||
|
if (message.type === 'ready') {
|
||||||
|
this.ready = true;
|
||||||
|
if (this.queued) {
|
||||||
|
this.queued = false;
|
||||||
|
}
|
||||||
|
await this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve against live tab state: the row was drawn from a snapshot.
|
||||||
|
const tab = message.id ? this.tabs.get(message.id) : undefined;
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A tab that has since been closed is no longer in any group.
|
||||||
|
if (!vscode.window.tabGroups.all.some(group => group.tabs.includes(tab))) {
|
||||||
|
await this.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'open': return this.actions.open(tab);
|
||||||
|
case 'close': return this.actions.close(tab);
|
||||||
|
case 'closeOthers': return this.actions.closeOthers(tab);
|
||||||
|
case 'togglePin': return this.actions.togglePin(tab);
|
||||||
|
case 'copyPath': return this.actions.copyPath(tab);
|
||||||
|
case 'reveal': return this.actions.reveal(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private html(webview: vscode.Webview): string {
|
||||||
|
const asset = (name: string) =>
|
||||||
|
webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'media', name));
|
||||||
|
const token = nonce();
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ` +
|
||||||
|
`style-src ${webview.cspSource}; script-src 'nonce-${token}';">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link href="${asset('tabs.css')}" rel="stylesheet">
|
||||||
|
<title>Tabs</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="list" role="listbox" aria-label="Open editors" tabindex="0"></div>
|
||||||
|
<div id="empty" hidden>No editors are open.</div>
|
||||||
|
<div id="menu" role="menu" hidden></div>
|
||||||
|
<script nonce="${token}" src="${asset('tabs.js')}"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user