diff --git a/.github/workflows/publish-vsix.yml b/.github/workflows/publish-vsix.yml index e6d496d5907..027b0863720 100644 --- a/.github/workflows/publish-vsix.yml +++ b/.github/workflows/publish-vsix.yml @@ -102,7 +102,7 @@ jobs: run: vsce publish -p ${{ secrets.VSCE_TOKEN }} --packagePath ${{ steps.vsix.outputs.vsixName }} ${{ steps.vsix.outputs.releaseMode }} - name: Publish to OpenVSX marketplace - if: ${{ github.event.inputs.openVSX == 'true' && github.event.inputs.isPreRelease == 'false' }} + if: ${{ github.event.inputs.openVSX == 'true' }} run: ovsx publish -p ${{ secrets.OPENVSX_TOKEN }} --packagePath ${{ steps.vsix.outputs.vsixName }} ${{ steps.vsix.outputs.releaseMode }} - name: Create a release in ${{ steps.repo.outputs.repo }} repo diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a3d27f66175..bb2bb7c016f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1600,6 +1600,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ~6.0.0 + version: 6.0.0(react@18.2.0) resize-observer-polyfill: specifier: ^1.5.1 version: 1.5.1 @@ -48714,6 +48717,11 @@ snapshots: '@babel/runtime': 7.28.4 react: 18.2.0 + react-error-boundary@6.0.0(react@18.2.0): + dependencies: + '@babel/runtime': 7.28.4 + react: 18.2.0 + react-error-boundary@6.0.0(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 diff --git a/workspaces/ballerina/ballerina-core/src/interfaces/bi.ts b/workspaces/ballerina/ballerina-core/src/interfaces/bi.ts index cda639857b3..ed44a15fef7 100644 --- a/workspaces/ballerina/ballerina-core/src/interfaces/bi.ts +++ b/workspaces/ballerina/ballerina-core/src/interfaces/bi.ts @@ -336,6 +336,7 @@ export type DiagramLabel = "On Fail" | "Body"; export type NodePropertyKey = | "agentType" + | "auth" | "checkError" | "client" | "collection" @@ -375,6 +376,7 @@ export type NodePropertyKey = | "store" | "systemPrompt" | "targetType" + | "toolKitName" | "tools" | "type" | "typeDescription" diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-agent/interfaces.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-agent/interfaces.ts index add05a31504..0b47ae9eb0f 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-agent/interfaces.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-agent/interfaces.ts @@ -47,6 +47,22 @@ export interface ToolParameterFormValues { } export interface ToolParameterItem { + id: number; + icon: string; + key: string; + value: string; + identifierEditable: boolean; + identifierRange?: { + fileName: string; + startLine: { + line: number; + offset: number; + }; + endLine: { + line: number; + offset: number; + }; + }; formValues: ToolParameterFormValues; } diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/index.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/index.ts index 1c56541b121..7a52708a484 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/index.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/index.ts @@ -15,9 +15,8 @@ * specific language governing permissions and limitations * under the License. */ -import { DataMapperModelResponse } from "../../interfaces/extended-lang-client"; import { LoginMethod } from "../../state-machine-types"; -import { AddToProjectRequest, GetFromFileRequest, DeleteFromProjectRequest, ProjectSource, ProjectDiagnostics, PostProcessRequest, PostProcessResponse, FetchDataRequest, FetchDataResponse, TestGenerationRequest, TestGenerationResponse, TestGenerationMentions, AIChatSummary, DeveloperDocument, RequirementSpecification, LLMDiagnostics, GetModuleDirParams, AIPanelPrompt, AIMachineSnapshot, SubmitFeedbackRequest, RelevantLibrariesAndFunctionsRequest, GenerateOpenAPIRequest, GenerateCodeRequest, TestPlanGenerationRequest, TestGeneratorIntermediaryState, RepairParams, RelevantLibrariesAndFunctionsResponse, DocGenerationRequest, AddFilesToProjectRequest, MetadataWithAttachments, DatamapperModelContext, ProcessContextTypeCreationRequest, ProcessMappingParametersRequest } from "./interfaces"; +import { GetFromFileRequest, DeleteFromProjectRequest, ProjectSource, ProjectDiagnostics, PostProcessRequest, PostProcessResponse, FetchDataRequest, FetchDataResponse, TestGenerationRequest, TestGenerationResponse, TestGenerationMentions, AIChatSummary, DeveloperDocument, RequirementSpecification, LLMDiagnostics, AIPanelPrompt, AIMachineSnapshot, SubmitFeedbackRequest, RelevantLibrariesAndFunctionsRequest, GenerateOpenAPIRequest, GenerateCodeRequest, TestPlanGenerationRequest, TestGeneratorIntermediaryState, RepairParams, RelevantLibrariesAndFunctionsResponse, DocGenerationRequest, AddFilesToProjectRequest, MetadataWithAttachments, ProcessContextTypeCreationRequest, ProcessMappingParametersRequest } from "./interfaces"; export interface AIPanelAPI { // ================================== @@ -31,7 +30,6 @@ export interface AIPanelAPI { getDefaultPrompt: () => Promise; getAIMachineSnapshot: () => Promise; fetchData: (params: FetchDataRequest) => Promise; - addToProject: (params: AddToProjectRequest) => Promise; getFromFile: (params: GetFromFileRequest) => Promise; getFileExists: (params: GetFromFileRequest) => Promise; deleteFromProject: (params: DeleteFromProjectRequest) => void; diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/interfaces.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/interfaces.ts index 4abe471c4c3..aabcee88cdb 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/interfaces.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/interfaces.ts @@ -21,7 +21,7 @@ import { FunctionDefinition } from "@wso2/syntax-tree"; import { AIMachineContext, AIMachineStateValue } from "../../state-machine-types"; import { Command, TemplateId } from "../../interfaces/ai-panel"; import { AllDataMapperSourceRequest, DataMapperSourceResponse, ExtendedDataMapperMetadata } from "../../interfaces/extended-lang-client"; -import { ComponentInfo, DataMapperMetadata, Diagnostics, ImportStatements, Project } from "../.."; +import { ComponentInfo, DataMapperMetadata, Diagnostics, DMModel, ImportStatements } from "../.."; // ================================== // General Interfaces @@ -90,12 +90,6 @@ export interface DiagnosticEntry { code?: string; } -export interface AddToProjectRequest { - filePath: string; - content: string; - isTestCode: boolean; -} - export interface AddFilesToProjectRequest { fileChanges: FileChanges[]; } @@ -225,12 +219,20 @@ export interface RepairCodeParams { tempDir?: string; } +export interface RepairedMapping { + output: string; + expression: string; +} + export interface repairCodeRequest { - sourceFiles: SourceFile[]; - diagnostics: DiagnosticList; + dmModel: DMModel; imports: ImportInfo[]; } +export interface RepairCodeResponse { + repairedMappings: RepairedMapping[]; +} + // Test-generator related interfaces export enum TestGenerationTarget { Service = "service", diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/rpc-type.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/rpc-type.ts index ee93b828631..870f5bc095c 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/rpc-type.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/ai-panel/rpc-type.ts @@ -17,9 +17,8 @@ * * THIS FILE INCLUDES AUTO GENERATED CODE */ -import { DataMapperModelResponse } from "../../interfaces/extended-lang-client"; import { LoginMethod } from "../../state-machine-types"; -import { AddToProjectRequest, GetFromFileRequest, DeleteFromProjectRequest, ProjectSource, ProjectDiagnostics, PostProcessRequest, PostProcessResponse, FetchDataRequest, FetchDataResponse, TestGenerationRequest, TestGenerationResponse, TestGenerationMentions, AIChatSummary, DeveloperDocument, RequirementSpecification, LLMDiagnostics, GetModuleDirParams, AIPanelPrompt, AIMachineSnapshot, SubmitFeedbackRequest, RelevantLibrariesAndFunctionsRequest, GenerateOpenAPIRequest, GenerateCodeRequest, TestPlanGenerationRequest, TestGeneratorIntermediaryState, RepairParams, RelevantLibrariesAndFunctionsResponse, DocGenerationRequest, AddFilesToProjectRequest, MetadataWithAttachments, DatamapperModelContext, ProcessContextTypeCreationRequest, ProcessMappingParametersRequest } from "./interfaces"; +import { GetFromFileRequest, DeleteFromProjectRequest, ProjectSource, ProjectDiagnostics, PostProcessRequest, PostProcessResponse, FetchDataRequest, FetchDataResponse, TestGenerationRequest, TestGenerationResponse, TestGenerationMentions, AIChatSummary, DeveloperDocument, RequirementSpecification, LLMDiagnostics, AIPanelPrompt, AIMachineSnapshot, SubmitFeedbackRequest, RelevantLibrariesAndFunctionsRequest, GenerateOpenAPIRequest, GenerateCodeRequest, TestPlanGenerationRequest, TestGeneratorIntermediaryState, RepairParams, RelevantLibrariesAndFunctionsResponse, DocGenerationRequest, AddFilesToProjectRequest, MetadataWithAttachments, ProcessContextTypeCreationRequest, ProcessMappingParametersRequest } from "./interfaces"; import { RequestType, NotificationType } from "vscode-messenger-common"; const _preFix = "ai-panel"; @@ -31,7 +30,6 @@ export const getRefreshedAccessToken: RequestType = { method: `${_ export const getDefaultPrompt: RequestType = { method: `${_preFix}/getDefaultPrompt` }; export const getAIMachineSnapshot: RequestType = { method: `${_preFix}/getAIMachineSnapshot` }; export const fetchData: RequestType = { method: `${_preFix}/fetchData` }; -export const addToProject: RequestType = { method: `${_preFix}/addToProject` }; export const getFromFile: RequestType = { method: `${_preFix}/getFromFile` }; export const getFileExists: RequestType = { method: `${_preFix}/getFileExists` }; export const deleteFromProject: NotificationType = { method: `${_preFix}/deleteFromProject` }; diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/index.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/index.ts index 3bd574f9f56..e2ded2bcde2 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/index.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/index.ts @@ -16,8 +16,23 @@ * under the License. */ -import { ConnectorRequest, ConnectorResponse, ConnectorsRequest, ConnectorsResponse } from "./interfaces"; +import { + ConnectorRequest, + ConnectorResponse, + ConnectorsRequest, + ConnectorsResponse, + PersistClientGenerateRequest, + IntrospectDatabaseResponse, + PersistClientGenerateResponse, + IntrospectDatabaseRequest, + WSDLApiClientGenerationRequest, + WSDLApiClientGenerationResponse +} from "./interfaces"; + export interface ConnectorWizardAPI { getConnector: (params: ConnectorRequest) => Promise; getConnectors: (params: ConnectorsRequest) => Promise; + introspectDatabase: (params: IntrospectDatabaseRequest) => Promise; + persistClientGenerate: (params: PersistClientGenerateRequest) => Promise; + generateWSDLApiClient: (params: WSDLApiClientGenerationRequest) => Promise; } diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/interfaces.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/interfaces.ts index 4cd31515a31..b2a7fce447f 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/interfaces.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/interfaces.ts @@ -17,6 +17,7 @@ */ import { BallerinaConnectorInfo, BallerinaConnectorsRequest, BallerinaConnector } from "../../interfaces/ballerina"; +import { TextEdit } from "../../interfaces/extended-lang-client"; export interface ConnectorRequest { id?: string @@ -41,3 +42,64 @@ export interface ConnectorsResponse { local?: BallerinaConnector[]; error?: string; } + +export interface IntrospectDatabaseRequest { + projectPath: string; + dbSystem: string; + host: string; + port: number; + database: string; + user: string; + password: string; +} + +export interface IntrospectDatabaseResponse { + tables?: string[]; + errorMsg?: string; +} + +export interface PersistClientGenerateRequest { + projectPath: string; + name: string; + dbSystem: string; + host: string; + port: number; + user: string; + password: string; + database: string; + selectedTables: string[]; + module?: string; +} + +export interface PersistClientGenerateResponse { + source?: PersistSource; + errorMsg?: string; + stackTrace?: string; +} + +export interface PersistSource { + isModuleExists?: boolean; + textEditsMap?: { + [key: string]: TextEdit[]; + }; +} + +export interface WSDLApiClientGenerationRequest { + projectPath: string; + module: string; + wsdlFilePath: string; + portName?: string; + operations?: string[]; +} + +export interface WSDLApiClientGenerationResponse { + source?: WSDLApiClientSource; + errorMsg?: string; + stackTrace?: string; +} + +export interface WSDLApiClientSource { + textEditsMap: { + [key: string]: TextEdit[]; + }; +} diff --git a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/rpc-type.ts b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/rpc-type.ts index e38504aee03..3c7e971302c 100644 --- a/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/rpc-type.ts +++ b/workspaces/ballerina/ballerina-core/src/rpc-types/connector-wizard/rpc-type.ts @@ -17,9 +17,23 @@ * * THIS FILE INCLUDES AUTO GENERATED CODE */ -import { ConnectorRequest, ConnectorResponse, ConnectorsRequest, ConnectorsResponse } from "./interfaces"; +import { + ConnectorRequest, + ConnectorResponse, + ConnectorsRequest, + ConnectorsResponse, + PersistClientGenerateRequest, + IntrospectDatabaseResponse, + PersistClientGenerateResponse, + IntrospectDatabaseRequest, + WSDLApiClientGenerationRequest, + WSDLApiClientGenerationResponse +} from "./interfaces"; import { RequestType } from "vscode-messenger-common"; const _preFix = "connector-wizard"; export const getConnector: RequestType = { method: `${_preFix}/getConnector` }; export const getConnectors: RequestType = { method: `${_preFix}/getConnectors` }; +export const introspectDatabase: RequestType = { method: `${_preFix}/introspectDatabase` }; +export const persistClientGenerate: RequestType = { method: `${_preFix}/persistClientGenerate` }; +export const generateWSDLApiClient: RequestType = { method: `${_preFix}/generateWSDLApiClient` }; diff --git a/workspaces/ballerina/ballerina-extension/CHANGELOG.md b/workspaces/ballerina/ballerina-extension/CHANGELOG.md index 37c8c539578..ad3d4bd2e3b 100644 --- a/workspaces/ballerina/ballerina-extension/CHANGELOG.md +++ b/workspaces/ballerina/ballerina-extension/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the **Ballerina** extension will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [5.6.4](https://github.com/wso2/vscode-extensions/compare/ballerina-integrator-1.5.3...ballerina-integrator-1.5.4) - 2025-12-05 + +### Fixed + +- **Data Mapper** — Fixed the issue with focusing into inner array queries. +- **Security** — Updated dependencies to address security vulnerabilities (`CVE-2024-51999`). + ## [5.6.3](https://github.com/wso2/vscode-extensions/compare/ballerina-integrator-1.5.2...ballerina-integrator-1.5.3) - 2025-12-01 ### Changed diff --git a/workspaces/ballerina/ballerina-extension/package.json b/workspaces/ballerina/ballerina-extension/package.json index 8874f6ea942..62260afa485 100644 --- a/workspaces/ballerina/ballerina-extension/package.json +++ b/workspaces/ballerina/ballerina-extension/package.json @@ -2,7 +2,7 @@ "name": "ballerina", "displayName": "Ballerina", "description": "Ballerina Language support, debugging, graphical visualization, AI-based data-mapping and many more.", - "version": "5.6.3", + "version": "5.6.4", "publisher": "wso2", "icon": "resources/images/ballerina.png", "homepage": "https://wso2.com/ballerina/vscode/docs", diff --git a/workspaces/ballerina/ballerina-extension/src/core/extended-language-client.ts b/workspaces/ballerina/ballerina-extension/src/core/extended-language-client.ts index 816c4f46e56..1bfc17679c6 100644 --- a/workspaces/ballerina/ballerina-extension/src/core/extended-language-client.ts +++ b/workspaces/ballerina/ballerina-extension/src/core/extended-language-client.ts @@ -275,7 +275,13 @@ import { ProjectMigrationResult, FieldPropertyRequest, ClausePositionResponse, - ClausePositionRequest + ClausePositionRequest, + IntrospectDatabaseRequest, + IntrospectDatabaseResponse, + PersistClientGenerateRequest, + PersistClientGenerateResponse, + WSDLApiClientGenerationRequest, + WSDLApiClientGenerationResponse } from "@wso2/ballerina-core"; import { BallerinaExtension } from "./index"; import { debug, handlePullModuleProgress } from "../utils"; @@ -459,6 +465,9 @@ enum EXTENDED_APIS { OPEN_API_GENERATE_CLIENT = 'openAPIService/genClient', OPEN_API_GENERATED_MODULES = 'openAPIService/getModules', OPEN_API_CLIENT_DELETE = 'openAPIService/deleteModule', + PERSIST_DATABASE_INTROSPECTION = 'persistService/introspectDatabase', + PERSIST_CLIENT_GENERATE = 'persistService/generatePersistClient', + WSDL_API_CLIENT_GENERATE = 'wsdlService/genClient', GET_PROJECT_INFO = 'designModelService/projectInfo', GET_ARTIFACTS = 'designModelService/artifacts', PUBLISH_ARTIFACTS = 'designModelService/publishArtifacts', @@ -698,6 +707,18 @@ export class ExtendedLangClient extends LanguageClient implements ExtendedLangCl return this.sendRequest(EXTENDED_APIS.CONNECTOR_CONNECTOR, params); } + async introspectDatabase(params: IntrospectDatabaseRequest): Promise { + return this.sendRequest(EXTENDED_APIS.PERSIST_DATABASE_INTROSPECTION, params); + } + + async generatePersistClient(params: PersistClientGenerateRequest): Promise { + return this.sendRequest(EXTENDED_APIS.PERSIST_CLIENT_GENERATE, params); + } + + async generateWSDLApiClient(params: WSDLApiClientGenerationRequest): Promise { + return this.sendRequest(EXTENDED_APIS.WSDL_API_CLIENT_GENERATE, params); + } + async getRecord(params: RecordParams): Promise { const isSupported = await this.isExtendedServiceSupported(EXTENDED_APIS.CONNECTOR_RECORD); if (!isSupported) { diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/constants.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/constants.ts index b6b2d3b27b5..6891aa98e77 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/constants.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/constants.ts @@ -46,6 +46,11 @@ export enum NullablePrimitiveType { BOOLEAN = "boolean?" } +// Error type +export enum ErrorType { + ERROR = "error" +} + // Array types for primitive data types export enum PrimitiveArrayType { // Basic array types diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/dataMapping.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/dataMapping.ts index 755b2db02b5..b9f8e46cd88 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/dataMapping.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/dataMapping.ts @@ -16,20 +16,20 @@ * under the License. */ -import { AllDataMapperSourceRequest, Attachment, CodeData, ComponentInfo, createFunctionSignature, CreateTempFileRequest, DataMapperMetadata, DatamapperModelContext, DataMapperModelResponse, DataMappingRecord, DiagnosticList, DMModel, EnumType, ExistingFunctionMatchResult, ExtendedDataMapperMetadata, ExtractMappingDetailsRequest, ExtractMappingDetailsResponse, GenerateTypesFromRecordRequest, GenerateTypesFromRecordResponse, getSource, ImportInfo, ImportStatements, InlineMappingsSourceResult, IORoot, IOTypeField, LinePosition, Mapping, MappingParameters, MetadataWithAttachments, ModuleSummary, PackageSummary, ProjectComponentsResponse, ProjectSource, RecordType, RepairCodeParams, repairCodeRequest, SourceFile, SyntaxTree, TempDirectoryPath } from "@wso2/ballerina-core"; +import { AllDataMapperSourceRequest, Attachment, CodeData, ComponentInfo, createFunctionSignature, CreateTempFileRequest, DataMapperMetadata, DatamapperModelContext, DataMapperModelResponse, DataMappingRecord, DiagnosticList, DMModel, EnumType, ExistingFunctionMatchResult, ExtendedDataMapperMetadata, ExtractMappingDetailsRequest, ExtractMappingDetailsResponse, GenerateTypesFromRecordRequest, GenerateTypesFromRecordResponse, getSource, ImportInfo, ImportStatements, InlineMappingsSourceResult, IORoot, IOTypeField, keywords, LinePosition, Mapping, MappingParameters, MetadataWithAttachments, ModuleSummary, PackageSummary, ProjectComponentsResponse, RecordType, RepairCodeParams, SourceFile, SyntaxTree, TempDirectoryPath } from "@wso2/ballerina-core"; import { camelCase } from "lodash"; import path from "path"; import * as fs from 'fs'; import * as os from 'os'; import { Uri } from "vscode"; -import { extractRecordTypeDefinitionsFromFile, generateMappingExpressionsFromModel, repairSourceFilesWithAI } from "../../rpc-managers/ai-panel/utils"; +import { extractRecordTypeDefinitionsFromFile, generateMappingExpressionsFromModel } from "../../rpc-managers/ai-panel/utils"; import { writeBallerinaFileDidOpenTemp } from "../../utils/modification"; import { ExtendedLangClient, NOT_SUPPORTED } from "../../core"; import { DefaultableParam, FunctionDefinition, IncludedRecordParam, ModulePart, RequiredParam, RestParam, STKindChecker, STNode } from "@wso2/syntax-tree"; import { addMissingRequiredFields, attemptRepairProject, checkProjectDiagnostics } from "../../../src/rpc-managers/ai-panel/repair-utils"; -import { NullablePrimitiveType, PrimitiveArrayType, PrimitiveType } from "./constants"; +import { ErrorType, NullablePrimitiveType, PrimitiveArrayType, PrimitiveType } from "./constants"; import { INVALID_RECORD_REFERENCE } from "../../../src/views/ai-panel/errorCodes"; -import { CodeRepairResult, PackageInfo, TypesGenerationResult } from "./service/datamapper/types"; +import { PackageInfo, TypesGenerationResult } from "./service/datamapper/types"; import { URI } from "vscode-uri"; import { getAllDataMapperSource } from "./service/datamapper/datamapper"; import { StateMachine } from "../../stateMachine"; @@ -67,8 +67,12 @@ const isPrimitiveArrayType = (type: string): boolean => { return false; }; +const isErrorType = (type: string): boolean => { + return Object.values(ErrorType).includes(type as ErrorType); +}; + const isAnyPrimitiveType = (type: string): boolean => { - return isPrimitiveType(type) || isNullablePrimitiveType(type) || isPrimitiveArrayType(type); + return isPrimitiveType(type) || isNullablePrimitiveType(type) || isPrimitiveArrayType(type) || isErrorType(type); }; // ================================================================================================ @@ -220,32 +224,6 @@ export async function createTempBallerinaDir(): Promise { return tempDir; } -export async function repairCodeWithLLM(codeRepairRequest: repairCodeRequest): Promise { - if (!codeRepairRequest) { - throw new Error("Code repair request is required"); - } - - if (!codeRepairRequest.sourceFiles || codeRepairRequest.sourceFiles.length === 0) { - throw new Error("Source files are required for code repair"); - } - - const repairedSourceFiles = await repairSourceFilesWithAI(codeRepairRequest); - - for (const repairedFile of repairedSourceFiles) { - try { - writeBallerinaFileDidOpenTemp( - repairedFile.filePath, - repairedFile.content - ); - } catch (error) { - console.error(`Error processing file ${repairedFile.filePath}:`, error); - } - } - - const projectSourceResponse = { sourceFiles: repairedSourceFiles, projectName: "", packagePath: "", isActive: true }; - return projectSourceResponse; -} - export function createDataMappingFunctionSource( inputParams: DataMappingRecord[], outputParam: DataMappingRecord, @@ -297,7 +275,9 @@ function getDefaultParamName(type: string, isArray: boolean): string { case PrimitiveType.BOOLEAN: return isArray ? "flagArr" : "flag"; default: - return camelCase(processedType); + const camelCaseName = camelCase(processedType); + // Check if the camelCase name is a reserved keyword + return keywords.includes(camelCaseName) ? `'${camelCaseName}` : camelCaseName; } } @@ -514,7 +494,7 @@ export async function generateMappings( // ================================================================================================ // DMModel Optimization - Functions for processing and optimizing data mapper models // ================================================================================================ -function ensureUnionRefs(model: DMModel): DMModel { +export function ensureUnionRefs(model: DMModel): DMModel { const processedModel = JSON.parse(JSON.stringify(model)); const unionRefs = new Map(); @@ -1316,45 +1296,6 @@ function prepareSourceFilesForRepair( return sourceFiles; } -// Repair code and get updated content -export async function repairCodeAndGetUpdatedContent( - params: RepairCodeParams, - langClient: ExtendedLangClient, - projectRoot: string -): Promise { - - // Read main file content - let finalContent = fs.readFileSync(params.tempFileMetadata.codeData.lineRange.fileName, 'utf8'); - - // Read custom functions content (only if path is provided) - let customFunctionsContent = params.customFunctionsFilePath - ? await getCustomFunctionsContent(params.customFunctionsFilePath) - : ''; - - // Check and repair diagnostics - const diagnostics = await checkAndRepairDiagnostics( - params, - langClient, - projectRoot - ); - - // Repair with LLM if needed - if (diagnostics.diagnosticsList && diagnostics.diagnosticsList.length > 0) { - const result = await repairWithLLM( - params.tempFileMetadata, - finalContent, - params.customFunctionsFilePath, - customFunctionsContent, - diagnostics, - params.imports - ); - finalContent = result.finalContent; - customFunctionsContent = result.customFunctionsContent; - } - - return { finalContent, customFunctionsContent }; -} - // Get custom functions content if file exists export async function getCustomFunctionsContent( customFunctionsFilePath: string | undefined, @@ -1382,34 +1323,6 @@ async function checkAndRepairDiagnostics( return await repairAndCheckDiagnostics(langClient, projectRoot, diagnosticsParams); } -// Repair code using LLM -async function repairWithLLM( - tempFileMetadata: ExtendedDataMapperMetadata, - mainContent: string, - customFunctionsFilePath: string | undefined, - customFunctionsContent: string, - diagnostics: DiagnosticList, - imports: ImportInfo[] -): Promise<{ finalContent: string; customFunctionsContent: string }> { - const sourceFiles = prepareSourceFilesForRepair( - tempFileMetadata.codeData.lineRange.fileName, - mainContent, - customFunctionsFilePath, - customFunctionsContent - ); - - await repairCodeWithLLM({sourceFiles, diagnostics, imports}); - - // Get updated content after repair - const finalContent = fs.readFileSync(tempFileMetadata.codeData.lineRange.fileName, 'utf8'); - const updatedCustomFunctionsContent = await getCustomFunctionsContent(customFunctionsFilePath); - - return { - finalContent, - customFunctionsContent: updatedCustomFunctionsContent - }; -} - // ================================================================================================ // processMappingParameters - Functions for processing mapping parameters // ================================================================================================ @@ -1433,6 +1346,11 @@ export function buildRecordMap( const recFilePath = filepath + rec.filePath; recordMap.set(rec.name, { type: rec.name, isArray: false, filePath: recFilePath }); }); + + mod.types.forEach((type: ComponentInfo) => { + const typeFilePath = filepath + type.filePath; + recordMap.set(type.name, { type: type.name, isArray: false, filePath: typeFilePath }); + }); } } diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/codeRepairPrompt.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/codeRepairPrompt.ts index 2f820687694..d11ee856d55 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/codeRepairPrompt.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/codeRepairPrompt.ts @@ -15,66 +15,90 @@ // under the License. /** - * Generates the main data mapping prompt for AI + * Generates the code repair prompt for AI using DM Model */ -export function getBallerinaCodeRepairPrompt(sourceFiles: string, diagnostics: string, imports: string): string { - return `You are an expert Ballerina programmer tasked with fixing compiler errors in Ballerina source code. - -# Context -You have been provided with: -1. Source files with Ballerina code that contains errors -2. Diagnostic information (compiler errors and warnings) -3. Import statements available in the project - -# Your Task -Analyze the provided code and diagnostics, then generate corrected Ballerina source files that: -- Fix all compiler errors identified in the diagnostics -- Maintain the original code structure and logic as much as possible -- Use correct Ballerina syntax and idioms -- Ensure all function signatures, type definitions, and record field accesses are valid -- Verify that all imported modules are used correctly -- Follow Ballerina best practices and conventions +export function getBallerinaCodeRepairPrompt(dmModel: string, imports: string): string { + return `You are an expert Ballerina programmer tasked with repairing mapping expressions that have compiler errors. -# Input Data +## Context + +You have been provided with a Data Mapper Model (DMModel) that contains mapping definitions with diagnostics (compiler errors and warnings). Your task is to fix the expressions in these mappings. + +The DMModel structure includes: +- **inputs**: Array of input record structures with their fields and types +- **output**: Output record structure with fields and types +- **subMappings**: Nested mapping definitions +- **mappings**: Array of mapping objects, each containing: + - \`output\`: Target field path (e.g., "outputRecord.fieldName") + - \`expression\`: Ballerina expression to map the value + - \`diagnostics\`: Array of compiler errors/warnings for this mapping (if any) +- **refs**: Referenced type definitions -## Source Files: -${sourceFiles} +# Input Data -## Diagnostics (Errors and Warnings): -${diagnostics} +## Data Mapper Model (DMModel with diagnostics): +${dmModel} ## Available Imports: ${imports} -# Instructions -1. Carefully examine each diagnostic error and identify the root cause -2. Check function signatures, return types, and parameter types against Ballerina documentation -3. Verify record field access patterns are correct (use dot notation for required fields, optional chaining for optional fields) -4. Ensure type compatibility in assignments and function calls -5. Fix any syntax errors or misused language constructs -6. Validate that all imported modules and their functions are used correctly -7. Return the complete corrected source files with the same file paths - -# Output Format -Return a JSON object with the following structure: +## Your Task + +Analyze each mapping that has diagnostics and repair the expression to fix all compiler errors. Focus on: + +1. **Type Compatibility**: Ensure the expression produces the correct type for the output field +2. **Field Access**: Use correct syntax for accessing record fields + - Required fields: \`record.field\` + - Optional fields: \`record?.field\` or \`record["field"]\` +3. **Null Safety**: Handle optional/nilable types appropriately +4. **Function Calls**: Verify imported functions are called correctly +5. **Type Conversions**: Add necessary type casts or conversions +6. **Syntax Errors**: Fix any Ballerina syntax issues + +## Output Format + +Return a JSON object with the repaired mappings: + +{ + "repairedMappings": [ + { + "output": "path.to.output.field", + "expression": "corrected_ballerina_expression" + } + ] +} + +## Requirements + +- Only include mappings that had diagnostics and were repaired +- Provide the complete corrected expression, not partial code +- Ensure expressions are valid Ballerina syntax +- Maintain the original mapping intent and logic +- Do NOT add comments or explanations in the expressions +- The repaired expression must resolve all diagnostic errors for that mapping + +## Example + +**Input Mapping with Diagnostic:** +{ + "output": "person.age", + "expression": "inputData.years", + "diagnostics": [ + { + "message": "incompatible types: expected 'int', found 'string'" + } + ] +} + +##Repaired Output: { - "repairedFiles": [ + "repairedMappings": [ { - "filePath": "path/to/file.bal", - "content": "// Complete corrected Ballerina code here" + "output": "person.age", + "expression": "int:fromString(inputData.years) ?: 0" } ] } -# Important Notes -- Include ALL source files in your response, even if some don't have errors -- Provide the COMPLETE file content, not just the changed portions -- Ensure the code compiles without errors -- Maintain code readability and formatting -- Preserve all existing comments from the original code -- Do NOT add any new comments or explanatory notes -- Only fix the errors without adding documentation or explanations -- Do not change the core logic or business requirements of the code - -Generate the repaired source files now.`; +Generate the repaired mappings now.`; } diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/datamapper.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/datamapper.ts index 64a1a6262a0..6cadac8def5 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/datamapper.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/datamapper.ts @@ -17,20 +17,21 @@ import { CoreMessage, ModelMessage, generateObject } from "ai"; import { getAnthropicClient, ANTHROPIC_SONNET_4 } from "../connection"; import { - CodeRepairResult, DatamapperResponse, DataModelStructure, MappingFields, - RepairedFiles, + RepairedMapping, + RepairedMappings, + DMModelDiagnosticsResult } from "./types"; -import { GeneratedMappingSchema, RepairedSourceFilesSchema } from "./schema"; -import { AIPanelAbortController } from "../../../../../src/rpc-managers/ai-panel/utils"; -import { DataMapperModelResponse, DMModel, Mapping, repairCodeRequest, SourceFile, DiagnosticList, ImportInfo, ProcessMappingParametersRequest, Command, MetadataWithAttachments, InlineMappingsSourceResult, ProcessContextTypeCreationRequest, ProjectImports, ImportStatements, TemplateId, GetModuleDirParams, TextEdit, DataMapperSourceResponse, DataMapperSourceRequest, AllDataMapperSourceRequest, DataMapperModelRequest, DeleteMappingRequest } from "@wso2/ballerina-core"; +import { GeneratedMappingSchema, RepairedMappingsSchema } from "./schema"; +import { AIPanelAbortController, repairSourceFilesWithAI } from "../../../../../src/rpc-managers/ai-panel/utils"; +import { DataMapperModelResponse, DMModel, Mapping, repairCodeRequest, DiagnosticList, ImportInfo, ProcessMappingParametersRequest, Command, MetadataWithAttachments, InlineMappingsSourceResult, ProcessContextTypeCreationRequest, ProjectImports, ImportStatements, TemplateId, GetModuleDirParams, TextEdit, DataMapperSourceResponse, DataMapperSourceRequest, AllDataMapperSourceRequest, DataMapperModelRequest, DeleteMappingRequest, CodeData } from "@wso2/ballerina-core"; import { getDataMappingPrompt } from "./dataMappingPrompt"; import { getBallerinaCodeRepairPrompt } from "./codeRepairPrompt"; import { CopilotEventHandler, createWebviewEventHandler } from "../event"; import { getErrorMessage } from "../utils"; -import { buildMappingFileArray, buildRecordMap, collectExistingFunctions, collectModuleInfo, createTempBallerinaDir, createTempFileAndGenerateMetadata, getFunctionDefinitionFromSyntaxTree, getUniqueFunctionFilePaths, prepareMappingContext, generateInlineMappingsSource, generateTypesFromContext, repairCodeAndGetUpdatedContent, extractImports, generateDataMapperModel, determineCustomFunctionsPath, generateMappings, getCustomFunctionsContent, repairAndCheckDiagnostics } from "../../dataMapping"; +import { buildMappingFileArray, buildRecordMap, collectExistingFunctions, collectModuleInfo, createTempBallerinaDir, createTempFileAndGenerateMetadata, getFunctionDefinitionFromSyntaxTree, getUniqueFunctionFilePaths, prepareMappingContext, generateInlineMappingsSource, generateTypesFromContext, extractImports, generateDataMapperModel, determineCustomFunctionsPath, generateMappings, repairAndCheckDiagnostics, ensureUnionRefs, normalizeRefs } from "../../dataMapping"; import { addCheckExpressionErrors } from "../../../../../src/rpc-managers/ai-panel/repair-utils"; import { BiDiagramRpcManager, getBallerinaFiles } from "../../../../../src/rpc-managers/bi-diagram/rpc-manager"; import { updateSourceCode } from "../../../../../src/utils/source-utils"; @@ -147,24 +148,18 @@ async function generateAIMappings( } } -// Uses Claude AI to repair Ballerina source files based on diagnostics and import information +// Uses Claude AI to repair code based on DM model with diagnostics and import information async function repairBallerinaCode( - filesToRepair: SourceFile[], - compilationDiagnostics: DiagnosticList, + dmModel: DMModel, availableImports: ImportInfo[] -): Promise { - if (!filesToRepair || filesToRepair.length === 0) { - throw new Error("Source files to repair are required and cannot be empty"); - } - - if (!compilationDiagnostics) { - throw new Error("Compilation diagnostics are required for code repair"); +): Promise { + if (!dmModel) { + throw new Error("DM model is required for code repair"); } // Build repair prompt const codeRepairPrompt = getBallerinaCodeRepairPrompt( - JSON.stringify(filesToRepair), - JSON.stringify(compilationDiagnostics), + JSON.stringify(dmModel), JSON.stringify(availableImports || []) ); @@ -178,14 +173,14 @@ async function repairBallerinaCode( maxOutputTokens: 8192, temperature: 0, messages: chatMessages, - schema: RepairedSourceFilesSchema, + schema: RepairedMappingsSchema, abortSignal: AIPanelAbortController.getInstance().signal, }); - return object.repairedFiles as SourceFile[]; + return object.repairedMappings as RepairedMapping[]; } catch (error) { console.error("Failed to parse response:", error); - throw new Error(`Failed to parse repaired files response: ${error}`); + throw new Error(`Failed to parse repaired mappings response: ${error}`); } } @@ -207,8 +202,8 @@ export async function generateAutoMappings(dataMapperModelResponse?: DataMapperM } } -// Generates repaired Ballerina code by fixing diagnostics with retry logic -export async function generateRepairCode(codeRepairRequest?: repairCodeRequest): Promise { +// Generates repaired mappings by fixing diagnostics with retry logic +export async function generateRepairCode(codeRepairRequest?: repairCodeRequest): Promise { if (!codeRepairRequest) { throw new Error("Code repair request is required for generating repair code"); } @@ -223,17 +218,15 @@ export async function generateRepairCode(codeRepairRequest?: repairCodeRequest): } try { - // Generate AI-powered repaired source files using Claude - const aiRepairedFiles = await repairBallerinaCode(codeRepairRequest.sourceFiles, codeRepairRequest.diagnostics, codeRepairRequest.imports); - - if (!aiRepairedFiles || aiRepairedFiles.length === 0) { - const error = new Error("No repaired files were generated. Unable to fix the provided source code."); - lastError = error; - attemptCount += 1; - continue; + // Generate AI-powered repaired mappings using Claude with DM model + const aiRepairedMappings = await repairBallerinaCode(codeRepairRequest.dmModel, codeRepairRequest.imports); + + if (!aiRepairedMappings || aiRepairedMappings.length === 0) { + console.warn("No mappings were repaired. The code may not have fixable errors."); + return { repairedMappings: [] }; } - return { repairedFiles: aiRepairedFiles }; + return { repairedMappings: aiRepairedMappings }; } catch (error) { console.error(`Error occurred while generating repaired code: ${error}`); @@ -246,6 +239,133 @@ export async function generateRepairCode(codeRepairRequest?: repairCodeRequest): throw lastError!; } +// Gets DM model for a function +async function getDMModel( + langClient: any, + mainFilePath: string, + functionName: string +): Promise { + // Get function definition to retrieve accurate position + const funcDefinitionNode = await getFunctionDefinitionFromSyntaxTree( + langClient, + mainFilePath, + functionName + ); + + // Build metadata with current function position + const dataMapperMetadata: DataMapperModelRequest = { + filePath: mainFilePath, + codedata: { + lineRange: { + fileName: mainFilePath, + startLine: { + line: funcDefinitionNode.position.startLine, + offset: funcDefinitionNode.position.startColumn, + }, + endLine: { + line: funcDefinitionNode.position.endLine, + offset: funcDefinitionNode.position.endColumn, + }, + }, + }, + targetField: functionName, + position: { + line: funcDefinitionNode.position.startLine, + offset: funcDefinitionNode.position.startColumn + } + }; + + // Get DM model with mapping-level diagnostics + const dataMapperModel = await langClient.getDataMapperMappings(dataMapperMetadata) as DataMapperModelResponse; + const dmModel = dataMapperModel.mappingsModel as DMModel; + + return { dataMapperMetadata, dmModel }; +} + +// Repairs mappings using LLM based on DM model diagnostics +async function repairMappingsWithLLM( + langClient: any, + dmModelResult: DMModelDiagnosticsResult, + imports: ImportInfo[] +): Promise { + const { dataMapperMetadata, dmModel } = dmModelResult; + + // Call LLM repair with targeted diagnostics and DM model context + try { + let mappingsModel = ensureUnionRefs(dmModel); + mappingsModel = normalizeRefs(mappingsModel); + + const repairResult = await repairSourceFilesWithAI({ + dmModel: mappingsModel, + imports + }); + + // Apply repaired mappings to the DM model + if (repairResult.repairedMappings && repairResult.repairedMappings.length > 0) { + // Apply each repaired mapping individually using the language server + for (const repairedMapping of repairResult.repairedMappings) { + const targetMapping = dmModel.mappings.find(m => m.output === repairedMapping.output); + if (targetMapping) { + // Update the mapping with the repaired expression + targetMapping.expression = repairedMapping.expression; + targetMapping.diagnostics = []; + + // Generate source for this individual mapping + const singleMappingRequest: DataMapperSourceRequest = { + filePath: dataMapperMetadata.filePath, + codedata: dataMapperMetadata.codedata, + varName: dataMapperMetadata.targetField, + targetField: dataMapperMetadata.targetField, + mapping: targetMapping + }; + + try { + const mappingSourceResponse = await langClient.getDataMapperSource(singleMappingRequest); + if (mappingSourceResponse.textEdits && Object.keys(mappingSourceResponse.textEdits).length > 0) { + await updateSourceCode({ textEdits: mappingSourceResponse.textEdits, skipPayloadCheck: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } catch (error) { + console.warn(`Failed to apply repaired mapping for ${repairedMapping.output}:`, error); + } + } + } + } + } catch (error) { + console.warn('LLM repair failed, continuing with other repairs:', error); + } +} + +// Repairs check expression errors (BCE3032) in DM model +async function repairCheckErrors( + langClient: any, + projectRoot: string, + mainFilePath: string, + allMappingsRequest: AllDataMapperSourceRequest, + tempDirectory: string, + isSameFile: boolean +): Promise { + // Apply programmatic fixes (imports, required fields, etc.) + const filePaths = [mainFilePath]; + if (allMappingsRequest.customFunctionsFilePath && !isSameFile) { + filePaths.push(allMappingsRequest.customFunctionsFilePath); + } + + let diags = await repairAndCheckDiagnostics(langClient, projectRoot, { + tempDir: tempDirectory, + filePaths + }); + + // Handle check expression errors (BCE3032) + const hasCheckError = diags.diagnosticsList.some(diagEntry => + diagEntry.diagnostics.some(d => d.code === "BCE3032") + ); + + if (hasCheckError) { + await addCheckExpressionErrors(diags.diagnosticsList, langClient); + } +} + // ============================================================================= // MAPPING CODE GENERATION WITH EVENT HANDLERS // ============================================================================= @@ -377,63 +497,50 @@ export async function generateMappingCodeCore(mappingRequest: ProcessMappingPara // Check if mappings file and custom functions file are the same const mainFilePath = tempFileMetadata.codeData.lineRange.fileName; - const isSameFile = customFunctionsTargetPath && + const isSameFile = customFunctionsTargetPath && path.resolve(mainFilePath) === path.resolve(path.join(tempDirectory, customFunctionsFileName)); - let codeRepairResult: CodeRepairResult; - const customContent = await getCustomFunctionsContent(allMappingsRequest.customFunctionsFilePath); eventHandler({ type: "content_block", content: "\nRepairing generated code..." }); - if (isSameFile) { - const mainContent = fs.readFileSync(mainFilePath, 'utf8'); + // Get DM model with diagnostics + const dmModelResult = await getDMModel( + langClient, + mainFilePath, + targetFunctionName + ); - if (customContent) { - // Merge: main content + custom functions - const mergedContent = `${mainContent}\n\n${customContent}`; - fs.writeFileSync(mainFilePath, mergedContent, 'utf8'); - } - - codeRepairResult = await repairCodeAndGetUpdatedContent({ - tempFileMetadata, - customFunctionsFilePath: undefined, - imports: uniqueImportStatements, - tempDir: tempDirectory - }, langClient, projectRoot); - - codeRepairResult.customFunctionsContent = ''; - } else { - // Files are different, repair them separately - codeRepairResult = await repairCodeAndGetUpdatedContent({ - tempFileMetadata, - customFunctionsFilePath: allMappingsRequest.customFunctionsFilePath, - imports: uniqueImportStatements, - tempDir: tempDirectory - }, langClient, projectRoot); - } + // Repair mappings using LLM based on DM model diagnostics + await repairMappingsWithLLM( + langClient, + dmModelResult, + uniqueImportStatements + ); - // Handle check expression errors and repair diagnostics - const filePaths = await handleCheckExpressionErrorsAndRepair( + // Repair check expression errors (BCE3032) + await repairCheckErrors( langClient, projectRoot, - tempFileMetadata, + mainFilePath, allMappingsRequest, tempDirectory, - isSameFile, - codeRepairResult + isSameFile ); // Remove compilation error mappings - const { updatedMainContent, updatedCustomContent } = await removeCompilationErrorMappingFields( + await removeCompilationErrorMappingFields( langClient, - projectRoot, mainFilePath, targetFunctionName, allMappingsRequest, - tempDirectory, - filePaths, - isSameFile ); + // Read updated content after removing compilation errors + const finalContent = fs.readFileSync(mainFilePath, 'utf8'); + let customFunctionsContent = ''; + if (allMappingsRequest.customFunctionsFilePath && !isSameFile) { + customFunctionsContent = fs.readFileSync(allMappingsRequest.customFunctionsFilePath, 'utf8'); + } + let generatedFunctionDefinition = await getFunctionDefinitionFromSyntaxTree( langClient, tempFileMetadata.codeData.lineRange.fileName, @@ -453,9 +560,9 @@ export async function generateMappingCodeCore(mappingRequest: ProcessMappingPara const generatedSourceFiles = buildMappingFileArray( targetFilePath, - updatedMainContent, + finalContent, customFunctionsTargetPath, - updatedCustomContent, + customFunctionsContent, ); // Build assistant response @@ -481,13 +588,13 @@ export async function generateMappingCodeCore(mappingRequest: ProcessMappingPara } if (isSameFile) { - const mergedContent = `${generatedFunctionDefinition.source}\n${customContent}`; - assistantResponse += `\n\`\`\`ballerina\n${mergedContent}\n\`\`\`\n`; + // For same file, custom content is already merged in the main content + assistantResponse += `\n\`\`\`ballerina\n${generatedFunctionDefinition.source}\n\`\`\`\n`; } else { assistantResponse += `\n\`\`\`ballerina\n${generatedFunctionDefinition.source}\n\`\`\`\n`; - if (updatedCustomContent) { - assistantResponse += `\n\`\`\`ballerina\n${updatedCustomContent}\n\`\`\`\n`; + if (customFunctionsContent) { + assistantResponse += `\n\`\`\`ballerina\n${customFunctionsContent}\n\`\`\`\n`; } } @@ -667,6 +774,65 @@ export function combineTextEdits(sortedTextEdits: TextEdit[]): TextEdit { }; } +// Gets DM model with diagnostics for inline variable +async function getInlineDMModelWithDiagnostics( + langClient: any, + mainFilePath: string, + variableName: string, + codedata: CodeData +): Promise { + // Build metadata for inline variable + const dataMapperMetadata = { + filePath: mainFilePath, + codedata: codedata, + targetField: variableName, + position: codedata.lineRange.startLine + }; + + // Get DM model with mapping-level diagnostics + const dataMapperModel = await langClient.getDataMapperMappings(dataMapperMetadata) as DataMapperModelResponse; + const dmModel = dataMapperModel.mappingsModel as DMModel; + + return { dataMapperMetadata, dmModel }; +} + +// Removes mappings with compilation errors +async function removeMappingsWithErrors( + langClient: any, + mainFilePath: string, + dmModelResult: DMModelDiagnosticsResult, + varName: string +): Promise { + const { dataMapperMetadata, dmModel } = dmModelResult; + + // Check if any mappings have diagnostics and delete them + if (dmModel && dmModel.mappings && dmModel.mappings.length > 0) { + // Extract mappings with diagnostics + const mappingsWithDiagnostics = dmModel.mappings?.filter((mapping: Mapping) => + mapping.diagnostics && mapping.diagnostics.length > 0 + ) || []; + + // Delete each mapping with diagnostics using the deleteMapping API + for (const mapping of mappingsWithDiagnostics) { + const deleteRequest: DeleteMappingRequest = { + filePath: mainFilePath, + codedata: dataMapperMetadata.codedata, + mapping: mapping, + varName: varName, + targetField: dataMapperMetadata.targetField, + }; + + const deleteResponse = await langClient.deleteMapping(deleteRequest); + + // Apply the text edits from the delete operation directly to temp files + if (Object.keys(deleteResponse.textEdits).length > 0) { + await applyTextEditsToTempFile(deleteResponse.textEdits, mainFilePath); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + } +} + // ============================================================================= // INLINE MAPPING CODE GENERATION WITH EVENT HANDLERS // ============================================================================= @@ -740,35 +906,34 @@ export async function generateInlineMappingCodeCore(inlineMappingRequest: Metada const isSameFile = customFunctionsTargetPath && path.resolve(mainFilePath) === path.resolve(path.join(inlineMappingsResult.tempDir, customFunctionsFileName)); - let codeRepairResult: CodeRepairResult; - const customContent = await getCustomFunctionsContent(inlineMappingsResult.allMappingsRequest.customFunctionsFilePath); eventHandler({ type: "content_block", content: "\nRepairing generated code..." }); - if (isSameFile) { - const mainContent = fs.readFileSync(mainFilePath, 'utf8'); + const variableName = inlineMappingRequest.metadata.name || inlineMappingsResult.tempFileMetadata.name; - if (customContent) { - // Merge: main content + custom functions - const mergedContent = `${mainContent}\n\n${customContent}`; - fs.writeFileSync(mainFilePath, mergedContent, 'utf8'); - } - - codeRepairResult = await repairCodeAndGetUpdatedContent({ - tempFileMetadata: inlineMappingsResult.tempFileMetadata, - customFunctionsFilePath: undefined, - imports: uniqueImportStatements, - tempDir: inlineMappingsResult.tempDir - }, langClient, projectRoot); - - codeRepairResult.customFunctionsContent = ''; - } else { - // Files are different, repair them separately - codeRepairResult = await repairCodeAndGetUpdatedContent({ - tempFileMetadata: inlineMappingsResult.tempFileMetadata, - customFunctionsFilePath: inlineMappingsResult.allMappingsRequest.customFunctionsFilePath, - tempDir: inlineMappingsResult.tempDir - }, langClient, projectRoot); - } + // Get DM model with diagnostics + const dmModelResult = await getInlineDMModelWithDiagnostics( + langClient, + mainFilePath, + variableName, + inlineMappingsResult.allMappingsRequest.codedata + ); + + // Repair inline mappings using LLM based on DM model diagnostics + await repairMappingsWithLLM( + langClient, + dmModelResult, + uniqueImportStatements + ); + + // Repair check expression errors (BCE3032) + await repairCheckErrors( + langClient, + projectRoot, + mainFilePath, + inlineMappingsResult.allMappingsRequest, + inlineMappingsResult.tempDir, + isSameFile + ); // For workspace projects, compute relative file path from workspace root let targetFilePath = path.relative(projectRoot, context.documentUri); @@ -779,33 +944,18 @@ export async function generateInlineMappingCodeCore(inlineMappingRequest: Metada targetFilePath = path.relative(workspacePath, context.documentUri); } - const variableName = inlineMappingRequest.metadata.name || inlineMappingsResult.tempFileMetadata.name; - - // Handle check expression errors and repair diagnostics for inline mappings - const { inlineFilePaths, updatedCodeToDisplay } = await handleInlineCheckExpressionErrorsAndRepair( - langClient, - projectRoot, - inlineMappingsResult, - isSameFile, - codeRepairResult, - variableName - ); - - if (updatedCodeToDisplay) { - codeToDisplay = updatedCodeToDisplay; - } - - // Remove compilation error mappings for inline mappings - const { updatedMainContent, updatedCustomContent } = await removeInlineCompilationErrorMappingFields( + // Remove compilation error mappings for inline mappings FIRST + await removeInlineCompilationErrorMappingFields( langClient, - projectRoot, mainFilePath, variableName, inlineMappingsResult, - inlineFilePaths, - isSameFile ); + // Read updated content after removing compilation errors + const finalContent = fs.readFileSync(mainFilePath, 'utf8'); + + // Extract code to display if (variableName) { const extractedVariableDefinition = await extractVariableDefinitionSource( mainFilePath, @@ -816,12 +966,16 @@ export async function generateInlineMappingCodeCore(inlineMappingRequest: Metada codeToDisplay = extractedVariableDefinition; } } + let customFunctionsContent = ''; + if (inlineMappingsResult.allMappingsRequest.customFunctionsFilePath && !isSameFile) { + customFunctionsContent = fs.readFileSync(inlineMappingsResult.allMappingsRequest.customFunctionsFilePath, 'utf8'); + } const generatedSourceFiles = buildMappingFileArray( context.documentUri, - updatedMainContent, + finalContent, customFunctionsTargetPath, - updatedCustomContent, + customFunctionsContent, ); // Build assistant response @@ -839,13 +993,13 @@ export async function generateInlineMappingCodeCore(inlineMappingRequest: Metada } if (isSameFile) { - const mergedCodeDisplay = customContent ? `${codeToDisplay}\n${customContent}` : codeToDisplay; - assistantResponse += `\n\`\`\`ballerina\n${mergedCodeDisplay}\n\`\`\`\n`; + // For same file, custom content is already merged in the code to display + assistantResponse += `\n\`\`\`ballerina\n${codeToDisplay}\n\`\`\`\n`; } else { assistantResponse += `\n\`\`\`ballerina\n${codeToDisplay}\n\`\`\`\n`; - if (updatedCustomContent) { - assistantResponse += `\n\`\`\`ballerina\n${updatedCustomContent}\n\`\`\`\n`; + if (customFunctionsContent) { + assistantResponse += `\n\`\`\`ballerina\n${customFunctionsContent}\n\`\`\`\n`; } } @@ -854,6 +1008,30 @@ export async function generateInlineMappingCodeCore(inlineMappingRequest: Metada eventHandler({ type: "stop", command: Command.DataMap }); } +// Removes mapping fields with compilation errors for inline mappings +async function removeInlineCompilationErrorMappingFields( + langClient: any, + mainFilePath: string, + variableName: string, + inlineMappingsResult: InlineMappingsSourceResult +): Promise { + // Get DM model with diagnostics for inline variable + const dmModelResult = await getInlineDMModelWithDiagnostics( + langClient, + mainFilePath, + variableName, + inlineMappingsResult.allMappingsRequest.codedata + ); + + // Use the to remove mappings with errors + await removeMappingsWithErrors( + langClient, + mainFilePath, + dmModelResult, + inlineMappingsResult.allMappingsRequest.varName + ); +} + // Main public function that uses the default event handler for inline mapping generation export async function generateInlineMappingCode(inlineMappingRequest: MetadataWithAttachments): Promise { const eventHandler = createWebviewEventHandler(Command.DataMap); @@ -908,7 +1086,7 @@ export async function generateContextTypesCore(typeCreationRequest: ProcessConte const sourceAttachmentNames = typeCreationRequest.attachments.map(a => a.name).join(", "); const fileText = typeCreationRequest.attachments.length === 1 ? "file" : "files"; assistantResponse = `Types generated from the ${sourceAttachmentNames} ${fileText} shown below.\n`; - assistantResponse += `\n\`\`\`ballerina\n${typesCode}\n\`\`\`\n`; + assistantResponse += `\n\`\`\`ballerina\n${typesCode}\n\`\`\`\n`; // Send assistant response through event handler eventHandler({ type: "content_block", content: assistantResponse }); @@ -953,261 +1131,27 @@ export async function openChatWindowWithCommand(): Promise { }); } -// Removes mapping fields with compilation errors for inline mappings and reads updated content -async function removeInlineCompilationErrorMappingFields( - langClient: any, - projectRoot: string, - mainFilePath: string, - variableName: string, - inlineMappingsResult: InlineMappingsSourceResult, - inlineFilePaths: string[], - isSameFile: boolean -): Promise<{ updatedMainContent: string; updatedCustomContent: string }> { - // For inline mappings, we use the variable's location from the codedata - const updatedDataMapperMetadata: DataMapperModelRequest = { - filePath: mainFilePath, - codedata: inlineMappingsResult.allMappingsRequest.codedata, - targetField: variableName, - position: inlineMappingsResult.allMappingsRequest.codedata.lineRange.startLine - }; - - // Get DM model with mappings to check for mapping-level diagnostics - const dataMapperModel = await langClient.getDataMapperMappings(updatedDataMapperMetadata) as DataMapperModelResponse; - const dmModel = dataMapperModel.mappingsModel as DMModel; - - // Check if any mappings have diagnostics - if (dmModel && dmModel.mappings && dmModel.mappings.length > 0) { - const mappingsWithDiagnostics = dmModel.mappings.filter((mapping: Mapping) => - mapping.diagnostics && mapping.diagnostics.length > 0 - ); - - if (mappingsWithDiagnostics.length > 0) { - // Delete each mapping with diagnostics using the deleteMapping API - for (const mapping of mappingsWithDiagnostics) { - const deleteRequest: DeleteMappingRequest = { - filePath: mainFilePath, - codedata: updatedDataMapperMetadata.codedata, - mapping: mapping, - varName: inlineMappingsResult.allMappingsRequest.varName, - targetField: variableName, - }; - - const deleteResponse = await langClient.deleteMapping(deleteRequest); - - // Apply the text edits from the delete operation directly to temp files - if (Object.keys(deleteResponse.textEdits).length > 0) { - await applyTextEditsToTempFile(deleteResponse.textEdits, mainFilePath); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - - await repairAndCheckDiagnostics(langClient, projectRoot, { - tempDir: inlineMappingsResult.tempDir, - filePaths: inlineFilePaths - }); - } - } - - // Read updated content after diagnostics handling - const updatedMainContent = fs.readFileSync(mainFilePath, 'utf8'); - let updatedCustomContent = ''; - if (inlineMappingsResult.allMappingsRequest.customFunctionsFilePath && !isSameFile) { - updatedCustomContent = fs.readFileSync(inlineMappingsResult.allMappingsRequest.customFunctionsFilePath, 'utf8'); - } - - return { updatedMainContent, updatedCustomContent }; -} - -// Handles check expression errors (BCE3032) and repairs diagnostics for inline mapping files -async function handleInlineCheckExpressionErrorsAndRepair( - langClient: any, - projectRoot: string, - inlineMappingsResult: InlineMappingsSourceResult, - isSameFile: boolean, - codeRepairResult: CodeRepairResult, - variableName: string -): Promise<{ inlineFilePaths: string[]; updatedCodeToDisplay?: string }> { - // Build file paths array for both main file and custom functions file - const inlineFilePaths = [inlineMappingsResult.tempFileMetadata.codeData.lineRange.fileName]; - if (inlineMappingsResult.allMappingsRequest.customFunctionsFilePath && !isSameFile) { - inlineFilePaths.push(inlineMappingsResult.allMappingsRequest.customFunctionsFilePath); - } - - // Repair and check diagnostics for all files - let diags = await repairAndCheckDiagnostics(langClient, projectRoot, { - tempDir: inlineMappingsResult.tempDir, - filePaths: inlineFilePaths - }); - - // Check for inline mappings with 'check' expressions (BCE3032 error) - const hasCheckError = diags.diagnosticsList.some(diagEntry => - diagEntry.diagnostics.some(d => d.code === "BCE3032") - ); - - let updatedCodeToDisplay: string; - - if (hasCheckError) { - const isModified = await addCheckExpressionErrors(diags.diagnosticsList, langClient); - if (isModified) { - // Re-read the files after modifications - const tempFilePath = inlineMappingsResult.tempFileMetadata.codeData.lineRange.fileName; - codeRepairResult.finalContent = fs.readFileSync(tempFilePath, 'utf8'); - - // Update the code to display if we're working with a variable - if (variableName) { - const extractedVariableDefinition = await extractVariableDefinitionSource( - tempFilePath, - inlineMappingsResult.tempFileMetadata.codeData, - variableName - ); - if (extractedVariableDefinition) { - updatedCodeToDisplay = extractedVariableDefinition; - } - } - - if (inlineMappingsResult.allMappingsRequest.customFunctionsFilePath && !isSameFile) { - codeRepairResult.customFunctionsContent = fs.readFileSync( - inlineMappingsResult.allMappingsRequest.customFunctionsFilePath, - 'utf8' - ); - } - } - } - - return { inlineFilePaths, updatedCodeToDisplay }; -} - -// Handles check expression errors (BCE3032) and repairs diagnostics for mapping files -async function handleCheckExpressionErrorsAndRepair( - langClient: any, - projectRoot: string, - tempFileMetadata: any, - allMappingsRequest: AllDataMapperSourceRequest, - tempDirectory: string, - isSameFile: boolean, - codeRepairResult: CodeRepairResult -): Promise { - // Build file paths array for both main file and custom functions file - const filePaths = [tempFileMetadata.codeData.lineRange.fileName]; - if (allMappingsRequest.customFunctionsFilePath && !isSameFile) { - filePaths.push(allMappingsRequest.customFunctionsFilePath); - } - - // Repair and check diagnostics for all files - let diags = await repairAndCheckDiagnostics(langClient, projectRoot, { - tempDir: tempDirectory, - filePaths - }); - - // Check for mappings with 'check' expressions (BCE3032 error) - const hasCheckError = diags.diagnosticsList.some((diagEntry) => - diagEntry.diagnostics.some(d => d.code === "BCE3032") - ); - - if (hasCheckError) { - const isModified = await addCheckExpressionErrors(diags.diagnosticsList, langClient); - if (isModified) { - // Re-read the files after modifications - const mainFilePath = tempFileMetadata.codeData.lineRange.fileName; - codeRepairResult.finalContent = fs.readFileSync(mainFilePath, 'utf8'); - - if (allMappingsRequest.customFunctionsFilePath && !isSameFile) { - codeRepairResult.customFunctionsContent = fs.readFileSync( - allMappingsRequest.customFunctionsFilePath, - 'utf8' - ); - } - } - } - - return filePaths; -} - -// Removes mapping fields with compilation errors to avoid syntax errors in generated code and reads updated content +// Removes mapping fields with compilation errors to avoid syntax errors in generated code async function removeCompilationErrorMappingFields( langClient: any, - projectRoot: string, mainFilePath: string, targetFunctionName: string, allMappingsRequest: AllDataMapperSourceRequest, - tempDirectory: string, - filePaths: string[], - isSameFile: boolean -): Promise<{ updatedMainContent: string; updatedCustomContent: string }> { - // Get function definition from syntax tree - const funcDefinitionNode = await getFunctionDefinitionFromSyntaxTree( +): Promise { + // Get DM model with diagnostics + const dmModelResult = await getDMModel( langClient, mainFilePath, targetFunctionName ); - const updatedDataMapperMetadata: DataMapperModelRequest = { - filePath: mainFilePath, - codedata: { - lineRange: { - fileName: mainFilePath, - startLine: { - line: funcDefinitionNode.position.startLine, - offset: funcDefinitionNode.position.startColumn, - }, - endLine: { - line: funcDefinitionNode.position.endLine, - offset: funcDefinitionNode.position.endColumn, - }, - }, - }, - targetField: targetFunctionName, - position: { - line: funcDefinitionNode.position.startLine, - offset: funcDefinitionNode.position.startColumn - } - }; - - // Get DM model with mappings to check for mapping-level diagnostics - const dataMapperModel = await langClient.getDataMapperMappings(updatedDataMapperMetadata) as DataMapperModelResponse; - const dmModel = dataMapperModel.mappingsModel as DMModel; - - // Check if any mappings have diagnostics - if (dmModel && dmModel.mappings && dmModel.mappings.length > 0) { - const mappingsWithDiagnostics = dmModel.mappings.filter((mapping: Mapping) => - mapping.diagnostics && mapping.diagnostics.length > 0 - ); - - if (mappingsWithDiagnostics.length > 0) { - // Delete each mapping with diagnostics using the deleteMapping API - for (const mapping of mappingsWithDiagnostics) { - const deleteRequest: DeleteMappingRequest = { - filePath: mainFilePath, - codedata: updatedDataMapperMetadata.codedata, - mapping: mapping, - varName: allMappingsRequest.varName, - targetField: targetFunctionName, - }; - - const deleteResponse = await langClient.deleteMapping(deleteRequest); - - // Apply the text edits from the delete operation directly to temp files - if (Object.keys(deleteResponse.textEdits).length > 0) { - await applyTextEditsToTempFile(deleteResponse.textEdits, mainFilePath); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - - await repairAndCheckDiagnostics(langClient, projectRoot, { - tempDir: tempDirectory, - filePaths: filePaths - }); - } - } - - // Read updated content after diagnostics handling - const updatedMainContent = fs.readFileSync(mainFilePath, 'utf8'); - let updatedCustomContent = ''; - if (allMappingsRequest.customFunctionsFilePath && !isSameFile) { - updatedCustomContent = fs.readFileSync(allMappingsRequest.customFunctionsFilePath, 'utf8'); - } - - return { updatedMainContent, updatedCustomContent }; + // Use function to remove mappings with compilation errors + await removeMappingsWithErrors( + langClient, + mainFilePath, + dmModelResult, + allMappingsRequest.varName + ); } // Applies text edits to a temporary file without using VS Code workspace APIs diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/schema.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/schema.ts index cc31e8123a9..ec44048a148 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/schema.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/schema.ts @@ -32,16 +32,16 @@ const GeneratedMappingSchema = z.object({ generatedMappings: DataMappingSchema, }); -// Schema for a single source file -const SourceFileSchema = z.object({ - filePath: z.string().min(1), - content: z.string(), +// Schema for a single repaired mapping +const RepairedMappingSchema = z.object({ + output: z.string(), + expression: z.string() }); -// Schema for the array of repaired source files -const RepairedSourceFilesSchema = z.object({ - repairedFiles: z.array(SourceFileSchema), +// Schema for the array of repaired mappings +const RepairedMappingsSchema = z.object({ + repairedMappings: z.array(RepairedMappingSchema), }); // Export the schema for reuse -export { GeneratedMappingSchema, RepairedSourceFilesSchema }; +export { GeneratedMappingSchema, RepairedMappingsSchema }; diff --git a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/types.ts b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/types.ts index cf477d48e9a..fe43c7c6718 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/types.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/ai/service/datamapper/types.ts @@ -14,7 +14,7 @@ // specific language governing permissions and limitations // under the License. -import { DataMappingRecord, EnumType, IORoot, Mapping, RecordType, SourceFile } from "@wso2/ballerina-core"; +import { DataMapperModelRequest, DataMappingRecord, DMModel, EnumType, IORoot, Mapping, RecordType } from "@wso2/ballerina-core"; // ============================================================================= // DATA MAPPING REQUEST/RESPONSE @@ -49,13 +49,18 @@ export interface DatamapperResponse { // DATAMAPPER CODE REPAIR // ============================================================================= -export interface RepairedFiles { - repairedFiles: SourceFile[]; +export interface RepairedMapping { + output: string; + expression: string; } -export interface CodeRepairResult { - finalContent: string; - customFunctionsContent: string; +export interface RepairedMappings { + repairedMappings: RepairedMapping[]; +} + +export interface DMModelDiagnosticsResult { + dataMapperMetadata: DataMapperModelRequest; + dmModel: DMModel; } // ============================================================================= diff --git a/workspaces/ballerina/ballerina-extension/src/features/bi/activator.ts b/workspaces/ballerina/ballerina-extension/src/features/bi/activator.ts index 30cd723aabd..ba06c56a1c7 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/bi/activator.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/bi/activator.ts @@ -41,7 +41,7 @@ import { createVersionNumber, findBallerinaPackageRoot, isSupportedSLVersion } f import { extension } from "../../BalExtensionContext"; import { VisualizerWebview } from "../../views/visualizer/webview"; import { getCurrentProjectRoot, tryGetCurrentBallerinaFile } from "../../utils/project-utils"; -import { needsProjectDiscovery, promptPackageSelection, requiresPackageSelection } from "../../utils/command-utils"; +import { selectPackageOrPrompt, needsProjectDiscovery, requiresPackageSelection } from "../../utils/command-utils"; import { findWorkspaceTypeFromWorkspaceFolders } from "../../rpc-managers/common/utils"; import { MESSAGES } from "../project"; @@ -49,66 +49,6 @@ const FOCUS_DEBUG_CONSOLE_COMMAND = 'workbench.debug.action.focusRepl'; const TRACE_SERVER_OFF = "off"; const TRACE_SERVER_VERBOSE = "verbose"; -/** - * Helper function to handle command invocation with proper context resolution. - * Supports both tree view clicks and command palette invocation. - * - * @param item - The tree item (undefined when invoked from command palette) - * @param view - The view to open - * @param additionalViewParams - Additional parameters to pass to the view - */ -async function handleCommandWithContext( - item: TreeItem | undefined, - view: MACHINE_VIEW, - additionalViewParams: Record = {} -): Promise { - const { projectInfo, projectPath, view: currentView, workspacePath } = StateMachine.context(); - const isWebviewOpen = VisualizerWebview.currentPanel !== undefined; - const hasActiveTextEditor = !!window.activeTextEditor; - - const currentBallerinaFile = tryGetCurrentBallerinaFile(); - const projectRoot = await findBallerinaPackageRoot(currentBallerinaFile); - - // Scenario 1: Multi-package workspace invoked from command palette - if (!item) { - if (requiresPackageSelection(workspacePath, currentView, projectPath, isWebviewOpen, hasActiveTextEditor)) { - await handleCommandWithPackageSelection(projectInfo, view, additionalViewParams); - return; - } - - if (needsProjectDiscovery(projectInfo, projectRoot, projectPath)) { - try { - const success = await tryHandleCommandWithDiscoveredProject(view, additionalViewParams); - if (!success) { - window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); - } - } catch { - window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); - } - return; - } - - openView(EVENT_TYPE.OPEN_VIEW, { - view, - projectPath, - ...additionalViewParams - }); - } - // Scenario 2: Invoked from tree view with item context - else if (item?.resourceUri) { - const projectPath = item.resourceUri.fsPath; - openView(EVENT_TYPE.OPEN_VIEW, { - view, - projectPath, - ...additionalViewParams - }); - } - // Scenario 3: Default - no specific context - else { - openView(EVENT_TYPE.OPEN_VIEW, { view, ...additionalViewParams }); - } -} - export function activate(context: BallerinaExtension) { const isWorkspaceSupported = isSupportedSLVersion(extension.ballerinaExtInstance, createVersionNumber(2201, 13, 0)); @@ -124,17 +64,13 @@ export function activate(context: BallerinaExtension) { const needsPackageSelection = requiresPackageSelection( workspacePath, view, projectPath, isWebviewOpen, hasActiveTextEditor ); - - if (needsPackageSelection && projectInfo?.children.length === 0) { - window.showErrorMessage("No packages found in the workspace."); - return; - } + prepareAndGenerateConfig(context, projectPath, false, true, true, needsPackageSelection); }); commands.registerCommand(BI_COMMANDS.BI_DEBUG_PROJECT, () => { commands.executeCommand(FOCUS_DEBUG_CONSOLE_COMMAND); - startDebugging(Uri.file(StateMachine.context().projectPath), false, true); + handleDebugCommandWithContext(); }); commands.registerCommand(BI_COMMANDS.ADD_CONNECTIONS, async (item?: TreeItem) => { @@ -233,6 +169,127 @@ export function activate(context: BallerinaExtension) { } +/** + * Helper function to handle command invocation with proper context resolution. + * Supports both tree view clicks and command palette invocation. + * + * @param item - The tree item (undefined when invoked from command palette) + * @param view - The view to open + * @param additionalViewParams - Additional parameters to pass to the view + */ +async function handleCommandWithContext( + item: TreeItem | undefined, + view: MACHINE_VIEW, + additionalViewParams: Record = {} +): Promise { + const { projectInfo, projectPath, view: currentView, workspacePath } = StateMachine.context(); + const isWebviewOpen = VisualizerWebview.currentPanel !== undefined; + const hasActiveTextEditor = !!window.activeTextEditor; + + const currentBallerinaFile = tryGetCurrentBallerinaFile(); + const projectRoot = await findBallerinaPackageRoot(currentBallerinaFile); + + // Scenario 1: Multi-package workspace invoked from command palette + if (!item) { + if (requiresPackageSelection(workspacePath, currentView, projectPath, isWebviewOpen, hasActiveTextEditor)) { + await handleCommandWithPackageSelection(projectInfo, view, additionalViewParams); + return; + } + + if (needsProjectDiscovery(projectInfo, projectRoot, projectPath)) { + try { + const success = await tryHandleCommandWithDiscoveredProject(view, additionalViewParams); + if (!success) { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + } + } catch { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + } + return; + } + + openView(EVENT_TYPE.OPEN_VIEW, { + view, + projectPath, + ...additionalViewParams + }); + } + // Scenario 2: Invoked from tree view with item context + else if (item?.resourceUri) { + const projectPath = item.resourceUri.fsPath; + openView(EVENT_TYPE.OPEN_VIEW, { + view, + projectPath, + ...additionalViewParams + }); + } + // Scenario 3: Default - no specific context + else { + openView(EVENT_TYPE.OPEN_VIEW, { view, ...additionalViewParams }); + } +} + +/** Handles the debug command based on current workspace context. */ +async function handleDebugCommandWithContext() { + const { workspacePath, view, projectPath, projectInfo } = StateMachine.context(); + const isWebviewOpen = VisualizerWebview.currentPanel !== undefined; + const hasActiveTextEditor = !!window.activeTextEditor; + + const currentBallerinaFile = tryGetCurrentBallerinaFile(); + const projectRoot = await findBallerinaPackageRoot(currentBallerinaFile); + + if (requiresPackageSelection(workspacePath, view, projectPath, isWebviewOpen, hasActiveTextEditor)) { + await handleDebugCommandWithPackageSelection(projectInfo); + return; + } + + if (needsProjectDiscovery(projectInfo, projectRoot, projectPath)) { + try { + await handleDebugCommandWithProjectDiscovery(); + } catch { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + } + return; + } + + if (!projectPath) { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + return; + } + + startDebugging(Uri.file(projectPath), false, true); +} + +/** + * Prompts user to select a package and starts debugging. + * @param projectInfo - The project info + * @returns void + */ +async function handleDebugCommandWithPackageSelection(projectInfo: ProjectInfo) { + const availablePackages = projectInfo?.children.map((child: ProjectInfo) => child.projectPath) ?? []; + + const selectedPackage = await selectPackageOrPrompt(availablePackages, "Select a package to debug"); + if (!selectedPackage) { + return; + } + + await StateMachine.updateProjectRootAndInfo(selectedPackage, projectInfo); + startDebugging(Uri.file(selectedPackage), false, true); +} + +/** Discovers project root from active file and starts debugging. */ +async function handleDebugCommandWithProjectDiscovery() { + const packageRoot = await getCurrentProjectRoot(); + + if (packageRoot) { + const projectInfo = await StateMachine.langClient().getProjectInfo({ projectPath: packageRoot }); + await StateMachine.updateProjectRootAndInfo(packageRoot, projectInfo); + startDebugging(Uri.file(packageRoot), false, true); + } else { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + } +} + function openBallerinaTomlFile(context: BallerinaExtension) { const projectPath = StateMachine.context().projectPath || StateMachine.context().workspacePath; if (!projectPath) { @@ -473,15 +530,10 @@ async function handleCommandWithPackageSelection( ): Promise { const availablePackages = projectInfo?.children.map((child: any) => child.projectPath) ?? []; - if (availablePackages.length === 0) { - window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); - return false; - } - - const selectedPackage = await promptPackageSelection(availablePackages); + const selectedPackage = await selectPackageOrPrompt(availablePackages); if (!selectedPackage) { - return false; // User cancelled + return false; } openView(EVENT_TYPE.OPEN_VIEW, { diff --git a/workspaces/ballerina/ballerina-extension/src/features/config-generator/configGenerator.ts b/workspaces/ballerina/ballerina-extension/src/features/config-generator/configGenerator.ts index 4a21bf27d09..5b55eceaffa 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/config-generator/configGenerator.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/config-generator/configGenerator.ts @@ -32,7 +32,7 @@ import { openView, StateMachine } from "../../stateMachine"; import * as path from "path"; import { TracerMachine } from "../tracing"; import { VisualizerWebview } from "../../views/visualizer/webview"; -import { promptPackageSelection } from "../../utils/command-utils"; +import { selectPackageOrPrompt } from "../../utils/command-utils"; const UNUSED_IMPORT_ERR_CODE = "BCE2002"; @@ -53,9 +53,7 @@ export async function prepareAndGenerateConfig( const packages = StateMachine.context().projectInfo?.children; const packageList = packages?.map((child) => child.projectPath) ?? []; - const selectedPackage = await promptPackageSelection(packageList, "Select a package to run"); - - // User cancelled selection + const selectedPackage = await selectPackageOrPrompt(packageList, "Select a package to run"); if (!selectedPackage) { return; } diff --git a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/add.ts b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/add.ts index 968f564e229..202876e26e9 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/add.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/add.ts @@ -18,12 +18,18 @@ import { LANGUAGE } from "../../../core"; import { extension } from "../../../BalExtensionContext"; -import { commands, window } from "vscode"; +import { commands, QuickPickItem, window } from "vscode"; import { TM_EVENT_PROJECT_ADD, TM_EVENT_ERROR_EXECUTE_PROJECT_ADD, CMP_PROJECT_ADD, sendTelemetryEvent, sendTelemetryException, getMessageObject } from "../../telemetry"; import { runCommand, BALLERINA_COMMANDS, MESSAGES, PROJECT_TYPE, PALETTE_COMMANDS } from "./cmd-runner"; -import { getCurrentBallerinaProject } from "../../../utils/project-utils"; +import { + getCurrentBallerinaProject, + getCurrentProjectRoot +} from "../../../utils/project-utils"; +import { MACHINE_VIEW, ProjectInfo } from "@wso2/ballerina-core"; +import { StateMachine } from "../../../stateMachine"; +import { selectPackageOrPrompt } from "../../../utils/command-utils"; function activateAddCommand() { // register ballerina add handler @@ -36,9 +42,38 @@ function activateAddCommand() { return; } - const currentProject = extension.ballerinaExtInstance.getDocumentContext().isActiveDiagram() ? await - getCurrentBallerinaProject(extension.ballerinaExtInstance.getDocumentContext().getLatestDocument()?.toString()) - : await getCurrentBallerinaProject(); + const context = StateMachine.context(); + const { workspacePath, view: webviewType, projectPath, projectInfo } = context; + + let targetPath = projectPath ?? ""; + if (workspacePath && webviewType === MACHINE_VIEW.WorkspaceOverview) { + const selection = await getPackage(projectInfo); + if (!selection) { + return; + } + targetPath = selection; + + } else if (workspacePath && !projectPath) { + try { + targetPath = await getCurrentProjectRoot(); + } catch (error){ + const selection = await getPackage(projectInfo); + if (!selection) { + return; + } + targetPath = selection; + } + } else { + targetPath = await getCurrentProjectRoot(); + } + + if (!targetPath) { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + return; + } + + const currentProject = await getCurrentBallerinaProject(targetPath); + if (currentProject.kind === PROJECT_TYPE.SINGLE_FILE || !currentProject.path) { sendTelemetryEvent(extension.ballerinaExtInstance, TM_EVENT_ERROR_EXECUTE_PROJECT_ADD, CMP_PROJECT_ADD, getMessageObject(MESSAGES.NOT_IN_PROJECT)); @@ -64,3 +99,15 @@ function activateAddCommand() { } export { activateAddCommand }; + +// Prompts user to select a package +async function getPackage(projectInfo: ProjectInfo): Promise { + const packages = projectInfo?.children.map((child) => child.projectPath) ?? []; + + const selectedPackage = await selectPackageOrPrompt(packages, "Select a package to add the module to"); + if (!selectedPackage) { + return undefined; + } + + return selectedPackage; +} diff --git a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/cmd-runner.ts b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/cmd-runner.ts index 8d498a2c258..4adf71b06ee 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/cmd-runner.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/cmd-runner.ts @@ -66,7 +66,7 @@ export const FOCUS_DEBUG_CONSOLE_COMMAND = 'workbench.debug.action.focusRepl'; export enum BALLERINA_COMMANDS { TEST = "test", BUILD = "build", FORMAT = "format", RUN = "run", RUN_WITH_WATCH = "run --watch", DOC = "doc", ADD = "add", OTHER = "other", PACK = "pack", RUN_WITH_EXPERIMENTAL = "run --experimental", - BUILD_WITH_EXPERIMENTAL = "build --experimental", + BUILD_WITH_EXPERIMENTAL = "build --experimental", PACK_WITH_EXPERIMENTAL = "pack --experimental", } export enum PROJECT_TYPE { @@ -207,3 +207,4 @@ export function getRunCommand(): BALLERINA_COMMANDS { } return BALLERINA_COMMANDS.RUN; } + diff --git a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/pack.ts b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/pack.ts index 89d55cca0f1..e7b7e719c36 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/project/cmds/pack.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/project/cmds/pack.ts @@ -23,12 +23,15 @@ import { } from "../../telemetry"; import { runCommand, BALLERINA_COMMANDS, PROJECT_TYPE, PALETTE_COMMANDS, MESSAGES } from "./cmd-runner"; -import { getCurrentBallerinaProject } +import { getCurrentBallerinaProject, getCurrentProjectRoot } from "../../../utils/project-utils"; import { LANGUAGE } from "../../../core"; +import { StateMachine } from "../../../stateMachine"; +import { MACHINE_VIEW } from "@wso2/ballerina-core"; +import { createVersionNumber, isSupportedSLVersion } from "../../../utils"; export function activatePackCommand() { - // register run project build handler + // register run project pack handler commands.registerCommand(PALETTE_COMMANDS.PACK, async () => { try { sendTelemetryEvent(extension.ballerinaExtInstance, TM_EVENT_PROJECT_PACK, CMP_PROJECT_PACK); @@ -38,11 +41,38 @@ export function activatePackCommand() { return; } - const currentProject = extension.ballerinaExtInstance.getDocumentContext().isActiveDiagram() ? await - getCurrentBallerinaProject(extension.ballerinaExtInstance.getDocumentContext().getLatestDocument()?.toString()) - : await getCurrentBallerinaProject(); + const context = StateMachine.context(); + const { workspacePath, view: webviewType, projectPath } = context; + + let targetPath = projectPath ?? ""; + + if (workspacePath && webviewType === MACHINE_VIEW.WorkspaceOverview) { + targetPath = workspacePath; + } else if (workspacePath && !projectPath) { + try { + targetPath = await getCurrentProjectRoot(); + } catch (error) { + targetPath = workspacePath; + } + } else { + targetPath = await getCurrentProjectRoot(); + } + + if (!targetPath) { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + return; + } + + const currentProject = await getCurrentBallerinaProject(targetPath); + + let balCommand = BALLERINA_COMMANDS.PACK; + + if (isSupportedSLVersion(extension.ballerinaExtInstance, createVersionNumber(2201, 13, 0)) && extension.ballerinaExtInstance.enabledExperimentalFeatures()) { + balCommand = BALLERINA_COMMANDS.PACK_WITH_EXPERIMENTAL; + } + if (currentProject.kind !== PROJECT_TYPE.SINGLE_FILE) { - runCommand(currentProject, extension.ballerinaExtInstance.getBallerinaCmd(), BALLERINA_COMMANDS.PACK, + runCommand(currentProject, extension.ballerinaExtInstance.getBallerinaCmd(), balCommand, currentProject.path!); } else { window.showErrorMessage(MESSAGES.INVALID_PACK); @@ -58,3 +88,4 @@ export function activatePackCommand() { } }); } + diff --git a/workspaces/ballerina/ballerina-extension/src/features/tryit/activator.ts b/workspaces/ballerina/ballerina-extension/src/features/tryit/activator.ts index 0f740e5a862..0dbd81a42cd 100644 --- a/workspaces/ballerina/ballerina-extension/src/features/tryit/activator.ts +++ b/workspaces/ballerina/ballerina-extension/src/features/tryit/activator.ts @@ -17,20 +17,22 @@ */ import { commands, window, workspace, FileSystemWatcher, Disposable, Uri } from "vscode"; -import { clearTerminal, PALETTE_COMMANDS } from "../project/cmds/cmd-runner"; +import { clearTerminal, MESSAGES, PALETTE_COMMANDS } from "../project/cmds/cmd-runner"; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { BallerinaExtension } from "src/core"; import Handlebars from "handlebars"; import { clientManager, findRunningBallerinaProcesses, handleError, HTTPYAC_CONFIG_TEMPLATE, TRYIT_TEMPLATE, waitForBallerinaService } from "./utils"; -import { BIDesignModelResponse, OpenAPISpec } from "@wso2/ballerina-core"; +import { BIDesignModelResponse, EVENT_TYPE, MACHINE_VIEW, OpenAPISpec, ProjectInfo } from "@wso2/ballerina-core"; import { getProjectWorkingDirectory } from "../../utils/file-utils"; import { startDebugging } from "../editor-support/activator"; import { v4 as uuidv4 } from "uuid"; import { createGraphqlView } from "../../views/graphql"; -import { StateMachine } from "../../stateMachine"; +import { openView, StateMachine } from "../../stateMachine"; import { getCurrentProjectRoot } from "../../utils/project-utils"; +import { requiresPackageSelection, selectPackageOrPrompt } from "../../utils/command-utils"; +import { VisualizerWebview } from "../../views/visualizer/webview"; // File constants const FILE_NAMES = { @@ -68,33 +70,14 @@ async function openTryItView(withNotice: boolean = false, resourceMetadata?: Res throw new Error('Ballerina Language Server is not connected'); } - const currentProjectRoot = await getCurrentProjectRoot(); - if (!currentProjectRoot) { - throw new Error('Please open a workspace first'); - } - - // If currentProjectRoot is a file (single file project), use its directory - // Otherwise, use the current project root - let projectPath: string; - try { - projectPath = getProjectWorkingDirectory(currentProjectRoot); - } catch (error) { - throw new Error(`Failed to determine working directory`); - } - - let services: ServiceInfo[] | null = await getAvailableServices(projectPath); - - // if the getDesignModel() LS API is unavailable, create a ServiceInfo from ServiceMetadata to support Try It functionality. (a fallback logic for Ballerina versions prior to 2201.12.x) - if (services == null && serviceMetadata && filePath) { - const service = createServiceInfoFromMetadata(serviceMetadata, projectPath, filePath); - services = [service]; - } - - if (!services || services.length === 0) { - vscode.window.showInformationMessage('No services found in the project'); + const projectAndServices = await getProjectPathAndServices(serviceMetadata, filePath); + + if (!projectAndServices) { return; } + const { projectPath, services } = projectAndServices; + if (withNotice) { const selection = await vscode.window.showInformationMessage( `${services.length} service${services.length === 1 ? '' : 's'} found in the integration. Test with Try It Client?`, @@ -303,7 +286,6 @@ async function findServiceForResource(services: ServiceInfo[], resourceMetadata: async function getAvailableServices(projectDir: string): Promise { try { - // const langClient = clientManager.getClient(); const langClient = StateMachine.langClient(); const response: BIDesignModelResponse = await langClient.getDesignModel({ @@ -566,13 +548,30 @@ async function checkBallerinaProcessRunning(projectDir: string): Promise 0) { + const project = projectInfo.children.find((child: ProjectInfo) => child.projectPath === projectDir); + projectName = project?.title || project?.name || ''; + } + const selection = await vscode.window.showWarningMessage( - 'The "Try It" feature requires a running Ballerina service. Would you like to run the integration first?', + `The "Try It" feature requires a running Ballerina service. + Would you like to run ${projectName ? `'${projectName}'` : 'the integration'} now?`, 'Run Integration', 'Cancel' ); if (selection === 'Run Integration') { + const isWebviewOpen = VisualizerWebview.currentPanel !== undefined; + const needsPackageSelection = requiresPackageSelection(workspacePath, webviewType, projectDir, isWebviewOpen, false); + + // Open the package overview view if the command is executed from workspace overview + if (isWebviewOpen && needsPackageSelection) { + openView(EVENT_TYPE.OPEN_VIEW, { view: MACHINE_VIEW.PackageOverview, projectPath: projectDir }); + } + // Execute the run command clearTerminal(); await startDebugging(Uri.file(projectDir), false, false, true); @@ -1121,3 +1120,93 @@ function createServiceInfoFromMetadata(serviceMetadata: ServiceMetadata, workspa } }; } + +async function getProjectRoots(): Promise { + const context = StateMachine.context(); + const { workspacePath, view: webviewType, projectPath, projectInfo } = context; + const isWebviewOpen = VisualizerWebview.currentPanel !== undefined; + const hasActiveTextEditor = !!window.activeTextEditor; + + if (requiresPackageSelection(workspacePath, webviewType, projectPath, isWebviewOpen, hasActiveTextEditor)) { + return projectInfo?.children.map((child: any) => child.projectPath) ?? []; + } + + const currentRoot = await getCurrentProjectRoot(); + return currentRoot ? [currentRoot] : []; +} + +async function getProjectPathAndServices( + serviceMetadata?: ServiceMetadata, + filePath?: string +): Promise<{ projectPath: string, services: ServiceInfo[] } | undefined> { + const currentProjectRoots = await getProjectRoots(); + if (!currentProjectRoots || currentProjectRoots.length === 0) { + throw new Error(MESSAGES.NO_PROJECT_FOUND); + } + + let projectPath: string; + const serviceInfos: Record = {}; + + if (currentProjectRoots.length === 1) { + // If currentProjectRoot is a file (single file project), use its directory + // Otherwise, use the current project root + try { + const root = currentProjectRoots[0]; + projectPath = getProjectWorkingDirectory(root); + const services = await getServiceInfo(projectPath, serviceMetadata, filePath); + if (!services || services.length === 0) { + vscode.window.showInformationMessage('No services found in the integration'); + return; + } + serviceInfos[projectPath] = services; + } catch (error) { + throw new Error(`Failed to determine working directory`); + } + } else { + for (const projectRoot of currentProjectRoots) { + const services = await getServiceInfo(projectRoot, serviceMetadata, filePath); + if (services && services.length > 0) { + serviceInfos[projectRoot] = services; + } + } + + if (Object.keys(serviceInfos).length === 0) { + vscode.window.showInformationMessage('None of the integrations contain services'); + return; + } + if (Object.keys(serviceInfos).length === 1) { + projectPath = Object.keys(serviceInfos)[0]; + } + if (Object.keys(serviceInfos).length > 1) { + const selectedProjectRoot = await selectPackageOrPrompt( + Object.keys(serviceInfos), + "Multiple integrations contain services. Please select one." + ); + if (!selectedProjectRoot) { + return; + } + + await StateMachine.updateProjectRootAndInfo(selectedProjectRoot, StateMachine.context().projectInfo); + projectPath = selectedProjectRoot; + } + } + + return { projectPath: projectPath, services: serviceInfos[projectPath] }; +} + +async function getServiceInfo( + projectPath: string, + serviceMetadata?: ServiceMetadata, + filePath?: string +): Promise { + let services: ServiceInfo[] | null = await getAvailableServices(projectPath); + + // if the getDesignModel() LS API is unavailable, create a ServiceInfo from ServiceMetadata + // to support Try It functionality. (a fallback logic for Ballerina versions prior to 2201.12.x) + if (services == null && serviceMetadata && filePath) { + const service = createServiceInfoFromMetadata(serviceMetadata, projectPath, filePath); + services = [service]; + } + + return services; +} diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-handler.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-handler.ts index 139e5aeb5ea..16dabff26cc 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-handler.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-handler.ts @@ -23,8 +23,6 @@ import { addChatSummary, addFilesToProject, AddFilesToProjectRequest, - addToProject, - AddToProjectRequest, AIChatSummary, applyDoOnFailBlocks, checkSyntaxError, @@ -110,7 +108,6 @@ export function registerAiPanelRpcHandlers(messenger: Messenger) { messenger.onRequest(getDefaultPrompt, () => rpcManger.getDefaultPrompt()); messenger.onRequest(getAIMachineSnapshot, () => rpcManger.getAIMachineSnapshot()); messenger.onRequest(fetchData, (args: FetchDataRequest) => rpcManger.fetchData(args)); - messenger.onRequest(addToProject, (args: AddToProjectRequest) => rpcManger.addToProject(args)); messenger.onRequest(getFromFile, (args: GetFromFileRequest) => rpcManger.getFromFile(args)); messenger.onRequest(getFileExists, (args: GetFromFileRequest) => rpcManger.getFileExists(args)); messenger.onNotification(deleteFromProject, (args: DeleteFromProjectRequest) => rpcManger.deleteFromProject(args)); diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-manager.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-manager.ts index 82a5e3d5152..96f82979f3e 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-manager.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/rpc-manager.ts @@ -23,9 +23,7 @@ import { AIPanelAPI, AIPanelPrompt, AddFilesToProjectRequest, - AddToProjectRequest, BIIntelSecrets, - AllDataMapperSourceRequest, BIModuleNodesRequest, BISourceCodeResponse, DeleteFromProjectRequest, @@ -93,6 +91,7 @@ import { getAccessToken, getLoginMethod, getRefreshedAccessToken, loginGithubCop import { writeBallerinaFileDidOpen, writeBallerinaFileDidOpenTemp } from "../../utils/modification"; import { updateSourceCode } from "../../utils/source-utils"; import { refreshDataMapper } from "../data-mapper/utils"; +import { buildProjectsStructure } from "../../utils/project-artifacts"; import { DEVELOPMENT_DOCUMENT, NATURAL_PROGRAMMING_DIR_NAME, REQUIREMENT_DOC_PREFIX, @@ -197,29 +196,6 @@ export class AiPanelRpcManager implements AIPanelAPI { }; } - async addToProject(req: AddToProjectRequest): Promise { - const projectPath = StateMachine.context().projectPath; - // Check if workspaceFolderPath is a Ballerina project - // Assuming a Ballerina project must contain a 'Ballerina.toml' file - const ballerinaProjectFile = path.join(projectPath, 'Ballerina.toml'); - if (!fs.existsSync(ballerinaProjectFile)) { - throw new Error("Not a Ballerina project."); - } - - let balFilePath = path.join(projectPath, req.filePath); - - const directory = path.dirname(balFilePath); - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }); - } - - await writeBallerinaFileDidOpen(balFilePath, req.content); - updateView(); - const datamapperMetadata = StateMachine.context().dataMapperMetadata; - await refreshDataMapper(balFilePath, datamapperMetadata.codeData, datamapperMetadata.name); - return true; - } - async getFromFile(req: GetFromFileRequest): Promise { let projectPath = StateMachine.context().projectPath; const workspacePath = StateMachine.context().workspacePath; diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/utils.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/utils.ts index 2d55200a43c..2b67b5bf780 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/ai-panel/utils.ts @@ -16,14 +16,14 @@ * under the License. */ -import { Attachment, AttachmentStatus, DiagnosticEntry, DataMapperModelResponse, Mapping, FileChanges, DMModel, SourceFile, repairCodeRequest} from "@wso2/ballerina-core"; +import { Attachment, AttachmentStatus, DiagnosticEntry, DataMapperModelResponse, Mapping, FileChanges, DMModel, SourceFile, repairCodeRequest, RepairedMapping} from "@wso2/ballerina-core"; import { Position, Range, Uri, workspace, WorkspaceEdit } from 'vscode'; import path from "path"; import * as fs from 'fs'; import { AIChatError } from "./utils/errors"; import { processDataMapperInput } from "../../../src/features/ai/service/datamapper/context_api"; -import { DataMapperRequest, DataMapperResponse, FileData } from "../../../src/features/ai/service/datamapper/types"; +import { DataMapperRequest, DataMapperResponse, FileData, RepairedMappings } from "../../../src/features/ai/service/datamapper/types"; import { getAskResponse } from "../../../src/features/ai/service/ask/ask"; import { MappingFileRecord} from "./types"; import { generateAutoMappings, generateRepairCode } from "../../../src/features/ai/service/datamapper/datamapper"; @@ -201,11 +201,11 @@ export async function enrichModelWithMappingInstructions(mappingInstructionFiles }; } -// Processes a repair request and returns the repaired source files using AI -export async function repairSourceFilesWithAI(codeRepairRequest: repairCodeRequest): Promise { +// Processes a repair request and returns the repaired mappings using AI +export async function repairSourceFilesWithAI(codeRepairRequest: repairCodeRequest): Promise<{ repairedMappings: RepairedMapping[] }> { try { const repairResponse = await generateRepairCode(codeRepairRequest); - return repairResponse.repairedFiles; + return { repairedMappings: repairResponse.repairedMappings }; } catch (error) { console.error(error); throw error; diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/bi-diagram/rpc-manager.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/bi-diagram/rpc-manager.ts index 0734288b72f..511554dc277 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/bi-diagram/rpc-manager.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/bi-diagram/rpc-manager.ts @@ -1790,7 +1790,7 @@ export class BiDiagramRpcManager implements BIDiagramAPI { async getRecordNames(): Promise { const projectComponents = await this.getProjectComponents(); - // Extracting all record names + // Extracting all record names and type names const recordNames: string[] = []; if (projectComponents?.components?.packages) { @@ -1801,6 +1801,11 @@ export class BiDiagramRpcManager implements BIDiagramAPI { recordNames.push(record.name); } } + if (module.types) { + for (const type of module.types) { + recordNames.push(type.name); + } + } } } } @@ -1939,7 +1944,7 @@ export class BiDiagramRpcManager implements BIDiagramAPI { projectPath: projectPath, module: params.module }; - StateMachine.langClient().openApiGenerateClient(request).then((res) => { + StateMachine.langClient().openApiGenerateClient(request).then(async (res) => { if (!res.source || !res.source.textEditsMap) { console.error("textEditsMap is undefined or null"); reject(new Error("textEditsMap is undefined or null")); @@ -1952,11 +1957,16 @@ export class BiDiagramRpcManager implements BIDiagramAPI { return; } - // Convert the plain object back to a Map - const textEditsMap = new Map(Object.entries(res.source.textEditsMap)); - textEditsMap.forEach(async (value, key) => { - await this.applyTextEdits(key, value); - }); + + if (res?.source?.textEditsMap) { + await updateSourceCode({ + textEdits: res.source.textEditsMap, + description: `OpenAPI Client Generation`, + skipUpdateViewOnTomlUpdate: true + }); + console.log(">>> Applied text edits for openapi client"); + } + resolve({}); }).catch((error) => { console.log(">>> error generating openapi client", error); diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/common/utils.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/common/utils.ts index fee5752c260..3dc2a3ebcda 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/common/utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/common/utils.ts @@ -92,7 +92,7 @@ export async function askFilePath() { canSelectMany: false, defaultUri: Uri.file(os.homedir()), filters: { - 'Files': ['yaml', 'json', 'yml', 'graphql'] + 'Files': ['yaml', 'json', 'yml', 'graphql', 'wsdl'] }, title: "Select a file", }); diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-handler.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-handler.ts index 861e30d48b5..9c6bfb19ba6 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-handler.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-handler.ts @@ -20,8 +20,14 @@ import { ConnectorRequest, ConnectorsRequest, + generateWSDLApiClient, getConnector, - getConnectors + getConnectors, + introspectDatabase, + IntrospectDatabaseRequest, + persistClientGenerate, + PersistClientGenerateRequest, + WSDLApiClientGenerationRequest } from "@wso2/ballerina-core"; import { Messenger } from "vscode-messenger"; import { ConnectorWizardRpcManager } from "./rpc-manager"; @@ -30,4 +36,7 @@ export function registerConnectorWizardRpcHandlers(messenger: Messenger) { const rpcManger = new ConnectorWizardRpcManager(); messenger.onRequest(getConnector, (args: ConnectorRequest) => rpcManger.getConnector(args)); messenger.onRequest(getConnectors, (args: ConnectorsRequest) => rpcManger.getConnectors(args)); + messenger.onRequest(introspectDatabase, (args: IntrospectDatabaseRequest) => rpcManger.introspectDatabase(args)); + messenger.onRequest(persistClientGenerate, (args: PersistClientGenerateRequest) => rpcManger.persistClientGenerate(args)); + messenger.onRequest(generateWSDLApiClient, (args: WSDLApiClientGenerationRequest) => rpcManger.generateWSDLApiClient(args)); } diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-manager.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-manager.ts index f35aca856f6..1a1e960a21e 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-manager.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/connector-wizard/rpc-manager.ts @@ -23,9 +23,16 @@ import { ConnectorResponse, ConnectorWizardAPI, ConnectorsRequest, - ConnectorsResponse + ConnectorsResponse, + IntrospectDatabaseRequest, + IntrospectDatabaseResponse, + PersistClientGenerateRequest, + PersistClientGenerateResponse, + WSDLApiClientGenerationRequest, + WSDLApiClientGenerationResponse } from "@wso2/ballerina-core"; import { StateMachine } from "../../stateMachine"; +import { updateSourceCode } from "../../utils/source-utils"; export class ConnectorWizardRpcManager implements ConnectorWizardAPI { @@ -62,4 +69,68 @@ export class ConnectorWizardRpcManager implements ConnectorWizardAPI { }); }); } + + async introspectDatabase(params: IntrospectDatabaseRequest): Promise { + return new Promise((resolve) => { + StateMachine.langClient() + .introspectDatabase(params) + .then((response) => { + console.log(">>> introspect database response", response); + resolve(response as IntrospectDatabaseResponse); + }) + .catch((error) => { + console.log(">>> error introspecting database", error); + resolve(undefined); + }); + }); + } + + async persistClientGenerate(params: PersistClientGenerateRequest): Promise { + return new Promise(async (resolve) => { + try { + const response = await StateMachine.langClient().generatePersistClient(params); + console.log(">>> persist client generate response", response); + + const persistResponse = response as PersistClientGenerateResponse; + + // Apply text edits if provided + if (persistResponse?.source?.textEditsMap) { + await updateSourceCode({ + textEdits: persistResponse.source.textEditsMap, + description: `Database Connection and Connector Generation` + }); + console.log(">>> Applied text edits for database connection"); + } + + resolve(persistResponse); + } catch (error) { + console.log(">>> error persisting client", error); + resolve(undefined); + } + }); + } + + async generateWSDLApiClient(params: WSDLApiClientGenerationRequest): Promise { + return new Promise(async (resolve) => { + try { + const response = await StateMachine.langClient().generateWSDLApiClient(params); + console.log(">>> generate wsdl api client response", response); + + const wsdlResponse = response as WSDLApiClientGenerationResponse; + + if (wsdlResponse?.source?.textEditsMap) { + await updateSourceCode({ + textEdits: wsdlResponse.source.textEditsMap, + description: `WSDL API Client Generation` + }); + console.log(">>> Applied text edits for wsdl api client"); + } + + resolve(wsdlResponse); + } catch (error) { + console.log(">>> error generating wsdl api client", error); + resolve(undefined); + } + }); + } } diff --git a/workspaces/ballerina/ballerina-extension/src/rpc-managers/data-mapper/utils.ts b/workspaces/ballerina/ballerina-extension/src/rpc-managers/data-mapper/utils.ts index b3357ad2696..eb84089cc1b 100644 --- a/workspaces/ballerina/ballerina-extension/src/rpc-managers/data-mapper/utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/rpc-managers/data-mapper/utils.ts @@ -732,7 +732,7 @@ function processTypeFields( let isFocused = false; let isSeq = !!model.groupById; - if (model.focusInputs) { + if (isSeq && model.focusInputs) { const focusMember = model.focusInputs[fieldId]; if (focusMember) { field = focusMember; diff --git a/workspaces/ballerina/ballerina-extension/src/utils/command-utils.ts b/workspaces/ballerina/ballerina-extension/src/utils/command-utils.ts index 81a7365205e..1008d75e7ab 100644 --- a/workspaces/ballerina/ballerina-extension/src/utils/command-utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/utils/command-utils.ts @@ -18,6 +18,7 @@ import { window } from "vscode"; import { MACHINE_VIEW, ProjectInfo } from "@wso2/ballerina-core"; +import { MESSAGES } from "../features/project"; export function requiresPackageSelection( workspacePath: string | undefined, @@ -33,7 +34,7 @@ export function requiresPackageSelection( ); } -export async function promptPackageSelection( +async function promptPackageSelection( availablePackages: string[], placeHolder?: string ): Promise { @@ -43,6 +44,20 @@ export async function promptPackageSelection( }); } +export async function selectPackageOrPrompt( + availablePackages: string[], + placeHolder?: string +): Promise { + if (availablePackages.length === 0) { + window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); + return; + } + if (availablePackages.length === 1) { + return availablePackages[0]; + } + return await promptPackageSelection(availablePackages, placeHolder); +} + export function needsProjectDiscovery( projectInfo: ProjectInfo, projectRoot: string | undefined, diff --git a/workspaces/ballerina/ballerina-extension/src/utils/source-utils.ts b/workspaces/ballerina/ballerina-extension/src/utils/source-utils.ts index dc3b1ffbc95..5150e8700b7 100644 --- a/workspaces/ballerina/ballerina-extension/src/utils/source-utils.ts +++ b/workspaces/ballerina/ballerina-extension/src/utils/source-utils.ts @@ -23,7 +23,8 @@ import { Uri } from 'vscode'; import { ArtifactData, EVENT_TYPE, MACHINE_VIEW, ProjectStructureArtifactResponse, STModification, TextEdit } from '@wso2/ballerina-core'; import { openView, StateMachine, undoRedoManager } from '../stateMachine'; import { ArtifactsUpdated, ArtifactNotificationHandler } from './project-artifacts-handler'; -import { existsSync, writeFileSync } from 'fs'; +import { existsSync, writeFileSync, mkdirSync } from 'fs'; +import * as path from 'path'; import { notifyCurrentWebview } from '../RPCLayer'; import { applyBallerinaTomlEdit } from '../rpc-managers/bi-diagram/utils'; @@ -37,6 +38,7 @@ export interface UpdateSourceCodeRequest { identifier?: string; skipPayloadCheck?: boolean; // This is used to skip the payload check because the payload data might become empty as a result of a change. Example: Deleting a component. isRenameOperation?: boolean; // This is used to identify if the update is a rename operation. + skipUpdateViewOnTomlUpdate?: boolean; // This is used to skip updating the view on toml updates in certain scenarios. } export async function updateSourceCode(updateSourceCodeRequest: UpdateSourceCodeRequest, isChangeFromHelperPane?: boolean): Promise { @@ -49,6 +51,11 @@ export async function updateSourceCode(updateSourceCodeRequest: UpdateSourceCode const fileUri = key.startsWith("file:") ? Uri.parse(key) : Uri.file(key); const fileUriString = fileUri.toString(); if (!existsSync(fileUri.fsPath)) { + // Ensure parent directory exists before creating the file + const dirPath = path.dirname(fileUri.fsPath); + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }); + } writeFileSync(fileUri.fsPath, ''); await new Promise(resolve => setTimeout(resolve, 500)); // Add small delay to ensure file is created await StateMachine.langClient().didOpen({ @@ -162,7 +169,7 @@ export async function updateSourceCode(updateSourceCodeRequest: UpdateSourceCode } return new Promise((resolve, reject) => { - if (tomlFilesUpdated) { + if (tomlFilesUpdated && !updateSourceCodeRequest?.skipUpdateViewOnTomlUpdate) { StateMachine.setReadyMode(); resolve([]); return; diff --git a/workspaces/ballerina/ballerina-extension/src/views/ai-panel/activate.ts b/workspaces/ballerina/ballerina-extension/src/views/ai-panel/activate.ts index 5ec1579ee30..e872726510b 100644 --- a/workspaces/ballerina/ballerina-extension/src/views/ai-panel/activate.ts +++ b/workspaces/ballerina/ballerina-extension/src/views/ai-panel/activate.ts @@ -24,7 +24,7 @@ import { notifyAiWebview } from '../../RPCLayer'; import { openView, StateMachine } from '../../stateMachine'; import { MESSAGES } from '../../features/project/cmds/cmd-runner'; import { VisualizerWebview } from '../visualizer/webview'; -import { needsProjectDiscovery, promptPackageSelection, requiresPackageSelection } from '../../utils/command-utils'; +import { selectPackageOrPrompt, needsProjectDiscovery, requiresPackageSelection } from '../../utils/command-utils'; import { getCurrentProjectRoot, tryGetCurrentBallerinaFile } from '../../utils/project-utils'; import { findBallerinaPackageRoot } from '../../utils'; import { findWorkspaceTypeFromWorkspaceFolders } from '../../rpc-managers/common/utils'; @@ -80,19 +80,10 @@ async function handleOpenAIPanel(defaultPrompt?: AIPanelPrompt): Promise { async function handleWorkspaceLevelAIPanel(projectInfo: ProjectInfo): Promise { const availablePackages = projectInfo?.children.map((child: ProjectInfo) => child.projectPath) ?? []; - if (availablePackages.length === 0) { - vscode.window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); - return false; - } - try { - const selectedPackage = await promptPackageSelection( - availablePackages, - "Select a package to open AI panel" - ); - + const selectedPackage = await selectPackageOrPrompt(availablePackages, "Select a package to open AI panel"); if (!selectedPackage) { - return true; // User cancelled + return true; } openPackageOverviewView(selectedPackage); diff --git a/workspaces/ballerina/ballerina-extension/src/views/visualizer/activate.ts b/workspaces/ballerina/ballerina-extension/src/views/visualizer/activate.ts index fd212c0f631..d3bfc9aeef8 100644 --- a/workspaces/ballerina/ballerina-extension/src/views/visualizer/activate.ts +++ b/workspaces/ballerina/ballerina-extension/src/views/visualizer/activate.ts @@ -26,7 +26,7 @@ import { createVersionNumber, findBallerinaPackageRoot, isSupportedSLVersion } f import { VisualizerWebview } from './webview'; import { findWorkspaceTypeFromWorkspaceFolders } from '../../rpc-managers/common/utils'; import { getCurrentProjectRoot, tryGetCurrentBallerinaFile } from '../../utils/project-utils'; -import { requiresPackageSelection, needsProjectDiscovery, promptPackageSelection } from '../../utils/command-utils'; +import { requiresPackageSelection, needsProjectDiscovery, selectPackageOrPrompt } from '../../utils/command-utils'; export function activateSubscriptions() { const context = extension.context; @@ -200,15 +200,9 @@ function openTypeDiagramView(projectPath?: string, resetHistory = false): void { async function openTypeDiagramForWorkspace(projectInfo: ProjectInfo): Promise { const availablePackages = projectInfo?.children.map((child: any) => child.projectPath) ?? []; - if (availablePackages.length === 0) { - vscode.window.showErrorMessage(MESSAGES.NO_PROJECT_FOUND); - return false; - } - - const selectedPackage = await promptPackageSelection(availablePackages, "Select a package to open type diagram"); - + const selectedPackage = await selectPackageOrPrompt(availablePackages, "Select a package to open type diagram"); if (!selectedPackage) { - return false; // User cancelled + return false; } openTypeDiagramView(selectedPackage); diff --git a/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/ai-panel/rpc-client.ts b/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/ai-panel/rpc-client.ts index 733e57fbd04..8bb17458bfa 100644 --- a/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/ai-panel/rpc-client.ts +++ b/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/ai-panel/rpc-client.ts @@ -23,7 +23,6 @@ import { AIPanelAPI, AIPanelPrompt, AddFilesToProjectRequest, - AddToProjectRequest, DeleteFromProjectRequest, DeveloperDocument, DocGenerationRequest, @@ -55,7 +54,6 @@ import { abortTestGeneration, addChatSummary, addFilesToProject, - addToProject, applyDoOnFailBlocks, checkSyntaxError, clearInitialPrompt, @@ -149,10 +147,6 @@ export class AiPanelRpcClient implements AIPanelAPI { return this._messenger.sendRequest(fetchData, HOST_EXTENSION, params); } - addToProject(params: AddToProjectRequest): Promise { - return this._messenger.sendRequest(addToProject, HOST_EXTENSION, params); - } - getFromFile(params: GetFromFileRequest): Promise { return this._messenger.sendRequest(getFromFile, HOST_EXTENSION, params); } diff --git a/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/connector-wizard/rpc-client.ts b/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/connector-wizard/rpc-client.ts index e35fbe3cb55..f23fb8f8d77 100644 --- a/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/connector-wizard/rpc-client.ts +++ b/workspaces/ballerina/ballerina-rpc-client/src/rpc-clients/connector-wizard/rpc-client.ts @@ -20,11 +20,20 @@ import { ConnectorRequest, ConnectorResponse, - ConnectorWizardAPI, ConnectorsRequest, ConnectorsResponse, + ConnectorWizardAPI, + generateWSDLApiClient, getConnector, - getConnectors + getConnectors, + introspectDatabase, + IntrospectDatabaseRequest, + IntrospectDatabaseResponse, + persistClientGenerate, + PersistClientGenerateRequest, + PersistClientGenerateResponse, + WSDLApiClientGenerationRequest, + WSDLApiClientGenerationResponse } from "@wso2/ballerina-core"; import { HOST_EXTENSION } from "vscode-messenger-common"; import { Messenger } from "vscode-messenger-webview"; @@ -43,4 +52,16 @@ export class ConnectorWizardRpcClient implements ConnectorWizardAPI { getConnectors(params: ConnectorsRequest): Promise { return this._messenger.sendRequest(getConnectors, HOST_EXTENSION, params); } + + introspectDatabase(params: IntrospectDatabaseRequest): Promise { + return this._messenger.sendRequest(introspectDatabase, HOST_EXTENSION, params); + } + + persistClientGenerate(params: PersistClientGenerateRequest): Promise { + return this._messenger.sendRequest(persistClientGenerate, HOST_EXTENSION, params); + } + + generateWSDLApiClient(params: WSDLApiClientGenerationRequest): Promise { + return this._messenger.sendRequest(generateWSDLApiClient, HOST_EXTENSION, params); + } } diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/Form/index.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/Form/index.tsx index ee7921bf142..8cd3684ecb6 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/Form/index.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/Form/index.tsx @@ -16,7 +16,7 @@ * under the License. */ -import React, { forwardRef, useMemo, useEffect, useImperativeHandle, useState, useRef } from "react"; +import React, { forwardRef, useMemo, useEffect, useState, useRef } from "react"; import { useForm } from "react-hook-form"; import ReactMarkdown from "react-markdown"; import { @@ -31,7 +31,7 @@ import { } from "@wso2/ui-toolkit"; import styled from "@emotion/styled"; -import { ExpressionFormField, FormExpressionEditorProps, FormField, FormImports, FormValues } from "./types"; +import { ExpressionFormField, FieldDerivation, FormExpressionEditorProps, FormField, FormImports, FormValues } from "./types"; import { EditorFactory } from "../editors/EditorFactory"; import { getValueForDropdown, isDropdownField } from "../editors/utils"; import { @@ -63,17 +63,37 @@ import FormDescription from "./FormDescription"; import TypeHelperText from "./TypeHelperText"; namespace S { - export const Container = styled(SidePanelBody) <{ nestedForm?: boolean; compact?: boolean }>` + export const Container = styled(SidePanelBody) <{ nestedForm?: boolean; compact?: boolean; footerActionButton?: boolean }>` display: flex; flex-direction: column; gap: ${({ compact }) => (compact ? "8px" : "20px")}; - height: ${({ nestedForm }) => (nestedForm ? "unset" : "calc(100vh - 100px)")}; - overflow-y: ${({ nestedForm }) => (nestedForm ? "visible" : "auto")}; + height: ${({ nestedForm, footerActionButton }) => { + if (nestedForm) return "unset"; + if (footerActionButton) return "100%"; + return "calc(100vh - 100px)"; + }}; + max-height: ${({ footerActionButton }) => footerActionButton ? "100%" : "none"}; + min-height: ${({ footerActionButton }) => footerActionButton ? "0" : "auto"}; + overflow: ${({ nestedForm, footerActionButton }) => { + if (nestedForm) return "visible"; + if (footerActionButton) return "hidden"; + return "auto"; + }}; + position: ${({ footerActionButton }) => footerActionButton ? "relative" : "static"}; & > :last-child { margin-top: ${({ compact }) => (compact ? "12px" : "0")}; } `; + export const ScrollableContent = styled.div<{}>` + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 20px; + `; + export const Row = styled.div<{}>` display: flex; flex-direction: row; @@ -115,6 +135,26 @@ namespace S { width: 100%; `; + export const FooterActionButtonContainer = styled.div<{}>` + position: sticky; + bottom: 0; + padding: 20px 0px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + width: 100%; + `; + + export const FooterActionButton = styled(Button)` + width: 100% !important; + min-width: 0 !important; + display: flex !important; + justify-content: center; + align-items: center; + height: 35px !important; + `; + export const TitleContainer = styled.div<{}>` display: flex; flex-direction: column; @@ -357,9 +397,11 @@ export interface FormProps { index: number; }[]; hideSaveButton?: boolean; // Option to hide the save button + footerActionButton?: boolean; // Render save button as footer action button onValidityChange?: (isValid: boolean) => void; // Callback for form validity status changeOptionalFieldTitle?: string; // Option to change the title of optional fields openFormTypeEditor?: (open: boolean, newType?: string, editingField?: FormField) => void; + derivedFields?: FieldDerivation[]; // Configuration for auto-deriving field values from other fields } export const Form = forwardRef((props: FormProps) => { @@ -397,9 +439,11 @@ export const Form = forwardRef((props: FormProps) => { scopeFieldAddon, injectedComponents, hideSaveButton = false, + footerActionButton = false, onValidityChange, changeOptionalFieldTitle = undefined, - openFormTypeEditor + openFormTypeEditor, + derivedFields = [] } = props; const { @@ -421,6 +465,7 @@ export const Form = forwardRef((props: FormProps) => { const [diagnosticsInfo, setDiagnosticsInfo] = useState(undefined); const [isMarkdownExpanded, setIsMarkdownExpanded] = useState(false); const [isIdentifierEditing, setIsIdentifierEditing] = useState(false); + const [manuallyEditedFields, setManuallyEditedFields] = useState>(new Set()); const [isSubComponentEnabled, setIsSubComponentEnabled] = useState(false); const [optionalFieldsTitle, setOptionalFieldsTitle] = useState("Advanced Configurations"); @@ -714,6 +759,54 @@ export const Form = forwardRef((props: FormProps) => { } }, [watchedValues]); + // Handle derived fields: auto-generate target field values from source fields + useEffect(() => { + if (derivedFields.length === 0) return; + + derivedFields.forEach(({ sourceField, targetField, deriveFn, breakOnManualEdit = true }) => { + const sourceValue = watchedValues[sourceField]; + const currentTargetValue = watchedValues[targetField]; + + // Skip if this field has been manually edited and breakOnManualEdit is true + if (breakOnManualEdit && manuallyEditedFields.has(targetField)) { + return; + } + + // Derive the new target value + const derivedValue = deriveFn(sourceValue); + + // Only update if the value has actually changed + if (derivedValue !== currentTargetValue) { + setValue(targetField, derivedValue); + } + }); + }, [watchedValues, derivedFields, manuallyEditedFields, setValue]); + + // Track manual edits to derived target fields + useEffect(() => { + if (derivedFields.length === 0) return; + + const prevValues = prevValuesRef.current; + derivedFields.forEach(({ targetField, breakOnManualEdit = true }) => { + if (!breakOnManualEdit) return; + + const currentValue = watchedValues[targetField]; + const prevValue = prevValues[targetField]; + + if (currentValue !== prevValue && prevValue !== undefined) { + // Mark this field as manually edited + setManuallyEditedFields(prev => { + if (!prev.has(targetField)) { + const newSet = new Set(prev); + newSet.add(targetField); + return newSet; + } + return prev; + }); + } + }); + }, [watchedValues, derivedFields]); + const handleOnOpenInDataMapper = () => { setSavingButton('dataMapper'); handleSubmit((data) => { @@ -732,12 +825,11 @@ export const Form = forwardRef((props: FormProps) => { } }; - return ( - - - {actionButton && {actionButton}} - {infoLabel && !compact && ( - + const formContent = ( + <> + {actionButton && {actionButton}} + {infoLabel && !compact && ( + {stripHtmlTags(infoLabel)} @@ -957,13 +1049,25 @@ export const Form = forwardRef((props: FormProps) => { )} - {concertMessage && ( - - - - )} + {concertMessage && ( + + + + )} + + ); - {onSubmit && !hideSaveButton && ( + return ( + + + {footerActionButton ? ( + + {formContent} + + ) : ( + formContent + )} + {onSubmit && !hideSaveButton && !footerActionButton && ( {onCancelForm && ( )} + {onSubmit && !hideSaveButton && footerActionButton && ( + + + {isValidatingForm ? ( + Validating... + ) : isSaving && savingButton === 'save' ? ( + {submitText || "Saving..."} + ) : ( + submitText || "Save" + )} + + + )} ); diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts index d74111308e8..a0e57742389 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/Form/types.ts @@ -26,6 +26,13 @@ export type FormValues = { [key: string]: any; }; +export type FieldDerivation = { + sourceField: string; + targetField: string; + deriveFn: (sourceValue: any) => any; + breakOnManualEdit?: boolean; +}; + export type FormField = { key: string; label: string; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx index b86bdff8fe0..a2f96b23f6d 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionField.tsx @@ -32,10 +32,11 @@ import { LineRange } from '@wso2/ballerina-core/lib/interfaces/common'; import { FormField, HelperpaneOnChangeOptions } from '../Form/types'; import { ChipExpressionEditorComponent } from './MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor'; import RecordConfigPreviewEditor from './MultiModeExpressionEditor/RecordConfigPreviewEditor/RecordConfigPreviewEditor'; -import { RawTemplateEditorConfig, StringTemplateEditorConfig, PrimaryModeChipExpressionEditorConfig } from './MultiModeExpressionEditor/Configurations'; +import { RawTemplateEditorConfig, StringTemplateEditorConfig } from './MultiModeExpressionEditor/Configurations'; import NumberExpressionEditor from './MultiModeExpressionEditor/NumberExpressionEditor/NumberEditor'; import BooleanEditor from './MultiModeExpressionEditor/BooleanEditor/BooleanEditor'; import { SQLExpressionEditor } from './MultiModeExpressionEditor/SqlExpressionEditor/SqlExpressionEditor'; +import { ChipExpressionEditorDefaultConfiguration } from './MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionDefaultConfig'; export interface ExpressionField { field: FormField; @@ -104,7 +105,6 @@ const EditorRibbon = ({ onClick }: { onClick: () => void }) => { export const ExpressionField: React.FC = ({ inputMode, field, - primaryMode, name, value, completions, @@ -115,22 +115,15 @@ export const ExpressionField: React.FC = ({ targetLineRange, onChange, extractArgsFromFunction, - onCompletionSelect, onFocus, onBlur, onSave, onCancel, onRemove, - isHelperPaneOpen, - changeHelperPaneState, getHelperPane, - helperPaneHeight, - helperPaneWidth, growRange, - helperPaneZIndex, exprRef, anchorRef, - onToggleHelperPane, sanitizedExpression, rawExpression, onOpenExpandedMode, @@ -285,7 +278,7 @@ export const ExpressionField: React.FC = ({ onOpenExpandedMode={onOpenExpandedMode} onRemove={onRemove} isInExpandedMode={isInExpandedMode} - configuration={new PrimaryModeChipExpressionEditorConfig(primaryMode)} + configuration={new ChipExpressionEditorDefaultConfiguration()} /> ); }; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionDefaultConfig.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionDefaultConfig.ts index 327024a32ec..581dc318f6b 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionDefaultConfig.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/ChipExpressionDefaultConfig.ts @@ -19,7 +19,7 @@ import FXButton from "./components/FxButton"; import { ParsedToken } from "./utils"; -export abstract class ChipExpressionEditorDefaultConfiguration { +export class ChipExpressionEditorDefaultConfiguration { getHelperValue(value: string, token?: ParsedToken) { return value; } diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/Configurations.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/Configurations.tsx index 83f5f56c56b..8e5e4bfa617 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/Configurations.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/Configurations.tsx @@ -172,26 +172,3 @@ export class ChipExpressionEditorConfig extends ChipExpressionEditorDefaultConfi return `\$\{${value}\}`; } } - -export class PrimaryModeChipExpressionEditorConfig extends ChipExpressionEditorDefaultConfiguration { - private readonly primaryMode: InputMode; - - constructor(primaryMode: InputMode) { - super(); - this.primaryMode = primaryMode; - } - - getHelperValue(value: string, token?: ParsedToken): string { - const isTemplateEditor = ( - this.primaryMode === InputMode.TEXT || - this.primaryMode === InputMode.TEMPLATE || - this.primaryMode === InputMode.SQL - ); - - - if (isTemplateEditor && (!token || token.type !== TokenType.FUNCTION)) { - return `\$\{${value}\}`; - } - return value; - } -} diff --git a/workspaces/ballerina/ballerina-visualizer/src/Hooks.tsx b/workspaces/ballerina/ballerina-visualizer/src/Hooks.tsx index 1505bef523e..9705db3fddc 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/Hooks.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/Hooks.tsx @@ -33,14 +33,17 @@ export const useDataMapperModel = ( const getDMModel = async () => { try { + + const codeDataPosition = { + line: codedata.lineRange.startLine.line, + offset: codedata.lineRange.startLine.offset + }; + const modelParams = { filePath, codedata, targetField: viewId, - position: position ?? { - line: codedata.lineRange.startLine.line, - offset: codedata.lineRange.startLine.offset - } + position: viewState.subMappingName ? codeDataPosition : (position ?? codeDataPosition) }; console.log('>>> [Data Mapper] Model Parameters:', modelParams); diff --git a/workspaces/ballerina/ballerina-visualizer/src/MainPanel.tsx b/workspaces/ballerina/ballerina-visualizer/src/MainPanel.tsx index 147dae0c14e..e3e4a8cfd66 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/MainPanel.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/MainPanel.tsx @@ -82,6 +82,8 @@ import { ServiceFunctionForm } from "./views/BI/ServiceFunctionForm"; import ServiceConfigureView from "./views/BI/ServiceDesigner/ServiceConfigureView"; import { WorkspaceOverview } from "./views/BI/WorkspaceOverview"; import { SamplesView } from "./views/BI/SamplesView"; +import AddConnectionPopup from "./views/BI/Connection/AddConnectionPopup"; +import EditConnectionPopup from "./views/BI/Connection/EditConnectionPopup"; const globalStyles = css` *, @@ -539,15 +541,16 @@ const MainPanel = () => { break; case MACHINE_VIEW.AddConnectionWizard: setViewComponent( - ); break; case MACHINE_VIEW.EditConnectionWizard: setViewComponent( - ); @@ -649,6 +652,10 @@ const MainPanel = () => { .openView({ type: EVENT_TYPE.CLOSE_VIEW, location: { view: null, recentIdentifier: parent?.recentIdentifier, artifactType: parent?.artifactType }, isPopup: true }); }; + const handleNavigateToOverview = () => { + rpcClient.getVisualizerRpcClient().goHome(); + }; + const handlePopupClose = (id: string) => { closeModal(id); } diff --git a/workspaces/ballerina/ballerina-visualizer/src/PopupPanel.tsx b/workspaces/ballerina/ballerina-visualizer/src/PopupPanel.tsx index 7237af4ed1c..098ebe0223b 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/PopupPanel.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/PopupPanel.tsx @@ -25,6 +25,8 @@ import { ThemeColors, Overlay } from "@wso2/ui-toolkit"; import EditConnectionWizard from "./views/BI/Connection/EditConnectionWizard"; import { FunctionForm } from "./views/BI"; import { DataMapper } from "./views/DataMapper"; +import AddConnectionPopup from "./views/BI/Connection/AddConnectionPopup"; +import EditConnectionPopup from "./views/BI/Connection/EditConnectionPopup"; const ViewContainer = styled.div<{ isFullScreen?: boolean }>` position: fixed; @@ -70,12 +72,11 @@ const PopupPanel = (props: PopupPanelProps) => { case MACHINE_VIEW.AddConnectionWizard: rpcClient.getVisualizerLocation().then((location) => { setViewComponent( - ); }); @@ -84,11 +85,10 @@ const PopupPanel = (props: PopupPanelProps) => { rpcClient.getVisualizerLocation().then((location) => { setViewComponent( <> - - ); }); diff --git a/workspaces/ballerina/ballerina-visualizer/src/utils/bi.tsx b/workspaces/ballerina/ballerina-visualizer/src/utils/bi.tsx index e19d948b6fd..75717de3322 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/utils/bi.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/utils/bi.tsx @@ -481,7 +481,7 @@ export function enrichFormTemplatePropertiesWithValues( ) { // Copy the value from formProperties to formTemplateProperties enrichedFormTemplateProperties[key as NodePropertyKey].value = formProperty.value; - + if (formProperty.hasOwnProperty('editable')) { enrichedFormTemplateProperties[key as NodePropertyKey].editable = formProperty.editable; enrichedFormTemplateProperties[key as NodePropertyKey].codedata = formProperty?.codedata; @@ -489,7 +489,7 @@ export function enrichFormTemplatePropertiesWithValues( if (formProperty.diagnostics) { enrichedFormTemplateProperties[key as NodePropertyKey].diagnostics = formProperty.diagnostics; - } + } } } } @@ -752,7 +752,7 @@ export function convertToVisibleTypes(types: VisibleTypeItem[], isFetchingTypesF export function convertRecordTypeToCompletionItem(type: Type): CompletionItem { const label = type?.name ?? ""; - const value = label; + const value = label; const kind = "struct"; const description = type?.metadata?.description; const labelDetails = (() => { @@ -1042,6 +1042,38 @@ export function filterUnsupportedDiagnostics(diagnostics: Diagnostic[]): Diagnos }); } +/** + * Filter out "undefined symbol" diagnostics when the symbol is a known Tool Input parameter + * @param diagnostics - Array of diagnostics to filter + * @param toolInputParameterNames - Array of Tool Input parameter names to exclude from diagnostics + * @returns Filtered diagnostics array + */ +export function filterToolInputSymbolDiagnostics( + diagnostics: Diagnostic[], + toolInputs?: { type: string, variable: string }[] +): Diagnostic[] { + if (!toolInputs || toolInputs.length === 0) { + return diagnostics; + } + + return diagnostics.filter((diagnostic) => { + // Only filter "undefined symbol" diagnostics + if (!diagnostic.message.includes('undefined symbol')) { + return true; + } + + // Extract symbol name from message like "undefined symbol 'code'" + const match = diagnostic.message.match(/['"`]([^'"`]+)['"`]/); + if (!match) { + return true; // Keep diagnostic if we can't parse it + } + + const symbolName = match[1]; + // Filter out if symbol is a Tool Input parameter + return !toolInputs.some(input => input.variable === symbolName); + }); +} + /** * Check if the type is supported by the data mapper * diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AIAgentSidePanel.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AIAgentSidePanel.tsx index d0f86c733ae..dcc105c31ff 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AIAgentSidePanel.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AIAgentSidePanel.tsx @@ -16,7 +16,7 @@ * under the License. */ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRpcContext } from "@wso2/ballerina-rpc-client"; import { NodeList, Category as PanelCategory, FormField, FormValues } from "@wso2/ballerina-side-panel"; import { @@ -40,12 +40,14 @@ import { Property, ToolParameterItem, NodeProperties, + Diagnostic, } from "@wso2/ballerina-core"; import { convertBICategoriesToSidePanelCategories, convertConfig, convertFunctionCategoriesToSidePanelCategories, + filterToolInputSymbolDiagnostics, } from "../../../utils/bi"; import FormGeneratorNew from "../Forms/FormGeneratorNew"; import { RelativeLoader } from "../../../components/RelativeLoader"; @@ -100,7 +102,6 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { const [categories, setCategories] = useState([]); const [selectedNodeCodeData, setSelectedNodeCodeData] = useState(undefined); const [toolNodeId, setToolNodeId] = useState(undefined); - const [injectedComponentIndex, setInjectedComponentIndex] = useState(3); const functionNode = useRef(null); const flowNode = useRef(null); @@ -140,6 +141,17 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { const selectedNodeRef = useRef(undefined); const agentFilePath = useRef(Utils.joinPath(URI.file(projectPath), "agents.bal").fsPath); const functionFilePath = useRef(Utils.joinPath(URI.file(projectPath), "functions.bal").fsPath); + const parameterFieldsRef = useRef([]); + + // Create custom diagnostic filter for Tool Input parameters + const customDiagnosticFilter = useCallback((diagnostics: Diagnostic[]) => { + if (!parameterFieldsRef.current || parameterFieldsRef.current.length === 0) { + return diagnostics; + } + const toolInputs = parameterFieldsRef.current.map(param => ({ type: param.formValues.type, variable: param.formValues.variable })); + return filterToolInputSymbolDiagnostics(diagnostics, toolInputs); + }, []); + useEffect(() => { fetchNodes(); }, []); @@ -322,7 +334,6 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { if (functionNodeResponse.functionDefinition.properties) { toolInputFields = convertConfig(functionNodeResponse.functionDefinition.properties); } - setInjectedComponentIndex(2 + toolInputFields.length); console.log(">>> Tool input fields", { toolInputFields }); const functionNodeTemplate = await rpcClient.getBIDiagramRpcClient().getNodeTemplate({ @@ -377,7 +388,7 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { field.advanced = false; // hack: remove headers and additionalValues from the tool inputs and set default value to () if (["headers", "additionalValues"].includes(field.key)) { - field.value = "()"; + field.value = "{}"; return; } includedKeys.push(field.key); @@ -386,7 +397,6 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { const filteredNodeParameterFields = nodeParameterFields.filter(field => includedKeys.includes(field.key)); toolInputFields = createToolInputFields(filteredNodeParameterFields); - setInjectedComponentIndex(2 + toolInputFields.length); console.log(">>> Tool input fields", { toolInputFields }); @@ -599,6 +609,13 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { concertRequired={concertRequired} description={description} helperPaneSide="left" + customDiagnosticFilter={customDiagnosticFilter} + onChange={(fieldKey, value) => { + if (fieldKey === "parameters") { + parameterFieldsRef.current = value as ToolParameterItem[]; + return; + } + }} injectedComponents={[ { component: ( @@ -609,7 +626,7 @@ export function AIAgentSidePanel(props: BIFlowDiagramProps) { ), - index: injectedComponentIndex, + index: 3, }, ]} /> diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AddMcpServer.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AddMcpServer.tsx index 51fb1774a2f..93ac021025f 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AddMcpServer.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/AddMcpServer.tsx @@ -13,10 +13,13 @@ import { debounce } from "lodash"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RelativeLoader } from "../../../components/RelativeLoader"; import FormGenerator from "../Forms/FormGenerator"; -import { McpToolsSelection } from "./McpToolsSelection"; +import { McpToolsSelection } from "./Mcp/McpToolsSelection"; +import { DiscoverToolsModal } from "./Mcp/DiscoverToolsModal"; +import { RequiresAuthCheckbox } from "./Mcp/RequiresAuthCheckbox"; +import { attemptValueResolution, createMockTools, extractOriginalValues, generateToolKitName } from "./Mcp/utils"; import { cleanServerUrl } from "./formUtils"; import { Container, LoaderContainer } from "./styles"; -import { extractAccessToken, findAgentNodeFromAgentCallNode, getAgentFilePath, getEndOfFileLineRange, parseToolsString, resolveVariableValue, resolveAuthConfig } from "./utils"; +import { extractAccessToken, findAgentNodeFromAgentCallNode, getAgentFilePath, getEndOfFileLineRange, resolveVariableValue, resolveAuthConfig } from "./utils"; interface Tool { name: string; @@ -33,6 +36,8 @@ interface AddMcpServerProps { const SERVER_URL_FIELD_KEY = "serverUrl"; const AUTH_FIELD_KEY = "auth"; +const RESULT_FIELD_KEY = "variable"; +const TOOLKIT_NAME_FIELD_KEY = "toolKitName"; export function AddMcpServer(props: AddMcpServerProps): JSX.Element { const { agentCallNode, onSave, editMode = false } = props; @@ -40,6 +45,8 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { const [serverUrl, setServerUrl] = useState(""); const [auth, setAuth] = useState(""); + const [requiresAuth, setRequiresAuth] = useState(false); + const [toolsInclude, setToolsInclude] = useState("all"); const [availableMcpTools, setAvailableMcpTools] = useState([]); const [selectedMcpTools, setSelectedMcpTools] = useState>(new Set()); @@ -48,6 +55,12 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + const [showDiscoverModal, setShowDiscoverModal] = useState(false); + + // Edit mode tracking + const [resolutionError, setResolutionError] = useState(""); + const [toolSource, setToolSource] = useState<'auto-fetched' | 'manual-discovery' | 'saved-mock' | null>(null); + const isInitializingEditModeRef = useRef(false); const mcpToolKitNodeTemplateRef = useRef(null); const mcpToolKitNodeRef = useRef(null); @@ -56,7 +69,6 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { const agentFilePathRef = useRef(""); const agentFileEndLineRangeRef = useRef(null); const formRef = useRef(null); - const moduleVariablesRef = useRef([]); const projectPathUriRef = useRef(""); const fetchAgentNode = async () => { @@ -87,6 +99,7 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { ); if (!mcpToolKitVariable) return; mcpToolKitNodeRef.current = mcpToolKitVariable; + initializeEditMode(); }; const initPanel = useCallback(async () => { @@ -101,8 +114,6 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { projectPathUriRef.current = visualizerLocation.projectPath; const moduleNodes = await fetchModuleNodes(); - // Store module variables for later use - moduleVariablesRef.current = moduleNodes.flowModel.variables || []; await fetchAgentNode(); const template = await fetchMcpToolKitTemplate(); @@ -118,35 +129,52 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { setIsLoading(false); }, [editMode, rpcClient]); - const fetchToolListFromMcpServer = useCallback(async (url: string, authValue: string = "") => { - // Resolve the URL variable if needed - const resolvedUrl = await resolveVariableValue( - url, - moduleVariablesRef.current, - rpcClient, - projectPathUriRef.current - ); + const fetchToolsFromServer = useCallback(async ( + url: string, + authValue: string = "", + options?: { + preselectTools?: string[], // If provided, only these tools will be selected; otherwise all tools are selected + skipResolution?: boolean // If true, skip variable resolution (already resolved) + } + ) => { + // Resolve the URL variable if needed (unless already resolved) + const resolvedUrl = options?.skipResolution + ? url + : await resolveVariableValue(url, rpcClient, projectPathUriRef.current, agentFilePathRef.current); const cleanUrl = cleanServerUrl(resolvedUrl); - if (!cleanUrl) { + if (cleanUrl === null) { + setMcpToolsError(""); + setAvailableMcpTools([]); + setSelectedMcpTools(new Set()); + setToolSource(null); + setResolutionError("Unable to resolve Server URL at design time. Tools cannot be auto-fetched with the current configuration."); + setLoadingMcpTools(false); + return []; + } + + // Resolve auth config variables if needed (unless already resolved) + const resolvedAuthValue = options?.skipResolution + ? authValue + : await resolveAuthConfig(authValue, rpcClient, projectPathUriRef.current, agentFilePathRef.current); + + const accessToken = extractAccessToken(resolvedAuthValue); + + if (requiresAuth && accessToken === null) { + setMcpToolsError(""); + setAvailableMcpTools([]); + setSelectedMcpTools(new Set()); + setToolSource(null); + setResolutionError("Unable to resolve authentication configuration at design time. Tools cannot be auto-fetched with the current configuration."); + setLoadingMcpTools(false); return []; } - // Reset state setAvailableMcpTools([]); setSelectedMcpTools(new Set()); setLoadingMcpTools(true); setMcpToolsError(""); - - // Resolve auth config variables if needed - const resolvedAuthValue = await resolveAuthConfig( - authValue, - moduleVariablesRef.current, - rpcClient, - projectPathUriRef.current - ); - - const accessToken = extractAccessToken(resolvedAuthValue); + setResolutionError(""); try { const response = await rpcClient.getAIAgentRpcClient().getMcpTools({ @@ -169,18 +197,20 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { setAvailableMcpTools(response.tools); - // Restore previously selected tools if in edit mode and URL matches - const shouldRestoreTools = editMode && url === mcpToolKitNodeRef.current?.properties?.serverUrl?.value; - const permittedToolsValue = mcpToolKitNodeRef.current?.properties?.permittedTools?.value; - if (shouldRestoreTools && permittedToolsValue) { - const permittedTools = parseToolsString(permittedToolsValue as string, true); - setSelectedMcpTools(new Set(permittedTools)); + // Select tools based on options + if (options?.preselectTools) { + // Only select tools that match the preselected names and exist in the fetched tools + const toolsToSelect = response.tools + .filter(tool => options.preselectTools.includes(tool.name)) + .map(tool => tool.name); + setSelectedMcpTools(new Set(toolsToSelect)); } else { - // Select all tools by default when not in edit mode or when URL changed + // Select all tools by default setSelectedMcpTools(new Set(response.tools.map(tool => tool.name))); } setLoadingMcpTools(false); + setToolSource('auto-fetched'); return response.tools; } catch (error) { console.error(`Failed to fetch tools from MCP server: ${error || 'Unknown error'}`); @@ -189,7 +219,7 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { setLoadingMcpTools(false); return []; } - }, [editMode, rpcClient]); + }, [rpcClient, requiresAuth]); useEffect(() => { initPanel(); @@ -198,7 +228,7 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { const debouncedFetchTools = useMemo( () => debounce((url: string, authValue: string) => { if (url.trim()) { - fetchToolListFromMcpServer(url, authValue); + fetchToolsFromServer(url, authValue); } else { setAvailableMcpTools([]); setSelectedMcpTools(new Set()); @@ -206,12 +236,108 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { setMcpToolsError(""); } }, 500), - [fetchToolListFromMcpServer] + [fetchToolsFromServer] ); useEffect(() => { + if (toolsInclude !== "selected") { + debouncedFetchTools.cancel(); + setAvailableMcpTools([]); + setSelectedMcpTools(new Set()); + setLoadingMcpTools(false); + setMcpToolsError(""); + setResolutionError(""); + setToolSource(null); + return; + } + + if (isInitializingEditModeRef.current) return; + if (editMode && toolSource !== null) return; + debouncedFetchTools(serverUrl, auth); - }, [serverUrl, auth, debouncedFetchTools]); + return () => debouncedFetchTools.cancel(); + }, [serverUrl, auth, toolsInclude, requiresAuth, debouncedFetchTools]); + + useEffect(() => { + // Clear auth field value when requiresAuth is unchecked + if (!requiresAuth && auth) { + setAuth(""); + if (formRef.current?.setFieldValue) { + formRef.current.setFieldValue(AUTH_FIELD_KEY, ""); + } + } + }, [requiresAuth]); + + const initializeEditMode = async () => { + isInitializingEditModeRef.current = true; + + try { + const node = mcpToolKitNodeRef.current; + if (!node) return; + + const { serverUrl: savedUrl, auth: savedAuth, permittedTools, requiresAuth: savedRequiresAuth } = extractOriginalValues(node); + + // Update form state so FormGenerator displays values + setRequiresAuth(savedRequiresAuth); + + // If no tools saved, exit early + if (permittedTools.length === 0) { + return; + } + + // Attempt to resolve variables + const resolution = await attemptValueResolution( + savedUrl, + savedAuth, + rpcClient, + projectPathUriRef.current, + agentFilePathRef.current + ); + + // Set toolSource BEFORE setToolsInclude to prevent the useEffect from triggering a duplicate fetch + setToolSource(resolution.canResolve ? 'auto-fetched' : 'saved-mock'); + setToolsInclude("selected"); + + if (resolution.canResolve) { + // Values CAN be resolved - fetch tools from server and preselect saved tools + const tools = await fetchToolsFromServer(resolution.resolvedUrl, resolution.resolvedAuth, { + preselectTools: permittedTools, + skipResolution: true // Already resolved + }); + + // If fetch failed, fall back to mock tools + if (!tools || tools.length === 0) { + if (resolution.error) { + setResolutionError(resolution.error); + } + displayMockTools(permittedTools); + } + } else { + // Values CANNOT be resolved - show mock tools + if (resolution.error) { + setResolutionError(resolution.error); + } + displayMockTools(permittedTools); + } + } finally { + isInitializingEditModeRef.current = false; + } + }; + + const displayMockTools = (toolNames: string[]) => { + if (toolNames.length === 0) { + setAvailableMcpTools([]); + setSelectedMcpTools(new Set()); + setToolSource(null); + return; + } + + const mockTools = createMockTools(toolNames); + setAvailableMcpTools(mockTools); + setSelectedMcpTools(new Set(toolNames)); // All ticked + setToolSource('saved-mock'); + setMcpToolsError(""); // Clear any connection errors when showing mock tools + }; const handleToolSelectionChange = useCallback((toolName: string, isSelected: boolean) => { setSelectedMcpTools(prev => { @@ -233,6 +359,25 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { } }, [selectedMcpTools.size, availableMcpTools]); + const handleDiscoveredTools = useCallback((tools: Tool[], selectedNames: Set) => { + setAvailableMcpTools(tools); + setSelectedMcpTools(selectedNames); + setToolSource('manual-discovery'); + setShowDiscoverModal(false); + setMcpToolsError(""); + setResolutionError("Loaded tools via manual discovery."); + }, []); + + const handleRetryFetch = useCallback(() => { + // Clear previous errors and reset tool source + setMcpToolsError(""); + setResolutionError(""); + setToolSource(null); + + // Retry fetching tools with current form values + fetchToolsFromServer(serverUrl, auth); + }, [serverUrl, auth, fetchToolsFromServer]); + const handleSave = async (node?: FlowNode) => { setIsSaving(true); try { @@ -257,23 +402,53 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { }, [availableMcpTools.length, selectedMcpTools.size]); const injectedComponents = useMemo(() => { - return [{ - component: ( - - ), - index: 1 - }]; - }, [availableMcpTools, selectedMcpTools, loadingMcpTools, mcpToolsError, serverUrl, handleToolSelectionChange, handleSelectAllTools, isSaveDisabled]); + const shouldShowDiscoverButton = toolsInclude === "selected" + && !loadingMcpTools && (resolutionError !== "" || mcpToolsError !== ""); + + return [ + { + component: ( + setRequiresAuth(prev => !prev)} + /> + ), + index: 1 + }, + { + component: ( + setShowDiscoverModal(true)} + resolutionError={resolutionError} + toolSource={toolSource} + onRetryFetch={handleRetryFetch} + /> + ), + index: 2 + }]; + }, [availableMcpTools, selectedMcpTools, loadingMcpTools, mcpToolsError, serverUrl, handleToolSelectionChange, handleSelectAllTools, isSaveDisabled, requiresAuth, toolsInclude, editMode, toolSource, resolutionError, handleRetryFetch]); + + const fieldOverrides = useMemo(() => ({ + auth: { + advanced: false, + hidden: !requiresAuth + }, + toolKitName: { + advanced: true, + } + }), [requiresAuth]); return ( @@ -295,17 +470,38 @@ export function AddMcpServer(props: AddMcpServerProps): JSX.Element { onChange={(fieldKey, value) => { if (fieldKey === SERVER_URL_FIELD_KEY) { setServerUrl(value); - } - if (fieldKey === AUTH_FIELD_KEY) { + if (editMode && !isInitializingEditModeRef.current && toolSource !== null) { + setToolSource(null); + } + } else if (fieldKey === AUTH_FIELD_KEY) { setAuth(value); + if (editMode && !isInitializingEditModeRef.current && toolSource !== null) { + setToolSource(null); + } } }} + derivedFields={editMode ? [] : [ + { + sourceField: RESULT_FIELD_KEY, + targetField: TOOLKIT_NAME_FIELD_KEY, + deriveFn: generateToolKitName, + breakOnManualEdit: true + } + ]} showProgressIndicator={isSaving} disableSaveButton={isSaveDisabled} injectedComponents={injectedComponents} - fieldPriority={{ auth: 1 }} + fieldOverrides={fieldOverrides} /> )} + + setShowDiscoverModal(false)} + onToolsSelected={handleDiscoveredTools} + rpcClient={rpcClient} + existingToolNames={selectedMcpTools} + /> ); } diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/DiscoverToolsModal.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/DiscoverToolsModal.tsx new file mode 100644 index 00000000000..50d6f3c6b68 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/DiscoverToolsModal.tsx @@ -0,0 +1,399 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useMemo, useCallback } from "react"; +import { createPortal } from "react-dom"; +import styled from "@emotion/styled"; +import { Button, ThemeColors, SearchBox, Codicon, Divider, Typography, TextField, Stepper, Dropdown } from "@wso2/ui-toolkit"; +import type { OptionProps } from "@wso2/ui-toolkit"; +import type { BallerinaRpcClient } from "@wso2/ballerina-rpc-client"; +import { + ToolsList, + formatErrorMessage, + ModalContainer, + ModalBox, + ModalHeaderSection, + ModalContent, + SearchContainer, + ErrorMessage, + LoadingMessage, + InlineSpinner, + ToolsHeader, + InfoMessage +} from "./McpToolsSelection"; +import { cleanServerUrl } from "../formUtils"; + +// McpTool interface (same as in McpToolsSelection) +interface McpTool { + name: string; + description?: string; +} + +interface DiscoverToolsModalProps { + isOpen: boolean; + onClose: () => void; + onToolsSelected: (tools: McpTool[], selectedToolNames: Set) => void; + rpcClient: BallerinaRpcClient; + existingToolNames?: Set; +} + +// Unique styled components for DiscoverToolsModal +const FormSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + padding: 8px 16px; + gap: 8px; +`; + +const BackArrow = styled.div` + cursor: pointer; + display: flex; + align-items: center; + padding: 4px; + margin-right: 8px; + &:hover { + opacity: 0.7; + } +`; + +const StyledModalContent = styled(ModalContent)` + .mcp-stepper { + margin: 12px 0; + } +`; + +const isValidUrl = (url: string): boolean => { + if (!url || !url.trim()) return false; + try { + const urlObj = new URL(url); + return urlObj.protocol === "http:" || urlObj.protocol === "https:"; + } catch { + return false; + } +}; + +export const DiscoverToolsModal: React.FC = ({ + isOpen, + onClose, + onToolsSelected, + rpcClient, + existingToolNames +}) => { + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [manualServerUrl, setManualServerUrl] = useState(""); + const [authType, setAuthType] = useState<"none" | "bearer">("none"); + const [authToken, setAuthToken] = useState(""); + const [discoveredTools, setDiscoveredTools] = useState([]); + const [selectedDiscoveredTools, setSelectedDiscoveredTools] = useState>(new Set()); + const [loadingDiscovery, setLoadingDiscovery] = useState(false); + const [discoveryError, setDiscoveryError] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [urlError, setUrlError] = useState(""); + + const formattedError = useMemo(() => formatErrorMessage(discoveryError), [discoveryError]); + + const authOptions: OptionProps[] = [ + { id: "none", content: "None", value: "none" }, + { id: "bearer", content: "Bearer Auth", value: "bearer" } + ]; + + const handleFetchTools = useCallback(async () => { + // Validate URL + const trimmedUrl = manualServerUrl.trim(); + if (!trimmedUrl) { + setUrlError("Server URL is required"); + return; + } + + const cleanUrl = cleanServerUrl(trimmedUrl); + if (!isValidUrl(cleanUrl)) { + setUrlError("Please enter a valid HTTP or HTTPS URL"); + return; + } + + setUrlError(""); + setDiscoveryError(""); + setLoadingDiscovery(true); + setDiscoveredTools([]); + setSelectedDiscoveredTools(new Set()); + + try { + // Use token directly if Bearer Auth is selected + const accessToken = authType === "bearer" && authToken.trim() + ? authToken.trim() + : ""; + + // Fetch tools from MCP server + const response = await rpcClient.getAIAgentRpcClient().getMcpTools({ + serviceUrl: cleanUrl, + accessToken + }); + + if (response.errorMsg) { + setDiscoveryError(response.errorMsg); + } else if (response.tools && response.tools.length > 0) { + setDiscoveredTools(response.tools); + + // Check if any tools match existing selections + const matchingTools = response.tools + .filter(t => existingToolNames?.has(t.name)) + .map(t => t.name); + + if (matchingTools.length > 0) { + // Pre-select matching tools + setSelectedDiscoveredTools(new Set(matchingTools)); + } else { + // No matches, select all (fallback to original behavior) + setSelectedDiscoveredTools(new Set(response.tools.map(t => t.name))); + } + + // Move to step 2 on successful fetch + setCurrentStep(2); + } else { + setDiscoveredTools([]); + } + } catch (error) { + setDiscoveryError(`Failed to fetch tools: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setLoadingDiscovery(false); + } + }, [manualServerUrl, authToken, authType, rpcClient, existingToolNames]); + + const handleToolSelectionChange = useCallback((toolName: string, isSelected: boolean) => { + setSelectedDiscoveredTools(prev => { + const newSet = new Set(prev); + if (isSelected) { + newSet.add(toolName); + } else { + newSet.delete(toolName); + } + return newSet; + }); + }, []); + + const handleSelectAll = useCallback(() => { + if (selectedDiscoveredTools.size === discoveredTools.length) { + // Deselect all + setSelectedDiscoveredTools(new Set()); + } else { + // Select all + setSelectedDiscoveredTools(new Set(discoveredTools.map(t => t.name))); + } + }, [discoveredTools, selectedDiscoveredTools.size]); + + const handleBack = useCallback(() => { + setCurrentStep(1); + setDiscoveryError(""); + setSearchQuery(""); + }, []); + + const handleAddSelectedTools = useCallback(() => { + if (selectedDiscoveredTools.size === 0) { + setDiscoveryError("Please select at least one tool to continue"); + return; + } + onToolsSelected(discoveredTools, selectedDiscoveredTools); + // Reset state + setCurrentStep(1); + setManualServerUrl(""); + setAuthType("none"); + setAuthToken(""); + setDiscoveredTools([]); + setSelectedDiscoveredTools(new Set()); + setDiscoveryError(""); + setSearchQuery(""); + setUrlError(""); + }, [discoveredTools, selectedDiscoveredTools, onToolsSelected]); + + const handleClose = useCallback(() => { + // Reset state when closing + setCurrentStep(1); + setManualServerUrl(""); + setAuthType("none"); + setAuthToken(""); + setDiscoveredTools([]); + setSelectedDiscoveredTools(new Set()); + setDiscoveryError(""); + setSearchQuery(""); + setUrlError(""); + onClose(); + }, [onClose]); + + if (!isOpen) return null; + + return createPortal( + + e.stopPropagation()}> + +
+ {currentStep === 2 && ( + + + + )} + + Discover MCP Tools + +
+
+ +
+
+ + + + + {currentStep === 1 ? ( + <> + + + Enter server details to discover available tools. This URL is for discovery only and won't update the Server URL field. + + + ) => { + setManualServerUrl(e.target.value); + setUrlError(""); + }} + disabled={loadingDiscovery} + errorMsg={urlError} + required + /> + + { + const newAuthType = value as "none" | "bearer"; + setAuthType(newAuthType); + if (newAuthType === "none") { + setAuthToken(""); + } + }} + disabled={loadingDiscovery} + containerSx={{ width: "100%" }} + /> + + {authType === "bearer" && ( + ) => setAuthToken(e.target.value)} + disabled={loadingDiscovery} + description="Enter your bearer token for authentication" + type="password" + /> + )} + + + {loadingDiscovery && ( + + + Fetching tools from MCP server... + + )} + + {discoveryError && !loadingDiscovery && ( + {formattedError} + )} + + ) : ( + <> + + Select the tools you want to add. You can search and filter the available tools below. + + + + setSearchQuery(val)} + value={searchQuery} + iconPosition="end" + aria-label="search-tools" + sx={{ width: '100%' }} + /> + + + + + {selectedDiscoveredTools.size} of {discoveredTools.length} selected + + + + + + + {discoveryError && ( + {formattedError} + )} + + )} + + + + {currentStep === 1 ? ( + + ) : ( + + )} + +
+
, + document.body + ); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/McpToolsSelection.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/McpToolsSelection.tsx similarity index 58% rename from workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/McpToolsSelection.tsx rename to workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/McpToolsSelection.tsx index 71f1212cf1b..d01ab40799d 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/McpToolsSelection.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/McpToolsSelection.tsx @@ -16,10 +16,11 @@ * under the License. */ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { createPortal } from "react-dom"; import styled from "@emotion/styled"; -import { Button, CheckBox, ThemeColors, SearchBox, Codicon, Divider, Typography } from "@wso2/ui-toolkit"; +import { Button, CheckBox, ThemeColors, SearchBox, Codicon, Divider, Typography, Dropdown, Tooltip, Icon } from "@wso2/ui-toolkit"; +import type { OptionProps } from "@wso2/ui-toolkit"; export interface McpTool { name: string; @@ -29,7 +30,7 @@ export interface McpTool { // Utility function to clean up error messages const formatErrorMessage = (error: string): string => { if (!error) return error; - + // Check if it's an HTML response (GitHub 404, etc.) if (error.includes('')) { // Try to extract meaningful info from HTML @@ -46,17 +47,12 @@ const formatErrorMessage = (error: string): string => { } return 'The server returned an HTML page instead of MCP tools. Please verify the URL is correct.'; } - - // Check for network errors - if (error.includes('Network error') || error.includes('Failed to fetch')) { - return 'Network error. Please check your connection and the server URL.'; - } - + // Truncate very long error messages if (error.length > 500) { return error.substring(0, 500) + '...'; } - + return error; }; @@ -69,6 +65,13 @@ interface McpToolsSelectionProps { onSelectAll: () => void; serviceUrl?: string; showValidationError?: boolean; + toolsInclude?: string; + onToolsIncludeChange?: (value: string) => void; + showDiscoverButton?: boolean; + onDiscoverClick?: () => void; + toolSource?: 'auto-fetched' | 'manual-discovery' | 'saved-mock' | null; + resolutionError?: string; + onRetryFetch?: () => void; } interface ToolsListProps { @@ -88,12 +91,12 @@ const ToolsContainer = styled.div` border-radius: 8px; width: 100%; `; -const ToolsHeader = styled.div` +export const ToolsHeader = styled.div<{ padding?: string }>` display: flex; flex-direction: row; align-items: center; justify-content: space-between; - padding: 12px 12px 6px 12px; + padding: ${(props: { padding?: string }) => props.padding || '12px 12px 6px 12px'}; `; const ToolsTitle = styled.div` font-size: 14px; @@ -118,12 +121,11 @@ const ToolCheckboxItem = styled.div<{ disabled?: boolean }>` padding: 4px 0; cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'default' : 'pointer'}; `; -const ErrorMessage = styled.div` +export const ErrorMessage = styled.div<{ padding?: string; maxHeight?: string }>` color: ${ThemeColors.ERROR}; font-size: 12px; - padding: 0 0 12px 12px; - max-height: 100px; - overflow-y: auto; + padding: ${(props: { padding?: string }) => props.padding || '0 0 4px 12px'}; + ${(props: { maxHeight?: string }) => props.maxHeight ? `max-height: ${props.maxHeight}; overflow-y: auto;` : ''} word-break: break-word; white-space: pre-wrap; `; @@ -135,15 +137,15 @@ const WarningMessage = styled.div` align-items: center; gap: 6px; `; -const LoadingMessage = styled.div` +export const LoadingMessage = styled.div<{ padding?: string }>` color: ${ThemeColors.ON_SURFACE_VARIANT}; font-size: 12px; display: flex; align-items: center; - padding: 0 0 12px 12px; + padding: ${(props: { padding?: string }) => props.padding || '0 0 12px 12px'}; gap: 8px; `; -const InlineSpinner = styled.span` +export const InlineSpinner = styled.span` display: inline-block; width: 16px; height: 16px; @@ -186,13 +188,14 @@ const ReadMoreButton = styled.button` } `; -const InfoMessage = styled.div` +export const InfoMessage = styled.div<{ padding?: string }>` color: ${ThemeColors.ON_SURFACE_VARIANT}; font-size: 12px; - padding: 0 12px; + padding: ${(props: { padding?: string }) => props.padding || '0 12px'}; `; -const ModalContainer = styled.div` +// Shared Modal Components +export const ModalContainer = styled.div` position: fixed; top: 0; left: 0; @@ -206,9 +209,9 @@ const ModalContainer = styled.div` font-family: GilmerRegular; `; -const ModalBox = styled.div` +export const ModalBox = styled.div<{ maxHeight?: string }>` width: 650px; - max-height: 80vh; + max-height: ${(props: { maxHeight?: string }) => props.maxHeight || '80vh'}; position: relative; display: flex; flex-direction: column; @@ -220,7 +223,7 @@ const ModalBox = styled.div` z-index: 30001; `; -const ModalHeaderSection = styled.header` +export const ModalHeaderSection = styled.header` display: flex; align-items: center; justify-content: space-between; @@ -228,15 +231,15 @@ const ModalHeaderSection = styled.header` margin-bottom: 8px; `; -const ModalContent = styled.div` +export const ModalContent = styled.div<{ marginTop?: string; padding?: string }>` flex: 1; overflow-y: auto; - padding: 0 16px; + padding: ${(props: { padding?: string }) => props.padding || '0 16px'}; + ${(props: { marginTop?: string }) => props.marginTop ? `margin-top: ${props.marginTop};` : ''} `; -const SearchContainer = styled.div` - padding: 12px 16px; - border-bottom: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +export const SearchContainer = styled.div<{ padding?: string }>` + padding: ${(props: { padding?: string }) => props.padding || '12px 16px'}; `; const ExpandButton = styled.button` @@ -244,7 +247,7 @@ const ExpandButton = styled.button` border: none; color: ${ThemeColors.ON_SURFACE}; cursor: pointer; - padding: 4px; + padding: 2px; display: flex; align-items: center; justify-content: center; @@ -410,6 +413,10 @@ const ToolsSelectionModal: React.FC<{ ); }; +// Export shared components and utilities +export { ToolsList, formatErrorMessage }; +export type { ToolsListProps }; + export const McpToolsSelection: React.FC = ({ tools, selectedTools, @@ -418,25 +425,64 @@ export const McpToolsSelection: React.FC = ({ onToolSelectionChange, onSelectAll, serviceUrl, - showValidationError = false + showValidationError = false, + toolsInclude = "all", + onToolsIncludeChange, + showDiscoverButton = false, + onDiscoverClick, + resolutionError = "", + toolSource = null, + onRetryFetch }) => { const [isModalOpen, setIsModalOpen] = useState(false); const formattedError = useMemo(() => formatErrorMessage(error), [error]); + const toolsIncludeOptions: OptionProps[] = [ + { id: "all", content: "All", value: "all" }, + { id: "selected", content: "Selected", value: "selected" } + ]; + + useEffect(() => { + if (toolsInclude === "all" && selectedTools.size > 0) { + // Clear all selections + selectedTools.forEach(toolName => { + onToolSelectionChange(toolName, false); + }); + } + }, [toolsInclude, selectedTools, onToolSelectionChange]); + return ( <> - - - Available Tools -
+ onToolsIncludeChange?.(value)} + containerSx={{ width: "100%" }} + /> + + {toolsInclude === "selected" && ( + + + Available Tools {tools.length > 0 && ( - <> +
+ {showDiscoverButton && (toolSource === 'saved-mock' || toolSource === 'manual-discovery') && ( + + + + )} setIsModalOpen(true)} title="Expand view" aria-label="Expand tools selection" > - + - +
)} -
-
- {loading && ( - - - Loading tools from MCP server... - - )} - {error && ( - <> - - Unable to load tools from MCP server. - - {formattedError} - - )} - {!loading && tools.length > 0 && ( - <> - {showValidationError && selectedTools.size === 0 ? ( - - Select at least one tool to continue - - ) : ( - - {selectedTools.size} of {tools.length} selected + + {loading && ( + + + Loading tools from MCP server... + + )} + {showDiscoverButton && toolSource !== 'saved-mock' && toolSource !== 'manual-discovery' && toolSource !== 'auto-fetched' && !error && ( + <> + + {resolutionError || "Tools cannot be loaded. Server URL or authentication configuration cannot be resolved."} - )} - - - )} - {!loading && !error && tools.length === 0 && serviceUrl?.trim() && ( - - No tools available from this MCP server - - )} - {!loading && !error && tools.length === 0 && !serviceUrl?.trim() && ( - - Enter a server URL to view available tools - - )} -
+
+ + +
+ + )} + {error && ( + <> + + Unable to load tools from MCP server. + + {formattedError} + {onRetryFetch && ( +
+ +
+ )} + + )} + {resolutionError && toolSource === 'saved-mock' && !error && ( + + {resolutionError}. Using saved tool selections. + + )} + {!loading && tools.length > 0 && (!showDiscoverButton || toolSource === 'saved-mock' || toolSource === 'manual-discovery' || toolSource === 'auto-fetched') && ( + <> + {showValidationError && selectedTools.size === 0 ? ( + + Select at least one tool to continue + + ) : ( + + {selectedTools.size} of {tools.length} selected + + )} + + + )} + {!loading && !error && !showDiscoverButton && tools.length === 0 && serviceUrl?.trim() && ( + + No tools available from this MCP server + + )} + {!loading && !error && !showDiscoverButton && tools.length === 0 && !serviceUrl?.trim() && ( + + Enter a server URL to view available tools + + )} + + )} void; +} + +export const RequiresAuthCheckbox: React.FC = ({ checked, onChange }) => { + const handleToggle = () => { + onChange(!checked); + }; + + return ( + +
+ Requires Authentication + + Enable if the server requires authentication + +
+ + { }} + sx={{ display: "contents" }} + /> + +
+ ); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/utils.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/utils.ts new file mode 100644 index 00000000000..b1f1ad5ea12 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/Mcp/utils.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FlowNode } from "@wso2/ballerina-core"; +import type { BallerinaRpcClient } from "@wso2/ballerina-rpc-client"; +import { resolveVariableValue, resolveAuthConfig, parseToolsString } from "../utils"; +import { cleanServerUrl } from "../formUtils"; + +export interface Tool { + name: string; + description?: string; +} + +export interface ResolutionResult { + canResolve: boolean; + resolvedUrl: string; + resolvedAuth: string; + error: string; +} + +export async function attemptValueResolution( + serverUrl: string, + auth: string, + rpcClient: BallerinaRpcClient, + projectPathUri: string, + filePath: string +): Promise { + let error = ""; + + // Try to resolve server URL + let resolvedUrl = null; + try { + resolvedUrl = await resolveVariableValue( + serverUrl, + rpcClient, + projectPathUri, + filePath + ); + + // Check if resolution actually worked (not just returned the variable name) + const cleanUrl = cleanServerUrl(resolvedUrl); + if (cleanUrl === null || resolvedUrl === serverUrl) { + // Resolution failed or returned same value (likely a variable) + error = "Server URL contains unresolvable variables"; + } + } catch (e) { + error = `Failed to resolve server URL: ${e instanceof Error ? e.message : String(e)}`; + } + + // Try to resolve auth + let resolvedAuthValue = null; + if (auth && auth.trim()) { + try { + resolvedAuthValue = await resolveAuthConfig( + auth, + rpcClient, + projectPathUri, + filePath + ); + if (resolvedAuthValue === null) { + error = "Authentication configuration contains unresolvable variables"; + } + } catch (e) { + if (!error) { + error = `Failed to resolve auth: ${e instanceof Error ? e.message : String(e)}`; + } + } + } + + return { + canResolve: !error && resolvedAuthValue !== null && resolvedUrl !== null && resolvedUrl !== serverUrl, + resolvedUrl: resolvedUrl, + resolvedAuth: resolvedAuthValue, + error: error + }; +} + +export function createMockTools(toolNames: string[]): Tool[] { + return toolNames.map(name => ({ + name: name, + description: undefined as string | undefined + })); +} + +export function extractOriginalValues(node: FlowNode): { + serverUrl: string; + auth: string; + permittedTools: string[]; + requiresAuth: boolean; + result: string; + toolKitName: string; +} { + const serverUrl = (node.properties?.serverUrl?.value as string) || ""; + const auth = (node.properties?.auth?.value as string) || ""; + const permittedToolsValue = (node.properties?.permittedTools?.value as string) || ""; + const permittedTools = parseToolsString(permittedToolsValue, true); + const requiresAuth = Boolean(auth && auth.trim()); + const result = (node.properties?.variable?.value as string) || ""; + const toolKitName = (node.properties?.toolKitName?.value as string) || ""; + + return { + serverUrl, + auth, + permittedTools, + requiresAuth, + result, + toolKitName + }; +} + +export const generateToolKitName = (resultValue: string): string => { + const trimmed = resultValue?.trim(); + if (!trimmed) return ""; + const pascalCase = convertToPascalCase(trimmed); + return /toolkit/i.test(trimmed) ? pascalCase : `${pascalCase}Toolkit`; +}; + +const convertToPascalCase = (input: string): string => { + if (!input) return ""; + + const words = input.split(/[_\-\s]+/).filter(word => word.length > 0); + if (words.length > 1) { + return words + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); + } + return input.charAt(0).toUpperCase() + input.slice(1); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/formUtils.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/formUtils.ts index 02da2f0289c..839276bc033 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/formUtils.ts +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/formUtils.ts @@ -224,6 +224,7 @@ export function createToolParameters(): ToolParameters { }; } -export const cleanServerUrl = (url: string): string => { +export const cleanServerUrl = (url: string): string | null => { + if (url === null || url === undefined) return null; return url.replace(/^"|"$/g, '').trim(); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/utils.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/utils.ts index 2470e39b8e1..93b1fa29814 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/utils.ts +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/AIChatAgent/utils.ts @@ -515,12 +515,12 @@ export const parseToolsString = (toolsStr: string, removeQuotes: boolean = false * Extracts access token from auth value string. * Expected format: {token: "..."} */ -export const extractAccessToken = (authValue: string): string => { - if (!authValue) return ""; +export const extractAccessToken = (authValue: string): string | null => { + if (authValue === null) return null; try { const tokenMatch = authValue.match(/token:\s*"([^"]*)"/); - return tokenMatch?.[1] ?? ""; + return tokenMatch?.[1] ?? null; } catch (error) { console.error("Failed to parse auth token:", error); return ""; @@ -566,7 +566,7 @@ export const getEndOfFileLineRange = async ( */ export const isStringLiteral = (value: string): boolean => { const trimmed = value.trim(); - return trimmed.startsWith('"') && trimmed.endsWith('"'); + return (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("string `") && trimmed.endsWith("`")); }; /** @@ -575,6 +575,9 @@ export const isStringLiteral = (value: string): boolean => { export const removeQuotes = (value: string): string => { const trimmed = value.trim(); if (isStringLiteral(trimmed)) { + if (trimmed.startsWith("string `") && trimmed.endsWith("`")) { + return trimmed.substring(8, trimmed.length - 1); + } return trimmed.substring(1, trimmed.length - 1); } return trimmed; @@ -583,17 +586,23 @@ export const removeQuotes = (value: string): string => { /** * Finds a variable value in module variables. */ -export const findValueInModuleVariables = ( +export const findValueInModuleVariables = async ( variableName: string, - moduleVariables: FlowNode[] -): string | null => { - if (!moduleVariables || !Array.isArray(moduleVariables)) { + rpcClient: BallerinaRpcClient, + filePath: string +): Promise => { + const queryMap: SearchNodesQueryParams = { + kind: "VARIABLE", + exactMatch: variableName + }; + + const variables = await findFlowNode(rpcClient, filePath, undefined, queryMap); + + if (!variables || variables.length === 0) { return null; } - const variable = moduleVariables.find( - (varNode) => varNode.properties?.variable?.value === variableName - ); + const variable = variables[0]; if (variable?.properties?.expression?.value && !variable?.codedata?.sourceCode?.includes("configurable")) { return variable.properties.expression.value as string; @@ -637,9 +646,9 @@ export const findValueInConfigVariables = async ( ); if (variable) { // Return the value from configValue or defaultValue - const configValue = variable.properties?.configValue?.value as string; - const defaultValue = variable.properties?.defaultValue?.value as string; - return configValue || defaultValue || null; + const configValue = variable.properties?.configValue?.value as string | null; + if (configValue === "" || configValue === null) return null; + return configValue; } } } @@ -668,19 +677,25 @@ export const isUrl = (value: string): boolean => { export const resolveVariableValue = async ( value: string, - moduleVariables: FlowNode[], - rpcClient?: BallerinaRpcClient, - projectPathUri?: string -): Promise => { + rpcClient: BallerinaRpcClient, + projectPathUri: string, + filePath: string +): Promise => { if (!value) { - return ""; + return null; } const trimmed = value.trim(); - // String literal - remove quotes + // String literal - remove quotes and check for interpolation if (isStringLiteral(trimmed)) { - return removeQuotes(trimmed); + const content = removeQuotes(trimmed); + const interpolationMatch = content.match(/^\s*\$\{([^}]+)\}\s*$/); + if (interpolationMatch) { + const variableName = interpolationMatch[1]; + return resolveVariableValue(variableName, rpcClient, projectPathUri, filePath); + } + return content; } // URL - return as-is to skip variable lookups @@ -689,21 +704,18 @@ export const resolveVariableValue = async ( } // Check module variables - const moduleValue = findValueInModuleVariables(trimmed, moduleVariables); + const moduleValue = await findValueInModuleVariables(trimmed, rpcClient, filePath); if (moduleValue) { return removeQuotes(moduleValue); } // Check config variables - if (rpcClient && projectPathUri) { - const configValue = await findValueInConfigVariables(trimmed, rpcClient, projectPathUri); - if (configValue) { - return removeQuotes(configValue); - } + const configValue = await findValueInConfigVariables(trimmed, rpcClient, projectPathUri); + if (configValue) { + return removeQuotes(configValue); } - // Treat as literal value - return trimmed; + return null; }; /** @@ -712,10 +724,10 @@ export const resolveVariableValue = async ( */ export const resolveAuthConfig = async ( authValue: string, - moduleVariables: FlowNode[], - rpcClient?: BallerinaRpcClient, - projectPathUri?: string -): Promise => { + rpcClient: BallerinaRpcClient, + projectPathUri: string, + filePath: string +): Promise => { if (!authValue) { return ""; } @@ -738,11 +750,16 @@ export const resolveAuthConfig = async ( // Resolve the variable const resolvedValue = await resolveVariableValue( variableOrValue, - moduleVariables, rpcClient, - projectPathUri + projectPathUri, + filePath ); + // Return null if the variable cannot be resolved + if (resolvedValue === null) { + return null; + } + // Replace in the auth string with quoted value resolvedAuth = resolvedAuth.replace( fullMatch, diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/APIConnectionPopup/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/APIConnectionPopup/index.tsx new file mode 100644 index 00000000000..e5da97bd414 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/APIConnectionPopup/index.tsx @@ -0,0 +1,512 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useMemo, useState } from "react"; +import styled from "@emotion/styled"; +import { Button, Codicon, Dropdown, Stepper, TextField, ThemeColors, Typography } from "@wso2/ui-toolkit"; +import { AvailableNode, Category, DataMapperDisplayMode, DIRECTORY_MAP, FlowNode, LinePosition, ParentPopupData } from "@wso2/ballerina-core"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; +import { ExpressionFormField } from "@wso2/ballerina-side-panel"; +import ConnectionConfigView from "../ConnectionConfigView"; +import { FormSubmitOptions } from "../../FlowDiagram"; +import { PopupOverlay, PopupContainer, PopupHeader, BackButton, HeaderTitleContainer, PopupTitle, PopupSubtitle, CloseButton } from "../styles"; + +const StepperContainer = styled.div` + padding: 20px 32px 18px 32px; + border-bottom: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +const ContentContainer = styled.div<{ hasFooterButton?: boolean }>` + flex: 1; + display: flex; + flex-direction: column; + overflow: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "hidden" : "auto"}; + padding: 24px 32px; + padding-bottom: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "0" : "24px"}; + min-height: 0; +`; + +const FooterContainer = styled.div` + position: sticky; + bottom: 0; + padding: 20px 32px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +`; + +const StepContent = styled.div<{ fillHeight?: boolean }>` + display: flex; + flex-direction: column; + gap: 20px; + ${(props: { fillHeight?: boolean }) => props.fillHeight && ` + flex: 1; + min-height: 0; + height: 100%; + `} +`; + +const SectionTitle = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const SectionSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const FormSection = styled.div` + display: flex; + flex-direction: column; + gap: 30px; + background: transparent; + border: none; + padding: 0; +`; + +const FormField = styled.div` + display: flex; + flex-direction: column; +`; + +const UploadCard = styled.div<{ hasFile?: boolean }>` + display: flex; + align-items: center; + gap: 12px; + padding: 16px 18px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 14px; + background: ${ThemeColors.SURFACE_DIM}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: ${ThemeColors.PRIMARY}; + background: ${ThemeColors.SURFACE_CONTAINER}; + } + + ${(props: { hasFile?: boolean }) => + props.hasFile + ? ` + border-color: ${ThemeColors.PRIMARY}; + background: ${ThemeColors.PRIMARY_CONTAINER}; + ` + : ""} +`; + +const UploadIcon = styled.div` + width: 48px; + height: 48px; + border-radius: 12px; + background: ${ThemeColors.SURFACE_BRIGHT}; + display: flex; + align-items: center; + justify-content: center; + color: ${ThemeColors.ON_SURFACE}; +`; + +const UploadText = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const UploadTitle = styled(Typography)` + font-size: 14px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const UploadSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const ActionButton = styled(Button)` + width: 100% !important; + min-width: 0 !important; + display: flex !important; + justify-content: center; + align-items: center; +`; + +const StepHeader = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +interface APIConnectionPopupProps { + projectPath: string; + fileName: string; + target?: LinePosition; + onClose?: (parent?: ParentPopupData) => void; + onBack?: () => void; +} + +export function APIConnectionPopup(props: APIConnectionPopupProps) { + const { projectPath, fileName, target, onBack, onClose } = props; + const { rpcClient } = useRpcContext(); + + const [currentStep, setCurrentStep] = useState(0); + const [specType, setSpecType] = useState("OpenAPI"); + const [selectedFilePath, setSelectedFilePath] = useState(""); + const [connectorName, setConnectorName] = useState(""); + + const [isSavingConnector, setIsSavingConnector] = useState(false); + const [isSavingConnection, setIsSavingConnection] = useState(false); + const [selectedFlowNode, setSelectedFlowNode] = useState(undefined); + const [updatedExpressionField, setUpdatedExpressionField] = useState(undefined); + + const steps = useMemo(() => ["Import API Specification", "Create Connection"], []); + + const apiSpecOptions = useMemo( + () => [ + { id: "openapi", value: "OpenAPI", content: "OpenAPI" }, + { id: "wsdl", value: "WSDL", content: "WSDL" }, + ], + [] + ); + + const supportedFileFormats = useMemo(() => { + const isOpenApi = specType.toLowerCase() === "openapi"; + return isOpenApi ? ".yaml, .yml, .json" : ".wsdl"; + }, [specType]); + + const handleFileSelect = async () => { + if (!rpcClient) { + return; + } + const projectDirectory = await rpcClient.getCommonRpcClient().selectFileOrDirPath({ isFile: true }); + if (projectDirectory.path) { + setSelectedFilePath(projectDirectory.path); + } + }; + + const getFileName = (filePath: string) => { + if (!filePath) return ""; + const parts = filePath.split(/[/\\]/); + return parts[parts.length - 1]; + }; + + const handleOnGenerateSubmit = async (specFilePath: string, module: string, specType: string) => { + if (!rpcClient) { + return { success: false, errorMessage: "RPC client not available" }; + } + + const isOpenApi = specType.toLowerCase() === "openapi"; + + if (isOpenApi) { + const response = await rpcClient.getBIDiagramRpcClient().generateOpenApiClient({ + openApiContractPath: specFilePath, + projectPath: projectPath, + module: module, + }); + return { + success: !response.errorMessage, + errorMessage: response.errorMessage, + }; + } else { + // WSDL generation + const response = await rpcClient.getConnectorWizardRpcClient().generateWSDLApiClient({ + wsdlFilePath: specFilePath, + projectPath: projectPath, + module: module, + }); + return { + success: !response.errorMsg, + errorMessage: response.errorMsg, + }; + } + }; + + const findConnectorByModule = (categories: Category[], moduleName: string): AvailableNode | null => { + for (const category of categories) { + if (category.items) { + for (const item of category.items) { + if ("codedata" in item) { + const availableNode = item as AvailableNode; + if (availableNode.codedata?.module === moduleName) { + return availableNode; + } + } + } + } + } + return null; + }; + + const handleSaveConnector = async () => { + if (!selectedFilePath || !connectorName || !rpcClient) { + return; + } + setIsSavingConnector(true); + const generateResponse = await handleOnGenerateSubmit(selectedFilePath, connectorName, specType); + + // Only proceed if there's no error message + if (generateResponse?.success) { + try { + // Small delay to ensure the connector is available + await new Promise(resolve => setTimeout(resolve, 500)); + + const defaultPosition = target || { line: 0, offset: 0 }; + const searchResponse = await rpcClient.getBIDiagramRpcClient().search({ + position: { + startLine: defaultPosition, + endLine: defaultPosition, + }, + filePath: fileName, + queryMap: { + limit: 60, + filterByCurrentOrg: false, + }, + searchKind: "CONNECTOR", + }); + + // Find the connector we just created + const createdConnector = findConnectorByModule(searchResponse.categories, connectorName); + if (createdConnector && createdConnector.codedata) { + // Get the flowNode template + const nodeTemplateResponse = await rpcClient.getBIDiagramRpcClient().getNodeTemplate({ + position: target || null, + filePath: fileName, + id: createdConnector.codedata, + }); + setSelectedFlowNode(nodeTemplateResponse.flowNode); + } else { + console.warn(">>> Created connector not found in search results"); + } + } catch (error) { + console.error(">>> Error finding created connector", error); + } + setCurrentStep(1); + } else { + console.error(">>> Error generating connector:", generateResponse?.errorMessage); + } + setIsSavingConnector(false); + }; + + const handleOnFormSubmit = async (node: FlowNode, _dataMapperMode?: DataMapperDisplayMode, options?: FormSubmitOptions) => { + console.log(">>> on form submit", node); + if (selectedFlowNode) { + setIsSavingConnection(true); + const visualizerLocation = await rpcClient.getVisualizerLocation(); + let connectionsFilePath = visualizerLocation.documentUri || visualizerLocation.projectPath; + + if (node.codedata.isGenerated && !connectionsFilePath.endsWith(".bal")) { + connectionsFilePath += "/main.bal"; + } + + if (connectionsFilePath === "") { + console.error(">>> Error updating source code. No source file found"); + setIsSavingConnection(false); + return; + } + + // node property scope is local. then use local file path and line position + if ((node.properties?.scope?.value as string)?.toLowerCase() === "local") { + node.codedata.lineRange = { + fileName: visualizerLocation.documentUri, + startLine: target, + endLine: target, + }; + } + + // Check if the node is a connector + const isConnector = node.codedata.node === "NEW_CONNECTION"; + + rpcClient + .getBIDiagramRpcClient() + .getSourceCode({ + filePath: connectionsFilePath, + flowNode: node, + isConnector: isConnector, + }) + .then((response) => { + console.log(">>> Updated source code", response); + if (response.artifacts.length > 0) { + setIsSavingConnection(false); + const newConnection = response.artifacts.find((artifact) => artifact.isNew); + onClose?.({ recentIdentifier: newConnection.name, artifactType: DIRECTORY_MAP.CONNECTION }); + } else { + console.error(">>> Error updating source code", response); + setIsSavingConnection(false); + } + }) + .catch((error) => { + console.error(">>> Error saving connection", error); + }).finally(() => { + setIsSavingConnection(false); + }); + } + }; + + const renderStepper = () => { + return ( + <> + + + + + ); + }; + + const renderImportStep = () => ( + + + + Connector Configuration + + + Import API specification for the connector + + + + + setSpecType(value)} + /> + + + + Connector Name + + + Name of the connector module to be generated + + setConnectorName(value)} + placeholder="Enter connector name" + /> + + + + Import Specification File + + + + + + + + {selectedFilePath ? selectedFilePath : "Choose file to import"} + + Supports {supportedFileFormats} files + + + + + + ); + + const renderConnectionStep = () => { + if (selectedFlowNode) { + return ( + +
+ Connection Details + + Configure connection settings + +
+ setUpdatedExpressionField(undefined)} + isPullingConnector={isSavingConnection} + footerActionButton={true} + /> +
+ ); + } + return ( + + + + Loading connector configuration... + + + + ); + }; + + const renderStepContent = () => { + if (currentStep === 0) { + return renderImportStep(); + } + return renderConnectionStep(); + }; + + return ( + <> + + + + + + + + Connect via API Specification + Import an API specification file to create a connection + + onClose?.()}> + + + + {renderStepper()} + {renderStepContent()} + {currentStep === 0 && ( + + + {isSavingConnector ? "Saving..." : "Save Connector"} + + + )} + + + ); +} + +export default APIConnectionPopup; + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/AddConnectionPopup/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/AddConnectionPopup/index.tsx new file mode 100644 index 00000000000..c6f9946ae97 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/AddConnectionPopup/index.tsx @@ -0,0 +1,729 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useState, useMemo, useCallback } from "react"; +import styled from "@emotion/styled"; +import { AvailableNode, Category, Item, LinePosition, ParentPopupData } from "@wso2/ballerina-core"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; +import { Button, Codicon, Icon, SearchBox, ThemeColors, Typography, ProgressRing } from "@wso2/ui-toolkit"; +import { cloneDeep, debounce } from "lodash"; +import ButtonCard from "../../../../components/ButtonCard"; +import { ConnectorIcon } from "@wso2/bi-diagram"; +import APIConnectionPopup from "../APIConnectionPopup"; +import ConnectionConfigurationPopup from "../ConnectionConfigurationPopup"; +import DatabaseConnectionPopup from "../DatabaseConnectionPopup"; +import { BodyTinyInfo } from "../../../styles"; +import { PopupOverlay, PopupContainer, PopupHeader, PopupTitle, CloseButton } from "../styles"; + +const PopupContent = styled.div` + flex: 1; + overflow-y: auto; + padding: 24px 32px; + display: flex; + flex-direction: column; + gap: 24px; +`; + +const IntroText = styled(Typography)` + font-size: 14px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + line-height: 1.5; + margin: 0; +`; + +const SearchContainer = styled.div` + width: 100%; +`; + +const StyledSearchBox = styled(SearchBox)` + width: 100%; +`; + +const Section = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SectionTitle = styled(Typography)` + font-size: 14px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const CreateConnectorOptions = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const ConnectorOptionCard = styled.div` + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_DIM}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: ${ThemeColors.PRIMARY_CONTAINER}; + border-color: ${ThemeColors.PRIMARY}; + } +`; + +const ConnectorOptionIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_CONTAINER}; + flex-shrink: 0; +`; + +const ConnectorOptionContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ConnectorOptionTitle = styled(Typography)` + font-size: 14px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const ConnectorOptionDescription = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const ConnectorOptionButtons = styled.div` + display: flex; + gap: 8px; + flex-wrap: wrap; +`; + +const ConnectorTypeButton = styled(Button)` + font-size: 12px; + height: auto; +`; + +const ArrowIcon = styled.div` + display: flex; + align-items: center; + color: ${ThemeColors.ON_SURFACE_VARIANT}; +`; + +const SectionHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +`; + +const FilterButtons = styled.div` + display: flex; + gap: 4px; + align-items: center; +`; + +const FilterButton = styled.button<{ active?: boolean }>` + font-size: 12px; + padding: 6px 12px; + height: 28px; + border-radius: 4px; + border: none; + cursor: pointer; + font-weight: ${(props: { active?: boolean }) => (props.active ? 600 : 400)}; + background-color: ${(props: { active?: boolean }) => + props.active ? ThemeColors.PRIMARY : "transparent"}; + color: ${(props: { active?: boolean }) => + props.active ? ThemeColors.ON_PRIMARY : ThemeColors.ON_SURFACE_VARIANT}; + transition: all 0.2s ease; + + &:hover { + background-color: ${(props: { active?: boolean }) => + props.active ? ThemeColors.PRIMARY : ThemeColors.SURFACE_CONTAINER}; + color: ${(props: { active?: boolean }) => + props.active ? ThemeColors.ON_PRIMARY : ThemeColors.ON_SURFACE}; + } +`; + +const ConnectorsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-top: 8px; +`; + +interface AddConnectionPopupProps { + projectPath: string; + fileName: string; + target?: LinePosition; + onClose?: (parent?: ParentPopupData) => void; + onNavigateToOverview?: () => void; +} + +export function AddConnectionPopup(props: AddConnectionPopupProps) { + const { projectPath, fileName, target, onClose, onNavigateToOverview } = props; + const { rpcClient } = useRpcContext(); + + const [searchText, setSearchText] = useState(""); + const [connectors, setConnectors] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [fetchingInfo, setFetchingInfo] = useState(false); + const [filterType, setFilterType] = useState<"All" | "Standard" | "Organization">("All"); + const [wizardStep, setWizardStep] = useState<"database" | "api" | "connector" | null>(null); + const [selectedConnector, setSelectedConnector] = useState(null); + const [experimentalEnabled, setExperimentalEnabled] = useState(false); + + useEffect(() => { + rpcClient + ?.getCommonRpcClient() + .experimentalEnabled() + .then((enabled) => setExperimentalEnabled(enabled)) + .catch((err) => { + console.error(">>> error checking experimental flag", err); + setExperimentalEnabled(false); + }); + }, [rpcClient]); + + const fetchConnectors = useCallback((filter?: boolean) => { + setFetchingInfo(true); + const defaultPosition: LinePosition = { line: 0, offset: 0 }; + const position = target || defaultPosition; + rpcClient + .getBIDiagramRpcClient() + .search({ + position: { + startLine: position, + endLine: position, + }, + filePath: fileName, + queryMap: { + limit: 60, + filterByCurrentOrg: filter ?? filterType === "Organization", + }, + searchKind: "CONNECTOR", + }) + .then(async (model) => { + console.log(">>> bi connectors", model); + console.log(">>> bi filtered connectors", model.categories); + setConnectors(model.categories); + }) + .finally(() => { + setIsSearching(false); + setFetchingInfo(false); + }); + }, [rpcClient, target, fileName, filterType]); + + useEffect(() => { + setIsSearching(true); + fetchConnectors(); + }, []); + + const handleSearch = useCallback((text: string) => { + const defaultPosition: LinePosition = { line: 0, offset: 0 }; + const position = target || defaultPosition; + rpcClient + .getBIDiagramRpcClient() + .search({ + position: { + startLine: position, + endLine: position, + }, + filePath: fileName, + queryMap: { + q: text, + limit: 60, + filterByCurrentOrg: filterType === "Organization" ? true : false, + }, + searchKind: "CONNECTOR", + }) + .then(async (model) => { + console.log(">>> bi searched connectors", model); + console.log(">>> bi filtered connectors", model.categories); + + // When searching, the API might return a flat array of connectors instead of categories + // Check if categories exist and have the proper structure (with items arrays) + let normalizedCategories: Category[] = []; + + if (model.categories && Array.isArray(model.categories)) { + // Check if the first item is a category (has items) or a connector (has codedata) + const firstItem = model.categories[0]; + if (firstItem && "items" in firstItem && Array.isArray(firstItem.items)) { + // Proper category structure - use as is + normalizedCategories = model.categories; + } else if (firstItem && "codedata" in firstItem) { + // Flat array of connectors - wrap in a category + normalizedCategories = [{ + metadata: { + label: "Search Results", + description: "" + }, + items: model.categories as unknown as AvailableNode[] + }]; + } + } + + console.log(">>> normalized categories for search", normalizedCategories); + setConnectors(normalizedCategories); + }) + .finally(() => { + setIsSearching(false); + }); + }, [rpcClient, target, fileName, filterType]); + + const debouncedSearch = useMemo( + () => debounce(handleSearch, 1100), + [handleSearch] + ); + + useEffect(() => { + setIsSearching(true); + debouncedSearch(searchText); + return () => debouncedSearch.cancel(); + }, [searchText, debouncedSearch]); + + useEffect(() => { + setIsSearching(true); + fetchConnectors(); + }, [filterType, fetchConnectors]); + + useEffect(() => { + rpcClient?.onProjectContentUpdated((state: boolean) => { + if (state) { + fetchConnectors(); + } + }); + }, [rpcClient, fetchConnectors]); + + const handleOnSearch = (text: string) => { + setSearchText(text); + }; + + const handleDatabaseConnection = () => { + // Navigate to database connection wizard + setWizardStep("database"); + }; + + const handleApiSpecConnection = () => { + // Navigate to API spec connection wizard (OpenAPI/WSDL) + setWizardStep("api"); + }; + + const handleSelectConnector = (connector: AvailableNode) => { + if (!connector.codedata) { + console.error(">>> Error selecting connector. No codedata found"); + return; + } + setSelectedConnector(connector); + setWizardStep("connector"); + }; + + const handleBackToConnectorList = () => { + setWizardStep(null); + setSelectedConnector(null); + }; + + const handleCloseWizard = (parent?: ParentPopupData) => { + // If a parent payload is provided, we are done with the entire flow. + // Close this popup (and navigate back) without resetting internal state first, + if (parent) { + onClose?.(parent); + if (onNavigateToOverview) { + onNavigateToOverview(); + } + return; + } + + // Otherwise, just close the inner wizard and go back to the connector list. + setWizardStep(null); + setSelectedConnector(null); + }; + + const filterItems = (items: Item[]): Item[] => { + return items + .map((item) => { + if ("items" in item) { + const filteredItems = filterItems(item.items); + return { + ...item, + items: filteredItems, + }; + } else { + const lowerCaseTitle = item.metadata.label.toLowerCase(); + const lowerCaseDescription = item.metadata.description?.toLowerCase() || ""; + const lowerCaseSearchText = searchText.toLowerCase(); + if ( + lowerCaseTitle.includes(lowerCaseSearchText) || + lowerCaseDescription.includes(lowerCaseSearchText) + ) { + return item; + } + } + }) + .filter(Boolean); + }; + + const filteredCategories = cloneDeep(connectors).map((category) => { + if (!category || !category.items) { + return category; + } + // Only apply client-side filtering if there's no search text (backend already filtered) + if (searchText) { + // When searching, show all items from backend results + return category; + } + category.items = filterItems(category.items); + return category; + }).filter((category) => { + if (!category) { + return false; + } + // When searching, show all categories that have items + if (searchText) { + return category.items && category.items.length > 0; + } + // Map filterType to category labels similar to ConnectorView + // "Standard" maps to "StandardLibrary" (exclude Local and CurrentOrg) + // "Organization" maps to "CurrentOrg" + if (filterType === "Standard") { + return category.metadata.label !== "Local" && category.metadata.label !== "CurrentOrg"; + } else if (filterType === "Organization") { + return category.metadata.label === "CurrentOrg"; + } + // "All" shows all categories except Local (which is handled separately) + return category.metadata.label !== "Local"; + }); + + const isLoading = isSearching || fetchingInfo; + + // Show configuration form when connector is selected + if (wizardStep === "connector" && selectedConnector) { + return ( + + ); + } + + if (wizardStep === "api") { + return ( + <> + + + + ); + } + + if (wizardStep === "database") { + return ( + + ); + } + + const handleClosePopup = () => { + if (onNavigateToOverview) { + onNavigateToOverview(); + } else { + onClose?.(); + } + }; + + const openLearnMoreURL = () => { + rpcClient.getCommonRpcClient().openExternalUrl({ + url: 'https://ballerina.io/learn/publish-packages-to-ballerina-central/' + }) + }; + + const getConnectorCreationOptions = () => { + if (!searchText || searchText.trim() === "") { + // No search - show both options (database only if experimental) + return { showApiSpec: true, showDatabase: experimentalEnabled }; + } + + const lowerSearchText = searchText.toLowerCase().trim(); + + // Database-related keywords + const databaseKeywords = [ + "database", "db", "mysql", "postgresql", "postgres", "mssql", "sql server", + "sqlserver", "oracle", "sqlite", "mariadb", "mongodb", "cassandra", + "redis", "dynamodb", "table", "schema", "query", "sql" + ]; + + // API-related keywords + const apiKeywords = [ + "api", "http", "https", "rest", "graphql", "soap", "wsdl", "openapi", + "swagger", "endpoint", "service", "client", "request", "response", + "json", "xml", "yaml", "websocket", "rpc" + ]; + + const isDatabaseSearch = databaseKeywords.some(keyword => lowerSearchText.includes(keyword)); + const isApiSearch = apiKeywords.some(keyword => lowerSearchText.includes(keyword)); + + // If search matches database keywords, show only database option + if (isDatabaseSearch && !isApiSearch) { + return { showApiSpec: false, showDatabase: experimentalEnabled }; + } + + // If search matches API keywords, show only API spec option + if (isApiSearch && !isDatabaseSearch) { + return { showApiSpec: true, showDatabase: false }; + } + + // If both or neither match, show both options + return { showApiSpec: true, showDatabase: experimentalEnabled }; + }; + + const connectorOptions = getConnectorCreationOptions(); + + return ( + <> + + + + Add Connection + handleClosePopup()}> + + + + + + {experimentalEnabled ? ( + <> + To establish your connection, first define a connector. You may create a custom connector using + an API specification or by introspecting a database. Alternatively, you can select one of the + pre-built connectors below. You will then be guided to provide the required details to complete + the connection setup. + + ) : ( + <> + To establish your connection, first define a connector. You may create a custom connector using + an API specification. Alternatively, you can select one of the pre-built connectors below. You will then be guided to provide the required details to complete + the connection setup. + + )} + + + + + + + + {(connectorOptions.showApiSpec || connectorOptions.showDatabase) && ( +
+ CREATE NEW CONNECTOR + + {connectorOptions.showApiSpec && ( + + + + + + Connect via API Specification + + Import an OpenAPI or WSDL file to create a connector + + + + OpenAPI + + + WSDL + + + + + + + + )} + {connectorOptions.showDatabase && ( + + + + + + Connect to a Database + + Enter credentials to introspect and discover database tables + + + + MySQL + + + MSSQL + + + PostgreSQL + + + + + + + + )} + +
+ )} + +
+ + PRE-BUILT CONNECTORS + + setFilterType("All")} + > + All + + setFilterType("Standard")} + > + Standard + + setFilterType("Organization")} + > + Organization + + + + {isLoading && ( +
+ +
+ )} + {!isLoading && filteredCategories && filteredCategories.length > 0 && ( + + {filteredCategories.map((category, index) => { + if (!category.items || category.items.length === 0) { + return null; + } + + return ( + + {category.items.map((connector, connectorIndex) => { + const availableNode = connector as AvailableNode; + if (!("codedata" in connector)) { + return null; + } + return ( + + ) : ( + + ) + } + onClick={() => handleSelectConnector(availableNode)} + /> + ); + })} + + ); + })} + + )} + {!isLoading && (!filteredCategories || filteredCategories.length === 0) && ( +
+ {filterType === "Organization" ? ( + <> + + No connectors found in your organization. You can create and publish connectors to Ballerina Central. + + + Learn how to{' '} + { + openLearnMoreURL(); + }} + > + publish packages to Ballerina Central + + + + ) : ( + + No connectors found. + + )} +
+ )} +
+
+
+ + ); +} + +export default AddConnectionPopup; + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigView/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigView/index.tsx index 046628e7094..c577a7fc5aa 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigView/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigView/index.tsx @@ -26,9 +26,17 @@ import { SidePanelView } from "../../FlowDiagram/PanelManager"; import { ConnectionKind } from "../../../../components/ConnectionSelector"; import { FormSubmitOptions } from "../../FlowDiagram"; -const Container = styled.div` - max-width: 600px; - height: calc(100% - 32px); +const Container = styled.div<{ footerActionButton?: boolean }>` + max-width: 800px; + ${(props: { footerActionButton?: boolean }) => props.footerActionButton ? ` + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + ` : ` + height: calc(100% - 32px); + `} `; export interface SidePanelProps { @@ -58,6 +66,7 @@ interface ConnectionConfigViewProps { isActiveSubPanel?: boolean; isPullingConnector?: boolean; navigateToPanel?: (targetPanel: SidePanelView, connectionKind?: ConnectionKind) => void; + footerActionButton?: boolean; // Render save button as footer action button } export function ConnectionConfigView(props: ConnectionConfigViewProps) { @@ -72,6 +81,7 @@ export function ConnectionConfigView(props: ConnectionConfigViewProps) { submitText, isSaving, navigateToPanel, + footerActionButton, } = props; const { rpcClient } = useRpcContext(); const [targetLineRange, setTargetLineRange] = useState(); @@ -101,7 +111,7 @@ export function ConnectionConfigView(props: ConnectionConfigViewProps) { }, [fileName, selectedNode, rpcClient]); return ( - + {targetLineRange && ( diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigurationPopup/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigurationPopup/index.tsx new file mode 100644 index 00000000000..166642f5056 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/ConnectionConfigurationPopup/index.tsx @@ -0,0 +1,514 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import { + AvailableNode, + Category, + DataMapperDisplayMode, + DIRECTORY_MAP, + FlowNode, + LinePosition, + ParentPopupData, + SubPanel, + SubPanelView, +} from "@wso2/ballerina-core"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; +import { Codicon, Icon, ThemeColors, Typography } from "@wso2/ui-toolkit"; +import { ConnectorIcon } from "@wso2/bi-diagram"; +import ConnectionConfigView from "../ConnectionConfigView"; +import { getFormProperties } from "../../../../utils/bi"; +import { ExpressionFormField } from "@wso2/ballerina-side-panel"; +import { RelativeLoader } from "../../../../components/RelativeLoader"; +import { HelperView } from "../../HelperView"; +import { DownloadIcon } from "../../../../components/DownloadIcon"; +import { FormSubmitOptions } from "../../FlowDiagram"; +import { cloneDeep } from "lodash"; +import { URI, Utils } from "vscode-uri"; +import { PopupOverlay, PopupContainer, PopupHeader as ConfigHeader, BackButton, HeaderTitleContainer as ConfigTitleContainer, PopupTitle, PopupSubtitle as ConfigSubtitle, CloseButton } from "../styles"; + +const ConnectorInfoCard = styled.div` + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + margin: 24px 32px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_DIM}; + position: relative; +`; + +const ConnectorInfoIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_CONTAINER}; + flex-shrink: 0; + + & > img { + width: 32px; + height: 32px; + object-fit: contain; + } + + & > svg { + width: 32px; + height: 32px; + } +`; + +const StyledCodicon = styled(Codicon)` + font-size: 32px; + width: 32px; + height: 32px; +`; + +const StyledConnectorIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + + & > img { + width: 32px; + height: 32px; + object-fit: contain; + } + + & > svg { + width: 32px; + height: 32px; + } +`; + +const ConnectorInfoContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const ConnectorInfoName = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const ConnectorInfoDescription = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const ConnectorTag = styled.div` + position: absolute; + top: 12px; + right: 12px; + padding: 4px 12px; + border-radius: 12px; + background-color: ${ThemeColors.SURFACE_CONTAINER}; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +const TagText = styled(Typography)` + font-size: 11px; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const ConfigContent = styled.div<{ hasFooterButton?: boolean }>` + flex: 1; + display: flex; + flex-direction: column; + overflow: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "hidden" : "auto"}; + padding: 0 32px ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "0" : "24px"} 32px; + min-height: 0; +`; + +const FormContainer = styled.div<{}>` + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +`; + +const ConnectionDetailsSection = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +`; + +const ConnectionDetailsTitle = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const ConnectionDetailsSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const StatusContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 40px; +`; + +const StatusCard = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + & > svg { + font-size: 32px; + width: 32px; + height: 32px; + color: ${ThemeColors.ON_SURFACE}; + } +`; + +const StatusText = styled(Typography)` + margin-top: 16px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + font-size: 14px; + text-align: center; +`; + +enum PullingStatus { + FETCHING = "fetching", + PULLING = "pulling", + SUCCESS = "success", + ERROR = "error", +} + +enum SavingFormStatus { + SAVING = "saving", + SUCCESS = "success", + ERROR = "error", +} + +interface ConnectionConfigurationPopupProps { + selectedConnector: AvailableNode; + fileName: string; + target?: LinePosition; + onClose: (parent?: ParentPopupData) => void; + onBack: () => void; + filteredCategories?: Category[]; +} + +export function ConnectionConfigurationPopup(props: ConnectionConfigurationPopupProps) { + const { selectedConnector, fileName, target, onClose, onBack, filteredCategories = [] } = props; + const { rpcClient } = useRpcContext(); + + const [pullingStatus, setPullingStatus] = useState(undefined); + const [savingFormStatus, setSavingFormStatus] = useState(undefined); + const selectedNodeRef = useRef(); + const [updatedExpressionField, setUpdatedExpressionField] = useState(undefined); + + useEffect(() => { + // Fetch node template when component mounts + const fetchNodeTemplate = async () => { + if (!selectedConnector.codedata) { + console.error(">>> Error selecting connector. No codedata found"); + return; + } + + try { + let timer: ReturnType | null = null; + let didTimeout = false; + + // Set status to FETCHING before starting + setPullingStatus(PullingStatus.FETCHING); + selectedNodeRef.current = undefined; + + // Start a timer for 3 seconds + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => { + didTimeout = true; + setPullingStatus(PullingStatus.PULLING); + resolve(); + }, 3000); + }); + + // Start the request + const nodeTemplatePromise = rpcClient.getBIDiagramRpcClient().getNodeTemplate({ + position: target || null, + filePath: fileName, + id: selectedConnector.codedata, + }); + + // Wait for either the timer or the request to finish + const response = await Promise.race([ + nodeTemplatePromise.then((res) => { + if (timer) { + clearTimeout(timer); + timer = null; + } + return res; + }), + timeoutPromise.then(() => nodeTemplatePromise), + ]); + + if (didTimeout) { + // If it timed out, set status to SUCCESS + setPullingStatus(PullingStatus.SUCCESS); + } + + console.log(">>> FlowNode template", response); + selectedNodeRef.current = response.flowNode; + const formProperties = getFormProperties(response.flowNode); + console.log(">>> Form properties", formProperties); + + if (Object.keys(formProperties).length === 0) { + // add node to source code + handleOnFormSubmit(response.flowNode); + return; + } + } catch (error) { + console.error(">>> Error selecting connector", error); + setPullingStatus(PullingStatus.ERROR); + } finally { + // After few seconds, set status to undefined + setTimeout(() => { + setPullingStatus(undefined); + }, 2000); + } + }; + + fetchNodeTemplate(); + }, [selectedConnector, fileName, target, rpcClient]); + + const handleOnFormSubmit = async (node: FlowNode, _dataMapperMode?: DataMapperDisplayMode, options?: FormSubmitOptions) => { + console.log(">>> on form submit", node); + if (selectedNodeRef.current) { + setSavingFormStatus(SavingFormStatus.SAVING); + const visualizerLocation = await rpcClient.getVisualizerLocation(); + let connectionsFilePath = visualizerLocation.documentUri || visualizerLocation.projectPath; + + if (node.codedata.isGenerated && !connectionsFilePath.endsWith(".bal")) { + connectionsFilePath = Utils.joinPath(URI.file(connectionsFilePath), "main.bal").fsPath; + } + + if (connectionsFilePath === "") { + console.error(">>> Error updating source code. No source file found"); + setSavingFormStatus(SavingFormStatus.ERROR); + return; + } + + // node property scope is local. then use local file path and line position + if ((node.properties?.scope?.value as string)?.toLowerCase() === "local") { + node.codedata.lineRange = { + fileName: visualizerLocation.documentUri, + startLine: target, + endLine: target, + }; + } + + // Check if the node is a connector + const isConnector = node.codedata.node === "NEW_CONNECTION"; + + rpcClient + .getBIDiagramRpcClient() + .getSourceCode({ + filePath: connectionsFilePath, + flowNode: node, + isConnector: isConnector, + }) + .then((response) => { + console.log(">>> Updated source code", response); + if (!isConnector) { + selectedNodeRef.current = undefined; + if (options?.postUpdateCallBack) { + options.postUpdateCallBack(); + } + return; + } + if (response.artifacts.length > 0) { + // clear memory + selectedNodeRef.current = undefined; + setSavingFormStatus(SavingFormStatus.SUCCESS); + const newConnection = response.artifacts.find((artifact) => artifact.isNew); + onClose({ recentIdentifier: newConnection.name, artifactType: DIRECTORY_MAP.CONNECTION }); + } else { + console.error(">>> Error updating source code", response); + setSavingFormStatus(SavingFormStatus.ERROR); + } + }) + .catch((error) => { + console.error(">>> Error updating source code", error); + setSavingFormStatus(SavingFormStatus.ERROR); + }); + } + }; + + const handleResetUpdatedExpressionField = () => { + setUpdatedExpressionField(undefined); + }; + + const getConnectorTag = () => { + if (selectedConnector.codedata?.org === "ballerinax") { + return "Standard"; + } + const isStandard = filteredCategories.some( + (cat) => + cat.metadata.label !== "CurrentOrg" && + cat.items?.some((item) => (item as AvailableNode).codedata?.id === selectedConnector.codedata?.id) + ); + return isStandard ? "Standard" : "Organization"; + }; + + return ( + <> + + + + + + + + Configure {selectedConnector.metadata.label} + + Configure connection settings for this connector + + + onClose()}> + + + + + + + {selectedConnector.metadata.icon ? ( + + + + ) : ( + + )} + + + {selectedConnector.metadata.label} + + {selectedConnector.metadata.description || ""} + + + + {getConnectorTag()} + + + + + {pullingStatus && ( + + {pullingStatus === PullingStatus.FETCHING && ( + + )} + {pullingStatus === PullingStatus.PULLING && ( + + + + Please wait while the connector is being pulled. + + + )} + {pullingStatus === PullingStatus.SUCCESS && ( + + + Connector pulled successfully. + + )} + {pullingStatus === PullingStatus.ERROR && ( + + + + Failed to pull the connector. Please try again. + + + )} + + )} + {!pullingStatus && selectedNodeRef.current && (() => { + // Remove description property from node before passing to form + // since it's already shown in the connector info card + const nodeWithoutDescription = cloneDeep(selectedNodeRef.current); + if (nodeWithoutDescription.metadata?.description) { + delete nodeWithoutDescription.metadata.description; + } + return ( + <> + + Connection Details + + Configure your connection settings + + + + + + + ); + })()} + + + + ); +} + +export default ConnectionConfigurationPopup; + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/DatabaseConnectionPopup/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/DatabaseConnectionPopup/index.tsx new file mode 100644 index 00000000000..c8547e1d288 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/DatabaseConnectionPopup/index.tsx @@ -0,0 +1,729 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useMemo, useState } from "react"; +import styled from "@emotion/styled"; +import { Button, Codicon, ThemeColors, Typography, TextField, Dropdown, OptionProps, Icon, SearchBox } from "@wso2/ui-toolkit"; +import { Stepper } from "@wso2/ui-toolkit"; +import { DIRECTORY_MAP, LinePosition, ParentPopupData } from "@wso2/ballerina-core"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; +import { PopupOverlay, PopupContainer, PopupHeader, BackButton, HeaderTitleContainer, PopupTitle, PopupSubtitle, CloseButton } from "../styles"; + +const StepperContainer = styled.div` + padding: 24px 32px; + border-bottom: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +const ContentContainer = styled.div<{ hasFooterButton?: boolean }>` + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + padding: 24px 32px; + padding-bottom: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "0" : "24px"}; + min-height: 0; +`; + +const StepContent = styled.div<{ fillHeight?: boolean }>` + display: flex; + flex-direction: column; + gap: 24px; + ${(props: { fillHeight?: boolean }) => props.fillHeight && ` + flex: 1; + min-height: 0; + height: 100%; + `} +`; + +const FooterContainer = styled.div` + position: sticky; + bottom: 0; + padding: 20px 32px; + display: flex; + justify-content: center; + align-items: center; + z-index: 10; +`; + +const SectionTitle = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const SectionSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +const FormSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const FormField = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ReadonlyValue = styled.div` + padding: 5px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 2px; + background: ${ThemeColors.SURFACE_CONTAINER}; + color: ${ThemeColors.ON_SURFACE}; + font-size: 14px; + line-height: 20px; + user-select: text; +`; + +const ActionButton = styled(Button)` + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + display: flex !important; + justify-content: center; + align-items: center; + align-self: stretch; + box-sizing: border-box; + + & > div { + width: 100% !important; + max-width: 100% !important; + } +`; + +const TablesGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-top: 16px; +`; + +const TableCard = styled.div<{ selected?: boolean }>` + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid ${(props: { selected?: boolean }) => (props.selected ? ThemeColors.PRIMARY : ThemeColors.OUTLINE_VARIANT)}; + border-radius: 8px; + background-color: ${(props: { selected?: boolean }) => (props.selected ? ThemeColors.PRIMARY_CONTAINER : ThemeColors.SURFACE_DIM)}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: ${ThemeColors.PRIMARY}; + background-color: ${ThemeColors.PRIMARY_CONTAINER}; + } +`; + +const TableCheckbox = styled.input` + width: 18px; + height: 18px; + cursor: pointer; +`; + +const TableName = styled(Typography)` + font-size: 14px; + font-weight: 500; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const SelectAllButton = styled(Button)` + align-self: flex-end; +`; + +const SelectionInfo = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const SearchRow = styled.div` + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; +`; + +const ConfigurablesPanel = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_DIM}; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + margin-top: 16px; +`; + +const ConfigurablesDescription = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; + line-height: 1.5; +`; + +const ErrorContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border-radius: 8px; + background-color: ${ThemeColors.SURFACE_DIM}; + border: 1px solid ${ThemeColors.ERROR}; + margin-top: 16px; +`; + +const ErrorHeader = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const ErrorTitle = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ERROR}; + margin: 0; +`; + +const SeparatorLine = styled.div` + width: 100%; + height: 1px; + background-color: ${ThemeColors.OUTLINE_VARIANT}; + opacity: 0.5; +`; + +const BrowseMoreButton = styled(Button)` + margin-top: 0; + width: 100% !important; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--vscode-button-secondaryBackground, #3c3c3c) !important; + color: var(--vscode-button-secondaryForeground, #ffffff) !important; + border-radius: 4px; + + &:hover { + background-color: var(--vscode-button-secondaryHoverBackground, #4a4a4a) !important; + } + + & > span { + color: var(--vscode-button-secondaryForeground, #ffffff) !important; + } +`; + +interface DatabaseConnectionPopupProps { + fileName: string; + target?: LinePosition; + onClose?: (data?: ParentPopupData) => void; + onBack?: () => void; + onBrowseConnectors?: () => void; +} + +type DatabaseType = "PostgreSQL" | "MySQL" | "MSSQL"; + +interface DatabaseCredentials { + databaseType: DatabaseType; + host: string; + port: number; + databaseName: string; + username: string; + password: string; +} + +interface DatabaseTable { + name: string; + selected: boolean; +} + +const DATABASE_TYPES: OptionProps[] = [ + { id: "postgresql", value: "PostgreSQL", content: "PostgreSQL" }, + { id: "mysql", value: "MySQL", content: "MySQL" }, + { id: "mssql", value: "MSSQL", content: "MSSQL" }, +]; + +const DEFAULT_PORTS: Record = { + PostgreSQL: 5432, + MySQL: 3306, + MSSQL: 1433, +}; + +export function DatabaseConnectionPopup(props: DatabaseConnectionPopupProps) { + const { fileName, target, onClose, onBack, onBrowseConnectors } = props; + const { rpcClient } = useRpcContext(); + + const [currentStep, setCurrentStep] = useState(0); + const [credentials, setCredentials] = useState({ + databaseType: "MySQL", + host: "", + port: 3306, + databaseName: "", + username: "", + password: "", + }); + const [tables, setTables] = useState([]); + const [isIntrospecting, setIsIntrospecting] = useState(false); + const [connectionName, setConnectionName] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [tableSearch, setTableSearch] = useState(""); + + const steps = ["Introspect Database", "Select Tables", "Create Connection"]; + + const handleDatabaseTypeChange = (value: string) => { + const dbType = value as DatabaseType; + setCredentials({ + ...credentials, + databaseType: dbType, + port: DEFAULT_PORTS[dbType], + }); + }; + + const handleCredentialsChange = (field: keyof DatabaseCredentials, value: string) => { + setCredentials({ + ...credentials, + [field]: field === "port" ? Number(value) : value, + }); + // Clear error when user modifies credentials + if (connectionError) { + setConnectionError(null); + } + }; + + const handleIntrospect = async () => { + setIsIntrospecting(true); + setConnectionError(null); + try { + // Map database type to dbSystem format expected by RPC + const dbSystemMap: Record = { + PostgreSQL: "postgresql", + MySQL: "mysql", + MSSQL: "mssql", + }; + + const visualizerLocation = await rpcClient.getVisualizerLocation(); + const projectPath = visualizerLocation.projectPath; + + const response = await rpcClient.getConnectorWizardRpcClient().introspectDatabase({ + projectPath: projectPath, + dbSystem: dbSystemMap[credentials.databaseType], + host: credentials.host, + port: credentials.port, + database: credentials.databaseName, + user: credentials.username, + password: credentials.password, + }); + + if (response.errorMsg) { + console.error(">>> Error introspecting database", response.errorMsg); + setConnectionError(response.errorMsg); + return; + } + + if (response.tables && response.tables.length > 0) { + const databaseTables: DatabaseTable[] = response.tables.map((tableName) => ({ + name: tableName, + selected: false, + })); + setTables(databaseTables); + setCurrentStep(1); + setConnectionError(null); + } else { + console.warn(">>> No tables found in database"); + setConnectionError("No tables found in the database."); + } + } catch (error) { + console.error(">>> Error introspecting database", error); + const errorMessage = error instanceof Error ? error.message : "Unable to connect to the database. Please verify your credentials and ensure the database server is accessible."; + setConnectionError(errorMessage); + } finally { + setIsIntrospecting(false); + } + }; + + const handleTableToggleByName = (name: string) => { + setTables((prev) => + prev.map((t) => + t.name === name ? { ...t, selected: !t.selected } : t + ) + ); + }; + + const handleSelectAll = () => { + const allSelected = tables.every((table) => table.selected); + setTables(tables.map((table) => ({ ...table, selected: !allSelected }))); + }; + + const handleContinueToConnectionDetails = () => { + setCurrentStep(2); + }; + + const handleSaveConnection = async () => { + setIsSaving(true); + try { + // Get project path + const visualizerLocation = await rpcClient.getVisualizerLocation(); + const projectPath = visualizerLocation.projectPath; + + // Map database type to dbSystem format expected by RPC + const dbSystemMap: Record = { + PostgreSQL: "postgresql", + MySQL: "mysql", + MSSQL: "mssql", + }; + + // Get selected tables - if all tables are selected, use ["*"] + const selectedTableNames = tables.filter((t) => t.selected).map((t) => t.name); + const allTablesSelected = tables.length > 0 && selectedTableNames.length === tables.length; + const selectedTables = allTablesSelected ? ["*"] : selectedTableNames; + + const response = await rpcClient.getConnectorWizardRpcClient().persistClientGenerate({ + projectPath: projectPath, + name: connectionName, + dbSystem: dbSystemMap[credentials.databaseType], + host: credentials.host, + port: credentials.port, + user: credentials.username, + password: credentials.password, + database: credentials.databaseName, + selectedTables: selectedTables, + }); + + if (response.errorMsg) { + console.error(">>> Error saving connection", response.errorMsg); + if (response.stackTrace) { + console.error(">>> Stack trace", response.stackTrace); + } + // TODO: Show error message to user + return; + } + + // Log success and text edits info if available + if (response.source?.textEditsMap) { + console.log(">>> Connection created successfully with text edits", Object.keys(response.source.textEditsMap)); + } + if (response.source?.isModuleExists !== undefined) { + console.log(">>> Module exists:", response.source.isModuleExists); + } + + + onClose?.({ recentIdentifier: connectionName, artifactType: DIRECTORY_MAP.CONNECTION }); + } catch (error) { + console.error(">>> Error saving connection", error); + // TODO: Show error message to user + } finally { + setIsSaving(false); + } + }; + + const handleBrowseMoreConnectors = () => { + if (onBrowseConnectors) { + onBrowseConnectors(); + } else { + // Fallback: close this popup and let parent handle navigation + onClose?.(); + } + }; + + const selectedTablesCount = tables.filter((t) => t.selected).length; + const totalTablesCount = tables.length; + const filteredTables = useMemo( + () => + tables.filter((t) => + t.name.toLowerCase().includes(tableSearch.trim().toLowerCase()) + ), + [tables, tableSearch] + ); + + const renderErrorDisplay = () => { + if (!connectionError) return null; + + return ( + + + + Connection Failed + + + Unable to connect to the database. Please verify your credentials and ensure the database server is accessible. + + + + Or try using a pre-built connector: + + + Browse Pre-built Connectors + + + ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( + +
+ Database Credentials + + Enter credentials to connect and introspect the database + +
+ {renderErrorDisplay()} + + + + + + handleCredentialsChange("host", value)} + /> + + + handleCredentialsChange("port", value)} + /> + + + handleCredentialsChange("databaseName", value)} + /> + + + handleCredentialsChange("username", value)} + /> + + + handleCredentialsChange("password", value)} + /> + + +
+ ); + + case 1: + return ( + + +
+ Select Tables + + Choose which tables to include in this connector + +
+ + {selectedTablesCount} of {totalTablesCount} selected + +
+ + + + Select All + + + + {filteredTables.map((table) => ( + handleTableToggleByName(table.name)} + > + { + e.stopPropagation(); + handleTableToggleByName(table.name); + }} + onClick={(e) => e.stopPropagation()} + /> + {table.name} + + ))} + +
+ ); + + case 2: + return ( + +
+ Connection Details + + Name your connection and configure default values for configurables + +
+ + + + + + +
+ Connection Configurables + + Configurables will be generated for the connection host, port, username, password, and database name, with default values specified below. + +
+ + + + {connectionName ? `${connectionName}Host` : "Host"} + + {credentials.host} + + + + {connectionName ? `${connectionName}Port` : "Port"} + + {credentials.port} + + + + {connectionName ? `${connectionName}User` : "Username"} + + {credentials.username} + + + + {connectionName ? `${connectionName}Database` : "Database Name"} + + {credentials.databaseName} + + +
+
+ ); + + default: + return null; + } + }; + + return ( + <> + + + + + + + + Connect to a Database + + Enter database credentials to introspect and discover available tables + + + onClose?.()}> + + + + + + + {renderStepContent()} + {currentStep === 0 && ( + + + {isIntrospecting ? "Connecting..." : "Connect & Introspect Database"} + + + )} + {currentStep === 1 && ( + + + Continue to Connection Details + + + )} + {currentStep === 2 && ( + + + {isSaving ? "Saving..." : "Save Connection"} + + + )} + + + ); +} + +export default DatabaseConnectionPopup; + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/EditConnectionPopup/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/EditConnectionPopup/index.tsx new file mode 100644 index 00000000000..0050ab700f4 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/EditConnectionPopup/index.tsx @@ -0,0 +1,259 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useState } from "react"; +import styled from "@emotion/styled"; +import { FlowNode, LinePosition, ParentPopupData } from "@wso2/ballerina-core"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; +import { Codicon, ThemeColors, Typography, ProgressRing } from "@wso2/ui-toolkit"; +import ConnectionConfigView from "../ConnectionConfigView"; +import { getFormProperties } from "../../../../utils/bi"; +import { ExpressionFormField } from "@wso2/ballerina-side-panel"; +import { cloneDeep } from "lodash"; +import { PopupOverlay, PopupContainer, PopupHeader as ConfigHeader, BackButton, HeaderTitleContainer as ConfigTitleContainer, PopupTitle, PopupSubtitle as ConfigSubtitle, CloseButton } from "../styles"; + +const ConnectionDetailsSection = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +`; + +const ConnectionDetailsTitle = styled(Typography)` + font-size: 16px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +const ConnectionDetailsSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + + +const ContentContainer = styled.div<{ hasFooterButton?: boolean }>` + flex: 1; + display: flex; + flex-direction: column; + overflow: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "hidden" : "auto"}; + padding: 24px 32px; + padding-bottom: ${(props: { hasFooterButton?: boolean }) => props.hasFooterButton ? "0" : "24px"}; +`; + +const LoadingContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + padding: 40px; +`; + +interface EditConnectionPopupProps { + connectionName: string; + fileName?: string; + target?: LinePosition; + onClose?: (data?: ParentPopupData) => void; +} + +export function EditConnectionPopup(props: EditConnectionPopupProps) { + const { connectionName, fileName, target, onClose } = props; + const { rpcClient } = useRpcContext(); + + const [connection, setConnection] = useState(); + const [filePath, setFilePath] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [updatedExpressionField, setUpdatedExpressionField] = useState(undefined); + + useEffect(() => { + const fetchConnection = async () => { + setIsLoading(true); + try { + const res = await rpcClient.getBIDiagramRpcClient().getModuleNodes(); + console.log(">>> moduleNodes", { moduleNodes: res }); + + if (!res.flowModel.connections || res.flowModel.connections.length === 0) { + console.error(">>> No connections found"); + if (onClose) { + onClose(); + } else { + rpcClient.getVisualizerRpcClient()?.goHome(); + } + return; + } + + const connector = res.flowModel.connections.find( + (node) => node.properties.variable.value === connectionName + ); + + if (!connector) { + console.error(">>> Error finding connector", { connectionName }); + if (onClose) { + onClose(); + } else { + rpcClient.getVisualizerRpcClient()?.goHome(); + } + return; + } + + const connectionFile = connector.codedata.lineRange.fileName; + const connectionFilePath = (await rpcClient.getVisualizerRpcClient().joinProjectPath({ + segments: [connectionFile] + })).filePath; + + setFilePath(connectionFilePath); + setConnection(connector); + + const formProperties = getFormProperties(connector); + console.log(">>> Connector form properties", formProperties); + } catch (error) { + console.error(">>> Error fetching connection", error); + if (onClose) { + onClose(); + } else { + rpcClient.getVisualizerRpcClient()?.goHome(); + } + } finally { + setIsLoading(false); + } + }; + + fetchConnection(); + }, [connectionName, rpcClient]); + + const handleClosePopup = () => { + if (onClose) { + onClose(); + } else { + rpcClient.getVisualizerRpcClient()?.goHome(); + } + }; + + const handleOnFormSubmit = async (node: FlowNode) => { + console.log(">>> on form submit", node); + if (connection) { + setIsSaving(true); + try { + if (filePath === "") { + console.error(">>> Error updating source code. No source file found"); + setIsSaving(false); + return; + } + + const response = await rpcClient.getBIDiagramRpcClient().getSourceCode({ + filePath: filePath, + flowNode: node, + isConnector: true, + }); + + console.log(">>> Updated source code", response); + if (response.artifacts.length > 0) { + handleClosePopup(); + } else { + console.error(">>> Error updating source code", response); + } + } catch (error) { + console.error(">>> Error saving connection", error); + } finally { + setIsSaving(false); + } + } + }; + + + const handleResetUpdatedExpressionField = () => { + setUpdatedExpressionField(undefined); + }; + + + const handleBack = () => { + handleClosePopup(); + }; + + if (isLoading) { + return ( + <> + + + + + + + + ); + } + + if (!connection) { + return null; + } + + return ( + <> + + + + + + + + Edit Connection + + Update connection details + + + + + + + + <> + + Connection Details + + Configure your connection settings + + + { + // Remove description property from node before passing to form + // since it's already shown in the connector info card + const nodeWithoutDescription = cloneDeep(connection); + if (nodeWithoutDescription?.metadata?.description) { + delete nodeWithoutDescription.metadata.description; + } + return nodeWithoutDescription; + })()} + onSubmit={handleOnFormSubmit} + updatedExpressionField={updatedExpressionField} + resetUpdatedExpressionField={handleResetUpdatedExpressionField} + isSaving={isSaving} + footerActionButton={true} + /> + + + + + ); +} + +export default EditConnectionPopup; + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/styles.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/styles.ts new file mode 100644 index 00000000000..b16dbcf935a --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Connection/styles.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import styled from "@emotion/styled"; +import { Button, Overlay, ThemeColors, Typography } from "@wso2/ui-toolkit"; + +export const PopupOverlay = styled(Overlay)` + z-index: 1999; +`; + +export const PopupContainer = styled.div` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80%; + max-width: 800px; + height: 80%; + max-height: 800px; + z-index: 2000; + background-color: ${ThemeColors.SURFACE_BRIGHT}; + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +`; + +export const PopupHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 32px; + gap: 16px; + border-bottom: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +export const BackButton = styled(Button)` + min-width: auto; + padding: 4px; +`; + +export const HeaderTitleContainer = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const PopupTitle = styled(Typography)` + font-size: 20px; + font-weight: 600; + color: ${ThemeColors.ON_SURFACE}; + margin: 0; +`; + +export const PopupSubtitle = styled(Typography)` + font-size: 12px; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + margin: 0; +`; + +export const CloseButton = styled(Button)` + min-width: auto; + padding: 4px; +`; + + diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx index fbd8332cd34..6d0a59d836a 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGenerator/index.tsx @@ -42,6 +42,7 @@ import { DataMapperDisplayMode } from "@wso2/ballerina-core"; import { + FieldDerivation, FormField, FormValues, Form, @@ -143,6 +144,8 @@ interface FormProps { navigateToPanel?: (panel: SidePanelView, connectionKind?: ConnectionKind) => void; fieldPriority?: Record; // Map of field keys to priority numbers (lower = rendered first) fieldOverrides?: Record>; + footerActionButton?: boolean; // Render save button as footer action button + derivedFields?: FieldDerivation[]; // Configuration for auto-deriving field values from other fields } // Styled component for the action button description @@ -217,16 +220,30 @@ export const FormGenerator = forwardRef(func onChange, injectedComponents, fieldPriority, + footerActionButton, } = props; const { rpcClient } = useRpcContext(); - const [fields, setFields] = useState([]); + const [baseFields, setBaseFields] = useState([]); const [formImports, setFormImports] = useState({}); const [typeEditorState, setTypeEditorState] = useState({ isOpen: false, newTypeValue: "" }); const [visualizableField, setVisualizableField] = useState(); const [recordTypeFields, setRecordTypeFields] = useState([]); const [valueTypeConstraints, setValueTypeConstraints] = useState(); + const fields = useMemo(() => { + if (!props.fieldOverrides || baseFields.length === 0) { + return baseFields; + } + return baseFields.map(field => { + const override = props.fieldOverrides[field.key]; + if (override) { + return { ...field, ...override }; + } + return field; + }); + }, [baseFields, props.fieldOverrides]); + /* Expression editor related state and ref variables */ const prevCompletionFetchText = useRef(""); const [completions, setCompletions] = useState([]); @@ -355,6 +372,7 @@ export const FormGenerator = forwardRef(func }; }, [node]); + const handleFormOpen = () => { rpcClient .getBIDiagramRpcClient() @@ -435,21 +453,10 @@ export const FormGenerator = forwardRef(func setRecordTypeFields(recordTypeFields); // get node properties - let fields = convertNodePropertiesToFormFields(enrichedNodeProperties || formProperties, connections, clientName); - - // Apply field overrides if provided - if (props.fieldOverrides) { - fields = fields.map(field => { - const override = props.fieldOverrides[field.key]; - if (override) { - return { ...field, ...override }; - } - return field; - }); - } + const fields = convertNodePropertiesToFormFields(enrichedNodeProperties || formProperties, connections, clientName); const sortedFields = sortFieldsByPriority(fields); - setFields(sortedFields); + setBaseFields(sortedFields); setFormImports(getImportsForFormFields(sortedFields)); }; @@ -472,7 +479,7 @@ export const FormGenerator = forwardRef(func } return updatedField; }); - setFields(updatedFields); + setBaseFields(updatedFields); } const handleOnSubmit = (data: FormValues, dirtyFields: any) => { @@ -524,7 +531,7 @@ export const FormGenerator = forwardRef(func } return updatedField; }); - setFields(updatedFields); + setBaseFields(updatedFields); setTypeEditorState({ isOpen, fieldKey: editingField?.key, newTypeValue: f[editingField?.key] }); }; @@ -842,7 +849,7 @@ export const FormGenerator = forwardRef(func if (type.codedata.node === "RECORD") { handleSelectedTypeChange(convertRecordTypeToCompletionItem(type)); } - setFields(updatedFields); + setBaseFields(updatedFields); }; const handleValueTypeConstChange = async (valueTypeConstraint: string) => { @@ -1408,6 +1415,7 @@ export const FormGenerator = forwardRef(func visualizableField={visualizableField} infoLabel={infoLabel} disableSaveButton={disableSaveButton} + footerActionButton={footerActionButton} actionButton={actionButton} recordTypeFields={recordTypeFields} isInferredReturnType={!!node.codedata?.inferredReturnType} @@ -1525,6 +1533,7 @@ export const FormGenerator = forwardRef(func visualizableField={visualizableField} infoLabel={infoLabel} disableSaveButton={disableSaveButton} + footerActionButton={footerActionButton} actionButton={actionButton} recordTypeFields={recordTypeFields} isInferredReturnType={!!node.codedata?.inferredReturnType} @@ -1538,6 +1547,7 @@ export const FormGenerator = forwardRef(func scopeFieldAddon={scopeFieldAddon} onChange={onChange} injectedComponents={injectedComponents} + derivedFields={props.derivedFields} /> )} {stack.map((item, i) => ( diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx index badf69eba60..f5749d234ff 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/Forms/FormGeneratorNew/index.tsx @@ -36,7 +36,8 @@ import { LinePosition, NodeProperties, ExpressionCompletionsRequest, - ExpressionCompletionsResponse + ExpressionCompletionsResponse, + Diagnostic } from "@wso2/ballerina-core"; import { FormField, @@ -115,6 +116,7 @@ interface FormProps { changeOptionalFieldTitle?: string; onChange?: (fieldKey: string, value: any, allValues: FormValues) => void; hideSaveButton?: boolean; + customDiagnosticFilter?: (diagnostics: Diagnostic[]) => Diagnostic[]; } export function FormGeneratorNew(props: FormProps) { @@ -147,7 +149,8 @@ export function FormGeneratorNew(props: FormProps) { injectedComponents, changeOptionalFieldTitle, onChange, - hideSaveButton + hideSaveButton, + customDiagnosticFilter } = props; const { rpcClient } = useRpcContext(); @@ -469,7 +472,7 @@ export function FormGeneratorNew(props: FormProps) { triggerCharacter: triggerCharacter as TriggerCharacter } }; - + let completions: ExpressionCompletionsResponse; if (!isDataMapperEditor) { completions = await rpcClient.getBIDiagramRpcClient().getExpressionCompletions(completionRequest); @@ -648,6 +651,10 @@ export function FormGeneratorNew(props: FormProps) { let uniqueDiagnostics = removeDuplicateDiagnostics(response.diagnostics); // HACK: filter unknown module and undefined type diagnostics for local connections uniqueDiagnostics = filterUnsupportedDiagnostics(uniqueDiagnostics); + // Apply custom diagnostic filter if provided + if (customDiagnosticFilter) { + uniqueDiagnostics = customDiagnosticFilter(uniqueDiagnostics); + } setDiagnosticsInfo({ key, diagnostics: uniqueDiagnostics }); } @@ -660,7 +667,7 @@ export function FormGeneratorNew(props: FormProps) { }, 250 ), - [rpcClient, fileName, targetLineRange] + [rpcClient, fileName, targetLineRange, customDiagnosticFilter] ); const handleGetHelperPane = ( diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/DataMapperView.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/DataMapperView.tsx index ceb62c78056..3e99bedf74c 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/DataMapperView.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/DataMapperView.tsx @@ -68,9 +68,12 @@ interface ModelSignature { refs: string; } -export function DataMapperView(props: DataMapperProps) { - const { filePath, codedata, name, projectPath, position, reusable, onClose } = props; +export interface DataMapperViewProps extends DataMapperProps { + goToSource: () => void; +} +export function DataMapperView(props: DataMapperViewProps) { + const { filePath, codedata, name, projectPath, position, reusable, onClose, goToSource } = props; const [isFileUpdateError, setIsFileUpdateError] = useState(false); const [modelState, setModelState] = useState({ model: null, @@ -81,6 +84,11 @@ export function DataMapperView(props: DataMapperProps) { codedata: codedata }); + const viewStateRef = useRef(viewState); + useEffect(() => { + viewStateRef.current = viewState; + }, [viewState]); + /* Completions related */ const [completions, setCompletions] = useState([]); const prevCompletionFetchText = useRef(""); @@ -105,14 +113,25 @@ export function DataMapperView(props: DataMapperProps) { const positionChanged = prevPositionRef.current?.line !== position?.line || prevPositionRef.current?.offset !== position?.offset; - - setViewState(prevState => ({ - viewId: positionChanged ? name : prevState.viewId || name, - codedata: codedata, - // Preserve subMappingName only if the position hasn't changed and there is an existing sub-mapping name. - // This ensures that changing the position resets the sub-mapping context. - subMappingName: !positionChanged && prevState.subMappingName - })); + + if (viewStateRef.current.subMappingName && !positionChanged) { + const viewId = viewStateRef.current.viewId; + rpcClient.getDataMapperRpcClient() + .getSubMappingCodedata({ + filePath, + codedata: codedata, + view: viewId + }).then((resp) => { + console.log(">>> [Data Mapper] getSubMappingCodedata response:", resp); + setViewState({ viewId: viewId, codedata: resp.codedata, subMappingName: viewId }); + }); + } else { + setViewState(prevState => ({ + viewId: positionChanged ? name : prevState.viewId || name, + codedata: codedata, + subMappingName: undefined + })); + } prevPositionRef.current = position; }, [name, codedata, position]); @@ -246,20 +265,15 @@ export function DataMapperView(props: DataMapperProps) { const handleView = async (viewId: string, isSubMapping?: boolean) => { if (isSubMapping) { - if (viewState.subMappingName) { - // If the view is a sub mapping, we can reuse the codedata of the parent view - setViewState({ viewId, codedata: viewState.codedata, subMappingName: viewState.subMappingName }); - } else { - const resp = await rpcClient - .getDataMapperRpcClient() - .getSubMappingCodedata({ - filePath, - codedata: viewState.codedata, - view: viewId - }); - console.log(">>> [Data Mapper] getSubMappingCodedata response:", resp); - setViewState({ viewId, codedata: resp.codedata, subMappingName: viewId }); - } + const resp = await rpcClient + .getDataMapperRpcClient() + .getSubMappingCodedata({ + filePath, + codedata: viewState.codedata, + view: viewId + }); + console.log(">>> [Data Mapper] getSubMappingCodedata response:", resp); + setViewState({ viewId, codedata: resp.codedata, subMappingName: viewId }); } else { if (viewState.subMappingName) { // If the view is a sub mapping, we need to get the codedata of the parent mapping @@ -513,7 +527,7 @@ export function DataMapperView(props: DataMapperProps) { .openView({ type: EVENT_TYPE.OPEN_VIEW, location: { documentUri, position } }); }; - const goToSource = async (outputId: string, viewId: string) => { + const goToFieldSource = async (outputId: string, viewId: string) => { const { property } = await rpcClient.getDataMapperRpcClient().getFieldProperty({ filePath, codedata: viewState.codedata, @@ -618,7 +632,7 @@ export function DataMapperView(props: DataMapperProps) { } else if (isFileUpdateError) { throw new Error("Error while updating file content"); } - }, [isError]); + }, [isError, isFileUpdateError]); const retrieveCompeletions = useCallback( debounce(async (outputId: string, viewId: string, value: string, cursorPosition?: number) => { @@ -744,6 +758,7 @@ export function DataMapperView(props: DataMapperProps) { enrichChildFields={enrichChildFields} genUniqueName={genUniqueName} undoRedoGroup={undoRedoGroup} + goToSource={goToSource} expressionBar={{ completions: filteredCompletions, isUpdatingSource, @@ -751,7 +766,7 @@ export function DataMapperView(props: DataMapperProps) { onCompletionSelect: handleCompletionSelect, onSave: updateExprFromExprBar, onCancel: handleExpressionCancel, - goToSource: goToSource + goToSource: goToFieldSource }} /> )} diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/index.tsx index 216c0cb23a7..4631e77827b 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/DataMapper/index.tsx @@ -18,11 +18,12 @@ import React from "react"; -import { CodeData, LinePosition } from "@wso2/ballerina-core"; -import { ErrorBoundary } from "@wso2/ui-toolkit"; +import { CodeData, LinePosition, NodePosition } from "@wso2/ballerina-core"; +import { DataMapperErrorBoundary } from "@wso2/ballerina-data-mapper"; import { TopNavigationBar } from "../../components/TopNavigationBar"; import { DataMapperView } from "./DataMapperView"; +import { useRpcContext } from "@wso2/ballerina-rpc-client"; import { BALLERINA_INTEGRATOR_ISSUES_URL } from "../../utils/bi"; export interface DataMapperProps { @@ -36,12 +37,30 @@ export interface DataMapperProps { } export function DataMapper(props: DataMapperProps) { + + const {rpcClient} = useRpcContext(); + + const goToSource = () => { + const lineRange = props.codedata.lineRange; + const position: NodePosition = { + startLine: lineRange?.startLine.line, + startColumn: lineRange?.startLine.offset, + endLine: lineRange?.endLine.line, + endColumn: lineRange?.endLine.offset, + }; + rpcClient.getCommonRpcClient().goToSource({ position }); + }; + + const onClose = props.onClose || (() => { + rpcClient.getVisualizerRpcClient()?.goBack(); + }); + return ( <> - - - + + + ); }; diff --git a/workspaces/ballerina/data-mapper/package.json b/workspaces/ballerina/data-mapper/package.json index 7b544924f33..1a0688c15a4 100644 --- a/workspaces/ballerina/data-mapper/package.json +++ b/workspaces/ballerina/data-mapper/package.json @@ -37,7 +37,8 @@ "zustand": "^5.0.4", "blueimp-md5": "^2.19.0", "mousetrap": "^1.6.5", - "@types/mousetrap": "~1.6.15" + "@types/mousetrap": "~1.6.15", + "react-error-boundary": "~6.0.0" }, "devDependencies": { "@types/lodash.debounce": "^4.0.6", @@ -70,4 +71,4 @@ "publishConfig": { "registry": "https://npm.pkg.github.com/" } -} \ No newline at end of file +} diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/DataMapperEditor.tsx b/workspaces/ballerina/data-mapper/src/components/DataMapper/DataMapperEditor.tsx index 74a8ad3cba2..0b593838b08 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/DataMapperEditor.tsx +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/DataMapperEditor.tsx @@ -27,7 +27,6 @@ import DataMapperDiagram from "../Diagram/Diagram"; import { DataMapperHeader } from "./Header/DataMapperHeader"; import { DataMapperNodeModel } from "../Diagram/Node/commons/DataMapperNode"; import { IONodeInitVisitor } from "../../visitors/IONodeInitVisitor"; -import { DataMapperErrorBoundary } from "./ErrorBoundary"; import { traverseNode } from "../../utils/model-utils"; import { View } from "./Views/DataMapperView"; import { @@ -41,7 +40,6 @@ import { import { KeyboardNavigationManager } from "../../utils/keyboard-navigation-manager"; import { DataMapperEditorProps } from "../../index"; import { ErrorNodeKind } from "./Error/RenderingError"; -import { IOErrorComponent } from "./Error/DataMapperError"; import { IntermediateNodeInitVisitor } from "../../visitors/IntermediateNodeInitVisitor"; import { LinkConnectorNode, @@ -158,8 +156,6 @@ export function DataMapperEditor(props: DataMapperEditorProps) { const [views, dispatch] = useReducer(viewsReducer, initialView); const [nodes, setNodes] = useState([]); - const [errorKind, setErrorKind] = useState(); - const [hasInternalError, setHasInternalError] = useState(false); const { isSMConfigPanelOpen, resetSubMappingConfig } = useDMSubMappingConfigPanelStore( useShallow(state => ({ @@ -284,7 +280,7 @@ export function DataMapperEditor(props: DataMapperEditorProps) { ]); } catch (error) { console.error("Error generating nodes:", error); - setHasInternalError(true); + throw error; } }; @@ -320,7 +316,7 @@ export function DataMapperEditor(props: DataMapperEditorProps) { }; const handleErrors = (kind: ErrorNodeKind) => { - setErrorKind(kind); + throw new Error("Diagram rendering error:" + kind); }; const autoMapWithAI = async () => { @@ -339,53 +335,48 @@ export function DataMapperEditor(props: DataMapperEditorProps) { } return ( - -
- {model && ( - + {model && ( + + )} + {nodes.length > 0 && ( + <> + - )} - {errorKind && } - {nodes.length > 0 && !errorKind && ( - <> - - {isSMConfigPanelOpen && ( - - )} - {isQueryClausesPanelOpen && ( - - )} - - )} - -
-
- ) + )} + {isQueryClausesPanelOpen && ( + + )} + + )} + + ); } diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/index.tsx b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/index.tsx index 8e5c49d3edc..2792ed58a91 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/index.tsx +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/index.tsx @@ -15,49 +15,56 @@ * specific language governing permissions and limitations * under the License. */ -import * as React from "react"; + +import React from "react"; +import { useErrorBoundary } from "react-error-boundary"; import { useStyles } from "./style"; -import { Button, Codicon, Typography } from "@wso2/ui-toolkit"; +import { Button, Codicon, Icon } from "@wso2/ui-toolkit"; import { ISSUES_URL } from "../../../Diagram/utils/constants"; interface ErrorScreenProps { - onClose: () => void; -}; + onClose: () => void; + goToSource: () => void; +} export default function ErrorScreen(props: ErrorScreenProps) { const classes = useStyles(); + const { resetBoundary } = useErrorBoundary(); + const { onClose, goToSource } = props; return ( <> -
- -
-
-
- - - - - +
+
+
+
+
+ +
+
+ +
+
+
+

This mapping cannot be visualized. Please switch to the source view to continue editing.

+

+ Please raise an issue with the sample code in our issue tracker. +

+
+
+ + +
- - A problem occurred while rendering the Data Mapper. - - - Please raise an issue with the sample code in our issue tracker -
); diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/style.ts b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/style.ts index 2dd91e01f48..2b27e0dfb8c 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/style.ts +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/Error/style.ts @@ -15,43 +15,70 @@ * specific language governing permissions and limitations * under the License. */ + import { css } from "@emotion/css"; +import { ThemeColors } from "@wso2/ui-toolkit"; export const useStyles = () => ({ - root: css({ - position: 'relative', - flexGrow: 1, - margin: '25vh auto', - width: 'fit-content' + + overlay: css({ + zIndex: 1, + position: 'absolute', + width: '100%', + height: '100%', + background: "var(--vscode-input-background)", + opacity: 0.5, + cursor: 'not-allowed' }), + errorContainer: css({ - display: "flex", - justifyContent: "center", - alignItems: "center", - flexDirection: "column" + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '90%', + maxWidth: '500px', + zIndex: 2 }), - errorTitle: css({ - color: "var(--vscode-badge-background)", - textAlign: "center" + + errorBody: css({ + backgroundColor: 'var(--vscode-editorWidget-background)', + color: 'var(--vscode-foreground)', + padding: '16px', + border: `1px solid ${ThemeColors.OUTLINE_VARIANT}`, + borderRadius: '4px', + display: 'flex', + flexDirection: 'column', + gap: '12px' }), - errorMsg: css({ - paddingTop: "16px", - color: "var(--vscode-checkbox-border)", - textAlign: "center" + + headerContainer: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px' }), - closeButtonContainer: css({ - position: 'absolute', - top: '16px', - right: '16px' + + infoIconContainer: css({ + display: 'flex', + alignItems: 'center', + color: 'var(--vscode-editorInfo-foreground)' }), - errorImg: css({ + + actionButtons: css({ display: 'flex', - justifyContent: 'center', - width: '100%' + gap: '8px', + alignItems: 'center', + alignSelf: 'flex-end' }), - gridContainer: css({ - height: "100%" + + errorMessage: css({ + paddingLeft: '8px', + '& p': { + margin: '8px 0' + } }), + link: css({ color: "var(--vscode-editor-selectionBackground)", textDecoration: "underline", @@ -59,5 +86,6 @@ export const useStyles = () => ({ color: "var(--vscode-editor-selectionBackground)", textDecoration: "underline", } - }) + }), + }); diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/index.tsx b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/index.tsx index b12962da2df..554932ce9c2 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/index.tsx +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/ErrorBoundary/index.tsx @@ -15,44 +15,30 @@ * specific language governing permissions and limitations * under the License. */ -import * as React from "react"; +import React from "react"; +import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; import ErrorScreen from "./Error"; export interface DataMapperErrorBoundaryProps { - hasError: boolean; - children?: React.ReactNode; + children: React.ReactNode; onClose: () => void; + goToSource: () => void; } -export class DataMapperErrorBoundaryC extends React.Component { - state = { hasError: false } - - static getDerivedStateFromProps(props: DataMapperErrorBoundaryProps, state: { hasError: boolean }) { - // Only update from props if we're not in an error state - if (!state.hasError) { - return { - hasError: props.hasError - }; - } - return null; // Don't update state if we're in an error state - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - componentDidCatch(error: any, errorInfo: any) { - // tslint:disable: no-console - console.error(error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ; - } - return this.props?.children; - } +export function DataMapperErrorBoundary(props: DataMapperErrorBoundaryProps) { + const { children, onClose, goToSource } = props; + + const handleError = (error: Error, errorInfo: React.ErrorInfo) => { + console.error("Error caught by DataMapperErrorBoundary:", error, errorInfo); + }; + + return ( + } + onError={handleError} + > + {children} + + ); } - -export const DataMapperErrorBoundary = DataMapperErrorBoundaryC; diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/Header/DataMapperHeader.tsx b/workspaces/ballerina/data-mapper/src/components/DataMapper/Header/DataMapperHeader.tsx index 7b2eac6a63d..7613ba0b1cc 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/Header/DataMapperHeader.tsx +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/Header/DataMapperHeader.tsx @@ -32,7 +32,7 @@ export interface DataMapperHeaderProps { views: View[]; reusable?: boolean; switchView: (index: number) => void; - hasEditDisabled: boolean; + hasEditDisabled?: boolean; onClose: () => void; onBack: () => void; onEdit?: () => void; diff --git a/workspaces/ballerina/data-mapper/src/components/DataMapper/SidePanel/QueryClauses/ClauseEditor.tsx b/workspaces/ballerina/data-mapper/src/components/DataMapper/SidePanel/QueryClauses/ClauseEditor.tsx index 1fd5357823a..75a57a37094 100644 --- a/workspaces/ballerina/data-mapper/src/components/DataMapper/SidePanel/QueryClauses/ClauseEditor.tsx +++ b/workspaces/ballerina/data-mapper/src/components/DataMapper/SidePanel/QueryClauses/ClauseEditor.tsx @@ -47,7 +47,8 @@ export function ClauseEditor(props: ClauseEditorProps) { { content: "Sort by", value: IntermediateClauseType.ORDER_BY }, { content: "Limit", value: IntermediateClauseType.LIMIT }, { content: "From", value: IntermediateClauseType.FROM }, - { content: "Join", value: IntermediateClauseType.JOIN } + { content: "Join", value: IntermediateClauseType.JOIN }, + { content: "Group by", value: IntermediateClauseType.GROUP_BY } ] const nameField: DMFormField = { diff --git a/workspaces/ballerina/data-mapper/src/components/Diagram/LinkState/CreateLinkState.ts b/workspaces/ballerina/data-mapper/src/components/Diagram/LinkState/CreateLinkState.ts index 70f8eff1d75..870a6762b4f 100644 --- a/workspaces/ballerina/data-mapper/src/components/Diagram/LinkState/CreateLinkState.ts +++ b/workspaces/ballerina/data-mapper/src/components/Diagram/LinkState/CreateLinkState.ts @@ -135,7 +135,7 @@ export class CreateLinkState extends State { context: (element.getNode() as DataMapperNodeModel).context })); this.link = link; - } else if (!isValueConfig) { + } else if (!isValueConfig && !isQueryHeaderPort(element)) { element.fireEvent({}, "expressionBarFocused"); this.clearState(); this.eject(); diff --git a/workspaces/ballerina/data-mapper/src/index.tsx b/workspaces/ballerina/data-mapper/src/index.tsx index 9cae88a622e..7654c42509c 100644 --- a/workspaces/ballerina/data-mapper/src/index.tsx +++ b/workspaces/ballerina/data-mapper/src/index.tsx @@ -25,11 +25,12 @@ import type {} from "@projectstorm/react-diagrams"; import { css, Global } from '@emotion/react'; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { DMFormProps, ModelState, IntermediateClause, Mapping, CodeData, FnMetadata, LineRange, ResultClauseType, IOType, Property, LinePosition } from "@wso2/ballerina-core"; -import { CompletionItem, ErrorBoundary } from "@wso2/ui-toolkit"; +import { CompletionItem } from "@wso2/ui-toolkit"; import { DataMapperEditor } from "./components/DataMapper/DataMapperEditor"; import { ExpressionProvider } from "./context/ExpressionContext"; -import { ISSUES_URL } from "./components/Diagram/utils/constants"; +import { DataMapperErrorBoundary } from "./components/DataMapper/ErrorBoundary"; +export { DataMapperErrorBoundary }; const queryClient = new QueryClient({ defaultOptions: { @@ -87,18 +88,19 @@ export interface DataMapperEditorProps { } export interface DataMapperProps extends DataMapperEditorProps { + goToSource: () => void; expressionBar: ExpressionBarProps; } -export function DataMapper({ expressionBar, ...props }: DataMapperProps) { +export function DataMapper({ goToSource, expressionBar, ...props }: DataMapperProps) { return ( - + - + - + ); } diff --git a/workspaces/bi/bi-extension/CHANGELOG.md b/workspaces/bi/bi-extension/CHANGELOG.md index 415db8bb09b..19a4c58d509 100644 --- a/workspaces/bi/bi-extension/CHANGELOG.md +++ b/workspaces/bi/bi-extension/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the **WSO2 Integrator: BI** extension will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [1.5.4](https://github.com/wso2/vscode-extensions/compare/ballerina-integrator-1.5.3...ballerina-integrator-1.5.4) - 2025-12-05 + +### Fixed + +- **Data Mapper** — Fixed the issue with focusing into inner array queries. +- **Security** — Updated dependencies to address security vulnerabilities (`CVE-2024-51999`). + ## [1.5.3](https://github.com/wso2/vscode-extensions/compare/ballerina-integrator-1.5.2...ballerina-integrator-1.5.3) - 2025-12-01 ### Changed diff --git a/workspaces/bi/bi-extension/package.json b/workspaces/bi/bi-extension/package.json index 5772496da7b..f1970f6fe6a 100644 --- a/workspaces/bi/bi-extension/package.json +++ b/workspaces/bi/bi-extension/package.json @@ -2,7 +2,7 @@ "name": "ballerina-integrator", "displayName": "WSO2 Integrator: BI", "description": "An extension which gives a development environment for designing, developing, debugging, and testing integration solutions.", - "version": "1.5.3", + "version": "1.5.4", "publisher": "wso2", "icon": "resources/images/wso2-ballerina-integrator-logo.png", "repository": { diff --git a/workspaces/bi/bi-extension/src/project-explorer/project-explorer-provider.ts b/workspaces/bi/bi-extension/src/project-explorer/project-explorer-provider.ts index 388514c5ee7..000b94c6903 100644 --- a/workspaces/bi/bi-extension/src/project-explorer/project-explorer-provider.ts +++ b/workspaces/bi/bi-extension/src/project-explorer/project-explorer-provider.ts @@ -146,7 +146,7 @@ export class ProjectExplorerEntryProvider implements vscode.TreeDataProvider { - let functionName = ''; - initTest(); - test('Create Data Mapper Artifact', async ({ }, testInfo) => { - const testAttempt = testInfo.retry + 1; - console.log('Creating a new data mapper in test attempt: ', testAttempt); - // Creating a HTTP Service - await addArtifact('Data Mapper Artifact', 'data-mapper'); - const artifactWebView = await switchToIFrame('WSO2 Integrator: BI', page.page); - if (!artifactWebView) { - throw new Error('WSO2 Integrator: BI webview not found'); - } - functionName = `sample${testAttempt}`; - const form = new Form(page.page, 'WSO2 Integrator: BI', artifactWebView); - await form.switchToFormView(false, artifactWebView); - await form.fill({ - values: { - 'Data Mapper Name*Name of the data mapper': { - type: 'input', - value: functionName, - } - } - }); - await form.submit('Create'); - const context = artifactWebView.locator(`text=${functionName}`); - await context.waitFor(); - const projectExplorer = new ProjectExplorer(page.page); - await projectExplorer.findItem(['sample', `${functionName}`], true); - const updateArtifactWebView = await switchToIFrame('WSO2 Integrator: BI', page.page); - if (!updateArtifactWebView) { - throw new Error('WSO2 Integrator: BI webview not found'); - } - }); - - test('Editing Data Mapper Artifact', async ({ }, testInfo) => { - const testAttempt = testInfo.retry + 1; - console.log('Editing a data mapper in test attempt: ', testAttempt); - const artifactWebView = await switchToIFrame('WSO2 Integrator: BI', page.page); - if (!artifactWebView) { - throw new Error('WSO2 Integrator: BI webview not found'); - } - const editBtn = artifactWebView.locator('#bi-edit'); - await editBtn.waitFor(); - await editBtn.click({ force: true }); - const form = new Form(page.page, 'WSO2 Integrator: BI', artifactWebView); - await form.switchToFormView(false, artifactWebView); - await form.fill({ - values: { - 'Return Type': { - type: 'textarea', - value: 'string', - additionalProps: { clickLabel: true } - } - } - }); - await form.submit('Save'); - const context = artifactWebView.locator(`text=${functionName}`); - await context.waitFor(); - const contextReturnType = artifactWebView.locator('span:has(i.fw-bi-return)', { hasText: 'string' }); - await contextReturnType.waitFor(); - }); - }); -} diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-ai-search.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-ai-search.svg new file mode 100644 index 00000000000..38153933a18 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-ai-search.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-api-spec.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-api-spec.svg new file mode 100644 index 00000000000..20be541d2ba --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-api-spec.svg @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-expand-modal.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-expand-modal.svg new file mode 100644 index 00000000000..b3264a5a077 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-expand-modal.svg @@ -0,0 +1,6 @@ + + + diff --git a/workspaces/common-libs/ui-toolkit/src/components/Stepper/CompletedStepCard.tsx b/workspaces/common-libs/ui-toolkit/src/components/Stepper/CompletedStepCard.tsx index c1e79ac5ae4..94a7083f916 100644 --- a/workspaces/common-libs/ui-toolkit/src/components/Stepper/CompletedStepCard.tsx +++ b/workspaces/common-libs/ui-toolkit/src/components/Stepper/CompletedStepCard.tsx @@ -33,10 +33,10 @@ export const CompletedStepCard: React.FC = (props: StepCardProps) {props.titleAlignment === "right" ? ( <> - + - + {props.step.title} {props.totalSteps === props.step.id + 1 ? null : } @@ -44,11 +44,11 @@ export const CompletedStepCard: React.FC = (props: StepCardProps) ) : <> - + {props.totalSteps === props.step.id + 1 ? null : } - + {props.step.title} diff --git a/workspaces/common-libs/ui-toolkit/src/components/Stepper/IncompleteStepCard.tsx b/workspaces/common-libs/ui-toolkit/src/components/Stepper/IncompleteStepCard.tsx index 1587dc2831a..fc9ff246eb3 100644 --- a/workspaces/common-libs/ui-toolkit/src/components/Stepper/IncompleteStepCard.tsx +++ b/workspaces/common-libs/ui-toolkit/src/components/Stepper/IncompleteStepCard.tsx @@ -21,28 +21,28 @@ import styled from "@emotion/styled"; import { colors } from "../Commons/Colors"; const StepNumber = styled.div` - color: var(--vscode-editor-background); + color: ${(props: { color?: string; }) => props.color}; `; export const InCompletedStepCard: React.FC = (props: StepCardProps) => ( {props.titleAlignment === "right" ? ( <> - - + + {props.step.id + 1} {props.step.title} - {(props.totalSteps === props.step.id + 1) ? null : } + {(props.totalSteps === props.step.id + 1) ? null : } ) : <> - - + + {props.step.id + 1} diff --git a/workspaces/common-libs/ui-toolkit/src/components/Stepper/Stepper.tsx b/workspaces/common-libs/ui-toolkit/src/components/Stepper/Stepper.tsx index 096801a4e08..cea5d0ba06d 100644 --- a/workspaces/common-libs/ui-toolkit/src/components/Stepper/Stepper.tsx +++ b/workspaces/common-libs/ui-toolkit/src/components/Stepper/Stepper.tsx @@ -113,6 +113,7 @@ export const StepCircle = styled.div` min-height: 24px; border-radius: 50%; flex-shrink: 0; + border: 1px solid var(--button-border); `; export const HorizontalBar = styled.div` @@ -159,7 +160,7 @@ export const Stepper: React.FC = (props: StepperProps) => { }; if (id < currentStep) { return ; - } + } return ; })} diff --git a/workspaces/mi/mi-extension/CHANGELOG.md b/workspaces/mi/mi-extension/CHANGELOG.md index 04232606b56..f4b642a1ff8 100644 --- a/workspaces/mi/mi-extension/CHANGELOG.md +++ b/workspaces/mi/mi-extension/CHANGELOG.md @@ -2,11 +2,20 @@ All notable changes to the "micro-integrator" extension will be documented in this file. +## [3.1.1] - 2025-12-12 + +### Fixed + +Fixed: Diagram issue when using Filter and If-Else mediators ([#1356](https://github.com/wso2/mi-vscode/issues/1356)) +Fixed: Ballerina Executable Not Found Error ([#1357](https://github.com/wso2/mi-vscode/issues/1357)) +Fixed: Cannot edit a proxy in Windows OS ([#1368](https://github.com/wso2/mi-vscode/issues/1368)) +Fixed: Issues in the build and run functionality ([#1369](https://github.com/wso2/mi-vscode/issues/1369)) + ## [3.1.0] - 2025-12-08 ### New Features -Added: Nested query support for data services via a seperate query view +Added: Nested query support for data services via a separate query view Added: Oracle synonym support for data service resource generation ### Fixed diff --git a/workspaces/mi/mi-extension/package.json b/workspaces/mi/mi-extension/package.json index b69c3b201f8..af10ee02abc 100644 --- a/workspaces/mi/mi-extension/package.json +++ b/workspaces/mi/mi-extension/package.json @@ -163,6 +163,12 @@ "default": true, "description": "Update car plugin version automatically" }, + "MI.serverTimeoutInSecs": { + "type": "number", + "default": 120, + "minimum": 30, + "description": "Maximum wait time for server startup in seconds" + }, "MI.logging.loggingLevel": { "type": "string", "enum": [ @@ -503,6 +509,11 @@ "icon": "$(play)", "category": "MI" }, + { + "command": "MI.terminate-server", + "title": "Stop MI Server Session", + "category": "MI" + }, { "command": "MI.build-bal-module", "title": "Build Ballerina Module", diff --git a/workspaces/mi/mi-extension/src/constants/index.ts b/workspaces/mi/mi-extension/src/constants/index.ts index fafa6f1760a..69ecaa28b0c 100644 --- a/workspaces/mi/mi-extension/src/constants/index.ts +++ b/workspaces/mi/mi-extension/src/constants/index.ts @@ -91,6 +91,7 @@ export const COMMANDS = { REMOTE_DEPLOY_PROJECT: 'MI.remote-deploy-project', CREATE_DOCKER_IMAGE: 'MI.create-docker-image', BUILD_AND_RUN_PROJECT: 'MI.build-and-run', + TERMINATE_SERVER: 'MI.terminate-server', BUILD_BAL_MODULE: 'MI.build-bal-module', ADD_DATA_SOURCE_COMMAND: 'MI.project-explorer.add-data-source', SHOW_DATA_SOURCE: 'MI.show.data-source', diff --git a/workspaces/mi/mi-extension/src/debugger/activate.ts b/workspaces/mi/mi-extension/src/debugger/activate.ts index eb2d774dea3..eacbee0cb5c 100644 --- a/workspaces/mi/mi-extension/src/debugger/activate.ts +++ b/workspaces/mi/mi-extension/src/debugger/activate.ts @@ -21,7 +21,7 @@ import { CancellationToken, DebugConfiguration, ProviderResult, Uri, window, wor import { MiDebugAdapter } from './debugAdapter'; import { COMMANDS } from '../constants'; import { extension } from '../MIExtensionContext'; -import {executeBuildTask, executeRemoteDeployTask, getServerPath} from './debugHelper'; +import {executeBuildTask, executeRemoteDeployTask, getServerPath, stopServer} from './debugHelper'; import { getDockerTask } from './tasks'; import { getStateMachine, refreshUI } from '../stateMachine'; import * as fs from 'fs'; @@ -222,9 +222,14 @@ export function activateDebugger(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.commands.registerCommand(COMMANDS.BUILD_AND_RUN_PROJECT, async (args: any) => { const webview = [...webviews.values()].find(webview => webview.getWebview()?.active); - + let projectUri: string | undefined = undefined; if (webview && webview?.getProjectUri()) { - const projectUri = webview.getProjectUri(); + projectUri = webview.getProjectUri(); + } + if (!projectUri) { + projectUri = await askForProject(); + } + if (projectUri) { const projectWorkspace = workspace.getWorkspaceFolder(Uri.file(projectUri)); const launchJsonPath = path.join(projectUri, '.vscode', 'launch.json'); const envPath = path.join(projectUri, '.env'); @@ -279,6 +284,34 @@ export function activateDebugger(context: vscode.ExtensionContext) { } })); + context.subscriptions.push(vscode.commands.registerCommand(COMMANDS.TERMINATE_SERVER, async () => { + + const webview = [...webviews.values()].find(webview => webview.getWebview()?.active); + let projectUri: string | undefined = undefined; + if (webview && webview?.getProjectUri()) { + projectUri = webview.getProjectUri(); + } + if (!projectUri) { + projectUri = await askForProject(); + } + if (projectUri) { + try { + getServerPath(projectUri).then(async (serverPath) => { + if (!serverPath) { + vscode.window.showErrorMessage("Server path not found to terminate the server."); + return; + } + await stopServer(projectUri, serverPath, process.platform === 'win32'); + vscode.commands.executeCommand('setContext', 'MI.isRunning', 'false'); + }); + } catch(error) { + vscode.window.showErrorMessage('Error occurred while terminating the server.'); + } + } else { + vscode.window.showErrorMessage('Error occurred while determining the project to terminate the server.'); + } + })); + context.subscriptions.push(vscode.commands.registerCommand(COMMANDS.BUILD_BAL_MODULE, async () => { const editor = vscode.window.activeTextEditor; if (!editor) { diff --git a/workspaces/mi/mi-extension/src/debugger/debugAdapter.ts b/workspaces/mi/mi-extension/src/debugger/debugAdapter.ts index a492451563a..ea827ca30f2 100644 --- a/workspaces/mi/mi-extension/src/debugger/debugAdapter.ts +++ b/workspaces/mi/mi-extension/src/debugger/debugAdapter.ts @@ -202,7 +202,7 @@ export class MiDebugAdapter extends LoggingDebugSession { response.body.supportsReadMemoryRequest = true; response.body.supportsWriteMemoryRequest = true; - response.body.supportSuspendDebuggee = true; + response.body.supportSuspendDebuggee = false; response.body.supportTerminateDebuggee = true; // response.body.supportsFunctionBreakpoints = true; response.body.supportsDelayedStackTraceLoading = false; @@ -290,7 +290,7 @@ export class MiDebugAdapter extends LoggingDebugSession { executeTasks(this.projectUri, serverPath, isDebugAllowed) .then(async () => { if (args?.noDebug) { - checkServerReadiness().then(() => { + checkServerReadiness(this.projectUri).then(() => { openRuntimeServicesWebview(this.projectUri); extension.isServerStarted = true; RPCLayer._messengers.get(this.projectUri)?.sendNotification(miServerRunStateChanged, { type: 'webview', webviewType: 'micro-integrator.runtime-services-panel' }, 'Running'); diff --git a/workspaces/mi/mi-extension/src/debugger/debugHelper.ts b/workspaces/mi/mi-extension/src/debugger/debugHelper.ts index effaae44f82..bfa72fcd98f 100644 --- a/workspaces/mi/mi-extension/src/debugger/debugHelper.ts +++ b/workspaces/mi/mi-extension/src/debugger/debugHelper.ts @@ -93,9 +93,11 @@ function checkServerLiveness(): Promise { }); } -export function checkServerReadiness(): Promise { +export function checkServerReadiness(projectUri: string): Promise { const startTime = Date.now(); - const maxTimeout = 120000; + const config = workspace.getConfiguration('MI', Uri.file(projectUri)); + const configuredTimeout = config.get("serverTimeoutInSecs"); + const maxTimeout = (Number.isFinite(Number(configuredTimeout)) && Number(configuredTimeout) > 0) ? Number(configuredTimeout) * 1000 : 120000; const retryInterval = 2000; return new Promise((resolve, reject) => { @@ -421,7 +423,9 @@ export async function stopServer(projectUri: string, serverPath: string, isWindo } export async function executeTasks(projectUri: string, serverPath: string, isDebug: boolean): Promise { - const maxTimeout = 120000; + const config = workspace.getConfiguration('MI', Uri.file(projectUri)); + const configuredTimeout = config.get("serverTimeoutInSecs"); + const maxTimeout = (Number.isFinite(Number(configuredTimeout)) && Number(configuredTimeout) > 0) ? Number(configuredTimeout) * 1000 : 120000; return new Promise(async (resolve, reject) => { const langClient = await MILanguageClient.getInstance(projectUri); const isTerminated = await langClient.shutdownTryoutServer(); diff --git a/workspaces/mi/mi-extension/src/debugger/debugger.ts b/workspaces/mi/mi-extension/src/debugger/debugger.ts index 665d0d7e6cf..814de8d0fde 100644 --- a/workspaces/mi/mi-extension/src/debugger/debugger.ts +++ b/workspaces/mi/mi-extension/src/debugger/debugger.ts @@ -376,7 +376,7 @@ export class Debugger extends EventEmitter { return new Promise(async (resolve, reject) => { this.startDebugger().then(() => { extension.preserveActivity = true; - checkServerReadiness().then(() => { + checkServerReadiness(this.projectUri).then(() => { this.sendResumeCommand().then(async () => { const allRuntimeBreakpoints = this.getAllRuntimeBreakpoints(); if (allRuntimeBreakpoints.size > 0) { diff --git a/workspaces/mi/mi-extension/src/util/onboardingUtils.ts b/workspaces/mi/mi-extension/src/util/onboardingUtils.ts index 0ab29e8fb57..342ff937b18 100644 --- a/workspaces/mi/mi-extension/src/util/onboardingUtils.ts +++ b/workspaces/mi/mi-extension/src/util/onboardingUtils.ts @@ -1094,7 +1094,7 @@ async function runBallerinaBuildsWithProgress(projectPath: string, isBallerinaIn console.debug('[Ballerina Build] Ballerina Command Path:', balCommand); // Check if Ballerina executable exists - if (!fs.existsSync(balCommand)) { + if (!(isBallerinaInstalled || fs.existsSync(balCommand))) { console.debug('[Ballerina Build] Error: Ballerina executable not found at:', balCommand); vscode.window.showErrorMessage(`Ballerina executable not found at: ${balCommand}`); reject(new Error('Ballerina executable not found')); diff --git a/workspaces/mi/mi-visualizer/src/views/Diagram/Proxy.tsx b/workspaces/mi/mi-visualizer/src/views/Diagram/Proxy.tsx index a6b76068d9d..c03236cc218 100644 --- a/workspaces/mi/mi-visualizer/src/views/Diagram/Proxy.tsx +++ b/workspaces/mi/mi-visualizer/src/views/Diagram/Proxy.tsx @@ -24,7 +24,7 @@ import { View, ViewContent, ViewHeader } from "../../components/View"; import { EditProxyForm, ProxyProps } from "../Forms/EditForms/EditProxyForm"; import { generateProxyData, onProxyEdit } from "../../utils/form"; import { useVisualizerContext } from "@wso2/mi-rpc-client"; -import { EVENT_TYPE, MACHINE_VIEW } from "@wso2/mi-core"; +import { EVENT_TYPE, MACHINE_VIEW, Platform } from "@wso2/mi-core"; import path from "path"; export interface ProxyViewProps { @@ -47,17 +47,20 @@ export const ProxyView = ({ model: ProxyModel, documentUri, diagnostics }: Proxy const handleEditProxy = () => { setFormOpen(true); } - const onSave = (data: EditProxyForm) => { + const onSave = async (data: EditProxyForm) => { let artifactNameChanged = false; let documentPath = documentUri; - if (path.basename(documentUri).split('.')[0] !== data.name) { - rpcClient.getMiDiagramRpcClient().renameFile({existingPath: documentUri, newPath: path.join(path.dirname(documentUri), `${data.name}.xml`)}); + const machineView = await rpcClient.getVisualizerState(); + const proxyName = machineView.platform === Platform.WINDOWS ? path.win32.basename(documentUri).split('.')[0] : path.basename(documentUri).split('.')[0]; + if (proxyName !== data.name) { + const updatedPath = machineView.platform === Platform.WINDOWS ? path.join(path.win32.dirname(documentUri), `${data.name}.xml`) : path.join(path.dirname(documentUri), `${data.name}.xml`); + await rpcClient.getMiDiagramRpcClient().renameFile({existingPath: documentUri, newPath: updatedPath}); artifactNameChanged = true; - documentPath = path.join(path.dirname(documentUri), `${data.name}.xml`); + documentPath = updatedPath; } onProxyEdit(data, model, documentPath, rpcClient); if (artifactNameChanged) { - rpcClient.getMiVisualizerRpcClient().openView({ type: EVENT_TYPE.OPEN_VIEW, location: { view: MACHINE_VIEW.Overview } }); + await rpcClient.getMiVisualizerRpcClient().openView({ type: EVENT_TYPE.OPEN_VIEW, location: { view: MACHINE_VIEW.Overview } }); } else { setFormOpen(false); } diff --git a/workspaces/wso2-platform/wso2-platform-core/src/constants.ts b/workspaces/wso2-platform/wso2-platform-core/src/constants.ts index 06f83669ce6..1c6e84d0d5f 100644 --- a/workspaces/wso2-platform/wso2-platform-core/src/constants.ts +++ b/workspaces/wso2-platform/wso2-platform-core/src/constants.ts @@ -22,6 +22,7 @@ export const CommandIds = { SignIn: "wso2.wso2-platform.sign.in", SignInWithAuthCode: "wso2.wso2-platform.sign.in.with.authCode", SignOut: "wso2.wso2-platform.sign.out", + CancelSignIn: "wso2.wso2-platform.cancel.sign.in", AddComponent: "wso2.wso2-platform.add.component", CreateNewComponent: "wso2.wso2-platform.create.component", DeleteComponent: "wso2.wso2-platform.delete.component", diff --git a/workspaces/wso2-platform/wso2-platform-extension/package.json b/workspaces/wso2-platform/wso2-platform-extension/package.json index 6499ed7a84a..976f5bef7cc 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/package.json +++ b/workspaces/wso2-platform/wso2-platform-extension/package.json @@ -3,7 +3,7 @@ "displayName": "WSO2 Platform", "description": "Manage WSO2 Choreo and Devant projects in VS Code.", "license": "Apache-2.0", - "version": "1.0.17", + "version": "1.0.18", "cliVersion": "v1.2.212509091800", "publisher": "wso2", "bugs": { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/PlatformExtensionApi.ts b/workspaces/wso2-platform/wso2-platform-extension/src/PlatformExtensionApi.ts index 4dd64f58801..a3a2ae96902 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/PlatformExtensionApi.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/PlatformExtensionApi.ts @@ -19,7 +19,6 @@ import type { AuthState, ComponentKind, ContextItemEnriched, ContextStoreComponentState, IWso2PlatformExtensionAPI, openClonedDirReq } from "@wso2/wso2-platform-core"; import { ext } from "./extensionVariables"; import { hasDirtyRepo } from "./git/util"; -import { authStore } from "./stores/auth-store"; import { contextStore } from "./stores/context-store"; import { webviewStateStore } from "./stores/webview-state-store"; import { openClonedDir } from "./uri-handlers"; @@ -32,8 +31,8 @@ export class PlatformExtensionApi implements IWso2PlatformExtensionAPI { ?.filter((item) => !!item) as ComponentKind[]) ?? [] } - public getAuthState = () => authStore.getState().state; - public isLoggedIn = () => !!authStore.getState().state?.userInfo; + public getAuthState = () => ext.authProvider?.getState().state ?? { userInfo: null, region: "US" as const }; + public isLoggedIn = () => !!ext.authProvider?.getState().state?.userInfo; public getDirectoryComponents = (fsPath: string) => this.getComponentsOfDir(fsPath, contextStore.getState().state?.components); public localRepoHasChanges = (fsPath: string) => hasDirtyRepo(fsPath, ext.context, ["context.yaml"]); public getWebviewStateStore = () => webviewStateStore.getState().state; @@ -44,8 +43,8 @@ export class PlatformExtensionApi implements IWso2PlatformExtensionAPI { public getDevantConsoleUrl = async() => (await ext.clients.rpcClient.getConfigFromCli()).devantConsoleUrl; // Auth state subscriptions - public subscribeAuthState = (callback: (state: AuthState)=>void) => authStore.subscribe((state)=>callback(state.state)); - public subscribeIsLoggedIn = (callback: (isLoggedIn: boolean)=>void) => authStore.subscribe((state)=>callback(!!state.state?.userInfo)); + public subscribeAuthState = (callback: (state: AuthState)=>void) => ext.authProvider?.subscribe((state)=>callback(state.state)) ?? (() => {}); + public subscribeIsLoggedIn = (callback: (isLoggedIn: boolean)=>void) => ext.authProvider?.subscribe((state)=>callback(!!state.state?.userInfo)) ?? (() => {}); // Context state subscriptions public subscribeContextState = (callback: (state: ContextItemEnriched | undefined)=>void) => contextStore.subscribe((state)=>callback(state.state?.selected)); diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/auth/wso2-auth-provider.ts b/workspaces/wso2-platform/wso2-platform-extension/src/auth/wso2-auth-provider.ts new file mode 100644 index 00000000000..f7bf750fb79 --- /dev/null +++ b/workspaces/wso2-platform/wso2-platform-extension/src/auth/wso2-auth-provider.ts @@ -0,0 +1,439 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CommandIds, type AuthState, type UserInfo } from "@wso2/wso2-platform-core"; +import { + type AuthenticationProvider, + type AuthenticationProviderAuthenticationSessionsChangeEvent, + type AuthenticationProviderSessionOptions, + type AuthenticationSession, + commands, + Disposable, + EventEmitter, + type SecretStorage, + window, +} from "vscode"; +import { ext } from "../extensionVariables"; +import { getLogger } from "../logger/logger"; +import { contextStore } from "../stores/context-store"; +import { dataCacheStore } from "../stores/data-cache-store"; + +export const WSO2_AUTH_PROVIDER_ID = "wso2-platform"; +const WSO2_SESSIONS_SECRET_KEY = `${WSO2_AUTH_PROVIDER_ID}.sessions`; + +interface SessionData { + id: string; + accessToken: string; + account: { + id: string; + label: string; + }; + scopes: string[]; + userInfo: UserInfo; + region: "US" | "EU"; +} + +export class WSO2AuthenticationProvider implements AuthenticationProvider, Disposable { + private _sessionChangeEmitter = new EventEmitter(); + private _stateChangeEmitter = new EventEmitter<{ state: AuthState }>(); + private _disposable: Disposable; + private _state: AuthState = { userInfo: null, region: "US" }; + private _pendingSessionCreation: { resolve: (session: AuthenticationSession) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout } | undefined; + + constructor(private readonly secretStorage: SecretStorage) { + console.log("WSO2AuthenticationProvider initialized"); + this._disposable = Disposable.from( + this._sessionChangeEmitter, + this._stateChangeEmitter + ); + } + + get onDidChangeSessions() { + return this._sessionChangeEmitter.event; + } + + /** + * Subscribe to auth state changes + */ + public subscribe(callback: (store: { state: AuthState }) => void): () => void { + const disposable = this._stateChangeEmitter.event(callback); + // Call immediately with current state + callback({ state: this._state }); + return () => disposable.dispose(); + } + + /** + * Get the current state + */ + public getState() { + return { + state: this._state, + resetState: this.resetState.bind(this), + loginSuccess: this.loginSuccess.bind(this), + logout: this.logout.bind(this), + initAuth: this.initAuth.bind(this), + }; + } + + /** + * Get current auth state + */ + get state(): AuthState { + return this._state; + } + + /** + * Get the existing sessions + */ + public async getSessions(scopes: readonly string[] | undefined, options: AuthenticationProviderSessionOptions): Promise { + const allSessions = await this.readSessions(); + + if (scopes && scopes.length > 0) { + const sessions = allSessions.filter((session) => scopes.every((scope) => session.scopes.includes(scope))); + return sessions; + } + + return allSessions; + } + + /** + * Cancel any pending session creation + * This is called when user initiates a new sign-in while one is already pending + */ + public cancelPendingSessionCreation() { + if (this._pendingSessionCreation) { + console.log("Cancelling pending session creation"); + clearTimeout(this._pendingSessionCreation.timeout); + const oldPending = this._pendingSessionCreation; + this._pendingSessionCreation = undefined; + oldPending.reject(new Error("Sign-in cancelled")); + } + } + + /** + * Create a new auth session by triggering the sign-in flow + * This is called when user clicks "Sign in" in VS Code's Accounts menu + */ + public async createSession(scopes: string[], options?: AuthenticationProviderSessionOptions): Promise { + console.log("Creating new auth session via VS Code Accounts menu"); + const customOptions = options as any; + const platform = customOptions?.platform; + + // Return a promise that will be resolved when login succeeds or timeout occurs + return new Promise((resolve, reject) => { + commands.executeCommand(CommandIds.SignIn, { + extName: platform, + }).then(undefined, (error) => { + console.error("Sign-in command failed:", error); + }); + + // Set up timeout + const timeout = setTimeout(() => { + console.log("Sign-in timeout reached"); + if (this._pendingSessionCreation) { + this._pendingSessionCreation = undefined; + reject(new Error("Sign-in timeout: User did not complete authentication within 2 minutes")); + } + }, 2 * 60 * 1000); // 2 minutes + + // Store the promise handlers so we can resolve/reject from loginSuccess + this._pendingSessionCreation = { resolve, reject, timeout }; + }); + } + + /** + * Reset state to initial values + */ + public resetState() { + this._state = { userInfo: null, region: "US" }; + this._stateChangeEmitter.fire({ state: this._state }); + } + + /** + * Handle successful login - updates state and stores session + */ + public async loginSuccess(userInfo: UserInfo, region: "US" | "EU") { + // Update local state + this._state = { userInfo, region }; + + // Update related stores + dataCacheStore.getState().setOrgs(userInfo.organizations); + contextStore.getState().refreshState(); + + // Store session in secure storage + const session = await this.storeSession(userInfo, region); + + // Notify subscribers + this._stateChangeEmitter.fire({ state: this._state }); + + // Resolve pending session creation if there is one + if (this._pendingSessionCreation) { + clearTimeout(this._pendingSessionCreation.timeout); + this._pendingSessionCreation.resolve(session); + this._pendingSessionCreation = undefined; + } + } + + /** + * Handle logout - signs out from RPC and clears all state + */ + public async logout(silent = false, skipClearSessions = false) { + getLogger().debug("Signing out from WSO2 Platform"); + + // Call RPC signOut first + try { + await ext.clients.rpcClient.signOut(); + } catch (error) { + getLogger().error("Error during RPC signOut", error); + } + + // Clear VS Code session storage (unless already cleared by removeSession) + if (!skipClearSessions) { + try { + await this.clearSessions(); + } catch (error) { + getLogger().error("Error clearing sessions", error); + } + } + + // Clear local state + this.resetState(); + + if (!silent) { + window.showInformationMessage("Successfully signed out from WSO2 Platform!"); + } + } + + /** + * Initialize authentication on startup + */ + public async initAuth() { + try { + const userInfo = await ext.clients.rpcClient.getUserInfo(); + if (userInfo) { + const region = await ext.clients.rpcClient.getCurrentRegion(); + await this.loginSuccess(userInfo, region); + const contextStoreState = contextStore.getState().state; + if (contextStoreState.selected?.org) { + ext?.clients?.rpcClient?.changeOrgContext(contextStoreState.selected?.org?.id?.toString()); + } + } else { + await this.logout(true); + } + } catch (err) { + getLogger().error("Error during auth initialization", err); + await this.logout(true); + } + } + + /** + * Store or update a session with user info and region + * Called internally after successful RPC authentication + */ + private async storeSession(userInfo: UserInfo, region: "US" | "EU"): Promise { + // Remove any existing sessions first (single account support) + const existingSessions = await this.readSessions(); + const removedSessions = [...existingSessions]; + + const sessionId = this.generateSessionId(); + const sessionData: SessionData = { + id: sessionId, + accessToken: "rpc-authenticated", // Placeholder since RPC handles auth + account: { + label: userInfo.displayName || userInfo.userEmail, + id: userInfo.userId, + }, + scopes: [], + userInfo, + region, + }; + + const session: AuthenticationSession = { + id: sessionData.id, + accessToken: sessionData.accessToken, + account: sessionData.account, + scopes: sessionData.scopes, + }; + + await this.storeSessions([session], sessionData); + + this._sessionChangeEmitter.fire({ + added: [session], + removed: removedSessions, + changed: [] + }); + + return session; + } + + /** + * Remove an existing session + * This is called when user signs out from VS Code's Accounts menu + */ + public async removeSession(sessionId: string): Promise { + const allSessions = await this.readSessions(); + const sessionIdx = allSessions.findIndex((s) => s.id === sessionId); + const session = allSessions[sessionIdx]; + if (!session) { + return; + } + + allSessions.splice(sessionIdx, 1); + await this.storeSessions(allSessions); + this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] }); + + // Trigger full logout flow (skipClearSessions=true to avoid loop) + await this.logout(false, true); + } + + /** + * Remove all sessions + */ + public async clearSessions(): Promise { + const allSessions = await this.readSessions(); + if (allSessions.length === 0) { + return; + } + + await this.secretStorage.delete(WSO2_SESSIONS_SECRET_KEY); + + this._sessionChangeEmitter.fire({ added: [], removed: allSessions, changed: [] }); + } + + /** + * Get session data including userInfo and region + */ + public async getSessionData(sessionId?: string): Promise { + const sessions = await this.readSessionsData(); + if (sessionId) { + return sessions.find((s) => s.id === sessionId); + } + // Return the first session if no ID is provided (single account support) + return sessions[0]; + } + + /** + * Dispose the provider + */ + public async dispose() { + this._disposable.dispose(); + } + + /** + * Get the user info from stored session (for backward compatibility) + */ + public getUserInfo(): UserInfo | null { + return this._state.userInfo; + } + + /** + * Get the region from stored session (for backward compatibility) + */ + public getRegion(): "US" | "EU" { + return this._state.region; + } + + /** + * Read sessions from secret storage + */ + private async readSessions(): Promise { + try { + const sessionsJson = await this.secretStorage.get(WSO2_SESSIONS_SECRET_KEY); + if (!sessionsJson) { + return []; + } + + const sessionData: SessionData[] = JSON.parse(sessionsJson); + return sessionData.map((data) => ({ + id: data.id, + accessToken: data.accessToken, + account: data.account, + scopes: data.scopes, + })); + } catch (e) { + getLogger().error("Error reading sessions", e); + return []; + } + } + + /** + * Store sessions to secret storage + */ + private async storeSessions(sessions: readonly AuthenticationSession[], newSessionData?: SessionData): Promise { + try { + const existingSessions = await this.readSessionsData(); + let updatedSessions: SessionData[]; + + if (newSessionData) { + // Add or update session + const existingIndex = existingSessions.findIndex((s) => s.id === newSessionData.id); + if (existingIndex >= 0) { + updatedSessions = [...existingSessions]; + updatedSessions[existingIndex] = newSessionData; + } else { + updatedSessions = [...existingSessions, newSessionData]; + } + } else { + // Filter out removed sessions + const sessionIds = sessions.map((s) => s.id); + updatedSessions = existingSessions.filter((s) => sessionIds.includes(s.id)); + } + + await this.secretStorage.store(WSO2_SESSIONS_SECRET_KEY, JSON.stringify(updatedSessions)); + } catch (e) { + getLogger().error("Error storing sessions", e); + } + } + + /** + * Read full session data including userInfo and region + */ + private async readSessionsData(): Promise { + try { + const sessionsJson = await this.secretStorage.get(WSO2_SESSIONS_SECRET_KEY); + if (!sessionsJson) { + return []; + } + + return JSON.parse(sessionsJson); + } catch (e) { + getLogger().error("Error reading session data", e); + return []; + } + } + + /** + * Generate a session ID + */ + private generateSessionId(): string { + return `wso2-${Date.now()}-${Math.random().toString(36).substring(2)}`; + } +} + +/** + * Helper function to wait for user login + */ +export const waitForLogin = async (): Promise => { + return new Promise((resolve) => { + ext.authProvider?.subscribe(({ state }) => { + if (state.userInfo) { + resolve(state.userInfo); + } + }); + }); +}; diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/clone-project-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/clone-project-cmd.ts index 41880c2f6e8..4d7178bea0d 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/clone-project-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/clone-project-cmd.ts @@ -31,7 +31,6 @@ import { import { type ExtensionContext, ProgressLocation, type QuickPickItem, QuickPickItemKind, Uri, commands, window } from "vscode"; import { ext } from "../extensionVariables"; import { initGit } from "../git/main"; -import { authStore } from "../stores/auth-store"; import { dataCacheStore } from "../stores/data-cache-store"; import { webviewStateStore } from "../stores/webview-state-store"; import { createDirectory, openDirectory } from "../utils"; @@ -168,7 +167,11 @@ export function cloneRepoCommand(context: ExtensionContext) { ]); // set context.yaml - updateContextFile(clonedResp[0].clonedPath, authStore.getState().state.userInfo!, selectedProject, selectedOrg, projectCache); + const userInfo = ext.authProvider?.getState()?.state?.userInfo; + if (!userInfo) { + throw new Error("User information is not available. Please ensure you are logged in."); + } + updateContextFile(clonedResp[0].clonedPath, userInfo, selectedProject, selectedOrg, projectCache); const subDir = params?.component?.spec?.source ? getComponentKindRepoSource(params?.component?.spec?.source)?.path || "" : ""; const subDirFullPath = join(clonedResp[0].clonedPath, subDir); if (params?.technology === "ballerina") { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/cmd-utils.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/cmd-utils.ts index da900aeedcf..01f4863135d 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/cmd-utils.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/cmd-utils.ts @@ -19,7 +19,7 @@ import { CommandIds, type ComponentKind, type ExtensionName, type Organization, type Project, type UserInfo } from "@wso2/wso2-platform-core"; import { ProgressLocation, type QuickPickItem, QuickPickItemKind, type WorkspaceFolder, commands, window, workspace } from "vscode"; import { type ExtensionVariables, ext } from "../extensionVariables"; -import { authStore, waitForLogin } from "../stores/auth-store"; +import { waitForLogin } from "../auth/wso2-auth-provider"; import { dataCacheStore } from "../stores/data-cache-store"; import { webviewStateStore } from "../stores/webview-state-store"; @@ -326,7 +326,7 @@ export async function quickPickWithLoader(params: { } export const getUserInfoForCmd = async (message: string): Promise => { - let userInfo = authStore.getState().state.userInfo; + let userInfo = ext.authProvider?.getState().state.userInfo; const extensionName = webviewStateStore.getState().state.extensionName; if (!userInfo) { const loginSelection = await window.showInformationMessage( @@ -351,7 +351,7 @@ export const getUserInfoForCmd = async (message: string): Promise { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/create-component-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/create-component-cmd.ts index ed5d6337a67..a65bd5732ed 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/create-component-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/create-component-cmd.ts @@ -20,15 +20,12 @@ import { existsSync, readFileSync } from "fs"; import * as os from "os"; import * as path from "path"; import { - ChoreoBuildPackNames, ChoreoComponentType, CommandIds, type ComponentKind, DevantScopes, type ExtensionName, type ICreateComponentCmdParams, - type Organization, - type Project, type SubmitComponentCreateReq, type WorkspaceConfig, getComponentKindRepoSource, @@ -37,16 +34,15 @@ import { getTypeOfIntegrationType, parseGitURL, } from "@wso2/wso2-platform-core"; -import { type ExtensionContext, ProgressLocation, type QuickPickItem, Uri, commands, env, window, workspace } from "vscode"; +import { type ExtensionContext, ProgressLocation, type QuickPickItem, Uri, commands, window, workspace } from "vscode"; import { ext } from "../extensionVariables"; import { initGit } from "../git/main"; import { getGitRemotes, getGitRoot } from "../git/util"; import { getLogger } from "../logger/logger"; -import { authStore } from "../stores/auth-store"; import { contextStore, waitForContextStoreToLoad } from "../stores/context-store"; import { dataCacheStore } from "../stores/data-cache-store"; import { webviewStateStore } from "../stores/webview-state-store"; -import { convertFsPathToUriPath, delay, isSamePath, isSubpath, openDirectory } from "../utils"; +import { convertFsPathToUriPath, isSamePath, isSubpath, openDirectory } from "../utils"; import { showComponentDetailsView } from "../webviews/ComponentDetailsView"; import { ComponentFormView, type IComponentCreateFormParams } from "../webviews/ComponentFormView"; import { getUserInfoForCmd, isRpcActive, selectOrg, selectProjectWithCreateNew, setExtensionName } from "./cmd-utils"; @@ -221,7 +217,13 @@ export function createNewComponentCommand(context: ExtensionContext) { ); if (resp !== "Proceed") { const projectCache = dataCacheStore.getState().getProjects(selectedOrg?.handle); - updateContextFile(gitRoot, authStore.getState().state.userInfo!, selectedProject, selectedOrg, projectCache); + const authProvider = ext.authProvider; + const userInfo = authProvider?.getState().state.userInfo; + if (!authProvider || !userInfo) { + window.showErrorMessage("User information is not available. Please sign in and try again."); + return; + } + updateContextFile(gitRoot, userInfo, selectedProject, selectedOrg, projectCache); contextStore.getState().refreshState(); return; } @@ -362,8 +364,13 @@ export const submitCreateComponentHandler = async ({ createParams, org, project } } } else { - updateContextFile(gitRoot, authStore.getState().state.userInfo!, project, org, projectCache); - contextStore.getState().refreshState(); + const userInfo = ext.authProvider?.getState().state.userInfo; + if (userInfo) { + updateContextFile(gitRoot, userInfo, project, org, projectCache); + contextStore.getState().refreshState(); + } else { + getLogger().error("Cannot update context file: userInfo is undefined."); + } } } } catch (err) { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/refresh-directory-context-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/refresh-directory-context-cmd.ts index 3903645d3f0..b41e159c173 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/refresh-directory-context-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/refresh-directory-context-cmd.ts @@ -19,7 +19,6 @@ import { CommandIds, type ICmdParamsBase } from "@wso2/wso2-platform-core"; import { type ExtensionContext, commands, window } from "vscode"; import { ext } from "../extensionVariables"; -import { authStore } from "../stores/auth-store"; import { contextStore } from "../stores/context-store"; import { isRpcActive, setExtensionName } from "./cmd-utils"; @@ -29,7 +28,7 @@ export function refreshContextCommand(context: ExtensionContext) { try { isRpcActive(ext); setExtensionName(params?.extName); - const userInfo = authStore.getState().state.userInfo; + const userInfo = ext.authProvider?.getState().state.userInfo; if (!userInfo) { throw new Error("You are not logged in. Please log in and retry."); } diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-cmd.ts index 589900bb5b1..306f4e58ff8 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-cmd.ts @@ -30,6 +30,8 @@ export function signInCommand(context: ExtensionContext) { setExtensionName(params?.extName); try { isRpcActive(ext); + // Cancel any pending session creation from accounts menu + ext.authProvider?.cancelPendingSessionCreation(); getLogger().debug("Signing in to WSO2 Platform"); const callbackUrl = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://wso2.wso2-platform/signin`)); @@ -54,5 +56,10 @@ export function signInCommand(context: ExtensionContext) { } } }), + // Register cancellation command + commands.registerCommand(CommandIds.CancelSignIn, () => { + console.log("Cancelling pending session creation via command"); + ext.authProvider?.cancelPendingSessionCreation(); + }) ); } diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-with-code-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-with-code-cmd.ts index 1825dba26d6..159f6a2da11 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-with-code-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-in-with-code-cmd.ts @@ -23,7 +23,6 @@ import { ResponseError } from "vscode-jsonrpc"; import { ErrorCode } from "../choreo-rpc/constants"; import { ext } from "../extensionVariables"; import { getLogger } from "../logger/logger"; -import { authStore } from "../stores/auth-store"; import { isRpcActive, setExtensionName } from "./cmd-utils"; export function signInWithAuthCodeCommand(context: ExtensionContext) { @@ -45,7 +44,7 @@ export function signInWithAuthCodeCommand(context: ExtensionContext) { ext.clients.rpcClient.signInWithAuthCode(authCode).then(async (userInfo) => { if (userInfo) { const region = await ext.clients.rpcClient.getCurrentRegion(); - authStore.getState().loginSuccess(userInfo, region); + ext.authProvider?.getState().loginSuccess(userInfo, region); } }); } else { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-out-cmd.ts b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-out-cmd.ts index ccd9bf49dfb..61e0a42b46d 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-out-cmd.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/cmds/sign-out-cmd.ts @@ -20,7 +20,6 @@ import { CommandIds } from "@wso2/wso2-platform-core"; import { type ExtensionContext, commands, window } from "vscode"; import { ext } from "../extensionVariables"; import { getLogger } from "../logger/logger"; -import { authStore } from "../stores/auth-store"; import { isRpcActive } from "./cmd-utils"; export function signOutCommand(context: ExtensionContext) { @@ -28,9 +27,7 @@ export function signOutCommand(context: ExtensionContext) { commands.registerCommand(CommandIds.SignOut, async () => { try { isRpcActive(ext); - getLogger().debug("Signing out from WSO2 Platform"); - authStore.getState().logout(); - window.showInformationMessage("Successfully signed out from WSO2 Platform!"); + ext.authProvider?.getState().logout(); } catch (error: any) { getLogger().error(`Error while signing out from WSO2 Platform. ${error?.message}${error?.cause ? `\nCause: ${error.cause.message}` : ""}`); if (error instanceof Error) { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/error-utils.ts b/workspaces/wso2-platform/wso2-platform-extension/src/error-utils.ts index cde75f4fc3b..c8f83763278 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/error-utils.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/error-utils.ts @@ -21,7 +21,6 @@ import { ResponseError } from "vscode-jsonrpc"; import { ErrorCode } from "./choreo-rpc/constants"; import { ext } from "./extensionVariables"; import { getLogger } from "./logger/logger"; -import { authStore } from "./stores/auth-store"; import { webviewStateStore } from "./stores/webview-state-store"; export function handlerError(err: any) { @@ -44,20 +43,20 @@ export function handlerError(err: any) { getLogger().error("InternalError", err); break; case ErrorCode.UnauthorizedError: - if (authStore.getState().state?.userInfo) { - authStore.getState().logout(); + if (ext.authProvider?.getState().state?.userInfo) { + ext.authProvider?.getState().logout(); w.showErrorMessage("Unauthorized. Please sign in again."); } break; case ErrorCode.TokenNotFoundError: - if (authStore.getState().state?.userInfo) { - authStore.getState().logout(); + if (ext.authProvider?.getState().state?.userInfo) { + ext.authProvider?.getState().logout(); w.showErrorMessage("Token not found. Please sign in again."); } break; case ErrorCode.InvalidTokenError: - if (authStore.getState().state?.userInfo) { - authStore.getState().logout(); + if (ext.authProvider?.getState().state?.userInfo) { + ext.authProvider?.getState().logout(); w.showErrorMessage("Invalid token. Please sign in again."); } break; @@ -65,8 +64,8 @@ export function handlerError(err: any) { getLogger().error("ForbiddenError", err); break; case ErrorCode.RefreshTokenError: - if (authStore.getState().state?.userInfo) { - authStore.getState().logout(); + if (ext.authProvider?.getState().state?.userInfo) { + ext.authProvider?.getState().logout(); w.showErrorMessage("Failed to refresh user session. Please sign in again."); } break; diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/extension.ts b/workspaces/wso2-platform/wso2-platform-extension/src/extension.ts index 93d2cf5d1eb..fc41f6841ab 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/extension.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/extension.ts @@ -17,7 +17,8 @@ */ import * as vscode from "vscode"; -import { type ConfigurationChangeEvent, commands, window, workspace } from "vscode"; +import { type ConfigurationChangeEvent, authentication, commands, window, workspace } from "vscode"; +import { WSO2AuthenticationProvider, WSO2_AUTH_PROVIDER_ID } from "./auth/wso2-auth-provider"; import { PlatformExtensionApi } from "./PlatformExtensionApi"; import { ChoreoRPCClient } from "./choreo-rpc"; import { initRPCServer } from "./choreo-rpc/activate"; @@ -30,7 +31,6 @@ import { ext } from "./extensionVariables"; import { getLogger, initLogger } from "./logger/logger"; import { activateChoreoMcp } from "./mcp"; import { activateStatusbar } from "./status-bar"; -import { authStore } from "./stores/auth-store"; import { contextStore } from "./stores/context-store"; import { dataCacheStore } from "./stores/data-cache-store"; import { locationStore } from "./stores/location-store"; @@ -53,15 +53,12 @@ export async function activate(context: vscode.ExtensionContext) { getLogger().info(`CLI version: ${getCliVersion()}`); // Initialize stores - await authStore.persist.rehydrate(); await contextStore.persist.rehydrate(); await dataCacheStore.persist.rehydrate(); await locationStore.persist.rehydrate(); // Set context values - authStore.subscribe(({ state }) => { - vscode.commands.executeCommand("setContext", "isLoggedIn", !!state.userInfo); - }); + // Note: authProvider will be set up below, so we'll subscribe to it in initAuth contextStore.subscribe(({ state }) => { vscode.commands.executeCommand("setContext", "isLoadingContextDirs", state.loading); vscode.commands.executeCommand("setContext", "hasSelectedProject", !!state.selected); @@ -74,9 +71,23 @@ export async function activate(context: vscode.ExtensionContext) { const rpcClient = new ChoreoRPCClient(); ext.clients = { rpcClient: rpcClient }; - await initRPCServer() + // Initialize and register authentication provider + const authProvider = new WSO2AuthenticationProvider(context.secrets); + ext.authProvider = authProvider; + context.subscriptions.push( + authentication.registerAuthenticationProvider(WSO2_AUTH_PROVIDER_ID, "WSO2 Platform", authProvider, { + supportsMultipleAccounts: false, + }), + ); + + // Subscribe to auth state changes + authProvider.subscribe(({ state }) => { + vscode.commands.executeCommand("setContext", "isLoggedIn", !!state.userInfo); + }); + + await initRPCServer(); await ext.clients.rpcClient.init(); - authStore.getState().initAuth(); + authProvider.getState().initAuth(); continueCreateComponent(); if (ext.isChoreoExtInstalled) { addTerminalHandlers(); @@ -116,7 +127,7 @@ function registerPreInitHandlers(): any { ); if (selection === "Restart Now") { if (affectsConfiguration("WSO2.WSO2-Platform.Advanced.ChoreoEnvironment")) { - authStore.getState().logout(); + ext.authProvider?.getState().logout(); } commands.executeCommand("workbench.action.reloadWindow"); } diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/extensionVariables.ts b/workspaces/wso2-platform/wso2-platform-extension/src/extensionVariables.ts index ac693995485..8002472c69e 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/extensionVariables.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/extensionVariables.ts @@ -18,6 +18,7 @@ import type { GetCliRpcResp } from "@wso2/wso2-platform-core"; import { type ExtensionContext, type StatusBarItem, extensions } from "vscode"; +import type { WSO2AuthenticationProvider } from "./auth/wso2-auth-provider"; import type { PlatformExtensionApi } from "./PlatformExtensionApi"; import type { ChoreoRPCClient } from "./choreo-rpc"; @@ -30,6 +31,7 @@ export class ExtensionVariables { public choreoEnv: string; public isChoreoExtInstalled: boolean; public isDevantCloudEditor: boolean; + public authProvider?: WSO2AuthenticationProvider; public constructor() { this.choreoEnv = "prod"; diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/status-bar.ts b/workspaces/wso2-platform/wso2-platform-extension/src/status-bar.ts index 9c7a9b95a61..fa7baa8059e 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/status-bar.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/status-bar.ts @@ -1,6 +1,6 @@ import { type AuthState, CommandIds, type ContextStoreState, type WebviewState } from "@wso2/wso2-platform-core"; import { type ExtensionContext, StatusBarAlignment, type StatusBarItem, window } from "vscode"; -import { authStore } from "./stores/auth-store"; +import { ext } from "./extensionVariables"; import { contextStore } from "./stores/context-store"; import { webviewStateStore } from "./stores/webview-state-store"; @@ -12,14 +12,14 @@ export function activateStatusbar({ subscriptions }: ExtensionContext) { subscriptions.push(statusBarItem); let webviewState: WebviewState = webviewStateStore.getState()?.state; - let authState: AuthState | null = authStore.getState()?.state; + let authState: AuthState | undefined = ext.authProvider?.getState()?.state; let contextStoreState: ContextStoreState | null = contextStore.getState()?.state; webviewStateStore.subscribe((state) => { webviewState = state.state; updateStatusBarItem(webviewState, authState, contextStoreState); }); - authStore.subscribe((state) => { + ext.authProvider?.subscribe((state) => { authState = state.state; updateStatusBarItem(webviewState, authState, contextStoreState); }); diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/stores/auth-store.ts b/workspaces/wso2-platform/wso2-platform-extension/src/stores/auth-store.ts deleted file mode 100644 index 923cbdda230..00000000000 --- a/workspaces/wso2-platform/wso2-platform-extension/src/stores/auth-store.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { AuthState, Organization, UserInfo } from "@wso2/wso2-platform-core"; -import { createStore } from "zustand"; -import { persist } from "zustand/middleware"; -import { ext } from "../extensionVariables"; -import { contextStore } from "./context-store"; -import { dataCacheStore } from "./data-cache-store"; -import { getGlobalStateStore } from "./store-utils"; - -interface AuthStore { - state: AuthState; - resetState: () => void; - loginSuccess: (userInfo: UserInfo, region: "US" | "EU") => void; - logout: () => Promise; - initAuth: () => Promise; -} - -const initialState: AuthState = { userInfo: null, region: "US" }; - -export const authStore = createStore( - persist( - (set, get) => ({ - state: initialState, - resetState: () => set(() => ({ state: initialState })), - loginSuccess: (userInfo, region) => { - dataCacheStore.getState().setOrgs(userInfo.organizations); - set(({ state }) => ({ state: { ...state, userInfo, region } })); - contextStore.getState().refreshState(); - }, - logout: async () => { - get().resetState(); - ext.clients.rpcClient.signOut().catch(() => { - // ignore error - }); - }, - initAuth: async () => { - try { - const userInfo = await ext.clients.rpcClient.getUserInfo(); - if (userInfo) { - const region = await ext.clients.rpcClient.getCurrentRegion(); - get().loginSuccess(userInfo, region); - const contextStoreState = contextStore.getState().state; - if (contextStoreState.selected?.org) { - ext?.clients?.rpcClient?.changeOrgContext(contextStoreState.selected?.org?.id?.toString()); - } - } else { - get().logout(); - } - } catch (err) { - get().logout(); - } - }, - }), - getGlobalStateStore("auth-zustand-storage"), - ), -); - -export const waitForLogin = async (): Promise => { - return new Promise((resolve) => { - authStore.subscribe(({ state }) => { - if (state.userInfo) { - resolve(state.userInfo); - } - }); - }); -}; diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/stores/context-store.ts b/workspaces/wso2-platform/wso2-platform-extension/src/stores/context-store.ts index 2120c34ceb9..ff59fef387d 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/stores/context-store.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/stores/context-store.ts @@ -37,7 +37,6 @@ import { persist } from "zustand/middleware"; import { ext } from "../extensionVariables"; import { getGitRemotes, getGitRoot } from "../git/util"; import { isSamePath, isSubpath } from "../utils"; -import { authStore } from "./auth-store"; import { dataCacheStore } from "./data-cache-store"; import { locationStore } from "./location-store"; import { getWorkspaceStateStore } from "./store-utils"; @@ -60,7 +59,7 @@ export const contextStore = createStore( resetState: () => set(() => ({ state: initialState })), refreshState: async () => { try { - if (authStore.getState().state?.userInfo) { + if (ext.authProvider?.getState().state?.userInfo) { set(({ state }) => ({ state: { ...state, loading: true } })); let items = await getAllContexts(get().state?.items); let selected = await getSelected(items, get().state?.selected); @@ -153,7 +152,7 @@ const getAllContexts = async (previousItems: { [key: string]: ContextItemEnriche } else if (previousItems?.[key]?.org && previousItems?.[key].project) { contextItems[key] = { ...previousItems?.[key], contextDirs: [contextDir] }; } else { - const userOrgs = authStore.getState().state.userInfo?.organizations; + const userOrgs = ext.authProvider?.getState().state.userInfo?.organizations; const matchingOrg = userOrgs?.find((item) => item.handle === contextItem.org); const projectsOfOrg = dataCacheStore.getState().getProjects(contextItem.org); @@ -199,7 +198,7 @@ const getAllContexts = async (previousItems: { [key: string]: ContextItemEnriche const getSelected = async (items: { [key: string]: ContextItemEnriched }, prevSelected?: ContextItemEnriched) => { if (ext.isDevantCloudEditor && process.env.CLOUD_INITIAL_ORG_ID && process.env.CLOUD_INITIAL_PROJECT_ID) { // Give priority to project provided as env variable, when running in the cloud editor - const userOrgs = authStore.getState().state.userInfo?.organizations; + const userOrgs = ext.authProvider?.getState().state.userInfo?.organizations; const matchingOrg = userOrgs?.find( (item) => item.uuid === process.env.CLOUD_INITIAL_ORG_ID || item.id?.toString() === process.env.CLOUD_INITIAL_ORG_ID, ); @@ -261,7 +260,7 @@ const getSelected = async (items: { [key: string]: ContextItemEnriched }, prevSe }; const getEnrichedContexts = async (items: { [key: string]: ContextItemEnriched }) => { - const userOrgs = authStore.getState().state.userInfo?.organizations; + const userOrgs = ext.authProvider?.getState().state.userInfo?.organizations; const orgsSet = new Set(); Object.values(items).forEach((item) => { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/stores/data-cache-store.ts b/workspaces/wso2-platform/wso2-platform-extension/src/stores/data-cache-store.ts index d57723e2db3..c4e35b6b9a7 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/stores/data-cache-store.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/stores/data-cache-store.ts @@ -20,7 +20,6 @@ import type { CommitHistory, ComponentKind, DataCacheState, Environment, Organiz import { createStore } from "zustand"; import { persist } from "zustand/middleware"; import { ext } from "../extensionVariables"; -import { authStore } from "./auth-store"; import { getGlobalStateStore } from "./store-utils"; interface DataCacheStore { @@ -161,7 +160,7 @@ export const dataCacheStore = createStore( ); const getRootKey = (orgHandle: string) => { - const region = authStore.getState().state.region; + const region = ext.authProvider?.getState().state.region; const env = ext.choreoEnv; let orgRegionHandle = `${region}-${orgHandle}`; if (env !== "prod") { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/tarminal-handlers.ts b/workspaces/wso2-platform/wso2-platform-extension/src/tarminal-handlers.ts index 4963fea8654..f1763451667 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/tarminal-handlers.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/tarminal-handlers.ts @@ -20,10 +20,9 @@ import { CommandIds, type ComponentKind } from "@wso2/wso2-platform-core"; import type vscode from "vscode"; import { commands, window, workspace } from "vscode"; import { getChoreoExecPath } from "./choreo-rpc/cli-install"; -import { authStore } from "./stores/auth-store"; import { contextStore } from "./stores/context-store"; -import { dataCacheStore } from "./stores/data-cache-store"; import { delay, getSubPath } from "./utils"; +import { ext } from "./extensionVariables"; export class ChoreoConfigurationProvider implements vscode.DebugConfigurationProvider { resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration): vscode.DebugConfiguration | undefined { @@ -65,7 +64,7 @@ export function addTerminalHandlers() { let cliCommand = e.name.split("[choreo-shell]").pop()?.replaceAll(")", ""); const terminalPath = (e.creationOptions as any)?.cwd; const rpcPath = getChoreoExecPath(); - const userInfo = authStore.getState().state?.userInfo; + const userInfo = ext.authProvider?.getState().state?.userInfo; if (terminalPath) { if (!e.name?.includes("--project")) { window diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/telemetry/utils.ts b/workspaces/wso2-platform/wso2-platform-extension/src/telemetry/utils.ts index 35b4aa7a182..8a5f79e8407 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/telemetry/utils.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/telemetry/utils.ts @@ -16,7 +16,7 @@ * under the License. */ -import { authStore } from "../stores/auth-store"; +import { ext } from "../extensionVariables"; import { getTelemetryReporter } from "./telemetry"; // export async function sendProjectTelemetryEvent(eventName: string, properties?: { [key: string]: string; }, measurements?: { [key: string]: number; }) { @@ -46,8 +46,8 @@ export function sendTelemetryException(error: Error, properties?: { [key: string // Create common properties for all events export function getCommonProperties(): { [key: string]: string } { return { - idpId: authStore.getState().state?.userInfo?.userId!, + idpId: ext.authProvider?.getState().state?.userInfo?.userId ?? "", // check if the email ends with "@wso2.com" - isWSO2User: authStore.getState().state?.userInfo?.userEmail?.endsWith("@wso2.com") ? "true" : "false", + isWSO2User: ext.authProvider?.getState().state?.userInfo?.userEmail?.endsWith("@wso2.com") ? "true" : "false", }; } diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/uri-handlers.ts b/workspaces/wso2-platform/wso2-platform-extension/src/uri-handlers.ts index b413bfe0b4c..3aa77705b58 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/uri-handlers.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/uri-handlers.ts @@ -36,7 +36,6 @@ import { updateContextFile } from "./cmds/create-directory-context-cmd"; import { ext } from "./extensionVariables"; import { getGitRemotes, getGitRoot } from "./git/util"; import { getLogger } from "./logger/logger"; -import { authStore } from "./stores/auth-store"; import { contextStore, getContextKey, waitForContextStoreToLoad } from "./stores/context-store"; import { dataCacheStore } from "./stores/data-cache-store"; import { locationStore } from "./stores/location-store"; @@ -81,9 +80,9 @@ export function activateURIHandlers() { contextStore.getState().resetState(); } } - const region = await ext.clients.rpcClient.getCurrentRegion(); - authStore.getState().loginSuccess(userInfo, region); - window.showInformationMessage(`Successfully signed into ${extName}`); + const region = await ext.clients.rpcClient.getCurrentRegion(); + await ext.authProvider?.getState().loginSuccess(userInfo, region); + window.showInformationMessage(`Successfully signed into ${extName}`); } } catch (error: any) { if (!(error instanceof ResponseError) || ![ErrorCode.NoOrgsAvailable, ErrorCode.NoAccountAvailable].includes(error.code)) { @@ -265,7 +264,12 @@ const switchContextAndOpenDir = async (selectedPath: string, org: Organization, return; } const projectCache = dataCacheStore.getState().getProjects(org?.handle); - const contextFilePath = updateContextFile(gitRoot, authStore.getState().state.userInfo!, project, org, projectCache); + const userInfo = ext.authProvider?.getState().state.userInfo; + if (!userInfo) { + window.showErrorMessage("User information is not available. Please sign in and try again."); + return; + } + const contextFilePath = updateContextFile(gitRoot, userInfo, project, org, projectCache); const isWithinWorkspace = workspace.workspaceFolders?.some((item) => isSamePath(item.uri?.fsPath, selectedPath)); if (isWithinWorkspace) { diff --git a/workspaces/wso2-platform/wso2-platform-extension/src/webviews/WebviewRPC.ts b/workspaces/wso2-platform/wso2-platform-extension/src/webviews/WebviewRPC.ts index 762dc2973fa..3e181a7a8ea 100644 --- a/workspaces/wso2-platform/wso2-platform-extension/src/webviews/WebviewRPC.ts +++ b/workspaces/wso2-platform/wso2-platform-extension/src/webviews/WebviewRPC.ts @@ -123,7 +123,6 @@ import { ext } from "../extensionVariables"; import { initGit } from "../git/main"; import { getGitHead, getGitRemotes, getGitRoot, hasDirtyRepo, removeCredentialsFromGitURL } from "../git/util"; import { getLogger } from "../logger/logger"; -import { authStore } from "../stores/auth-store"; import { contextStore } from "../stores/context-store"; import { dataCacheStore } from "../stores/data-cache-store"; import { webviewStateStore } from "../stores/webview-state-store"; @@ -132,11 +131,11 @@ import { getConfigFileDrifts, getNormalizedPath, getSubPath, goTosource, readLoc // Register handlers function registerWebviewRPCHandlers(messenger: Messenger, view: WebviewPanel | WebviewView) { - authStore.subscribe((store) => messenger.sendNotification(AuthStoreChangedNotification, BROADCAST, store.state)); + ext.authProvider?.subscribe((store) => messenger.sendNotification(AuthStoreChangedNotification, BROADCAST, store.state)); webviewStateStore.subscribe((store) => messenger.sendNotification(WebviewStateChangedNotification, BROADCAST, store.state)); contextStore.subscribe((store) => messenger.sendNotification(ContextStoreChangedNotification, BROADCAST, store.state)); - messenger.onRequest(GetAuthState, () => authStore.getState().state); + messenger.onRequest(GetAuthState, () => ext.authProvider?.getState().state); messenger.onRequest(GetWebviewStoreState, async () => webviewStateStore.getState().state); messenger.onRequest(GetContextState, async () => contextStore.getState().state); @@ -399,7 +398,7 @@ function registerWebviewRPCHandlers(messenger: Messenger, view: WebviewPanel | W rmSync(join(params.componentDir, ".choreo", "component-config.yaml")); } - const org = authStore?.getState().state?.userInfo?.organizations?.find((item) => item.uuid === params.marketplaceItem?.organizationId); + const org = ext.authProvider?.getState().state?.userInfo?.organizations?.find((item) => item.uuid === params.marketplaceItem?.organizationId); if (!org) { return; }