From ea9445d0275eabc81c678bd3ba7c656ceb0eb968 Mon Sep 17 00:00:00 2001 From: Nico Vogel Date: Tue, 1 Nov 2022 17:08:15 +0100 Subject: [PATCH] feat: project tree view (#1390) Co-authored-by: Max Kless Co-authored-by: Jonathan Cammisuli --- apps/vscode/src/main.ts | 4 +- apps/vscode/src/package.json | 13 +- .../src/lib/configuration-keys.ts | 13 + .../src/lib/global-configuration-store.ts | 8 +- libs/vscode/nx-project-view/src/index.ts | 1 - .../src/lib/nx-project-tree-item.ts | 20 - .../src/lib/nx-project-tree-provider.ts | 198 ++--- .../nx-project-view/src/lib/views/index.ts | 2 + .../src/lib/views/nx-project-base-view.ts | 152 ++++ .../src/lib/views/nx-project-list-view.ts | 41 + .../lib/views/nx-project-tree-view.spec.ts | 713 ++++++++++++++++++ .../src/lib/views/nx-project-tree-view.ts | 149 ++++ .../src/lib/views/nx-project-util.spec.ts | 173 +++++ .../src/lib/views/nx-project-util.ts | 84 +++ 14 files changed, 1414 insertions(+), 157 deletions(-) delete mode 100644 libs/vscode/nx-project-view/src/lib/nx-project-tree-item.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/index.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-base-view.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-list-view.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.spec.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-util.spec.ts create mode 100644 libs/vscode/nx-project-view/src/lib/views/nx-project-util.ts diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index c59079806e..66e796c25b 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -23,8 +23,8 @@ import { NxCommandsTreeProvider, } from '@nx-console/vscode/nx-commands-view'; import { - NxProjectTreeItem, NxProjectTreeProvider, + NxTreeItem, } from '@nx-console/vscode/nx-project-view'; import { LOCATE_YOUR_WORKSPACE, @@ -72,7 +72,7 @@ import { } from '@nx-console/vscode/nx-workspace'; let runTargetTreeView: TreeView; -let nxProjectTreeView: TreeView; +let nxProjectTreeView: TreeView; let nxCommandsTreeView: TreeView; let nxHelpAndFeedbackTreeView: TreeView; diff --git a/apps/vscode/src/package.json b/apps/vscode/src/package.json index eacdb72e82..7ce7f6aa14 100644 --- a/apps/vscode/src/package.json +++ b/apps/vscode/src/package.json @@ -121,12 +121,12 @@ "view/item/context": [ { "command": "nxConsole.editWorkspaceJson", - "when": "view == nxProjects", + "when": "view == nxProjects && viewItem == project || viewItem == task", "group": "inline" }, { "command": "nxConsole.revealInExplorer", - "when": "view == nxProjects && viewItem == project", + "when": "view == nxProjects && viewItem != task", "group": "inline" }, { @@ -723,6 +723,15 @@ "default": true, "description": "Enables the filter for listed generators with Nx Console." }, + "nxConsole.projectViewingStyle": { + "type": "string", + "default": "list", + "enum": [ + "list", + "tree" + ], + "description": "Define how the 'Projects' view shows the entries.\n\nlist: show the projects in an ordered list.\ntree: show the projects in the same folder structure as they are located in your repo." + }, "nxConsole.generatorAllowlist": { "type": "array", "default": [], diff --git a/libs/vscode/configuration/src/lib/configuration-keys.ts b/libs/vscode/configuration/src/lib/configuration-keys.ts index fcaa3e48b9..3587820cd0 100644 --- a/libs/vscode/configuration/src/lib/configuration-keys.ts +++ b/libs/vscode/configuration/src/lib/configuration-keys.ts @@ -7,8 +7,21 @@ export const GLOBAL_CONFIG_KEYS = [ 'generatorAllowlist', 'generatorBlocklist', 'enableTaskExecutionDryRunOnChange', + 'projectViewingStyle', ] as const; +export type GlobalConfig = { + enableTelemetry: boolean; + enableGenerateFromContextMenu: boolean; + enableWorkspaceConfigCodeLens: boolean; + enableLibraryImports: boolean; + enableGeneratorFilters: boolean; + generatorAllowlist: string[]; + generatorBlocklist: string[]; + enableTaskExecutionDryRunOnChange: boolean; + projectViewingStyle: 'list' | 'tree'; +}; + /** * configuration Keys used for NxConsole */ diff --git a/libs/vscode/configuration/src/lib/global-configuration-store.ts b/libs/vscode/configuration/src/lib/global-configuration-store.ts index 90c4840bcf..5f31032e72 100644 --- a/libs/vscode/configuration/src/lib/global-configuration-store.ts +++ b/libs/vscode/configuration/src/lib/global-configuration-store.ts @@ -5,7 +5,11 @@ import { Memento, } from 'vscode'; import { Store } from '@nx-console/shared/schema'; -import { GLOBAL_CONFIG_KEYS, GlobalConfigKeys } from './configuration-keys'; +import { + GLOBAL_CONFIG_KEYS, + GlobalConfigKeys, + GlobalConfig, +} from './configuration-keys'; let CONFIG_STORE: GlobalConfigurationStore; @@ -28,6 +32,8 @@ export class GlobalConfigurationStore implements Store { private constructor(private readonly state: Memento) {} + get(key: T): GlobalConfig[T] | null; + get(key: GlobalConfigKeys, defaultValue?: T): T | null; get(key: GlobalConfigKeys, defaultValue?: T): T | null { const value = this.storage(key).get(key, defaultValue); return typeof value === 'undefined' ? defaultValue || null : value; diff --git a/libs/vscode/nx-project-view/src/index.ts b/libs/vscode/nx-project-view/src/index.ts index 2516ead802..e23fe535b3 100644 --- a/libs/vscode/nx-project-view/src/index.ts +++ b/libs/vscode/nx-project-view/src/index.ts @@ -1,2 +1 @@ export * from './lib/nx-project-tree-provider'; -export * from './lib/nx-project-tree-item'; diff --git a/libs/vscode/nx-project-view/src/lib/nx-project-tree-item.ts b/libs/vscode/nx-project-view/src/lib/nx-project-tree-item.ts deleted file mode 100644 index 9dafa2cc0b..0000000000 --- a/libs/vscode/nx-project-view/src/lib/nx-project-tree-item.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TreeItem, TreeItemCollapsibleState } from 'vscode'; - -export class NxProjectTreeItem extends TreeItem { - constructor( - public nxProject: NxProject, - treeItemLabel: string, - collapsibleState?: TreeItemCollapsibleState | undefined - ) { - super(treeItemLabel, collapsibleState); - } -} - -export interface NxProject { - project: string; - root: string; - target?: { - name: string; - configuration?: string; - }; -} diff --git a/libs/vscode/nx-project-view/src/lib/nx-project-tree-provider.ts b/libs/vscode/nx-project-view/src/lib/nx-project-tree-provider.ts index c92eddd739..7da5c6e55d 100644 --- a/libs/vscode/nx-project-view/src/lib/nx-project-tree-provider.ts +++ b/libs/vscode/nx-project-view/src/lib/nx-project-tree-provider.ts @@ -1,22 +1,30 @@ +import { GlobalConfigurationStore } from '@nx-console/vscode/configuration'; import { revealNxProject } from '@nx-console/vscode/nx-workspace'; import { CliTaskProvider } from '@nx-console/vscode/tasks'; -import { - AbstractTreeProvider, - getOutputChannel, -} from '@nx-console/vscode/utils'; -import { join } from 'path'; +import { AbstractTreeProvider } from '@nx-console/vscode/utils'; import { commands, ExtensionContext, + ProviderResult, + TreeItem, TreeItemCollapsibleState, Uri, } from 'vscode'; -import { NxProject, NxProjectTreeItem } from './nx-project-tree-item'; +import { + createListViewStrategy, + createTreeViewStrategy, + ListViewStrategy, + TreeViewStrategy, +} from './views'; +import { ListViewItem, ViewItem } from './views/nx-project-base-view'; /** * Provides data for the "Projects" tree view */ -export class NxProjectTreeProvider extends AbstractTreeProvider { +export class NxProjectTreeProvider extends AbstractTreeProvider { + private readonly listView: ListViewStrategy; + private readonly treeView: TreeViewStrategy; + constructor( context: ExtensionContext, private readonly cliTaskProvider: CliTaskProvider @@ -29,140 +37,52 @@ export class NxProjectTreeProvider extends AbstractTreeProvider Promise][] + ] as const ).forEach(([commandSuffix, callback]) => { context.subscriptions.push( commands.registerCommand(`nxConsole.${commandSuffix}`, callback, this) ); }); - } - async getParent( - element: NxProjectTreeItem - ): Promise { - const { project, target, root } = element.nxProject; - - if (target) { - if (target.configuration) { - return this.createNxProjectTreeItem( - { project, target: { name: target.name }, root }, - target.name - ); - } else { - return this.createNxProjectTreeItem({ project, root }, project); - } - } else { - return null; - } + this.listView = createListViewStrategy(this.cliTaskProvider); + this.treeView = createTreeViewStrategy(this.cliTaskProvider); } - async createNxProjectTreeItem( - nxProject: NxProject, - treeItemLabel: string, - hasChildren?: boolean - ) { - const item = new NxProjectTreeItem( - nxProject, - treeItemLabel, - hasChildren - ? TreeItemCollapsibleState.Collapsed - : TreeItemCollapsibleState.None - ); - if (!nxProject.target) { - const projectDef = (await this.cliTaskProvider.getProjects())[ - nxProject.project - ]; - if (projectDef) { - if (projectDef.root === undefined) { - getOutputChannel().appendLine( - `Project ${nxProject.project} has no root. This could be because of an error loading the workspace configuration.` - ); - } - - item.resourceUri = Uri.file( - join(this.cliTaskProvider.getWorkspacePath(), projectDef.root ?? '') - ); - } - item.contextValue = 'project'; - } else { - item.contextValue = 'task'; - } + getParent() { + // not implemented, because the reveal API is not needed for the projects view + return null; + } - return item; + getChildren(element?: NxTreeItem): ProviderResult { + return this.getViewChildren(element?.item).then((items) => { + if (!items) return []; + return items.map((item) => new NxTreeItem(item)); + }); } - async getChildren( - parent?: NxProjectTreeItem - ): Promise { - if (!parent) { - const projects = await this.cliTaskProvider.getProjectEntries(); - return Promise.all( - projects.map( - async ([name, def]): Promise => - this.createNxProjectTreeItem( - { project: name, root: def.root }, - name, - Boolean(def.targets) - ) - ) - ); - } - - const { nxProject } = parent; - const { target, project } = nxProject; - const projectDef = (await this.cliTaskProvider.getProjects())[project]; - - if (!projectDef) { - return; + private async getViewChildren(viewItem?: ViewItem) { + if (this.isListViewElement(viewItem)) { + return this.listView.getChildren(viewItem); } + return this.treeView.getChildren(viewItem); + } - if (!target) { - if (projectDef.targets) { - return Promise.all( - Object.keys(projectDef.targets).map( - async (name): Promise => - this.createNxProjectTreeItem( - { target: { name }, project, root: projectDef.root }, - name, - Boolean(projectDef.targets?.[name].configurations) - ) - ) - ); - } - } else { - const { configuration } = target; - - if (configuration || !projectDef.targets) { - return; - } - - const configurations = projectDef.targets - ? projectDef.targets[target.name].configurations - : undefined; - if (!configurations) { - return; - } - - return Promise.all( - Object.keys(configurations).map(async (name) => - this.createNxProjectTreeItem( - { - target: { ...target, configuration: name }, - project, - root: projectDef.root, - }, - name - ) - ) - ); - } + private isListViewElement(_?: ViewItem): _ is ListViewItem { + const config = GlobalConfigurationStore.instance.get('projectViewingStyle'); + return config === 'list' || config === null; } - private async runTask(selection: NxProjectTreeItem) { - const { target, project } = selection.nxProject; - if (!target) { + private async runTask(selection: NxTreeItem) { + const viewItem = selection.item; + if ( + viewItem.contextValue === 'project' || + viewItem.contextValue === 'folder' + ) { + // can not run a task on a project return; } + const { project } = viewItem.nxProject; + const target = viewItem.nxTarget; const flags = []; if (target.configuration) { @@ -176,21 +96,37 @@ export class NxProjectTreeProvider extends AbstractTreeProvider { + getChildren(element?: T): Promise; +} + +export type ViewDataProvider = Pick< + CliTaskProvider, + 'getWorkspacePath' | 'getProjects' +>; + +export type ListViewItem = ProjectViewItem | TargetViewItem; +export type TreeViewItem = FolderViewItem | ProjectViewItem | TargetViewItem; +export type ViewItem = ListViewItem | TreeViewItem; + +interface BaseViewItem { + contextValue: Context; + label: string; + collapsible: Collapsible; +} + +export interface FolderViewItem extends BaseViewItem<'folder'> { + path: string; + resource: string; +} + +export interface ProjectViewItem extends BaseViewItem<'project'> { + nxProject: NxProject; + resource: string; +} + +export interface TargetViewItem extends BaseViewItem<'task'> { + nxProject: NxProject; + nxTarget: NxTarget; +} + +export type Collapsible = 'None' | 'Collapsed' | 'Expanded'; + +export interface NxProject { + project: string; + root: string; +} + +export interface NxTarget { + name: string; + configuration?: string; +} + +export abstract class BaseView { + constructor(protected readonly cliTaskProvider: ViewDataProvider) {} + + createProjectViewItem([projectName, { root, name, targets }]: [ + projectName: string, + projectDefinition: ProjectConfiguration + ]): ProjectViewItem { + const hasChildren = !!targets; + const nxProject = { project: name ?? projectName, root }; + + if (root === undefined) { + getOutputChannel().appendLine( + `Project ${nxProject.project} has no root. This could be because of an error loading the workspace configuration.` + ); + } + + return { + contextValue: 'project', + nxProject, + label: projectName, + resource: join( + this.cliTaskProvider.getWorkspacePath(), + nxProject.root ?? '' + ), + collapsible: hasChildren ? 'Collapsed' : 'None', + }; + } + + async createTargetsFromProject(parent: ProjectViewItem) { + const { nxProject } = parent; + + const projectDef = (await this.cliTaskProvider.getProjects())[ + nxProject.project + ]; + if (!projectDef) { + return; + } + + const { targets } = projectDef; + if (!targets) { + return; + } + + return Object.entries(targets).map((target) => + this.createTargetTreeItem(nxProject, target) + ); + } + + createTargetTreeItem( + nxProject: NxProject, + [targetName, { configurations }]: [ + targetName: string, + targetDefinition: TargetConfiguration + ] + ): TargetViewItem { + const hasChildren = !!configurations; + return { + contextValue: 'task', + nxProject, + nxTarget: { name: targetName }, + label: targetName, + collapsible: hasChildren ? 'Collapsed' : 'None', + }; + } + + async createConfigurationsFromTarget( + parent: TargetViewItem + ): Promise { + const { nxProject, nxTarget } = parent; + + const projectDef = (await this.cliTaskProvider.getProjects())[ + nxProject.project + ]; + if (!projectDef) { + return; + } + + const { targets } = projectDef; + if (!targets) { + return; + } + + const target = targets[nxTarget.name]; + if (!target) { + return; + } + + const { configurations } = target; + if (!configurations) { + return; + } + + return Object.keys(configurations).map((configuration) => ({ + contextValue: 'task', + nxProject, + nxTarget: { name: nxTarget.name, configuration }, + label: configuration, + collapsible: 'None', + })); + } +} diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-list-view.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-list-view.ts new file mode 100644 index 0000000000..0c067e950e --- /dev/null +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-list-view.ts @@ -0,0 +1,41 @@ +import { CliTaskProvider } from '@nx-console/vscode/tasks'; +import { + BaseView, + ListViewItem, + ProjectViewStrategy, +} from './nx-project-base-view'; + +export type ListViewStrategy = ProjectViewStrategy; + +export function createListViewStrategy( + cliTaskProvider: CliTaskProvider +): ListViewStrategy { + const listView = new ListView(cliTaskProvider); + return { + getChildren: listView.getChildren.bind(listView), + }; +} + +class ListView extends BaseView { + constructor(cliTaskProvider: CliTaskProvider) { + super(cliTaskProvider); + } + + async getChildren(element?: ListViewItem) { + if (!element) { + // should return root elements if no element was passed + return this.createProjects(); + } + if (element.contextValue === 'project') { + return this.createTargetsFromProject(element); + } + return this.createConfigurationsFromTarget(element); + } + + private async createProjects() { + const projectDefs = await this.cliTaskProvider.getProjects(); + return Object.entries(projectDefs).map((project) => + this.createProjectViewItem(project) + ); + } +} diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.spec.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.spec.ts new file mode 100644 index 0000000000..b78996266a --- /dev/null +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.spec.ts @@ -0,0 +1,713 @@ +import { ViewDataProvider } from './nx-project-base-view'; +import { + createTreeViewStrategy, + TreeViewStrategy, +} from './nx-project-tree-view'; + +const testRootChildren = async ( + expectedOutput: string[], + treeView: TreeViewStrategy +) => { + const rootElements = await treeView.getChildren(); + expect(rootElements).toHaveLength(expectedOutput.length); + const paths = rootElements?.map((e) => e.label); + expect(paths).toEqual(expectedOutput); +}; + +describe('Project View: TreeView', () => { + describe('nx workspace', () => { + it('should find root directories', async () => { + const viewProvider = createMockViewDataProvider( + nxExample.workspacePath, + nxExample.project + ); + const expectedOutput = ['apps', 'libs']; + const treeView = createTreeViewStrategy(viewProvider); + + await testRootChildren(expectedOutput, treeView); + }); + }); + + describe('angular', () => { + it('should find root directory "projects"', async () => { + const viewProvider = createMockViewDataProvider( + ngExample.workspacePath, + ngExample.project + ); + const expectedOutput = ['projects']; + const treeView = createTreeViewStrategy(viewProvider); + + await testRootChildren(expectedOutput, treeView); + }); + + it('should use root placeholder for empty roots', async () => { + const viewProvider = createMockViewDataProvider( + ngExampleInSrc.workspacePath, + ngExampleInSrc.project + ); + const expectedOutput = ['']; + const treeView = createTreeViewStrategy(viewProvider); + + await testRootChildren(expectedOutput, treeView); + }); + + it('should find projects below root directory', async () => { + const viewProvider = createMockViewDataProvider( + ngExampleInSrc.workspacePath, + ngExampleInSrc.project + ); + const expectedOutput = [ + 'multi-application-example', + 'multi-application-example-e2e', + 'multi-application-example1', + 'multi-application-example1-e2e', + ]; + const treeView = createTreeViewStrategy(viewProvider); + const [srcDir] = (await treeView.getChildren()) ?? []; + + const projects = await treeView.getChildren(srcDir); + + expect(projects).toHaveLength(expectedOutput.length); + const paths = projects?.map((e) => e.label); + expect(paths).toEqual(expectedOutput); + }); + }); +}); + +type MockDataGetWorkspacePath = ReturnType< + ViewDataProvider['getWorkspacePath'] +>; +type MockDataGetProjects = Awaited>; + +function createMockViewDataProvider( + workspacePath: MockDataGetWorkspacePath, + projects: unknown +): ViewDataProvider { + return { + getWorkspacePath: () => workspacePath, + getProjects: () => + new Promise((resolve) => + setTimeout(() => resolve(projects as MockDataGetProjects)) + ), + }; +} + +const nxExample = { + workspacePath: + '/git/nx-console/apps/vscode-e2e/testworkspaces/testworkspace-nx', + // Result of cliTaskProvider.getProjects() for apps/vscode-e2e/testworkspaces/testworkspace-nx + project: { + app1: { + targets: { + build: { + executor: '@nrwl/webpack:webpack', + configurations: { + production: {}, + }, + dependsOn: ['^build'], + inputs: ['production', '^production'], + }, + test: { + executor: '@nrwl/jest:jest', + }, + }, + root: 'apps/app1', + tags: [], + files: [ + { + file: 'apps/app1/app1.js', + hash: 'fda9bc547f3d044be11c43ba8df3b8f387f29532', + deps: ['lib1', 'lib2'], + }, + { + file: 'apps/app1/project.json', + hash: '5b8dd29636672949793a788e40c9f39b75aadc99', + }, + ], + }, + lib1: { + targets: { + test: { + executor: '@nrwl/jest:jest', + }, + }, + root: 'libs/lib1', + tags: [], + files: [ + { + file: 'libs/lib1/project.json', + hash: '929b00c99a39f3bb085b2487fed80d8e8421aea7', + }, + { + file: 'libs/lib1/src/index.ts', + hash: '56e2812af30c82f0f15bb726f98a27321363e5ca', + }, + { + file: 'libs/lib1/src/lib/lib1.js', + hash: 'f63c731f25f9ac8efaaee3420cac1e7f08185169', + }, + ], + }, + lib2: { + targets: { + test: { + executor: '@nrwl/jest:jest', + }, + weird: {}, + }, + root: 'libs/lib2', + tags: [], + files: [ + { + file: 'libs/lib2/project.json', + hash: '157a68f9115ba2aeb01e074404066c7aef10c34b', + }, + { + file: 'libs/lib2/src/index.js', + hash: '9aeaf007321d35731cf3a05e937f966216376c95', + }, + { + file: 'libs/lib2/src/lib/lib2.js', + hash: 'fcc28489c736ebac8fe0b1b4f3c2aa3041a0de8e', + }, + ], + }, + }, +}; + +const ngExample = { + workspacePath: '/git/ng-multi-projects/my-workspace', + // Results of cliTaskProvider.getProjects() from https://github.com/saaivs/ng-multi-projects commit 767463aaaccb3da3905b90f3127c418e17163345 + project: { + app1: { + projectType: 'application', + generators: { + '@schematics/angular:component': { + style: 'scss', + }, + '@schematics/angular:application': { + strict: true, + }, + }, + root: 'projects/app1', + sourceRoot: 'projects/app1/src', + prefix: 'app', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist/app1', + index: 'projects/app1/src/index.html', + main: 'projects/app1/src/main.ts', + polyfills: 'projects/app1/src/polyfills.ts', + tsConfig: 'projects/app1/tsconfig.app.json', + inlineStyleLanguage: 'scss', + assets: [ + 'projects/app1/src/favicon.ico', + 'projects/app1/src/assets', + ], + styles: [ + 'node_modules/material-design-icons-iconfont/dist/material-design-icons.css', + 'node_modules/roboto-fontface/css/roboto/roboto-fontface.css', + 'node_modules/@fortawesome/fontawesome-free/css/all.min.css', + 'projects/app1/src/styles.scss', + ], + scripts: [ + 'node_modules/document-register-element/build/document-register-element.js', + ], + }, + configurations: { + production: { + budgets: [ + { + type: 'initial', + maximumWarning: '500kb', + maximumError: '1mb', + }, + { + type: 'anyComponentStyle', + maximumWarning: '2kb', + maximumError: '4kb', + }, + ], + fileReplacements: [ + { + replace: 'projects/app1/src/environments/environment.ts', + with: 'projects/app1/src/environments/environment.prod.ts', + }, + ], + outputHashing: 'all', + }, + stage: { + budgets: [ + { + type: 'initial', + maximumWarning: '500kb', + maximumError: '1mb', + }, + { + type: 'anyComponentStyle', + maximumWarning: '2kb', + maximumError: '4kb', + }, + ], + fileReplacements: [ + { + replace: 'projects/app1/src/environments/environment.ts', + with: 'projects/app1/src/environments/environment.stage.ts', + }, + ], + outputHashing: 'all', + }, + development: { + buildOptimizer: false, + optimization: false, + vendorChunk: true, + extractLicenses: false, + sourceMap: true, + namedChunks: true, + }, + }, + defaultConfiguration: 'production', + }, + serve: { + executor: '@angular-devkit/build-angular:dev-server', + configurations: { + production: { + browserTarget: 'app1:build:production', + }, + development: { + browserTarget: 'app1:build:development', + }, + }, + defaultConfiguration: 'development', + }, + 'extract-i18n': { + executor: '@angular-devkit/build-angular:extract-i18n', + options: { + browserTarget: 'app1:build', + }, + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'projects/app1/src/test.ts', + polyfills: 'projects/app1/src/polyfills.ts', + tsConfig: 'projects/app1/tsconfig.spec.json', + karmaConfig: 'projects/app1/karma.conf.js', + inlineStyleLanguage: 'scss', + assets: [ + 'projects/app1/src/favicon.ico', + 'projects/app1/src/assets', + ], + styles: ['projects/app1/src/styles.scss'], + scripts: [], + }, + }, + }, + }, + app2: { + projectType: 'application', + generators: { + '@schematics/angular:component': { + style: 'scss', + }, + '@schematics/angular:application': { + strict: true, + }, + }, + root: 'projects/app2', + sourceRoot: 'projects/app2/src', + prefix: 'app', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist/app2', + index: 'projects/app2/src/index.html', + main: 'projects/app2/src/main.ts', + polyfills: 'projects/app2/src/polyfills.ts', + tsConfig: 'projects/app2/tsconfig.app.json', + inlineStyleLanguage: 'scss', + assets: [ + 'projects/app2/src/favicon.ico', + 'projects/app2/src/assets', + ], + styles: ['projects/app2/src/styles.scss'], + scripts: [], + }, + configurations: { + production: { + budgets: [ + { + type: 'initial', + maximumWarning: '500kb', + maximumError: '1mb', + }, + { + type: 'anyComponentStyle', + maximumWarning: '2kb', + maximumError: '4kb', + }, + ], + fileReplacements: [ + { + replace: 'projects/app2/src/environments/environment.ts', + with: 'projects/app2/src/environments/environment.prod.ts', + }, + ], + outputHashing: 'all', + }, + development: { + buildOptimizer: false, + optimization: false, + vendorChunk: true, + extractLicenses: false, + sourceMap: true, + namedChunks: true, + }, + }, + defaultConfiguration: 'production', + }, + serve: { + executor: '@angular-devkit/build-angular:dev-server', + configurations: { + production: { + browserTarget: 'app2:build:production', + }, + development: { + browserTarget: 'app2:build:development', + }, + }, + defaultConfiguration: 'development', + }, + 'extract-i18n': { + executor: '@angular-devkit/build-angular:extract-i18n', + options: { + browserTarget: 'app2:build', + }, + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'projects/app2/src/test.ts', + polyfills: 'projects/app2/src/polyfills.ts', + tsConfig: 'projects/app2/tsconfig.spec.json', + karmaConfig: 'projects/app2/karma.conf.js', + inlineStyleLanguage: 'scss', + assets: [ + 'projects/app2/src/favicon.ico', + 'projects/app2/src/assets', + ], + styles: ['projects/app2/src/styles.scss'], + scripts: [], + }, + }, + }, + }, + lib1: { + projectType: 'library', + root: 'projects/lib1', + sourceRoot: 'projects/lib1/src', + prefix: 'lib', + targets: { + build: { + executor: '@angular-devkit/build-angular:ng-packagr', + options: { + project: 'projects/lib1/ng-package.json', + }, + configurations: { + production: { + tsConfig: 'projects/lib1/tsconfig.lib.prod.json', + }, + development: { + tsConfig: 'projects/lib1/tsconfig.lib.json', + }, + }, + defaultConfiguration: 'production', + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'projects/lib1/src/test.ts', + tsConfig: 'projects/lib1/tsconfig.spec.json', + karmaConfig: 'projects/lib1/karma.conf.js', + }, + }, + }, + }, + lib2: { + projectType: 'library', + root: 'projects/lib2', + sourceRoot: 'projects/lib2/src', + prefix: 'lib', + targets: { + build: { + executor: '@angular-devkit/build-angular:ng-packagr', + options: { + project: 'projects/lib2/ng-package.json', + }, + configurations: { + production: { + tsConfig: 'projects/lib2/tsconfig.lib.prod.json', + }, + development: { + tsConfig: 'projects/lib2/tsconfig.lib.json', + }, + }, + defaultConfiguration: 'production', + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'projects/lib2/src/test.ts', + tsConfig: 'projects/lib2/tsconfig.spec.json', + karmaConfig: 'projects/lib2/karma.conf.js', + }, + }, + }, + }, + lib3: { + projectType: 'library', + root: 'projects/lib3', + sourceRoot: 'projects/lib3/src', + prefix: 'lib', + targets: { + build: { + executor: '@angular-devkit/build-angular:ng-packagr', + options: { + project: 'projects/lib3/ng-package.json', + }, + configurations: { + production: { + tsConfig: 'projects/lib3/tsconfig.lib.prod.json', + }, + development: { + tsConfig: 'projects/lib3/tsconfig.lib.json', + }, + }, + defaultConfiguration: 'production', + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'projects/lib3/src/test.ts', + tsConfig: 'projects/lib3/tsconfig.spec.json', + karmaConfig: 'projects/lib3/karma.conf.js', + }, + }, + }, + }, + }, +}; + +const ngExampleInSrc = { + workspacePath: '/git/angular-multiple-applications-example', + // Results of cliTaskProvider.getProjects() from https://github.com/JoelViney/angular-multiple-applications-example commit 3d7d2627c57e7e6d4dc4ee78abcff25c6d29fbbe + project: { + 'multi-application-example': { + root: '', + sourceRoot: 'src', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist-application-a', + index: 'src/application-a/index.html', + main: 'src/application-a/main.ts', + tsConfig: 'src/application-a/tsconfig.app.json', + polyfills: 'src/application-a/polyfills.ts', + assets: [ + 'src/application-a/assets', + 'src/application-a/favicon.ico', + ], + styles: ['src/application-a/styles.css'], + scripts: [], + }, + configurations: { + production: { + optimization: true, + outputHashing: 'all', + sourceMap: false, + extractCss: true, + namedChunks: false, + aot: true, + extractLicenses: true, + vendorChunk: false, + buildOptimizer: true, + fileReplacements: [ + { + replace: 'src/application-a/environments/environment.ts', + with: 'src/application-a/environments/environment.prod.ts', + }, + ], + }, + }, + }, + serve: { + executor: '@angular-devkit/build-angular:dev-server', + options: { + browserTarget: 'multi-application-example:build', + }, + configurations: { + production: { + browserTarget: 'multi-application-example:build:production', + }, + }, + }, + 'extract-i18n': { + executor: '@angular-devkit/build-angular:extract-i18n', + options: { + browserTarget: 'multi-application-example:build', + }, + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'src/application-a/test.ts', + karmaConfig: './karma.conf.js', + polyfills: 'src/application-a/polyfills.ts', + tsConfig: 'src/application-a/tsconfig.spec.json', + scripts: [], + styles: ['src/application-a/styles.css'], + assets: [ + 'src/application-a/assets', + 'src/application-a/favicon.ico', + ], + }, + }, + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + tsConfig: ['src/tsconfig.app.json', 'src/tsconfig.spec.json'], + exclude: ['**/node_modules/**'], + }, + }, + }, + }, + 'multi-application-example-e2e': { + root: '', + sourceRoot: '', + projectType: 'application', + targets: { + e2e: { + executor: '@angular-devkit/build-angular:protractor', + options: { + protractorConfig: './protractor.conf.js', + devServerTarget: 'multi-application-example:serve', + }, + }, + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + tsConfig: ['e2e/tsconfig.e2e.json'], + exclude: ['**/node_modules/**'], + }, + }, + }, + }, + 'multi-application-example1': { + root: '', + sourceRoot: 'src', + projectType: 'application', + targets: { + build: { + executor: '@angular-devkit/build-angular:browser', + options: { + outputPath: 'dist-application-b', + index: 'src/application-b/index.html', + main: 'src/application-b/main.ts', + tsConfig: 'src/application-b/tsconfig.app.json', + polyfills: 'src/application-b/polyfills.ts', + assets: [ + 'src/application-b/assets', + 'src/application-b/favicon.ico', + ], + styles: ['src/application-b/styles.css'], + scripts: [], + }, + configurations: { + production: { + optimization: true, + outputHashing: 'all', + sourceMap: false, + extractCss: true, + namedChunks: false, + aot: true, + extractLicenses: true, + vendorChunk: false, + buildOptimizer: true, + fileReplacements: [ + { + replace: 'src/application-b/environments/environment.ts', + with: 'src/application-b/environments/environment.prod.ts', + }, + ], + }, + }, + }, + serve: { + executor: '@angular-devkit/build-angular:dev-server', + options: { + browserTarget: 'multi-application-example1:build', + }, + configurations: { + production: { + browserTarget: 'multi-application-example1:build:production', + }, + }, + }, + 'extract-i18n': { + executor: '@angular-devkit/build-angular:extract-i18n', + options: { + browserTarget: 'multi-application-example1:build', + }, + }, + test: { + executor: '@angular-devkit/build-angular:karma', + options: { + main: 'src/application-b/test.ts', + karmaConfig: './karma.conf.js', + polyfills: 'src/application-b/polyfills.ts', + tsConfig: 'src/application-b/tsconfig.spec.json', + scripts: [], + styles: ['src/application-b/styles.css'], + assets: [ + 'src/application-b/assets', + 'src/application-b/favicon.ico', + ], + }, + }, + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + tsConfig: ['src/tsconfig.app.json', 'src/tsconfig.spec.json'], + exclude: ['**/node_modules/**'], + }, + }, + }, + }, + 'multi-application-example1-e2e': { + root: '', + sourceRoot: '', + projectType: 'application', + targets: { + e2e: { + executor: '@angular-devkit/build-angular:protractor', + options: { + protractorConfig: './protractor.conf.js', + devServerTarget: 'multi-application-example1:serve', + }, + }, + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + tsConfig: ['e2e/tsconfig.e2e.json'], + exclude: ['**/node_modules/**'], + }, + }, + }, + }, + }, +}; diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.ts new file mode 100644 index 0000000000..a0033dcb1e --- /dev/null +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-tree-view.ts @@ -0,0 +1,149 @@ +import { ProjectConfiguration } from '@nrwl/devkit'; +import { getOutputChannel } from '@nx-console/vscode/utils'; +import { join } from 'node:path'; +import { + BaseView, + FolderViewItem, + ProjectViewStrategy, + TreeViewItem, + ViewDataProvider, +} from './nx-project-base-view'; +import { isDefined, PathHelper } from './nx-project-util'; + +export type TreeViewStrategy = ProjectViewStrategy; +type TreeViewMap = Map; + +export function createTreeViewStrategy( + cliTaskProvider: ViewDataProvider +): TreeViewStrategy { + const listView = new TreeView(cliTaskProvider); + return { + getChildren: listView.getChildren.bind(listView), + }; +} + +class TreeView extends BaseView { + private pathHelper = new PathHelper(); + + constructor(cliTaskProvider: ViewDataProvider) { + super(cliTaskProvider); + } + + async getChildren(element?: TreeViewItem) { + if (!element) { + return this.createRoot(); + } + if (element.contextValue === 'folder') { + return this.createFoldersOrProjectFromFolder(element); + } + if (element.contextValue === 'project') { + return this.createTargetsFromProject(element); + } + return this.createConfigurationsFromTarget(element); + } + + private async createRoot() { + const projectDefs = await this.cliTaskProvider.getProjects(); + const map = this.groupByRootPath(projectDefs); + + if (map.size === 0) { + // An angular project has its root project dir at '' + // Therefore, the map will be empty + const [[projectName, projectDef]] = Object.entries(projectDefs); + return [this.createProjectViewItem([projectName, projectDef])]; + } + + const rootFolders = this.getRootFolders(map); + return rootFolders.map(([path]) => this.createTreeItemFromPath(path)); + } + + private async createFoldersOrProjectFromFolder(parent: FolderViewItem) { + const projectDefs = await this.cliTaskProvider.getProjects(); + const map = this.groupByRootPath(projectDefs); + + /** + * In case that the parent is the placeholder, then the children can only be projects + */ + if (parent.path === '') { + return map.get('')?.map((project) => this.createProjectViewItem(project)); + } + + const subFolders = this.getSubFolders(map, parent.path); + return subFolders.map(([path, projects]) => + this.createTreeItemFromPath(path, projects) + ); + } + + private createTreeItemFromPath( + path: string, + projects?: [string, ProjectConfiguration][] + ) { + if (projects && projects.length === 1) { + const [project] = projects; + return this.createProjectViewItem(project); + } + return this.createFolderTreeItem(path); + } + + private createFolderTreeItem(path: string): FolderViewItem { + const folderName = this.pathHelper.getFolderName(path); + /** + * In case that a project does not have a root value. + * Show a placeholder value instead + */ + const label = folderName === '' ? '' : folderName; + + return { + contextValue: 'folder', + path, + label, + resource: join(this.cliTaskProvider.getWorkspacePath(), path), + collapsible: 'Collapsed', + }; + } + + /** + * Groups the ProjectConfiguration by directory. + * Each entry is added n times. + * n is determined by the directory depth. + */ + private groupByRootPath(projectDefs: { + [projectName: string]: ProjectConfiguration; + }): TreeViewMap { + return Object.entries(projectDefs) + .flatMap((project) => { + const [projectName, projectDef] = project; + const { root } = projectDef; + if (root === undefined) { + getOutputChannel().appendLine( + `Project ${projectName} has no root. This could be because of an error loading the workspace configuration.` + ); + return; + } + return this.pathHelper + .createPathPermutations(root) + .map((dir) => [dir, project] as const); + }) + .filter(isDefined) + .reduce((map, [dir, project]) => { + const list = map.get(dir) ?? []; + list.push(project); + return map.set(dir, list); + }, new Map()); + } + + private getRootFolders(map: TreeViewMap) { + return Array.from(map.entries()).filter(([key]) => + this.pathHelper.isRoot(key) + ); + } + + private getSubFolders(map: TreeViewMap, path: string) { + const depth = this.pathHelper.getDepth(path); + + return Array.from(map.entries()).filter( + ([key]) => + key.includes(path) && this.pathHelper.getDepth(key) === depth + 1 + ); + } +} diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-util.spec.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-util.spec.ts new file mode 100644 index 0000000000..8ee64f9b78 --- /dev/null +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-util.spec.ts @@ -0,0 +1,173 @@ +import * as win32Path from 'path/win32'; +import * as posixPath from 'path/posix'; +import { PathHelper } from './nx-project-util'; + +const dirsWindowsTests = (ph: PathHelper) => { + it('only root results in empty output', () => { + const input = 'C:\\'; + const output: string[] = []; + expect(ph.dirs(input)).toEqual(output); + }); + it('absolute path results in array of folder names', () => { + const input = 'C:\\Users\\foo\\nx-console'; + const output: string[] = ['Users', 'foo', 'nx-console']; + expect(ph.dirs(input)).toEqual(output); + }); + it('absolute path (depth 1) results in array of folder names', () => { + const input = 'C:\\Users'; + const output: string[] = ['Users']; + expect(ph.dirs(input)).toEqual(output); + }); + it('relative path results in array of folder names', () => { + const input = 'Users\\foo\\nx-console'; + const output: string[] = ['Users', 'foo', 'nx-console']; + expect(ph.dirs(input)).toEqual(output); + }); + it('relative path (depth 1) results in array of folder names', () => { + const input = 'Users'; + const output: string[] = ['Users']; + expect(ph.dirs(input)).toEqual(output); + }); +}; + +const dirsLinuxTests = (ph: PathHelper) => { + it('only root results in empty output', () => { + const input = '/'; + const output: string[] = []; + expect(ph.dirs(input)).toEqual(output); + }); + it('absolute path results in array of folder names', () => { + const input = '/home/foo/nx-console'; + const output: string[] = ['home', 'foo', 'nx-console']; + expect(ph.dirs(input)).toEqual(output); + }); + it('absolute path (depth 1) results in array of folder names', () => { + const input = '/home'; + const output: string[] = ['home']; + expect(ph.dirs(input)).toEqual(output); + }); + it('relative path results in array of folder names', () => { + const input = 'home/foo/nx-console'; + const output: string[] = ['home', 'foo', 'nx-console']; + expect(ph.dirs(input)).toEqual(output); + }); + it('relative path (depth 1) results in array of folder names', () => { + const input = 'home'; + const output: string[] = ['home']; + expect(ph.dirs(input)).toEqual(output); + }); +}; + +const createPathPermutationsWindowsTests = (ph: PathHelper) => { + it('only root results in empty output', () => { + const input = 'C:\\'; + const output: string[] = []; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('absolute path results in folder permutations', () => { + const input = 'C:\\Users\\foo\\nx-console'; + const output: string[] = [ + 'C:\\Users\\foo\\nx-console', + 'C:\\Users\\foo', + 'C:\\Users', + ]; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('absolute path (depth 1) results in folder permutation', () => { + const input = 'C:\\Users'; + const output: string[] = ['C:\\Users']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('relative path results in array in folder permutation', () => { + const input = 'Users\\foo\\nx-console'; + const output: string[] = ['Users\\foo\\nx-console', 'Users\\foo', 'Users']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('relative path (depth 1) results in array in folder permutation', () => { + const input = 'Users'; + const output: string[] = ['Users']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); +}; + +const createPathPermutationsLinuxTests = (ph: PathHelper) => { + it('only root results in empty output', () => { + const input = '/'; + const output: string[] = []; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('absolute path results in array in folder permutation', () => { + const input = '/home/foo/nx-console'; + const output: string[] = ['/home/foo/nx-console', '/home/foo', '/home']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('absolute path (depth 1) results in array in folder permutation', () => { + const input = '/home'; + const output: string[] = ['/home']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('relative path results in array in folder permutation', () => { + const input = 'home/foo/nx-console'; + const output: string[] = ['home/foo/nx-console', 'home/foo', 'home']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); + it('relative path (depth 1) results in array in folder permutation', () => { + const input = 'home'; + const output: string[] = ['home']; + expect(ph.createPathPermutations(input)).toEqual(output); + }); +}; + +describe('Project View: PathHelper', () => { + describe('dirs', () => { + it('empty input results in empty output', () => { + const input = ''; + const output: string[] = ['']; + expect(new PathHelper().dirs(input)).toEqual(output); + }); + + describe('windows', () => { + const ph = new PathHelper(win32Path); + dirsWindowsTests(ph); + + describe('with linux paths', () => { + dirsLinuxTests(ph); + }); + }); + + describe('linux', () => { + const ph = new PathHelper(posixPath); + dirsLinuxTests(ph); + + describe('with windows paths', () => { + dirsWindowsTests(ph); + }); + }); + }); + + describe('createPathPermutations', () => { + it('empty input results in empty output', () => { + const input = ''; + const output: string[] = ['']; + expect(new PathHelper().createPathPermutations(input)).toEqual(output); + }); + + describe('windows', () => { + const ph = new PathHelper(win32Path); + createPathPermutationsWindowsTests(ph); + + describe('with linux paths', () => { + createPathPermutationsLinuxTests(ph); + }); + }); + + describe('linux', () => { + const ph = new PathHelper(posixPath); + createPathPermutationsLinuxTests(ph); + + describe('with windows paths', () => { + createPathPermutationsWindowsTests(ph); + }); + }); + }); +}); diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-util.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-util.ts new file mode 100644 index 0000000000..a8bf51ed71 --- /dev/null +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-util.ts @@ -0,0 +1,84 @@ +import * as path from 'node:path'; +import * as win32Path from 'path/win32'; +import * as posixPath from 'path/posix'; + +export function isDefined(val?: T): val is T { + return !!val; +} + +type DetailedDirs = [dirs: string[], root: string, api: typeof path]; + +export class PathHelper { + constructor(private pathApi: typeof path = path) {} + + private detailedDirs(val: string): DetailedDirs { + if (!val) return [[''], '', this.pathApi]; + + const oppositeApi = this.getOppositeApi(); + const api = val.includes(oppositeApi.sep) ? oppositeApi : this.pathApi; + + /** + * @example windows: + * path.parse('C:\\Users\\foo\\nx-console') + * { root: 'C:\\', dir: 'C:\\Users\\foo', base: 'nx-console', ext: '', name: 'nx-console' } + * + * linux: + * path.parse('/home/foo/nx-console') + * { root: '/', dir: '/home/foo', base: 'nx-console', ext: '', name: 'nx-console' } + */ + const { root, dir, base } = api.parse(val); + const dirWithoutRoot = root ? dir.slice(root.length) : dir; + const dirs = dirWithoutRoot ? dirWithoutRoot.split(api.sep) : []; + if (base) { + dirs.push(base); + } + + return [dirs, root, api]; + } + + dirs(val: string) { + return this.detailedDirs(val)[0]; + } + + getDepth(val: string) { + return this.dirs(val).length; + } + + isRoot(val: string) { + return this.getDepth(val) === 1; + } + + getFolderName(val: string) { + return this.dirs(val).pop() ?? ''; + } + + /** + * Create a permutation for each sub directory. + * @example + * input: 'libs/shared/collections' + * output: [ + * 'libs/shared/collections' + * 'libs/shared' + * 'libs' + * ] + */ + createPathPermutations(dir: string) { + if (dir === '') return ['']; + + const [dirs, root, api] = this.detailedDirs(dir); + const parts = dirs.reverse(); + const permutations: string[] = []; + for (let i = 0; i < parts.length; i++) { + const partialDir = api.join(root, ...parts.slice(i).reverse()); + permutations.push(partialDir); + } + return permutations; + } + + private getOppositeApi() { + if (this.pathApi.sep === win32Path.sep) { + return posixPath; + } + return win32Path; + } +}