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>
This commit is contained in:
max
2026-09-07 18:52:42 +02:00
co-authored by Claude Opus 5
parent 925e55d619
commit 2865ced2f0
5 changed files with 153 additions and 0 deletions
+12
View File
@@ -5,6 +5,16 @@ off (`"workbench.editor.showTabs": "none"`), where a side list *is* the tab bar.
It shows up as a **Tabs** view in the Explorer — drag it wherever you want it, including the secondary side bar.
## Two ways to use it
**Just the colours.** colours files by project wherever VS Code shows file
decorations — the Explorer, the built-in **Open Editors** view, and the editor tabs. If colour coding is all you
want, use this and keep the built-in view: it drags to dock, shows file icons, and can focus every kind of tab.
Set it to to add a two-letter project badge such as for .
**The Tabs view.** A drawn list that the built-in view cannot be: a project border down each row, real spacing,
and file names that keep their extension. Costs drag-and-drop and file icons — see *Known limitations*.
## What it adds over the built-in Open Editors view
- **A colour per project**, as a border down the left of each row — the way Visual Studio marks them. The
@@ -37,6 +47,8 @@ the close button replaces on hover.
## Settings
- `verticalTabs.decorateFiles` — colour files by project in the Explorer, Open Editors and tabs:
`off`, `color` (default), or `colorAndBadge`
- `verticalTabs.order``open` (default), `project`, or `name`
- `verticalTabs.pinnedFirst` — list pinned tabs above the rest (default `true`)
- `verticalTabs.colorByProject` — colour each row's left border by project (default `true`)
+15
View File
@@ -170,6 +170,21 @@
"minimum": 0,
"maximum": 8,
"description": "Width in pixels of the project colour border on the left of each row."
},
"verticalTabs.decorateFiles": {
"type": "string",
"enum": [
"off",
"color",
"colorAndBadge"
],
"enumDescriptions": [
"Do not decorate files.",
"Colour each file's name by its project.",
"Colour the name and add a short project badge, such as AC for Acme.Shop.Client."
],
"default": "color",
"markdownDescription": "Colour files by project wherever VS Code shows file decorations: the Explorer, the built-in **Open Editors** view, and the editor tabs. This is what makes the built-in Open Editors view project-coloured without giving up its drag-and-drop.\n\nThere is no way to decorate only the Open Editors view, so the Explorer is coloured too. Git also decorates modified files and only one colour wins, so a dirty file may show Git's colour instead of its project's. Requires `explorer.decorations.colors`, which is on by default."
}
}
},
+78
View File
@@ -0,0 +1,78 @@
import * as vscode from 'vscode';
import { projectColorIndex, projectOf } from './projects';
export type DecorationMode = 'off' | 'color' | 'colorAndBadge';
/**
* Colours files by project wherever VS Code shows file decorations: the Explorer, the
* built-in Open Editors view, and the editor tabs.
*
* This is how the project colours reach views we do not own. It is also the reason the
* custom list is optional — if all you want is colour coding, this gives it to the
* built-in Open Editors view while keeping every native behaviour it has, including
* drag-and-drop.
*
* Two caveats worth knowing:
* - There is no way to decorate only the Open Editors view. The Explorer gets the same
* colours, which some people like and some find noisy.
* - Git decorates modified files too, and only one colour wins per file, so a dirty
* file may show Git's colour instead of its project's.
*/
export class ProjectDecorations implements vscode.FileDecorationProvider {
private readonly changed = new vscode.EventEmitter<undefined>();
readonly onDidChangeFileDecorations = this.changed.event;
private mode: DecorationMode = 'color';
setMode(mode: DecorationMode): void {
if (mode !== this.mode) {
this.mode = mode;
this.changed.fire(undefined);
}
}
/** Re-asks for every decoration, after the project layout may have changed. */
invalidate(): void {
this.changed.fire(undefined);
}
async provideFileDecoration(uri: vscode.Uri): Promise<vscode.FileDecoration | undefined> {
if (this.mode === 'off' || uri.scheme !== 'file') {
return undefined;
}
const project = await projectOf(uri);
if (!project) {
return undefined;
}
const decoration = new vscode.FileDecoration(
this.mode === 'colorAndBadge' ? badgeFor(project) : undefined,
`Project: ${project}`,
new vscode.ThemeColor(`verticalTabs.project${projectColorIndex(project)}`));
// Colouring the containing folders too would repaint most of the Explorer.
decoration.propagate = false;
return decoration;
}
dispose(): void {
this.changed.dispose();
}
}
/**
* A one or two character badge for a project name — all a `FileDecoration` badge fits.
*
* `Acme.Shop.Client` becomes `AC`: the initial of the name and of its last segment,
* which keeps sibling projects like `…Client` and `…Server` apart.
*/
export function badgeFor(project: string): string {
const segments = project.split(/[.\-_ ]+/).filter(segment => segment.length > 0);
if (segments.length === 0) {
return '?';
}
const first = segments[0][0].toUpperCase();
if (segments.length === 1) {
return first + (segments[0][1] ?? '').toLowerCase();
}
return first + segments[segments.length - 1][0].toUpperCase();
}
+13
View File
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { closeOthers, closeTab, copyPath, openTab, revealInExplorer, togglePin } from './actions';
import { DecorationMode, ProjectDecorations } from './decorations';
import { clearProjectCache } from './projects';
import { Order } from './tabs';
import { TabsView } from './view';
@@ -9,6 +10,11 @@ function config() {
}
export function activate(context: vscode.ExtensionContext): void {
// Project colours for the views we do not own — the Explorer, the built-in Open
// Editors view, and the tabs.
const decorations = new ProjectDecorations();
decorations.setMode(config().get<DecorationMode>('decorateFiles', 'color'));
const view = new TabsView(context.extensionUri, {
open: openTab,
close: closeTab,
@@ -32,16 +38,23 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(TabsView.viewId, view,
{ webviewOptions: { retainContextWhenHidden: true } }),
vscode.window.registerFileDecorationProvider(decorations),
decorations,
vscode.window.tabGroups.onDidChangeTabs(refresh),
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
clearProjectCache();
decorations.invalidate();
refresh();
}),
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('verticalTabs.projectFiles')) {
clearProjectCache();
decorations.invalidate();
}
if (event.affectsConfiguration('verticalTabs.decorateFiles')) {
decorations.setMode(config().get<DecorationMode>('decorateFiles', 'color'));
}
if (event.affectsConfiguration('verticalTabs')) {
refresh();
+35
View File
@@ -3,6 +3,7 @@ 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,
@@ -84,6 +85,40 @@ suite('Project resolution', () => {
});
});
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');