From 925e55d6192668a74f981c8cea277614528744b8 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 7 Sep 2026 18:42:14 +0200 Subject: [PATCH] Give the file name the row, and stop repeating the project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a real project the name lost the width fight and the trailing text read "MyGame.Runtime MyGame.Runtime" — the directory and the project were the same string, printed twice. - The detail text now has a shrink factor of 1000 against the name's 1, plus a 45% cap, so it gives up its width first and the name is the last thing to ellipsize. - A directory that already begins with the project name says everything the project name would, so only one of the two is shown. - verticalTabs.showDirectory became never / duplicates / always, defaulting to duplicates: width goes to a directory only when two open tabs share a file name, which is the usual reason to want it. Turning showProject off as well gives the name the whole row. - The trailing text is composed in the extension now rather than the webview, so the deduplication is unit-testable and there is one implementation. Co-Authored-By: Claude Opus 5 --- media/tabs.css | 8 +++++-- media/tabs.js | 20 +++++------------ package.json | 18 +++++++++++---- src/tabs.ts | 31 +++++++++++++++++++++----- src/test/suite/tabs.test.ts | 44 ++++++++++++++++++++++++++++++++----- src/view.ts | 28 ++++++++++++++++------- 6 files changed, 109 insertions(+), 40 deletions(-) diff --git a/media/tabs.css b/media/tabs.css index 0ee7a47..93f22dd 100644 --- a/media/tabs.css +++ b/media/tabs.css @@ -78,6 +78,8 @@ body { /* --- the file name ------------------------------------------------------ */ +/* The name is the point of the row, so it shrinks last: the detail text has a huge + shrink factor and gives up its width first. */ .name { display: flex; flex: 1 1 auto; @@ -102,12 +104,14 @@ body { font-style: italic; } -.dir { - flex: 0 1 auto; +.detail { + flex: 0 1000 auto; min-width: 0; + max-width: 45%; overflow: hidden; color: var(--vscode-descriptionForeground); font-size: 0.9em; + text-align: right; text-overflow: ellipsis; white-space: nowrap; } diff --git a/media/tabs.js b/media/tabs.js index 1e1f837..eed4a42 100644 --- a/media/tabs.js +++ b/media/tabs.js @@ -98,21 +98,11 @@ } div.appendChild(name); - const trailing = []; - if (data.showDirectory && row.dir) { - trailing.push(row.dir); - } - if (data.showProject && row.project) { - trailing.push(row.project); - } - if (data.showColumn && row.column) { - trailing.push(`#${row.column}`); - } - if (trailing.length > 0) { - const dir = document.createElement('span'); - dir.className = 'dir'; - dir.textContent = trailing.join(' '); - div.appendChild(dir); + if (row.detail) { + const detail = document.createElement('span'); + detail.className = 'detail'; + detail.textContent = row.detail; + div.appendChild(detail); } const dirty = document.createElement('span'); diff --git a/package.json b/package.json index 677e954..db7e254 100644 --- a/package.json +++ b/package.json @@ -111,12 +111,22 @@ "verticalTabs.showProject": { "type": "boolean", "default": true, - "description": "Show the containing project name beside the file." + "description": "Show the project name beside the file. The row's left border already encodes the project, so turning this off gives the file name the whole row." }, "verticalTabs.showDirectory": { - "type": "boolean", - "default": true, - "description": "Show the containing directory beside the file, for files with the same name." + "type": "string", + "enum": [ + "never", + "duplicates", + "always" + ], + "enumDescriptions": [ + "Never show the directory. The file name gets the whole row.", + "Only when two open files share a name, which is the usual reason to need it.", + "Always show the directory." + ], + "default": "duplicates", + "description": "When to spend row width on the containing directory." }, "verticalTabs.colorByProject": { "type": "boolean", diff --git a/src/tabs.ts b/src/tabs.ts index d8fe587..fe911b8 100644 --- a/src/tabs.ts +++ b/src/tabs.ts @@ -130,20 +130,41 @@ export function sortEntries( return indexed.map(item => item.entry); } -/** The dimmed text after the file name: directory, project, and the editor group. */ +/** + * The dimmed text after the file name: directory, project, and the editor group. + * + * The file name is what matters, so this stays as short as it can. A directory that + * already begins with the project name says everything the project name would, so only + * one of the two is shown — otherwise a `.csproj`-per-directory layout reads + * "MyGame.Runtime MyGame.Runtime". + */ export function describeEntry( entry: TabEntry, options: { showProject: boolean; showDirectory: boolean; showColumn: boolean }, ): string { const parts: string[] = []; - if (options.showDirectory && entry.dir) { - parts.push(entry.dir); + const dir = options.showDirectory ? entry.dir : ''; + const project = options.showProject ? entry.project ?? '' : ''; + const dirCoversProject = !!project && !!dir && + (dir === project || dir.startsWith(`${project}/`) || dir.startsWith(`${project}\\`)); + + if (dir) { + parts.push(dir); } - if (options.showProject && entry.project) { - parts.push(`[${entry.project}]`); + if (project && !dirCoversProject) { + parts.push(project); } if (options.showColumn && entry.column !== undefined) { parts.push(`#${entry.column}`); } return parts.join(' '); } + +/** File names that more than one open tab shares, so only those need their directory. */ +export function duplicateLabels(entries: readonly TabEntry[]): Set { + const counts = new Map(); + for (const entry of entries) { + counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1); + } + return new Set([...counts].filter(([, count]) => count > 1).map(([label]) => label)); +} diff --git a/src/test/suite/tabs.test.ts b/src/test/suite/tabs.test.ts index 3f8daac..48b2a44 100644 --- a/src/test/suite/tabs.test.ts +++ b/src/test/suite/tabs.test.ts @@ -4,7 +4,9 @@ import * as vscode from 'vscode'; import { closeOthers, closeTab, togglePin } from '../../actions'; import { projectColorIndex, projectOf } from '../../projects'; -import { TabEntry, buildEntries, describeEntry, sortEntries, tabUri } from '../../tabs'; +import { + TabEntry, buildEntries, describeEntry, duplicateLabels, sortEntries, tabUri, +} from '../../tabs'; /** Files in three different projects of the test solution. */ const FILES = [ @@ -240,23 +242,53 @@ suite('Tab list', () => { assert.ok(settings.id.startsWith('1:label:'), `unexpected id ${settings.id}`); }); - test('the description shows directory, project and only shows the group when split', () => { + 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: 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: 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', () => { diff --git a/src/view.ts b/src/view.ts index bb43fe6..ea08b04 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,13 +1,16 @@ import * as vscode from 'vscode'; import { projectColorIndex, projectOf } from './projects'; -import { Order, TabEntry, buildEntries, tabUri } from './tabs'; +import { Order, TabEntry, buildEntries, describeEntry, duplicateLabels, tabUri } from './tabs'; + +/** How often a row spends width on its directory. */ +type DirectoryMode = 'never' | 'duplicates' | 'always'; /** One row, as the webview needs it. */ interface Row { id: string; label: string; - dir: string; - project: string; + /** Dimmed trailing text: directory and/or project, already deduplicated. */ + detail: string; /** Palette slot, 0 for no project. */ colorIndex: number; uri: boolean; @@ -127,19 +130,28 @@ export class TabsView implements vscode.WebviewViewProvider { const showColumn = cfg.get('allGroups', true) && vscode.window.tabGroups.all.length > 1; + // "duplicates" only spends width on a directory when the file name alone is + // ambiguous, which is the usual reason to want it. + const directoryMode = cfg.get('showDirectory', 'duplicates'); + const ambiguous = directoryMode === 'duplicates' + ? duplicateLabels(this.entries) + : new Set(); + const showDirectory = (entry: TabEntry) => directoryMode === 'always' || + (directoryMode === 'duplicates' && ambiguous.has(entry.label)); + void this.view.webview.postMessage({ type: 'rows', followActive, - showProject: cfg.get('showProject', true), - showDirectory: cfg.get('showDirectory', true), - showColumn, rowSpacing: cfg.get('rowSpacing', 2), borderWidth: cfg.get('borderWidth', 3), rows: this.entries.map((entry): Row => ({ id: entry.id, label: entry.label, - dir: entry.dir, - project: entry.project ?? '', + detail: describeEntry(entry, { + showProject: cfg.get('showProject', true), + showDirectory: showDirectory(entry), + showColumn, + }), colorIndex: cfg.get('colorByProject', true) ? projectColorIndex(entry.project) : 0,