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 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 {
|
.name {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -102,12 +104,14 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dir {
|
.detail {
|
||||||
flex: 0 1 auto;
|
flex: 0 1000 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 45%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
text-align: right;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-15
@@ -98,21 +98,11 @@
|
|||||||
}
|
}
|
||||||
div.appendChild(name);
|
div.appendChild(name);
|
||||||
|
|
||||||
const trailing = [];
|
if (row.detail) {
|
||||||
if (data.showDirectory && row.dir) {
|
const detail = document.createElement('span');
|
||||||
trailing.push(row.dir);
|
detail.className = 'detail';
|
||||||
}
|
detail.textContent = row.detail;
|
||||||
if (data.showProject && row.project) {
|
div.appendChild(detail);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dirty = document.createElement('span');
|
const dirty = document.createElement('span');
|
||||||
|
|||||||
+14
-4
@@ -111,12 +111,22 @@
|
|||||||
"verticalTabs.showProject": {
|
"verticalTabs.showProject": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"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": {
|
"verticalTabs.showDirectory": {
|
||||||
"type": "boolean",
|
"type": "string",
|
||||||
"default": true,
|
"enum": [
|
||||||
"description": "Show the containing directory beside the file, for files with the same name."
|
"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": {
|
"verticalTabs.colorByProject": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
+26
-5
@@ -130,20 +130,41 @@ export function sortEntries(
|
|||||||
return indexed.map(item => item.entry);
|
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(
|
export function describeEntry(
|
||||||
entry: TabEntry,
|
entry: TabEntry,
|
||||||
options: { showProject: boolean; showDirectory: boolean; showColumn: boolean },
|
options: { showProject: boolean; showDirectory: boolean; showColumn: boolean },
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (options.showDirectory && entry.dir) {
|
const dir = options.showDirectory ? entry.dir : '';
|
||||||
parts.push(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) {
|
if (project && !dirCoversProject) {
|
||||||
parts.push(`[${entry.project}]`);
|
parts.push(project);
|
||||||
}
|
}
|
||||||
if (options.showColumn && entry.column !== undefined) {
|
if (options.showColumn && entry.column !== undefined) {
|
||||||
parts.push(`#${entry.column}`);
|
parts.push(`#${entry.column}`);
|
||||||
}
|
}
|
||||||
return parts.join(' ');
|
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 { closeOthers, closeTab, togglePin } from '../../actions';
|
||||||
import { projectColorIndex, projectOf } from '../../projects';
|
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. */
|
/** Files in three different projects of the test solution. */
|
||||||
const FILES = [
|
const FILES = [
|
||||||
@@ -240,23 +242,53 @@ suite('Tab list', () => {
|
|||||||
assert.ok(settings.id.startsWith('1:label:'), `unexpected id ${settings.id}`);
|
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 = {
|
const entry = {
|
||||||
label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime', column: 1,
|
label: 'Profiler.cs', dir: 'Nerfed.Runtime', project: 'Nerfed.Runtime', column: 1,
|
||||||
} as TabEntry;
|
} as TabEntry;
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
describeEntry(entry, { showProject: true, showDirectory: true, showColumn: false }),
|
|
||||||
'Nerfed.Runtime [Nerfed.Runtime]');
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
describeEntry(entry, { showProject: false, showDirectory: true, showColumn: false }),
|
describeEntry(entry, { showProject: false, showDirectory: true, showColumn: false }),
|
||||||
'Nerfed.Runtime');
|
'Nerfed.Runtime');
|
||||||
|
assert.strictEqual(
|
||||||
|
describeEntry(entry, { showProject: true, showDirectory: false, showColumn: false }),
|
||||||
|
'Nerfed.Runtime');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: true }),
|
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: true }),
|
||||||
'#1');
|
'#1');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: false }),
|
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', () => {
|
test('sorting is stable for entries that compare equal', () => {
|
||||||
|
|||||||
+20
-8
@@ -1,13 +1,16 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { projectColorIndex, projectOf } from './projects';
|
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. */
|
/** One row, as the webview needs it. */
|
||||||
interface Row {
|
interface Row {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
dir: string;
|
/** Dimmed trailing text: directory and/or project, already deduplicated. */
|
||||||
project: string;
|
detail: string;
|
||||||
/** Palette slot, 0 for no project. */
|
/** Palette slot, 0 for no project. */
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
uri: boolean;
|
uri: boolean;
|
||||||
@@ -127,19 +130,28 @@ export class TabsView implements vscode.WebviewViewProvider {
|
|||||||
const showColumn = cfg.get<boolean>('allGroups', true) &&
|
const showColumn = cfg.get<boolean>('allGroups', true) &&
|
||||||
vscode.window.tabGroups.all.length > 1;
|
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({
|
void this.view.webview.postMessage({
|
||||||
type: 'rows',
|
type: 'rows',
|
||||||
followActive,
|
followActive,
|
||||||
showProject: cfg.get<boolean>('showProject', true),
|
|
||||||
showDirectory: cfg.get<boolean>('showDirectory', true),
|
|
||||||
showColumn,
|
|
||||||
rowSpacing: cfg.get<number>('rowSpacing', 2),
|
rowSpacing: cfg.get<number>('rowSpacing', 2),
|
||||||
borderWidth: cfg.get<number>('borderWidth', 3),
|
borderWidth: cfg.get<number>('borderWidth', 3),
|
||||||
rows: this.entries.map((entry): Row => ({
|
rows: this.entries.map((entry): Row => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
label: entry.label,
|
label: entry.label,
|
||||||
dir: entry.dir,
|
detail: describeEntry(entry, {
|
||||||
project: entry.project ?? '',
|
showProject: cfg.get<boolean>('showProject', true),
|
||||||
|
showDirectory: showDirectory(entry),
|
||||||
|
showColumn,
|
||||||
|
}),
|
||||||
colorIndex: cfg.get<boolean>('colorByProject', true)
|
colorIndex: cfg.get<boolean>('colorByProject', true)
|
||||||
? projectColorIndex(entry.project)
|
? projectColorIndex(entry.project)
|
||||||
: 0,
|
: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user