diff --git a/apps/nxls/src/main.ts b/apps/nxls/src/main.ts index 1b04cbc68d..b63c12e409 100644 --- a/apps/nxls/src/main.ts +++ b/apps/nxls/src/main.ts @@ -5,48 +5,16 @@ import { projectSchemaIsRegistered, resetInferencePluginsCompletionCache, } from '@nx-console/language-server-capabilities-code-completion'; -import { - downloadAndExtractArtifact, - getNxCloudTerminalOutput, - getRecentCIPEData, - nxCloudAuthHeaders, -} from '@nx-console/shared-nx-cloud'; import { getDefinition } from '@nx-console/language-server-capabilities-definition'; import { getDocumentLinks } from '@nx-console/language-server-capabilities-document-links'; import { getHover } from '@nx-console/language-server-capabilities-hover'; import { NxChangeWorkspace, - NxCloudAuthHeadersRequest, - NxCloudOnboardingInfoRequest, - NxCloudStatusRequest, - NxCloudTerminalOutputRequest, - NxCreateProjectGraphRequest, - NxDownloadAndExtractArtifactRequest, - NxGeneratorContextV2Request, - NxGeneratorOptionsRequest, - NxGeneratorOptionsRequestOptions, - NxGeneratorsRequest, - NxGeneratorsRequestOptions, - NxHasAffectedProjectsRequest, - NxPDVDataRequest, - NxParseTargetStringRequest, - NxProjectByPathRequest, - NxProjectByRootRequest, - NxProjectFolderTreeRequest, - NxProjectGraphOutputRequest, - NxProjectsByPathsRequest, - NxRecentCIPEDataRequest, - NxSourceMapFilesToProjectsMapRequest, - NxStartupMessageRequest, - NxStopDaemonRequest, - NxTargetsForConfigFileRequest, - NxTransformedGeneratorSchemaRequest, - NxVersionRequest, + NxWatcherOperationalNotification, NxWorkspacePathRequest, NxWorkspaceRefreshNotification, NxWorkspaceRefreshStartedNotification, NxWorkspaceRequest, - NxWorkspaceSerializedRequest, } from '@nx-console/language-server-types'; import { getJsonLanguageService, @@ -55,35 +23,15 @@ import { setLspLogger, } from '@nx-console/language-server-utils'; import { - languageServerWatcher, cleanupAllWatchers, + languageServerWatcher, } from '@nx-console/language-server-watcher'; import { - createProjectGraph, - getCloudOnboardingInfo, - getGeneratorContextV2, - getGeneratorOptions, - getNxCloudStatus, - getPDVData, getProjectByPath, - getProjectByRoot, - getProjectFolderTree, - getProjectGraphOutput, - getProjectsByPaths, - getSourceMapFilesToProjectsMap, - getStartupMessage, - getTargetsForConfigFile, - getTransformedGeneratorSchema, - hasAffectedProjects, - nxStopDaemon, - parseTargetString, resetProjectPathCache, resetSourceMapFilesToProjectCache, } from '@nx-console/language-server-workspace'; -import { GeneratorSchema } from '@nx-console/shared-generate-ui-types'; import { - getGenerators, - getNxVersion, nxWorkspace, resetNxVersionCache, } from '@nx-console/shared-nx-workspace-info'; @@ -93,6 +41,12 @@ import { killGroup, loadRootEnvFiles, } from '@nx-console/shared-utils'; +import { + DaemonWatcherCallback, + NativeWatcher, +} from '@nx-console/shared-watcher'; +import type { ProjectGraph } from 'nx/src/devkit-exports'; +import type { ConfigurationSourceMaps } from 'nx/src/project-graph/utils/project-configuration-utils'; import { ClientCapabilities, TextDocument } from 'vscode-json-languageservice'; import { CreateFilesParams, @@ -107,7 +61,7 @@ import { } from 'vscode-languageserver/node'; import { URI } from 'vscode-uri'; import { ensureOnlyJsonRpcStdout } from './ensureOnlyJsonRpcStdout'; -import { NativeWatcher } from '@nx-console/shared-watcher'; +import { registerRequests } from './requests'; process.on('unhandledRejection', (e: any) => { connection.console.error(formatError(`Unhandled exception`, e)); @@ -122,6 +76,23 @@ let CLIENT_CAPABILITIES: ClientCapabilities | undefined = undefined; let unregisterFileWatcher: () => Promise = async () => { //noop }; +const fileWatcherCallback: DaemonWatcherCallback = async ( + _, + projectGraphAndSourceMaps, +) => { + if (!WORKING_PATH) { + return; + } + await reconfigureAndSendNotificationWithBackoff( + WORKING_PATH, + projectGraphAndSourceMaps, + ); +}; +const fileWatcherOperationalCallback = (isOperational: boolean) => { + connection.sendNotification(NxWatcherOperationalNotification.method, { + isOperational, + }); +}; let reconfigureAttempts = 0; const connection = createConnection(ProposedFeatures.all); @@ -154,14 +125,11 @@ connection.onInitialize(async (params) => { CLIENT_CAPABILITIES = params.capabilities; await configureSchemas(WORKING_PATH, CLIENT_CAPABILITIES); + unregisterFileWatcher = await languageServerWatcher( WORKING_PATH, - async () => { - if (!WORKING_PATH) { - return; - } - await reconfigureAndSendNotificationWithBackoff(WORKING_PATH); - }, + fileWatcherCallback, + fileWatcherOperationalCallback, ); } catch (e) { lspLogger.log('Unable to get Nx info: ' + e.toString()); @@ -331,14 +299,6 @@ connection.onShutdown(async () => { lspLogger.log('Language server shutdown completed'); }); -connection.onRequest(NxStopDaemonRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - - return await nxStopDaemon(WORKING_PATH, lspLogger); -}); - connection.onRequest(NxWorkspaceRequest, async ({ reset }) => { if (!WORKING_PATH) { return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); @@ -347,280 +307,10 @@ connection.onRequest(NxWorkspaceRequest, async ({ reset }) => { return await nxWorkspace(WORKING_PATH, lspLogger, reset); }); -connection.onRequest(NxWorkspaceSerializedRequest, async ({ reset }) => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - - const workspace = await nxWorkspace(WORKING_PATH, lspLogger, reset); - return JSON.stringify(workspace); -}); - connection.onRequest(NxWorkspacePathRequest, () => { return WORKING_PATH; }); -connection.onRequest( - NxGeneratorsRequest, - async (args: { options?: NxGeneratorsRequestOptions }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - - return await getGenerators(WORKING_PATH, args.options); - }, -); - -connection.onRequest( - NxGeneratorOptionsRequest, - async (args: { options: NxGeneratorOptionsRequestOptions }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - - return await getGeneratorOptions( - WORKING_PATH, - args.options.collection, - args.options.name, - args.options.path, - ); - }, -); - -connection.onRequest( - NxProjectByPathRequest, - async (args: { projectPath: string }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getProjectByPath(args.projectPath, WORKING_PATH); - }, -); - -connection.onRequest( - NxProjectsByPathsRequest, - async (args: { paths: string[] }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getProjectsByPaths(args.paths, WORKING_PATH); - }, -); - -connection.onRequest( - NxProjectByRootRequest, - async (args: { projectRoot: string }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getProjectByRoot(args.projectRoot, WORKING_PATH); - }, -); - -connection.onRequest( - NxGeneratorContextV2Request, - async (args: { path: string | undefined }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getGeneratorContextV2(args.path, WORKING_PATH); - }, -); - -connection.onRequest(NxVersionRequest, async ({ reset }) => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - const nxVersion = await getNxVersion(WORKING_PATH, reset); - return nxVersion; -}); - -connection.onRequest(NxProjectGraphOutputRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await getProjectGraphOutput(WORKING_PATH); -}); - -connection.onRequest(NxCreateProjectGraphRequest, async ({ showAffected }) => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - try { - await createProjectGraph(WORKING_PATH, showAffected); - } catch (e) { - lspLogger.log('Error creating project graph: ' + e.toString()); - return e; - } -}); - -connection.onRequest(NxProjectFolderTreeRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await getProjectFolderTree(WORKING_PATH); -}); - -connection.onRequest( - NxTransformedGeneratorSchemaRequest, - async (schema: GeneratorSchema) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getTransformedGeneratorSchema(WORKING_PATH, schema); - }, -); - -connection.onRequest( - NxStartupMessageRequest, - async (schema: GeneratorSchema) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getStartupMessage(WORKING_PATH, schema); - }, -); - -connection.onRequest(NxHasAffectedProjectsRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await hasAffectedProjects(WORKING_PATH, lspLogger); -}); - -connection.onRequest(NxSourceMapFilesToProjectsMapRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await getSourceMapFilesToProjectsMap(WORKING_PATH); -}); - -connection.onRequest( - NxTargetsForConfigFileRequest, - async (args: { projectName: string; configFilePath: string }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getTargetsForConfigFile( - args.projectName, - args.configFilePath, - WORKING_PATH, - ); - }, -); - -connection.onRequest(NxCloudStatusRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await getNxCloudStatus(WORKING_PATH); -}); - -connection.onRequest( - NxCloudOnboardingInfoRequest, - async (args: { force?: boolean }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await getCloudOnboardingInfo(WORKING_PATH, args.force); - }, -); - -connection.onRequest(NxPDVDataRequest, async (args: { filePath: string }) => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await getPDVData(WORKING_PATH, args.filePath); -}); - -connection.onRequest(NxRecentCIPEDataRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - - return await getRecentCIPEData(WORKING_PATH, lspLogger); -}); - -connection.onRequest( - NxCloudTerminalOutputRequest, - async (args: { - ciPipelineExecutionId?: string; - taskId: string; - linkId?: string; - }) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return getNxCloudTerminalOutput(args, WORKING_PATH, lspLogger); - }, -); - -connection.onRequest( - NxParseTargetStringRequest, - async (targetString: string) => { - if (!WORKING_PATH) { - return new ResponseError( - 1000, - 'Unable to get Nx info: no workspace path', - ); - } - return await parseTargetString(targetString, WORKING_PATH); - }, -); - -connection.onRequest(NxCloudAuthHeadersRequest, async () => { - if (!WORKING_PATH) { - return new ResponseError(1000, 'Unable to get Nx info: no workspace path'); - } - return await nxCloudAuthHeaders(WORKING_PATH); -}); - -connection.onRequest( - NxDownloadAndExtractArtifactRequest, - async ({ artifactUrl }) => { - try { - const content = await downloadAndExtractArtifact(artifactUrl, lspLogger); - return { content }; - } catch (e) { - lspLogger.log(`Error downloading artifact: ${e.message}`); - return { error: e.message }; - } - }, -); - connection.onNotification(NxWorkspaceRefreshNotification, async () => { if (!WORKING_PATH) { return new ResponseError(1001, 'Unable to get Nx info: no workspace path'); @@ -629,6 +319,8 @@ connection.onNotification(NxWorkspaceRefreshNotification, async () => { await reconfigureAndSendNotificationWithBackoff(WORKING_PATH); }); +registerRequests(connection, () => WORKING_PATH); + connection.onNotification( 'workspace/didCreateFiles', async (createdFiles: CreateFilesParams) => { @@ -672,11 +364,17 @@ connection.onNotification(NxChangeWorkspace, async (workspacePath) => { await reconfigureAndSendNotificationWithBackoff(WORKING_PATH); }); -async function reconfigureAndSendNotificationWithBackoff(workingPath: string) { +async function reconfigureAndSendNotificationWithBackoff( + workingPath: string, + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, +) { if (reconfigureAttempts === 0) { connection.sendNotification(NxWorkspaceRefreshStartedNotification.method); } - const workspace = await reconfigure(workingPath); + const workspace = await reconfigure(workingPath, projectGraphAndSourceMaps); await connection.sendNotification(NxWorkspaceRefreshNotification.method); if ( @@ -709,20 +407,31 @@ async function reconfigureAndSendNotificationWithBackoff(workingPath: string) { async function reconfigure( workingPath: string, + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, ): Promise { resetNxVersionCache(); resetProjectPathCache(); resetSourceMapFilesToProjectCache(); resetInferencePluginsCompletionCache(); - const workspace = await nxWorkspace(workingPath, lspLogger, true); + const workspace = await nxWorkspace( + workingPath, + lspLogger, + true, + projectGraphAndSourceMaps, + ); await configureSchemas(workingPath, CLIENT_CAPABILITIES); - await unregisterFileWatcher(); + unregisterFileWatcher?.(); - unregisterFileWatcher = await languageServerWatcher(workingPath, async () => { - reconfigureAndSendNotificationWithBackoff(workingPath); - }); + unregisterFileWatcher = await languageServerWatcher( + workingPath, + fileWatcherCallback, + fileWatcherOperationalCallback, + ); return workspace; } diff --git a/apps/nxls/src/requests.ts b/apps/nxls/src/requests.ts new file mode 100644 index 0000000000..7ffa185976 --- /dev/null +++ b/apps/nxls/src/requests.ts @@ -0,0 +1,410 @@ +import { + NxCloudAuthHeadersRequest, + NxCloudOnboardingInfoRequest, + NxCloudStatusRequest, + NxCloudTerminalOutputRequest, + NxCreateProjectGraphRequest, + NxDownloadAndExtractArtifactRequest, + NxGeneratorContextV2Request, + NxGeneratorOptionsRequest, + NxGeneratorOptionsRequestOptions, + NxGeneratorsRequest, + NxGeneratorsRequestOptions, + NxHasAffectedProjectsRequest, + NxParseTargetStringRequest, + NxPDVDataRequest, + NxProjectByPathRequest, + NxProjectByRootRequest, + NxProjectFolderTreeRequest, + NxProjectGraphOutputRequest, + NxRecentCIPEDataRequest, + NxSourceMapFilesToProjectsMapRequest, + NxStartDaemonRequest, + NxStartupMessageRequest, + NxStopDaemonRequest, + NxTargetsForConfigFileRequest, + NxTransformedGeneratorSchemaRequest, + NxVersionRequest, + NxWorkspaceSerializedRequest, +} from '@nx-console/language-server-types'; +import { lspLogger } from '@nx-console/language-server-utils'; +import { + createProjectGraph, + getCloudOnboardingInfo, + getGeneratorContextV2, + getGeneratorOptions, + getNxCloudStatus, + getPDVData, + getProjectByPath, + getProjectByRoot, + getProjectFolderTree, + getProjectGraphOutput, + getSourceMapFilesToProjectsMap, + getStartupMessage, + getTargetsForConfigFile, + getTransformedGeneratorSchema, + hasAffectedProjects, + nxStartDaemon, + nxStopDaemon, + parseTargetString, +} from '@nx-console/language-server-workspace'; +import { GeneratorSchema } from '@nx-console/shared-generate-ui-types'; +import { + downloadAndExtractArtifact, + getNxCloudTerminalOutput, + getRecentCIPEData, + nxCloudAuthHeaders, +} from '@nx-console/shared-nx-cloud'; +import { + getGenerators, + getNxVersion, + nxWorkspace, +} from '@nx-console/shared-nx-workspace-info'; +import { _, _Connection, ResponseError } from 'vscode-languageserver/node'; + +export function registerRequests( + connection: _Connection<_, _, _, _, _, _, _, _>, + getWorkingPath: () => string | undefined, +) { + connection.onRequest(NxStopDaemonRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + return await nxStopDaemon(WORKING_PATH, lspLogger); + }); + + connection.onRequest(NxStartDaemonRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + return await nxStartDaemon(WORKING_PATH, lspLogger); + }); + + connection.onRequest(NxWorkspaceSerializedRequest, async ({ reset }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + const workspace = await nxWorkspace(WORKING_PATH, lspLogger, reset); + return JSON.stringify(workspace); + }); + + connection.onRequest( + NxGeneratorsRequest, + async (args: { options?: NxGeneratorsRequestOptions }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + return await getGenerators(WORKING_PATH, args.options); + }, + ); + + connection.onRequest( + NxGeneratorOptionsRequest, + async (args: { options: NxGeneratorOptionsRequestOptions }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + return await getGeneratorOptions( + WORKING_PATH, + args.options.collection, + args.options.name, + args.options.path, + ); + }, + ); + + connection.onRequest( + NxProjectByPathRequest, + async (args: { projectPath: string }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getProjectByPath(args.projectPath, WORKING_PATH); + }, + ); + + connection.onRequest( + NxProjectByRootRequest, + async (args: { projectRoot: string }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getProjectByRoot(args.projectRoot, WORKING_PATH); + }, + ); + + connection.onRequest( + NxGeneratorContextV2Request, + async (args: { path: string | undefined }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getGeneratorContextV2(args.path, WORKING_PATH); + }, + ); + + connection.onRequest(NxVersionRequest, async ({ reset }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + const nxVersion = await getNxVersion(WORKING_PATH, reset); + return nxVersion; + }); + + connection.onRequest(NxProjectGraphOutputRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getProjectGraphOutput(WORKING_PATH); + }); + + connection.onRequest( + NxCreateProjectGraphRequest, + async ({ showAffected }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + try { + await createProjectGraph(WORKING_PATH, showAffected); + } catch (e) { + lspLogger.log('Error creating project graph: ' + e.toString()); + return e; + } + }, + ); + + connection.onRequest(NxProjectFolderTreeRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getProjectFolderTree(WORKING_PATH); + }); + + connection.onRequest( + NxTransformedGeneratorSchemaRequest, + async (schema: GeneratorSchema) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getTransformedGeneratorSchema(WORKING_PATH, schema); + }, + ); + + connection.onRequest( + NxStartupMessageRequest, + async (schema: GeneratorSchema) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getStartupMessage(WORKING_PATH, schema); + }, + ); + + connection.onRequest(NxHasAffectedProjectsRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await hasAffectedProjects(WORKING_PATH, lspLogger); + }); + + connection.onRequest(NxSourceMapFilesToProjectsMapRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getSourceMapFilesToProjectsMap(WORKING_PATH); + }); + + connection.onRequest( + NxTargetsForConfigFileRequest, + async (args: { projectName: string; configFilePath: string }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getTargetsForConfigFile( + args.projectName, + args.configFilePath, + WORKING_PATH, + ); + }, + ); + + connection.onRequest(NxCloudStatusRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getNxCloudStatus(WORKING_PATH); + }); + + connection.onRequest( + NxCloudOnboardingInfoRequest, + async (args: { force?: boolean }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getCloudOnboardingInfo(WORKING_PATH, args.force); + }, + ); + + connection.onRequest(NxPDVDataRequest, async (args: { filePath: string }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await getPDVData(WORKING_PATH, args.filePath); + }); + + connection.onRequest(NxRecentCIPEDataRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + + return await getRecentCIPEData(WORKING_PATH, lspLogger); + }); + + connection.onRequest( + NxCloudTerminalOutputRequest, + async (args: { + ciPipelineExecutionId?: string; + taskId: string; + linkId?: string; + }) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return getNxCloudTerminalOutput(args, WORKING_PATH, lspLogger); + }, + ); + + connection.onRequest( + NxParseTargetStringRequest, + async (targetString: string) => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await parseTargetString(targetString, WORKING_PATH); + }, + ); + + connection.onRequest(NxCloudAuthHeadersRequest, async () => { + const WORKING_PATH = getWorkingPath(); + if (!WORKING_PATH) { + return new ResponseError( + 1000, + 'Unable to get Nx info: no workspace path', + ); + } + return await nxCloudAuthHeaders(WORKING_PATH); + }); + + connection.onRequest( + NxDownloadAndExtractArtifactRequest, + async ({ artifactUrl }) => { + try { + const content = await downloadAndExtractArtifact( + artifactUrl, + lspLogger, + ); + return { content }; + } catch (e) { + lspLogger.log(`Error downloading artifact: ${e.message}`); + return { error: e.message }; + } + }, + ); +} diff --git a/apps/vscode/package.json b/apps/vscode/package.json index d07994b7ff..9f9de4e1c8 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -215,6 +215,11 @@ "command": "nxConsole.showProblems", "when": "view == nxProjects && viewItem == projectGraphError", "group": "inline@1" + }, + { + "command": "nxConsole.restartDaemonWatcher", + "when": "view == nxProjects && viewItem == daemonWatcherNotRunning", + "group": "inline@1" } ], "editor/title": [ @@ -935,6 +940,12 @@ "command": "nxConsole.showProblems", "icon": "$(eye)" }, + { + "category": "Nx", + "title": "Restart Daemon Watcher", + "command": "nxConsole.restartDaemonWatcher", + "icon": "$(refresh)" + }, { "category": "Nx Migrate", "title": "Refresh View", diff --git a/libs/language-server/types/src/index.ts b/libs/language-server/types/src/index.ts index 69af0b84eb..dded11b7d5 100644 --- a/libs/language-server/types/src/index.ts +++ b/libs/language-server/types/src/index.ts @@ -34,9 +34,16 @@ export const NxWorkspaceRefreshNotification: NotificationType = export const NxWorkspaceRefreshStartedNotification: NotificationType = new NotificationType('nx/refreshWorkspaceStarted'); +export const NxWatcherOperationalNotification: NotificationType<{ + isOperational: boolean; +}> = new NotificationType('nx/fileWatcherOperational'); + export const NxStopDaemonRequest: RequestType = new RequestType('nx/stopDaemon'); +export const NxStartDaemonRequest: RequestType = + new RequestType('nx/startDaemon'); + export const NxWorkspaceRequest: RequestType< { reset: boolean }, NxWorkspace, diff --git a/libs/language-server/watcher/src/lib/parcel-watcher.ts b/libs/language-server/watcher/src/lib/parcel-watcher.ts deleted file mode 100644 index b6a1cfab3b..0000000000 --- a/libs/language-server/watcher/src/lib/parcel-watcher.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { lspLogger } from '@nx-console/language-server-utils'; -import { importNxPackagePath } from '@nx-console/shared-npm'; -import { platform } from 'os'; - -export class ParcelWatcher { - private subscription: import('@parcel/watcher').AsyncSubscription | undefined; - private stopped = false; - constructor( - private workspacePath: string, - private callback: () => unknown, - ) { - this.initWatcher(); - } - - public stop() { - this.subscription?.unsubscribe(); - this.stopped = true; - } - - private async initWatcher() { - const module = await import('@parcel/watcher'); - const watcher = module.default; - - this.subscription = await watcher.subscribe( - this.workspacePath, - (err, events) => { - if (this.stopped) { - this.subscription?.unsubscribe(); - return; - } - if (err) { - lspLogger.log('Error watching files: ' + err.toString()); - } else if ( - events.some( - (e) => - e.path.endsWith('project.json') || - e.path.endsWith('package.json') || - e.path.endsWith('nx.json') || - e.path.endsWith('workspace.json') || - e.path.endsWith('tsconfig.base.json'), - ) - ) { - lspLogger.log( - `Project configuration changed, ${events.map((e) => e.path)}`, - ); - this.callback(); - } - }, - await this.watcherOptions(), - ); - lspLogger.log('Parcel watcher initialized'); - } - - private async watcherOptions(): Promise< - import('@parcel/watcher').Options | undefined - > { - let ignoredGlobs: string[] = []; - try { - const { getIgnoredGlobs } = await importNxPackagePath( - this.workspacePath, - 'src/utils/ignore', - lspLogger, - ); - ignoredGlobs = getIgnoredGlobs(this.workspacePath).filter( - (glob) => !glob.startsWith('!'), - ); - } catch (e) { - // do nothing as parcel is only used for Nx < 16.4.0, and this function was removed in Nx 21 - } - - const options: import('@parcel/watcher').Options = { - ignore: ignoredGlobs, - }; - - if (platform() === 'win32') { - options.backend = 'windows'; - } - - return options; - } -} diff --git a/libs/language-server/watcher/src/lib/watcher.ts b/libs/language-server/watcher/src/lib/watcher.ts index d6e6d87fec..63505aa269 100644 --- a/libs/language-server/watcher/src/lib/watcher.ts +++ b/libs/language-server/watcher/src/lib/watcher.ts @@ -1,25 +1,33 @@ import { lspLogger } from '@nx-console/language-server-utils'; -import { getNxVersion } from '@nx-console/shared-nx-workspace-info'; +import { gte, NxVersion } from '@nx-console/nx-version'; +import { + getNxDaemonClient, + getNxVersion, +} from '@nx-console/shared-nx-workspace-info'; import { debounce } from '@nx-console/shared-utils'; -import { DaemonWatcher, NativeWatcher } from '@nx-console/shared-watcher'; -import { ParcelWatcher } from './parcel-watcher'; -import { gte } from '@nx-console/nx-version'; +import { + DaemonWatcher, + DaemonWatcherCallback, + NativeWatcher, + PassiveDaemonWatcher, +} from '@nx-console/shared-watcher'; -let _daemonWatcher: DaemonWatcher | undefined; +let _passiveDaemonWatcher: PassiveDaemonWatcher | undefined; let _nativeWatcher: NativeWatcher | undefined; +let _daemonWatcher: DaemonWatcher | undefined; export async function cleanupAllWatchers(): Promise { const cleanupPromises: Promise[] = []; - if (_nativeWatcher) { + if (_passiveDaemonWatcher) { cleanupPromises.push( - _nativeWatcher.stop().catch((e) => { + Promise.resolve(_passiveDaemonWatcher.dispose()).catch((e) => { lspLogger.log( - 'Error stopping native watcher during global cleanup: ' + e, + 'Error stopping daemon watcher during global cleanup: ' + e, ); }), ); - _nativeWatcher = undefined; + _passiveDaemonWatcher = undefined; } if (_daemonWatcher) { @@ -33,82 +41,147 @@ export async function cleanupAllWatchers(): Promise { _daemonWatcher = undefined; } + if (_nativeWatcher) { + cleanupPromises.push( + _nativeWatcher.stop().catch((e) => { + lspLogger.log( + 'Error stopping native watcher during global cleanup: ' + e, + ); + }), + ); + _nativeWatcher = undefined; + } + await Promise.all(cleanupPromises); } export async function languageServerWatcher( workspacePath: string, - callback: () => unknown, + callback: DaemonWatcherCallback, + watcherOperationalCallback?: (isOperational: boolean) => void, +): Promise<() => Promise> { + const nxVersion = await getNxVersion(workspacePath); + if (!nxVersion || !gte(nxVersion, '16.4.0')) { + lspLogger.log( + 'File watching is not supported for Nx versions below 16.4.0.', + ); + watcherOperationalCallback?.(false); + return async () => { + lspLogger.log('unregistering empty watcher'); + }; + } + + if (gte(nxVersion, '22.0.0')) { + return registerPassiveDaemonWatcher( + workspacePath, + callback, + watcherOperationalCallback, + ); + } else { + // older versions don't have this granular watcher tracking so we just assume true + watcherOperationalCallback?.(true); + return registerOldWatcher(workspacePath, nxVersion, callback); + } +} + +async function registerPassiveDaemonWatcher( + workspacePath: string, + callback: DaemonWatcherCallback, + watcherOperationalCallback?: (isOperational: boolean) => void, +): Promise<() => Promise> { + const daemonClient = await getNxDaemonClient(workspacePath, lspLogger); + + if (!daemonClient.daemonClient.enabled()) { + lspLogger.log('Daemon client is not enabled, file watching not available.'); + return async () => { + lspLogger.log('unregistering empty watcher'); + }; + } + try { + _passiveDaemonWatcher = new PassiveDaemonWatcher( + workspacePath, + lspLogger, + watcherOperationalCallback, + ); + await _passiveDaemonWatcher.start(); + _passiveDaemonWatcher.listen((error, projectGraphAndSourceMaps) => { + callback(error, projectGraphAndSourceMaps); + }); + return async () => { + if (_passiveDaemonWatcher) { + return _passiveDaemonWatcher.dispose(); + } + }; + } catch (e) { + lspLogger.log( + 'Error starting passive daemon watcher: ' + (e as Error).message, + ); + return async () => { + lspLogger.log('unregistering empty watcher'); + }; + } +} + +async function registerOldWatcher( + workspacePath: string, + nxVersion: NxVersion, + callback: () => void, ): Promise<() => Promise> { - const version = await getNxVersion(workspacePath); const debouncedCallback = debounce(callback, 1000); - if (gte(version, '16.4.0')) { - if (process.platform === 'win32') { - if (_nativeWatcher) { - try { - await _nativeWatcher.stop(); - } catch (e) { - lspLogger.log('Error stopping previous native watcher: ' + e); - } + if (process.platform === 'win32') { + if (_nativeWatcher) { + try { + await _nativeWatcher.stop(); + } catch (e) { + lspLogger.log('Error stopping previous native watcher: ' + e); + } + _nativeWatcher = undefined; + } + const nativeWatcher = new NativeWatcher( + workspacePath, + debouncedCallback, + lspLogger, + ); + _nativeWatcher = nativeWatcher; + return async () => { + lspLogger.log('Unregistering file watcher'); + try { + await nativeWatcher.stop(); + } catch (e) { + lspLogger.log('Error stopping native watcher during cleanup: ' + e); + } + if (_nativeWatcher === nativeWatcher) { _nativeWatcher = undefined; } - const nativeWatcher = new NativeWatcher( - workspacePath, - debouncedCallback, - lspLogger, - ); - _nativeWatcher = nativeWatcher; - return async () => { - lspLogger.log('Unregistering file watcher'); - try { - await nativeWatcher.stop(); - } catch (e) { - lspLogger.log('Error stopping native watcher during cleanup: ' + e); - } - if (_nativeWatcher === nativeWatcher) { - _nativeWatcher = undefined; - } - }; - } else { - if (_daemonWatcher) { - try { - _daemonWatcher.stop(); - } catch (e) { - lspLogger.log('Error stopping previous daemon watcher: ' + e); - } - _daemonWatcher = undefined; + }; + } else { + if (_daemonWatcher) { + try { + _daemonWatcher.stop(); + } catch (e) { + lspLogger.log('Error stopping previous daemon watcher: ' + e); } - const daemonWatcher = new DaemonWatcher( - workspacePath, - version, - debouncedCallback, - lspLogger, - ); - _daemonWatcher = daemonWatcher; - - await daemonWatcher.start(); - return async () => { - lspLogger.log('Unregistering file watcher'); - try { - await daemonWatcher.stop(); - } catch (e) { - lspLogger.log('Error stopping daemon watcher during cleanup: ' + e); - } - if (_daemonWatcher === daemonWatcher) { - _daemonWatcher = undefined; - } - }; + _daemonWatcher = undefined; } - } else { - lspLogger.log('Nx version <16.4.0, using @parcel/watcher'); - const parcelWatcher = new ParcelWatcher(workspacePath, debouncedCallback); + const daemonWatcher = new DaemonWatcher( + workspacePath, + nxVersion, + debouncedCallback, + lspLogger, + ); + _daemonWatcher = daemonWatcher; + + await daemonWatcher.start(); return async () => { lspLogger.log('Unregistering file watcher'); try { - parcelWatcher.stop(); + await daemonWatcher.stop(); } catch (e) { - lspLogger.log('Error stopping parcel watcher during cleanup: ' + e); + lspLogger.log('Error stopping daemon watcher during cleanup: ' + e); + } + if (_daemonWatcher === daemonWatcher) { + _daemonWatcher = undefined; } }; } diff --git a/libs/language-server/watcher/tsconfig.json b/libs/language-server/watcher/tsconfig.json index 5febd9309d..18a19c1473 100644 --- a/libs/language-server/watcher/tsconfig.json +++ b/libs/language-server/watcher/tsconfig.json @@ -3,9 +3,6 @@ "files": [], "include": [], "references": [ - { - "path": "../../shared/nx-version" - }, { "path": "../../shared/watcher" }, @@ -16,7 +13,7 @@ "path": "../../shared/nx-workspace-info" }, { - "path": "../../shared/npm" + "path": "../../shared/nx-version" }, { "path": "../utils" diff --git a/libs/language-server/watcher/tsconfig.lib.json b/libs/language-server/watcher/tsconfig.lib.json index d756a3cb9b..5c91a6e59b 100644 --- a/libs/language-server/watcher/tsconfig.lib.json +++ b/libs/language-server/watcher/tsconfig.lib.json @@ -7,9 +7,6 @@ "exclude": ["out-tsc", "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], "include": ["**/*.ts"], "references": [ - { - "path": "../../shared/nx-version/tsconfig.lib.json" - }, { "path": "../../shared/watcher/tsconfig.lib.json" }, @@ -20,7 +17,7 @@ "path": "../../shared/nx-workspace-info/tsconfig.lib.json" }, { - "path": "../../shared/npm/tsconfig.lib.json" + "path": "../../shared/nx-version/tsconfig.lib.json" }, { "path": "../utils/tsconfig.lib.json" diff --git a/libs/language-server/workspace/src/index.ts b/libs/language-server/workspace/src/index.ts index b38194d822..442d1395d2 100644 --- a/libs/language-server/workspace/src/index.ts +++ b/libs/language-server/workspace/src/index.ts @@ -8,7 +8,7 @@ export * from './lib/get-project-folder-tree'; export * from './lib/nx-console-plugins'; export * from './lib/has-affected-projects'; export * from './lib/get-source-map'; -export * from './lib/nx-stop-daemon'; +export * from './lib/nx-daemon'; export * from './lib/get-nx-cloud-status'; export * from './lib/get-cloud-onboarding-info'; export * from './lib/get-pdv-data'; diff --git a/libs/language-server/workspace/src/lib/nx-daemon.ts b/libs/language-server/workspace/src/lib/nx-daemon.ts new file mode 100644 index 0000000000..c09ad3277f --- /dev/null +++ b/libs/language-server/workspace/src/lib/nx-daemon.ts @@ -0,0 +1,30 @@ +import { Logger } from '@nx-console/shared-utils'; +import { getPackageManagerCommand } from '@nx-console/shared-npm'; +import { execSync } from 'node:child_process'; + +export async function nxStopDaemon(workspacePath: string, logger: Logger) { + const packageManagerCommands = await getPackageManagerCommand( + workspacePath, + logger, + ); + const command = `${packageManagerCommands.exec} nx daemon --stop`; + logger.log(`stopping daemon with ${command}`); + execSync(command, { + cwd: workspacePath, + windowsHide: true, + }); +} + +export async function nxStartDaemon(workspacePath: string, logger: Logger) { + const packageManagerCommands = await getPackageManagerCommand( + workspacePath, + logger, + ); + const command = `${packageManagerCommands.exec} nx daemon --start`; + logger.log(`starting daemon with ${command}`); + + execSync(command, { + cwd: workspacePath, + windowsHide: true, + }); +} diff --git a/libs/language-server/workspace/src/lib/nx-stop-daemon.ts b/libs/language-server/workspace/src/lib/nx-stop-daemon.ts deleted file mode 100644 index c53879440f..0000000000 --- a/libs/language-server/workspace/src/lib/nx-stop-daemon.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Logger } from '@nx-console/shared-utils'; -import { getPackageManagerCommand } from '@nx-console/shared-npm'; -import { execSync } from 'node:child_process'; - -export async function nxStopDaemon(workspacePath: string, logger: Logger) { - logger.log('stopping daemon with `nx daemon --stop`'); - - const packageManagerCommands = await getPackageManagerCommand( - workspacePath, - logger, - ); - execSync(`${packageManagerCommands.exec} nx daemon --stop`, { - cwd: workspacePath, - windowsHide: true, - }); -} diff --git a/libs/shared/nx-workspace-info/src/lib/get-nx-workspace-config.ts b/libs/shared/nx-workspace-info/src/lib/get-nx-workspace-config.ts index b1b1c6fd18..7e5711a141 100644 --- a/libs/shared/nx-workspace-info/src/lib/get-nx-workspace-config.ts +++ b/libs/shared/nx-workspace-info/src/lib/get-nx-workspace-config.ts @@ -25,6 +25,10 @@ export async function getNxWorkspaceConfig( workspacePath: string, nxVersion: NxVersion, logger: Logger, + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, ): Promise<{ projectGraph: ProjectGraph | undefined; sourceMaps: ConfigurationSourceMaps | undefined; @@ -100,67 +104,88 @@ export async function getNxWorkspaceConfig( }); } - try { - _defaultProcessExit = process.exit; - process.exit = function (code?: number) { - console.warn('process.exit called with code', code); - } as (code?: number) => never; - - if (nxOutput !== undefined) { - nxOutput.output.error = (output) => { - // do nothing - }; - nxOutput.output.log = (output) => { - // do nothing - }; - } + if (!projectGraphAndSourceMaps) { + try { + _defaultProcessExit = process.exit; + process.exit = function (code?: number) { + console.warn('process.exit called with code', code); + } as (code?: number) => never; + + if (nxOutput !== undefined) { + nxOutput.output.error = (output) => { + // do nothing + }; + nxOutput.output.log = (output) => { + // do nothing + }; + } - if (gte(nxVersion, '17.2.0')) { - logger.log('createProjectGraphAndSourceMapsAsync'); - try { - const projectGraphAndSourceMaps = await ( - nxProjectGraph as any - ).createProjectGraphAndSourceMapsAsync({ + if (gte(nxVersion, '17.2.0')) { + logger.log('createProjectGraphAndSourceMapsAsync'); + try { + const projectGraphAndSourceMaps = await ( + nxProjectGraph as any + ).createProjectGraphAndSourceMapsAsync({ + exitOnError: false, + }); + projectGraph = projectGraphAndSourceMaps.projectGraph; + + sourceMaps = projectGraphAndSourceMaps.sourceMaps; + } catch (e) { + if (isProjectGraphError(e)) { + logger.log('caught ProjectGraphError, using partial graph'); + projectGraph = e.getPartialProjectGraph() ?? { + nodes: {}, + dependencies: {}, + }; + sourceMaps = e.getPartialSourcemaps(); + errors = e.getErrors().map((error) => ({ + name: error.name, + message: error.message, + stack: error.stack, + file: + (error as any).file ?? + ((error as any).cause as any)?.errors?.[0]?.location?.file, + pluginName: (error as any).pluginName, + cause: (error as any).cause, + })); + isPartial = true; + } else { + throw e; + } + } + logger.log('createProjectGraphAndSourceMapsAsync successful'); + } else { + logger.log('createProjectGraphAsync'); + projectGraph = await nxProjectGraph.createProjectGraphAsync({ exitOnError: false, }); - projectGraph = projectGraphAndSourceMaps.projectGraph; + logger.log('createProjectGraphAsync successful'); + } + } catch (e) { + logger.log('Unable to get project graph'); + logger.log(e.stack); + errors = [{ stack: e.stack }]; + } - sourceMaps = projectGraphAndSourceMaps.sourceMaps; + // reset the daemon client after getting all required information from the daemon + if ( + nxDaemonClientModule && + nxDaemonClientModule.daemonClient?.enabled() + ) { + try { + logger.log('Resetting daemon client'); + nxDaemonClientModule.daemonClient?.reset(); } catch (e) { - if (isProjectGraphError(e)) { - logger.log('caught ProjectGraphError, using partial graph'); - projectGraph = e.getPartialProjectGraph() ?? { - nodes: {}, - dependencies: {}, - }; - sourceMaps = e.getPartialSourcemaps(); - errors = e.getErrors().map((error) => ({ - name: error.name, - message: error.message, - stack: error.stack, - file: - (error as any).file ?? - ((error as any).cause as any)?.errors?.[0]?.location?.file, - pluginName: (error as any).pluginName, - cause: (error as any).cause, - })); - isPartial = true; - } else { - throw e; - } + logger.log(`Error while resetting daemon client, moving on...`); } - logger.log('createProjectGraphAndSourceMapsAsync successful'); - } else { - logger.log('createProjectGraphAsync'); - projectGraph = await nxProjectGraph.createProjectGraphAsync({ - exitOnError: false, - }); - logger.log('createProjectGraphAsync successful'); } - } catch (e) { - logger.log('Unable to get project graph'); - logger.log(e.stack); - errors = [{ stack: e.stack }]; + } else { + logger.log( + 'received project graph and source maps, skipping recomputation', + ); + projectGraph = projectGraphAndSourceMaps.projectGraph; + sourceMaps = projectGraphAndSourceMaps.sourceMaps; } if (gte(nxVersion, '16.3.1') && projectGraph) { @@ -176,16 +201,6 @@ export async function getNxWorkspaceConfig( }); } - // reset the daemon client after getting all required information from the daemon - if (nxDaemonClientModule && nxDaemonClientModule.daemonClient?.enabled()) { - try { - logger.log('Resetting daemon client'); - nxDaemonClientModule.daemonClient?.reset(); - } catch (e) { - logger.log(`Error while resetting daemon client, moving on...`); - } - } - const end = performance.now(); logger.log(`Retrieved workspace configuration in: ${end - start} ms`); diff --git a/libs/shared/nx-workspace-info/src/lib/workspace.ts b/libs/shared/nx-workspace-info/src/lib/workspace.ts index 8dd03d6920..9cec0f9305 100644 --- a/libs/shared/nx-workspace-info/src/lib/workspace.ts +++ b/libs/shared/nx-workspace-info/src/lib/workspace.ts @@ -15,6 +15,10 @@ import { } from 'rxjs'; import { getNxVersion } from './get-nx-version'; import { getNxWorkspaceConfig } from './get-nx-workspace-config'; +import { getNxDaemonClient } from './get-nx-workspace-package'; +import type { ProjectGraph } from 'nx/src/devkit-exports'; +import type { ConfigurationSourceMaps } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { execSync } from 'child_process'; const enum Status { not_started, @@ -34,8 +38,12 @@ export async function nxWorkspace( workspacePath: string, logger: Logger, reset?: boolean, + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, ): Promise { - if (reset) { + if (reset || projectGraphAndSourceMaps) { resetStatus(workspacePath); } @@ -46,7 +54,9 @@ export async function nxWorkspace( tap(() => { status = Status.in_progress; }), - switchMap(() => from(_workspace(workspacePath, logger))), + switchMap(() => + from(_workspace(workspacePath, logger, projectGraphAndSourceMaps)), + ), tap((workspace) => { cachedReplay.next(workspace); status = Status.cached; @@ -60,9 +70,15 @@ export async function nxWorkspace( async function _workspace( workspacePath: string, logger: Logger, + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, ): Promise { try { + const daemonClientModule = await getNxDaemonClient(workspacePath, logger); const nxVersion = await getNxVersion(workspacePath); + const { projectGraph, sourceMaps, @@ -70,11 +86,17 @@ async function _workspace( projectFileMap, errors, isPartial, - } = await getNxWorkspaceConfig(workspacePath, nxVersion, logger); + } = await getNxWorkspaceConfig( + workspacePath, + nxVersion, + logger, + projectGraphAndSourceMaps, + ); const isLerna = await fileExists(join(workspacePath, 'lerna.json')); return { + daemonEnabled: daemonClientModule?.isDaemonEnabled() ?? false, projectGraph: projectGraph ?? { nodes: {}, dependencies: {}, @@ -99,6 +121,7 @@ async function _workspace( // Default to nx workspace return { + daemonEnabled: false, validWorkspaceJson: false, projectGraph: { nodes: {}, diff --git a/libs/shared/telemetry/src/lib/telemetry-types.ts b/libs/shared/telemetry/src/lib/telemetry-types.ts index c7829748d0..9141b5fe08 100644 --- a/libs/shared/telemetry/src/lib/telemetry-types.ts +++ b/libs/shared/telemetry/src/lib/telemetry-types.ts @@ -19,6 +19,7 @@ export type TelemetryEvents = | 'misc.open-project-details-codelens' | 'misc.exception' | 'misc.nx-latest-no-provenance' + | 'misc.restart-daemon-watcher' // migrate | 'migrate.open' | 'migrate.start' @@ -78,6 +79,7 @@ export type TelemetryEvents = | 'ai.configure-agents-check-end' | 'ai.configure-agents-check-error' | 'ai.configure-agents-check-expected-error' + | 'ai.configure-agents-check-finally' | 'ai.configure-agents-check-notification' | 'ai.configure-agents-action' | 'ai.configure-agents-dont-ask-again' diff --git a/libs/shared/types/src/lib/nx-workspace.ts b/libs/shared/types/src/lib/nx-workspace.ts index 4945cbded9..b48338b469 100644 --- a/libs/shared/types/src/lib/nx-workspace.ts +++ b/libs/shared/types/src/lib/nx-workspace.ts @@ -12,6 +12,7 @@ export type NxProjectConfiguration = ProjectConfiguration & { }; export interface NxWorkspace { + daemonEnabled: boolean; validWorkspaceJson: boolean; nxJson: NxJsonConfiguration; projectGraph: ProjectGraph; diff --git a/libs/shared/watcher/src/lib/passive-daemon-watcher.spec.ts b/libs/shared/watcher/src/lib/passive-daemon-watcher.spec.ts new file mode 100644 index 0000000000..0db0b9019e --- /dev/null +++ b/libs/shared/watcher/src/lib/passive-daemon-watcher.spec.ts @@ -0,0 +1,512 @@ +import { PassiveDaemonWatcher } from './passive-daemon-watcher'; +import type * as nxWorkspaceInfo from '@nx-console/shared-nx-workspace-info'; + +jest.mock('@nx-console/shared-nx-workspace-info'); + +const { getNxDaemonClient } = + require('@nx-console/shared-nx-workspace-info') as typeof nxWorkspaceInfo; + +describe('PassiveDaemonWatcher', () => { + let mockDaemonClient: any; + let capturedDaemonCallback: any; + let mockUnregister: jest.Mock; + let mockLogger: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUnregister = jest.fn(); + mockLogger = { + log: jest.fn(), + }; + + mockDaemonClient = { + enabled: jest.fn().mockReturnValue(true), + registerProjectGraphRecomputationListener: jest + .fn() + .mockImplementation((callback) => { + capturedDaemonCallback = callback; + return mockUnregister; + }), + }; + + (getNxDaemonClient as jest.Mock).mockResolvedValue({ + daemonClient: mockDaemonClient, + }); + }); + + describe('Basic Listener Flow', () => { + it('should trigger registered listener when daemon emits project graph change', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener = jest.fn(); + + watcher.listen(listener); + watcher.start(); + + await flushPromises(); + + const mockProjectGraph = { nodes: {}, dependencies: {} }; + const mockSourceMaps = {}; + capturedDaemonCallback(null, { + projectGraph: mockProjectGraph, + sourceMaps: mockSourceMaps, + }); + + expect(listener).toHaveBeenCalledWith(null, { + projectGraph: mockProjectGraph, + sourceMaps: mockSourceMaps, + }); + + watcher.dispose(); + }); + + it('should trigger multiple listeners when daemon emits change', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const listener3 = jest.fn(); + + watcher.listen(listener1); + watcher.listen(listener2); + watcher.listen(listener3); + watcher.start(); + + await flushPromises(); + + const mockData = { + projectGraph: { nodes: {}, dependencies: {} }, + sourceMaps: {}, + }; + capturedDaemonCallback(null, mockData); + + expect(listener1).toHaveBeenCalledWith(null, mockData); + expect(listener2).toHaveBeenCalledWith(null, mockData); + expect(listener3).toHaveBeenCalledWith(null, mockData); + + watcher.dispose(); + }); + + it('should stop notifying listeners after unregister is called', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener = jest.fn(); + + const unregister = watcher.listen(listener); + watcher.start(); + + await flushPromises(); + + unregister(); + + const mockData = { + projectGraph: { nodes: {}, dependencies: {} }, + sourceMaps: {}, + }; + capturedDaemonCallback(null, mockData); + + expect(listener).not.toHaveBeenCalled(); + + watcher.dispose(); + }); + }); + + describe('Retry on Registration Failure', () => { + it( + 'should retry registration if getNxDaemonClient fails', + async () => { + (getNxDaemonClient as jest.Mock) + .mockRejectedValueOnce(new Error('Failed')) + .mockRejectedValueOnce(new Error('Failed')) + .mockResolvedValueOnce({ daemonClient: mockDaemonClient }); + + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener = jest.fn(); + + watcher.listen(listener); + watcher.start(); + + await flushPromises(); + + expect(getNxDaemonClient).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect(getNxDaemonClient).toHaveBeenCalledTimes(2); + + await new Promise((resolve) => setTimeout(resolve, 5500)); + + expect(getNxDaemonClient).toHaveBeenCalledTimes(3); + + const mockData = { + projectGraph: { nodes: {}, dependencies: {} }, + sourceMaps: {}, + }; + capturedDaemonCallback(null, mockData); + + expect(listener).toHaveBeenCalledWith(null, mockData); + + watcher.dispose(); + }, + 15000, + ); + + it( + 'should retry registration if daemon.enabled() returns false', + async () => { + mockDaemonClient.enabled + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(0); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(1); + + watcher.dispose(); + }, + 10000, + ); + + it( + 'should retry registration if registerProjectGraphRecomputationListener throws', + async () => { + mockDaemonClient.registerProjectGraphRecomputationListener + .mockImplementationOnce(() => { + throw new Error('Registration failed'); + }) + .mockImplementationOnce((callback: any) => { + capturedDaemonCallback = callback; + return mockUnregister; + }); + + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(2); + + watcher.dispose(); + }, + 10000, + ); + }); + + describe('Retry on Listener Error', () => { + it( + 'should retry when daemon listener callback receives an error', + async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(1); + + capturedDaemonCallback(new Error('Daemon error')); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(2); + + watcher.dispose(); + }, + 10000, + ); + + it( + 'should retry when daemon listener callback receives "closed"', + async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(1); + + capturedDaemonCallback('closed'); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(2); + + watcher.dispose(); + }, + 10000, + ); + + it( + 'should notify listeners of error before retrying', + async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener = jest.fn(); + + watcher.listen(listener); + watcher.start(); + + await flushPromises(); + + const error = new Error('Daemon error'); + capturedDaemonCallback(error); + + expect(listener).toHaveBeenCalledWith(error); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + + expect( + mockDaemonClient.registerProjectGraphRecomputationListener, + ).toHaveBeenCalledTimes(2); + + watcher.dispose(); + }, + 10000, + ); + }); + + describe('Exponential Backoff', () => { + it( + 'should use exponential backoff for retries (2s, 5s, 10s, 20s)', + async () => { + (getNxDaemonClient as jest.Mock).mockRejectedValue( + new Error('Always fails'), + ); + + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + expect(getNxDaemonClient).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setTimeout(resolve, 2500)); + expect(getNxDaemonClient).toHaveBeenCalledTimes(2); + + await new Promise((resolve) => setTimeout(resolve, 5500)); + expect(getNxDaemonClient).toHaveBeenCalledTimes(3); + + await new Promise((resolve) => setTimeout(resolve, 10500)); + expect(getNxDaemonClient).toHaveBeenCalledTimes(4); + + await new Promise((resolve) => setTimeout(resolve, 20500)); + expect(getNxDaemonClient).toHaveBeenCalledTimes(5); + + watcher.dispose(); + }, + 50000, + ); + + it( + 'should stop retrying after 4 failed attempts', + async () => { + (getNxDaemonClient as jest.Mock).mockRejectedValue( + new Error('Always fails'), + ); + + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + await new Promise((resolve) => setTimeout(resolve, 40000)); + + expect(getNxDaemonClient).toHaveBeenCalledTimes(5); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + expect(getNxDaemonClient).toHaveBeenCalledTimes(5); + + watcher.dispose(); + }, + 50000, + ); + }); + + describe('Cleanup', () => { + it('should call unregister callback on stop()', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect(mockUnregister).not.toHaveBeenCalled(); + + watcher.stop(); + + await flushPromises(); + + expect(mockUnregister).toHaveBeenCalled(); + + watcher.dispose(); + }); + + it('should call unregister callback on dispose()', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + watcher.start(); + + await flushPromises(); + + expect(mockUnregister).not.toHaveBeenCalled(); + + watcher.dispose(); + + expect(mockUnregister).toHaveBeenCalled(); + }); + + it('should clear all listeners on dispose()', async () => { + const watcher = new PassiveDaemonWatcher('/workspace', mockLogger); + const listener = jest.fn(); + + watcher.listen(listener); + watcher.start(); + + await flushPromises(); + + watcher.dispose(); + + const mockData = { + projectGraph: { nodes: {}, dependencies: {} }, + sourceMaps: {}, + }; + + capturedDaemonCallback?.(null, mockData); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('Operational State Callback', () => { + it('should call callback with true when starting', async () => { + const onOperationalStateChange = jest.fn(); + const watcher = new PassiveDaemonWatcher( + '/workspace', + mockLogger, + onOperationalStateChange, + ); + + watcher.start(); + await flushPromises(); + + expect(onOperationalStateChange).toHaveBeenCalledWith(true); + + watcher.dispose(); + }); + + it('should call callback with true when listening', async () => { + const onOperationalStateChange = jest.fn(); + const watcher = new PassiveDaemonWatcher( + '/workspace', + mockLogger, + onOperationalStateChange, + ); + + watcher.start(); + await flushPromises(); + + expect(onOperationalStateChange).toHaveBeenCalledWith(true); + + watcher.dispose(); + }); + + it('should call callback with true when failed but can retry', async () => { + const onOperationalStateChange = jest.fn(); + (getNxDaemonClient as jest.Mock).mockRejectedValueOnce( + new Error('Failed once'), + ); + + const watcher = new PassiveDaemonWatcher( + '/workspace', + mockLogger, + onOperationalStateChange, + ); + + watcher.start(); + await flushPromises(); + + const trueCalls = onOperationalStateChange.mock.calls.filter( + (call) => call[0] === true, + ); + expect(trueCalls.length).toBeGreaterThan(0); + + watcher.dispose(); + }); + + it( + 'should call callback with false when permanently failed', + async () => { + const onOperationalStateChange = jest.fn(); + (getNxDaemonClient as jest.Mock).mockRejectedValue( + new Error('Always fails'), + ); + + const watcher = new PassiveDaemonWatcher( + '/workspace', + mockLogger, + onOperationalStateChange, + ); + + watcher.start(); + await flushPromises(); + + await new Promise((resolve) => setTimeout(resolve, 45000)); + await flushPromises(); + + const falseCalls = onOperationalStateChange.mock.calls.filter( + (call) => call[0] === false, + ); + expect(falseCalls.length).toBeGreaterThan(0); + + watcher.dispose(); + }, + 50000, + ); + + it('should call callback with true after stop', async () => { + const onOperationalStateChange = jest.fn(); + const watcher = new PassiveDaemonWatcher( + '/workspace', + mockLogger, + onOperationalStateChange, + ); + + watcher.start(); + await flushPromises(); + + onOperationalStateChange.mockClear(); + + watcher.stop(); + await flushPromises(); + + expect(onOperationalStateChange).toHaveBeenCalledWith(true); + + watcher.dispose(); + }); + }); +}); + +async function flushPromises() { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/libs/shared/watcher/src/lib/passive-daemon-watcher.ts b/libs/shared/watcher/src/lib/passive-daemon-watcher.ts index c30bfa2aae..e04b95330d 100644 --- a/libs/shared/watcher/src/lib/passive-daemon-watcher.ts +++ b/libs/shared/watcher/src/lib/passive-daemon-watcher.ts @@ -3,29 +3,236 @@ import { Logger } from '@nx-console/shared-utils'; import { randomUUID } from 'crypto'; import type { ProjectGraph } from 'nx/src/config/project-graph'; import type { ConfigurationSourceMaps } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { + AnyEventObject, + assign, + createActor, + fromPromise, + not, + setup, +} from 'xstate'; + +export type DaemonWatcherCallback = ( + error?: Error | null | 'closed', + projectGraphAndSourceMaps?: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, +) => void; + +type MachineContext = { + attemptNumber: number; + error: Error | null; +}; + +type MachineEvents = + | { type: 'START' } + | { type: 'STOP' } + | { type: 'LISTENER_ERROR'; error: Error | 'closed' } + | { type: 'RESET_ATTEMPTS' }; -// uses the daemon client to subscribe to project graph change events -// doesn't fallback to native watcher and doesn't work on older versions of nx -// that's an acceptable limitation for keeping this as lean as possible export class PassiveDaemonWatcher { - private listeners: Map< - string, - (error: Error | null | 'closed', projectGraph: ProjectGraph | null) => void - > = new Map(); + private listeners: Map = new Map(); + private unregisterCallback: (() => void) | null = null; + + private machine = setup({ + types: { + context: {} as MachineContext, + events: {} as MachineEvents, + }, + actions: { + assignError: assign(({ event }) => { + if (event.type === 'LISTENER_ERROR') { + const error = + event.error === 'closed' + ? new Error('Daemon connection closed') + : event.error; + return { error }; + } + return {}; + }), + incrementAttempt: assign(({ context }) => ({ + attemptNumber: context.attemptNumber + 1, + })), + resetAttempts: assign(() => ({ + attemptNumber: 0, + error: null, + })), + storeUnregisterCallback: ({ event }) => { + this.unregisterCallback = + ((event as AnyEventObject)['output'] as (() => void) | undefined) ?? + null; + }, + cleanupListener: () => { + if (this.unregisterCallback) { + this.unregisterCallback(); + this.unregisterCallback = null; + } + }, + logTransition: ({ context }, params: { to: string }) => { + this.logger.debug?.( + `PassiveDaemonWatcher: transitioning to ${params.to} (attempt ${context.attemptNumber})`, + ); + }, + notifyOperationalState: (_, params: { isOperational: boolean }) => { + if (this.onOperationalStateChange) { + this.onOperationalStateChange(params.isOperational); + } + }, + logPermanentFailure: ({ context }) => { + this.logger.log( + `PassiveDaemonWatcher: Failed to register daemon listener after ${context.attemptNumber} attempts. Giving up.`, + ); + }, + }, + actors: { + registerListener: fromPromise(async () => { + const daemonClientModule = await getNxDaemonClient( + this.workspacePath, + this.logger, + ); + + if (!daemonClientModule) { + throw new Error( + 'Nx Daemon client is not available. Make sure you are using a compatible version of Nx.', + ); + } - private unregisterCallback?: () => void; + if (!daemonClientModule.daemonClient?.enabled()) { + throw new Error('Nx Daemon client is not enabled.'); + } + + const unregister = + await daemonClientModule.daemonClient.registerProjectGraphRecomputationListener( + ( + error: Error | null | 'closed', + projectGraphAndSourceMaps: { + projectGraph: ProjectGraph; + sourceMaps: ConfigurationSourceMaps; + } | null, + ) => { + if (error) { + this.actor.send({ type: 'LISTENER_ERROR', error }); + } else { + this.actor.send({ type: 'RESET_ATTEMPTS' }); + this.listeners.forEach((listener) => + listener(error, projectGraphAndSourceMaps), + ); + } + }, + ); + + return unregister; + }), + }, + guards: { + canRetry: ({ context }) => context.attemptNumber < 5, + }, + delays: { + retryDelay: ({ context }) => + Math.min(2000 * Math.pow(2, context.attemptNumber - 1), 40000), + }, + }).createMachine({ + id: 'passiveDaemonWatcher', + initial: 'idle', + context: { + attemptNumber: 0, + error: null, + }, + states: { + idle: { + entry: [ + { type: 'logTransition', params: { to: 'idle' } }, + { type: 'notifyOperationalState', params: { isOperational: true } }, + ], + on: { + START: 'starting', + }, + }, + starting: { + entry: [ + 'incrementAttempt', + { type: 'logTransition', params: { to: 'starting' } }, + { type: 'notifyOperationalState', params: { isOperational: true } }, + ], + invoke: { + id: 'registerListener', + src: 'registerListener', + onDone: { + target: 'listening', + actions: ['storeUnregisterCallback'], + }, + onError: { + target: 'failed', + actions: ['assignError'], + }, + }, + }, + listening: { + entry: [ + { type: 'logTransition', params: { to: 'listening' } }, + { type: 'notifyOperationalState', params: { isOperational: true } }, + ], + on: { + RESET_ATTEMPTS: { + actions: ['resetAttempts'], + }, + LISTENER_ERROR: { + target: 'failed', + actions: ['assignError'], + }, + STOP: { + target: 'idle', + actions: ['cleanupListener'], + }, + }, + }, + failed: { + entry: [ + { type: 'logTransition', params: { to: 'failed' } }, + { type: 'notifyOperationalState', params: { isOperational: true } }, + ], + always: [ + { + guard: not('canRetry'), + actions: [ + 'logPermanentFailure', + { + type: 'notifyOperationalState', + params: { isOperational: false }, + }, + ], + }, + ], + after: { + retryDelay: [ + { + guard: 'canRetry', + target: 'starting', + }, + ], + }, + on: { + STOP: { + target: 'idle', + actions: ['cleanupListener'], + }, + }, + }, + }, + }); + + private actor = createActor(this.machine); constructor( private workspacePath: string, private logger: Logger, - ) {} - - listen( - callback: ( - error: Error | null | 'closed', - projectGraph: ProjectGraph | null, - ) => void, - ): () => void { + private onOperationalStateChange?: (isOperational: boolean) => void, + ) { + this.actor.start(); + } + + listen(callback: DaemonWatcherCallback): () => void { const id = randomUUID(); this.listeners.set(id, callback); return () => { @@ -33,40 +240,21 @@ export class PassiveDaemonWatcher { }; } - async start() { - const daemonClientModule = await getNxDaemonClient( - this.workspacePath, - this.logger, - ); - - if (!daemonClientModule) { - throw new Error( - 'Nx Daemon client is not available. Make sure you are using a compatible version of Nx.', - ); - } - - if (!daemonClientModule.daemonClient?.enabled()) { - throw new Error('Nx Daemon client is not enabled.'); - } - - this.unregisterCallback = - await daemonClientModule.daemonClient.registerProjectGraphRecomputationListener( - ( - error: Error | null | 'closed', - projectGraphAndSourceMaps: { - projectGraph: ProjectGraph; - sourceMaps: ConfigurationSourceMaps; - } | null, - ) => { - this.listeners.forEach((listener) => - listener(error, projectGraphAndSourceMaps?.projectGraph ?? null), - ); - }, - ); + start() { + this.actor.send({ type: 'START' }); + } + + stop() { + this.actor.send({ type: 'STOP' }); + } + + get state() { + return this.actor.getSnapshot().value; } dispose() { + this.stop(); this.listeners.clear(); - this.unregisterCallback?.(); + this.actor.stop(); } } diff --git a/libs/vscode/lsp-client/src/index.ts b/libs/vscode/lsp-client/src/index.ts index a5abbe0f2a..687a3f0a8c 100644 --- a/libs/vscode/lsp-client/src/index.ts +++ b/libs/vscode/lsp-client/src/index.ts @@ -1,2 +1,3 @@ export * from './nxls-client'; export * from './show-refresh-loading'; +export * from './watcher-running-service'; diff --git a/libs/vscode/lsp-client/src/nxls-client.ts b/libs/vscode/lsp-client/src/nxls-client.ts index 0b7c5a55a6..adc4f23265 100644 --- a/libs/vscode/lsp-client/src/nxls-client.ts +++ b/libs/vscode/lsp-client/src/nxls-client.ts @@ -34,12 +34,15 @@ import { } from 'vscode-languageclient/node'; import { createActor, fromPromise, waitFor } from 'xstate'; import { nxlsClientStateMachine } from './nxls-client-state-machine'; +import { WatcherRunningService } from './watcher-running-service'; let _nxlsClient: NxlsClient | undefined; export function createNxlsClient(extensionContext: ExtensionContext) { _nxlsClient = new NxlsClient(extensionContext); + extensionContext.subscriptions.push(new WatcherRunningService(_nxlsClient)); + const disposable = refreshWorkspaceOnBranchChange(_nxlsClient); if (disposable) { extensionContext.subscriptions.push(disposable); diff --git a/libs/vscode/lsp-client/src/watcher-running-service.ts b/libs/vscode/lsp-client/src/watcher-running-service.ts new file mode 100644 index 0000000000..90acf6b5d1 --- /dev/null +++ b/libs/vscode/lsp-client/src/watcher-running-service.ts @@ -0,0 +1,43 @@ +import { Disposable } from 'vscode-languageserver'; +import { NxlsClient } from './nxls-client'; +import { NxWatcherOperationalNotification } from '@nx-console/language-server-types'; +import { EventEmitter } from 'vscode'; + +export class WatcherRunningService implements Disposable { + static INSTANCE: WatcherRunningService | undefined; + static get instance(): WatcherRunningService { + if (!WatcherRunningService.INSTANCE) { + throw new Error('WatcherRunningService instance is not initialized yet.'); + } + return WatcherRunningService.INSTANCE; + } + + private _isOperational = false; + private _listener: Disposable | null = null; + + public get isOperational(): boolean { + return this._isOperational; + } + + private readonly _onOperationalStateChange: EventEmitter = + new EventEmitter(); + readonly onOperationalStateChange = this._onOperationalStateChange.event; + + constructor(nxlsClient: NxlsClient) { + WatcherRunningService.INSTANCE = this; + nxlsClient.onNotification( + NxWatcherOperationalNotification, + ({ isOperational }) => { + if (this._isOperational === isOperational) { + return; + } + this._isOperational = isOperational; + this._onOperationalStateChange.fire(isOperational); + }, + ); + } + + dispose() { + this._listener?.dispose(); + } +} diff --git a/libs/vscode/nx-project-view/src/lib/init-nx-project-view.ts b/libs/vscode/nx-project-view/src/lib/init-nx-project-view.ts index f0e4e9412f..be17e1be41 100644 --- a/libs/vscode/nx-project-view/src/lib/init-nx-project-view.ts +++ b/libs/vscode/nx-project-view/src/lib/init-nx-project-view.ts @@ -1,4 +1,8 @@ -import { showRefreshLoadingAtLocation } from '@nx-console/vscode-lsp-client'; +import { + getNxlsClient, + NxlsClient, + showRefreshLoadingAtLocation, +} from '@nx-console/vscode-lsp-client'; import { selectProject } from '@nx-console/vscode-nx-cli-quickpicks'; import { revealNxProject } from '@nx-console/vscode-nx-config-decoration'; import { getNxWorkspaceProjects } from '@nx-console/vscode-nx-workspace'; @@ -8,9 +12,17 @@ import { AtomizerDecorationProvider } from './atomizer-decorations'; import { NxProjectTreeProvider } from './nx-project-tree-provider'; import { NxTreeItem } from './nx-tree-item'; import { ProjectGraphErrorDecorationProvider } from './project-graph-error-decorations'; +import { + NxStartDaemonRequest, + NxWorkspaceRefreshNotification, +} from '@nx-console/language-server-types'; +import { + logAndShowError, + showErrorMessageWithOpenLogs, +} from '@nx-console/vscode-output-channels'; export function initNxProjectView( - context: ExtensionContext + context: ExtensionContext, ): NxProjectTreeProvider { const nxProjectsTreeProvider = new NxProjectTreeProvider(context); const nxProjectTreeView = window.createTreeView('nxProjects', { @@ -19,17 +31,21 @@ export function initNxProjectView( }); context.subscriptions.push(nxProjectTreeView); - - commands.registerCommand( - 'nxConsole.showProjectConfiguration', - showProjectConfiguration + context.subscriptions.push( + commands.registerCommand( + 'nxConsole.showProjectConfiguration', + showProjectConfiguration, + ), + commands.registerCommand('nxConsole.restartDaemonWatcher', async () => { + await tryRestartDaemonWatcher(); + }), ); AtomizerDecorationProvider.register(context); ProjectGraphErrorDecorationProvider.register(context); context.subscriptions.push( - showRefreshLoadingAtLocation({ viewId: 'nxProjects' }) + showRefreshLoadingAtLocation({ viewId: 'nxProjects' }), ); return nxProjectsTreeProvider; @@ -49,7 +65,9 @@ export async function showProjectConfiguration(selection: NxTreeItem) { const viewItem = selection.item; if ( viewItem.contextValue === 'folder' || - viewItem.contextValue === 'projectGraphError' + viewItem.contextValue === 'projectGraphError' || + viewItem.contextValue === 'daemonDisabled' || + viewItem.contextValue === 'daemonWatcherNotRunning' ) { return; } @@ -64,3 +82,17 @@ export async function showProjectConfiguration(selection: NxTreeItem) { const target = viewItem.nxTarget; return revealNxProject(project, root, target); } + +async function tryRestartDaemonWatcher() { + getTelemetry().logUsage('misc.restart-daemon-watcher'); + + const nxlsClient = getNxlsClient(); + try { + await nxlsClient.sendRequest(NxStartDaemonRequest, undefined); + } catch (e) { + showErrorMessageWithOpenLogs('Failed to start Nx daemon watcher'); + return; + } + + await nxlsClient.sendNotification(NxWorkspaceRefreshNotification); +} 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 537118771c..576e796820 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,6 +1,9 @@ import { NxWorkspace } from '@nx-console/shared-types'; import { GlobalConfigurationStore } from '@nx-console/vscode-configuration'; -import { onWorkspaceRefreshed } from '@nx-console/vscode-lsp-client'; +import { + onWorkspaceRefreshed, + WatcherRunningService, +} from '@nx-console/vscode-lsp-client'; import { getNxWorkspace, getProjectFolderTree, @@ -64,7 +67,12 @@ export class NxProjectTreeProvider extends AbstractTreeProvider { this.refresh(), ); - onWorkspaceRefreshed(() => this.refresh()); + context.subscriptions.push( + onWorkspaceRefreshed(() => this.refresh()), + WatcherRunningService.INSTANCE.onOperationalStateChange(() => + this.refresh(), + ), + ); } getParent() { @@ -147,7 +155,9 @@ export class NxProjectTreeProvider extends AbstractTreeProvider { viewItem.contextValue === 'project' || viewItem.contextValue === 'folder' || viewItem.contextValue === 'targetGroup' || - viewItem.contextValue === 'projectGraphError' + viewItem.contextValue === 'projectGraphError' || + viewItem.contextValue === 'daemonDisabled' || + viewItem.contextValue === 'daemonWatcherNotRunning' ) { // can not run a task on a project, folder or target group return; diff --git a/libs/vscode/nx-project-view/src/lib/nx-tree-item.ts b/libs/vscode/nx-project-view/src/lib/nx-tree-item.ts index 75ce3ea9bc..512d982b09 100644 --- a/libs/vscode/nx-project-view/src/lib/nx-tree-item.ts +++ b/libs/vscode/nx-project-view/src/lib/nx-tree-item.ts @@ -7,7 +7,10 @@ import { TargetViewItem, } from './views/nx-project-base-view'; import { ATOMIZED_SCHEME } from './atomizer-decorations'; -import { PROJECT_GRAPH_ERROR_DECORATION_SCHEME } from './project-graph-error-decorations'; +import { + NX_DAEMON_WARNING_DECORATION_SCHEME, + PROJECT_GRAPH_ERROR_DECORATION_SCHEME, +} from './project-graph-error-decorations'; export class NxTreeItem extends TreeItem { constructor(public readonly item: ViewItem) { @@ -32,6 +35,20 @@ export class NxTreeItem extends TreeItem { path: item.errorCount.toString(), }); this.tooltip = `${item.errorCount} errors detected. The project graph may be missing some information`; + } else if (item.contextValue === 'daemonDisabled') { + this.resourceUri = Uri.from({ + scheme: NX_DAEMON_WARNING_DECORATION_SCHEME, + path: '1', + }); + this.tooltip = + 'Nx Daemon is disabled. Nx Console will not receive file changes.'; + } else if (item.contextValue === 'daemonWatcherNotRunning') { + this.resourceUri = Uri.from({ + scheme: NX_DAEMON_WARNING_DECORATION_SCHEME, + path: '2', + }); + this.tooltip = + 'Nx Daemon watcher is not running. Nx Console will not receive file changes. You can restart the watcher by clicking the refresh icon.'; } this.setIcons(item.iconPath); @@ -43,6 +60,14 @@ export class NxTreeItem extends TreeItem { return; } + if ( + this.contextValue === 'daemonDisabled' || + this.contextValue === 'daemonWatcherNotRunning' + ) { + this.iconPath = new ThemeIcon('warning'); + return; + } + if (iconPath) { this.iconPath = new ThemeIcon(iconPath); return; diff --git a/libs/vscode/nx-project-view/src/lib/project-graph-error-decorations.ts b/libs/vscode/nx-project-view/src/lib/project-graph-error-decorations.ts index beb8bbd4ee..07bf1711c7 100644 --- a/libs/vscode/nx-project-view/src/lib/project-graph-error-decorations.ts +++ b/libs/vscode/nx-project-view/src/lib/project-graph-error-decorations.ts @@ -10,6 +10,7 @@ import { } from 'vscode'; export const PROJECT_GRAPH_ERROR_DECORATION_SCHEME = 'nx-project-graph-error'; +export const NX_DAEMON_WARNING_DECORATION_SCHEME = 'nx-daemon-disabled'; export class ProjectGraphErrorDecorationProvider implements FileDecorationProvider @@ -22,17 +23,21 @@ export class ProjectGraphErrorDecorationProvider propagate: false, color: new ThemeColor('errorForeground'), }; + } else if (uri.scheme === NX_DAEMON_WARNING_DECORATION_SCHEME) { + return { + color: new ThemeColor('warningForeground'), + }; } } static register(context: ExtensionContext) { context.subscriptions.push( window.registerFileDecorationProvider( - new ProjectGraphErrorDecorationProvider() + new ProjectGraphErrorDecorationProvider(), ), commands.registerCommand('nxConsole.showProblems', () => { commands.executeCommand('workbench.actions.view.problems'); - }) + }), ); } } diff --git a/libs/vscode/nx-project-view/src/lib/views/nx-project-base-view.ts b/libs/vscode/nx-project-view/src/lib/views/nx-project-base-view.ts index f377e9049a..ad86bceaf4 100644 --- a/libs/vscode/nx-project-view/src/lib/views/nx-project-base-view.ts +++ b/libs/vscode/nx-project-view/src/lib/views/nx-project-base-view.ts @@ -46,6 +46,11 @@ export interface ProjectGraphErrorViewItem errorCount: number; } +export type DaemonDisabledViewItem = BaseViewItem<'daemonDisabled'>; + +export type DaemonWatcherNotRunningViewItem = + BaseViewItem<'daemonWatcherNotRunning'>; + export interface NxProject { project: string; root: string; @@ -250,6 +255,24 @@ export abstract class BaseView { }; } + createDaemonDisabledViewItem(): DaemonDisabledViewItem { + return { + id: 'daemonDisabled', + contextValue: 'daemonDisabled', + label: `Nx Daemon is disabled`, + collapsible: TreeItemCollapsibleState.None, + }; + } + + createDaemonWatcherNotRunningViewItem(): DaemonWatcherNotRunningViewItem { + return { + id: 'daemonWatcherNotRunning', + contextValue: 'daemonWatcherNotRunning', + label: `Nx daemon watcher is not running`, + collapsible: TreeItemCollapsibleState.None, + }; + } + protected async getProjectData() { if (this.workspaceData?.projectGraph.nodes) { return this.workspaceData.projectGraph.nodes; 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 index 09c87c4d56..d4955275b0 100644 --- 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 @@ -1,5 +1,8 @@ +import { WatcherRunningService } from '@nx-console/vscode-lsp-client'; import { BaseView, + DaemonDisabledViewItem, + DaemonWatcherNotRunningViewItem, ProjectGraphErrorViewItem, ProjectViewItem, TargetGroupViewItem, @@ -10,7 +13,9 @@ export type ListViewItem = | ProjectViewItem | TargetViewItem | TargetGroupViewItem - | ProjectGraphErrorViewItem; + | ProjectGraphErrorViewItem + | DaemonDisabledViewItem + | DaemonWatcherNotRunningViewItem; export class ListView extends BaseView { async getChildren(element?: ListViewItem) { @@ -18,9 +23,16 @@ export class ListView extends BaseView { const items: ListViewItem[] = []; if (this.workspaceData?.errors) { items.push( - this.createProjectGraphErrorViewItem(this.workspaceData.errors.length) + this.createProjectGraphErrorViewItem( + this.workspaceData.errors.length, + ), ); } + if (this.workspaceData.daemonEnabled === false) { + items.push(this.createDaemonDisabledViewItem()); + } else if (WatcherRunningService.INSTANCE.isOperational === false) { + items.push(this.createDaemonWatcherNotRunningViewItem()); + } // should return root elements if no element was passed items.push(...(await this.createProjects())); return items; @@ -34,7 +46,9 @@ export class ListView extends BaseView { if (element.contextValue === 'projectGraphError') { return []; } - return this.createConfigurationsFromTarget(element); + if (element.contextValue === 'target') { + return this.createConfigurationsFromTarget(element); + } } private async createProjects() { @@ -43,7 +57,7 @@ export class ListView extends BaseView { return []; } return Object.entries(projectDefs).map((project) => - this.createProjectViewItem(project) + this.createProjectViewItem(project), ); } } 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 index f29f6f36bf..fad0bfe982 100644 --- 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 @@ -5,19 +5,24 @@ import { join, parse } from 'path'; import { TreeItemCollapsibleState } from 'vscode'; import { BaseView, + DaemonDisabledViewItem, + DaemonWatcherNotRunningViewItem, FolderViewItem, ProjectGraphErrorViewItem, ProjectViewItem, TargetGroupViewItem, TargetViewItem, } from './nx-project-base-view'; +import { WatcherRunningService } from '@nx-console/vscode-lsp-client'; export type TreeViewItem = | FolderViewItem | ProjectViewItem | TargetViewItem | TargetGroupViewItem - | ProjectGraphErrorViewItem; + | ProjectGraphErrorViewItem + | DaemonDisabledViewItem + | DaemonWatcherNotRunningViewItem; export type ProjectInfo = { dir: string; @@ -30,17 +35,25 @@ export class TreeView extends BaseView { roots: TreeNode[]; async getChildren( - element?: TreeViewItem + element?: TreeViewItem, ): Promise { if (!element) { const items: TreeViewItem[] = []; if (this.workspaceData?.errors) { items.push( - this.createProjectGraphErrorViewItem(this.workspaceData.errors.length) + this.createProjectGraphErrorViewItem( + this.workspaceData.errors.length, + ), ); } + if (this.workspaceData.daemonEnabled === false) { + items.push(this.createDaemonDisabledViewItem()); + } else if (WatcherRunningService.INSTANCE.isOperational === false) { + items.push(this.createDaemonWatcherNotRunningViewItem()); + } + // if there's only a single root, start with it expanded const isSingleProject = this.roots.length === 1; items.push( @@ -58,9 +71,9 @@ export class TreeView extends BaseView { root, isSingleProject ? TreeItemCollapsibleState.Expanded - : TreeItemCollapsibleState.Collapsed - ) - ) + : TreeItemCollapsibleState.Collapsed, + ), + ), ); return items; } @@ -74,8 +87,8 @@ export class TreeView extends BaseView { .get(element.nxProject.root)! .children.map((folderOrProjectNodeDir) => this.createFolderOrProjectTreeItemFromNode( - this.treeMap.get(folderOrProjectNodeDir)! - ) + this.treeMap.get(folderOrProjectNodeDir)!, + ), ); } return [...targetChildren, ...folderAndProjectChildren]; @@ -87,8 +100,8 @@ export class TreeView extends BaseView { .get(element.path)! .children.map((folderOrProjectNodeDir) => this.createFolderOrProjectTreeItemFromNode( - this.treeMap.get(folderOrProjectNodeDir)! - ) + this.treeMap.get(folderOrProjectNodeDir)!, + ), ); } } @@ -104,7 +117,7 @@ export class TreeView extends BaseView { private createFolderTreeItem( path: string, - collapsible = TreeItemCollapsibleState.Collapsed + collapsible = TreeItemCollapsibleState.Collapsed, ): FolderViewItem { const folderName = parse(path).base; /** @@ -125,13 +138,13 @@ export class TreeView extends BaseView { private createFolderOrProjectTreeItemFromNode( node: TreeNode, - collapsible = TreeItemCollapsibleState.Collapsed + collapsible = TreeItemCollapsibleState.Collapsed, ): ProjectViewItem | FolderViewItem { const config = node.projectConfiguration; return config ? this.createProjectViewItem( [config.name ?? node.projectName ?? '', config], - collapsible + collapsible, ) : this.createFolderTreeItem(node.dir, collapsible); } diff --git a/libs/vscode/nx-project-view/tsconfig.json b/libs/vscode/nx-project-view/tsconfig.json index fc365fe1b8..3a6c53019a 100644 --- a/libs/vscode/nx-project-view/tsconfig.json +++ b/libs/vscode/nx-project-view/tsconfig.json @@ -3,9 +3,6 @@ "files": [], "include": [], "references": [ - { - "path": "../output-channels" - }, { "path": "../../shared/nx-console-plugins" }, @@ -21,6 +18,12 @@ { "path": "../../shared/types" }, + { + "path": "../output-channels" + }, + { + "path": "../../language-server/types" + }, { "path": "../telemetry" }, diff --git a/libs/vscode/nx-project-view/tsconfig.lib.json b/libs/vscode/nx-project-view/tsconfig.lib.json index 0a35377082..1d9127b2ad 100644 --- a/libs/vscode/nx-project-view/tsconfig.lib.json +++ b/libs/vscode/nx-project-view/tsconfig.lib.json @@ -9,9 +9,6 @@ "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts", "out-tsc"], "include": ["**/*.ts"], "references": [ - { - "path": "../output-channels/tsconfig.lib.json" - }, { "path": "../../shared/nx-console-plugins/tsconfig.lib.json" }, @@ -27,6 +24,12 @@ { "path": "../../shared/types/tsconfig.lib.json" }, + { + "path": "../output-channels/tsconfig.lib.json" + }, + { + "path": "../../language-server/types/tsconfig.lib.json" + }, { "path": "../telemetry/tsconfig.lib.json" },