diff --git a/src/features/views/projectView.ts b/src/features/views/projectView.ts index e6fd7f3b..e8dda9d2 100644 --- a/src/features/views/projectView.ts +++ b/src/features/views/projectView.ts @@ -252,7 +252,9 @@ export class ProjectView implements TreeDataProvider { // Store the reference for refreshing packages this.packageRoots.set(uri ? uri.fsPath : 'global', environmentItem); - return packages.map((p) => new ProjectPackage(environmentItem, p, pkgManager)); + return packages + .sort((a, b) => (a.isTransitive === b.isTransitive ? 0 : a.isTransitive ? 1 : -1)) + .map((p) => new ProjectPackage(environmentItem, p, pkgManager)); } //return nothing if the element is not a project, environment, or undefined diff --git a/src/features/views/treeViewItems.ts b/src/features/views/treeViewItems.ts index 0293afb7..04173e79 100644 --- a/src/features/views/treeViewItems.ts +++ b/src/features/views/treeViewItems.ts @@ -213,7 +213,7 @@ export class PackageTreeItem implements EnvTreeItem { const defaultIcon = pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); item.iconPath = pkg.iconPath ?? defaultIcon; item.contextValue = pkg.isTransitive ? 'python-package-transitive' : 'python-package'; - item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version); + item.description = (pkg.isTransitive ? l10n.t('(transitive) ') : '') + (pkg.description ?? pkg.version ?? ''); item.tooltip = pkg.isTransitive ? l10n.t('This package is a dependency of another installed package. It may also have been explicitly installed.') : pkg.tooltip; @@ -433,10 +433,16 @@ export class ProjectPackage implements ProjectTreeItem { ) { this.id = ProjectPackage.getId(parent, pkg); const item = new TreeItem(this.pkg.displayName, TreeItemCollapsibleState.None); - item.iconPath = this.pkg.iconPath; + const defaultIcon = this.pkg.isTransitive ? new ThemeIcon('list-tree') : new ThemeIcon('package'); + item.iconPath = this.pkg.iconPath ?? defaultIcon; item.contextValue = this.pkg.isTransitive ? 'python-package-transitive' : 'python-package'; - item.description = this.pkg.description ?? this.pkg.version; - item.tooltip = this.pkg.tooltip; + item.description = + (this.pkg.isTransitive ? l10n.t('(transitive) ') : '') + (this.pkg.description ?? this.pkg.version ?? ''); + item.tooltip = this.pkg.isTransitive + ? l10n.t( + 'This package is a dependency of another installed package. It may also have been explicitly installed.', + ) + : this.pkg.tooltip; this.treeItem = item; } diff --git a/src/test/features/views/treeViewItems.unit.test.ts b/src/test/features/views/treeViewItems.unit.test.ts index eca57557..7e7bc972 100644 --- a/src/test/features/views/treeViewItems.unit.test.ts +++ b/src/test/features/views/treeViewItems.unit.test.ts @@ -1,14 +1,17 @@ import * as assert from 'assert'; -import { Uri } from 'vscode'; +import { ThemeIcon, Uri } from 'vscode'; +import { Package } from '../../../api'; import { UvInstallStrings, VenvManagerStrings } from '../../../common/localize'; import { EnvManagerTreeItem, getEnvironmentParentDirName, NoPythonEnvTreeItem, + ProjectEnvironment, + ProjectPackage, PythonEnvTreeItem, PythonGroupEnvTreeItem, } from '../../../features/views/treeViewItems'; -import { InternalEnvironmentManager, PythonEnvironmentImpl } from '../../../internal.api'; +import { InternalEnvironmentManager, InternalPackageManager, PythonEnvironmentImpl } from '../../../internal.api'; /** * Helper to create a mock PythonEnvironmentImpl with minimal required fields. @@ -487,4 +490,79 @@ suite('Test TreeView Items', () => { assert.equal(item.treeItem.command, undefined, 'Should not have a command'); }); }); + + suite('ProjectPackage', () => { + // ProjectPackage only reads parent.id and does not call any manager methods, + // so minimal cast mocks are sufficient for exercising the tree item rendering. + const parent = { id: 'project>>>env' } as ProjectEnvironment; + const manager = {} as InternalPackageManager; + + function createMockPackage(options: Partial = {}): Package { + return { + name: options.name ?? 'requests', + displayName: options.displayName ?? options.name ?? 'requests', + version: options.version, + description: options.description, + tooltip: options.tooltip, + iconPath: options.iconPath, + isTransitive: options.isTransitive, + pkgId: { id: options.name ?? 'requests', managerId: 'ms-python.python:pip' }, + } as Package; + } + + test('Direct package uses package icon and shows no transitive prefix', () => { + // Arrange + const pkg = createMockPackage({ name: 'requests', version: '2.31.0', isTransitive: false }); + + // Act + const item = new ProjectPackage(parent, pkg, manager); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'python-package'); + assert.strictEqual((item.treeItem.iconPath as ThemeIcon).id, 'package'); + assert.strictEqual(item.treeItem.description, '2.31.0'); + }); + + test('Transitive package uses list-tree icon and shows transitive prefix', () => { + // Arrange + const pkg = createMockPackage({ name: 'urllib3', version: '2.0.0', isTransitive: true }); + + // Act + const item = new ProjectPackage(parent, pkg, manager); + + // Assert + assert.strictEqual(item.treeItem.contextValue, 'python-package-transitive'); + assert.strictEqual((item.treeItem.iconPath as ThemeIcon).id, 'list-tree'); + assert.ok( + (item.treeItem.description as string).startsWith('(transitive) '), + 'Transitive package description should be prefixed with "(transitive) "', + ); + assert.ok(item.treeItem.tooltip, 'Transitive package should have an explanatory tooltip'); + }); + + test('Prefers package-provided iconPath over default icon', () => { + // Arrange + const pkg = createMockPackage({ name: 'numpy', isTransitive: true, iconPath: new ThemeIcon('symbol-numeric') }); + + // Act + const item = new ProjectPackage(parent, pkg, manager); + + // Assert + assert.strictEqual((item.treeItem.iconPath as ThemeIcon).id, 'symbol-numeric'); + }); + + test('Falls back to empty description when version and description are missing', () => { + // Arrange + const directPkg = createMockPackage({ name: 'mypkg', isTransitive: false }); + const transitivePkg = createMockPackage({ name: 'mypkg', isTransitive: true }); + + // Act + const directItem = new ProjectPackage(parent, directPkg, manager); + const transitiveItem = new ProjectPackage(parent, transitivePkg, manager); + + // Assert + assert.strictEqual(directItem.treeItem.description, ''); + assert.strictEqual(transitiveItem.treeItem.description, '(transitive) '); + }); + }); });