From cf168b2e7594958d64d01014dc30ad910110d89f Mon Sep 17 00:00:00 2001 From: max Date: Mon, 7 Sep 2026 18:34:26 +0200 Subject: [PATCH] Draw the tab list as a webview, VS-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- media/tabs.css | 252 +++++++++++++++++++++++++++ media/tabs.js | 330 ++++++++++++++++++++++++++++++++++++ package.json | 139 +++------------ src/actions.ts | 88 ++++++++++ src/extension.ts | 307 +++------------------------------ src/projects.ts | 11 +- src/test/suite/tabs.test.ts | 47 ++--- src/view.ts | 232 +++++++++++++++++++++++++ 8 files changed, 981 insertions(+), 425 deletions(-) create mode 100644 media/tabs.css create mode 100644 media/tabs.js create mode 100644 src/actions.ts create mode 100644 src/view.ts 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 + + +
+ + + + +`; + } +}