Files
vs-code-vertical-tabs/src/test/suite/tabs.test.ts
T
maxandClaude Opus 5 2865ced2f0 Colour files by project in the Explorer, Open Editors and tabs
A fair question about the custom list: what does it buy over the built-in
Open Editors view? Most of the answer was the project colours — and those do
not need a custom view. A FileDecorationProvider puts them on the built-in
Open Editors view, the Explorer and the tabs, which keeps everything native
that view already does, including the drag-to-dock this list cannot offer.

verticalTabs.decorateFiles is off / color / colorAndBadge, defaulting to
color. Badges are the two characters a FileDecoration allows, taken from the
first and last segments of the project name so that Acme.Shop.Client and
Acme.Shop.Server read as AC and AS rather than colliding.

Two limits are documented rather than papered over: there is no way to
decorate only the Open Editors view, so the Explorer is coloured too; and Git
decorates modified files with only one colour winning, so a dirty file can
show Git's colour instead of its project's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:52:42 +02:00

338 lines
15 KiB
TypeScript

import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { closeOthers, closeTab, togglePin } from '../../actions';
import { ProjectDecorations, badgeFor } from '../../decorations';
import { projectColorIndex, projectOf } from '../../projects';
import {
TabEntry, buildEntries, describeEntry, duplicateLabels, sortEntries, tabUri,
} from '../../tabs';
/** Files in three different projects of the test solution. */
const FILES = [
path.join('Nerfed.Runtime', 'Profiler.cs'),
path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs'),
path.join('Nerfed.Builder', 'Program.cs'),
];
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
function root(): vscode.Uri {
const folder = vscode.workspace.workspaceFolders?.[0];
assert.ok(folder, 'no workspace folder');
return folder.uri;
}
function uriFor(relative: string): vscode.Uri {
return vscode.Uri.joinPath(root(), ...relative.split(path.sep));
}
async function closeEverything(): Promise<void> {
// Not closeAllEditors: that command leaves *pinned* editors open, which would leak
// into the next test. The tab API closes them regardless.
const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs);
if (tabs.length > 0) {
await vscode.window.tabGroups.close(tabs, true);
}
await sleep(200);
const left = vscode.window.tabGroups.all.flatMap(group => group.tabs);
assert.deepStrictEqual(left.map(tab => tab.label), [], 'tabs survived the cleanup');
}
/** Resolves the project for every open tab, the way the provider does. */
async function projectMap(): Promise<Map<string, string | undefined>> {
const map = new Map<string, string | undefined>();
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
const uri = tabUri(tab);
if (uri) {
map.set(uri.toString(), await projectOf(uri));
}
}
}
return map;
}
async function entries(order: 'open' | 'project' | 'name' = 'open', pinnedFirst = true) {
return buildEntries(vscode.window.tabGroups.all, await projectMap(), { order, pinnedFirst });
}
suite('Project resolution', () => {
test('a file resolves to its nearest .csproj', async () => {
assert.strictEqual(await projectOf(uriFor(FILES[0])), 'Nerfed.Runtime');
assert.strictEqual(await projectOf(uriFor(FILES[1])), 'Nerfed.Editor');
});
test('a file outside any project resolves to nothing', async () => {
// README.md sits at the solution root, above every .csproj.
assert.strictEqual(await projectOf(uriFor('README.md')), undefined);
});
test('project colours are stable and spread across the palette', () => {
const first = projectColorIndex('Nerfed.Runtime');
assert.strictEqual(projectColorIndex('Nerfed.Runtime'), first,
'the same project must always get the same colour');
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 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.strictEqual(projectColorIndex(undefined), 0, 'no project means slot 0');
});
});
suite('File decorations', () => {
test('a badge keeps sibling projects apart', () => {
assert.strictEqual(badgeFor('Acme.Shop.Client'), 'AC');
assert.strictEqual(badgeFor('Acme.Shop.Server'), 'AS');
assert.strictEqual(badgeFor('Nerfed.Runtime'), 'NR');
assert.strictEqual(badgeFor('Nerfed.Editor'), 'NE');
// A single-word project has no last segment to borrow from.
assert.strictEqual(badgeFor('MoonWorks'), 'Mo');
assert.strictEqual(badgeFor('a'), 'A', 'badges are uppercased');
assert.strictEqual(badgeFor(''), '?');
});
test('decorations name the project and pick its palette colour', async () => {
const decorations = new ProjectDecorations();
decorations.setMode('colorAndBadge');
const inProject = uriFor(FILES[0]);
const decoration = await decorations.provideFileDecoration(inProject);
assert.ok(decoration, 'a file inside a project should be decorated');
assert.strictEqual(decoration.badge, 'NR');
assert.strictEqual(decoration.tooltip, 'Project: Nerfed.Runtime');
assert.deepStrictEqual(decoration.color,
new vscode.ThemeColor(`verticalTabs.project${projectColorIndex('Nerfed.Runtime')}`));
// Colouring parents would repaint most of the Explorer.
assert.strictEqual(decoration.propagate, false);
// Nothing outside a project, and nothing when switched off.
assert.strictEqual(await decorations.provideFileDecoration(uriFor('README.md')), undefined);
decorations.setMode('off');
assert.strictEqual(await decorations.provideFileDecoration(inProject), undefined);
decorations.dispose();
});
});
suite('Tab list', () => {
suiteSetup(async () => {
const ext = vscode.extensions.getExtension('local.vertical-tabs');
assert.ok(ext, 'extension not found');
await ext.activate();
});
setup(closeEverything);
suiteTeardown(closeEverything);
test('every open editor becomes one entry, with its project', 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();
assert.strictEqual(list.length, FILES.length, 'one entry per open editor');
assert.deepStrictEqual(list.map(e => e.label),
FILES.map(f => path.basename(f)));
assert.deepStrictEqual(list.map(e => e.project),
['Nerfed.Runtime', 'Nerfed.Editor', 'Nerfed.Builder']);
assert.ok(list.every(e => e.uri && e.canActivate), 'text tabs must be activatable');
assert.strictEqual(list.filter(e => e.isActive).length, 1, 'exactly one active tab');
});
test('open order matches the tab order, and the other orders sort', async () => {
for (const file of FILES) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
const byName = await entries('name');
assert.deepStrictEqual(byName.map(e => e.label),
[...byName.map(e => e.label)].sort((a, b) => a.localeCompare(b)));
const byProject = await entries('project');
assert.deepStrictEqual(byProject.map(e => e.project),
['Nerfed.Builder', 'Nerfed.Editor', 'Nerfed.Runtime']);
});
test('pinnedFirst lifts a pinned tab above the sort order', async () => {
for (const file of FILES) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
// Pin the *last* opened editor, which is the active one. Program.cs also happens
// to sort last by name, so pinnedFirst has something to prove.
await vscode.commands.executeCommand('workbench.action.pinEditor');
await sleep(300);
const list = await entries();
assert.ok(list[0].isPinned, `first entry should be pinned, got ${list[0].label}`);
assert.strictEqual(list[0].label, path.basename(FILES[2]));
assert.strictEqual(list.filter(e => e.isPinned).length, 1);
// VS Code moves a pinned tab to the front of its group, so `open` order shows it
// first whether or not we sort pinned tabs up. Name order is the honest test.
assert.strictEqual((await entries('name', true))[0].label, path.basename(FILES[2]));
assert.deepStrictEqual((await entries('name', false)).map(e => e.label),
['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']);
});
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 target = (await entries()).find(e => e.label === path.basename(FILES[0]));
assert.ok(target, 'target tab not in the list');
assert.ok(!target.tab.isPinned);
await togglePin(target.tab);
await sleep(400);
assert.deepStrictEqual((await entries()).filter(e => e.isPinned).map(e => e.label),
[path.basename(FILES[0])]);
// 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).map(e => e.label), []);
});
test('close closes the given tab, and close others closes the rest', async () => {
for (const file of FILES) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
assert.ok(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 closeOthers(keep.tab);
await sleep(300);
assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]);
});
test('close others keeps pinned tabs', async () => {
for (const file of FILES) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
{ preview: false });
}
await sleep(300);
// 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 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 closeOthers(from.tab);
await sleep(400);
const left = (await entries()).map(e => e.label).sort();
assert.deepStrictEqual(left, [path.basename(FILES[1]), path.basename(FILES[0])].sort(),
'the pinned tab and the kept tab should both survive');
});
test('a diff tab reports its modified side and stays activatable', async () => {
await vscode.commands.executeCommand('vscode.diff',
uriFor(FILES[0]), uriFor(FILES[1]), 'Profiler vs Editor');
await sleep(400);
const list = await entries();
assert.strictEqual(list.length, 1, 'expected only the diff tab');
assert.ok(list[0].uri, 'a diff tab should expose a resource');
assert.strictEqual(list[0].uri.fsPath, uriFor(FILES[1]).fsPath,
'the modified side identifies the diff');
assert.ok(list[0].canActivate);
});
test('a webview tab is listed but reports that it cannot be activated', async () => {
await vscode.commands.executeCommand('workbench.action.openSettings');
await sleep(600);
const list = await entries();
const settings = list.find(e => !e.uri);
assert.ok(settings, `no non-file tab found among ${list.map(e => e.label).join(', ')}`);
assert.ok(!settings.canActivate, 'a webview tab must not claim to be activatable');
assert.ok(settings.id.startsWith('1:label:'), `unexpected id ${settings.id}`);
});
test('the detail text never repeats the project inside the directory', () => {
const all = { showProject: true, showDirectory: true, showColumn: false };
// A .csproj-per-directory layout: the directory already names the project.
const same = { label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime' } as TabEntry;
assert.strictEqual(describeEntry(same, all), 'Nerfed.Runtime');
// A subdirectory of the project: still no need to repeat it.
const nested = {
label: 'Transform.cs', dir: 'Nerfed.Runtime/Util', project: 'Nerfed.Runtime',
} as TabEntry;
assert.strictEqual(describeEntry(nested, all), 'Nerfed.Runtime/Util');
// A Unity-style layout, where the directory says nothing about the assembly.
const unrelated = {
label: 'ClientTranslator.cs', dir: 'Assets/Scripts/Client', project: 'Acme.Shop.Client',
} as TabEntry;
assert.strictEqual(describeEntry(unrelated, all),
'Assets/Scripts/Client Acme.Shop.Client');
});
test('the detail text can be reduced to nothing', () => {
const entry = {
label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime', column: 1,
} as TabEntry;
assert.strictEqual(
describeEntry(entry, { showProject: false, showDirectory: true, showColumn: false }),
'Nerfed.Runtime');
assert.strictEqual(
describeEntry(entry, { showProject: true, showDirectory: false, showColumn: false }),
'Nerfed.Runtime');
assert.strictEqual(
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: true }),
'#1');
assert.strictEqual(
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: false }),
'', 'the file name should be able to have the whole row');
});
test('only names shared by more than one tab count as ambiguous', () => {
const entry = (label: string) => ({ label } as TabEntry);
assert.deepStrictEqual(
[...duplicateLabels([entry('a.cs'), entry('b.cs'), entry('a.cs')])],
['a.cs']);
assert.deepStrictEqual([...duplicateLabels([entry('a.cs'), entry('b.cs')])], []);
assert.deepStrictEqual([...duplicateLabels([])], []);
});
test('sorting is stable for entries that compare equal', () => {
const make = (label: string, index: number) => ({
id: String(index), label, dir: '', project: 'P', isPinned: false,
} as TabEntry);
const list = [make('a', 0), make('a', 1), make('a', 2)];
const sorted = sortEntries(list, { order: 'project', pinnedFirst: true });
assert.deepStrictEqual(sorted.map(e => e.id), ['0', '1', '2']);
});
});