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:
+6
-2
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user