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:
+24
-23
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user