Vertical Tabs: open editors as a project-coloured flat list
For working with the tab bar off, where a side list is the tab bar. VS Code's Open Editors view already does pinning; what it does not do is colour by project, and it is a tree rather than a flat list. - A Tabs view in the Explorer: one row per open editor, no group nodes. A TreeDataProvider whose items have no children renders as a list, since there is no separate list API. - Each row's icon is tinted by the project containing the file — the nearest .csproj/.fsproj/.vbproj, package.json, Cargo.toml, go.mod, pyproject.toml, pom.xml or build.gradle above it, configurable. The colour comes from a hash of the project name, so a project keeps its colour across sessions rather than depending on the order tabs happened to open in. Eight contributed theme colours, all overridable. - Sort by open order, project (so the colours run in blocks), or file name, with pinned tabs lifted to the top. - Close, close others (keeping pinned), pin/unpin, copy path, reveal. - The list follows the active editor. Two API limits shape this, both covered by tests: - Nothing can activate an arbitrary tab. A file-backed tab is focused by re-opening its resource in its own group, which handles text, diffs, notebooks and custom editors. Webview tabs have no resource, so they are listed and marked as unfocusable instead of pretending. - Only the *active* editor can be pinned, so pinning another row focuses it first. Commands act on live vscode.Tab objects rather than the row snapshots handed to them. The first cut compared against a snapshot's isPinned and so silently did nothing on unpin, and read the provider's cached list, which is empty until the tree has been rendered. Also worth recording, both found by the tests: VS Code moves a pinned tab to the front of its group, so `open` order shows pinned first regardless of the pinnedFirst setting; and closeAllEditors leaves pinned editors open, so test cleanup closes through the tab API instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
out
|
||||
*.vsix
|
||||
.vscode-test
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/out/**/*.js"],
|
||||
"preLaunchTask": "npm: compile"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "compile",
|
||||
"problemMatcher": "$tsc",
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
out/test/**
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
.gitignore
|
||||
package-lock.json
|
||||
@@ -0,0 +1,15 @@
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
|
||||
to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
||||
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Vertical Tabs
|
||||
|
||||
Your open editors as a flat vertical list, colour-coded by project. Built for working with the tab bar turned
|
||||
off (`"workbench.editor.showTabs": "none"`), where a side list *is* the tab bar.
|
||||
|
||||
It shows up as a **Tabs** view in the Explorer — drag it wherever you want it, including the secondary side bar.
|
||||
|
||||
## What it adds over the built-in Open Editors view
|
||||
|
||||
- **A colour per project.** Each row's icon is tinted by the project that contains the file — the nearest
|
||||
`.csproj`/`.fsproj`/`.vbproj`, `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, `pom.xml` or
|
||||
`build.gradle` above it. The colour comes from a hash of the project name, so a project keeps the same colour
|
||||
across sessions and windows.
|
||||
- **A flat list, not a tree.** One row per open editor, no group nodes to expand.
|
||||
- **Sorting.** Open order (matching the tab bar), by project so the colours run in blocks, or by file name.
|
||||
Pinned tabs stay on top.
|
||||
- **The project name beside the file**, along with the directory when two files share a name.
|
||||
|
||||
Pinning is VS Code's own — this view just exposes it per row, and pinned tabs sort first.
|
||||
|
||||
## Usage
|
||||
|
||||
| Action | How |
|
||||
| --- | --- |
|
||||
| Focus a tab | Click the row |
|
||||
| Close | The `×` on hover, or the context menu |
|
||||
| Pin / unpin | The pin icon on hover, or the context menu |
|
||||
| Close others | Context menu — keeps pinned tabs |
|
||||
| Change sorting | The view's `…` menu |
|
||||
|
||||
The list follows the active editor, so whatever you switch to is selected.
|
||||
|
||||
## Settings
|
||||
|
||||
- `verticalTabs.order` — `open` (default), `project`, or `name`
|
||||
- `verticalTabs.pinnedFirst` — list pinned tabs above the rest (default `true`)
|
||||
- `verticalTabs.showProject` — show the project name beside the file (default `true`)
|
||||
- `verticalTabs.showDirectory` — show the directory beside the file (default `true`)
|
||||
- `verticalTabs.colorByProject` — tint each row's icon by project (default `true`)
|
||||
- `verticalTabs.allGroups` — list every editor group, not just the active one (default `true`)
|
||||
- `verticalTabs.projectFiles` — file names or extensions that mark a project root
|
||||
|
||||
The eight project colours are theme colours (`verticalTabs.project1` … `project8`, plus
|
||||
`verticalTabs.noProject`), so you can override any of them in `workbench.colorCustomizations`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Webview tabs cannot be focused from the list.** There is no API to activate an arbitrary tab; a file-backed
|
||||
tab is focused by re-opening its resource, which webviews (Settings, Keyboard Shortcuts, Markdown preview,
|
||||
extension panels) do not have. They are listed, with a tooltip saying so, but clicking them does nothing.
|
||||
- **Pinning another tab focuses it first.** VS Code can only pin the *active* editor, so pinning a row means
|
||||
opening it and then pinning. Clicking a row's pin icon therefore also switches to it.
|
||||
- **No drag to reorder.** The list reflects VS Code's own tab order; it cannot rearrange it. Note that VS Code
|
||||
moves a pinned tab to the front of its group, so `open` order shows pinned tabs first regardless of
|
||||
`verticalTabs.pinnedFirst` — that setting only changes the `project` and `name` orders.
|
||||
- **The colour replaces the file-type icon.** Each row shows a tinted dot instead of the usual `.cs`/`.json`
|
||||
icon. Set `verticalTabs.colorByProject` to `false` to get file icons back and keep the project name in the
|
||||
description.
|
||||
- The colour is on the row's icon, not its label. Labels get the theme's own colouring (including Git status),
|
||||
which an extension cannot override without also recolouring the Explorer.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm test` launches VS Code with all other extensions disabled on a multi-project folder, opens editors, and
|
||||
asserts on the resulting list — projects resolved, ordering, pinning through the commands, diff tabs reporting
|
||||
their modified side, and webview tabs correctly reporting that they cannot be activated.
|
||||
|
||||
Point it at your own multi-project folder with `VERTICAL_TABS_TEST_FOLDER`:
|
||||
|
||||
```bash
|
||||
VERTICAL_TABS_TEST_FOLDER=/path/to/solution npm test
|
||||
```
|
||||
Generated
+1376
File diff suppressed because it is too large
Load Diff
+355
@@ -0,0 +1,355 @@
|
||||
{
|
||||
"name": "vertical-tabs",
|
||||
"displayName": "Vertical Tabs",
|
||||
"description": "Open editors as a flat vertical list, colour-coded by project, with pinning. For people who turn the tab bar off.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "local",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"tabs",
|
||||
"vertical tabs",
|
||||
"open editors",
|
||||
"csharp",
|
||||
"visual studio"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"views": {
|
||||
"explorer": [
|
||||
{
|
||||
"id": "verticalTabs.tabs",
|
||||
"name": "Tabs",
|
||||
"contextualTitle": "Tabs",
|
||||
"visibility": "visible"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "verticalTabs.tabs",
|
||||
"contents": "No editors are open."
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "verticalTabs.open",
|
||||
"title": "Open Tab",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.close",
|
||||
"title": "Close",
|
||||
"category": "Tabs",
|
||||
"icon": "$(close)"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.closeOthers",
|
||||
"title": "Close Others",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.pin",
|
||||
"title": "Pin",
|
||||
"category": "Tabs",
|
||||
"icon": "$(pin)"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.unpin",
|
||||
"title": "Unpin",
|
||||
"category": "Tabs",
|
||||
"icon": "$(pinned)"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.copyPath",
|
||||
"title": "Copy Path",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.revealInExplorer",
|
||||
"title": "Reveal in Explorer View",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.setOrderOpen",
|
||||
"title": "Sort by Open Order",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.setOrderProject",
|
||||
"title": "Sort by Project",
|
||||
"category": "Tabs"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.setOrderName",
|
||||
"title": "Sort by File Name",
|
||||
"category": "Tabs"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "verticalTabs.setOrderOpen",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "1_order@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.setOrderProject",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "1_order@2"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.setOrderName",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "1_order@3"
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "verticalTabs.pin",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /unpinned/",
|
||||
"group": "inline@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.unpin",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /pinned/",
|
||||
"group": "inline@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.close",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "inline@2"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.pin",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /unpinned/",
|
||||
"group": "1_state@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.unpin",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /pinned/",
|
||||
"group": "1_state@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.close",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "2_close@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.closeOthers",
|
||||
"when": "view == verticalTabs.tabs",
|
||||
"group": "2_close@2"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.copyPath",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
|
||||
"group": "3_path@1"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.revealInExplorer",
|
||||
"when": "view == verticalTabs.tabs && viewItem =~ /file/",
|
||||
"group": "3_path@2"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "verticalTabs.open",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.close",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.closeOthers",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.pin",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.unpin",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.copyPath",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "verticalTabs.revealInExplorer",
|
||||
"when": "false"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Vertical Tabs",
|
||||
"properties": {
|
||||
"verticalTabs.order": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"open",
|
||||
"project",
|
||||
"name"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Keep the order the editors are open in, matching the tab bar.",
|
||||
"Group by project, so the colours run in blocks.",
|
||||
"Sort by file name."
|
||||
],
|
||||
"default": "open",
|
||||
"description": "How the tab list is ordered. Pinned tabs always come first."
|
||||
},
|
||||
"verticalTabs.pinnedFirst": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "List pinned tabs above the rest."
|
||||
},
|
||||
"verticalTabs.showProject": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show the containing project name beside the file."
|
||||
},
|
||||
"verticalTabs.showDirectory": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show the containing directory beside the file, for files with the same name."
|
||||
},
|
||||
"verticalTabs.colorByProject": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Colour each tab's icon by its containing project."
|
||||
},
|
||||
"verticalTabs.allGroups": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "List the tabs of every editor group. When off, only the active group is listed."
|
||||
},
|
||||
"verticalTabs.projectFiles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [
|
||||
".csproj",
|
||||
".fsproj",
|
||||
".vbproj",
|
||||
"package.json",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"pom.xml",
|
||||
"build.gradle"
|
||||
],
|
||||
"description": "File names or extensions that mark a project root. The nearest match above a file names its project."
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"id": "verticalTabs.project1",
|
||||
"description": "Project colour 1.",
|
||||
"defaults": {
|
||||
"dark": "#4ec9b0",
|
||||
"light": "#267f99",
|
||||
"highContrast": "#4ec9b0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project2",
|
||||
"description": "Project colour 2.",
|
||||
"defaults": {
|
||||
"dark": "#dcdcaa",
|
||||
"light": "#795e26",
|
||||
"highContrast": "#dcdcaa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project3",
|
||||
"description": "Project colour 3.",
|
||||
"defaults": {
|
||||
"dark": "#9cdcfe",
|
||||
"light": "#001080",
|
||||
"highContrast": "#9cdcfe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project4",
|
||||
"description": "Project colour 4.",
|
||||
"defaults": {
|
||||
"dark": "#c586c0",
|
||||
"light": "#af00db",
|
||||
"highContrast": "#c586c0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project5",
|
||||
"description": "Project colour 5.",
|
||||
"defaults": {
|
||||
"dark": "#ce9178",
|
||||
"light": "#a31515",
|
||||
"highContrast": "#ce9178"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project6",
|
||||
"description": "Project colour 6.",
|
||||
"defaults": {
|
||||
"dark": "#b5cea8",
|
||||
"light": "#098658",
|
||||
"highContrast": "#b5cea8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project7",
|
||||
"description": "Project colour 7.",
|
||||
"defaults": {
|
||||
"dark": "#569cd6",
|
||||
"light": "#0000ff",
|
||||
"highContrast": "#569cd6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.project8",
|
||||
"description": "Project colour 8.",
|
||||
"defaults": {
|
||||
"dark": "#d7ba7d",
|
||||
"light": "#b8860b",
|
||||
"highContrast": "#d7ba7d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "verticalTabs.noProject",
|
||||
"description": "Colour for files that belong to no project.",
|
||||
"defaults": {
|
||||
"dark": "descriptionForeground",
|
||||
"light": "descriptionForeground",
|
||||
"highContrast": "descriptionForeground"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run compile",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile",
|
||||
"test": "node ./out/test/runTest.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vscode/test-electron": "^2.4.1",
|
||||
"mocha": "^10.4.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { clearProjectCache, projectColor, projectOf } from './projects';
|
||||
import { Order, TabEntry, buildEntries, canActivate, describeEntry, tabUri } from './tabs';
|
||||
|
||||
const VIEW_ID = 'verticalTabs.tabs';
|
||||
|
||||
function config() {
|
||||
return vscode.workspace.getConfiguration('verticalTabs');
|
||||
}
|
||||
|
||||
/**
|
||||
* The open tabs as a flat list.
|
||||
*
|
||||
* A `TreeDataProvider` with only root items renders as a list — there is no separate
|
||||
* list API — so nothing here has children.
|
||||
*/
|
||||
class TabsProvider implements vscode.TreeDataProvider<TabEntry> {
|
||||
private readonly changed = new vscode.EventEmitter<void>();
|
||||
readonly onDidChangeTreeData = this.changed.event;
|
||||
private entries: TabEntry[] = [];
|
||||
|
||||
refresh(): void {
|
||||
this.changed.fire();
|
||||
}
|
||||
|
||||
/** The entry for a tab, so commands can act on the current list. */
|
||||
find(predicate: (entry: TabEntry) => boolean): TabEntry | undefined {
|
||||
return this.entries.find(predicate);
|
||||
}
|
||||
|
||||
get all(): readonly TabEntry[] {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
async getChildren(element?: TabEntry): Promise<TabEntry[]> {
|
||||
if (element) {
|
||||
return []; // flat list
|
||||
}
|
||||
const cfg = config();
|
||||
const groups = cfg.get<boolean>('allGroups', true)
|
||||
? vscode.window.tabGroups.all
|
||||
: [vscode.window.tabGroups.activeTabGroup];
|
||||
|
||||
// Resolve every project once, then build the list synchronously.
|
||||
const projects = new Map<string, string | undefined>();
|
||||
if (cfg.get<boolean>('showProject', true) || cfg.get<boolean>('colorByProject', true) ||
|
||||
cfg.get<Order>('order', 'open') === 'project') {
|
||||
const uris = new Map<string, vscode.Uri>();
|
||||
for (const group of groups) {
|
||||
for (const tab of group.tabs) {
|
||||
const uri = tabUri(tab);
|
||||
if (uri) {
|
||||
uris.set(uri.toString(), uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([...uris].map(async ([key, uri]) => {
|
||||
projects.set(key, await projectOf(uri));
|
||||
}));
|
||||
}
|
||||
|
||||
this.entries = buildEntries(groups, projects, {
|
||||
order: cfg.get<Order>('order', 'open'),
|
||||
pinnedFirst: cfg.get<boolean>('pinnedFirst', true),
|
||||
});
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
getParent(): TabEntry | undefined {
|
||||
return undefined; // required for TreeView.reveal on a flat list
|
||||
}
|
||||
|
||||
getTreeItem(entry: TabEntry): vscode.TreeItem {
|
||||
const cfg = config();
|
||||
const item = new vscode.TreeItem(entry.label);
|
||||
item.id = entry.id;
|
||||
item.description = describeEntry(entry, {
|
||||
showProject: cfg.get<boolean>('showProject', true),
|
||||
showDirectory: cfg.get<boolean>('showDirectory', true),
|
||||
showColumn: vscode.window.tabGroups.all.length > 1 && cfg.get<boolean>('allGroups', true),
|
||||
});
|
||||
|
||||
// resourceUri gives the file icon and the theme's file-name colouring; the
|
||||
// project colour rides on the icon instead so the two do not fight.
|
||||
if (entry.uri) {
|
||||
item.resourceUri = entry.uri;
|
||||
}
|
||||
if (cfg.get<boolean>('colorByProject', true) && entry.uri) {
|
||||
item.iconPath = new vscode.ThemeIcon(
|
||||
entry.isDirty ? 'circle-filled' : 'circle-outline', projectColor(entry.project));
|
||||
} else if (entry.isDirty) {
|
||||
item.iconPath = new vscode.ThemeIcon('circle-filled');
|
||||
}
|
||||
|
||||
const state = [
|
||||
entry.isPinned ? 'pinned' : 'unpinned',
|
||||
entry.uri ? 'file' : 'other',
|
||||
entry.canActivate ? 'activatable' : 'inert',
|
||||
];
|
||||
item.contextValue = state.join('.');
|
||||
|
||||
const tooltip = new vscode.MarkdownString();
|
||||
tooltip.appendMarkdown(`**${entry.label}**${entry.isDirty ? ' — unsaved' : ''}\n\n`);
|
||||
if (entry.uri) {
|
||||
tooltip.appendMarkdown(`${vscode.workspace.asRelativePath(entry.uri, true)}\n\n`);
|
||||
}
|
||||
if (entry.project) {
|
||||
tooltip.appendMarkdown(`Project: ${entry.project}\n\n`);
|
||||
}
|
||||
if (!entry.canActivate) {
|
||||
tooltip.appendMarkdown('_This kind of tab cannot be focused from here._');
|
||||
}
|
||||
item.tooltip = tooltip;
|
||||
|
||||
if (entry.canActivate) {
|
||||
item.command = {
|
||||
command: 'verticalTabs.open',
|
||||
title: 'Open Tab',
|
||||
arguments: [entry],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.changed.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings a tab to the front by re-opening its resource in its own group. There is no
|
||||
* API to activate a tab directly.
|
||||
*
|
||||
* Everything below takes a live `vscode.Tab` rather than a `TabEntry`: an entry is a
|
||||
* snapshot taken when the row was drawn, and acting on stale state silently does the
|
||||
* wrong thing (or nothing).
|
||||
*/
|
||||
async function openTab(tab: vscode.Tab): Promise<void> {
|
||||
const uri = tabUri(tab);
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
const column = tab.group.viewColumn ?? vscode.ViewColumn.Active;
|
||||
const input = tab.input;
|
||||
|
||||
if (input instanceof vscode.TabInputNotebook) {
|
||||
const notebook = await vscode.workspace.openNotebookDocument(uri);
|
||||
await vscode.window.showNotebookDocument(notebook, { viewColumn: column, preview: false });
|
||||
return;
|
||||
}
|
||||
if (input instanceof vscode.TabInputTextDiff) {
|
||||
await vscode.commands.executeCommand('vscode.diff', input.original, input.modified,
|
||||
tab.label, { viewColumn: column, preview: false });
|
||||
return;
|
||||
}
|
||||
if (input instanceof vscode.TabInputCustom) {
|
||||
await vscode.commands.executeCommand('vscode.openWith', uri, input.viewType,
|
||||
{ viewColumn: column, preview: false });
|
||||
return;
|
||||
}
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
if (!tab.isActive) {
|
||||
if (!canActivate(tab)) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`Cannot pin “${tab.label}”: this kind of tab has to be focused first.`);
|
||||
return;
|
||||
}
|
||||
await openTab(tab);
|
||||
}
|
||||
await vscode.commands.executeCommand(
|
||||
pinned ? 'workbench.action.pinEditor' : 'workbench.action.unpinEditor');
|
||||
}
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const provider = new TabsProvider();
|
||||
const view = vscode.window.createTreeView(VIEW_ID, {
|
||||
treeDataProvider: provider,
|
||||
showCollapseAll: false,
|
||||
});
|
||||
|
||||
const updateTitle = () => {
|
||||
const count = vscode.window.tabGroups.all.reduce((n, g) => n + g.tabs.length, 0);
|
||||
view.description = count > 0 ? String(count) : undefined;
|
||||
};
|
||||
|
||||
/** Keeps the list's selection on whatever tab is active. */
|
||||
const followActiveTab = async () => {
|
||||
if (!view.visible) {
|
||||
return;
|
||||
}
|
||||
const active = provider.find(entry => entry.isActive);
|
||||
if (active && !view.selection.some(entry => entry.id === active.id)) {
|
||||
try {
|
||||
await view.reveal(active, { select: true, focus: false });
|
||||
} catch {
|
||||
// reveal throws if the item is gone; the next refresh will sort it out.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
provider.refresh();
|
||||
updateTitle();
|
||||
void followActiveTab();
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab a command should act on: the row it was invoked from, else the row selected
|
||||
* in the list, else whatever editor is active. Never depends on the tree having been
|
||||
* rendered.
|
||||
*/
|
||||
const tabOf = (arg: unknown): vscode.Tab | undefined => {
|
||||
const entry = arg as TabEntry | undefined;
|
||||
return entry?.tab ?? view.selection[0]?.tab ??
|
||||
vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||
};
|
||||
|
||||
const liveTabs = (): vscode.Tab[] =>
|
||||
vscode.window.tabGroups.all.flatMap(group => group.tabs);
|
||||
|
||||
context.subscriptions.push(
|
||||
view,
|
||||
provider,
|
||||
|
||||
vscode.window.tabGroups.onDidChangeTabs(refresh),
|
||||
vscode.window.tabGroups.onDidChangeTabGroups(refresh),
|
||||
vscode.window.onDidChangeActiveTextEditor(() => void followActiveTab()),
|
||||
vscode.workspace.onDidChangeWorkspaceFolders(() => {
|
||||
clearProjectCache();
|
||||
refresh();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration(event => {
|
||||
if (event.affectsConfiguration('verticalTabs.projectFiles')) {
|
||||
clearProjectCache();
|
||||
}
|
||||
if (event.affectsConfiguration('verticalTabs')) {
|
||||
refresh();
|
||||
}
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand('verticalTabs.open', (arg?: unknown) => {
|
||||
const tab = tabOf(arg);
|
||||
return tab ? openTab(tab) : undefined;
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.close', async (arg?: unknown) => {
|
||||
const tab = tabOf(arg);
|
||||
if (tab) {
|
||||
await vscode.window.tabGroups.close(tab, true);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.closeOthers', async (arg?: unknown) => {
|
||||
const tab = tabOf(arg);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const others = liveTabs().filter(other => other !== tab && !other.isPinned);
|
||||
if (others.length > 0) {
|
||||
await vscode.window.tabGroups.close(others, true);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.pin', (arg?: unknown) => {
|
||||
const tab = tabOf(arg);
|
||||
return tab ? setPinned(tab, true) : undefined;
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.unpin', (arg?: unknown) => {
|
||||
const tab = tabOf(arg);
|
||||
return tab ? setPinned(tab, false) : undefined;
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.copyPath', async (arg?: unknown) => {
|
||||
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
|
||||
if (uri) {
|
||||
await vscode.env.clipboard.writeText(uri.fsPath);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('verticalTabs.revealInExplorer', async (arg?: unknown) => {
|
||||
const uri = tabOf(arg) && tabUri(tabOf(arg)!);
|
||||
if (uri) {
|
||||
await vscode.commands.executeCommand('revealInExplorer', uri);
|
||||
}
|
||||
}),
|
||||
|
||||
...(['open', 'project', 'name'] as Order[]).map(order =>
|
||||
vscode.commands.registerCommand(
|
||||
`verticalTabs.setOrder${order[0].toUpperCase()}${order.slice(1)}`,
|
||||
() => config().update('order', order, vscode.ConfigurationTarget.Global))),
|
||||
|
||||
view.onDidChangeVisibility(event => {
|
||||
if (event.visible) {
|
||||
refresh();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// nothing beyond the subscriptions
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
|
||||
/** How many project colours `contributes.colors` declares. */
|
||||
const PALETTE_SIZE = 8;
|
||||
|
||||
function markers(): string[] {
|
||||
return vscode.workspace.getConfiguration('verticalTabs')
|
||||
.get<string[]>('projectFiles', ['.csproj']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory -> project name. Cached because the walk hits the file system, and a tab
|
||||
* list is rebuilt on every tab change.
|
||||
*/
|
||||
const cache = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
/**
|
||||
* Walks up from a file looking for a project marker — a file whose name or extension
|
||||
* appears in `verticalTabs.projectFiles`. The project is named after that file's
|
||||
* directory for generic markers like `package.json`, and after the file itself for
|
||||
* per-project files like `Foo.csproj`.
|
||||
*/
|
||||
export function projectOf(uri: vscode.Uri): Promise<string | undefined> {
|
||||
if (uri.scheme !== 'file') {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
const dir = path.dirname(uri.fsPath);
|
||||
const cached = cache.get(dir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const wanted = markers();
|
||||
const extensions = new Set(wanted.filter(m => m.startsWith('.')).map(m => m.toLowerCase()));
|
||||
const names = new Set(wanted.filter(m => !m.startsWith('.')).map(m => m.toLowerCase()));
|
||||
const root = vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath;
|
||||
|
||||
let current = dir;
|
||||
for (let depth = 0; depth < 30; depth++) {
|
||||
let entries: [string, vscode.FileType][];
|
||||
try {
|
||||
entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(current));
|
||||
} catch {
|
||||
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;
|
||||
// `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);
|
||||
}
|
||||
|
||||
if (!root || current === root || path.dirname(current) === current) {
|
||||
return undefined;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
cache.set(dir, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Project lookups are cached per directory; drop everything when the tree may have changed. */
|
||||
export function clearProjectCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable colour for a project name.
|
||||
*
|
||||
* Hashing the name rather than assigning in discovery order keeps a project the same
|
||||
* colour across sessions and between windows, which is the point of colour coding.
|
||||
*/
|
||||
export function projectColor(project: string | undefined): vscode.ThemeColor {
|
||||
if (!project) {
|
||||
return new vscode.ThemeColor('verticalTabs.noProject');
|
||||
}
|
||||
let hash = 0;
|
||||
for (let i = 0; i < project.length; i++) {
|
||||
// djb2, kept in 32 bits.
|
||||
hash = ((hash << 5) - hash + project.charCodeAt(i)) | 0;
|
||||
}
|
||||
const index = Math.abs(hash) % PALETTE_SIZE + 1;
|
||||
return new vscode.ThemeColor(`verticalTabs.project${index}`);
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
|
||||
export type Order = 'open' | 'project' | 'name';
|
||||
|
||||
/** A tab, flattened into what the list needs to draw and act on it. */
|
||||
export interface TabEntry {
|
||||
/** Stable across refreshes so the tree keeps its selection. */
|
||||
id: string;
|
||||
tab: vscode.Tab;
|
||||
label: string;
|
||||
/** The resource behind the tab, when there is one. */
|
||||
uri: vscode.Uri | undefined;
|
||||
/** Workspace-relative directory, empty at the workspace root. */
|
||||
dir: string;
|
||||
project: string | undefined;
|
||||
isActive: boolean;
|
||||
isDirty: boolean;
|
||||
isPinned: boolean;
|
||||
isPreview: boolean;
|
||||
/** Editor group, shown when more than one group is open. */
|
||||
column: vscode.ViewColumn | undefined;
|
||||
/** Whether clicking can bring this tab to the front. Webviews cannot. */
|
||||
canActivate: boolean;
|
||||
}
|
||||
|
||||
/** The resource a tab shows, for the inputs that have one. */
|
||||
export function tabUri(tab: vscode.Tab): vscode.Uri | undefined {
|
||||
const input = tab.input;
|
||||
if (input instanceof vscode.TabInputText) {
|
||||
return input.uri;
|
||||
}
|
||||
if (input instanceof vscode.TabInputTextDiff) {
|
||||
return input.modified;
|
||||
}
|
||||
if (input instanceof vscode.TabInputCustom) {
|
||||
return input.uri;
|
||||
}
|
||||
if (input instanceof vscode.TabInputNotebook) {
|
||||
return input.uri;
|
||||
}
|
||||
if (input instanceof vscode.TabInputNotebookDiff) {
|
||||
return input.modified;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we can bring a tab to the front. There is no API to activate a tab, so this
|
||||
* is only possible by re-opening its resource — which webview and terminal tabs do not
|
||||
* have.
|
||||
*/
|
||||
export function canActivate(tab: vscode.Tab): boolean {
|
||||
return tabUri(tab) !== undefined;
|
||||
}
|
||||
|
||||
function relativeDir(uri: vscode.Uri | undefined): string {
|
||||
if (!uri || uri.scheme !== 'file') {
|
||||
return '';
|
||||
}
|
||||
const relative = vscode.workspace.asRelativePath(uri, false);
|
||||
const dir = path.dirname(relative);
|
||||
return dir === '.' ? '' : dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the flat list of entries for the open tabs.
|
||||
*
|
||||
* `projects` resolves a file to its project; it is passed in so the caller can await all
|
||||
* lookups once and keep this function synchronous and testable.
|
||||
*/
|
||||
export function buildEntries(
|
||||
groups: readonly vscode.TabGroup[],
|
||||
projects: Map<string, string | undefined>,
|
||||
options: { order: Order; pinnedFirst: boolean },
|
||||
): TabEntry[] {
|
||||
const entries: TabEntry[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
for (const tab of group.tabs) {
|
||||
const uri = tabUri(tab);
|
||||
entries.push({
|
||||
id: `${group.viewColumn ?? 0}:${uri?.toString() ?? `label:${tab.label}`}`,
|
||||
tab,
|
||||
label: tab.label,
|
||||
uri,
|
||||
dir: relativeDir(uri),
|
||||
project: uri ? projects.get(uri.toString()) : undefined,
|
||||
isActive: tab.isActive,
|
||||
isDirty: tab.isDirty,
|
||||
isPinned: tab.isPinned,
|
||||
isPreview: tab.isPreview,
|
||||
column: group.viewColumn,
|
||||
canActivate: canActivate(tab),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sortEntries(entries, options);
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
|
||||
export function sortEntries(
|
||||
entries: TabEntry[], options: { order: Order; pinnedFirst: boolean },
|
||||
): TabEntry[] {
|
||||
// `open` order is the order the tab groups already report, so only the other modes
|
||||
// need a comparison. Keep it a stable sort either way.
|
||||
const indexed = entries.map((entry, index) => ({ entry, index }));
|
||||
|
||||
indexed.sort((a, b) => {
|
||||
if (options.pinnedFirst && a.entry.isPinned !== b.entry.isPinned) {
|
||||
return a.entry.isPinned ? -1 : 1;
|
||||
}
|
||||
if (options.order === 'project') {
|
||||
// Files with no project sort last: U+FFFF is above any real name.
|
||||
const byProject = collator.compare(a.entry.project ?? '', b.entry.project ?? '');
|
||||
const byDir = collator.compare(a.entry.dir, b.entry.dir);
|
||||
const byLabel = collator.compare(a.entry.label, b.entry.label);
|
||||
return byProject || byDir || byLabel || a.index - b.index;
|
||||
}
|
||||
if (options.order === 'name') {
|
||||
return collator.compare(a.entry.label, b.entry.label) ||
|
||||
collator.compare(a.entry.dir, b.entry.dir) ||
|
||||
a.index - b.index;
|
||||
}
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return indexed.map(item => item.entry);
|
||||
}
|
||||
|
||||
/** The dimmed text after the file name: directory, project, and the editor group. */
|
||||
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);
|
||||
}
|
||||
if (options.showProject && entry.project) {
|
||||
parts.push(`[${entry.project}]`);
|
||||
}
|
||||
if (options.showColumn && entry.column !== undefined) {
|
||||
parts.push(`#${entry.column}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { runTests } from '@vscode/test-electron';
|
||||
|
||||
/**
|
||||
* Launches VS Code on a multi-project folder and runs the integration suite.
|
||||
*
|
||||
* Unlike a language-server extension this needs no other extensions, so the run
|
||||
* disables them all: it is faster and nothing else can decorate or reorder tabs.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
|
||||
const extensionTestsPath = path.resolve(__dirname, './suite/index');
|
||||
|
||||
const folder = path.normalize(
|
||||
process.env.VERTICAL_TABS_TEST_FOLDER ?? 'D:/Projects/MyGame');
|
||||
if (!fs.existsSync(folder)) {
|
||||
throw new Error(`Test folder does not exist: ${folder}`);
|
||||
}
|
||||
console.log(`[runTest] folder: ${folder}`);
|
||||
|
||||
await runTests({
|
||||
extensionDevelopmentPath,
|
||||
extensionTestsPath,
|
||||
launchArgs: [
|
||||
folder,
|
||||
'--disable-extensions',
|
||||
'--disable-workspace-trust',
|
||||
'--skip-welcome',
|
||||
'--skip-release-notes',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Integration tests failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as path from 'path';
|
||||
import Mocha = require('mocha');
|
||||
|
||||
export function run(): Promise<void> {
|
||||
const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 60 * 1000, slow: 5 * 1000 });
|
||||
mocha.addFile(path.resolve(__dirname, 'tabs.test.js'));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
mocha.run((failures: number) => {
|
||||
if (failures > 0) {
|
||||
reject(new Error(`${failures} test(s) failed.`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import * as assert from 'assert';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
import { projectColor, projectOf } from '../../projects';
|
||||
import { TabEntry, buildEntries, describeEntry, sortEntries, tabUri } from '../../tabs';
|
||||
|
||||
/** Files in three different projects of the test solution. */
|
||||
const FILES = [
|
||||
path.join('Nerfed.Runtime', 'Profiler.cs'),
|
||||
path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs'),
|
||||
path.join('Nerfed.Builder', 'Program.cs'),
|
||||
];
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function root(): vscode.Uri {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
assert.ok(folder, 'no workspace folder');
|
||||
return folder.uri;
|
||||
}
|
||||
|
||||
function uriFor(relative: string): vscode.Uri {
|
||||
return vscode.Uri.joinPath(root(), ...relative.split(path.sep));
|
||||
}
|
||||
|
||||
async function closeEverything(): Promise<void> {
|
||||
// Not closeAllEditors: that command leaves *pinned* editors open, which would leak
|
||||
// into the next test. The tab API closes them regardless.
|
||||
const tabs = vscode.window.tabGroups.all.flatMap(group => group.tabs);
|
||||
if (tabs.length > 0) {
|
||||
await vscode.window.tabGroups.close(tabs, true);
|
||||
}
|
||||
await sleep(200);
|
||||
const left = vscode.window.tabGroups.all.flatMap(group => group.tabs);
|
||||
assert.deepStrictEqual(left.map(tab => tab.label), [], 'tabs survived the cleanup');
|
||||
}
|
||||
|
||||
/** Resolves the project for every open tab, the way the provider does. */
|
||||
async function projectMap(): Promise<Map<string, string | undefined>> {
|
||||
const map = new Map<string, string | undefined>();
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const uri = tabUri(tab);
|
||||
if (uri) {
|
||||
map.set(uri.toString(), await projectOf(uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function entries(order: 'open' | 'project' | 'name' = 'open', pinnedFirst = true) {
|
||||
return buildEntries(vscode.window.tabGroups.all, await projectMap(), { order, pinnedFirst });
|
||||
}
|
||||
|
||||
suite('Project resolution', () => {
|
||||
test('a file resolves to its nearest .csproj', async () => {
|
||||
assert.strictEqual(await projectOf(uriFor(FILES[0])), 'Nerfed.Runtime');
|
||||
assert.strictEqual(await projectOf(uriFor(FILES[1])), 'Nerfed.Editor');
|
||||
});
|
||||
|
||||
test('a file outside any project resolves to nothing', async () => {
|
||||
// README.md sits at the solution root, above every .csproj.
|
||||
assert.strictEqual(await projectOf(uriFor('README.md')), undefined);
|
||||
});
|
||||
|
||||
test('project colours are stable and spread across the palette', () => {
|
||||
const first = projectColor('Nerfed.Runtime');
|
||||
assert.deepStrictEqual(projectColor('Nerfed.Runtime'), first,
|
||||
'the same project must always get the same colour');
|
||||
assert.notDeepStrictEqual(projectColor('Nerfed.Editor'), first,
|
||||
'these two projects should not collide');
|
||||
|
||||
const colours = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
||||
.map(name => JSON.stringify(projectColor(`Project.${name}`))));
|
||||
assert.ok(colours.size >= 5, `12 projects only used ${colours.size} colours`);
|
||||
|
||||
assert.deepStrictEqual(projectColor(undefined),
|
||||
new vscode.ThemeColor('verticalTabs.noProject'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('Tab list', () => {
|
||||
suiteSetup(async () => {
|
||||
const ext = vscode.extensions.getExtension('local.vertical-tabs');
|
||||
assert.ok(ext, 'extension not found');
|
||||
await ext.activate();
|
||||
});
|
||||
|
||||
setup(closeEverything);
|
||||
suiteTeardown(closeEverything);
|
||||
|
||||
test('every open editor becomes one entry, with its project', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
const list = await entries();
|
||||
assert.strictEqual(list.length, FILES.length, 'one entry per open editor');
|
||||
assert.deepStrictEqual(list.map(e => e.label),
|
||||
FILES.map(f => path.basename(f)));
|
||||
assert.deepStrictEqual(list.map(e => e.project),
|
||||
['Nerfed.Runtime', 'Nerfed.Editor', 'Nerfed.Builder']);
|
||||
assert.ok(list.every(e => e.uri && e.canActivate), 'text tabs must be activatable');
|
||||
assert.strictEqual(list.filter(e => e.isActive).length, 1, 'exactly one active tab');
|
||||
});
|
||||
|
||||
test('open order matches the tab order, and the other orders sort', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
const byName = await entries('name');
|
||||
assert.deepStrictEqual(byName.map(e => e.label),
|
||||
[...byName.map(e => e.label)].sort((a, b) => a.localeCompare(b)));
|
||||
|
||||
const byProject = await entries('project');
|
||||
assert.deepStrictEqual(byProject.map(e => e.project),
|
||||
['Nerfed.Builder', 'Nerfed.Editor', 'Nerfed.Runtime']);
|
||||
});
|
||||
|
||||
test('pinnedFirst lifts a pinned tab above the sort order', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
// Pin the *last* opened editor, which is the active one. Program.cs also happens
|
||||
// to sort last by name, so pinnedFirst has something to prove.
|
||||
await vscode.commands.executeCommand('workbench.action.pinEditor');
|
||||
await sleep(300);
|
||||
|
||||
const list = await entries();
|
||||
assert.ok(list[0].isPinned, `first entry should be pinned, got ${list[0].label}`);
|
||||
assert.strictEqual(list[0].label, path.basename(FILES[2]));
|
||||
assert.strictEqual(list.filter(e => e.isPinned).length, 1);
|
||||
|
||||
// VS Code moves a pinned tab to the front of its group, so `open` order shows it
|
||||
// first whether or not we sort pinned tabs up. Name order is the honest test.
|
||||
assert.strictEqual((await entries('name', true))[0].label, path.basename(FILES[2]));
|
||||
assert.deepStrictEqual((await entries('name', false)).map(e => e.label),
|
||||
['EditorProfilerWindow.cs', 'Profiler.cs', 'Program.cs']);
|
||||
});
|
||||
|
||||
test('the pin command pins the tab it is given, not just the active one', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
const list = await entries();
|
||||
const target = list.find(e => e.label === path.basename(FILES[0]));
|
||||
assert.ok(target, 'target tab not in the list');
|
||||
assert.ok(!target.isPinned);
|
||||
|
||||
await vscode.commands.executeCommand('verticalTabs.pin', target);
|
||||
await sleep(400);
|
||||
|
||||
const pinned = (await entries()).filter(e => e.isPinned).map(e => e.label);
|
||||
assert.deepStrictEqual(pinned, [path.basename(FILES[0])]);
|
||||
|
||||
await vscode.commands.executeCommand('verticalTabs.unpin', target);
|
||||
await sleep(400);
|
||||
assert.deepStrictEqual((await entries()).filter(e => e.isPinned), []);
|
||||
});
|
||||
|
||||
test('close closes the given tab, and close others closes the rest', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
const first = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
||||
assert.ok(first);
|
||||
await vscode.commands.executeCommand('verticalTabs.close', first);
|
||||
await sleep(300);
|
||||
assert.deepStrictEqual((await entries()).map(e => e.label),
|
||||
[path.basename(FILES[0]), path.basename(FILES[2])]);
|
||||
|
||||
const keep = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
||||
assert.ok(keep);
|
||||
await vscode.commands.executeCommand('verticalTabs.closeOthers', keep);
|
||||
await sleep(300);
|
||||
assert.deepStrictEqual((await entries()).map(e => e.label), [path.basename(FILES[0])]);
|
||||
});
|
||||
|
||||
test('close others keeps pinned tabs', async () => {
|
||||
for (const file of FILES) {
|
||||
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uriFor(file)),
|
||||
{ preview: false });
|
||||
}
|
||||
await sleep(300);
|
||||
|
||||
// Pin one tab, then close others from a different one.
|
||||
const pin = (await entries()).find(e => e.label === path.basename(FILES[1]));
|
||||
assert.ok(pin);
|
||||
await vscode.commands.executeCommand('verticalTabs.pin', pin);
|
||||
await sleep(400);
|
||||
|
||||
const from = (await entries()).find(e => e.label === path.basename(FILES[0]));
|
||||
assert.ok(from, 'the tab to keep is gone');
|
||||
await vscode.commands.executeCommand('verticalTabs.closeOthers', from);
|
||||
await sleep(400);
|
||||
|
||||
const left = (await entries()).map(e => e.label).sort();
|
||||
assert.deepStrictEqual(left, [path.basename(FILES[1]), path.basename(FILES[0])].sort(),
|
||||
'the pinned tab and the kept tab should both survive');
|
||||
});
|
||||
|
||||
test('a diff tab reports its modified side and stays activatable', async () => {
|
||||
await vscode.commands.executeCommand('vscode.diff',
|
||||
uriFor(FILES[0]), uriFor(FILES[1]), 'Profiler vs Editor');
|
||||
await sleep(400);
|
||||
|
||||
const list = await entries();
|
||||
assert.strictEqual(list.length, 1, 'expected only the diff tab');
|
||||
assert.ok(list[0].uri, 'a diff tab should expose a resource');
|
||||
assert.strictEqual(list[0].uri.fsPath, uriFor(FILES[1]).fsPath,
|
||||
'the modified side identifies the diff');
|
||||
assert.ok(list[0].canActivate);
|
||||
});
|
||||
|
||||
test('a webview tab is listed but reports that it cannot be activated', async () => {
|
||||
await vscode.commands.executeCommand('workbench.action.openSettings');
|
||||
await sleep(600);
|
||||
|
||||
const list = await entries();
|
||||
const settings = list.find(e => !e.uri);
|
||||
assert.ok(settings, `no non-file tab found among ${list.map(e => e.label).join(', ')}`);
|
||||
assert.ok(!settings.canActivate, 'a webview tab must not claim to be activatable');
|
||||
assert.ok(settings.id.startsWith('1:label:'), `unexpected id ${settings.id}`);
|
||||
});
|
||||
|
||||
test('the description shows directory, project and only shows the group when split', () => {
|
||||
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: false, showDirectory: false, showColumn: true }),
|
||||
'#1');
|
||||
assert.strictEqual(
|
||||
describeEntry(entry, { showProject: false, showDirectory: false, showColumn: false }),
|
||||
'');
|
||||
});
|
||||
|
||||
test('sorting is stable for entries that compare equal', () => {
|
||||
const make = (label: string, index: number) => ({
|
||||
id: String(index), label, dir: '', project: 'P', isPinned: false,
|
||||
} as TabEntry);
|
||||
const list = [make('a', 0), make('a', 1), make('a', 2)];
|
||||
const sorted = sortEntries(list, { order: 'project', pinnedFirst: true });
|
||||
assert.deepStrictEqual(sorted.map(e => e.id), ['0', '1', '2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2022"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
Reference in New Issue
Block a user