Give the file name the row, and stop repeating the project

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 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 18:42:14 +02:00
co-authored by Claude Opus 5
parent 95799c6353
commit 925e55d619
6 changed files with 109 additions and 40 deletions
+6 -2
View File
@@ -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;
}
+5 -15
View File
@@ -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');
+14 -4
View File
@@ -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",
+26 -5
View File
@@ -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<string> {
const counts = new Map<string, number>();
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));
}
+38 -6
View File
@@ -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', () => {
+20 -8
View File
@@ -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<boolean>('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<DirectoryMode>('showDirectory', 'duplicates');
const ambiguous = directoryMode === 'duplicates'
? duplicateLabels(this.entries)
: new Set<string>();
const showDirectory = (entry: TabEntry) => directoryMode === 'always' ||
(directoryMode === 'duplicates' && ambiguous.has(entry.label));
void this.view.webview.postMessage({
type: 'rows',
followActive,
showProject: cfg.get<boolean>('showProject', true),
showDirectory: cfg.get<boolean>('showDirectory', true),
showColumn,
rowSpacing: cfg.get<number>('rowSpacing', 2),
borderWidth: cfg.get<number>('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<boolean>('showProject', true),
showDirectory: showDirectory(entry),
showColumn,
}),
colorIndex: cfg.get<boolean>('colorByProject', true)
? projectColorIndex(entry.project)
: 0,