Fix the pin toggle, stale selection, and project detection in Unity repos

Three problems visible on a real project:

- The inline pin button showed twice on every unpinned row: the `when`
  clauses matched /pinned/ and /unpinned/, and "unpinned" contains "pinned".
  Context values now use unambiguous `pin:on` / `pin:off` tokens, and both
  commands call one togglePin — the pair exists only so the button's icon can
  reflect the current state.
- The list selected the tab you just left. followActiveTab read `isActive`
  from the entry snapshot, and after a refresh the tree has not necessarily
  asked for children yet, so it matched against the previous build. It now
  rebuilds the list and compares against the live active tab.
- Every file in a Unity repo resolved to an arbitrary project. Unity generates
  one .csproj per assembly into the repo root, and the walk took whichever
  readDirectory happened to return first. Matches are now sorted, a project
  file named after its directory wins, and .asmdef — which lives in the
  assembly's own directory and is what actually defines a Unity assembly — is
  a default marker, so it is found before reaching the root.

Palette widened from 8 to 12 hues, since a hash-assigned colour collides more
often than it looks like it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 18:24:36 +02:00
co-authored by Claude Opus 5
parent 7e104450e8
commit 2e9a43f78b
3 changed files with 90 additions and 31 deletions
+49 -12
View File
@@ -115,12 +115,12 @@
"view/item/context": [
{
"command": "verticalTabs.pin",
"when": "view == verticalTabs.tabs && viewItem =~ /unpinned/",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
"group": "inline@1"
},
{
"command": "verticalTabs.unpin",
"when": "view == verticalTabs.tabs && viewItem =~ /pinned/",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
"group": "inline@1"
},
{
@@ -130,12 +130,12 @@
},
{
"command": "verticalTabs.pin",
"when": "view == verticalTabs.tabs && viewItem =~ /unpinned/",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:off/",
"group": "1_state@1"
},
{
"command": "verticalTabs.unpin",
"when": "view == verticalTabs.tabs && viewItem =~ /pinned/",
"when": "view == verticalTabs.tabs && viewItem =~ /pin:on/",
"group": "1_state@1"
},
{
@@ -239,6 +239,7 @@
"type": "string"
},
"default": [
".asmdef",
".csproj",
".fsproj",
".vbproj",
@@ -259,7 +260,7 @@
"description": "Project colour 1.",
"defaults": {
"dark": "#4ec9b0",
"light": "#267f99",
"light": "#227d68",
"highContrast": "#4ec9b0"
}
},
@@ -268,7 +269,7 @@
"description": "Project colour 2.",
"defaults": {
"dark": "#dcdcaa",
"light": "#795e26",
"light": "#7a6e17",
"highContrast": "#dcdcaa"
}
},
@@ -277,7 +278,7 @@
"description": "Project colour 3.",
"defaults": {
"dark": "#9cdcfe",
"light": "#001080",
"light": "#0b5f92",
"highContrast": "#9cdcfe"
}
},
@@ -286,7 +287,7 @@
"description": "Project colour 4.",
"defaults": {
"dark": "#c586c0",
"light": "#af00db",
"light": "#8b338a",
"highContrast": "#c586c0"
}
},
@@ -295,7 +296,7 @@
"description": "Project colour 5.",
"defaults": {
"dark": "#ce9178",
"light": "#a31515",
"light": "#a3441f",
"highContrast": "#ce9178"
}
},
@@ -304,7 +305,7 @@
"description": "Project colour 6.",
"defaults": {
"dark": "#b5cea8",
"light": "#098658",
"light": "#3c7a2e",
"highContrast": "#b5cea8"
}
},
@@ -313,7 +314,7 @@
"description": "Project colour 7.",
"defaults": {
"dark": "#569cd6",
"light": "#0000ff",
"light": "#1d4f91",
"highContrast": "#569cd6"
}
},
@@ -322,10 +323,46 @@
"description": "Project colour 8.",
"defaults": {
"dark": "#d7ba7d",
"light": "#b8860b",
"light": "#8a6512",
"highContrast": "#d7ba7d"
}
},
{
"id": "verticalTabs.project9",
"description": "Project colour 9.",
"defaults": {
"dark": "#f28b82",
"light": "#b3261e",
"highContrast": "#f28b82"
}
},
{
"id": "verticalTabs.project10",
"description": "Project colour 10.",
"defaults": {
"dark": "#a5d6a7",
"light": "#2e7d32",
"highContrast": "#a5d6a7"
}
},
{
"id": "verticalTabs.project11",
"description": "Project colour 11.",
"defaults": {
"dark": "#b39ddb",
"light": "#5e35b1",
"highContrast": "#b39ddb"
}
},
{
"id": "verticalTabs.project12",
"description": "Project colour 12.",
"defaults": {
"dark": "#80cbc4",
"light": "#00695c",
"highContrast": "#80cbc4"
}
},
{
"id": "verticalTabs.noProject",
"description": "Colour for files that belong to no project.",
+23 -10
View File
@@ -92,12 +92,14 @@ class TabsProvider implements vscode.TreeDataProvider<TabEntry> {
item.iconPath = new vscode.ThemeIcon('circle-filled');
}
// Tokens have to be unambiguous as substrings: a `when` clause matching
// /pinned/ would also match "unpinned", showing both pin and unpin at once.
const state = [
entry.isPinned ? 'pinned' : 'unpinned',
entry.isPinned ? 'pin:on' : 'pin:off',
entry.uri ? 'file' : 'other',
entry.canActivate ? 'activatable' : 'inert',
];
item.contextValue = state.join('.');
item.contextValue = state.join(' ');
const tooltip = new vscode.MarkdownString();
tooltip.appendMarkdown(`**${entry.label}**${entry.isDirty ? ' — unsaved' : ''}\n\n`);
@@ -166,10 +168,8 @@ async function openTab(tab: vscode.Tab): Promise<void> {
* VS Code can only pin the *active* editor, so pinning another tab means focusing it
* first. The focus lands where the user clicked anyway, which is what they expect.
*/
async function setPinned(tab: vscode.Tab, pinned: boolean): Promise<void> {
if (tab.isPinned === pinned) {
return;
}
async function togglePin(tab: vscode.Tab): Promise<void> {
const pinned = !tab.isPinned;
if (!tab.isActive) {
if (!canActivate(tab)) {
void vscode.window.showInformationMessage(
@@ -178,6 +178,8 @@ async function setPinned(tab: vscode.Tab, pinned: boolean): Promise<void> {
}
await openTab(tab);
}
// Both the pin and unpin commands land here: the two exist only so the inline
// button's icon can reflect the current state.
await vscode.commands.executeCommand(
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
}
@@ -194,12 +196,23 @@ export function activate(context: vscode.ExtensionContext): void {
view.description = count > 0 ? String(count) : undefined;
};
/** Keeps the list's selection on whatever tab is active. */
/**
* Keeps the list's selection on whatever tab is active.
*
* The entries hold a snapshot of `isActive`, and after a refresh the tree has not
* necessarily asked for children yet — so rebuild the list first and match against
* the live active tab rather than a stale flag.
*/
const followActiveTab = async () => {
if (!view.visible) {
return;
}
const active = provider.find(entry => entry.isActive);
const activeTab = vscode.window.tabGroups.activeTabGroup.activeTab;
if (!activeTab) {
return;
}
const entries = await provider.getChildren();
const active = entries.find(entry => entry.tab === activeTab);
if (active && !view.selection.some(entry => entry.id === active.id)) {
try {
await view.reveal(active, { select: true, focus: false });
@@ -271,11 +284,11 @@ export function activate(context: vscode.ExtensionContext): void {
}),
vscode.commands.registerCommand('verticalTabs.pin', (arg?: unknown) => {
const tab = tabOf(arg);
return tab ? setPinned(tab, true) : undefined;
return tab ? togglePin(tab) : undefined;
}),
vscode.commands.registerCommand('verticalTabs.unpin', (arg?: unknown) => {
const tab = tabOf(arg);
return tab ? setPinned(tab, false) : undefined;
return tab ? togglePin(tab) : undefined;
}),
vscode.commands.registerCommand('verticalTabs.copyPath', async (arg?: unknown) => {
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
+18 -9
View File
@@ -2,11 +2,11 @@ import * as vscode from 'vscode';
import * as path from 'path';
/** How many project colours `contributes.colors` declares. */
const PALETTE_SIZE = 8;
const PALETTE_SIZE = 12;
function markers(): string[] {
return vscode.workspace.getConfiguration('verticalTabs')
.get<string[]>('projectFiles', ['.csproj']);
.get<string[]>('projectFiles', ['.asmdef', '.csproj']);
}
/**
@@ -46,14 +46,23 @@ export function projectOf(uri: vscode.Uri): Promise<string | undefined> {
return undefined;
}
const match = entries.find(([name, type]) => type === vscode.FileType.File &&
(extensions.has(path.extname(name).toLowerCase()) || names.has(name.toLowerCase())));
if (match) {
const [name] = match;
// A directory can hold several project files — a Unity repo root has one
// generated .csproj per assembly. Sort so the choice is at least stable, and
// prefer one named after its directory, which is the usual convention.
const matches = entries
.filter(([name, type]) => type === vscode.FileType.File &&
(extensions.has(path.extname(name).toLowerCase()) || names.has(name.toLowerCase())))
.map(([name]) => name)
.sort((a, b) => a.localeCompare(b));
if (matches.length > 0) {
const dirName = path.basename(current);
const match = matches.find(
name => path.basename(name, path.extname(name)) === dirName) ?? matches[0];
// `Foo.csproj` names itself; `package.json` names its directory.
return extensions.has(path.extname(name).toLowerCase())
? path.basename(name, path.extname(name))
: path.basename(current);
return extensions.has(path.extname(match).toLowerCase())
? path.basename(match, path.extname(match))
: dirName;
}
if (!root || current === root || path.dirname(current) === current) {