diff --git a/data/working_smells_config.json b/data/working_smells_config.json index 4c7e3df..fc3ce77 100644 --- a/data/working_smells_config.json +++ b/data/working_smells_config.json @@ -58,7 +58,7 @@ "threshold": { "label": "Threshold", "description": "Defines a threshold for triggering this smell.", - "value": 9 + "value": 3 } } }, @@ -66,7 +66,7 @@ "message_id": "LEC001", "name": "Long Element Chain (LEC)", "acronym": "LEC", - "enabled": true, + "enabled": false, "smell_description": "Chained element access can be inefficient in large structures, increasing access time and CPU effort, thereby consuming more energy.", "analyzer_options": { "threshold": { @@ -80,7 +80,7 @@ "message_id": "CRC001", "name": "Cached Repeated Calls (CRC)", "acronym": "CRC", - "enabled": true, + "enabled": false, "smell_description": "Failing to cache repeated expensive calls leads to redundant computation, which wastes CPU cycles and drains energy needlessly.", "analyzer_options": { "threshold": { @@ -98,4 +98,4 @@ "smell_description": "String concatenation in loops creates new objects each time, increasing memory churn and CPU workload, which leads to higher energy consumption.", "analyzer_options": {} } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8dfc29b..9c948bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ecooptimizer", - "version": "0.2.2", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ecooptimizer", - "version": "0.2.2", + "version": "0.2.4", "dependencies": { "@types/dotenv": "^6.1.1", "bufferutil": "^4.0.9", diff --git a/package.json b/package.json index 124eddb..9eddb9a 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,11 @@ }, "views": { "ecooptimizer": [ + { + "id": "ecooptimizer.dashboardView", + "name": "Energy Dashboard", + "icon": "assets/eco-icon.png" + }, { "id": "ecooptimizer.refactorView", "name": "Refactoring Details", @@ -155,11 +160,6 @@ "name": "Code Smells", "icon": "assets/eco-icon.png" }, - { - "id": "ecooptimizer.metricsView", - "name": "Carbon Metrics", - "icon": "assets/eco-icon.png" - }, { "id": "ecooptimizer.filterView", "name": "Filter Smells", @@ -179,8 +179,8 @@ "when": "!workspaceState.workspaceConfigured" }, { - "view": "ecooptimizer.metricsView", - "contents": "No energy savings to declare. Configure your workspace to start saving energy!\n\n[Configure Workspace](command:ecooptimizer.configureWorkspace)\n\n[Read the docs](https://code.visualstudio.com/api) to learn how to use Eco-Optimizer.", + "view": "ecooptimizer.dashboardView", + "contents": "Welcome to the Energy Dashboard! Configure your workspace to start tracking refactoring statistics and energy savings.\n\n[Configure Workspace](command:ecooptimizer.configureWorkspace)\n\n[Read the docs](https://github.com/ssm-lab/capstone--sco-vs-code-plugin/wiki) to learn how to use Eco-Optimizer.", "when": "!workspaceState.workspaceConfigured" } ], @@ -255,6 +255,12 @@ "icon": "$(tools)", "category": "Eco" }, + { + "command": "ecooptimizer.refactorAllSmells", + "title": "Refactor All Smells", + "icon": "$(wrench)", + "category": "Eco" + }, { "command": "ecooptimizer.refactorSmell", "title": "Refactor Smell", @@ -279,13 +285,8 @@ "category": "Eco" }, { - "command": "ecooptimizer.clearMetricsData", - "title": "Clear Metrics Data", - "category": "Eco" - }, - { - "command": "ecooptimizer.metricsView.refresh", - "title": "Refresh Metrics Data", + "command": "ecooptimizer.dashboardView.refresh", + "title": "Refresh Dashboard", "icon": "$(sync)", "category": "Eco" }, @@ -337,17 +338,12 @@ }, { "command": "ecooptimizer.exportMetricsData", - "when": "view == ecooptimizer.metricsView", - "group": "resource" - }, - { - "command": "ecooptimizer.clearMetricsData", - "when": "view == ecooptimizer.metricsView", + "when": "view == ecooptimizer.dashboardView", "group": "resource" }, { - "command": "ecooptimizer.metricsView.refresh", - "when": "view == ecooptimizer.metricsView", + "command": "ecooptimizer.dashboardView.refresh", + "when": "view == ecooptimizer.dashboardView", "group": "navigation" } ], @@ -372,6 +368,11 @@ "when": "view == ecooptimizer.smellsView && viewItem == file_with_smells && !refactoringInProgress", "group": "inline" }, + { + "command": "ecooptimizer.refactorAllSmells", + "when": "view == ecooptimizer.smellsView && viewItem == file_with_smells && !refactoringInProgress", + "group": "inline" + }, { "command": "ecooptimizer.refactorSmell", "when": "view == ecooptimizer.smellsView && viewItem == smell && !refactoringInProgress", diff --git a/src/api/backend.ts b/src/api/backend.ts index 00a8e23..6ca0f39 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -90,6 +90,7 @@ export async function initLogs(log_dir: string): Promise { * - Response contains invalid data format */ export async function fetchSmells( + projectRoot: string, filePath: string, enabledSmells: Record>, ): Promise<{ smells: Smell[]; status: number }> { @@ -99,6 +100,7 @@ export async function fetchSmells( try { ecoOutput.debug(`[backend.ts] Request payload for ${fileName}:`, { + project_root: projectRoot, file_path: filePath, enabled_smells: enabledSmells }); @@ -109,6 +111,7 @@ export async function fetchSmells( 'Content-Type': 'application/json', }, body: JSON.stringify({ + project_root: projectRoot, file_path: filePath, enabled_smells: enabledSmells, }), @@ -185,7 +188,7 @@ export async function backendRefactorSmell( } ecoOutput.info(`[backend.ts] Starting refactoring for smell: ${smell.symbol}`); - console.log('Starting refactoring for smell:', smell); + console.log('Starting refactoring for smell:', smell, "in workspace:", workspacePath); try { const response = await fetch(url, { @@ -223,12 +226,11 @@ export async function backendRefactorSmell( * @returns A promise resolving to the refactored data or throwing an error if unsuccessful. */ export async function backendRefactorSmellType( - smell: Smell, - workspacePath: string + workspacePath: string, + smellType: string, + filePath: string, ): Promise { const url = `${BASE_URL}/refactor-by-type`; - const filePath = smell.path; - const smellType = smell.symbol; // Validate workspace configuration if (!workspacePath) { @@ -242,7 +244,7 @@ export async function backendRefactorSmellType( const payload = { sourceDir: workspacePath, smellType, - firstSmell: smell, + targetFile: filePath, }; try { @@ -261,7 +263,59 @@ export async function backendRefactorSmellType( } const result = await response.json(); - ecoOutput.info(`[backend.ts] Refactoring successful for ${smell.symbol}`); + ecoOutput.info(`[backend.ts] Refactoring successful for ${smellType}`); + return result; + + } catch (error: any) { + ecoOutput.error(`[backend.ts] Refactoring error: ${error.message}`); + throw new Error(`Refactoring failed: ${error.message}`); + } +} + +/** + * Sends a request to the backend to refactor all smells of a type. + * + * @param smell - The smell to refactor. + * @param workspacePath - The path to the workspace. + * @returns A promise resolving to the refactored data or throwing an error if unsuccessful. + */ +export async function backendRefactorSmellAll( + workspacePath: string, + filePath: string, +): Promise { + const url = `${BASE_URL}/refactor-all`; + + // Validate workspace configuration + if (!workspacePath) { + ecoOutput.error('[backend.ts] Refactoring aborted: No workspace path'); + throw new Error('No workspace path provided'); + } + + ecoOutput.info(`[backend.ts] Starting refactoring for all smells in "${filePath}"`); + + // Prepare the payload for the backend + const payload = { + sourceDir: workspacePath, + targetFile: filePath, + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json(); + ecoOutput.error(`[backend.ts] Refactoring failed: ${errorData.detail || 'Unknown error'}`); + throw new Error(errorData.detail || 'Refactoring failed'); + } + + const result = await response.json(); + ecoOutput.info(`[backend.ts] Refactoring successful for all smells`); return result; } catch (error: any) { diff --git a/src/commands/configureWorkspace.ts b/src/commands/configureWorkspace.ts index 5e1901e..51d6cd5 100644 --- a/src/commands/configureWorkspace.ts +++ b/src/commands/configureWorkspace.ts @@ -34,6 +34,9 @@ function findPythonFoldersRecursively(folderPath: string): string[] { // Validate current folder contains Python artifacts if ( files.includes('__init__.py') || + files.includes('setup.py') || + files.includes('pyproject.toml') || + files.includes('requirements.txt') || files.some((file) => file.endsWith('.py')) ) { hasPythonFiles = true; diff --git a/src/commands/detection/detectSmells.ts b/src/commands/detection/detectSmells.ts index 6dbb5a8..58c230e 100644 --- a/src/commands/detection/detectSmells.ts +++ b/src/commands/detection/detectSmells.ts @@ -18,6 +18,7 @@ import { ecoOutput } from '../../extension'; * @param smellsCacheManager - Manager for cached smell results */ export async function detectSmellsFile( + projectRoot: string, filePath: string, smellsViewProvider: SmellsViewProvider, smellsCacheManager: SmellsCacheManager, @@ -38,7 +39,11 @@ export async function detectSmellsFile( try { ecoOutput.info(`[detection.ts] Analyzing: ${path.basename(filePath)}`); - const { smells, status } = await fetchSmells(filePath, enabledSmellsForBackend); + const { smells, status } = await fetchSmells( + projectRoot, + filePath, + enabledSmellsForBackend, + ); // Handle backend response if (status === 200) { @@ -136,6 +141,7 @@ async function precheckAndMarkQueued( * @param smellsCacheManager - Manager for cached smell results */ export async function detectSmellsFolder( + projectRoot: string, folderPath: string, smellsViewProvider: SmellsViewProvider, smellsCacheManager: SmellsCacheManager, @@ -186,7 +192,12 @@ export async function detectSmellsFolder( // Process each found Python file for (const file of pythonFiles) { - await detectSmellsFile(file, smellsViewProvider, smellsCacheManager); + await detectSmellsFile( + projectRoot, + file, + smellsViewProvider, + smellsCacheManager, + ); } }, ); diff --git a/src/commands/refactor/acceptRefactoring.ts b/src/commands/refactor/acceptRefactoring.ts index eca8b47..fd7d4c1 100644 --- a/src/commands/refactor/acceptRefactoring.ts +++ b/src/commands/refactor/acceptRefactoring.ts @@ -54,6 +54,26 @@ export async function acceptRefactoring( ecoOutput.info(`[refactorActions.ts] Updated affected file: ${file.original}`); }); + const ecoRoot = context.workspaceState.get( + envConfig.WORKSPACE_CONFIGURED_PATH!, + )!; + + const tempUpdatedEnergySmells = vscode.Uri.joinPath( + vscode.Uri.file(ecoRoot), + '__ecocache__', + 'energy_smells.temp.json', + ).fsPath; + + const updatedEnergySmells = vscode.Uri.joinPath( + vscode.Uri.file(ecoRoot), + '__ecocache__', + 'energy_smells.updated.json', + ).fsPath; + + fs.copyFileSync(tempUpdatedEnergySmells, updatedEnergySmells); + fs.unlinkSync(tempUpdatedEnergySmells); + ecoOutput.debug('[refactorActions.ts] Updated energy smells data'); + // Update metrics if energy savings data exists if ( refactoringDetailsViewProvider.energySaved && @@ -61,8 +81,8 @@ export async function acceptRefactoring( ) { metricsDataProvider.updateMetrics( targetFile.original, - refactoringDetailsViewProvider.energySaved, refactoringDetailsViewProvider.targetSmell.symbol, + refactoringDetailsViewProvider.energySaved, ); ecoOutput.info('[refactorActions.ts] Updated energy savings metrics'); } @@ -83,6 +103,7 @@ export async function acceptRefactoring( }); await detectSmellsFile( + context.workspaceState.get(envConfig.WORKSPACE_CONFIGURED_PATH!)!, targetFile.original, smellsViewProvider, smellsCacheManager, diff --git a/src/commands/refactor/refactor.ts b/src/commands/refactor/refactor.ts index a57c5a2..ba9a8e3 100644 --- a/src/commands/refactor/refactor.ts +++ b/src/commands/refactor/refactor.ts @@ -1,7 +1,11 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import { backendRefactorSmell, backendRefactorSmellType } from '../../api/backend'; +import { + backendRefactorSmell, + backendRefactorSmellAll, + backendRefactorSmellType, +} from '../../api/backend'; import { SmellsViewProvider } from '../../providers/SmellsViewProvider'; import { RefactoringDetailsViewProvider } from '../../providers/RefactoringDetailsViewProvider'; import { ecoOutput } from '../../extension'; @@ -27,14 +31,26 @@ import { envConfig } from '../../utils/envConfig'; export async function refactor( smellsViewProvider: SmellsViewProvider, refactoringDetailsViewProvider: RefactoringDetailsViewProvider, - smell: Smell, context: vscode.ExtensionContext, - isRefactorAllOfType: boolean = false, + smell: Smell, + refactorAction: string = 'single', ): Promise { // Log and notify refactoring initiation - const action = isRefactorAllOfType - ? 'Refactoring all smells of type' - : 'Refactoring'; + let action: string; + switch (refactorAction) { + case 'single': + action = 'Refactoring'; + break; + case 'type': + action = 'Refactoring all smells of type'; + break; + case 'all': + action = 'Refactoring all smells'; + break; + default: + action = 'Refactoring'; + } + ecoOutput.info(`[refactor.ts] ${action} ${smell.symbol} in ${smell.path}`); vscode.window.showInformationMessage(`${action} ${smell.symbol}...`); @@ -66,13 +82,38 @@ export async function refactor( try { // Execute backend refactoring ecoOutput.trace(`[refactor.ts] Sending ${action} request...`); - const refactoredData = isRefactorAllOfType - ? await backendRefactorSmellType(smell, workspacePath) - : await backendRefactorSmell(smell, workspacePath); + let refactoredData: RefactoredData; + if (refactorAction === 'single') { + if (smell === undefined) { + ecoOutput.error( + '[refactor.ts] Refactoring aborted: No smell provided for single refactor action', + ); + vscode.window.showErrorMessage( + 'Unable to refactor this file right now, please try again later', + ); + return; + } + refactoredData = await backendRefactorSmell(smell, workspacePath); + } else if (refactorAction === 'type') + refactoredData = await backendRefactorSmellType( + workspacePath, + smell.symbol, + smell.path, + ); + else if (refactorAction === 'all') + refactoredData = await backendRefactorSmellAll(workspacePath, smell.path); + else { + ecoOutput.error( + '[refactor.ts] Refactoring aborted: Invalid refactoring action', + ); + vscode.window.showErrorMessage( + 'Unable to refactor this file right now, please try again later', + ); + return; + } ecoOutput.info( - `[refactor.ts] Refactoring completed for ${path.basename(smell.path)}. ` + - `Energy saved: ${refactoredData.energySaved ?? 'N/A'} kg CO2`, + `[refactor.ts] Refactoring completed for ${path.basename(smell.path)}. `, ); await context.workspaceState.update(envConfig.UNFINISHED_REFACTORING!, { diff --git a/src/commands/refactor/rejectRefactoring.ts b/src/commands/refactor/rejectRefactoring.ts index da1e282..f2b61a3 100644 --- a/src/commands/refactor/rejectRefactoring.ts +++ b/src/commands/refactor/rejectRefactoring.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import * as fs from 'fs'; import { RefactoringDetailsViewProvider } from '../../providers/RefactoringDetailsViewProvider'; import { hideRefactorActionButtons } from '../../utils/refactorActionButtons'; @@ -32,6 +33,18 @@ export async function rejectRefactoring( ecoOutput.trace(`[refactorActions.ts] Reset status for ${originalPath}`); } + const ecoRoot = context.workspaceState.get( + envConfig.WORKSPACE_CONFIGURED_PATH!, + )!; + + const tempUpdatedEnergySmells = vscode.Uri.joinPath( + vscode.Uri.file(ecoRoot), + '__ecocache__', + 'energy_smells.temp.json', + ).fsPath; + + fs.unlinkSync(tempUpdatedEnergySmells); + // Clean up UI components await closeAllTrackedDiffEditors(); refactoringDetailsViewProvider.resetRefactoringDetails(); diff --git a/src/extension.ts b/src/extension.ts index a04e98a..facc07a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,6 +38,7 @@ import { import { MetricsViewProvider } from './providers/MetricsViewProvider'; import { FilterViewProvider } from './providers/FilterViewProvider'; import { RefactoringDetailsViewProvider } from './providers/RefactoringDetailsViewProvider'; +import { DashboardViewProvider } from './providers/DashboardViewProvider'; // === Commands === import { configureWorkspace } from './commands/configureWorkspace'; @@ -116,18 +117,22 @@ export async function activate(context: vscode.ExtensionContext): Promise smellsViewProvider, ); const refactoringDetailsViewProvider = new RefactoringDetailsViewProvider(); + const dashboardViewProvider = new DashboardViewProvider( + context, + smellsCacheManager, + ); initializeStatusesFromCache(context, smellsCacheManager, smellsViewProvider); // === Register Tree Views === context.subscriptions.push( + vscode.window.createTreeView('ecooptimizer.dashboardView', { + treeDataProvider: dashboardViewProvider, + showCollapseAll: true, + }), vscode.window.createTreeView('ecooptimizer.smellsView', { treeDataProvider: smellsViewProvider, }), - vscode.window.createTreeView('ecooptimizer.metricsView', { - treeDataProvider: metricsViewProvider, - showCollapseAll: true, - }), vscode.window.createTreeView('ecooptimizer.filterView', { treeDataProvider: filterSmellsProvider, showCollapseAll: true, @@ -165,6 +170,7 @@ export async function activate(context: vscode.ExtensionContext): Promise await configureWorkspace(context); smellsViewProvider.refresh(); metricsViewProvider.refresh(); + dashboardViewProvider.refresh(); }), vscode.commands.registerCommand('ecooptimizer.resetConfiguration', async () => { @@ -174,6 +180,7 @@ export async function activate(context: vscode.ExtensionContext): Promise smellsViewProvider.clearAllStatuses(); smellsViewProvider.refresh(); metricsViewProvider.refresh(); + dashboardViewProvider.refresh(); vscode.window.showInformationMessage( 'Workspace configuration and analysis data have been reset.', ); @@ -270,7 +277,12 @@ export async function activate(context: vscode.ExtensionContext): Promise return; } } - detectSmellsFile(filePath, smellsViewProvider, smellsCacheManager); + detectSmellsFile( + context.workspaceState.get(envConfig.WORKSPACE_CONFIGURED_PATH!)!, + filePath, + smellsViewProvider, + smellsCacheManager, + ); }, ), @@ -364,7 +376,12 @@ export async function activate(context: vscode.ExtensionContext): Promise } folderPath = folderItem.resourceUri!.fsPath; } - detectSmellsFolder(folderPath, smellsViewProvider, smellsCacheManager); + detectSmellsFolder( + context.workspaceState.get(envConfig.WORKSPACE_CONFIGURED_PATH!)!, + folderPath, + smellsViewProvider, + smellsCacheManager, + ); }, ), @@ -381,7 +398,45 @@ export async function activate(context: vscode.ExtensionContext): Promise vscode.window.showErrorMessage('No code smell detected for this item.'); return; } - refactor(smellsViewProvider, refactoringDetailsViewProvider, smell, context); + refactor(smellsViewProvider, refactoringDetailsViewProvider, context, smell); + }, + ), + + vscode.commands.registerCommand( + 'ecooptimizer.refactorAllSmells', + async (item: TreeItem | { fullPath: string; smellType: string }) => { + let filePath = item.fullPath; + if (!filePath) { + vscode.window.showWarningMessage( + 'Unable to get file path for smell refactoring.', + ); + return; + } + + const cachedSmells = smellsCacheManager.getCachedSmells(filePath); + if (!cachedSmells || cachedSmells.length === 0) { + vscode.window.showInformationMessage('No smells detected in this file.'); + return; + } + + ecoOutput.info(`🟡 Found ${cachedSmells.length} smells in ${filePath}`); + + const uniqueMessageIds = new Set(); + for (const smell of cachedSmells) { + uniqueMessageIds.add(smell.messageId); + } + + ecoOutput.info(`🔁 Triggering refactorAllSmells in: ${filePath}`); + + const firstSmell = cachedSmells[0]; + + await refactor( + smellsViewProvider, + refactoringDetailsViewProvider, + context, + firstSmell, + 'all', // isRefactorAllOfType + ); }, ), @@ -454,9 +509,9 @@ export async function activate(context: vscode.ExtensionContext): Promise await refactor( smellsViewProvider, refactoringDetailsViewProvider, - firstSmell, context, - true, // isRefactorAllOfType + firstSmell, + 'type', // isRefactorAllOfType ); }, ), @@ -469,6 +524,7 @@ export async function activate(context: vscode.ExtensionContext): Promise smellsCacheManager, smellsViewProvider, ); + dashboardViewProvider.refresh(); }), vscode.commands.registerCommand('ecooptimizer.rejectRefactoring', async () => { @@ -506,28 +562,8 @@ export async function activate(context: vscode.ExtensionContext): Promise exportMetricsData(context); }), - vscode.commands.registerCommand('ecooptimizer.metricsView.refresh', () => { - metricsViewProvider.refresh(); - }), - - vscode.commands.registerCommand('ecooptimizer.clearMetricsData', () => { - vscode.window - .showWarningMessage( - 'Clear all metrics data? This cannot be undone unless you have exported it.', - { modal: true }, - 'Clear', - 'Cancel', - ) - .then((selection) => { - if (selection === 'Clear') { - context.workspaceState.update( - envConfig.WORKSPACE_METRICS_DATA!, - undefined, - ); - metricsViewProvider.refresh(); - vscode.window.showInformationMessage('Metrics data cleared.'); - } - }); + vscode.commands.registerCommand('ecooptimizer.dashboardView.refresh', () => { + dashboardViewProvider.refresh(); }), ); @@ -541,6 +577,7 @@ export async function activate(context: vscode.ExtensionContext): Promise smellsCacheManager, smellsViewProvider, metricsViewProvider, + dashboardViewProvider, ), ); @@ -577,7 +614,12 @@ export async function activate(context: vscode.ExtensionContext): Promise const lintActiveEditors = (): void => { for (const editor of vscode.window.visibleTextEditors) { const filePath = editor.document.uri.fsPath; - detectSmellsFile(filePath, smellsViewProvider, smellsCacheManager); + detectSmellsFile( + context.workspaceState.get(envConfig.WORKSPACE_CONFIGURED_PATH!)!, + filePath, + smellsViewProvider, + smellsCacheManager, + ); ecoOutput.info( `[WorkspaceListener] Smell linting is ON — auto-detecting smells for ${filePath}`, ); diff --git a/src/listeners/workspaceModifiedListener.ts b/src/listeners/workspaceModifiedListener.ts index 14eb386..13e7cdc 100644 --- a/src/listeners/workspaceModifiedListener.ts +++ b/src/listeners/workspaceModifiedListener.ts @@ -4,6 +4,7 @@ import { basename } from 'path'; import { SmellsCacheManager } from '../context/SmellsCacheManager'; import { SmellsViewProvider } from '../providers/SmellsViewProvider'; import { MetricsViewProvider } from '../providers/MetricsViewProvider'; +import { DashboardViewProvider } from '../providers/DashboardViewProvider'; import { ecoOutput, isSmellLintingEnabled } from '../extension'; import { detectSmellsFile } from '../commands/detection/detectSmells'; import { envConfig } from '../utils/envConfig'; @@ -24,6 +25,7 @@ export class WorkspaceModifiedListener { private smellsCacheManager: SmellsCacheManager, private smellsViewProvider: SmellsViewProvider, private metricsViewProvider: MetricsViewProvider, + private dashboardViewProvider: DashboardViewProvider, ) { this.initializeFileWatcher(); this.initializeSaveListener(); @@ -90,6 +92,9 @@ export class WorkspaceModifiedListener { `[WorkspaceListener] Smell linting is ON — auto-detecting smells for ${document.uri.fsPath}`, ); detectSmellsFile( + this.context.workspaceState.get( + envConfig.WORKSPACE_CONFIGURED_PATH!, + )!, document.uri.fsPath, this.smellsViewProvider, this.smellsCacheManager, @@ -185,6 +190,7 @@ export class WorkspaceModifiedListener { private refreshViews(): void { this.smellsViewProvider.refresh(); this.metricsViewProvider.refresh(); + this.dashboardViewProvider.refresh(); ecoOutput.trace('[WorkspaceListener] Refreshed all views'); } diff --git a/src/providers/DashboardViewProvider.ts b/src/providers/DashboardViewProvider.ts new file mode 100644 index 0000000..efe32b8 --- /dev/null +++ b/src/providers/DashboardViewProvider.ts @@ -0,0 +1,855 @@ +import * as vscode from 'vscode'; +import { envConfig } from '../utils/envConfig'; +import { getFilterSmells } from '../utils/smellsData'; +import { SmellsCacheManager } from '../context/SmellsCacheManager'; +import { MetricsDataItem } from './MetricsViewProvider'; + +/** + * Static mapping of carbon savings per smell type (in kg CO2). + * TODO: This is a placeholder and should be replaced with actual values once empirical data is available. + */ +const CARBON_SAVINGS_MAPPING: Record = { + // Pylint smells + 'use-a-generator': 0.0025, + 'too-many-arguments': 0.0015, + 'no-self-use': 0.001, + + // Custom smells + 'long-lambda-expression': 0.002, + 'long-message-chain': 0.003, + 'long-element-chain': 0.0025, + 'cached-repeated-calls': 0.005, + 'string-concat-loop': 0.004, +}; + +/** + * Custom TreeItem for displaying dashboard metrics + */ +class DashboardTreeItem extends vscode.TreeItem { + constructor( + public readonly label: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly contextValue: string, + public readonly value?: number, + public readonly description?: string, + public readonly iconName?: string, + public readonly iconColor?: vscode.ThemeColor, + ) { + super(label, collapsibleState); + + // Set icon for each item + if (iconName) { + this.iconPath = new vscode.ThemeIcon(iconName, iconColor); + } else { + switch (this.contextValue) { + case 'overview': + this.iconPath = new vscode.ThemeIcon( + 'dashboard', + new vscode.ThemeColor('charts.blue'), + ); + break; + case 'achievements': + this.iconPath = new vscode.ThemeIcon( + 'history', + new vscode.ThemeColor('charts.green'), + ); + break; + case 'opportunities': + this.iconPath = new vscode.ThemeIcon( + 'target', + new vscode.ThemeColor('charts.orange'), + ); + break; + case 'energy-guide': + this.iconPath = new vscode.ThemeIcon( + 'book', + new vscode.ThemeColor('charts.purple'), + ); + break; + case 'high-impact-header': + case 'medium-impact-header': + case 'low-impact-header': + // no icon for impact headers + this.iconPath = undefined; + break; + case 'stat-item': + case 'achievement-item': + case 'smell-item': + case 'guide-item': + case 'target-item': + this.iconPath = undefined; + break; + default: + this.iconPath = undefined; + } + } + + this.description = description; + this.tooltip = this.createTooltip(); + } + + private createTooltip(): string { + // Special tooltips for overview items + if (this.label.startsWith('Refactorings Applied:')) { + return `Total accepted individual code refactorings`; + } + if (this.label.startsWith('Carbon Emission Savings:')) { + return `Cumulative carbon savings for all refactorings`; + } + if (this.label.startsWith('Code Smells Identified:')) { + return `Active code smells in workspace`; + } + if (this.label.startsWith('Potential Carbon Emission Reduction:')) { + return `Available carbon savings from unrefactored code smells`; + } + + return ''; + } +} + +/** + * Dashboard statistics + */ +interface DashboardStats { + totalRefactorings: number; + totalEnergySaved: number; + detectedSmells: number; + potentialSavings: number; + topAchievements: Array<{ + symbol: string; + name: string; + acronym: string; + energySaved: number; + refactorings: number; + }>; + topOpportunities: Array<{ + symbol: string; + name: string; + acronym: string; + detectedCount: number; + potentialSavings: number; + }>; +} + +/** + * Provides a dashboard view of refactoring statistics and carbon savings + */ +export class DashboardViewProvider + implements vscode.TreeDataProvider +{ + private _onDidChangeTreeData = new vscode.EventEmitter< + DashboardTreeItem | undefined + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private context: vscode.ExtensionContext, + private smellsCacheManager: SmellsCacheManager, + ) { + // Listen for cache updates to refresh dashboard + this.smellsCacheManager.onSmellsUpdated(() => { + this.refresh(); + }); + } + + /** + * Triggers a refresh of the dashboard + */ + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: DashboardTreeItem): vscode.TreeItem { + return element; + } + + /** + * Builds the dashboard tree hierarchy + */ + async getChildren(element?: DashboardTreeItem): Promise { + if (!element) { + // Root level - show main dashboard sections + return this.createRootItems(); + } + + // Handle expandable sections + switch (element.contextValue) { + case 'overview': + return this.createOverviewItems(); + case 'achievements': + return this.createAchievementsItems(); + case 'opportunities': + return this.createOpportunitiesItems(); + case 'energy-guide': + return this.createEnergyGuideItems(); + case 'high-impact-guide': + case 'high-impact-header': + return this.createHighImpactGuideItems(); + case 'medium-impact-guide': + case 'medium-impact-header': + return this.createMediumImpactGuideItems(); + case 'low-impact-guide': + case 'low-impact-header': + return this.createLowImpactGuideItems(); + case 'high-impact-targets-header': + return this.createHighImpactTargetsItems(); + case 'medium-impact-targets-header': + return this.createMediumImpactTargetsItems(); + case 'low-impact-targets-header': + return this.createLowImpactTargetsItems(); + default: + return []; + } + } + + /** + * Creates the main dashboard sections + */ + private createRootItems(): DashboardTreeItem[] { + // Check if workspace is configured + const workspaceConfigured = this.context.workspaceState.get( + envConfig.WORKSPACE_CONFIGURED_PATH!, + ); + + if (!workspaceConfigured) { + // only show energy impact reference and configuration prompt when no workspace is configured + const configureItem = new DashboardTreeItem( + 'Configure Workspace', + vscode.TreeItemCollapsibleState.None, + 'configure-workspace', + undefined, + 'Set up your workspace to start tracking refactoring statistics and energy savings', + 'settings-gear', + new vscode.ThemeColor('charts.blue'), + ); + + configureItem.command = { + title: 'Configure Workspace', + command: 'ecooptimizer.configureWorkspace', + arguments: [], + }; + + return [ + configureItem, + new DashboardTreeItem( + 'ENERGY IMPACT REFERENCE', + vscode.TreeItemCollapsibleState.Expanded, + 'energy-guide', + undefined, + undefined, + ), + ]; + } + + // show complete dashboard when workspace is configured + return [ + new DashboardTreeItem( + 'OVERVIEW', + vscode.TreeItemCollapsibleState.Expanded, + 'overview', + undefined, + undefined, + ), + new DashboardTreeItem( + 'REFACTORING HISTORY', + vscode.TreeItemCollapsibleState.Collapsed, + 'achievements', + undefined, + undefined, + 'history', + new vscode.ThemeColor('charts.green'), + ), + new DashboardTreeItem( + 'OPTIMIZATION TARGETS', + vscode.TreeItemCollapsibleState.Collapsed, + 'opportunities', + undefined, + undefined, + ), + new DashboardTreeItem( + 'ENERGY IMPACT REFERENCE', + vscode.TreeItemCollapsibleState.Collapsed, + 'energy-guide', + undefined, + undefined, + ), + ]; + } + + /** + * Create overview summary items + */ + private createOverviewItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + items.push( + new DashboardTreeItem( + `Refactorings Applied: ${stats.totalRefactorings}`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + stats.totalRefactorings, + undefined, + 'check-all', + new vscode.ThemeColor('charts.green'), + ), + ); + + items.push( + new DashboardTreeItem( + `Carbon Emission Savings: ${this.formatNumber(stats.totalEnergySaved)} kg CO₂`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + stats.totalEnergySaved, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.yellow'), + ), + ); + + items.push( + new DashboardTreeItem( + `Code Smells Identified: ${stats.detectedSmells}`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + stats.detectedSmells, + undefined, + 'search', + new vscode.ThemeColor('charts.orange'), + ), + ); + + items.push( + new DashboardTreeItem( + `Potential Carbon Emission Reduction: ${this.formatNumber(stats.potentialSavings)} kg CO₂`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + stats.potentialSavings, + undefined, + 'target', + new vscode.ThemeColor('charts.red'), + ), + ); + + return items; + } + + /** + * Create achievements section items + */ + private createAchievementsItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + if (stats.totalRefactorings === 0) { + items.push( + new DashboardTreeItem( + 'No optimizations applied yet', + vscode.TreeItemCollapsibleState.None, + 'stat-item', + undefined, + 'Begin refactoring to track optimization history', + undefined, + undefined, + ), + ); + return items; + } + + const avgEnergy = stats.totalEnergySaved / stats.totalRefactorings; + items.push( + new DashboardTreeItem( + `Total Optimizations: ${stats.totalRefactorings}`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + stats.totalRefactorings, + undefined, + undefined, + undefined, + ), + ); + + items.push( + new DashboardTreeItem( + `Average Impact per Refactoring: ${this.formatNumber(avgEnergy)} kg CO₂`, + vscode.TreeItemCollapsibleState.None, + 'stat-item', + avgEnergy, + undefined, + undefined, + undefined, + ), + ); + + if (stats.topAchievements.length > 0) { + stats.topAchievements.slice(0, 3).forEach((achievement) => { + items.push( + new DashboardTreeItem( + `${achievement.name}: ${achievement.refactorings} fixes, ${this.formatNumber(achievement.energySaved)} kg CO₂`, + vscode.TreeItemCollapsibleState.None, + 'achievement-item', + achievement.energySaved, + undefined, + undefined, + undefined, + ), + ); + }); + } + + return items; + } + + /** + * Create opportunities section items + */ + private createOpportunitiesItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + if (stats.detectedSmells === 0) { + items.push( + new DashboardTreeItem( + ' No code issues detected', + vscode.TreeItemCollapsibleState.None, + 'stat-item', + undefined, + 'Run analysis to identify optimization opportunities', + 'info', + ), + ); + return items; + } + + // Group by impact level + const highImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell >= 0.004; + }); + + const mediumImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell >= 0.002 && energyPerSmell < 0.004; + }); + + const lowImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell < 0.002; + }); + + // High impact targets + if (highImpactOpportunities.length > 0) { + const item = new DashboardTreeItem( + `HIGH IMPACT (≥0.004 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'high-impact-targets-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.red'), + ); + items.push(item); + } + + // Medium impact targets + if (mediumImpactOpportunities.length > 0) { + const item = new DashboardTreeItem( + `MEDIUM IMPACT (0.002-0.004 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'medium-impact-targets-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.orange'), + ); + items.push(item); + } + + // Low impact targets + if (lowImpactOpportunities.length > 0) { + const item = new DashboardTreeItem( + `LOW IMPACT (<0.002 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'low-impact-targets-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.blue'), + ); + items.push(item); + } + + return items; + } + + /** + * Create high-impact targets items + */ + private createHighImpactTargetsItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + const highImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell >= 0.004; + }); + + highImpactOpportunities.forEach((opportunity) => { + items.push( + new DashboardTreeItem( + `${opportunity.name}: ${this.formatNumber(opportunity.potentialSavings)} kg CO₂ total`, + vscode.TreeItemCollapsibleState.None, + 'target-item', + opportunity.potentialSavings, + `${opportunity.detectedCount} instances`, + undefined, + undefined, + ), + ); + }); + + return items; + } + + /** + * Create medium-impact targets items + */ + private createMediumImpactTargetsItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + const mediumImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell >= 0.002 && energyPerSmell < 0.004; + }); + + mediumImpactOpportunities.forEach((opportunity) => { + items.push( + new DashboardTreeItem( + `${opportunity.name}: ${this.formatNumber(opportunity.potentialSavings)} kg CO₂ total`, + vscode.TreeItemCollapsibleState.None, + 'target-item', + opportunity.potentialSavings, + `${opportunity.detectedCount} instances`, + undefined, + undefined, + ), + ); + }); + + return items; + } + + /** + * Create low-impact targets items + */ + private createLowImpactTargetsItems(): DashboardTreeItem[] { + const stats = this.calculateDashboardStats(); + const items: DashboardTreeItem[] = []; + + const lowImpactOpportunities = stats.topOpportunities.filter((opp) => { + const energyPerSmell = CARBON_SAVINGS_MAPPING[opp.symbol] || 0; + return energyPerSmell < 0.002; + }); + + lowImpactOpportunities.forEach((opportunity) => { + items.push( + new DashboardTreeItem( + `${opportunity.name}: ${this.formatNumber(opportunity.potentialSavings)} kg CO₂ total`, + vscode.TreeItemCollapsibleState.None, + 'target-item', + opportunity.potentialSavings, + `${opportunity.detectedCount} instances`, + undefined, + undefined, + ), + ); + }); + + return items; + } + + /** + * Create energy guide section items + */ + private createEnergyGuideItems(): DashboardTreeItem[] { + const smellConfigData = getFilterSmells(); + const items: DashboardTreeItem[] = []; + + // Sort smell types by energy savings from highest to lowest + const sortedSmells = Object.entries(CARBON_SAVINGS_MAPPING) + .filter(([symbol]) => smellConfigData[symbol]) // Only show enabled smells + .sort(([, a], [, b]) => b - a); + + // Group by impact level + const highImpact = sortedSmells.filter(([, energy]) => energy >= 0.004); + const mediumImpact = sortedSmells.filter( + ([, energy]) => energy >= 0.002 && energy < 0.004, + ); + const lowImpact = sortedSmells.filter(([, energy]) => energy < 0.002); + + // High impact section + if (highImpact.length > 0) { + const item = new DashboardTreeItem( + `HIGH IMPACT (≥0.004 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'high-impact-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.red'), + ); + items.push(item); + } + + // Medium impact section + if (mediumImpact.length > 0) { + const item = new DashboardTreeItem( + `MEDIUM IMPACT (0.002-0.004 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'medium-impact-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.orange'), + ); + items.push(item); + } + + // Low impact section + if (lowImpact.length > 0) { + const item = new DashboardTreeItem( + `LOW IMPACT (<0.002 kg CO₂)`, + vscode.TreeItemCollapsibleState.Collapsed, + 'low-impact-header', + undefined, + undefined, + 'lightbulb', + new vscode.ThemeColor('charts.blue'), + ); + items.push(item); + } + + return items; + } + + /** + * Creates high impact guide items + */ + private createHighImpactGuideItems(): DashboardTreeItem[] { + const smellConfigData = getFilterSmells(); + const items: DashboardTreeItem[] = []; + + const sortedSmells = Object.entries(CARBON_SAVINGS_MAPPING) + .filter(([symbol]) => smellConfigData[symbol]) + .sort(([, a], [, b]) => b - a); + + const highImpact = sortedSmells.filter(([, energy]) => energy >= 0.004); + + highImpact.forEach(([symbol, energySaving]) => { + const config = smellConfigData[symbol]; + if (config) { + items.push( + new DashboardTreeItem( + `${config.name}: ${this.formatNumber(energySaving)} kg CO₂ per refactoring`, + vscode.TreeItemCollapsibleState.None, + 'guide-item', + energySaving, + undefined, + undefined, + undefined, + ), + ); + } + }); + + return items; + } + + /** + * Creates medium impact guide items + */ + private createMediumImpactGuideItems(): DashboardTreeItem[] { + const smellConfigData = getFilterSmells(); + const items: DashboardTreeItem[] = []; + + const sortedSmells = Object.entries(CARBON_SAVINGS_MAPPING) + .filter(([symbol]) => smellConfigData[symbol]) + .sort(([, a], [, b]) => b - a); + + const mediumImpact = sortedSmells.filter( + ([, energy]) => energy >= 0.002 && energy < 0.004, + ); + + mediumImpact.forEach(([symbol, energySaving]) => { + const config = smellConfigData[symbol]; + if (config) { + items.push( + new DashboardTreeItem( + `${config.name}: ${this.formatNumber(energySaving)} kg CO₂ per refactoring`, + vscode.TreeItemCollapsibleState.None, + 'guide-item', + energySaving, + undefined, + undefined, + undefined, + ), + ); + } + }); + + return items; + } + + /** + * Creates low impact guide items + */ + private createLowImpactGuideItems(): DashboardTreeItem[] { + const smellConfigData = getFilterSmells(); + const items: DashboardTreeItem[] = []; + + const sortedSmells = Object.entries(CARBON_SAVINGS_MAPPING) + .filter(([symbol]) => smellConfigData[symbol]) + .sort(([, a], [, b]) => b - a); + + const lowImpact = sortedSmells.filter(([, energy]) => energy < 0.002); + + lowImpact.forEach(([symbol, energySaving]) => { + const config = smellConfigData[symbol]; + if (config) { + items.push( + new DashboardTreeItem( + `${config.name}: ${this.formatNumber(energySaving)} kg CO₂ per refactoring`, + vscode.TreeItemCollapsibleState.None, + 'guide-item', + energySaving, + undefined, + undefined, + undefined, + ), + ); + } + }); + + return items; + } + + /** + * Calculates comprehensive dashboard statistics + */ + private calculateDashboardStats(): DashboardStats { + const metricsData = this.context.workspaceState.get<{ + [path: string]: MetricsDataItem; + }>(envConfig.WORKSPACE_METRICS_DATA!, {}); + + const smellConfigData = getFilterSmells(); + const smellCache = this.smellsCacheManager.getFullSmellCache(); + const pathMap = this.smellsCacheManager.getHashToPathMap(); + + // Calculate achievements from metrics data + const achievementMap = new Map< + string, + { + name: string; + acronym: string; + refactorings: number; + energySaved: number; + } + >(); + + let totalRefactorings = 0; + let totalEnergySaved = 0; + + Object.entries(metricsData).forEach(([filePath, fileData]) => { + Object.entries(fileData.smellDistribution).forEach(([symbol, energySaved]) => { + if (energySaved > 0 && smellConfigData[symbol]) { + const refactorCount = Math.round( + energySaved / (CARBON_SAVINGS_MAPPING[symbol] || 0.001), + ); + + if (!achievementMap.has(symbol)) { + achievementMap.set(symbol, { + name: smellConfigData[symbol].name, + acronym: smellConfigData[symbol].acronym, + refactorings: 0, + energySaved: 0, + }); + } + + const achievement = achievementMap.get(symbol)!; + achievement.refactorings += refactorCount; + achievement.energySaved += energySaved; + totalRefactorings += refactorCount; + } + }); + totalEnergySaved += fileData.totalCarbonSaved; + }); + + // Calculate opportunities from detected smells + const opportunityMap = new Map< + string, + { + name: string; + acronym: string; + detectedCount: number; + potentialSavings: number; + } + >(); + + let detectedSmells = 0; + let potentialSavings = 0; + + Object.entries(smellCache).forEach(([hash, smells]) => { + const filePath = pathMap[hash]; + if (filePath) { + smells.forEach((smell) => { + if (smellConfigData[smell.symbol]) { + const energyPerSmell = CARBON_SAVINGS_MAPPING[smell.symbol] || 0; + + if (!opportunityMap.has(smell.symbol)) { + opportunityMap.set(smell.symbol, { + name: smellConfigData[smell.symbol].name, + acronym: smellConfigData[smell.symbol].acronym, + detectedCount: 0, + potentialSavings: 0, + }); + } + + const opportunity = opportunityMap.get(smell.symbol)!; + opportunity.detectedCount += 1; + opportunity.potentialSavings += energyPerSmell; + detectedSmells += 1; + potentialSavings += energyPerSmell; + } + }); + } + }); + + // Create sorted arrays + const topAchievements = Array.from(achievementMap.entries()) + .map(([symbol, data]) => ({ symbol, ...data })) + .sort((a, b) => b.energySaved - a.energySaved); + + const topOpportunities = Array.from(opportunityMap.entries()) + .map(([symbol, data]) => ({ symbol, ...data })) + .sort((a, b) => b.potentialSavings - a.potentialSavings); + + return { + totalRefactorings, + totalEnergySaved, + detectedSmells, + potentialSavings, + topAchievements, + topOpportunities, + }; + } + + /** + * Formats numbers for display + */ + private formatNumber(number: number, decimalPlaces: number = 4): string { + if (number === 0) return '0'; + if (number < 0.0001) return '< 0.0001'; + return number.toFixed(decimalPlaces); + } +} diff --git a/src/providers/MetricsViewProvider.ts b/src/providers/MetricsViewProvider.ts index e003054..6924ae2 100644 --- a/src/providers/MetricsViewProvider.ts +++ b/src/providers/MetricsViewProvider.ts @@ -1,345 +1,35 @@ import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { basename, dirname } from 'path'; -import { buildPythonTree } from '../utils/TreeStructureBuilder'; import { envConfig } from '../utils/envConfig'; -import { getFilterSmells } from '../utils/smellsData'; import { normalizePath } from '../utils/normalizePath'; /** - * Custom TreeItem for displaying metrics in the VS Code explorer - * Handles different node types (folders, files, smells) with appropriate icons and behaviors - */ -class MetricTreeItem extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly contextValue: string, - public readonly carbonSaved?: number, - public readonly resourceUri?: vscode.Uri, - public readonly smellName?: string, - ) { - super(label, collapsibleState); - - // Set icon based on node type - switch (this.contextValue) { - case 'folder': - this.iconPath = new vscode.ThemeIcon('folder'); - break; - case 'file': - this.iconPath = new vscode.ThemeIcon('file'); - break; - case 'smell': - this.iconPath = new vscode.ThemeIcon('tag'); - break; - case 'folder-stats': - this.iconPath = new vscode.ThemeIcon('graph'); - break; - } - - // Format carbon savings display - this.description = - carbonSaved !== undefined - ? `Carbon Saved: ${formatNumber(carbonSaved)} kg` - : ''; - this.tooltip = smellName || this.description; - - // Make files clickable to open them - if (resourceUri && contextValue === 'file') { - this.command = { - title: 'Open File', - command: 'vscode.open', - arguments: [resourceUri], - }; - } - } -} - -/** - * Interface for storing metrics data for individual files + * Interface for metrics data stored per file */ export interface MetricsDataItem { totalCarbonSaved: number; - smellDistribution: { - [smell: string]: number; - }; -} - -/** - * Structure for aggregating metrics across folders - */ -interface FolderMetrics { - totalCarbonSaved: number; - smellDistribution: Map; // Map - children: { - files: Map; // Map - folders: Map; // Map - }; + smellDistribution: Record; } /** - * Provides a tree view of carbon savings metrics across the workspace - * Aggregates data by folder structure and smell types with caching for performance + * Service for managing carbon savings metrics data + * Handles storage and retrieval of refactoring metrics without UI components */ -export class MetricsViewProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter< - MetricTreeItem | undefined - >(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - // Cache for folder metrics to avoid repeated calculations - private folderMetricsCache: Map = new Map(); - +export class MetricsViewProvider { constructor(private context: vscode.ExtensionContext) {} /** - * Triggers a refresh of the tree view + * Updates metrics data for a specific file + * @param filePath Path to the file being updated + * @param smellType Type of smell that was refactored + * @param carbonSaved Amount of carbon saved by this refactoring */ - refresh(): void { - this._onDidChangeTreeData.fire(undefined); - } - - getTreeItem(element: MetricTreeItem): vscode.TreeItem { - return element; - } - - /** - * Builds the tree view hierarchy - * @param element The parent element or undefined for root items - * @returns Promise resolving to child tree items - */ - async getChildren(element?: MetricTreeItem): Promise { - const metricsData = this.context.workspaceState.get<{ - [path: string]: MetricsDataItem; - }>(envConfig.WORKSPACE_METRICS_DATA!, {}); - - // Root level items - if (!element) { - const configuredPath = this.context.workspaceState.get( - envConfig.WORKSPACE_CONFIGURED_PATH!, - ); - if (!configuredPath) return []; - - // Show either single file or folder contents at root - const isDirectory = - fs.existsSync(configuredPath) && fs.statSync(configuredPath).isDirectory(); - if (isDirectory) { - return [this.createFolderItem(configuredPath)]; - } else { - return [this.createFileItem(configuredPath, metricsData)]; - } - } - - // Folder contents - if (element.contextValue === 'folder') { - const folderPath = element.resourceUri!.fsPath; - const folderMetrics = await this.calculateFolderMetrics( - folderPath, - metricsData, - ); - const treeNodes = buildPythonTree(folderPath); - - // Create folder statistics section - const folderStats = [ - new MetricTreeItem( - `Total Carbon Saved: ${formatNumber(folderMetrics.totalCarbonSaved)} kg`, - vscode.TreeItemCollapsibleState.None, - 'folder-stats', - ), - ...Array.from(folderMetrics.smellDistribution.entries()).map( - ([acronym, [name, carbonSaved]]) => - this.createSmellItem({ acronym, name, carbonSaved }), - ), - ].sort(compareTreeItems); - - // Create folder contents listing - const contents = treeNodes.map((node) => { - return node.isFile - ? this.createFileItem(node.fullPath, metricsData) - : this.createFolderItem(node.fullPath); - }); - - return [...contents, ...folderStats]; - } - - // File smell breakdown - if (element.contextValue === 'file') { - const filePath = element.resourceUri!.fsPath; - const fileMetrics = this.calculateFileMetrics(filePath, metricsData); - return fileMetrics.smellData.map((data) => this.createSmellItem(data)); - } - - return []; - } - - /** - * Creates a folder tree item - */ - private createFolderItem(folderPath: string): MetricTreeItem { - return new MetricTreeItem( - basename(folderPath), - vscode.TreeItemCollapsibleState.Collapsed, - 'folder', - undefined, - vscode.Uri.file(folderPath), - ); - } - - /** - * Creates a file tree item with carbon savings - */ - private createFileItem( - filePath: string, - metricsData: { [path: string]: MetricsDataItem }, - ): MetricTreeItem { - const fileMetrics = this.calculateFileMetrics(filePath, metricsData); - return new MetricTreeItem( - basename(filePath), - vscode.TreeItemCollapsibleState.Collapsed, - 'file', - fileMetrics.totalCarbonSaved, - vscode.Uri.file(filePath), - ); - } - - /** - * Creates a smell breakdown item - */ - private createSmellItem(data: { - acronym: string; - name: string; - carbonSaved: number; - }): MetricTreeItem { - return new MetricTreeItem( - `${data.acronym}: ${formatNumber(data.carbonSaved)} kg`, - vscode.TreeItemCollapsibleState.None, - 'smell', - undefined, - undefined, - data.name, - ); - } - - /** - * Calculates aggregated metrics for a folder and its contents - * Uses caching to optimize performance for large folder structures - */ - private async calculateFolderMetrics( - folderPath: string, - metricsData: { [path: string]: MetricsDataItem }, - ): Promise { - // Return cached metrics if available - const cachedMetrics = this.folderMetricsCache.get(folderPath); - if (cachedMetrics) { - return cachedMetrics; - } - - const folderMetrics: FolderMetrics = { - totalCarbonSaved: 0, - smellDistribution: new Map(), - children: { - files: new Map(), - folders: new Map(), - }, - }; - - // Build directory tree structure - const treeNodes = buildPythonTree(folderPath); - - for (const node of treeNodes) { - if (node.isFile) { - // Aggregate file metrics - const fileMetrics = this.calculateFileMetrics(node.fullPath, metricsData); - folderMetrics.children.files.set( - node.fullPath, - fileMetrics.totalCarbonSaved, - ); - folderMetrics.totalCarbonSaved += fileMetrics.totalCarbonSaved; - - // Aggregate smell distribution from file - for (const smellData of fileMetrics.smellData) { - const current = - folderMetrics.smellDistribution.get(smellData.acronym)?.[1] || 0; - folderMetrics.smellDistribution.set(smellData.acronym, [ - smellData.name, - current + smellData.carbonSaved, - ]); - } - } else { - // Recursively process subfolders - const subFolderMetrics = await this.calculateFolderMetrics( - node.fullPath, - metricsData, - ); - folderMetrics.children.folders.set(node.fullPath, subFolderMetrics); - folderMetrics.totalCarbonSaved += subFolderMetrics.totalCarbonSaved; - - // Aggregate smell distribution from subfolder - subFolderMetrics.smellDistribution.forEach( - ([name, carbonSaved], acronym) => { - const current = folderMetrics.smellDistribution.get(acronym)?.[1] || 0; - folderMetrics.smellDistribution.set(acronym, [ - name, - current + carbonSaved, - ]); - }, - ); - } - } - - // Cache the calculated metrics - this.folderMetricsCache.set(folderPath, folderMetrics); - return folderMetrics; - } - - /** - * Calculates metrics for a single file - */ - private calculateFileMetrics( - filePath: string, - metricsData: { [path: string]: MetricsDataItem }, - ): { - totalCarbonSaved: number; - smellData: { acronym: string; name: string; carbonSaved: number }[]; - } { - const smellConfigData = getFilterSmells(); - const fileData = metricsData[normalizePath(filePath)] || { - totalCarbonSaved: 0, - smellDistribution: {}, - }; - - // Filter smell distribution to only include enabled smells - const smellDistribution = Object.keys(smellConfigData).reduce( - (acc, symbol) => { - if (smellConfigData[symbol]) { - acc[symbol] = fileData.smellDistribution[symbol] || 0; - } - return acc; - }, - {} as Record, - ); - - return { - totalCarbonSaved: fileData.totalCarbonSaved, - smellData: Object.entries(smellDistribution).map(([symbol, carbonSaved]) => ({ - acronym: smellConfigData[symbol].acronym, - name: smellConfigData[symbol].name, - carbonSaved, - })), - }; - } - - /** - * Updates metrics for a file when new analysis results are available - */ - updateMetrics(filePath: string, carbonSaved: number, smellSymbol: string): void { + updateMetrics(filePath: string, smellType: string, carbonSaved: number): void { + const normalizedPath = normalizePath(filePath); const metrics = this.context.workspaceState.get<{ [path: string]: MetricsDataItem; }>(envConfig.WORKSPACE_METRICS_DATA!, {}); - const normalizedPath = normalizePath(filePath); - - // Initialize metrics if they don't exist + // Initialize file metrics if not exists if (!metrics[normalizedPath]) { metrics[normalizedPath] = { totalCarbonSaved: 0, @@ -348,74 +38,35 @@ export class MetricsViewProvider implements vscode.TreeDataProvider( - envConfig.WORKSPACE_CONFIGURED_PATH!, - ); - - if (!configuredPath) { - return; - } - configuredPath = normalizePath(configuredPath); - - // Walk up the directory tree clearing cache - let currentPath = dirname(filePath); - while (currentPath.includes(configuredPath)) { - this.folderMetricsCache.delete(currentPath); - currentPath = dirname(currentPath); - } + getMetricsData(): { [path: string]: MetricsDataItem } { + return this.context.workspaceState.get<{ + [path: string]: MetricsDataItem; + }>(envConfig.WORKSPACE_METRICS_DATA!, {}); } -} - -// =========================================================== -// HELPER FUNCTIONS -// =========================================================== - -/** - * Priority for sorting tree items by type - */ -const contextPriority: { [key: string]: number } = { - folder: 1, - file: 2, - smell: 3, - 'folder-stats': 4, -}; -/** - * Comparator for tree items (folders first, then files, then smells) - */ -function compareTreeItems(a: MetricTreeItem, b: MetricTreeItem): number { - const priorityA = contextPriority[a.contextValue] || 0; - const priorityB = contextPriority[b.contextValue] || 0; - if (priorityA !== priorityB) return priorityA - priorityB; - return a.label.localeCompare(b.label); -} + /** + * Clears all metrics data + */ + clearMetricsData(): void { + this.context.workspaceState.update(envConfig.WORKSPACE_METRICS_DATA!, undefined); + } -/** - * Formats numbers for display, using scientific notation for very small values - */ -function formatNumber(number: number, decimalPlaces: number = 2): string { - const threshold = 0.001; - return Math.abs(number) < threshold - ? number.toExponential(decimalPlaces) - : number.toFixed(decimalPlaces); + /** + * Legacy method for compatibility - no longer refreshes UI since there's no UI + */ + refresh(): void { + // No-op: This method is kept for compatibility but does nothing + // since this is now a data-only service + } } diff --git a/test/listeners/workspaceModifiedListener.test.ts b/test/listeners/workspaceModifiedListener.test.ts index 8d90dca..53d51e4 100644 --- a/test/listeners/workspaceModifiedListener.test.ts +++ b/test/listeners/workspaceModifiedListener.test.ts @@ -7,6 +7,7 @@ import { WorkspaceModifiedListener } from '../../src/listeners/workspaceModified import { SmellsCacheManager } from '../../src/context/SmellsCacheManager'; import { SmellsViewProvider } from '../../src/providers/SmellsViewProvider'; import { MetricsViewProvider } from '../../src/providers/MetricsViewProvider'; +import { DashboardViewProvider } from '../../src/providers/DashboardViewProvider'; import { ecoOutput } from '../../src/extension'; import { detectSmellsFile } from '../../src/commands/detection/detectSmells'; @@ -23,6 +24,7 @@ describe('WorkspaceModifiedListener', () => { let mockSmellsCacheManager: jest.Mocked; let mockSmellsViewProvider: jest.Mocked; let mockMetricsViewProvider: jest.Mocked; + let mockDashboardViewProvider: jest.Mocked; let listener: WorkspaceModifiedListener; beforeEach(() => { @@ -51,6 +53,10 @@ describe('WorkspaceModifiedListener', () => { mockMetricsViewProvider = { refresh: jest.fn(), } as unknown as jest.Mocked; + + mockDashboardViewProvider = { + refresh: jest.fn(), + } as unknown as jest.Mocked; }); describe('Initialization', () => { @@ -61,6 +67,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); expect(ecoOutput.trace).toHaveBeenCalledWith( '[WorkspaceListener] No workspace configured - skipping file watcher', @@ -74,6 +81,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); console.log((ecoOutput.trace as jest.Mock).mock); @@ -92,6 +100,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); }); @@ -147,6 +156,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); }); @@ -203,6 +213,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); // Trigger save event @@ -232,6 +243,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); // Trigger save event @@ -255,6 +267,7 @@ describe('WorkspaceModifiedListener', () => { mockSmellsCacheManager, mockSmellsViewProvider, mockMetricsViewProvider, + mockDashboardViewProvider, ); listener.dispose();