diff --git a/README.md b/README.md index eec24fc..38f3b7d 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/package.json b/package.json index db7e254..93c0a25 100644 --- a/package.json +++ b/package.json @@ -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." } } }, diff --git a/src/decorations.ts b/src/decorations.ts new file mode 100644 index 0000000..acfa882 --- /dev/null +++ b/src/decorations.ts @@ -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(); + 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 { + 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(); +} diff --git a/src/extension.ts b/src/extension.ts index eced8c2..3c789cd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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('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('decorateFiles', 'color')); } if (event.affectsConfiguration('verticalTabs')) { refresh(); diff --git a/src/test/suite/tabs.test.ts b/src/test/suite/tabs.test.ts index 48b2a44..ca533ef 100644 --- a/src/test/suite/tabs.test.ts +++ b/src/test/suite/tabs.test.ts @@ -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');