Draw the tab list as a webview, VS-style

A tree view row cannot be styled: no border, no spacing, and a long file
name gets middle-ellipsized in a narrow strip — which on a real project left
"ClientDogSurger...nionBehaviour.cs" and hid the project text entirely.
Drawing the rows means controlling all of it.

- A coloured left border per project instead of a tinted icon, so the file
  icon slot is free and the name gets the width. The border uses
  var(--vscode-verticalTabs-projectN): VS Code injects every contributed
  theme colour into a webview, so overrides in colorCustomizations still
  apply and nothing is hardcoded.
- The name is split into stem and extension, and only the stem ellipsizes,
  so ".cs" survives on a long name in a narrow view.
- Configurable row spacing and border width, a rule under the pinned block,
  a dirty dot that the close button replaces on hover, italics for preview
  and for tabs that cannot be focused.
- Selection, arrow/Home/End/Enter/Delete keys, middle-click to close, and an
  HTML context menu, since contributes.menus does not reach webview rows.
- Clicking a webview row posts nothing rather than asking for an open that
  would silently do nothing.

Actions moved to actions.ts so the wiring, the view and the tests share one
implementation; extension.ts is now just registration. The per-row commands
are gone — rows act through webview messages — and the palette keeps
close / closeOthers / togglePin acting on the active editor.

Verified in a browser harness at side-bar width: all twelve project colours
resolve, rows stay a consistent 24px, and click, pin, close, middle-click,
keyboard and both context-menu variants post the right intents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 18:34:26 +02:00
co-authored by Claude Opus 5
parent 2e9a43f78b
commit cf168b2e75
8 changed files with 981 additions and 425 deletions
+24 -23
View File
@@ -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();