Vertical Tabs: open editors as a project-coloured flat list
For working with the tab bar off, where a side list is the tab bar. VS Code's Open Editors view already does pinning; what it does not do is colour by project, and it is a tree rather than a flat list. - A Tabs view in the Explorer: one row per open editor, no group nodes. A TreeDataProvider whose items have no children renders as a list, since there is no separate list API. - Each row's icon is tinted by the project containing the file — the nearest .csproj/.fsproj/.vbproj, package.json, Cargo.toml, go.mod, pyproject.toml, pom.xml or build.gradle above it, configurable. The colour comes from a hash of the project name, so a project keeps its colour across sessions rather than depending on the order tabs happened to open in. Eight contributed theme colours, all overridable. - Sort by open order, project (so the colours run in blocks), or file name, with pinned tabs lifted to the top. - Close, close others (keeping pinned), pin/unpin, copy path, reveal. - The list follows the active editor. Two API limits shape this, both covered by tests: - Nothing can activate an arbitrary tab. A file-backed tab is focused by re-opening its resource in its own group, which handles text, diffs, notebooks and custom editors. Webview tabs have no resource, so they are listed and marked as unfocusable instead of pretending. - Only the *active* editor can be pinned, so pinning another row focuses it first. Commands act on live vscode.Tab objects rather than the row snapshots handed to them. The first cut compared against a snapshot's isPinned and so silently did nothing on unpin, and read the provider's cached list, which is empty until the tree has been rendered. Also worth recording, both found by the tests: VS Code moves a pinned tab to the front of its group, so `open` order shows pinned first regardless of the pinnedFirst setting; and closeAllEditors leaves pinned editors open, so test cleanup closes through the tab API instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import * as assert from 'assert';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
import { projectColor, projectOf } from '../../projects';
|
||||
import { TabEntry, buildEntries, describeEntry, 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 = projectColor('Nerfed.Runtime');
|
||||
assert.deepStrictEqual(projectColor('Nerfed.Runtime'), first,
|
||||
'the same project must always get the same colour');
|
||||
assert.notDeepStrictEqual(projectColor('Nerfed.Editor'), first,
|
||||
'these two projects should not collide');
|
||||
|
||||
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`);
|
||||
|
||||
assert.deepStrictEqual(projectColor(undefined),
|
||||
new vscode.ThemeColor('verticalTabs.noProject'));
|
||||
});
|
||||
});
|
||||
|
||||
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('the pin command pins the tab it is given, not just 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]));
|
||||
assert.ok(target, 'target tab not in the list');
|
||||
assert.ok(!target.isPinned);
|
||||
|
||||
await vscode.commands.executeCommand('verticalTabs.pin', target);
|
||||
await sleep(400);
|
||||
|
||||
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);
|
||||
await sleep(400);
|
||||
assert.deepStrictEqual((await entries()).filter(e => e.isPinned), []);
|
||||
});
|
||||
|
||||
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 vscode.commands.executeCommand('verticalTabs.close', first);
|
||||
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 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 vscode.commands.executeCommand('verticalTabs.pin', pin);
|
||||
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 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 description shows directory, project and only shows the group when split', () => {
|
||||
const entry = {
|
||||
label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime', column: 1,
|
||||
} as TabEntry;
|
||||
|
||||
assert.strictEqual(
|
||||
describeEntry(entry, { showProject: true, showDirectory: true, showColumn: false }),
|
||||
'Nerfed.Runtime [Nerfed.Runtime]');
|
||||
assert.strictEqual(
|
||||
describeEntry(entry, { showProject: false, showDirectory: true, 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 }),
|
||||
'');
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user