diff --git a/.vscode/launch.json b/.vscode/launch.json index 50fc968ef7..979318e82f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,16 +5,26 @@ { "version": "0.2.0", "configurations": [ + { + "args": [ + "--disable-extensions", + "--extensionDevelopmentPath=${workspaceFolder}/dist/apps/vscode" + ], + "name": "Launch Extension", + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "request": "launch", + "type": "pwa-extensionHost" + }, { "name": "Run Extension In Dev Mode", - "type": "extensionHost", + "type": "pwa-extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", "args": [ "--disable-extensions", "--extensionDevelopmentPath=${workspaceFolder}/dist/apps/vscode" ], - "trace": "false", + "trace": false, "internalConsoleOptions": "openOnFirstSessionStart", "outFiles": [ "${workspaceFolder}/dist/apps/vscode", diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index e3bbdabbed..85a7fec4d4 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -2,8 +2,11 @@ import { existsSync } from 'fs'; import { dirname, join, parse } from 'path'; import { commands, + ConfigurationChangeEvent, + Disposable, ExtensionContext, FileSystemWatcher, + languages, tasks, TreeView, Uri, @@ -33,7 +36,7 @@ import { RunTargetTreeItem, RunTargetTreeProvider, } from '@nx-console/vscode/nx-run-target-view'; -import { verifyNodeModules, verifyWorkspace } from '@nx-console/vscode/verify'; +import { verifyNodeModules } from '@nx-console/vscode/verify'; import { NxCommandsTreeItem, NxCommandsTreeProvider, @@ -42,6 +45,10 @@ import { NxProjectTreeItem, NxProjectTreeProvider, } from '@nx-console/vscode/nx-project-view'; +import { + verifyWorkspace, + WorkspaceCodeLensProvider, +} from '@nx-console/vscode/nx-workspace'; import { environment } from './environments/environment'; let runTargetTreeView: TreeView; @@ -99,7 +106,7 @@ export function activate(c: ExtensionContext) { ); const manuallySelectWorkspaceDefinitionCommand = commands.registerCommand( - LOCATE_YOUR_WORKSPACE.command!.command, + LOCATE_YOUR_WORKSPACE.command?.command || '', async () => { return manuallySelectWorkspaceDefinition(); } @@ -117,6 +124,9 @@ export function activate(c: ExtensionContext) { manuallySelectWorkspaceDefinitionCommand ); + // registers itself as a CodeLensProvider and watches config to dispose/re-register + new WorkspaceCodeLensProvider(context); + getTelemetry().extensionActivated((Date.now() - startTime) / 1000); } catch (e) { window.showErrorMessage( diff --git a/apps/vscode/src/package.json b/apps/vscode/src/package.json index ad16712090..ce25d2c0f9 100644 --- a/apps/vscode/src/package.json +++ b/apps/vscode/src/package.json @@ -47,7 +47,17 @@ "menus": { "explorer/context": [ { - "when": "isAngularWorkspace && config.nxConsole.enableGenerateFromContextMenu || isNxWorkspace && config.nxConsole.enableGenerateFromContextMenu", + "when": "isAngularWorkspace && config.nxConsole.enableGenerateFromContextMenu", + "command": "ng.generate.ui.fileexplorer", + "group": "explorerContext" + }, + { + "when": "isAngularWorkspace && config.nxConsole.enableGenerateFromContextMenu", + "command": "ng.run.fileexplorer", + "group": "explorerContext" + }, + { + "when": "isNxWorkspace && config.nxConsole.enableGenerateFromContextMenu", "command": "nx.generate.ui.fileexplorer", "group": "explorerContext" }, @@ -82,6 +92,14 @@ } ], "commandPalette": [ + { + "command": "ng.generate.ui.fileexplorer", + "when": "false" + }, + { + "command": "ng.run.fileexplorer", + "when": "false" + }, { "command": "nx.generate.ui.fileexplorer", "when": "false" @@ -114,6 +132,10 @@ "command": "ng.test", "when": "isAngularWorkspace" }, + { + "command": "ng.run", + "when": "isAngularWorkspace" + }, { "command": "ng.lint.ui", "when": "isAngularWorkspace" @@ -300,6 +322,11 @@ "title": "test", "command": "ng.test" }, + { + "category": "ng", + "title": "run", + "command": "ng.run" + }, { "category": "ng", "title": "lint (ui)", @@ -335,6 +362,16 @@ "title": "generate (ui)", "command": "ng.generate.ui" }, + { + "category": "ng", + "title": "ng generate (ui)", + "command": "ng.generate.ui.fileexplorer" + }, + { + "category": "ng", + "title": "ng run", + "command": "ng.run.fileexplorer" + }, { "category": "Nx", "title": "lint", @@ -493,6 +530,11 @@ "type": "boolean", "default": true, "description": "Shows or hides Nx Generate ui option from the file explorer context menu." + }, + "nxConsole.enableWorkspaceConfigCodeLens": { + "type": "boolean", + "default": true, + "description": "Shows or hides CodeLens for running targets from the Nx workspace config file." } } }, diff --git a/libs/vscode/configuration/src/lib/configuration-keys.ts b/libs/vscode/configuration/src/lib/configuration-keys.ts index ff625ca7e2..91cb714430 100644 --- a/libs/vscode/configuration/src/lib/configuration-keys.ts +++ b/libs/vscode/configuration/src/lib/configuration-keys.ts @@ -2,6 +2,7 @@ export const GLOBAL_CONFIG_KEYS = [ 'enableTelemetry', 'useNVM', 'enableGenerateFromContextMenu', + 'enableWorkspaceConfigCodeLens', ] as const; /** diff --git a/libs/vscode/nx-workspace/src/index.ts b/libs/vscode/nx-workspace/src/index.ts index 7e3c823ca6..8e17111a0a 100644 --- a/libs/vscode/nx-workspace/src/index.ts +++ b/libs/vscode/nx-workspace/src/index.ts @@ -1,2 +1,4 @@ export * from './lib/find-workspace-json-target'; export * from './lib/reveal-workspace-json'; +export * from './lib/workspace-codelens-provider'; +export * from './lib/verify-workspace'; diff --git a/libs/vscode/nx-workspace/src/lib/find-workspace-json-target.ts b/libs/vscode/nx-workspace/src/lib/find-workspace-json-target.ts index a099b5bf4c..891ca0a892 100644 --- a/libs/vscode/nx-workspace/src/lib/find-workspace-json-target.ts +++ b/libs/vscode/nx-workspace/src/lib/find-workspace-json-target.ts @@ -1,5 +1,6 @@ import { TextDocument } from 'vscode'; import { JSONVisitor, visit } from 'jsonc-parser'; +import * as typescript from 'typescript'; export function findWorkspaceJsonTarget( document: TextDocument, @@ -61,3 +62,95 @@ export function findWorkspaceJsonTarget( return scriptOffset; } + +export interface ProjectLocations { + [projectName: string]: ProjectTargetLocation; +} +export interface ProjectTargetLocation { + [target: string]: { + position: number; + configurations?: ProjectTargetLocation; + }; +} + +export function getProjectLocations(document: TextDocument) { + const projectLocations: ProjectLocations = {}; + const json = typescript.parseJsonText('workspace.json', document.getText()); + const statement = json.statements[0]; + const projects = getProperties(statement.expression)?.find( + (property) => getPropertyName(property) === 'projects' + ) as typescript.PropertyAssignment | undefined; + + if (!projects) { + return projectLocations; + } + + getProperties(projects.initializer)?.forEach((project) => { + const projectName = getPropertyName(project); + if (!projectName) { + return; + } + projectLocations[projectName] = + getPositions(project, ['architect', 'targets'], json) ?? {}; + }); + + return projectLocations; +} + +function getProperties( + objectLiteral: typescript.Node +): typescript.NodeArray | undefined { + if (typescript.isObjectLiteralExpression(objectLiteral)) { + return objectLiteral.properties; + } else if (typescript.isPropertyAssignment(objectLiteral)) { + return getProperties(objectLiteral.initializer); + } +} + +function getPropertyName(property: typescript.ObjectLiteralElementLike) { + if ( + typescript.isPropertyAssignment(property) && + typescript.isStringLiteral(property.name) + ) { + return property.name.text; + } +} + +function getPositions( + property: typescript.Node, + properties: string[], + document: typescript.JsonSourceFile +): ProjectTargetLocation | undefined { + const objectLike = getProperties(property)?.find((prop) => { + const propName = getPropertyName(prop); + return properties.some((value) => propName === value); + }); + + if (!objectLike) { + return undefined; + } + + return getProperties(objectLike)?.reduce( + (acc, prop) => { + const propName = getPropertyName(prop); + + if (!propName) { + return acc; + } + + acc[propName] = { + position: prop.getStart(document), + }; + + // get configuration positions + const configs = getPositions(prop, ['configurations'], document); + + if (configs) { + acc[propName].configurations = configs; + } + + return acc; + }, + {} + ); +} diff --git a/libs/vscode/verify/src/lib/verify-workspace.ts b/libs/vscode/nx-workspace/src/lib/verify-workspace.ts similarity index 100% rename from libs/vscode/verify/src/lib/verify-workspace.ts rename to libs/vscode/nx-workspace/src/lib/verify-workspace.ts diff --git a/libs/vscode/nx-workspace/src/lib/workspace-codelens-provider.ts b/libs/vscode/nx-workspace/src/lib/workspace-codelens-provider.ts new file mode 100644 index 0000000000..1ca7fa91d1 --- /dev/null +++ b/libs/vscode/nx-workspace/src/lib/workspace-codelens-provider.ts @@ -0,0 +1,157 @@ +import { + CodeLens, + CodeLensProvider, + Command, + ConfigurationChangeEvent, + Disposable, + ExtensionContext, + languages, + Range, + workspace, +} from 'vscode'; +import { TextDocument } from 'vscode'; +import { verifyWorkspace } from './verify-workspace'; +import { getProjectLocations } from './find-workspace-json-target'; +import { GlobalConfigurationStore } from '@nx-console/vscode/configuration'; + +export class ProjectCodeLens extends CodeLens { + constructor( + range: Range, + public workspaceType: 'nx' | 'ng', + public project: string, + public target: string, + public configuration?: string + ) { + super(range); + } +} +export class WorkspaceCodeLensProvider implements CodeLensProvider { + /** + * CodeLensProvider is disposed and re-registered on setting changes + */ + codeLensProvider: Disposable | null; + + /** + * The WorkspaceCodeLensProvider adds clickable nx run targets in the workspace config file. + * It is enabled by default and can be disabled with the `enableWorkspaceConfigCodeLens` setting. + * @param context instance of ExtensionContext from activate + */ + constructor(private readonly context: ExtensionContext) { + this.registerWorkspaceCodeLensProvider(context); + this.watchWorkspaceCodeLensConfigChange(context); + } + + /** + * Provides a CodeLens set for a matched document + * @param document a document matched by the pattern passed to registerCodeLensProvider + * @returns ProjectCodeLens Range locations and properties for the document + */ + provideCodeLenses(document: TextDocument): CodeLens[] | undefined { + const lens: CodeLens[] = []; + + const projectLocations = getProjectLocations(document); + const { validWorkspaceJson, workspaceType } = verifyWorkspace(); + if (!validWorkspaceJson) { + return; + } + + for (const projectName in projectLocations) { + const project = projectLocations[projectName]; + for (const target in project) { + const position = document.positionAt(project[target].position); + + lens.push( + new ProjectCodeLens( + new Range(position, position), + workspaceType, + projectName, + target + ) + ); + const configurations = project[target].configurations; + if (configurations) { + for (const configuration in configurations) { + const configurationPosition = document.positionAt( + configurations[configuration].position + ); + + lens.push( + new ProjectCodeLens( + new Range(configurationPosition, configurationPosition), + workspaceType, + projectName, + target, + configuration + ) + ); + } + } + } + } + return lens; + } + + /** + * Resolves and sets the command on visible CodeLens + * @param lens lens to be resolve + * @returns ProjectCodeLens with command + */ + // https://github.com/microsoft/vscode-extension-samples/blob/main/codelens-sample/src/CodelensProvider.ts + resolveCodeLens(lens: CodeLens): CodeLens | Promise | null { + if (lens instanceof ProjectCodeLens) { + const command: Command = { + command: `${lens.workspaceType}.run`, + title: lens.configuration + ? `${lens.workspaceType} run ${lens.project}:${lens.target}:${lens.configuration}` + : `${lens.workspaceType} run ${lens.project}:${lens.target}`, + arguments: [lens.project, lens.target, lens.configuration], + }; + lens.command = command; + return lens; + } + return null; + } + + /** + * Checks the enableWorkspaceConfigCodeLens setting and registers this as a CodeLensProvider. + * @param context instance of ExtensionContext from activate + */ + registerWorkspaceCodeLensProvider(context: ExtensionContext) { + const enableWorkspaceConfigCodeLens = GlobalConfigurationStore.instance.get( + 'enableWorkspaceConfigCodeLens' + ); + if (enableWorkspaceConfigCodeLens) { + this.codeLensProvider = languages.registerCodeLensProvider( + { pattern: '**/{workspace,angular}.json' }, + this + ); + context.subscriptions.push(this.codeLensProvider); + } + } + + /** + * Watches for settings/configuration changes and enables/disables the CodeLensProvider + * @param context instance of ExtensionContext from activate + */ + watchWorkspaceCodeLensConfigChange(context: ExtensionContext) { + context.subscriptions.push( + workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { + // if the `nxConsole` config changes, check enableWorkspaceConfigCodeLens and register or dispose + const affectsNxConsoleConfig = event.affectsConfiguration( + GlobalConfigurationStore.configurationSection + ); + if (affectsNxConsoleConfig) { + const enableWorkspaceConfigCodeLens = GlobalConfigurationStore.instance.get( + 'enableWorkspaceConfigCodeLens' + ); + if (enableWorkspaceConfigCodeLens && !this.codeLensProvider) { + this.registerWorkspaceCodeLensProvider(this.context); + } else if (!enableWorkspaceConfigCodeLens && this.codeLensProvider) { + this.codeLensProvider.dispose(); + this.codeLensProvider = null; + } + } + }) + ); + } +} diff --git a/libs/vscode/tasks/src/lib/cli-task-commands.ts b/libs/vscode/tasks/src/lib/cli-task-commands.ts index 9373ce45ef..29a3969383 100644 --- a/libs/vscode/tasks/src/lib/cli-task-commands.ts +++ b/libs/vscode/tasks/src/lib/cli-task-commands.ts @@ -1,10 +1,8 @@ import { commands, ExtensionContext, window, Uri } from 'vscode'; import { selectSchematic } from '@nx-console/server'; -import { - verifyWorkspace, - verifyBuilderDefinition, -} from '@nx-console/vscode/verify'; +import { verifyWorkspace } from '@nx-console/vscode/nx-workspace'; +import { verifyBuilderDefinition } from '@nx-console/vscode/verify'; import { WorkspaceRouteTitle, RunTargetTreeItem, @@ -49,30 +47,30 @@ export function registerCliTaskCommands( ); }); - commands.registerCommand('nx.run', () => - selectCliCommandAndPromptForFlags('run') - ); - commands.registerCommand(`nx.run.fileexplorer`, (uri: Uri) => - selectCliCommandAndPromptForFlags('run', getCliProjectFromUri(uri)) - ); + ['ng', 'nx'].forEach(cli => { + commands.registerCommand( + `${cli}.run`, + (project?: string, target?: string, configuration?: string) => { + selectCliCommandAndPromptForFlags('run', project, target, configuration) + } + ); + commands.registerCommand(`${cli}.run.fileexplorer`, (uri: Uri) => + selectCliCommandAndPromptForFlags('run', getCliProjectFromUri(uri)) + ); - commands.registerCommand(`ng.generate`, () => - selectSchematicAndPromptForFlags() - ); + commands.registerCommand(`${cli}.generate`, () => + selectSchematicAndPromptForFlags() + ); - commands.registerCommand(`ng.generate.ui`, () => - selectCliCommandAndShowUi('generate', context.extensionPath) - ); - commands.registerCommand(`nx.generate`, () => - selectSchematicAndPromptForFlags() - ); + commands.registerCommand(`${cli}.generate.ui`, () => + selectCliCommandAndShowUi('generate', context.extensionPath) + ); + + commands.registerCommand(`${cli}.generate.ui.fileexplorer`, (uri: Uri) => + selectCliCommandAndShowUi('generate', context.extensionPath, uri) + ); + }) - commands.registerCommand(`nx.generate.ui`, () => - selectCliCommandAndShowUi('generate', context.extensionPath) - ); - commands.registerCommand(`nx.generate.ui.fileexplorer`, (uri: Uri) => - selectCliCommandAndShowUi('generate', context.extensionPath, uri) - ); } function selectCliCommandAndShowUi( @@ -105,7 +103,19 @@ function selectCliCommandAndShowUi( ); } -async function selectCliCommandAndPromptForFlags(command: string, projectName?: string) { +async function selectCliCommandAndPromptForFlags( + command: string, + projectName?: string, + target?: string, + configuration?: string +) { + let flags: string[] | undefined; + if (configuration) { + flags = [`--configuration=${configuration}`]; + } else if (projectName && target) { + // don't prompt for flags when project and target are already specified + flags = []; + } const { validWorkspaceJson, json, workspaceType } = verifyWorkspace(); if (!projectName) { @@ -118,12 +128,17 @@ async function selectCliCommandAndPromptForFlags(command: string, projectName?: projectName = selection.projectName; } - let target = command; const isRunCommand = command === 'run'; - if (isRunCommand) { - target = (await selectCliTarget(Object.keys(json.projects[projectName].architect || {}))) as string; - if (!target) { - return; + if (!target) { + if (isRunCommand) { + target = (await selectCliTarget( + Object.keys(json.projects[projectName].architect || {}) + )) as string; + if (!target) { + return; + } + } else { + target = command; } } @@ -142,31 +157,27 @@ async function selectCliCommandAndPromptForFlags(command: string, projectName?: return; } - if (configurations.length) { - const configurationsOption: Option = { - name: 'configuration', - description: - 'A named build target, as specified in the "configurations" section of angular.json.', - type: OptionType.String, - enum: configurations, - aliases: [], - }; - options = [configurationsOption, ...options]; - } + if (!flags) { + if (configurations.length) { + const configurationsOption: Option = { + name: 'configuration', + description: + 'A named build target, as specified in the "configurations" section of angular.json.', + type: OptionType.String, + enum: configurations, + aliases: [], + }; + options = [configurationsOption, ...options]; + } - const flags = await selectFlags( - isRunCommand - ? `${command} ${projectName}:${target}` - : `${command} ${projectName}`, - options, - workspaceType - ); + flags = await selectFlags(isRunCommand + ? `${command} ${projectName}:${target}` + : `${command} ${projectName}`, options, workspaceType); + } if (flags !== undefined) { cliTaskProvider.executeTask({ - positional: isRunCommand - ? `${projectName}:${target}` - : projectName, + positional: isRunCommand ? `${projectName}:${target}` : projectName, command, flags, }); @@ -241,9 +252,7 @@ export function selectCliProject(command: string, json: any) { }); } -async function selectCliTarget( - targets: string[] -): Promise { +async function selectCliTarget(targets: string[]): Promise { return window.showQuickPick(targets, { placeHolder: 'Target to run', }); diff --git a/libs/vscode/tasks/src/lib/cli-task-provider.ts b/libs/vscode/tasks/src/lib/cli-task-provider.ts index 8a4b6c2c21..20fc3942cc 100644 --- a/libs/vscode/tasks/src/lib/cli-task-provider.ts +++ b/libs/vscode/tasks/src/lib/cli-task-provider.ts @@ -8,7 +8,8 @@ import { } from 'vscode'; import { getTelemetry } from '@nx-console/server'; -import { verifyWorkspace, verifyNodeModules } from '@nx-console/vscode/verify'; +import { verifyWorkspace } from '@nx-console/vscode/nx-workspace'; +import { verifyNodeModules } from '@nx-console/vscode/verify'; import { CliTask } from './cli-task'; import { CliTaskDefinition, diff --git a/libs/vscode/tasks/src/lib/nx-task-commands.ts b/libs/vscode/tasks/src/lib/nx-task-commands.ts index 104481c23a..2f7430dc5e 100644 --- a/libs/vscode/tasks/src/lib/nx-task-commands.ts +++ b/libs/vscode/tasks/src/lib/nx-task-commands.ts @@ -5,7 +5,7 @@ import { commands, ExtensionContext, window, tasks } from 'vscode'; import { ProjectDef } from './cli-task-definition'; import { CliTaskProvider } from './cli-task-provider'; import { selectFlags } from './select-flags'; -import { verifyWorkspace } from '@nx-console/vscode/verify'; +import { verifyWorkspace } from '@nx-console/vscode/nx-workspace'; import { getTelemetry } from '@nx-console/server'; import { NxTask } from './nx-task'; diff --git a/libs/vscode/verify/src/index.ts b/libs/vscode/verify/src/index.ts index f74cab8727..25bd5831a3 100644 --- a/libs/vscode/verify/src/index.ts +++ b/libs/vscode/verify/src/index.ts @@ -1,3 +1,2 @@ export * from './lib/verify-builder-definition'; export * from './lib/verify-node-modules'; -export * from './lib/verify-workspace'; diff --git a/libs/vscode/webview/src/lib/get-task-execution-schema.ts b/libs/vscode/webview/src/lib/get-task-execution-schema.ts index f4c13ed887..5f65a019ff 100644 --- a/libs/vscode/webview/src/lib/get-task-execution-schema.ts +++ b/libs/vscode/webview/src/lib/get-task-execution-schema.ts @@ -5,10 +5,8 @@ import { readArchitectDef, selectSchematic, } from '@nx-console/server'; -import { - verifyBuilderDefinition, - verifyWorkspace, -} from '@nx-console/vscode/verify'; +import { verifyWorkspace } from '@nx-console/vscode/nx-workspace'; +import { verifyBuilderDefinition } from '@nx-console/vscode/verify'; import { Uri, window } from 'vscode'; import { WorkspaceRouteTitle } from '@nx-console/vscode/nx-run-target-view'; import {