From e38c0569dc21e5b144043f8a5a0ea0a0bfdc0a92 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 19 Apr 2024 16:03:28 -0700 Subject: [PATCH] Add Project views to python extension --- package.json | 17 ++- package.nls.json | 3 +- src/client/extensionActivation.ts | 3 + .../pythonEnvironments/views/managerView.ts | 2 + src/client/pythonEnvironments/views/types.ts | 2 + src/client/pythonProjects/api.ts | 10 ++ src/client/pythonProjects/projectsManager.ts | 92 ++++++++++++ src/client/pythonProjects/projectsView.ts | 142 ++++++++++++++++++ src/client/pythonProjects/settings.ts | 2 + src/client/pythonProjects/types.ts | 104 +++++++++++++ src/client/pythonProjects/viewItems/types.ts | 97 ++++++++++++ 11 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 src/client/pythonEnvironments/views/managerView.ts create mode 100644 src/client/pythonEnvironments/views/types.ts create mode 100644 src/client/pythonProjects/api.ts create mode 100644 src/client/pythonProjects/projectsManager.ts create mode 100644 src/client/pythonProjects/projectsView.ts create mode 100644 src/client/pythonProjects/settings.ts create mode 100644 src/client/pythonProjects/types.ts create mode 100644 src/client/pythonProjects/viewItems/types.ts diff --git a/package.json b/package.json index 45948ff42903f..967ef37d3c932 100644 --- a/package.json +++ b/package.json @@ -430,6 +430,19 @@ "scope": "resource", "type": "string" }, + "python.environments.activityBar": { + "default": "hide", + "description": "%python.environments.activityBar.description%", + "scope": "resource", + "type": "string", + "enum": [ + "show", + "hide" + ], + "tags": [ + "experimental" + ] + }, "python.experiments.enabled": { "default": true, "description": "%python.experiments.enabled.description%", @@ -1496,8 +1509,8 @@ "views": { "python-environments": [ { - "id": "python-projects-scripts", - "name": "%python.views.treeView.projectsAndScripts.title%", + "id": "python-projects", + "name": "%python.views.treeView.foldersAndScripts.title%", "icon": "resources/logo.svg" }, { diff --git a/package.nls.json b/package.nls.json index 48622d821d535..164aa09bff414 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,6 +35,7 @@ "python.defaultInterpreterPath.description": "Path to default Python to use when extension loads up for the first time, no longer used once an interpreter is selected for the workspace. See [here](https://aka.ms/AAfekmf) to understand when this is used", "python.diagnostics.sourceMapsEnabled.description": "Enable source map support for meaningful stack traces in error logs.", "python.envFile.description": "Absolute path to a file containing environment variable definitions.", + "python.environments.activityBar.description": "Show Python environments in the activity bar.", "python.experiments.enabled.description": "Enables A/B tests experiments in the Python extension. If enabled, you may get included in proposed enhancements and/or features.", "python.experiments.optInto.description": "List of experiment to opt into. If empty, user is assigned the default experiment groups. See [here](https://github.com/microsoft/vscode-python/wiki/AB-Experiments) for more details.", "python.experiments.optOutFrom.description": "List of experiment to opt out of. If empty, user is assigned the default experiment groups. See [here](https://github.com/microsoft/vscode-python/wiki/AB-Experiments) for more details.", @@ -85,7 +86,7 @@ "python.venvFolders.description": "Folders in your home directory to look into for virtual environments (supports pyenv, direnv and virtualenvwrapper by default).", "python.venvPath.description": "Path to folder with a list of Virtual Environments (e.g. ~/.pyenv, ~/Envs, ~/.virtualenvs).", "python.views.activityBar.title": "Python Environments", - "python.views.treeView.projectsAndScripts.title": "Workspaces, Projects, and Scripts", + "python.views.treeView.foldersAndScripts.title": "Workspaces, Projects, and Scripts", "python.views.treeView.environmentManagers.title": "Environment Managers", "walkthrough.pythonWelcome.title": "Get Started with Python Development", "walkthrough.pythonWelcome.description": "Your first steps to set up a Python project with all the powerful tools and features that the Python extension has to offer!", diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 77ed2edf6716f..b88a222c60acf 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -52,6 +52,7 @@ import { initializePersistentStateForTriggers } from './common/persistentState'; import { logAndNotifyOnLegacySettings } from './logging/settingLogs'; import { DebuggerTypeName } from './debugger/constants'; import { StopWatch } from './common/utils/stopWatch'; +import { registerProjectFeatures } from './pythonProjects/api'; import { registerReplCommands, registerReplExecuteOnEnter, registerStartNativeReplCommand } from './repl/replCommands'; export async function activateComponents( @@ -111,6 +112,8 @@ export function activateFeatures(ext: ExtensionState, _components: Components): registerStartNativeReplCommand(ext.disposables, interpreterService); registerReplCommands(ext.disposables, interpreterService, executionHelper, commandManager); registerReplExecuteOnEnter(ext.disposables, interpreterService, commandManager); + + registerProjectFeatures(ext.disposables); } /// ////////////////////////// diff --git a/src/client/pythonEnvironments/views/managerView.ts b/src/client/pythonEnvironments/views/managerView.ts new file mode 100644 index 0000000000000..d194bed477a31 --- /dev/null +++ b/src/client/pythonEnvironments/views/managerView.ts @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. diff --git a/src/client/pythonEnvironments/views/types.ts b/src/client/pythonEnvironments/views/types.ts new file mode 100644 index 0000000000000..d194bed477a31 --- /dev/null +++ b/src/client/pythonEnvironments/views/types.ts @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. diff --git a/src/client/pythonProjects/api.ts b/src/client/pythonProjects/api.ts new file mode 100644 index 0000000000000..4fc06174bcb18 --- /dev/null +++ b/src/client/pythonProjects/api.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable } from 'vscode'; +import { getPythonProjectView } from './projectsView'; + +export function registerProjectFeatures(disposables: Disposable[]): void { + const projectView = getPythonProjectView(); + disposables.push(projectView); +} diff --git a/src/client/pythonProjects/projectsManager.ts b/src/client/pythonProjects/projectsManager.ts new file mode 100644 index 0000000000000..abf29982ac075 --- /dev/null +++ b/src/client/pythonProjects/projectsManager.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Event, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; +import { PythonProject, PythonProjectsApi, PythonProjectsChangedEvent } from './types'; +import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis'; + +class PythonProjectsManagerImpl implements PythonProjectsApi { + private _projects: Map = new Map(); + + private readonly _onDidChangePythonProjects = new EventEmitter(); + + constructor(projects: readonly PythonProject[]) { + projects.forEach((project) => this.addProject(project)); + } + + addPythonProjects(...pythonProjects: PythonProject[]): void { + const addedProjects: PythonProject[] = []; + pythonProjects.forEach((project) => { + if (this.addProject(project)) { + addedProjects.push(project); + } + }); + if (addedProjects.length > 0) { + this._onDidChangePythonProjects.fire({ added: addedProjects, removed: [] }); + } + } + + removePythonProjects(...pythonProjects: PythonProject[]): boolean { + const removedProjects: PythonProject[] = []; + pythonProjects.forEach((project) => { + if (this._projects.delete(project.uri.toString())) { + removedProjects.push(project); + } + }); + if (removedProjects.length > 0) { + this._onDidChangePythonProjects.fire({ added: [], removed: removedProjects }); + } + return removedProjects.length > 0; + } + + onDidChangePythonProjects: Event = this._onDidChangePythonProjects.event; + + get pythonProjects(): readonly PythonProject[] { + return Array.from(this._projects.values()); + } + + getPythonProject(uri: Uri): PythonProject | undefined { + const project: PythonProject | undefined = this._projects.get(uri.toString()); + if (project) { + return project; + } + // Check if given uri is a child of a project + const projects = Array.from(this._projects.values()) + .sort((a, b) => a.uri.fsPath.length - b.uri.fsPath.length) + .reverse(); + + const normalizedUriPath = path.normalize(uri.fsPath); + for (const p of projects) { + const normalizedWorkspacePath = path.normalize(p.uri.fsPath); + if (normalizedWorkspacePath === normalizedUriPath) { + return p; + } + let parentPath = path.dirname(normalizedUriPath); + while (parentPath !== path.dirname(parentPath)) { + if (normalizedWorkspacePath === parentPath) { + return p; + } + parentPath = path.dirname(parentPath); + } + } + return undefined; + } + + private addProject(project: PythonProject): boolean { + if (this._projects.has(project.uri.toString())) { + return false; + } + this._projects.set(project.uri.toString(), project); + return true; + } +} + +let _projectManager: PythonProjectsApi | undefined; +export function getPythonProjectsApi(): PythonProjectsApi { + if (!_projectManager) { + const workspaces: readonly WorkspaceFolder[] = getWorkspaceFolders() ?? []; + _projectManager = new PythonProjectsManagerImpl(workspaces.map((w) => ({ uri: w.uri, name: w.name }))); + } + return _projectManager; +} diff --git a/src/client/pythonProjects/projectsView.ts b/src/client/pythonProjects/projectsView.ts new file mode 100644 index 0000000000000..f037567442410 --- /dev/null +++ b/src/client/pythonProjects/projectsView.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { + Disposable, + Event, + EventEmitter, + ProviderResult, + TreeDataProvider, + TreeItem, + TreeView, + Uri, + window, +} from 'vscode'; +import { getPythonProjectsApi } from './projectsManager'; +import { PythonProject, PythonProjectsApi, PythonProjectsChangedEvent } from './types'; +import { + ProjectEnvironmentInfoViewItem, + ProjectEnvironmentViewItem, + ProjectNoEnvironmentViewItem, + ProjectPackageViewItem, + ProjectPackagesViewItem, + ProjectViewItem, + PythonProjectTreeItem, +} from './viewItems/types'; + +const PROJECT_TREE_VIEW_ID = 'python-projects'; + +export interface PythonProjectsViewApi extends Disposable { + revealEnvironment(uri: Uri | PythonProject): void; +} + +class PythonProjectViewImpl implements PythonProjectsViewApi, TreeDataProvider { + private disposables: Disposable[] = []; + + private _treeView: TreeView | undefined; + + private _treeDataChanged: EventEmitter; + + private _projectViews: Map = new Map(); + + private _projectEnvironments: Map = new Map< + string, + ProjectEnvironmentViewItem + >(); + + constructor(private readonly api: PythonProjectsApi) { + this._treeView = window.createTreeView(PROJECT_TREE_VIEW_ID, { treeDataProvider: this }); + this._treeDataChanged = new EventEmitter< + void | PythonProjectTreeItem | PythonProjectTreeItem[] | null | undefined + >(); + this.onDidChangeTreeData = this._treeDataChanged.event; + this.disposables.push( + this._treeView, + this._treeDataChanged, + this.api.onDidChangePythonProjects((e) => { + this.onDidChangePythonProjects(e); + }), + ); + + this.api.pythonProjects.forEach((project) => { + this.addProject(project); + }); + } + + onDidChangeTreeData?: Event; + + // eslint-disable-next-line class-methods-use-this + getTreeItem(element: PythonProjectTreeItem): TreeItem { + return element.getTreeItem(); + } + + getChildren(element?: PythonProjectTreeItem | undefined): ProviderResult { + if (element === undefined) { + return Array.from(this._projectViews.values()); + } + + if (element.kind === 'project') { + const { project } = element as ProjectViewItem; + const envItem = this._projectEnvironments.get(project.uri.toString()); + return envItem ? [envItem] : [new ProjectNoEnvironmentViewItem(project)]; + } + + if (element.kind === 'environment') { + return [ + new ProjectEnvironmentInfoViewItem('Environment Manager: '), + new ProjectEnvironmentInfoViewItem('Package Manager: '), + new ProjectEnvironmentInfoViewItem('Python: '), + new ProjectPackagesViewItem(), + ]; + } + + if (element.kind === 'packages') { + return [new ProjectPackageViewItem('Not Supported')]; + } + + return []; + } + + // eslint-disable-next-line class-methods-use-this + getParent?(element: PythonProjectTreeItem): ProviderResult { + return element.parent; + } + + dispose() { + this.disposables.forEach((d) => d.dispose()); + } + + revealEnvironment(uri: Uri | PythonProject): void { + const project = uri instanceof Uri ? this.api.getPythonProject(uri) : uri; + if (!project) { + return; + } + const envItem = this._projectEnvironments.get(project.uri.toString()); + if (envItem) { + this._treeView?.reveal(envItem); + } + } + + private onDidChangePythonProjects(e: PythonProjectsChangedEvent) { + e.added.forEach((project) => this.addProject(project)); + e.removed.forEach((project) => this.removeProject(project)); + this._treeDataChanged.fire(undefined); + } + + private addProject(project: PythonProject) { + const projectView = new ProjectViewItem(project); + this._projectViews.set(project.uri.toString(), projectView); + } + + private removeProject(project: PythonProject) { + this._projectViews.delete(project.uri.toString()); + } +} + +let _projectViews: PythonProjectsViewApi | undefined; +export function getPythonProjectView(): PythonProjectsViewApi { + if (!_projectViews) { + _projectViews = new PythonProjectViewImpl(getPythonProjectsApi()); + } + return _projectViews; +} diff --git a/src/client/pythonProjects/settings.ts b/src/client/pythonProjects/settings.ts new file mode 100644 index 0000000000000..d194bed477a31 --- /dev/null +++ b/src/client/pythonProjects/settings.ts @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. diff --git a/src/client/pythonProjects/types.ts b/src/client/pythonProjects/types.ts new file mode 100644 index 0000000000000..9ba78ec0cc631 --- /dev/null +++ b/src/client/pythonProjects/types.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Event, ThemeIcon, Uri } from 'vscode'; + +/** + * A Python project is any folder or script that needs special handling by the python extension. A Python project + * is similar to a VS Code workspace, but some differences in how they are handled. + * + * Following are example of Python projects: + * - A folder that needs some unique handling, like files under that folder should use a different environment. + * - A folder that is a python package in a mono-repo. + * - A script that needs some unique handling (like PEP-723 script) + * + * All Python projects are treated equal with no notion of active or primary. The main difference from VS Code + * workspace folder is that VS Code will not see it as separate workspaces. Python extension and dependents of + * python extension can use the Python projects APIs to get the list of Python projects and handle them as needed. + */ +export interface PythonProject { + /** + * The name of the Python project. Typically name of the file or folder. + */ + readonly name: string; + + /** + * The associated absolute uri of the python script or folder. + */ + readonly uri: Uri; + + /** + * The icon path or {@link ThemeIcon} for the tree item. + * When `falsy`, {@link ThemeIcon.Folder Folder Theme Icon} is assigned, if {@link uri uri} is a folder, + * otherwise {@link ThemeIcon.File File Theme Icon} for files. When a file or folder {@link ThemeIcon} is specified, + * icon is derived from the current file icon theme for the specified theme icon using {@link uri uri}. + */ + readonly iconPath?: string | Uri | { light: string | Uri; dark: string | Uri } | ThemeIcon; + + /** + * A human-readable string which is rendered less prominent. + */ + readonly description?: string; +} + +/** + * An event that is emitted when {@link PythonProject Python projects} are added or removed. + */ +export interface PythonProjectsChangedEvent { + /** + * Added Python projects. + */ + readonly added: readonly PythonProject[]; + + /** + * Removed Python projects. + */ + readonly removed: readonly PythonProject[]; +} + +export interface PythonProjectsApi { + /** + * Add Python projects. + * + * @param pythonProjects The Python projects to be added. + * + */ + addPythonProjects(...pythonProjects: PythonProject[]): void; + + /** + * Remove Python projects. + * + * @param pythonProjects The Python projects to be removed. + */ + removePythonProjects(...pythonProjects: PythonProject[]): boolean; + + /** + * An event that is emitted when Python projects are added or removed. This is only fired when a new Python project + * is added or existing Python project is removed. + * + * **Note:** This event is **not** fired: + * - when workspaces as available in VS Code workspace folders are loaded. + * - when python projects are loaded from settings. + */ + onDidChangePythonProjects: Event; + + /** + * List of {@link PythonProject python projects}. + * - Empty if no VS Code workspace folders are loaded. + * - VS Code workspace folders are added by default as Python projects. + * - You can exclude VS Code workspace folders from being added as Python projects + * by excluding them in settings. + * - Python Folders and Scripts can be added as projects using the {@link addPythonProjects add Python projects api}. + * - Python Folders and Scripts can be removed as projects using the {@link removePythonProjects remove Python projects api}. + */ + readonly pythonProjects: readonly PythonProject[]; + + /** + * Get the {@link_PythonProject Python project} that contains the given uri. Returns `undefined` if the + * given uri doesn't match any Python project, or is not a child of any known Python project. + * + * @param uri Absolute uri to a folder or file. + * @returns The Python project or `undefined`. + */ + getPythonProject(uri: Uri): PythonProject | undefined; +} diff --git a/src/client/pythonProjects/viewItems/types.ts b/src/client/pythonProjects/viewItems/types.ts new file mode 100644 index 0000000000000..d6b68cb0b2be7 --- /dev/null +++ b/src/client/pythonProjects/viewItems/types.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/* eslint-disable max-classes-per-file */ +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { PythonProject } from '../types'; + +export enum PythonProjectTreeItemKind { + Project = 'project', + Environment = 'environment', + NoEnvironment = 'noEnvironment', + EnvironmentInfo = 'environmentInfo', + Packages = 'packages', + Package = 'package', + RefreshingPackages = 'refreshingPackages', +} + +export interface PythonProjectTreeItem { + kind: PythonProjectTreeItemKind; + getTreeItem(): TreeItem; + parent?: PythonProjectTreeItem; +} + +export class ProjectViewItem implements PythonProjectTreeItem { + constructor(public readonly project: PythonProject) {} + + kind = PythonProjectTreeItemKind.Project; + + getTreeItem(): TreeItem { + const item = new TreeItem(this.project.name, TreeItemCollapsibleState.Expanded); + item.resourceUri = this.project.uri; + item.iconPath = this.project.iconPath; + item.description = this.project.description; + return item; + } +} + +export class ProjectEnvironmentViewItem implements PythonProjectTreeItem { + constructor(public readonly project: PythonProject) {} + + kind = PythonProjectTreeItemKind.Environment; + + getTreeItem(): TreeItem { + const item = new TreeItem(this.project.name, TreeItemCollapsibleState.Collapsed); + item.resourceUri = this.project.uri; + item.iconPath = this.project.iconPath; + item.description = this.project.description; + return item; + } +} + +export class ProjectNoEnvironmentViewItem implements PythonProjectTreeItem { + constructor(public readonly project: PythonProject) {} + + kind = PythonProjectTreeItemKind.NoEnvironment; + + // eslint-disable-next-line class-methods-use-this + getTreeItem(): TreeItem { + const item = new TreeItem('Please select and environment for this project', TreeItemCollapsibleState.None); + item.contextValue = 'noEnvironment'; + return item; + } +} + +export class ProjectEnvironmentInfoViewItem implements PythonProjectTreeItem { + constructor(public label: string, public description?: string) {} + + kind = PythonProjectTreeItemKind.EnvironmentInfo; + + getTreeItem(): TreeItem { + const item = new TreeItem(this.label, TreeItemCollapsibleState.None); + item.description = this.description; + return item; + } +} + +export class ProjectPackagesViewItem implements PythonProjectTreeItem { + kind = PythonProjectTreeItemKind.Packages; + + // eslint-disable-next-line class-methods-use-this + getTreeItem(): TreeItem { + const item = new TreeItem('Packages', TreeItemCollapsibleState.Collapsed); + return item; + } +} + +export class ProjectPackageViewItem implements PythonProjectTreeItem { + constructor(public label: string, public description?: string) {} + + kind = PythonProjectTreeItemKind.Package; + + getTreeItem(): TreeItem { + const item = new TreeItem(this.label, TreeItemCollapsibleState.None); + item.description = this.description; + return item; + } +}