diff --git a/workspaces/ballerina/ballerina-core/src/interfaces/constants.ts b/workspaces/ballerina/ballerina-core/src/interfaces/constants.ts index 1610359e42..5c8388e577 100644 --- a/workspaces/ballerina/ballerina-core/src/interfaces/constants.ts +++ b/workspaces/ballerina/ballerina-core/src/interfaces/constants.ts @@ -53,4 +53,5 @@ export const BI_COMMANDS = { ADD_NATURAL_FUNCTION: 'BI.project-explorer.add-natural-function', TOGGLE_TRACE_LOGS: 'BI.toggle.trace.logs', ADD_INTEGRATION: 'BI.project-explorer.add-integration', + NOTIFY_PROJECT_EXPLORER: 'BI.project-explorer.notify', }; diff --git a/workspaces/ballerina/ballerina-extension/src/stateMachine.ts b/workspaces/ballerina/ballerina-extension/src/stateMachine.ts index 12bf584187..9c266e17ad 100644 --- a/workspaces/ballerina/ballerina-extension/src/stateMachine.ts +++ b/workspaces/ballerina/ballerina-extension/src/stateMachine.ts @@ -2,12 +2,38 @@ import { ExtendedLangClient } from './core'; import { createMachine, assign, interpret } from 'xstate'; import { activateBallerina } from './extension'; -import { EVENT_TYPE, SyntaxTree, History, MachineStateValue, IUndoRedoManager, VisualizerLocation, webviewReady, MACHINE_VIEW, DIRECTORY_MAP, SCOPE, ProjectStructureResponse, ProjectStructureArtifactResponse, CodeData, ProjectDiagnosticsResponse, Type, dependencyPullProgress } from "@wso2/ballerina-core"; +import { + EVENT_TYPE, + SyntaxTree, + History, + MachineStateValue, + IUndoRedoManager, + VisualizerLocation, + webviewReady, + MACHINE_VIEW, + DIRECTORY_MAP, + SCOPE, + ProjectStructureResponse, + ProjectStructureArtifactResponse, + CodeData, + ProjectDiagnosticsResponse, + Type, + dependencyPullProgress, + BI_COMMANDS, + NodePosition +} from "@wso2/ballerina-core"; import { fetchAndCacheLibraryData } from './features/library-browser'; import { VisualizerWebview } from './views/visualizer/webview'; import { commands, extensions, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { notifyCurrentWebview, RPCLayer } from './RPCLayer'; -import { generateUid, getComponentIdentifier, getNodeByIndex, getNodeByName, getNodeByUid, getView } from './utils/state-machine-utils'; +import { + generateUid, + getComponentIdentifier, + getNodeByIndex, + getNodeByName, + getNodeByUid, + getView +} from './utils/state-machine-utils'; import * as path from 'path'; import { extension } from './BalExtensionContext'; import { AIStateMachine } from './views/ai-panel/aiMachine'; @@ -89,6 +115,7 @@ const stateMachine = createMachine( async (context, event) => { await buildProjectsStructure(context.projectInfo, StateMachine.langClient(), true); notifyCurrentWebview(); + notifyTreeView(event.projectPath, context.documentUri, context.position, context.view); // Resolve the next pending promise waiting for project root update completion pendingProjectRootUpdateResolvers.shift()?.(); } @@ -137,7 +164,13 @@ const stateMachine = createMachine( position: (context, event) => event.viewLocation.position ? event.viewLocation.position : context.position, identifier: (context, event) => event.viewLocation.identifier ? event.viewLocation.identifier : context.identifier, addType: (context, event) => event.viewLocation?.addType !== undefined ? event.viewLocation.addType : context?.addType, - }) + }), + (context, event) => notifyTreeView( + context.projectPath, + event.viewLocation.documentUri || context.documentUri, + event.viewLocation.position || context.position, + context.view + ) ] } }, @@ -149,26 +182,42 @@ const stateMachine = createMachine( { target: "renderInitialView", cond: (context, event) => event.data && event.data.isBI, - actions: assign({ - isBI: (context, event) => event.data.isBI, - projectPath: (context, event) => event.data.projectPath, - workspacePath: (context, event) => event.data.workspacePath, - scope: (context, event) => event.data.scope, - org: (context, event) => event.data.orgName, - package: (context, event) => event.data.packageName - }) + actions: [ + assign({ + isBI: (context, event) => event.data.isBI, + projectPath: (context, event) => event.data.projectPath, + workspacePath: (context, event) => event.data.workspacePath, + scope: (context, event) => event.data.scope, + org: (context, event) => event.data.orgName, + package: (context, event) => event.data.packageName + }), + (context, event) => notifyTreeView( + event.data.projectPath, + context.documentUri, + context.position, + context.view + ) + ] }, { target: "activateLS", cond: (context, event) => event.data && event.data.isBI === false, - actions: assign({ - isBI: (context, event) => event.data.isBI, - projectPath: (context, event) => event.data.projectPath, - workspacePath: (context, event) => event.data.workspacePath, - scope: (context, event) => event.data.scope, - org: (context, event) => event.data.orgName, - package: (context, event) => event.data.packageName - }) + actions: [ + assign({ + isBI: (context, event) => event.data.isBI, + projectPath: (context, event) => event.data.projectPath, + workspacePath: (context, event) => event.data.workspacePath, + scope: (context, event) => event.data.scope, + org: (context, event) => event.data.orgName, + package: (context, event) => event.data.packageName + }), + (context, event) => notifyTreeView( + event.data.projectPath, + context.documentUri, + context.position, + context.view + ) + ] } ], onError: { @@ -248,23 +297,31 @@ const stateMachine = createMachine( on: { OPEN_VIEW: { target: "viewActive", - actions: assign({ - org: (context, event) => event.viewLocation?.org, - package: (context, event) => event.viewLocation?.package, - view: (context, event) => event.viewLocation.view, - documentUri: (context, event) => event.viewLocation.documentUri, - projectPath: (context, event) => event.viewLocation?.projectPath || context?.projectPath, - position: (context, event) => event.viewLocation.position, - identifier: (context, event) => event.viewLocation.identifier, - serviceType: (context, event) => event.viewLocation.serviceType, - type: (context, event) => event.viewLocation?.type, - isGraphql: (context, event) => event.viewLocation?.isGraphql, - metadata: (context, event) => event.viewLocation?.metadata, - addType: (context, event) => event.viewLocation?.addType, - dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata, - artifactInfo: (context, event) => event.viewLocation?.artifactInfo, - rootDiagramId: (context, event) => event.viewLocation?.rootDiagramId - }) + actions: [ + assign({ + org: (context, event) => event.viewLocation?.org, + package: (context, event) => event.viewLocation?.package, + view: (context, event) => event.viewLocation.view, + documentUri: (context, event) => event.viewLocation.documentUri, + position: (context, event) => event.viewLocation.position, + projectPath: (context, event) => event.viewLocation?.projectPath || context?.projectPath, + identifier: (context, event) => event.viewLocation.identifier, + serviceType: (context, event) => event.viewLocation.serviceType, + type: (context, event) => event.viewLocation?.type, + isGraphql: (context, event) => event.viewLocation?.isGraphql, + metadata: (context, event) => event.viewLocation?.metadata, + addType: (context, event) => event.viewLocation?.addType, + dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata, + artifactInfo: (context, event) => event.viewLocation?.artifactInfo, + rootDiagramId: (context, event) => event.viewLocation?.rootDiagramId + }), + (context, event) => notifyTreeView( + context.projectPath, + event.viewLocation?.documentUri, + event.viewLocation?.position, + event.viewLocation?.view + ) + ] } } }, @@ -325,37 +382,53 @@ const stateMachine = createMachine( on: { OPEN_VIEW: { target: "viewInit", - actions: assign({ - view: (context, event) => event.viewLocation.view, - documentUri: (context, event) => event.viewLocation.documentUri, - position: (context, event) => event.viewLocation.position, - identifier: (context, event) => event.viewLocation.identifier, - serviceType: (context, event) => event.viewLocation.serviceType, - projectPath: (context, event) => event.viewLocation?.projectPath || context?.projectPath, - org: (context, event) => event.viewLocation?.org || context?.org, - package: (context, event) => event.viewLocation?.package || context?.package, - type: (context, event) => event.viewLocation?.type, - isGraphql: (context, event) => event.viewLocation?.isGraphql, - metadata: (context, event) => event.viewLocation?.metadata, - addType: (context, event) => event.viewLocation?.addType, - dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata, - artifactInfo: (context, event) => event.viewLocation?.artifactInfo, - rootDiagramId: (context, event) => event.viewLocation?.rootDiagramId - }) + actions: [ + assign({ + view: (context, event) => event.viewLocation.view, + documentUri: (context, event) => event.viewLocation.documentUri, + position: (context, event) => event.viewLocation.position, + identifier: (context, event) => event.viewLocation.identifier, + serviceType: (context, event) => event.viewLocation.serviceType, + projectPath: (context, event) => event.viewLocation?.projectPath || context?.projectPath, + org: (context, event) => event.viewLocation?.org || context?.org, + package: (context, event) => event.viewLocation?.package || context?.package, + type: (context, event) => event.viewLocation?.type, + isGraphql: (context, event) => event.viewLocation?.isGraphql, + metadata: (context, event) => event.viewLocation?.metadata, + addType: (context, event) => event.viewLocation?.addType, + dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata, + artifactInfo: (context, event) => event.viewLocation?.artifactInfo, + rootDiagramId: (context, event) => event.viewLocation?.rootDiagramId + }), + (context, event) => notifyTreeView( + event.viewLocation?.projectPath || context?.projectPath, + event.viewLocation?.documentUri, + event.viewLocation?.position, + event.viewLocation?.view + ) + ] }, VIEW_UPDATE: { target: "webViewLoaded", - actions: assign({ - documentUri: (context, event) => event.viewLocation.documentUri, - position: (context, event) => event.viewLocation.position, - view: (context, event) => event.viewLocation.view, - identifier: (context, event) => event.viewLocation.identifier, - serviceType: (context, event) => event.viewLocation.serviceType, - type: (context, event) => event.viewLocation?.type, - isGraphql: (context, event) => event.viewLocation?.isGraphql, - addType: (context, event) => event.viewLocation?.addType, - dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata - }) + actions: [ + assign({ + documentUri: (context, event) => event.viewLocation.documentUri, + position: (context, event) => event.viewLocation.position, + view: (context, event) => event.viewLocation.view, + identifier: (context, event) => event.viewLocation.identifier, + serviceType: (context, event) => event.viewLocation.serviceType, + type: (context, event) => event.viewLocation?.type, + isGraphql: (context, event) => event.viewLocation?.isGraphql, + addType: (context, event) => event.viewLocation?.addType, + dataMapperMetadata: (context, event) => event.viewLocation?.dataMapperMetadata + }), + (context, event) => notifyTreeView( + context.projectPath, + event.viewLocation?.documentUri, + event.viewLocation?.position, + event.viewLocation?.view + ) + ] }, FILE_EDIT: { target: "viewEditing" @@ -395,7 +468,9 @@ const stateMachine = createMachine( fetchProjectInfo: (context, event) => { return new Promise(async (resolve, reject) => { try { - const projectInfo = await context.langClient.getProjectInfo({ projectPath: context.workspacePath || context.projectPath }); + const projectInfo = await context.langClient.getProjectInfo({ + projectPath: context.workspacePath || context.projectPath + }); resolve({ projectInfo }); } catch (error) { throw new Error("Error occurred while fetching project info.", error); @@ -945,6 +1020,20 @@ async function handleSingleWorkspaceFolder(workspaceURI: Uri): Promise dataProvider.refresh()); + commands.registerCommand( + BI_COMMANDS.NOTIFY_PROJECT_EXPLORER, + (event: { + projectPath: string, + documentUri: string, + position: NodePosition, + view: MACHINE_VIEW + }) => { + dataProvider.revealInTreeView(event.documentUri, event.projectPath, event.position, event.view); + } + ); commands.executeCommand('setContext', 'BI.isWorkspaceSupported', extension.isWorkspaceSupported ?? false); if (isBallerinaWorkspace) { 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 ebc0c203c5..ec8a4dce67 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 @@ -19,7 +19,17 @@ import * as vscode from 'vscode'; import { window, Uri, commands } from 'vscode'; import path = require('path'); -import { DIRECTORY_MAP, ProjectStructureArtifactResponse, ProjectStructureResponse, SHARED_COMMANDS, BI_COMMANDS, PackageConfigSchema, BallerinaProject, VisualizerLocation, ProjectStructure } from "@wso2/ballerina-core"; +import { + DIRECTORY_MAP, + ProjectStructureArtifactResponse, + ProjectStructureResponse, + SHARED_COMMANDS, + BI_COMMANDS, + VisualizerLocation, + ProjectStructure, + MACHINE_VIEW, + NodePosition +} from "@wso2/ballerina-core"; import { extension } from "../biExtentionContext"; interface Property { @@ -34,17 +44,20 @@ interface Property { export class ProjectExplorerEntry extends vscode.TreeItem { children: ProjectExplorerEntry[] | undefined; info: string | undefined; + position: NodePosition | undefined; constructor( public readonly label: string, public collapsibleState: vscode.TreeItemCollapsibleState, info: string | undefined = undefined, icon: string = 'folder', - isCodicon: boolean = false + isCodicon: boolean = false, + position: NodePosition | undefined = undefined ) { super(label, collapsibleState); this.tooltip = `${this.label}`; this.info = info; + this.position = position; if (icon && isCodicon) { this.iconPath = new vscode.ThemeIcon(icon); } else if (icon) { @@ -62,25 +75,156 @@ export class ProjectExplorerEntryProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + private _treeView: vscode.TreeView | undefined; + private _isRefreshing: boolean = false; + private _pendingRefresh: boolean = false; + + setTreeView(treeView: vscode.TreeView): void { + this._treeView = treeView; + } refresh(): void { + // If already refreshing, mark that we need another refresh after current one completes + if (this._isRefreshing) { + this._pendingRefresh = true; + return; + } + + this._isRefreshing = true; + this._pendingRefresh = false; + window.withProgress({ location: { viewId: BI_COMMANDS.PROJECT_EXPLORER }, title: 'Loading project structure' }, async () => { try { - const data = await getProjectStructureData(); + this._data = []; + + const data = await getProjectStructureData(); this._data = data; // Fire the event after data is fully populated this._onDidChangeTreeData.fire(); } catch (err) { - console.error(err); + console.error('[ProjectExplorer] Error during refresh:', err); this._data = []; this._onDidChangeTreeData.fire(); + } finally { + this._isRefreshing = false; + + // If another refresh was requested while we were refreshing, do it now + if (this._pendingRefresh) { + console.log('[ProjectExplorer] Executing pending refresh'); + this._pendingRefresh = false; + this.refresh(); + } } }); } + revealInTreeView( + documentUri: string | undefined, + projectPath: string | undefined, + position: NodePosition | undefined, + view: MACHINE_VIEW | undefined + ): void { + if (!this._treeView) { + return; + } + + let itemToReveal: ProjectExplorerEntry | undefined; + + // Case 1: If documentUri is present, find the tree item with matching path and position + if (documentUri) { + itemToReveal = this.findItemByPathAndPosition(documentUri, position); + } + // Case 2: If documentUri is undefined but projectPath is present and view is not 'WorkspaceOverview' + else if (projectPath && view !== MACHINE_VIEW.WorkspaceOverview) { + itemToReveal = this.findItemByPathAndPosition(projectPath, position); + } + + // Reveal the item if found + if (itemToReveal) { + this._treeView.reveal(itemToReveal, { + select: true, + focus: true, + expand: true + }); + } + } + + /** + * Recursively search for a tree item by its path and position + */ + private findItemByPathAndPosition(targetPath: string, targetPosition: NodePosition | undefined): ProjectExplorerEntry | undefined { + for (const rootItem of this._data) { + // Check if the root item matches + if (this.matchesPathAndPosition(rootItem, targetPath, targetPosition)) { + return rootItem; + } + // Recursively search children + const found = this.searchChildrenByPathAndPosition(rootItem, targetPath, targetPosition); + if (found) { + return found; + } + } + return undefined; + } + + /** + * Recursively search through children for a matching path and position + */ + private searchChildrenByPathAndPosition(parent: ProjectExplorerEntry, targetPath: string, targetPosition: NodePosition | undefined): ProjectExplorerEntry | undefined { + if (!parent.children) { + return undefined; + } + + for (const child of parent.children) { + if (this.matchesPathAndPosition(child, targetPath, targetPosition)) { + return child; + } + + const found = this.searchChildrenByPathAndPosition(child, targetPath, targetPosition); + if (found) { + return found; + } + } + + return undefined; + } + + /** + * Check if an item matches the given path and position + */ + private matchesPathAndPosition(item: ProjectExplorerEntry, targetPath: string, targetPosition: NodePosition | undefined): boolean { + // Path must match + if (item.info !== targetPath) { + return false; + } + + // If no target position is provided, match by path only + if (!targetPosition) { + return true; + } + + // If target position is provided but item has no position, don't match + if (!item.position) { + return true; // Fall back to path-only matching for items without position + } + + // Compare positions + return this.positionsMatch(item.position, targetPosition); + } + + /** + * Check if two positions match + */ + private positionsMatch(pos1: NodePosition, pos2: NodePosition): boolean { + return pos1.startLine === pos2.startLine && + pos1.startColumn === pos2.startColumn && + pos1.endLine === pos2.endLine && + pos1.endColumn === pos2.endColumn; + } + constructor() { this._data = []; } @@ -166,8 +310,18 @@ async function getProjectStructureData(): Promise { // Generate the tree data for the projects const projects = projectStructure.projects; - const isSingleProject = projects.length === 1; + // Filter projects to avoid duplicates - only include unique project paths + const uniqueProjects = new Map(); for (const project of projects) { + if (!uniqueProjects.has(project.projectPath)) { + uniqueProjects.set(project.projectPath, project); + } + } + + const filteredProjects = Array.from(uniqueProjects.values()); + + const isSingleProject = filteredProjects.length === 1; + for (const project of filteredProjects) { const projectTree = generateTreeData(project, isSingleProject); if (projectTree) { data.push(projectTree); @@ -371,7 +525,9 @@ function getComponents(items: ProjectStructureArtifactResponse[], itemType: DIRE comp.name, vscode.TreeItemCollapsibleState.None, comp.path, - comp.icon + comp.icon, + false, + comp.position ); fileEntry.resourceUri = Uri.parse(`bi-category:${projectPath}`); fileEntry.command = { @@ -394,4 +550,3 @@ function getComponents(items: ProjectStructureArtifactResponse[], itemType: DIRE } return entries; } -