diff --git a/media/tabs.css b/media/tabs.css
new file mode 100644
index 0000000..0ee7a47
--- /dev/null
+++ b/media/tabs.css
@@ -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;
+}
diff --git a/media/tabs.js b/media/tabs.js
new file mode 100644
index 0000000..1e1f837
--- /dev/null
+++ b/media/tabs.js
@@ -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: '',
+ 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' });
+})();
diff --git a/package.json b/package.json
index 0ed0a0c..677e954 100644
--- a/package.json
+++ b/package.json
@@ -28,69 +28,41 @@
{
"id": "verticalTabs.tabs",
"name": "Tabs",
+ "type": "webview",
"contextualTitle": "Tabs",
"visibility": "visible"
}
]
},
- "viewsWelcome": [
- {
- "view": "verticalTabs.tabs",
- "contents": "No editors are open."
- }
- ],
"commands": [
- {
- "command": "verticalTabs.open",
- "title": "Open Tab",
- "category": "Tabs"
- },
{
"command": "verticalTabs.close",
- "title": "Close",
- "category": "Tabs",
- "icon": "$(close)"
+ "title": "Close Active Tab",
+ "category": "Tabs"
},
{
"command": "verticalTabs.closeOthers",
- "title": "Close Others",
+ "title": "Close Other Tabs",
"category": "Tabs"
},
{
- "command": "verticalTabs.pin",
- "title": "Pin",
- "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",
+ "command": "verticalTabs.togglePin",
+ "title": "Pin or Unpin Active Tab",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderOpen",
- "title": "Sort by Open Order",
+ "title": "Sort Tabs by Open Order",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderProject",
- "title": "Sort by Project",
+ "title": "Sort Tabs by Project",
"category": "Tabs"
},
{
"command": "verticalTabs.setOrderName",
- "title": "Sort by File Name",
+ "title": "Sort Tabs by File Name",
"category": "Tabs"
}
],
@@ -111,83 +83,6 @@
"when": "view == verticalTabs.tabs",
"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": {
@@ -226,7 +121,7 @@
"verticalTabs.colorByProject": {
"type": "boolean",
"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": {
"type": "boolean",
@@ -251,6 +146,20 @@
"build.gradle"
],
"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."
}
}
},
diff --git a/src/actions.ts b/src/actions.ts
new file mode 100644
index 0000000..fbe3f71
--- /dev/null
+++ b/src/actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const uri = tabUri(tab);
+ if (uri) {
+ await vscode.env.clipboard.writeText(uri.fsPath);
+ }
+}
+
+export async function revealInExplorer(tab: vscode.Tab): Promise {
+ const uri = tabUri(tab);
+ if (uri) {
+ await vscode.commands.executeCommand('revealInExplorer', uri);
+ }
+}
diff --git a/src/extension.ts b/src/extension.ts
index 39856f8..eced8c2 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,254 +1,40 @@
import * as vscode from 'vscode';
-import { clearProjectCache, projectColor, projectOf } from './projects';
-import { Order, TabEntry, buildEntries, canActivate, describeEntry, tabUri } from './tabs';
-
-const VIEW_ID = 'verticalTabs.tabs';
+import { closeOthers, closeTab, copyPath, openTab, revealInExplorer, togglePin } from './actions';
+import { clearProjectCache } from './projects';
+import { Order } from './tabs';
+import { TabsView } from './view';
function config() {
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 {
- private readonly changed = new vscode.EventEmitter();
- 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 {
- if (element) {
- return []; // flat list
- }
- const cfg = config();
- const groups = cfg.get('allGroups', true)
- ? vscode.window.tabGroups.all
- : [vscode.window.tabGroups.activeTabGroup];
-
- // Resolve every project once, then build the list synchronously.
- const projects = new Map();
- if (cfg.get('showProject', true) || cfg.get('colorByProject', true) ||
- cfg.get('order', 'open') === 'project') {
- const uris = new Map();
- 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', 'open'),
- pinnedFirst: cfg.get('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('showProject', true),
- showDirectory: cfg.get('showDirectory', true),
- showColumn: vscode.window.tabGroups.all.length > 1 && cfg.get('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('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 {
- 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 {
- 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 {
- const provider = new TabsProvider();
- const view = vscode.window.createTreeView(VIEW_ID, {
- treeDataProvider: provider,
- showCollapseAll: false,
+ const view = new TabsView(context.extensionUri, {
+ open: openTab,
+ close: closeTab,
+ closeOthers,
+ togglePin,
+ copyPath,
+ reveal: revealInExplorer,
});
- const updateTitle = () => {
- const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
- view.description = count > 0 ? String(count) : undefined;
- };
+ const refresh = () => void view.refresh();
- /**
- * Keeps the list's selection on whatever tab is active.
- *
- * The entries hold a snapshot of `isActive`, and after a refresh the tree has not
- * necessarily asked for children yet — so rebuild the list first and match against
- * the live active tab rather than a stale flag.
- */
- 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.
- }
+ /** The tab a palette command acts on. */
+ const activeTab = () => vscode.window.tabGroups.activeTabGroup.activeTab;
+ const onActiveTab = (action: (tab: vscode.Tab) => Promise) => async () => {
+ const tab = activeTab();
+ if (tab) {
+ await action(tab);
}
};
- 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(
- view,
- provider,
+ vscode.window.registerWebviewViewProvider(TabsView.viewId, view,
+ { webviewOptions: { retainContextWhenHidden: true } }),
vscode.window.tabGroups.onDidChangeTabs(refresh),
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
- vscode.window.onDidChangeActiveTextEditor(() => void followActiveTab()),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
clearProjectCache();
refresh();
@@ -262,60 +48,17 @@ export function activate(context: vscode.ExtensionContext): void {
}
}),
- vscode.commands.registerCommand('verticalTabs.open', (arg?: unknown) => {
- const tab = tabOf(arg);
- return tab ? openTab(tab) : undefined;
- }),
- vscode.commands.registerCommand('verticalTabs.close', async (arg?: unknown) => {
- 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);
- }
- }),
+ // Palette / keybinding entry points. The rows have their own buttons and context
+ // menu inside the webview, so these act on the active editor.
+ vscode.commands.registerCommand('verticalTabs.close', onActiveTab(closeTab)),
+ vscode.commands.registerCommand('verticalTabs.closeOthers', onActiveTab(closeOthers)),
+ vscode.commands.registerCommand('verticalTabs.togglePin', onActiveTab(togglePin)),
...(['open', 'project', 'name'] as Order[]).map(order =>
vscode.commands.registerCommand(
`verticalTabs.setOrder${order[0].toUpperCase()}${order.slice(1)}`,
() => config().update('order', order, vscode.ConfigurationTarget.Global))),
-
- view.onDidChangeVisibility(event => {
- if (event.visible) {
- refresh();
- }
- }),
);
-
- updateTitle();
}
export function deactivate(): void {
diff --git a/src/projects.ts b/src/projects.ts
index 970a081..104dc71 100644
--- a/src/projects.ts
+++ b/src/projects.ts
@@ -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
* 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) {
- return new vscode.ThemeColor('verticalTabs.noProject');
+ return 0;
}
let hash = 0;
for (let i = 0; i < project.length; i++) {
// djb2, kept in 32 bits.
hash = ((hash << 5) - hash + project.charCodeAt(i)) | 0;
}
- const index = Math.abs(hash) % PALETTE_SIZE + 1;
- return new vscode.ThemeColor(`verticalTabs.project${index}`);
+ return Math.abs(hash) % PALETTE_SIZE + 1;
}
diff --git a/src/test/suite/tabs.test.ts b/src/test/suite/tabs.test.ts
index 82e2608..3f8daac 100644
--- a/src/test/suite/tabs.test.ts
+++ b/src/test/suite/tabs.test.ts
@@ -2,7 +2,8 @@ import * as assert from 'assert';
import * as path from 'path';
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';
/** 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', () => {
- const first = projectColor('Nerfed.Runtime');
- assert.deepStrictEqual(projectColor('Nerfed.Runtime'), first,
+ const first = projectColorIndex('Nerfed.Runtime');
+ assert.strictEqual(projectColorIndex('Nerfed.Runtime'), first,
'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');
+ 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']
- .map(name => JSON.stringify(projectColor(`Project.${name}`))));
- assert.ok(colours.size >= 5, `12 projects only used ${colours.size} colours`);
+ const slots = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
+ .map(name => projectColorIndex(`Project.${name}`)));
+ assert.ok(slots.size >= 6, `12 projects only used ${slots.size} slots`);
- assert.deepStrictEqual(projectColor(undefined),
- new vscode.ThemeColor('verticalTabs.noProject'));
+ assert.strictEqual(projectColorIndex(undefined), 0, 'no project means slot 0');
});
});
@@ -148,27 +149,27 @@ suite('Tab list', () => {
['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) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
- const list = await entries();
- const target = list.find(e => e.label === path.basename(FILES[0]));
+ const target = (await entries()).find(e => e.label === path.basename(FILES[0]));
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);
+ 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);
- assert.deepStrictEqual(pinned, [path.basename(FILES[0])]);
-
- await vscode.commands.executeCommand('verticalTabs.unpin', target);
+ // The second call has to read the *live* pinned state, not the stale snapshot in
+ // `target` — getting that wrong made unpin a no-op.
+ await togglePin(target.tab);
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 () => {
@@ -180,14 +181,14 @@ suite('Tab list', () => {
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
assert.ok(first);
- await vscode.commands.executeCommand('verticalTabs.close', first);
+ await closeTab(first.tab);
await sleep(300);
assert.deepStrictEqual((await entries()).map(e => e.label),
[path.basename(FILES[0]), path.basename(FILES[2])]);
const keep = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(keep);
- await vscode.commands.executeCommand('verticalTabs.closeOthers', keep);
+ await closeOthers(keep.tab);
await sleep(300);
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.
const pin = (await entries()).find(e => e.label === path.basename(FILES[1]));
assert.ok(pin);
- await vscode.commands.executeCommand('verticalTabs.pin', pin);
+ await togglePin(pin.tab);
await sleep(400);
const from = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(from, 'the tab to keep is gone');
- await vscode.commands.executeCommand('verticalTabs.closeOthers', from);
+ await closeOthers(from.tab);
await sleep(400);
const left = (await entries()).map(e => e.label).sort();
diff --git a/src/view.ts b/src/view.ts
new file mode 100644
index 0000000..bb43fe6
--- /dev/null
+++ b/src/view.ts
@@ -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();
+ private entries: TabEntry[] = [];
+ private ready = false;
+ private queued = false;
+
+ constructor(
+ private readonly extensionUri: vscode.Uri,
+ private readonly actions: {
+ open(tab: vscode.Tab): Promise;
+ close(tab: vscode.Tab): Promise;
+ closeOthers(tab: vscode.Tab): Promise;
+ togglePin(tab: vscode.Tab): Promise;
+ copyPath(tab: vscode.Tab): Promise;
+ reveal(tab: vscode.Tab): Promise;
+ },
+ ) { }
+
+ 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 {
+ if (!this.view) {
+ return;
+ }
+ if (!this.ready) {
+ this.queued = true;
+ return;
+ }
+
+ const cfg = config();
+ const groups = cfg.get('allGroups', true)
+ ? vscode.window.tabGroups.all
+ : [vscode.window.tabGroups.activeTabGroup];
+
+ const projects = new Map();
+ const uris = new Map();
+ 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', 'open'),
+ pinnedFirst: cfg.get('pinnedFirst', true),
+ });
+
+ this.tabs = new Map(this.entries.map(entry => [entry.id, entry.tab]));
+
+ const showColumn = cfg.get('allGroups', true) &&
+ vscode.window.tabGroups.all.length > 1;
+
+ void this.view.webview.postMessage({
+ type: 'rows',
+ followActive,
+ showProject: cfg.get('showProject', true),
+ showDirectory: cfg.get('showDirectory', true),
+ showColumn,
+ rowSpacing: cfg.get('rowSpacing', 2),
+ borderWidth: cfg.get('borderWidth', 3),
+ rows: this.entries.map((entry): Row => ({
+ id: entry.id,
+ label: entry.label,
+ dir: entry.dir,
+ project: entry.project ?? '',
+ colorIndex: cfg.get('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 {
+ 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 `
+
+
+
+
+
+
+ Tabs
+
+
+
+ No editors are open.
+
+
+
+`;
+ }
+}