From ba67543b01d56743b15fdb2cb8fa077e01c84831 Mon Sep 17 00:00:00 2001 From: Luciano Date: Tue, 9 Sep 2025 13:56:08 -0300 Subject: [PATCH 01/36] Adds addComposition command --- package.json | 14 ++ src/commands/commandRegistration.ts | 168 +++++++++++--- src/commands/fields/addField.ts | 1 + src/commands/models/addComposition.ts | 292 +++++++++++++++++++++++++ src/explorer/explorerProvider.ts | 153 +++++++------ src/services/projectAnalysisService.ts | 5 +- src/services/sourceCodeService.ts | 55 ++++- src/test/addComposition.test.ts | 248 +++++++++++++++++++++ 8 files changed, 835 insertions(+), 101 deletions(-) create mode 100644 src/commands/models/addComposition.ts create mode 100644 src/test/addComposition.test.ts diff --git a/package.json b/package.json index 5a56b4c..8b99e0c 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,10 @@ "command": "slingr-vscode-extension.addField", "title": "Add Field" }, + { + "command": "slingr-vscode-extension.addComposition", + "title": "Add Composition" + }, { "command": "slingr-vscode-extension.newFolder", "title": "New Folder" @@ -139,6 +143,11 @@ "when": "view == slingrExplorer && viewItem == 'model'", "group": "0_creation" }, + { + "command": "slingr-vscode-extension.addComposition", + "when": "view == slingrExplorer && viewItem == 'model'", + "group": "0_creation" + }, { "command": "slingr-vscode-extension.createTest", "when": "view == slingrExplorer && viewItem == 'model'", @@ -226,6 +235,11 @@ "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", "group": "1_field" }, + { + "command": "slingr-vscode-extension.addComposition", + "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "group": "1_field" + }, { "command": "slingr-vscode-extension.createTest", "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index e38e7bf..e7c5c7f 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { MetadataCache } from '../cache/cache'; +import { DecoratedClass, MetadataCache } from '../cache/cache'; import { ExplorerProvider } from '../explorer/explorerProvider'; import { NewModelTool } from './models/newModel'; import { DefineFieldsTool } from './fields/defineFields'; @@ -11,7 +11,9 @@ import { CreateTestTool } from './createTest'; import { AppTreeItem } from '../explorer/appTreeItem'; import { CreateModelFromDescriptionTool } from './models/createModelFromDesc'; import { ModifyModelTool } from './models/modifyModel'; +import { AddCompositionTool } from './models/addComposition'; import { AIService } from '../services/aiService'; +import { ProjectAnalysisService } from '../services/projectAnalysisService'; export function registerGeneralCommands( context: vscode.ExtensionContext, @@ -20,6 +22,7 @@ export function registerGeneralCommands( ): vscode.Disposable[] { const disposables: vscode.Disposable[] = []; const aiService = new AIService(); + const projectAnalysisService = new ProjectAnalysisService(); // Navigation command const navigateToCodeCommand = vscode.commands.registerCommand('slingr-vscode-extension.navigateToCode', (location: vscode.Location) => { @@ -54,30 +57,55 @@ export function registerGeneralCommands( // Define Fields Tool const defineFieldsTool = new DefineFieldsTool(); - const defineFieldsCommand = vscode.commands.registerCommand('slingr-vscode-extension.defineFields', async () => { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file to define fields.'); + const defineFieldsCommand = vscode.commands.registerCommand('slingr-vscode-extension.defineFields', async (uri?: vscode.Uri | AppTreeItem) => { + let targetUri: vscode.Uri; + + if (uri) { + // URI provided from context menu (right-click on file in explorer) + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case - check if it's a model with metadata + if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + } else { + vscode.window.showErrorMessage('Please select a model file to define fields for.'); + return; + } + } + } else { + // Fallback to active editor if no URI provided + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('Please select a model file or open one in the editor to define fields.'); + return; + } + targetUri = activeEditor.document.uri; + } + + // Validate that it's a TypeScript file + if (!targetUri.fsPath.endsWith('.ts')) { + vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); return; } - const document = activeEditor.document; + // Open the document to extract model information + const document = await vscode.workspace.openTextDocument(targetUri); const content = document.getText(); // Check if this is a model file if (!content.includes('@Model')) { - vscode.window.showErrorMessage('The current file does not appear to be a model file.'); + vscode.window.showErrorMessage('The selected file does not appear to be a model file.'); return; } - // Extract model name from class declaration - const classMatch = content.match(/export\s+class\s+(\w+)\s+extends\s+BaseModel/); - if (!classMatch) { - vscode.window.showErrorMessage('Could not find model class definition.'); + const model = await projectAnalysisService.findModelClass(document, cache); + + if (!model) { + vscode.window.showErrorMessage('Could not identify a model class in the selected file.'); return; } - - const modelName = classMatch[1]; + const modelName = model?.name; // Get field descriptions from user const fieldsDescription = await vscode.window.showInputBox({ @@ -93,7 +121,7 @@ export function registerGeneralCommands( try { await defineFieldsTool.processFieldDescriptions( fieldsDescription, - document.uri, + targetUri, cache, modelName ); @@ -105,30 +133,88 @@ export function registerGeneralCommands( // Add Field Tool const addFieldTool = new AddFieldTool(); - const addFieldCommand = vscode.commands.registerCommand('slingr-vscode-extension.addField', async () => { - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file to add a field.'); - return; + const addFieldCommand = vscode.commands.registerCommand('slingr-vscode-extension.addField', async (uri?: vscode.Uri | AppTreeItem) => { + let targetUri: vscode.Uri; + + if (uri) { + // URI provided from context menu (right-click on file in explorer) + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case - check if it's a model with metadata + if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + } else { + vscode.window.showErrorMessage('Please select a model file to add a field to.'); + return; + } + } + } else { + // Fallback to active editor if no URI provided + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('Please select a model file or open one in the editor to add a field.'); + return; + } + targetUri = activeEditor.document.uri; } - const document = activeEditor.document; - const content = document.getText(); - - // Check if this is a model file - if (!content.includes('@Model')) { - vscode.window.showErrorMessage('The current file does not appear to be a model file.'); + // Validate that it's a TypeScript file + if (!targetUri.fsPath.endsWith('.ts')) { + vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); return; } try { - await addFieldTool.addField(document.uri, cache); + await addFieldTool.addField(targetUri, cache); } catch (error) { vscode.window.showErrorMessage(`Failed to add field: ${error}`); } }); disposables.push(addFieldCommand); + // Add Composition Tool + const addCompositionTool = new AddCompositionTool(); + const addCompositionCommand = vscode.commands.registerCommand('slingr-vscode-extension.addComposition', async (uri?: vscode.Uri | AppTreeItem) => { + let targetUri: vscode.Uri; + + if (uri) { + // URI provided from context menu (right-click on file in explorer) + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case - check if it's a model with metadata + if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + } else { + vscode.window.showErrorMessage('Please select a model file to add a composition to.'); + return; + } + } + } else { + // Fallback to active editor if no URI provided + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('Please select a model file or open one in the editor to add a composition.'); + return; + } + targetUri = activeEditor.document.uri; + } + + // Validate that it's a TypeScript file + if (!targetUri.fsPath.endsWith('.ts')) { + vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); + return; + } + + try { + await addCompositionTool.addComposition(targetUri, cache); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add composition: ${error}`); + } + }); + disposables.push(addCompositionCommand); + // New Folder Tool const newFolderTool = new NewFolderTool(); const newFolderCommand = vscode.commands.registerCommand('slingr-vscode-extension.newFolder', (uri?: vscode.Uri | AppTreeItem) => { @@ -152,18 +238,38 @@ export function registerGeneralCommands( // Create Test Tool const createTestTool = new CreateTestTool(aiService); - const createTestCommand = vscode.commands.registerCommand('slingr-vscode-extension.createTest', async (uri?: vscode.Uri) => { - let targetUri = uri; - - if (!targetUri) { + const createTestCommand = vscode.commands.registerCommand('slingr-vscode-extension.createTest', async (uri?: vscode.Uri | AppTreeItem) => { + let targetUri: vscode.Uri; + + if (uri) { + // URI provided from context menu (right-click on file in explorer) + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case - check if it's a model with metadata + if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + } else { + vscode.window.showErrorMessage('Please select a model file to create a test for.'); + return; + } + } + } else { + // Fallback to active editor if no URI provided const activeEditor = vscode.window.activeTextEditor; if (!activeEditor) { - vscode.window.showErrorMessage('Please open a model file or select a file to create a test.'); + vscode.window.showErrorMessage('Please select a model file or open one in the editor to create a test.'); return; } targetUri = activeEditor.document.uri; } + // Validate that it's a TypeScript file + if (!targetUri.fsPath.endsWith('.ts')) { + vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); + return; + } + try { await createTestTool.createTest(targetUri, cache); } catch (error) { diff --git a/src/commands/fields/addField.ts b/src/commands/fields/addField.ts index 8083aba..c6348bb 100644 --- a/src/commands/fields/addField.ts +++ b/src/commands/fields/addField.ts @@ -203,6 +203,7 @@ export class AddFieldTool implements AIEnhancedTool { } const modelClass = await this.projectAnalysisService.findModelClass(document, cache); + if (!modelClass) { throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); } diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts new file mode 100644 index 0000000..a2b4a0f --- /dev/null +++ b/src/commands/models/addComposition.ts @@ -0,0 +1,292 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass } from "../../cache/cache"; +import { AIEnhancedTool, FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; + +/** + * Tool for adding composition relationships to existing Model classes. + * + * This tool creates a new inner model within the same file as the outer model + * and establishes a composition relationship between them. The inner model + * name is derived from the field name (converted to singular), and the field + * in the outer model is created as an array if the field name is plural. + * + * @example + * ```typescript + * // Adding field "addresses" creates: + * // 1. New Address model with backref to parent + * // 2. Field in outer model: addresses: Address[] + * + * @Field() + * @Relationship({ type: 'composition' }) + * addresses!: Address[]; + * ``` + */ +export class AddCompositionTool implements AIEnhancedTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + } + + /** + * Processes user input with AI enhancement for composition addition. + * @param userInput - Description of the composition to create + * @param targetUri - Target model file for the new composition + * @param cache - Metadata cache instance + * @param additionalContext - Additional context for composition creation + */ + async processWithAI( + userInput: string, + targetUri: vscode.Uri, + cache: MetadataCache, + additionalContext?: any + ): Promise { + // For now, delegate to the main method + await this.addComposition(targetUri, cache); + } + + /** + * Adds a composition relationship to an existing model file. + * + * @param targetUri - The URI of the model file where the composition should be added + * @param cache - The metadata cache for context about existing models + * @returns Promise that resolves when the composition is added + */ + public async addComposition(targetUri: vscode.Uri, cache: MetadataCache): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); + + // Step 2: Get field name from user + const fieldName = await this.getCompositionFieldName(modelClass); + if (!fieldName) { + return; // User cancelled + } + + // Step 3: Determine inner model name and array status + const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); + + // Step 4: Check if inner model already exists + await this.validateInnerModelName(document, innerModelName); + + // Step 5: Create the inner model + await this.createInnerModel(document, innerModelName, modelClass.name); + + // Step 6: Add composition field to outer model + await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); + + // Step 7: Show success message + vscode.window.showInformationMessage( + `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` + ); + + } catch (error) { + vscode.window.showErrorMessage(`Failed to add composition: ${error}`); + console.error("Error adding composition:", error); + } + } + + /** + * Validates the target file and prepares it for composition addition. + */ + private async validateAndPrepareTarget( + targetUri: vscode.Uri, + cache: MetadataCache + ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + // Ensure the file is a TypeScript file + if (!targetUri.fsPath.endsWith(".ts")) { + throw new Error("Target file must be a TypeScript file (.ts)"); + } + + // Open the document + const document = await vscode.workspace.openTextDocument(targetUri); + + // Get model information from cache + const modelClass = await this.projectAnalysisService.findModelClass(document, cache); + if (!modelClass) { + throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); + } + + return { modelClass, document }; + } + + /** + * Gets the composition field name from the user. + */ + private async getCompositionFieldName(modelClass: DecoratedClass): Promise { + const fieldName = await vscode.window.showInputBox({ + prompt: "Enter the composition field name (camelCase)", + placeHolder: "e.g., addresses, phoneNumbers, tasks", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Field name is required"; + } + if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Field name must be in camelCase (e.g., addresses, phoneNumbers)"; + } + + // Check if field already exists in the model + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(value.trim())) { + return `Field '${value.trim()}' already exists in this model`; + } + + return null; + }, + }); + + return fieldName?.trim() || null; + } + + /** + * Determines the inner model name and whether the field should be an array. + */ + private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { + const singularName = this.toSingular(fieldName); + const innerModelName = this.toPascalCase(singularName); + const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array + + return { innerModelName, isArray }; + } + + /** + * Converts a potentially plural field name to singular. + */ + private toSingular(fieldName: string): string { + // Handle common pluralization patterns + if (fieldName.endsWith("ies")) { + return fieldName.slice(0, -3) + "y"; + } else if (fieldName.endsWith("es")) { + // Check if it's a word that ends with s, x, ch, sh + const base = fieldName.slice(0, -1); + if (base.endsWith("s") || base.endsWith("x") || base.endsWith("ch") || base.endsWith("sh")) { + return base; + } + // Otherwise it might be a regular plural like "boxes" -> "box" + return fieldName.slice(0, -2); + } else if (fieldName.endsWith("s") && fieldName.length > 1) { + // Simple plural case + return fieldName.slice(0, -1); + } + + // If no plural pattern found, return as is + return fieldName; + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Validates that the inner model name doesn't already exist. + */ + private async validateInnerModelName(document: vscode.TextDocument, innerModelName: string): Promise { + const content = document.getText(); + if (content.includes(`class ${innerModelName}`)) { + throw new Error(`A class named '${innerModelName}' already exists in this file`); + } + } + + /** + * Creates the inner model in the same file. + */ + private async createInnerModel( + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string + ): Promise { + // Generate the inner model code + const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName); + + // Use the new insertModel method to insert after the outer model + await this.sourceCodeService.insertModel( + document, + innerModelCode, + outerModelName, // Insert after the outer model + new Set(["Model", "Field", "Relationship"]) // Ensure required decorators are imported + ); + } + + /** + * Generates the TypeScript code for the inner model. + */ + private generateInnerModelCode(innerModelName: string, outerModelName: string): string { + const lines: string[] = []; + + lines.push(`@Model()`); + lines.push(`class ${innerModelName} {`); + lines.push(``); + lines.push(`}`); + + return lines.join("\n"); + } + + /** + * Adds the composition field to the outer model. + */ + private async addCompositionField( + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Relationship", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship" + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: innerModelName, + targetModelPath: document.uri.fsPath + } + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Insert the field + await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache); + } + + /** + * Generates the TypeScript code for the composition field. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Relationship decorator + lines.push("@Composition()"); + + // Add property declaration + const typeDeclaration = isArray ? `${innerModelName}[]` : innerModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } +} diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index 0527ca0..2dbf894 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -5,7 +5,6 @@ import { AppTreeItem } from "./appTreeItem"; import * as fs from "fs"; import * as path from "path"; - // Define custom MIME types for our drag-and-drop operations const FIELD_MIME_TYPE = "application/vnd.slingr-vscode-extension.field"; const MODEL_MIME_TYPE = "application/vnd.slingr-vscode-extension.model"; @@ -69,7 +68,11 @@ export class ExplorerProvider }) ); } - } else if (draggedItem.itemType === "model" && draggedItem.metadata && this.isDecoratedClass(draggedItem.metadata)) { + } else if ( + draggedItem.itemType === "model" && + draggedItem.metadata && + this.isDecoratedClass(draggedItem.metadata) + ) { // Check if this is a composition model (nested model within another model) if (draggedItem.parent && draggedItem.parent.itemType === "model") { // This is a composition model @@ -147,19 +150,19 @@ export class ExplorerProvider const modelTransferItem = dataTransfer.get(MODEL_MIME_TYPE); const folderTransferItem = dataTransfer.get(FOLDER_MIME_TYPE); - if (fieldTransferItem?.value !== '' && fieldTransferItem) { + if (fieldTransferItem?.value !== "" && fieldTransferItem) { await this.handleFieldDrop(target, fieldTransferItem); return; } // Handle model moving to folders - if (modelTransferItem?.value !== '' && modelTransferItem) { + if (modelTransferItem?.value !== "" && modelTransferItem) { await this.handleModelDrop(target, modelTransferItem); return; } // Handle folder moving to other folders - if (folderTransferItem?.value !== '' && folderTransferItem) { + if (folderTransferItem?.value !== "" && folderTransferItem) { await this.handleFolderDrop(target, folderTransferItem); return; } @@ -173,7 +176,9 @@ export class ExplorerProvider // Check if someone is trying to drop a composition model into a folder or data root if (target && (target.itemType === "folder" || target.itemType === "dataRoot" || target.itemType === "model")) { - vscode.window.showWarningMessage("Composition models cannot be moved to folders or models. They are part of their parent model structure."); + vscode.window.showWarningMessage( + "Composition models cannot be moved to folders or models. They are part of their parent model structure." + ); return; } @@ -281,7 +286,7 @@ export class ExplorerProvider return; } - const srcDataPath = path.join(workspaceFolder.uri.fsPath, 'src', 'data'); + const srcDataPath = path.join(workspaceFolder.uri.fsPath, "src", "data"); const targetPath = target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); try { @@ -306,15 +311,15 @@ export class ExplorerProvider const workspaceEdit = new vscode.WorkspaceEdit(); const sourceUri = vscode.Uri.file(sourcePath); const targetUri = vscode.Uri.file(newPath); - + workspaceEdit.renameFile(sourceUri, targetUri); - + const success = await vscode.workspace.applyEdit(workspaceEdit); - + if (success) { // Force cache refresh after model move to ensure proper file path updates await this.cache.forceRefresh(); - + // Wait a bit longer and then refresh the tree to ensure cache is fully updated setTimeout(() => { this.refresh(); @@ -330,7 +335,10 @@ export class ExplorerProvider } } - private async handleFolderDrop(target: AppTreeItem | undefined, transferItem: vscode.DataTransferItem): Promise { + private async handleFolderDrop( + target: AppTreeItem | undefined, + transferItem: vscode.DataTransferItem + ): Promise { const draggedData = transferItem.value; // Folders can only be dropped into other folders or the data root @@ -344,7 +352,7 @@ export class ExplorerProvider // Normalize paths for cross-platform comparison const normalizedTargetPath = target.folderPath.replace(/[\/\\]/g, path.sep); const normalizedDraggedPath = draggedData.folderPath.replace(/[\/\\]/g, path.sep); - + if (normalizedTargetPath.startsWith(normalizedDraggedPath)) { vscode.window.showWarningMessage("Cannot move a folder into itself or its subfolder."); return; @@ -357,15 +365,18 @@ export class ExplorerProvider return; } - const srcDataPath = path.join(workspaceFolder.uri.fsPath, 'src', 'data'); + const srcDataPath = path.join(workspaceFolder.uri.fsPath, "src", "data"); const sourcePath = path.join(srcDataPath, draggedData.folderPath); - const targetBasePath = target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); + const targetBasePath = + target.itemType === "dataRoot" ? srcDataPath : path.join(srcDataPath, target.folderPath || ""); const newPath = path.join(targetBasePath, draggedData.folderName); try { // Check if target folder already exists if (fs.existsSync(newPath)) { - vscode.window.showErrorMessage(`A folder named "${draggedData.folderName}" already exists in the target location.`); + vscode.window.showErrorMessage( + `A folder named "${draggedData.folderName}" already exists in the target location.` + ); return; } @@ -379,15 +390,15 @@ export class ExplorerProvider const workspaceEdit = new vscode.WorkspaceEdit(); const sourceUri = vscode.Uri.file(sourcePath); const targetUri = vscode.Uri.file(newPath); - + workspaceEdit.renameFile(sourceUri, targetUri); - + const success = await vscode.workspace.applyEdit(workspaceEdit); - + if (success) { // Force cache refresh after folder move to ensure proper file path updates await this.cache.forceRefresh(); - + // Wait a bit longer and then refresh the tree to ensure cache is fully updated setTimeout(() => { this.refresh(); @@ -487,7 +498,8 @@ export class ExplorerProvider (d) => d.name === "Relationship" && d.arguments.some((arg) => arg.type === "Composition" || arg.type === "composition") - ) + ) || + field.decorators.some((d) => d.name === "Composition") ) { const relationshipType = this.extractBaseTypeFromArrayType(field.type); const relatedModel = this.cache.getDataModelClasses().find((model) => model.name === relationshipType); @@ -500,7 +512,7 @@ export class ExplorerProvider relatedModel, element ); - + // Set command for click handling (single vs double-click detection) if (relatedModel) { compositionItem.command = { @@ -599,12 +611,12 @@ export class ExplorerProvider return; } - const srcDataPath = path.join(workspaceFolder.uri.fsPath, 'src', 'data'); + const srcDataPath = path.join(workspaceFolder.uri.fsPath, "src", "data"); if (!fs.existsSync(srcDataPath)) { return; } - this.scanDirectoryRecursively(srcDataPath, root, ''); + this.scanDirectoryRecursively(srcDataPath, root, ""); } /** @@ -613,7 +625,7 @@ export class ExplorerProvider private scanDirectoryRecursively(dirPath: string, currentNode: FolderNode, relativePath: string): void { try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - + for (const entry of entries) { if (entry.isDirectory()) { const folderName = entry.name; @@ -689,8 +701,14 @@ export class ExplorerProvider // Only show models that are NOT referenced by composition relationships if (!this.isModelReferencedByComposition(model)) { - const modelItem = new AppTreeItem(label, vscode.TreeItemCollapsibleState.Collapsed, "model", this.extensionUri, model); - + const modelItem = new AppTreeItem( + label, + vscode.TreeItemCollapsibleState.Collapsed, + "model", + this.extensionUri, + model + ); + // Set command for click handling (single vs double-click detection) modelItem.command = { command: "slingr-vscode-extension.handleTreeItemClick", @@ -742,50 +760,51 @@ export class ExplorerProvider } private isModelReferencedByComposition(item: DecoratedClass): boolean { - // Instead of relying on pre-computed references, scan all models in the cache - // to find composition relationships. This is more reliable after file moves. - const allModels = this.cache.getDataModelClasses(); - - for (const model of allModels) { - // Skip the model itself - if (model.name === item.name) { - continue; - } - - // Check all properties of this model - for (const property of Object.values(model.properties)) { - // Check if this property references our target model type - const baseType = this.extractBaseTypeFromArrayType(property.type); - - if (baseType === item.name) { - // Check if this property has a @Field decorator (indicating it's a field) - const hasFieldDecorator = property.decorators.some((d) => d.name === "Field"); - - if (hasFieldDecorator) { - // Check if this property has a @Relationship decorator with type: "Composition" - const relationshipDecorator = property.decorators.find((d) => d.name === "Relationship"); - - if (relationshipDecorator) { - // Check if the relationship decorator has type: "Composition" or "composition" - const hasCompositionType = relationshipDecorator.arguments.some( - (arg) => { - if (typeof arg === "object" && arg !== null) { - return arg.type === "Composition" || arg.type === "composition"; - } - return arg === "Composition" || arg === "composition"; - } - ); - - if (hasCompositionType) { - return true; - } - } - } + // Instead of relying on pre-computed references, scan all models in the cache + // to find composition relationships. This is more reliable after file moves. + const allModels = this.cache.getDataModelClasses(); + + for (const model of allModels) { + // Skip the model itself + if (model.name === item.name) { + continue; + } + + // Check all properties of this model + for (const property of Object.values(model.properties)) { + // Check if this property references our target model type + const baseType = this.extractBaseTypeFromArrayType(property.type); + + if (baseType === item.name) { + // Check if this property has a @Field decorator (indicating it's a field) + const hasFieldDecorator = property.decorators.some((d) => d.name === "Field"); + + if (hasFieldDecorator) { + // Check if this property has a @Relationship decorator with type: "Composition" + const relationshipDecorator = property.decorators.find((d) => d.name === "Relationship"); + const compositionDecorator = property.decorators.find((d) => d.name === "Composition"); + + if (relationshipDecorator) { + // Check if the relationship decorator has type: "Composition" or "composition" + const hasCompositionType = relationshipDecorator.arguments.some((arg) => { + if (typeof arg === "object" && arg !== null) { + return arg.type === "Composition" || arg.type === "composition"; + } + return arg === "Composition" || arg === "composition"; + }); + + if (hasCompositionType) { + return true; } + } else if (compositionDecorator) { + return true; + } } + } } + } - return false; + return false; } /** diff --git a/src/services/projectAnalysisService.ts b/src/services/projectAnalysisService.ts index ccbdbed..2b1ee78 100644 --- a/src/services/projectAnalysisService.ts +++ b/src/services/projectAnalysisService.ts @@ -31,7 +31,10 @@ export class ProjectAnalysisService { } if (modelClasses.length > 1) { - const selected = await vscode.window.showQuickPick(modelClasses.map((c) => c.name)); + const selected = await vscode.window.showQuickPick( + modelClasses.map((c) => c.name), + { placeHolder: 'Select a model class from this file' } + ); return modelClasses.find((c) => c.name === selected); } return undefined; diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 8b97c13..21301aa 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -26,8 +26,13 @@ export class SourceCodeService { await this.ensureSlingrFrameworkImports(document, edit, new Set(["Field", fieldInfo.type.decorator])); - if (fieldInfo.type.decorator === "Relationship" && fieldInfo.additionalConfig?.targetModel) { - await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); + if (fieldInfo.additionalConfig?.targetModelPath !== document.uri.fsPath) { + if ( + (fieldInfo.type.decorator === "Relationship" || fieldInfo.type.decorator === "Composition") && + fieldInfo.additionalConfig?.targetModel + ) { + await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); + } } const { classEndLine } = this.findClassBoundaries(lines, modelClassName); @@ -258,6 +263,52 @@ export class SourceCodeService { return newRelativePath.replace(/\\/g, "/"); // Normalize path separators for imports } + /** + * Inserts a new model class into a document at the appropriate location. + * + * @param document - The document to insert the model into + * @param modelCode - The complete model code to insert + * @param afterModelName - Optional name of existing model to insert after (defaults to end of file) + * @param requiredImports - Set of imports to ensure are present + */ + public async insertModel( + document: vscode.TextDocument, + modelCode: string, + afterModelName?: string, + requiredImports?: Set + ): Promise { + const edit = new vscode.WorkspaceEdit(); + const lines = document.getText().split("\n"); + + // Ensure required imports are present + if (requiredImports && requiredImports.size > 0) { + await this.ensureSlingrFrameworkImports(document, edit, requiredImports); + } + + // Determine insertion point + let insertionLine = lines.length; // Default to end of file + + if (afterModelName) { + try { + const { classEndLine } = this.findClassBoundaries(lines, afterModelName); + insertionLine = classEndLine + 1; + } catch (error) { + // If we can't find the specified model, fall back to end of file + console.warn(`Could not find model ${afterModelName}, inserting at end of file`); + } + } + + // Detect indentation from the file + const indentation = detectIndentation(lines, 0, lines.length); + const indentedModelCode = applyIndentation(modelCode, indentation); + + // Insert the model with appropriate spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${indentedModelCode}\n`); + + await vscode.workspace.applyEdit(edit); + } + /** * Finds the file path for a given model name in the cache. */ diff --git a/src/test/addComposition.test.ts b/src/test/addComposition.test.ts new file mode 100644 index 0000000..716fcac --- /dev/null +++ b/src/test/addComposition.test.ts @@ -0,0 +1,248 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { AddCompositionTool } from '../commands/models/addComposition'; +import { MetadataCache } from '../cache/cache'; + +// Only run tests if we're in a test environment (Mocha globals are available) +if (typeof suite !== 'undefined') { + suite('AddComposition Tool Tests', () => { + let testWorkspaceDir: string; + let testModelFile: string; + let mockCache: MetadataCache; + let addCompositionTool: AddCompositionTool; + + setup(async () => { + // Create a temporary workspace directory for testing + testWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-addcomposition-test-')); + const testDataDir = path.join(testWorkspaceDir, 'src', 'data'); + + // Create the src/data directory structure + fs.mkdirSync(testDataDir, { recursive: true }); + + // Create a sample model file for testing + testModelFile = path.join(testDataDir, 'testModel.ts'); + const modelContent = `import { BaseModel, Field, Text, Model } from 'slingr-framework'; + +@Model() +export class TestModel extends BaseModel { + @Field() + @Text() + existingField!: string; +} +`; + fs.writeFileSync(testModelFile, modelContent); + + // Create mock cache + mockCache = { + getMetadataForFile: (filePath: string) => { + if (filePath === testModelFile) { + return { + classes: { + TestModel: { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ], + type: 'string' + } + }, + methods: {}, + extends: 'BaseModel' + } + } + }; + } + return { classes: {} }; + }, + getDataModelClasses: () => [ + { + name: 'TestModel', + decorators: [{ name: 'Model', arguments: [] }], + properties: { + existingField: { + name: 'existingField', + decorators: [ + { name: 'Field', arguments: [] }, + { name: 'Text', arguments: [] } + ], + type: 'string' + } + }, + methods: {}, + extends: 'BaseModel', + filePath: testModelFile + } + ], + getModelPath: () => testDataDir, + isLoaded: () => true, + refresh: () => Promise.resolve(), + watchFile: () => {}, + unwatchFile: () => {} + } as unknown as MetadataCache; + + addCompositionTool = new AddCompositionTool(); + }); + + teardown(() => { + // Clean up temporary files + if (fs.existsSync(testWorkspaceDir)) { + fs.rmSync(testWorkspaceDir, { recursive: true }); + } + }); + + test('should convert plural field names to singular model names', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const determineInfo = (tool as any).determineInnerModelInfo.bind(tool); + + // Test plural to singular conversion + assert.deepStrictEqual(determineInfo('addresses'), { + innerModelName: 'Address', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('phoneNumbers'), { + innerModelName: 'PhoneNumber', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('categories'), { + innerModelName: 'Category', + isArray: true + }); + + assert.deepStrictEqual(determineInfo('boxes'), { + innerModelName: 'Box', + isArray: true + }); + + // Test singular field names (should not be array) + assert.deepStrictEqual(determineInfo('profile'), { + innerModelName: 'Profile', + isArray: false + }); + }); + + test('should correctly convert camelCase to PascalCase', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const toPascalCase = (tool as any).toPascalCase.bind(tool); + + assert.strictEqual(toPascalCase('address'), 'Address'); + assert.strictEqual(toPascalCase('phoneNumber'), 'PhoneNumber'); + assert.strictEqual(toPascalCase('userProfile'), 'UserProfile'); + }); + + test('should correctly convert plural to singular', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const toSingular = (tool as any).toSingular.bind(tool); + + // Test various pluralization patterns + assert.strictEqual(toSingular('addresses'), 'address'); + assert.strictEqual(toSingular('boxes'), 'box'); + assert.strictEqual(toSingular('categories'), 'category'); + assert.strictEqual(toSingular('phoneNumbers'), 'phoneNumber'); + assert.strictEqual(toSingular('companies'), 'company'); + + // Test singular words (should remain unchanged) + assert.strictEqual(toSingular('profile'), 'profile'); + assert.strictEqual(toSingular('user'), 'user'); + }); + + test('should generate correct inner model code', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateInnerModelCode = (tool as any).generateInnerModelCode.bind(tool); + + const result = generateInnerModelCode('Address', 'User'); + + const expected = `@Model() +export class Address { + + @Field() + @Relationship({ type: 'reference' }) + parent!: User; + +}`; + + assert.strictEqual(result, expected); + }); + + test('should generate correct composition field code for array', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateCompositionFieldCode = (tool as any).generateCompositionFieldCode.bind(tool); + + const fieldInfo = { + name: 'addresses', + type: { + label: 'Relationship', + decorator: 'Relationship', + tsType: 'Address[]', + description: 'Composition relationship' + }, + required: false, + additionalConfig: { + relationshipType: 'composition', + targetModel: 'Address' + } + }; + + const result = generateCompositionFieldCode(fieldInfo, 'Address', true); + + const expected = `@Field({}) +@Relationship({ + type: 'composition' +}) +addresses!: Address[];`; + + assert.strictEqual(result, expected); + }); + + test('should generate correct composition field code for single object', () => { + const tool = new AddCompositionTool(); + + // Access the private method via type assertion for testing + const generateCompositionFieldCode = (tool as any).generateCompositionFieldCode.bind(tool); + + const fieldInfo = { + name: 'profile', + type: { + label: 'Relationship', + decorator: 'Relationship', + tsType: 'Profile', + description: 'Composition relationship' + }, + required: false, + additionalConfig: { + relationshipType: 'composition', + targetModel: 'Profile' + } + }; + + const result = generateCompositionFieldCode(fieldInfo, 'Profile', false); + + const expected = `@Field({}) +@Relationship({ + type: 'composition' +}) +profile!: Profile;`; + + assert.strictEqual(result, expected); + }); + }); +} From fe760cdeff809c0e14cad7f4cbedbf0407e80b75 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 10 Sep 2025 12:06:34 -0300 Subject: [PATCH 02/36] Added utility commands to cache --- src/cache/cache.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 9e7bf34..2bb5267 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -786,6 +786,12 @@ export class MetadataCache { return dataModels; } + public getModelByName(name: string): DecoratedClass | null { + const models = this.getDataModels(); + return models.find(m => m.name === name) || null; + } + + /** * Returns all @Model decorated classes that are stored in the src/data folder. * This is a more specific version of getDataModels() that only returns @@ -798,6 +804,10 @@ export class MetadataCache { ); } + public getModelDecoratorByName(name: string, model: DecoratedClass): DecoratorMetadata | null { + return model.decorators.find(decorator => decorator.name === name) || null; + } + /** * Utility to convert a ts-morph Node's position to a VS Code Range. * @param node The ts-morph Node. From 6882ac3b936293b35a52480a53de2a144a887c6f Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 10 Sep 2025 12:08:50 -0300 Subject: [PATCH 03/36] Updated the support for calling the addComposition command from the explorer when there are more than one models in the file. --- src/commands/commandRegistration.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index e7c5c7f..d7ccbf4 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -174,9 +174,10 @@ export function registerGeneralCommands( disposables.push(addFieldCommand); // Add Composition Tool - const addCompositionTool = new AddCompositionTool(); + const addCompositionTool = new AddCompositionTool(explorerProvider); const addCompositionCommand = vscode.commands.registerCommand('slingr-vscode-extension.addComposition', async (uri?: vscode.Uri | AppTreeItem) => { let targetUri: vscode.Uri; + let modelName: string | undefined; if (uri) { // URI provided from context menu (right-click on file in explorer) @@ -186,19 +187,14 @@ export function registerGeneralCommands( // AppTreeItem case - check if it's a model with metadata if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { targetUri = uri.metadata.declaration.uri; + modelName = uri.metadata?.name; } else { vscode.window.showErrorMessage('Please select a model file to add a composition to.'); return; } } } else { - // Fallback to active editor if no URI provided - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please select a model file or open one in the editor to add a composition.'); - return; - } - targetUri = activeEditor.document.uri; + throw new Error('URI must be provided to add a composition.'); } // Validate that it's a TypeScript file @@ -208,7 +204,13 @@ export function registerGeneralCommands( } try { - await addCompositionTool.addComposition(targetUri, cache); + if (modelName) { + await addCompositionTool.addComposition(cache, modelName); + } + else{ + vscode.window.showErrorMessage('Model name could not be determined.'); + } + } catch (error) { vscode.window.showErrorMessage(`Failed to add composition: ${error}`); } From aed9dd13368018f8ec16553de3f498fda0099275 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 10 Sep 2025 12:10:30 -0300 Subject: [PATCH 04/36] Adjustments in the addComposition command. --- src/commands/models/addComposition.ts | 512 +++++++++++++------------- src/services/sourceCodeService.ts | 6 +- 2 files changed, 257 insertions(+), 261 deletions(-) diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index a2b4a0f..3adbad7 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -5,288 +5,284 @@ import { UserInputService } from "../../services/userInputService"; import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; /** * Tool for adding composition relationships to existing Model classes. * - * This tool creates a new inner model within the same file as the outer model - * and establishes a composition relationship between them. The inner model - * name is derived from the field name (converted to singular), and the field - * in the outer model is created as an array if the field name is plural. - * - * @example - * ```typescript - * // Adding field "addresses" creates: - * // 1. New Address model with backref to parent - * // 2. Field in outer model: addresses: Address[] - * - * @Field() - * @Relationship({ type: 'composition' }) - * addresses!: Address[]; - * ``` */ -export class AddCompositionTool implements AIEnhancedTool { - private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; - - constructor() { - this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); +export class AddCompositionTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private explorerProvider: ExplorerProvider; + + constructor(explorerProvider: ExplorerProvider) { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.explorerProvider = explorerProvider; + } + + /** + * Adds a composition relationship to an existing model file. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @returns Promise that resolves when the composition is added + */ + public async addComposition(cache: MetadataCache, modelName: string): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Get field name from user + const fieldName = await this.getCompositionFieldName(modelClass); + if (!fieldName) { + return; // User cancelled + } + + // Step 3: Determine inner model name and array status + const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); + + // Step 4: Check if inner model already exists + await this.validateInnerModelName(cache, innerModelName); + + // Step 5: Create the inner model + await this.createInnerModel(document, innerModelName, modelClass.name, cache); + + // Step 6: Add composition field to outer model + await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); + + this.explorerProvider.refresh(); + + // Step 7: Show success message + vscode.window.showInformationMessage( + `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add composition: ${error}`); + console.error("Error adding composition:", error); } - - /** - * Processes user input with AI enhancement for composition addition. - * @param userInput - Description of the composition to create - * @param targetUri - Target model file for the new composition - * @param cache - Metadata cache instance - * @param additionalContext - Additional context for composition creation - */ - async processWithAI( - userInput: string, - targetUri: vscode.Uri, - cache: MetadataCache, - additionalContext?: any - ): Promise { - // For now, delegate to the main method - await this.addComposition(targetUri, cache); + } + + /** + * Validates the target file and prepares it for composition addition. + */ + private async validateAndPrepareTarget( + modelName: string, + cache: MetadataCache + ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + // Get model information from cache + const modelClass = cache.getModelByName(modelName); + if (!modelClass) { + throw new Error(`Model '${modelName}' not found in the project`); } - /** - * Adds a composition relationship to an existing model file. - * - * @param targetUri - The URI of the model file where the composition should be added - * @param cache - The metadata cache for context about existing models - * @returns Promise that resolves when the composition is added - */ - public async addComposition(targetUri: vscode.Uri, cache: MetadataCache): Promise { - try { - // Step 1: Validate target file - const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); - - // Step 2: Get field name from user - const fieldName = await this.getCompositionFieldName(modelClass); - if (!fieldName) { - return; // User cancelled - } - - // Step 3: Determine inner model name and array status - const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); - - // Step 4: Check if inner model already exists - await this.validateInnerModelName(document, innerModelName); - - // Step 5: Create the inner model - await this.createInnerModel(document, innerModelName, modelClass.name); - - // Step 6: Add composition field to outer model - await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); - - // Step 7: Show success message - vscode.window.showInformationMessage( - `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` - ); - - } catch (error) { - vscode.window.showErrorMessage(`Failed to add composition: ${error}`); - console.error("Error adding composition:", error); - } + const document = await vscode.workspace.openTextDocument(modelClass.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${modelName}'`); } - /** - * Validates the target file and prepares it for composition addition. - */ - private async validateAndPrepareTarget( - targetUri: vscode.Uri, - cache: MetadataCache - ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { - // Ensure the file is a TypeScript file - if (!targetUri.fsPath.endsWith(".ts")) { - throw new Error("Target file must be a TypeScript file (.ts)"); + return { modelClass, document }; + } + + /** + * Gets the composition field name from the user. + */ + private async getCompositionFieldName(modelClass: DecoratedClass): Promise { + const fieldName = await vscode.window.showInputBox({ + prompt: "Enter the composition field name (camelCase)", + placeHolder: "e.g., addresses, phoneNumbers, tasks", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Field name is required"; } - - // Open the document - const document = await vscode.workspace.openTextDocument(targetUri); - - // Get model information from cache - const modelClass = await this.projectAnalysisService.findModelClass(document, cache); - if (!modelClass) { - throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); + if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Field name must be in camelCase (e.g., addresses, phoneNumbers)"; } - return { modelClass, document }; - } - - /** - * Gets the composition field name from the user. - */ - private async getCompositionFieldName(modelClass: DecoratedClass): Promise { - const fieldName = await vscode.window.showInputBox({ - prompt: "Enter the composition field name (camelCase)", - placeHolder: "e.g., addresses, phoneNumbers, tasks", - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return "Field name is required"; - } - if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { - return "Field name must be in camelCase (e.g., addresses, phoneNumbers)"; - } - - // Check if field already exists in the model - const existingFields = Object.keys(modelClass.properties || {}); - if (existingFields.includes(value.trim())) { - return `Field '${value.trim()}' already exists in this model`; - } - - return null; - }, - }); - - return fieldName?.trim() || null; - } - - /** - * Determines the inner model name and whether the field should be an array. - */ - private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { - const singularName = this.toSingular(fieldName); - const innerModelName = this.toPascalCase(singularName); - const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array - - return { innerModelName, isArray }; - } - - /** - * Converts a potentially plural field name to singular. - */ - private toSingular(fieldName: string): string { - // Handle common pluralization patterns - if (fieldName.endsWith("ies")) { - return fieldName.slice(0, -3) + "y"; - } else if (fieldName.endsWith("es")) { - // Check if it's a word that ends with s, x, ch, sh - const base = fieldName.slice(0, -1); - if (base.endsWith("s") || base.endsWith("x") || base.endsWith("ch") || base.endsWith("sh")) { - return base; - } - // Otherwise it might be a regular plural like "boxes" -> "box" - return fieldName.slice(0, -2); - } else if (fieldName.endsWith("s") && fieldName.length > 1) { - // Simple plural case - return fieldName.slice(0, -1); + // Check if field already exists in the model + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(value.trim())) { + return `Field '${value.trim()}' already exists in this model`; } - - // If no plural pattern found, return as is - return fieldName; - } - /** - * Converts camelCase to PascalCase. - */ - private toPascalCase(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); + return null; + }, + }); + + return fieldName?.trim() || null; + } + + /** + * Determines the inner model name and whether the field should be an array. + */ + private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { + const singularName = this.toSingular(fieldName); + const innerModelName = this.toPascalCase(singularName); + const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array + + return { innerModelName, isArray }; + } + + /** + * Converts a potentially plural field name to singular using basic rules. + * @param fieldName The plural string to convert. + * @returns The singular form of the string. + */ + private toSingular(fieldName: string): string { + if (!fieldName) { + return ""; } - /** - * Validates that the inner model name doesn't already exist. - */ - private async validateInnerModelName(document: vscode.TextDocument, innerModelName: string): Promise { - const content = document.getText(); - if (content.includes(`class ${innerModelName}`)) { - throw new Error(`A class named '${innerModelName}' already exists in this file`); - } + // Rule 1: Handle "...ies" -> "...y" (e.g., "cities" -> "city") + if (fieldName.toLowerCase().endsWith("ies")) { + return fieldName.slice(0, -3) + "y"; } - /** - * Creates the inner model in the same file. - */ - private async createInnerModel( - document: vscode.TextDocument, - innerModelName: string, - outerModelName: string - ): Promise { - // Generate the inner model code - const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName); - - // Use the new insertModel method to insert after the outer model - await this.sourceCodeService.insertModel( - document, - innerModelCode, - outerModelName, // Insert after the outer model - new Set(["Model", "Field", "Relationship"]) // Ensure required decorators are imported - ); + // Rule 2: Handle "...es" -> "..." (e.g., "boxes" -> "box", "wishes" -> "wish") + // This is more specific than a simple "s", so it should be checked first. + if (fieldName.toLowerCase().endsWith("es")) { + const base = fieldName.slice(0, -2); + // Check if the base word ends in s, x, z, ch, sh + if (["s", "x", "z"].some((char) => base.endsWith(char)) || ["ch", "sh"].some((pair) => base.endsWith(pair))) { + return base; + } } - /** - * Generates the TypeScript code for the inner model. - */ - private generateInnerModelCode(innerModelName: string, outerModelName: string): string { - const lines: string[] = []; - - lines.push(`@Model()`); - lines.push(`class ${innerModelName} {`); - lines.push(``); - lines.push(`}`); - - return lines.join("\n"); + // Rule 3: Handle simple "...s" -> "..." (e.g., "cats" -> "cat") + // Avoids changing words that end in "ss" (e.g., "address") + if (fieldName.toLowerCase().endsWith("s") && !fieldName.toLowerCase().endsWith("ss")) { + return fieldName.slice(0, -1); } - /** - * Adds the composition field to the outer model. - */ - private async addCompositionField( - document: vscode.TextDocument, - outerModelName: string, - fieldName: string, - innerModelName: string, - isArray: boolean, - cache: MetadataCache - ): Promise { - // Create field info for the composition field - const fieldType: FieldTypeOption = { - label: "Relationship", - decorator: "Relationship", - tsType: isArray ? `${innerModelName}[]` : innerModelName, - description: "Composition relationship" - }; - - const fieldInfo: FieldInfo = { - name: fieldName, - type: fieldType, - required: false, // Compositions are typically optional - additionalConfig: { - relationshipType: "composition", - targetModel: innerModelName, - targetModelPath: document.uri.fsPath - } - }; - - // Generate the field code - const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); - - // Insert the field - await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache); + // If no plural pattern was found, return the original string + return fieldName; + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + /** + * Validates that the inner model name doesn't already exist. + */ + private async validateInnerModelName(cache: MetadataCache, innerModelName: string): Promise { + const existingModel = cache.getModelByName(innerModelName); + if (existingModel) { + throw new Error(`A model named '${innerModelName}' already exists in the project`); + } + } + + /** + * Creates the inner model in the same file. + */ + private async createInnerModel( + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + cache: MetadataCache + ): Promise { + // Determine data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); } - /** - * Generates the TypeScript code for the composition field. - */ - private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { - const lines: string[] = []; - - // Add Field decorator - lines.push("@Field({})"); - - // Add Relationship decorator - lines.push("@Composition()"); - - // Add property declaration - const typeDeclaration = isArray ? `${innerModelName}[]` : innerModelName; - lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); - - return lines.join("\n"); + const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); + const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; + + // Generate the inner model code + const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName, dataSource); + + // Use the new insertModel method to insert after the outer model + await this.sourceCodeService.insertModel( + document, + innerModelCode, + outerModelName, // Insert after the outer model + new Set(["Model", "Field", "Relationship"]) // Ensure required decorators are imported + ); + } + + /** + * Generates the TypeScript code for the inner model. + */ + private generateInnerModelCode(innerModelName: string, outerModelName: string, dataSource: string): string { + const lines: string[] = []; + + if (dataSource) { + lines.push(`@Model({`); + lines.push(`\tdataSource: ${dataSource}`); + } else { + lines.push(`@Model()`); } + lines.push(`})`); + lines.push(`class ${innerModelName} extends PersistentComponentModel<${outerModelName}> {`); + lines.push(``); + lines.push(`}`); + + return lines.join("\n"); + } + + /** + * Adds the composition field to the outer model. + */ + private async addCompositionField( + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Relationship", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: innerModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Insert the field + await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache); + } + + /** + * Generates the TypeScript code for the composition field. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Relationship decorator + lines.push("@Composition()"); + + // Add property declaration + const typeDeclaration = isArray ? `${innerModelName}[]` : innerModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } } diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 21301aa..592f4d5 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -299,12 +299,12 @@ export class SourceCodeService { } // Detect indentation from the file - const indentation = detectIndentation(lines, 0, lines.length); - const indentedModelCode = applyIndentation(modelCode, indentation); + //const indentation = detectIndentation(lines, 0, lines.length); + //const indentedModelCode = applyIndentation(modelCode, indentation); // Insert the model with appropriate spacing const spacing = insertionLine < lines.length ? "\n\n" : "\n"; - edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${indentedModelCode}\n`); + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${modelCode}\n`); await vscode.workspace.applyEdit(edit); } From dfc5a13e294061188af2b0760d43dfbed3098dfc Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 10 Sep 2025 13:14:57 -0300 Subject: [PATCH 05/36] Adds the addReference command --- package.json | 14 + src/cache/cache.ts | 20 ++ src/commands/commandRegistration.ts | 45 +++ src/commands/models/addReference.ts | 416 ++++++++++++++++++++++++++++ 4 files changed, 495 insertions(+) create mode 100644 src/commands/models/addReference.ts diff --git a/package.json b/package.json index 8b99e0c..2621b70 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,10 @@ "command": "slingr-vscode-extension.addComposition", "title": "Add Composition" }, + { + "command": "slingr-vscode-extension.addReference", + "title": "Add Reference" + }, { "command": "slingr-vscode-extension.newFolder", "title": "New Folder" @@ -148,6 +152,11 @@ "when": "view == slingrExplorer && viewItem == 'model'", "group": "0_creation" }, + { + "command": "slingr-vscode-extension.addReference", + "when": "view == slingrExplorer && viewItem == 'model'", + "group": "0_creation" + }, { "command": "slingr-vscode-extension.createTest", "when": "view == slingrExplorer && viewItem == 'model'", @@ -240,6 +249,11 @@ "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", "group": "1_field" }, + { + "command": "slingr-vscode-extension.addReference", + "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "group": "1_field" + }, { "command": "slingr-vscode-extension.createTest", "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 2bb5267..e41dc56 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -808,6 +808,26 @@ export class MetadataCache { return model.decorators.find(decorator => decorator.name === name) || null; } + /** + * Returns all models that have the same datasource as the specified model. + * @param sourceModel - The model to compare datasources with + * @returns An array of DecoratedClass objects with the same datasource + */ + public getModelsByDataSource(sourceModel: DecoratedClass): DecoratedClass[] { + const sourceModelDecorator = this.getModelDecoratorByName("Model", sourceModel); + const sourceDataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + return this.getDataModelClasses().filter(model => { + if (model.name === sourceModel.name) { + return false; // Don't include the source model itself + } + + const modelDecorator = this.getModelDecoratorByName("Model", model); + const modelDataSource = modelDecorator?.arguments?.[0]?.dataSource; + return modelDataSource === sourceDataSource; + }); + } + /** * Utility to convert a ts-morph Node's position to a VS Code Range. * @param node The ts-morph Node. diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index d7ccbf4..3ba96b4 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -12,6 +12,7 @@ import { AppTreeItem } from '../explorer/appTreeItem'; import { CreateModelFromDescriptionTool } from './models/createModelFromDesc'; import { ModifyModelTool } from './models/modifyModel'; import { AddCompositionTool } from './models/addComposition'; +import { AddReferenceTool } from './models/addReference'; import { AIService } from '../services/aiService'; import { ProjectAnalysisService } from '../services/projectAnalysisService'; @@ -217,6 +218,50 @@ export function registerGeneralCommands( }); disposables.push(addCompositionCommand); + // Add Reference Tool + const addReferenceTool = new AddReferenceTool(explorerProvider); + const addReferenceCommand = vscode.commands.registerCommand('slingr-vscode-extension.addReference', async (uri?: vscode.Uri | AppTreeItem) => { + let targetUri: vscode.Uri; + let modelName: string | undefined; + + if (uri) { + // URI provided from context menu (right-click on file in explorer) + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case - check if it's a model with metadata + if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + modelName = uri.metadata?.name; + } else { + vscode.window.showErrorMessage('Please select a model file to add a reference to.'); + return; + } + } + } else { + throw new Error('URI must be provided to add a reference.'); + } + + // Validate that it's a TypeScript file + if (!targetUri.fsPath.endsWith('.ts')) { + vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); + return; + } + + try { + if (modelName) { + await addReferenceTool.addReference(cache, modelName); + } + else{ + vscode.window.showErrorMessage('Model name could not be determined.'); + } + + } catch (error) { + vscode.window.showErrorMessage(`Failed to add reference: ${error}`); + } + }); + disposables.push(addReferenceCommand); + // New Folder Tool const newFolderTool = new NewFolderTool(); const newFolderCommand = vscode.commands.registerCommand('slingr-vscode-extension.newFolder', (uri?: vscode.Uri | AppTreeItem) => { diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts new file mode 100644 index 0000000..1560f38 --- /dev/null +++ b/src/commands/models/addReference.ts @@ -0,0 +1,416 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass } from "../../cache/cache"; +import { AIEnhancedTool, FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import * as path from "path"; + +/** + * Tool for adding reference relationships to existing Model classes. + * + * Allows users to create references to either existing models or new models. + * When referencing a new model, it creates the model in a new file with the same datasource. + */ +export class AddReferenceTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private explorerProvider: ExplorerProvider; + + constructor(explorerProvider: ExplorerProvider) { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.explorerProvider = explorerProvider; + } + + /** + * Adds a reference relationship to an existing model file. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the reference is being added + * @returns Promise that resolves when the reference is added + */ + public async addReference(cache: MetadataCache, modelName: string): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Get field name from user + const fieldName = await this.getReferenceFieldName(modelClass); + if (!fieldName) { + return; // User cancelled + } + + // Step 3: Ask if reference is to existing or new model + const referenceType = await this.askReferenceType(); + if (!referenceType) { + return; // User cancelled + } + + let targetModelName: string; + let targetModelPath: string; + + if (referenceType === 'existing') { + // Step 4a: Let user pick existing model on same datasource + const selectedModel = await this.selectExistingModel(modelClass, cache, fieldName); + if (!selectedModel) { + return; // User cancelled + } + targetModelName = selectedModel.name; + targetModelPath = selectedModel.declaration.uri.fsPath; + } else { + // Step 4b: Create new model + const newModelInfo = await this.createNewReferencedModel(modelClass, fieldName, cache); + if (!newModelInfo) { + return; // User cancelled or failed + } + targetModelName = newModelInfo.name; + targetModelPath = newModelInfo.path; + } + + // Step 5: Add reference field to source model + await this.addReferenceField(document, modelClass.name, fieldName, targetModelName, targetModelPath, cache); + + this.explorerProvider.refresh(); + + // Step 6: Show success message + vscode.window.showInformationMessage( + `Reference relationship created successfully! Added ${fieldName} field referencing ${targetModelName}.` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add reference: ${error}`); + console.error("Error adding reference:", error); + } + } + + /** + * Validates the target file and prepares it for reference addition. + */ + private async validateAndPrepareTarget( + modelName: string, + cache: MetadataCache + ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + // Get model information from cache + const modelClass = cache.getModelByName(modelName); + if (!modelClass) { + throw new Error(`Model '${modelName}' not found in the project`); + } + + const document = await vscode.workspace.openTextDocument(modelClass.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${modelName}'`); + } + + return { modelClass, document }; + } + + /** + * Gets the reference field name from the user. + */ + private async getReferenceFieldName(modelClass: DecoratedClass): Promise { + const fieldName = await vscode.window.showInputBox({ + prompt: "Enter the reference field name (camelCase)", + placeHolder: "e.g., user, category, parentTask", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Field name is required"; + } + if (!/^[a-z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Field name must be in camelCase (e.g., user, category, parentTask)"; + } + + // Check if field already exists in the model + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(value.trim())) { + return `Field '${value.trim()}' already exists in this model`; + } + + return null; + }, + }); + + return fieldName?.trim() || null; + } + + /** + * Asks the user whether they want to reference an existing model or create a new one. + */ + private async askReferenceType(): Promise<'existing' | 'new' | null> { + const choice = await vscode.window.showQuickPick( + [ + { + label: "Reference existing model", + description: "Select from existing models in the same datasource", + value: 'existing' + }, + { + label: "Create new model", + description: "Create a new model file and reference it", + value: 'new' + } + ], + { + placeHolder: "Do you want to reference an existing model or create a new one?", + matchOnDescription: true + } + ); + + return choice?.value as 'existing' | 'new' | null; + } + + /** + * Lets the user select an existing model from the same datasource. + */ + private async selectExistingModel( + sourceModel: DecoratedClass, + cache: MetadataCache, + fieldName: string + ): Promise { + // Get all models with the same datasource + const sameDataSourceModels = cache.getModelsByDataSource(sourceModel); + + if (sameDataSourceModels.length === 0) { + vscode.window.showWarningMessage( + `No other models found with the same datasource as ${sourceModel.name}. Consider creating a new model instead.` + ); + return null; + } + + // Create suggestions based on field name + const suggestions = this.generateSuggestions(fieldName, sameDataSourceModels); + + // Create quick pick items + const quickPickItems = sameDataSourceModels.map(model => ({ + label: model.name, + description: this.getModelDescription(model, cache), + detail: suggestions.includes(model.name) ? "⭐ Suggested based on field name" : undefined, + model: model + })).sort((a, b) => { + // Sort suggestions first + const aIsSuggested = suggestions.includes(a.model.name); + const bIsSuggested = suggestions.includes(b.model.name); + + if (aIsSuggested && !bIsSuggested) { + return -1; + } + if (!aIsSuggested && bIsSuggested) { + return 1; + } + + // Then alphabetically + return a.label.localeCompare(b.label); + }); + + const selected = await vscode.window.showQuickPick(quickPickItems, { + placeHolder: `Select the model to reference from field '${fieldName}'`, + matchOnDescription: true, + matchOnDetail: true + }); + + return selected?.model || null; + } + + /** + * Generates model name suggestions based on the field name. + */ + private generateSuggestions(fieldName: string, availableModels: DecoratedClass[]): string[] { + const suggestions: string[] = []; + const fieldLower = fieldName.toLowerCase(); + + // Direct match (e.g., "user" -> "User") + const directMatch = this.toPascalCase(fieldName); + if (availableModels.some(m => m.name === directMatch)) { + suggestions.push(directMatch); + } + + // Partial matches (e.g., "parentTask" -> "Task") + availableModels.forEach(model => { + const modelLower = model.name.toLowerCase(); + if (fieldLower.includes(modelLower) || modelLower.includes(fieldLower)) { + if (!suggestions.includes(model.name)) { + suggestions.push(model.name); + } + } + }); + + return suggestions; + } + + /** + * Gets a description for a model based on its properties or decorators. + */ + private getModelDescription(model: DecoratedClass, cache: MetadataCache): string { + const modelDecorator = cache.getModelDecoratorByName("Model", model); + const dataSource = modelDecorator?.arguments?.[0]?.dataSource || "default"; + const fieldCount = Object.keys(model.properties || {}).length; + + return `${fieldCount} fields • datasource: ${dataSource}`; + } + + /** + * Creates a new model to be referenced. + */ + private async createNewReferencedModel( + sourceModel: DecoratedClass, + fieldName: string, + cache: MetadataCache + ): Promise<{ name: string; path: string } | null> { + // Suggest model name based on field name + const suggestedName = this.toPascalCase(fieldName); + + const modelName = await vscode.window.showInputBox({ + prompt: "Enter the name for the new model", + value: suggestedName, + placeHolder: "e.g., User, Category, Task", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Model name is required"; + } + if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Model name must be in PascalCase (e.g., User, Category, Task)"; + } + + // Check if model already exists + const existingModel = cache.getModelByName(value.trim()); + if (existingModel) { + return `Model '${value.trim()}' already exists`; + } + + return null; + }, + }); + + if (!modelName?.trim()) { + return null; // User cancelled + } + + const finalModelName = modelName.trim(); + + // Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Determine target directory (same as source model's directory or data folder) + const sourceModelDir = path.dirname(sourceModel.declaration.uri.fsPath); + const targetDir = sourceModelDir; + + // Generate model content + const modelContent = this.generateNewModelContent(finalModelName, dataSource); + + // Create the file + const fileName = `${finalModelName}.ts`; + const filePath = path.join(targetDir, fileName); + + try { + const targetFileUri = await this.fileSystemService.createFile(finalModelName, filePath, modelContent, false); + + // Open the new file + const document = await vscode.workspace.openTextDocument(targetFileUri); + await vscode.window.showTextDocument(document, { preview: false, viewColumn: vscode.ViewColumn.Beside }); + + return { + name: finalModelName, + path: targetFileUri.fsPath + }; + } catch (error) { + throw new Error(`Failed to create new model file: ${error}`); + } + } + + /** + * Generates the TypeScript code for a new referenced model. + */ + private generateNewModelContent(modelName: string, dataSource?: string): string { + const lines: string[] = []; + + // Add imports + lines.push(`import { Model, BaseModel, Field } from 'slingr-framework';`); + lines.push(``); + + // Add model decorator and class + if (dataSource) { + lines.push(`@Model({`); + lines.push(`\tdataSource: ${dataSource}`); + lines.push(`})`); + } else { + lines.push(`@Model()`); + } + lines.push(`export class ${modelName} extends PersistentModel {`); + lines.push(``); + lines.push(`\t@Field({})`); + lines.push(`\tname!: string;`); + lines.push(``); + lines.push(`}`); + lines.push(``); + + return lines.join("\n"); + } + + /** + * Adds the reference field to the source model. + */ + private async addReferenceField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + targetModelPath: string, + cache: MetadataCache + ): Promise { + // Create field info for the reference field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Relationship", + tsType: targetModelName, + description: "Reference relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // References are typically optional + additionalConfig: { + relationshipType: "reference", + targetModel: targetModelName, + targetModelPath: targetModelPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateReferenceFieldCode(fieldInfo, targetModelName); + + // Insert the field + await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache); + } + + /** + * Generates the TypeScript code for the reference field. + */ + private generateReferenceFieldCode(fieldInfo: FieldInfo, targetModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Relationship decorator for reference + lines.push("@Reference()"); + + // Add property declaration + lines.push(`${fieldInfo.name}!: ${targetModelName};`); + + return lines.join("\n"); + } + + /** + * Converts camelCase to PascalCase. + */ + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} From 7ddb299c9c7748685712b04d04269e0d64772a0a Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 10 Sep 2025 14:40:01 -0300 Subject: [PATCH 06/36] Added logic to insert data source import correctly --- src/commands/models/addReference.ts | 23 ++++++++++-- src/services/sourceCodeService.ts | 58 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index 1560f38..71a3c39 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -301,7 +301,7 @@ export class AddReferenceTool { const targetDir = sourceModelDir; // Generate model content - const modelContent = this.generateNewModelContent(finalModelName, dataSource); + const modelContent = await this.generateNewModelContent(finalModelName, sourceModel, dataSource); // Create the file const fileName = `${finalModelName}.ts`; @@ -326,11 +326,24 @@ export class AddReferenceTool { /** * Generates the TypeScript code for a new referenced model. */ - private generateNewModelContent(modelName: string, dataSource?: string): string { + private async generateNewModelContent( + modelName: string, + sourceModel: DecoratedClass, + dataSource?: string + ): Promise { const lines: string[] = []; - // Add imports - lines.push(`import { Model, BaseModel, Field } from 'slingr-framework';`); + // Add basic framework imports + lines.push(`import { Model, PersistentModel, Field } from 'slingr-framework';`); + + // Add datasource import if needed + if (dataSource) { + const dataSourceImport = await this.sourceCodeService.extractImport(sourceModel, dataSource); + if (dataSourceImport) { + lines.push(dataSourceImport); + } + } + lines.push(``); // Add model decorator and class @@ -352,6 +365,8 @@ export class AddReferenceTool { return lines.join("\n"); } + + /** * Adds the reference field to the source model. */ diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 592f4d5..94a27e7 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as path from "path"; -import { MetadataCache } from "../cache/cache"; +import { DecoratedClass, MetadataCache } from "../cache/cache"; import { FieldInfo } from "../commands/interfaces"; import { detectIndentation, applyIndentation } from "../utils/detectIndentation"; import { FileSystemService } from "./fileSystemService"; @@ -328,4 +328,60 @@ export class SourceCodeService { return undefined; } + + /** + * Extracts the datasource import from the source model file. + */ + public async extractImport(sourceModel: DecoratedClass, importName: string): Promise { + try { + // Read the source model file to extract datasource imports + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const content = document.getText(); + const lines = content.split("\n"); + + // Clean up the importName (remove quotes if it's a string literal) + const cleanImportName = importName.replace(/['"]/g, ""); + + // Look for import lines that might contain the datasource + for (const line of lines) { + if ( + line.includes("import") && + (line.includes(cleanImportName) || + line.includes(`'${cleanImportName}'`) || + line.includes(`"${cleanImportName}"`)) + ) { + return line; + } + } + + // Look for import lines from dataSources directory + for (const line of lines) { + if (line.includes("import") && line.includes("dataSources")) { + // Check if this import contains our datasource + if (line.includes(cleanImportName)) { + return line; + } + } + } + + // If no specific import found, create a generic datasource import + // Calculate relative path to dataSources directory + const sourceModelDir = path.dirname(sourceModel.declaration.uri.fsPath); + const workspaceRoot = vscode.workspace.getWorkspaceFolder(sourceModel.declaration.uri)?.uri.fsPath; + + if (workspaceRoot) { + const relativePath = path.relative(sourceModelDir, path.join(workspaceRoot, "src", "dataSources")); + const importPath = relativePath.replace(/\\/g, "/"); + return `import { ${cleanImportName} } from '${ + importPath.startsWith(".") ? importPath : "./" + importPath + }/${cleanImportName}';`; + } + + // Fallback + return `import { ${cleanImportName} } from '../dataSources/${cleanImportName}';`; + } catch (error) { + console.warn("Could not extract datasource import:", error); + return null; + } + } } From 3656fb7fe896373ebd8c51b4a0e4ca2c791673c8 Mon Sep 17 00:00:00 2001 From: Luciano Date: Thu, 11 Sep 2025 11:06:13 -0300 Subject: [PATCH 07/36] Added logic to focus on the newly created fields. Improved imports management. --- src/commands/models/addComposition.ts | 11 ++- src/commands/models/addReference.ts | 11 ++- src/services/sourceCodeService.ts | 107 +++++++++++++++++++++++--- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 3adbad7..6298c1d 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -58,7 +58,10 @@ export class AddCompositionTool { this.explorerProvider.refresh(); - // Step 7: Show success message + // Step 7: Focus on the newly created field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 8: Show success message vscode.window.showInformationMessage( `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` ); @@ -244,7 +247,7 @@ export class AddCompositionTool { // Create field info for the composition field const fieldType: FieldTypeOption = { label: "Relationship", - decorator: "Relationship", + decorator: "Composition", tsType: isArray ? `${innerModelName}[]` : innerModelName, description: "Composition relationship", }; @@ -264,7 +267,7 @@ export class AddCompositionTool { const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); // Insert the field - await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache); + await this.sourceCodeService.insertField(document, outerModelName, fieldInfo, fieldCode, cache, false); } /** @@ -285,4 +288,6 @@ export class AddCompositionTool { return lines.join("\n"); } + + } diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index 71a3c39..f228dd1 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -79,7 +79,10 @@ export class AddReferenceTool { this.explorerProvider.refresh(); - // Step 6: Show success message + // Step 6: Focus on the newly created field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 7: Show success message vscode.window.showInformationMessage( `Reference relationship created successfully! Added ${fieldName} field referencing ${targetModelName}.` ); @@ -309,10 +312,6 @@ export class AddReferenceTool { try { const targetFileUri = await this.fileSystemService.createFile(finalModelName, filePath, modelContent, false); - - // Open the new file - const document = await vscode.workspace.openTextDocument(targetFileUri); - await vscode.window.showTextDocument(document, { preview: false, viewColumn: vscode.ViewColumn.Beside }); return { name: finalModelName, @@ -381,7 +380,7 @@ export class AddReferenceTool { // Create field info for the reference field const fieldType: FieldTypeOption = { label: "Relationship", - decorator: "Relationship", + decorator: "Reference", tsType: targetModelName, description: "Reference relationship", }; diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 94a27e7..a2f4399 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -19,20 +19,20 @@ export class SourceCodeService { modelClassName: string, fieldInfo: FieldInfo, fieldCode: string, - cache?: MetadataCache + cache?: MetadataCache, + importModel: boolean = true ): Promise { const edit = new vscode.WorkspaceEdit(); const lines = document.getText().split("\n"); + const newImports = new Set(["Field", fieldInfo.type.decorator]); + if(fieldInfo.type.decorator === "Composition") { + newImports.add("PersistentComponentModel"); + } - await this.ensureSlingrFrameworkImports(document, edit, new Set(["Field", fieldInfo.type.decorator])); + await this.ensureSlingrFrameworkImports(document, edit, newImports); - if (fieldInfo.additionalConfig?.targetModelPath !== document.uri.fsPath) { - if ( - (fieldInfo.type.decorator === "Relationship" || fieldInfo.type.decorator === "Composition") && - fieldInfo.additionalConfig?.targetModel - ) { + if (importModel && fieldInfo.additionalConfig) { await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); - } } const { classEndLine } = this.findClassBoundaries(lines, modelClassName); @@ -60,8 +60,12 @@ export class SourceCodeService { inClass = true; } if (inClass) { - if (line.includes("{")) braceCount++; - if (line.includes("}")) braceCount--; + if (line.includes("{")) { + braceCount++; + } + if (line.includes("}")) { + braceCount--; + } if (braceCount === 0 && classStartLine !== -1) { classEndLine = i; break; @@ -384,4 +388,87 @@ export class SourceCodeService { return null; } } + + /** + * Focuses on an element in a document navigating to it and highlighting it. + * This method can find and focus on various types of elements including: + * - Class properties (fields with !: or :) + * - Method names + * - Class names + * - Variable declarations + */ + public async focusOnElement(document: vscode.TextDocument, elementName: string): Promise { + try { + // Ensure the document is visible and active + const editor = await vscode.window.showTextDocument(document, { preview: false }); + + // Find the line containing the element + const content = document.getText(); + const lines = content.split("\n"); + + let elementLine = -1; + let elementIndex = -1; + + // Look for different patterns in order of specificity + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Pattern 1: Property declarations (fieldName!: Type or fieldName: Type) + if (line.includes(`${elementName}!:`) || line.includes(`${elementName}:`)) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 2: Method declarations (methodName() or methodName( + if (line.includes(`${elementName}(`) && (line.includes('function') || line.includes('){') || line.includes(') {'))) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 3: Class declarations (class ClassName) + if (line.includes(`class ${elementName}`)) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 4: Variable declarations (const elementName, let elementName, var elementName) + if ((line.includes(`const ${elementName}`) || line.includes(`let ${elementName}`) || line.includes(`var ${elementName}`)) && + (line.includes('=') || line.includes(';'))) { + elementLine = i; + elementIndex = line.indexOf(elementName); + break; + } + + // Pattern 5: General word boundary match (as fallback) + const wordBoundaryRegex = new RegExp(`\\b${elementName}\\b`); + if (wordBoundaryRegex.test(line)) { + const match = line.match(wordBoundaryRegex); + if (match && match.index !== undefined) { + elementLine = i; + elementIndex = match.index; + break; + } + } + } + + if (elementLine !== -1 && elementIndex !== -1) { + // Position the cursor at the element name + const startPosition = new vscode.Position(elementLine, elementIndex); + const endPosition = new vscode.Position(elementLine, elementIndex + elementName.length); + + // Set selection to highlight the element name + editor.selection = new vscode.Selection(startPosition, endPosition); + + // Reveal the line in the center of the editor + editor.revealRange(new vscode.Range(startPosition, endPosition), vscode.TextEditorRevealType.InCenter); + } + } catch (error) { + console.warn("Could not focus on element:", error); + // Fallback: just make sure the document is visible + await vscode.window.showTextDocument(document, { preview: false }); + } + } } From ef5c46876b4277d5a0cbaa487a630f50d6ffb3a6 Mon Sep 17 00:00:00 2001 From: Luciano Date: Thu, 11 Sep 2025 13:20:39 -0300 Subject: [PATCH 08/36] Update on the cache change listener. --- src/cache/cache.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cache/cache.ts b/src/cache/cache.ts index e41dc56..6e1f29d 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -140,13 +140,13 @@ export class MetadataCache { * Sets up file system watchers to detect changes, creations, and deletions * of TypeScript files and folder structure changes in src/data. */ - private setupFileWatcher(): void { + private async setupFileWatcher(): Promise { // Watch for TypeScript file changes this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*.ts'); - this.fileWatcher.onDidCreate(uri => this.queueFileChange(uri, 'create')); - this.fileWatcher.onDidChange(uri => this.queueFileChange(uri, 'change')); - this.fileWatcher.onDidDelete(uri => this.queueFileChange(uri, 'delete')); + this.fileWatcher.onDidCreate(async uri => await this.queueFileChange(uri, 'create')); + this.fileWatcher.onDidChange(async uri => await this.queueFileChange(uri, 'change')); + this.fileWatcher.onDidDelete(async uri => await this.queueFileChange(uri, 'delete')); // Watch for folder structure changes in src/data directory // ignoreCreateEvents: false, ignoreChangeEvents: true, ignoreDeleteEvents: false @@ -162,12 +162,12 @@ export class MetadataCache { * @param uri The URI of the file that changed. * @param type The type of change (create, change, delete). */ - private queueFileChange(uri: vscode.Uri, type: FileChangeType): void { + private async queueFileChange(uri: vscode.Uri, type: FileChangeType): Promise { if (uri.path.includes('/node_modules/')) { return; } this.fileChangeQueue.push({ uri, type }); - this.processQueue(); + await this.processQueue(); } /** @@ -241,7 +241,7 @@ export class MetadataCache { return; } - this.isProcessingQueue = true; + //this.isProcessingQueue = true; const { uri, type } = this.fileChangeQueue.shift()!; const filePath = uri.fsPath.replace(/\\/g, '/'); try { @@ -297,7 +297,7 @@ export class MetadataCache { console.error(`Error processing file change for ${uri.fsPath}:`, error); } finally { this.isProcessingQueue = false; - this.processQueue(); + await this.processQueue(); } } From 94e57f15f787a815f998d4c4d3b496b50c51a4af Mon Sep 17 00:00:00 2001 From: Luciano Date: Thu, 11 Sep 2025 14:09:10 -0300 Subject: [PATCH 09/36] Adds the changeReferenceToComposition command. --- package.json | 21 +- src/commands/commandRegistration.ts | 1 + .../fields/changeReferenceToComposition.ts | 393 ++++++++++++++++++ src/explorer/appTreeItem.ts | 3 + src/explorer/explorerProvider.ts | 8 +- src/refactor/refactorDisposables.ts | 2 + src/refactor/refactorInterfaces.ts | 24 +- .../tools/changeReferenceToComposition.ts | 170 ++++++++ 8 files changed, 613 insertions(+), 9 deletions(-) create mode 100644 src/commands/fields/changeReferenceToComposition.ts create mode 100644 src/refactor/tools/changeReferenceToComposition.ts diff --git a/package.json b/package.json index 2621b70..89fe717 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,10 @@ "command": "slingr-vscode-extension.changeFieldType", "title": "Change Field Type" }, + { + "command": "slingr-vscode-extension.changeReferenceToComposition", + "title": "Change Reference to Composition" + }, { "command": "slingr-vscode-extension.newModel", "title": "New Model" @@ -184,17 +188,22 @@ }, { "command": "slingr-vscode-extension.renameField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "1_modification" }, { "command": "slingr-vscode-extension.deleteField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "2_modification" }, { "command": "slingr-vscode-extension.changeFieldType", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "3_modification" + }, + { + "command": "slingr-vscode-extension.changeReferenceToComposition", + "when": "view == slingrExplorer && viewItem == 'referenceField'", "group": "3_modification" }, { @@ -288,17 +297,17 @@ }, { "command": "slingr-vscode-extension.renameField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "1_modification" }, { "command": "slingr-vscode-extension.deleteField", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "2_modification" }, { "command": "slingr-vscode-extension.changeFieldType", - "when": "view == slingrExplorer && viewItem == 'field'", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "3_modification" } ] diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index 3ba96b4..6747324 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -4,6 +4,7 @@ import { ExplorerProvider } from '../explorer/explorerProvider'; import { NewModelTool } from './models/newModel'; import { DefineFieldsTool } from './fields/defineFields'; import { AddFieldTool } from './fields/addField'; +import { ChangeReferenceToCompositionTool } from './fields/changeReferenceToComposition'; import { NewFolderTool } from './folders/newFolder'; import { DeleteFolderTool } from './folders/deleteFolder'; import { RenameFolderTool } from './folders/renameFolder'; diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts new file mode 100644 index 0000000..28da7c2 --- /dev/null +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -0,0 +1,393 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import * as path from "path"; + +/** + * Tool for converting reference relationships to composition relationships. + * + * This tool converts a @Reference field to a @Composition field by: + * 1. Checking if the referenced model is used elsewhere + * 2. Optionally deleting the referenced model file if not used elsewhere + * 3. Creating a new component model in the same file as the owner + * 4. Converting the field from @Reference to @Composition + */ +export class ChangeReferenceToCompositionTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private explorerProvider: ExplorerProvider; + + constructor(explorerProvider: ExplorerProvider) { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.explorerProvider = explorerProvider; + } + + /** + * Converts a reference field to a composition field. + * + * @param cache - The metadata cache for context about existing models + * @param sourceModelName - The name of the model containing the reference field + * @param fieldName - The name of the reference field to convert + * @returns Promise that resolves when the conversion is complete + */ + public async changeReferenceToComposition( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise { + try { + // Step 1: Validate the source model and reference field + const { sourceModel, document, referenceField, targetModel } = await this.validateReferenceField( + cache, + sourceModelName, + fieldName + ); + + // Step 2: Check if target model is referenced by other fields + const isReferencedElsewhere = this.isModelReferencedElsewhere(cache, targetModel.name, sourceModelName, fieldName); + + // Step 3: Inform user about the action and get confirmation + const shouldProceed = await this.confirmConversion(targetModel.name, isReferencedElsewhere); + if (!shouldProceed) { + return; // User cancelled + } + + // Step 4: Create the component model content based on the target model + const componentModelCode = await this.generateComponentModelCode(targetModel, sourceModel, cache); + + // Step 5: Remove the reference field decorators + await this.removeReferenceField(document, referenceField); + + // Step 6: Add the component model to the source file + await this.addComponentModel(document, componentModelCode, sourceModel.name, cache); + + // Step 7: Add the composition field + await this.addCompositionField(document, sourceModel.name, fieldName, targetModel.name, false, cache); + + // Step 8: Delete the target model file if not referenced elsewhere + if (!isReferencedElsewhere) { + await this.deleteTargetModelFile(targetModel); + } + + // Refresh the explorer to reflect changes + //this.explorerProvider.refresh(); + + // Step 9: Focus on the newly modified field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 10: Show success message + const message = isReferencedElsewhere + ? `Reference converted to composition! The original ${targetModel.name} model was kept as it's referenced elsewhere.` + : `Reference converted to composition! The original ${targetModel.name} model was deleted and recreated as a component.`; + + vscode.window.showInformationMessage(message); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); + console.error("Error changing reference to composition:", error); + } + } + + /** + * Validates that the specified field is a valid reference field. + */ + private async validateReferenceField( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise<{ + sourceModel: DecoratedClass; + document: vscode.TextDocument; + referenceField: PropertyMetadata; + targetModel: DecoratedClass; + }> { + // Get source model + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Source model '${sourceModelName}' not found in the project`); + } + + // Get field + const referenceField = sourceModel.properties[fieldName]; + if (!referenceField) { + throw new Error(`Field '${fieldName}' not found in model '${sourceModelName}'`); + } + + // Check if field has @Reference decorator + const hasReferenceDecorator = referenceField.decorators.some(d => d.name === "Reference"); + if (!hasReferenceDecorator) { + throw new Error(`Field '${fieldName}' is not a reference field`); + } + + // Extract target model name from the field type + const targetModelName = referenceField.type; + const targetModel = cache.getModelByName(targetModelName); + if (!targetModel) { + throw new Error(`Target model '${targetModelName}' not found in the project`); + } + + // Open source document + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${sourceModelName}'`); + } + + return { sourceModel, document, referenceField, targetModel }; + } + + /** + * Checks if a model is referenced by other fields in other models. + */ + private isModelReferencedElsewhere( + cache: MetadataCache, + targetModelName: string, + excludeSourceModelName: string, + excludeFieldName: string + ): boolean { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + // Skip the source model when checking the specific field + if (model.name === excludeSourceModelName) { + // Check other fields in the same model + for (const [fieldName, field] of Object.entries(model.properties)) { + if (fieldName === excludeFieldName) { + continue; // Skip the field we're converting + } + + if (this.isFieldReferencingModel(field, targetModelName)) { + return true; + } + } + } else { + // Check all fields in other models + for (const field of Object.values(model.properties)) { + if (this.isFieldReferencingModel(field, targetModelName)) { + return true; + } + } + } + } + + return false; + } + + /** + * Checks if a field references a specific model. + */ + private isFieldReferencingModel(field: PropertyMetadata, targetModelName: string): boolean { + // Check if field has relationship decorators and the type matches + const hasRelationshipDecorator = field.decorators.some(d => + d.name === "Reference" || d.name === "Composition" || d.name === "Relationship" + ); + + if (hasRelationshipDecorator && field.type === targetModelName) { + return true; + } + + // Also check for array types like "TargetModel[]" + if (hasRelationshipDecorator && field.type === `${targetModelName}[]`) { + return true; + } + + return false; + } + + /** + * Asks user for confirmation before proceeding with the conversion. + */ + private async confirmConversion(targetModelName: string, isReferencedElsewhere: boolean): Promise { + const message = isReferencedElsewhere + ? `Convert reference to composition? The referenced model '${targetModelName}' is used elsewhere, so it will be kept and a new component model will be created.` + : `Convert reference to composition? The referenced model '${targetModelName}' is not used elsewhere, so it will be deleted and recreated as a component model.`; + + const choice = await vscode.window.showWarningMessage( + message, + { modal: true }, + "Convert", + "Cancel" + ); + + return choice === "Convert"; + } + + /** + * Generates the TypeScript code for the new component model. + */ + private async generateComponentModelCode( + targetModel: DecoratedClass, + sourceModel: DecoratedClass, + cache: MetadataCache + ): Promise { + const lines: string[] = []; + + // Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Add model decorator + if (dataSource) { + lines.push(`@Model({`); + lines.push(`\tdataSource: ${dataSource}`); + lines.push(`})`); + } else { + lines.push(`@Model()`); + } + + // Add class declaration as component model + lines.push(`class ${targetModel.name} extends PersistentComponentModel<${sourceModel.name}> {`); + lines.push(``); + + // Copy fields from the original model (except decorators that might not be compatible) + for (const [fieldName, field] of Object.entries(targetModel.properties)) { + // Add field decorators (filter out any that might be problematic) + const validDecorators = field.decorators.filter(d => + d.name === "Field" || + d.name === "Text" || + d.name === "Integer" || + d.name === "Number" || + d.name === "Boolean" || + d.name === "Date" || + d.name === "Email" || + d.name === "LongText" || + d.name === "Html" + ); + + // If no Field decorator, add one + if (!validDecorators.some(d => d.name === "Field")) { + lines.push(`\t@Field({})`); + } + + // Add other decorators + for (const decorator of validDecorators) { + if (decorator.name !== "Field") { + lines.push(`\t@${decorator.name}()`); + } else { + lines.push(`\t@Field({})`); + } + } + + // Add property declaration + lines.push(`\t${fieldName}!: ${field.type};`); + lines.push(``); + } + + lines.push(`}`); + + return lines.join("\n"); + } + + /** + * Removes the @Reference and @Field decorators from the field. + */ + private async removeReferenceField(document: vscode.TextDocument, field: PropertyMetadata): Promise { + const edit = new vscode.WorkspaceEdit(); + + // Find and remove @Reference and @Field decorators + for (const decorator of field.decorators) { + if (decorator.name === "Reference" || decorator.name === "Field") { + const decoratorLine = document.lineAt(decorator.position.start.line); + edit.delete(document.uri, decoratorLine.rangeIncludingLineBreak); + } + } + + await vscode.workspace.applyEdit(edit); + } + + /** + * Adds the component model to the source file. + */ + private async addComponentModel( + document: vscode.TextDocument, + componentModelCode: string, + sourceModelName: string, + cache: MetadataCache + ): Promise { + const newImports = new Set(["Model", "PersistentComponentModel"]); + + await this.sourceCodeService.insertModel( + document, + componentModelCode, + sourceModelName, // Insert after the source model + newImports + ); + } + + /** + * Adds the composition field to the source model. + */ + private async addCompositionField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${targetModelName}[]` : targetModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: targetModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, targetModelName, isArray); + + // Insert the field + await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache, false); + } + + /** + * Generates the TypeScript code for the composition field. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, targetModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Composition decorator + lines.push("@Composition()"); + + // Add property declaration + const typeDeclaration = isArray ? `${targetModelName}[]` : targetModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } + + /** + * Deletes the target model file if it's safe to do so. + */ + private async deleteTargetModelFile(targetModel: DecoratedClass): Promise { + try { + await vscode.workspace.fs.delete(targetModel.declaration.uri); + console.log(`Deleted target model file: ${targetModel.declaration.uri.fsPath}`); + } catch (error) { + console.warn(`Could not delete target model file: ${error}`); + // Don't throw error here as the conversion was successful + } + } +} \ No newline at end of file diff --git a/src/explorer/appTreeItem.ts b/src/explorer/appTreeItem.ts index e593d9f..283eb68 100644 --- a/src/explorer/appTreeItem.ts +++ b/src/explorer/appTreeItem.ts @@ -42,6 +42,9 @@ export class AppTreeItem extends vscode.TreeItem { case "field": iconFileName = "field.svg"; break; + case "referenceField": + iconFileName = "field.svg"; // You could create a specific icon for reference fields + break; case "modelActionsFolder": iconFileName = "action.svg"; break; diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index 2dbf894..74e062b 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -726,10 +726,16 @@ export class ExplorerProvider private mapPropertyToTreeItem(propData: PropertyMetadata, itemType: string, parent?: AppTreeItem): AppTreeItem { const upperFieldName = propData.name.charAt(0).toUpperCase() + propData.name.slice(1); + // Check if this is a reference field and adjust the itemType accordingly + let actualItemType = itemType; + if (itemType === "field" && propData.decorators.some(d => d.name === "Reference")) { + actualItemType = "referenceField"; + } + const item = new AppTreeItem( upperFieldName, vscode.TreeItemCollapsibleState.None, - itemType, + actualItemType, this.extensionUri, propData, parent diff --git a/src/refactor/refactorDisposables.ts b/src/refactor/refactorDisposables.ts index 6a030f1..016f5bc 100644 --- a/src/refactor/refactorDisposables.ts +++ b/src/refactor/refactorDisposables.ts @@ -10,6 +10,7 @@ import { findNodeAtPosition } from '../utils/ast'; import { cache } from '../extension'; import { AppTreeItem } from '../explorer/appTreeItem'; import { AddDecoratorTool } from './tools/addDecorator'; +import { ChangeReferenceToCompositionRefactorTool } from './tools/changeReferenceToComposition'; import { isModelFile } from '../utils/metadata'; import { PropertyMetadata } from '../cache/cache'; import { fieldTypeConfig } from '../utils/fieldTypes'; @@ -36,6 +37,7 @@ export function getAllRefactorTools(): IRefactorTool[] { new DeleteFieldTool(), new ChangeFieldTypeTool(), new AddDecoratorTool(), + new ChangeReferenceToCompositionRefactorTool(), ]; } diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index 441041c..b6a7957 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -111,6 +111,24 @@ export interface AddDecoratorPayload { isManual: boolean; } +/** + * Payload interface for changing a reference field to a composition field. + * This involves removing the @Reference decorator and adding a @Composition decorator, + * potentially deleting the referenced model if it's not used elsewhere, + * and creating a component model in the same file. + * + * @property {string} sourceModelName - The name of the model containing the reference field + * @property {string} fieldName - The name of the reference field to be changed + * @property {PropertyMetadata} fieldMetadata - Metadata information about the reference field + * @property {boolean} isManual - Whether the change was initiated manually by the user + */ +export interface ChangeReferenceToCompositionPayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + /** * Represents the specific type of refactoring change being applied. @@ -122,8 +140,9 @@ export interface AddDecoratorPayload { * - `DELETE_FIELD`: A change that deletes a field from an model. * - `CHANGE_FIELD_TYPE`: A change that modifies the data type of a field. * - `ADD_DECORATOR`: A change that adds a decorator to a field. + * - `CHANGE_REFERENCE_TO_COMPOSITION`: A change that converts a reference field to a composition field. */ -export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR'; +export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR' | 'CHANGE_REFERENCE_TO_COMPOSITION'; /** * Represents a single, atomic change to be applied as part of a refactoring operation. @@ -145,7 +164,8 @@ export interface ChangeObject { | RenameFieldPayload | DeleteFieldPayload | ChangeFieldTypePayload - | AddDecoratorPayload; + | AddDecoratorPayload + | ChangeReferenceToCompositionPayload; } diff --git a/src/refactor/tools/changeReferenceToComposition.ts b/src/refactor/tools/changeReferenceToComposition.ts new file mode 100644 index 0000000..c6c80b5 --- /dev/null +++ b/src/refactor/tools/changeReferenceToComposition.ts @@ -0,0 +1,170 @@ +import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, +} from "../refactorInterfaces"; +import { MetadataCache, PropertyMetadata, DecoratedClass } from "../../cache/cache"; +import { ChangeReferenceToCompositionTool } from "../../commands/fields/changeReferenceToComposition"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { isModelFile } from "../../utils/metadata"; + +/** + * Payload interface for changing a reference field to a composition field. + */ +export interface ChangeReferenceToCompositionPayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Refactor tool for converting reference fields to composition fields. + * + * This tool allows users to convert @Reference fields to @Composition fields + * through the VS Code refactor menu. It validates that the field is indeed + * a reference field before allowing the conversion. + */ +export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.changeReferenceToComposition"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Change Reference to Composition"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["CHANGE_REFERENCE_TO_COMPOSITION"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Only allows conversion if the field is a reference field. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Must have field metadata + if (!context.metadata || !('decorators' in context.metadata)) { + return false; + } + + const fieldMetadata = context.metadata as PropertyMetadata; + + // Check if this field has a @Reference decorator + const hasReferenceDecorator = fieldMetadata.decorators.some(d => d.name === "Reference"); + + return hasReferenceDecorator; + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by creating a change object. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + if (!context.metadata || !('decorators' in context.metadata)) { + return undefined; + } + + const fieldMetadata = context.metadata as PropertyMetadata; + + // Find the model that contains this field + const cache = context.cache; + const sourceModel = this.findSourceModel(cache, fieldMetadata); + + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find the model containing this field"); + return undefined; + } + + const payload: ChangeReferenceToCompositionPayload = { + sourceModelName: sourceModel.name, + fieldName: fieldMetadata.name, + fieldMetadata: fieldMetadata, + isManual: true, + }; + + return { + type: "CHANGE_REFERENCE_TO_COMPOSITION", + uri: context.uri, + description: `Change reference field '${fieldMetadata.name}' to composition in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + * This delegates to the actual implementation tool. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ChangeReferenceToCompositionPayload; + + // We don't actually prepare the edit here since the command tool handles everything + // This is more of a trigger for the actual implementation + const workspaceEdit = new vscode.WorkspaceEdit(); + + // Execute the actual command + setTimeout(async () => { + try { + // Get the explorer provider from the extension context + // For now, we'll create a mock explorer provider + const explorerProvider = { + refresh: () => {} + } as any; + + const tool = new ChangeReferenceToCompositionTool(explorerProvider); + await tool.changeReferenceToComposition( + cache, + payload.sourceModelName, + payload.fieldName + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); + } + }, 100); + + return workspaceEdit; + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModel(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + prop => prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } +} From 9af930a5bd795b797e30bd6fcd7db92deb524961 Mon Sep 17 00:00:00 2001 From: Luciano Date: Thu, 11 Sep 2025 14:35:22 -0300 Subject: [PATCH 10/36] Adds logic to remove imports when a model is inserted in anothers file. --- .../fields/changeReferenceToComposition.ts | 3 + src/services/fileSystemService.ts | 110 ++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index 28da7c2..c296d38 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -71,6 +71,9 @@ export class ChangeReferenceToCompositionTool { // Step 6: Add the component model to the source file await this.addComponentModel(document, componentModelCode, sourceModel.name, cache); + // Step 6.1: Remove the import for the target model since it's now defined in the same file + await this.fileSystemService.removeModelImport(document, targetModel.name); + // Step 7: Add the composition field await this.addCompositionField(document, sourceModel.name, fieldName, targetModel.name, false, cache); diff --git a/src/services/fileSystemService.ts b/src/services/fileSystemService.ts index 4b4ed4d..cfdad46 100644 --- a/src/services/fileSystemService.ts +++ b/src/services/fileSystemService.ts @@ -458,4 +458,114 @@ export class FileSystemService { } return true; } + + /** + * Removes import statements for a specific model from a document. + * This is useful when a model is moved from an external file to the same file, + * making the import unnecessary. + * + * @param document - The document to remove imports from + * @param modelName - The name of the model to remove imports for + * @returns Promise that resolves when the import is removed + */ + public async removeModelImport(document: vscode.TextDocument, modelName: string): Promise { + const edit = new vscode.WorkspaceEdit(); + const content = document.getText(); + const lines = content.split("\n"); + + // Find and remove import lines that contain the model name + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for import statements that import the specific model + if (this.isImportLineForModel(line, modelName)) { + // Check if this is a single import or multiple imports + if (this.isSingleModelImport(line, modelName)) { + // Remove the entire import line + const lineRange = new vscode.Range(i, 0, i + 1, 0); + edit.delete(document.uri, lineRange); + } else { + // Remove only the specific model from a multi-import line + const updatedLine = this.removeModelFromImportLine(line, modelName); + if (updatedLine !== line) { + const lineRange = new vscode.Range(i, 0, i, line.length); + edit.replace(document.uri, lineRange, updatedLine); + } + } + } + } + + if (edit.size > 0) { + await vscode.workspace.applyEdit(edit); + } + } + + /** + * Checks if a line is an import statement for a specific model. + */ + private isImportLineForModel(line: string, modelName: string): boolean { + // Must be an import line + if (!line.trim().startsWith('import')) { + return false; + } + + // Skip slingr-framework imports + if (line.includes('slingr-framework')) { + return false; + } + + // Check if the model name appears in the import + const importRegex = /import\s+\{([^}]+)\}\s+from/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1].split(',').map(item => item.trim()); + return importedItems.includes(modelName); + } + + // Also check for default imports + const defaultImportRegex = new RegExp(`import\\s+${modelName}\\s+from`); + return defaultImportRegex.test(line); + } + + /** + * Checks if the import line only imports a single model. + */ + private isSingleModelImport(line: string, modelName: string): boolean { + const importRegex = /import\s+\{([^}]+)\}\s+from/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1].split(',').map(item => item.trim()).filter(item => item.length > 0); + return importedItems.length === 1 && importedItems[0] === modelName; + } + + // For default imports, it's always a single import + const defaultImportRegex = new RegExp(`import\\s+${modelName}\\s+from`); + return defaultImportRegex.test(line); + } + + /** + * Removes a specific model from a multi-import line. + */ + private removeModelFromImportLine(line: string, modelName: string): string { + const importRegex = /import\s+\{([^}]+)\}\s+from(.+)/; + const match = line.match(importRegex); + + if (match) { + const importedItems = match[1] + .split(',') + .map(item => item.trim()) + .filter(item => item.length > 0 && item !== modelName); + + if (importedItems.length > 0) { + return `import { ${importedItems.join(', ')} } from${match[2]}`; + } else { + // If no items left, return empty string to indicate line should be removed + return ''; + } + } + + return line; + } } From c3228fe4deb458bfa87535100f44cb1da15036bb Mon Sep 17 00:00:00 2001 From: Luciano Date: Thu, 11 Sep 2025 14:37:15 -0300 Subject: [PATCH 11/36] Removed manual explorer refresh --- src/commands/models/addComposition.ts | 2 -- src/commands/models/addReference.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 6298c1d..3ddbdd3 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -56,8 +56,6 @@ export class AddCompositionTool { // Step 6: Add composition field to outer model await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); - this.explorerProvider.refresh(); - // Step 7: Focus on the newly created field await this.sourceCodeService.focusOnElement(document, fieldName); diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index f228dd1..a3e415f 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -77,8 +77,6 @@ export class AddReferenceTool { // Step 5: Add reference field to source model await this.addReferenceField(document, modelClass.name, fieldName, targetModelName, targetModelPath, cache); - this.explorerProvider.refresh(); - // Step 6: Focus on the newly created field await this.sourceCodeService.focusOnElement(document, fieldName); From 40cf273c4d143ba9ac4a92deb333fdadb850a2f6 Mon Sep 17 00:00:00 2001 From: Gaviola Date: Fri, 12 Sep 2025 11:53:49 -0300 Subject: [PATCH 12/36] fix quick infor preview for reference and composition --- src/quickInfoPanel/renderers/rendererRegistry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quickInfoPanel/renderers/rendererRegistry.ts b/src/quickInfoPanel/renderers/rendererRegistry.ts index 25cd583..7e1917b 100644 --- a/src/quickInfoPanel/renderers/rendererRegistry.ts +++ b/src/quickInfoPanel/renderers/rendererRegistry.ts @@ -13,4 +13,6 @@ import { FieldRenderer } from './fieldRenderer'; export const rendererRegistry = new Map([ ['model', new ModelRenderer()], ['field', new FieldRenderer()], + ['referenceField', new FieldRenderer()], + ['compositionField', new FieldRenderer()], ]); \ No newline at end of file From 464418bb9735138cbbc3915bf262b547129d8b87 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 15 Sep 2025 10:33:48 -0300 Subject: [PATCH 13/36] Updated to manage correctly the field extraction and choice fields enum. --- .../fields/changeReferenceToComposition.ts | 253 ++++++++++++++---- 1 file changed, 198 insertions(+), 55 deletions(-) diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index c296d38..9e07011 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -6,6 +6,7 @@ import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import * as path from "path"; /** @@ -23,6 +24,7 @@ export class ChangeReferenceToCompositionTool { private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; private explorerProvider: ExplorerProvider; + private deleteFieldTool: DeleteFieldTool; constructor(explorerProvider: ExplorerProvider) { this.userInputService = new UserInputService(); @@ -30,6 +32,7 @@ export class ChangeReferenceToCompositionTool { this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); this.explorerProvider = explorerProvider; + this.deleteFieldTool = new DeleteFieldTool(); } /** @@ -66,7 +69,7 @@ export class ChangeReferenceToCompositionTool { const componentModelCode = await this.generateComponentModelCode(targetModel, sourceModel, cache); // Step 5: Remove the reference field decorators - await this.removeReferenceField(document, referenceField); + await this.removeReferenceField(document, referenceField, cache); // Step 6: Add the component model to the source file await this.addComponentModel(document, componentModelCode, sourceModel.name, cache); @@ -224,86 +227,226 @@ export class ChangeReferenceToCompositionTool { } /** - * Generates the TypeScript code for the new component model. + * Generates the TypeScript code for the new component model by copying the target model's class body. */ private async generateComponentModelCode( targetModel: DecoratedClass, sourceModel: DecoratedClass, cache: MetadataCache ): Promise { - const lines: string[] = []; - - // Get datasource from source model + // Step 1: Get the target model document to extract the class body + const targetDocument = await vscode.workspace.openTextDocument(targetModel.declaration.uri); + + // Step 2: Extract the complete class body from the target model + const classBody = this.sourceCodeService.extractClassBody(targetDocument, targetModel.name); + + // Step 3: Get datasource from source model const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; - - // Add model decorator - if (dataSource) { - lines.push(`@Model({`); - lines.push(`\tdataSource: ${dataSource}`); - lines.push(`})`); - } else { - lines.push(`@Model()`); + + // Step 4: Extract any enums from the target model file + const enumDefinitions = this.extractEnumDefinitions(targetDocument); + + // Step 5: Check for enum name conflicts and resolve them + const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const resolvedEnums = await this.resolveEnumConflicts(enumDefinitions, sourceDocument, classBody, sourceModel.name); + + // Step 6: Generate the complete component model content + let componentModelCode = this.sourceCodeService.generateModelFileContent( + targetModel.name, + resolvedEnums.updatedClassBody, + `PersistentComponentModel<${sourceModel.name}>`, // Use component model base class + dataSource, + new Set(["Field", "PersistentComponentModel"]), // Ensure required imports + true // This is a component model (no export keyword) + ); + + // Step 7: Extract only the component model part (remove imports and add enums) + const componentModelParts = this.extractComponentModelFromFileContent(componentModelCode); + + // Step 8: Add enum definitions if any exist + if (resolvedEnums.enumDefinitions.length > 0) { + const enumsContent = resolvedEnums.enumDefinitions.join('\n\n'); + return `${enumsContent}\n\n${componentModelParts}`; } + + return componentModelParts; + } - // Add class declaration as component model - lines.push(`class ${targetModel.name} extends PersistentComponentModel<${sourceModel.name}> {`); - lines.push(``); - - // Copy fields from the original model (except decorators that might not be compatible) - for (const [fieldName, field] of Object.entries(targetModel.properties)) { - // Add field decorators (filter out any that might be problematic) - const validDecorators = field.decorators.filter(d => - d.name === "Field" || - d.name === "Text" || - d.name === "Integer" || - d.name === "Number" || - d.name === "Boolean" || - d.name === "Date" || - d.name === "Email" || - d.name === "LongText" || - d.name === "Html" - ); - - // If no Field decorator, add one - if (!validDecorators.some(d => d.name === "Field")) { - lines.push(`\t@Field({})`); + /** + * Extracts enum definitions from a document. + */ + private extractEnumDefinitions(document: vscode.TextDocument): string[] { + const content = document.getText(); + const lines = content.split('\n'); + const enumDefinitions: string[] = []; + + let currentEnum: string[] = []; + let inEnum = false; + let braceCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we're starting an enum + if (line.trim().startsWith('export enum ') || line.trim().startsWith('enum ')) { + inEnum = true; + braceCount = 0; } + + if (inEnum) { + currentEnum.push(line); + + // Count braces + const openBraces = (line.match(/{/g) || []).length; + const closeBraces = (line.match(/}/g) || []).length; + braceCount += openBraces - closeBraces; + + // If we've closed all braces, we're done with this enum + if (braceCount === 0 && line.includes('}')) { + inEnum = false; + enumDefinitions.push(currentEnum.join('\n')); + currentEnum = []; + } + } + } + + return enumDefinitions; + } - // Add other decorators - for (const decorator of validDecorators) { - if (decorator.name !== "Field") { - lines.push(`\t@${decorator.name}()`); - } else { - lines.push(`\t@Field({})`); + /** + * Resolves enum name conflicts between target and source files. + */ + private async resolveEnumConflicts( + enumDefinitions: string[], + sourceDocument: vscode.TextDocument, + classBody: string, + sourceModelName: string + ): Promise<{ enumDefinitions: string[]; updatedClassBody: string }> { + if (enumDefinitions.length === 0) { + return { enumDefinitions: [], updatedClassBody: classBody }; + } + + const sourceContent = sourceDocument.getText(); + const existingEnums = this.extractEnumNames(sourceContent); + const resolvedEnums: string[] = []; + let updatedClassBody = classBody; + + for (const enumDef of enumDefinitions) { + const enumName = this.extractEnumName(enumDef); + + if (enumName && existingEnums.includes(enumName)) { + // Conflict detected, rename the enum + const newEnumName = `${sourceModelName}${enumName}`; + existingEnums.push(newEnumName); // Add to list to avoid future conflicts + + // Update enum definition + const updatedEnumDef = enumDef.replace( + new RegExp(`enum\\s+${enumName}\\b`), + `enum ${newEnumName}` + ); + + // Update class body to use new enum name + updatedClassBody = updatedClassBody.replace( + new RegExp(`\\b${enumName}\\b`, 'g'), + newEnumName + ); + + resolvedEnums.push(updatedEnumDef); + } else { + resolvedEnums.push(enumDef); + if (enumName) { + existingEnums.push(enumName); } } + } + + return { enumDefinitions: resolvedEnums, updatedClassBody }; + } - // Add property declaration - lines.push(`\t${fieldName}!: ${field.type};`); - lines.push(``); + /** + * Extracts enum names from file content. + */ + private extractEnumNames(content: string): string[] { + const enumRegex = /(?:export\s+)?enum\s+(\w+)/g; + const enumNames: string[] = []; + let match; + + while ((match = enumRegex.exec(content)) !== null) { + enumNames.push(match[1]); } + + return enumNames; + } - lines.push(`}`); + /** + * Extracts enum name from an enum definition. + */ + private extractEnumName(enumDefinition: string): string | null { + const match = enumDefinition.match(/(?:export\s+)?enum\s+(\w+)/); + return match ? match[1] : null; + } - return lines.join("\n"); + /** + * Extracts only the component model part from full file content (removes imports). + */ + private extractComponentModelFromFileContent(fileContent: string): string { + const lines = fileContent.split('\n'); + const result: string[] = []; + let foundModel = false; + + for (const line of lines) { + // Skip import lines + if (line.trim().startsWith('import ')) { + continue; + } + + // Skip empty lines before the model + if (!foundModel && line.trim() === '') { + continue; + } + + // Once we find the model decorator or class, include everything + if (line.trim().startsWith('@Model') || line.includes('class ')) { + foundModel = true; + } + + if (foundModel) { + result.push(line); + } + } + + return result.join('\n'); } /** * Removes the @Reference and @Field decorators from the field. */ - private async removeReferenceField(document: vscode.TextDocument, field: PropertyMetadata): Promise { - const edit = new vscode.WorkspaceEdit(); - - // Find and remove @Reference and @Field decorators - for (const decorator of field.decorators) { - if (decorator.name === "Reference" || decorator.name === "Field") { - const decoratorLine = document.lineAt(decorator.position.start.line); - edit.delete(document.uri, decoratorLine.rangeIncludingLineBreak); + private async removeReferenceField(document: vscode.TextDocument, field: PropertyMetadata, cache: MetadataCache): Promise { + // Find the model name that contains this field + const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); + let modelName = 'Unknown'; + + if (fileMetadata) { + for (const [className, classData] of Object.entries(fileMetadata.classes)) { + // Type assertion since we know the structure from cache + const classInfo = classData as DecoratedClass; + if (classInfo.properties[field.name] === field) { + modelName = className; + break; + } } } - await vscode.workspace.applyEdit(edit); + // Use the DeleteFieldTool to programmatically remove the field + const workspaceEdit = await this.deleteFieldTool.deleteFieldProgrammatically( + field, + modelName, + cache + ); + + // Apply the workspace edit + await vscode.workspace.applyEdit(workspaceEdit); } /** From afe08d133490799b3ebed338c2b807346f6d360f Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 15 Sep 2025 10:39:06 -0300 Subject: [PATCH 14/36] Refactored the commands registration. --- src/commands/commandHelpers.ts | 189 +++++++++++++++++ src/commands/commandRegistration.ts | 304 +++++++--------------------- 2 files changed, 261 insertions(+), 232 deletions(-) create mode 100644 src/commands/commandHelpers.ts diff --git a/src/commands/commandHelpers.ts b/src/commands/commandHelpers.ts new file mode 100644 index 0000000..22915b3 --- /dev/null +++ b/src/commands/commandHelpers.ts @@ -0,0 +1,189 @@ +import * as vscode from 'vscode'; +import { AppTreeItem } from '../explorer/appTreeItem'; + +/** + * Interface for URI resolution options + */ +export interface UriResolutionOptions { + /** Whether to require a TypeScript file */ + requireTypeScript?: boolean; + /** Whether to require a model file (contains @Model) */ + requireModel?: boolean; + /** Whether to allow fallback to active editor */ + allowActiveEditorFallback?: boolean; + /** Whether to use workspace folder as fallback when no URI provided */ + useWorkspaceFolderFallback?: boolean; + /** Error message when no URI is provided */ + noUriErrorMessage?: string; + /** Error message when file is not TypeScript */ + notTypeScriptErrorMessage?: string; + /** Error message when file is not a model */ + notModelErrorMessage?: string; +} + +/** + * Result of URI resolution + */ +export interface UriResolutionResult { + /** Resolved target URI */ + targetUri: vscode.Uri; + /** Model name if available from AppTreeItem */ + modelName?: string; + /** The document for the resolved URI */ + document?: vscode.TextDocument; +} + +/** + * Default options for URI resolution + */ +const DEFAULT_URI_OPTIONS: UriResolutionOptions = { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript file (.ts).', + notModelErrorMessage: 'The selected file does not appear to be a model file.' +}; + +/** + * Resolves a URI from various input sources with validation + */ +export async function resolveTargetUri( + uri?: vscode.Uri | AppTreeItem, + options: UriResolutionOptions = {} +): Promise { + const opts = { ...DEFAULT_URI_OPTIONS, ...options }; + let targetUri: vscode.Uri; + let modelName: string | undefined; + + // Step 1: Resolve the URI from different sources + if (uri) { + if (uri instanceof vscode.Uri) { + targetUri = uri; + } else { + // AppTreeItem case + if ((uri.itemType === 'model' || uri.itemType === 'compositionField') && uri.metadata?.declaration?.uri) { + targetUri = uri.metadata.declaration.uri; + modelName = uri.metadata?.name; + } else { + throw new Error(opts.noUriErrorMessage!); + } + } + } else { + // Fallback to active editor if allowed + if (opts.allowActiveEditorFallback) { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + // Use workspace folder as final fallback if allowed + if (opts.useWorkspaceFolderFallback) { + targetUri = vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file(''); + } else { + throw new Error(opts.noUriErrorMessage!); + } + } else { + targetUri = activeEditor.document.uri; + } + } else { + throw new Error(opts.noUriErrorMessage!); + } + } + + // Step 2: Validate TypeScript file if required + if (opts.requireTypeScript && !targetUri.fsPath.endsWith('.ts')) { + throw new Error(opts.notTypeScriptErrorMessage!); + } + + // Step 3: Load document and validate model if required + let document: vscode.TextDocument | undefined; + if (opts.requireModel) { + document = await vscode.workspace.openTextDocument(targetUri); + const content = document.getText(); + + if (!content.includes('@Model')) { + throw new Error(opts.notModelErrorMessage!); + } + } + + return { + targetUri, + modelName, + document + }; +} + +/** + * Creates a standardized command handler that includes error handling + */ +export function createCommandHandler( + commandFn: (result: UriResolutionResult, ...args: any[]) => Promise, + uriOptions?: UriResolutionOptions +) { + return async (uri?: vscode.Uri | AppTreeItem, ...additionalArgs: any[]) => { + try { + const result = await resolveTargetUri(uri, uriOptions); + await commandFn(result, ...additionalArgs); + } catch (error) { + vscode.window.showErrorMessage(`${error}`); + } + }; +} + +/** + * Creates a command registration helper + */ +export function registerCommand( + disposables: vscode.Disposable[], + commandId: string, + commandFn: (result: UriResolutionResult, ...args: any[]) => Promise, + uriOptions?: UriResolutionOptions +): void { + const handler = createCommandHandler(commandFn, uriOptions); + const command = vscode.commands.registerCommand(commandId, handler); + disposables.push(command); +} + +/** + * Pre-configured URI resolution options for common scenarios + */ +export const URI_OPTIONS = { + /** For commands that work with any TypeScript file */ + TYPESCRIPT_FILE: { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a TypeScript file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript file (.ts).' + } as UriResolutionOptions, + + /** For commands that specifically need model files */ + MODEL_FILE: { + requireTypeScript: true, + requireModel: true, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a model file or open one in the editor.', + notTypeScriptErrorMessage: 'Please select a TypeScript model file (.ts).', + notModelErrorMessage: 'The selected file does not appear to be a model file.' + } as UriResolutionOptions, + + /** For commands that require explicit file selection (no active editor fallback) */ + EXPLICIT_MODEL_SELECTION: { + requireTypeScript: true, + requireModel: false, + allowActiveEditorFallback: false, + useWorkspaceFolderFallback: false, + noUriErrorMessage: 'Please select a model file.', + notTypeScriptErrorMessage: 'Please select a TypeScript model file (.ts).' + } as UriResolutionOptions, + + /** For commands that work with any file type */ + ANY_FILE: { + requireTypeScript: false, + requireModel: false, + allowActiveEditorFallback: true, + useWorkspaceFolderFallback: true, + noUriErrorMessage: 'Please select a file or open one in the editor.' + } as UriResolutionOptions +}; diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index 6747324..167aacf 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -4,7 +4,6 @@ import { ExplorerProvider } from '../explorer/explorerProvider'; import { NewModelTool } from './models/newModel'; import { DefineFieldsTool } from './fields/defineFields'; import { AddFieldTool } from './fields/addField'; -import { ChangeReferenceToCompositionTool } from './fields/changeReferenceToComposition'; import { NewFolderTool } from './folders/newFolder'; import { DeleteFolderTool } from './folders/deleteFolder'; import { RenameFolderTool } from './folders/renameFolder'; @@ -16,6 +15,7 @@ import { AddCompositionTool } from './models/addComposition'; import { AddReferenceTool } from './models/addReference'; import { AIService } from '../services/aiService'; import { ProjectAnalysisService } from '../services/projectAnalysisService'; +import { registerCommand, URI_OPTIONS, UriResolutionResult } from './commandHelpers'; export function registerGeneralCommands( context: vscode.ExtensionContext, @@ -26,6 +26,8 @@ export function registerGeneralCommands( const aiService = new AIService(); const projectAnalysisService = new ProjectAnalysisService(); + + // Navigation command const navigateToCodeCommand = vscode.commands.registerCommand('slingr-vscode-extension.navigateToCode', (location: vscode.Location) => { vscode.window.showTextDocument(location.uri).then(editor => { @@ -50,218 +52,87 @@ export function registerGeneralCommands( // New Model Tool const newModelTool = new NewModelTool(); - const newModelCommand = vscode.commands.registerCommand('slingr-vscode-extension.newModel', (uri?: vscode.Uri | AppTreeItem) => { - // If no URI provided, use the current workspace folder - const targetUri = uri || (vscode.workspace.workspaceFolders?.[0]?.uri ?? vscode.Uri.file('')); - return newModelTool.createNewModel(targetUri, cache); - }); - disposables.push(newModelCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.newModel', + async (result: UriResolutionResult) => { + await newModelTool.createNewModel(result.targetUri, cache); + }, + URI_OPTIONS.ANY_FILE + ); // Define Fields Tool const defineFieldsTool = new DefineFieldsTool(); - const defineFieldsCommand = vscode.commands.registerCommand('slingr-vscode-extension.defineFields', async (uri?: vscode.Uri | AppTreeItem) => { - let targetUri: vscode.Uri; - - if (uri) { - // URI provided from context menu (right-click on file in explorer) - if (uri instanceof vscode.Uri) { - targetUri = uri; - } else { - // AppTreeItem case - check if it's a model with metadata - if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { - targetUri = uri.metadata.declaration.uri; - } else { - vscode.window.showErrorMessage('Please select a model file to define fields for.'); - return; - } + registerCommand( + disposables, + 'slingr-vscode-extension.defineFields', + async (result: UriResolutionResult) => { + const document = result.document || await vscode.workspace.openTextDocument(result.targetUri); + + const model = await projectAnalysisService.findModelClass(document, cache); + if (!model) { + throw new Error('Could not identify a model class in the selected file.'); } - } else { - // Fallback to active editor if no URI provided - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please select a model file or open one in the editor to define fields.'); - return; + + // Get field descriptions from user + const fieldsDescription = await vscode.window.showInputBox({ + prompt: "Enter field descriptions to be processed by AI", + placeHolder: "e.g., title, description, project (relationship to Project), status (enum: todo, in-progress, done)", + ignoreFocusOut: true + }); + + if (!fieldsDescription) { + return; // User cancelled } - targetUri = activeEditor.document.uri; - } - - // Validate that it's a TypeScript file - if (!targetUri.fsPath.endsWith('.ts')) { - vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); - return; - } - - // Open the document to extract model information - const document = await vscode.workspace.openTextDocument(targetUri); - const content = document.getText(); - - // Check if this is a model file - if (!content.includes('@Model')) { - vscode.window.showErrorMessage('The selected file does not appear to be a model file.'); - return; - } - - const model = await projectAnalysisService.findModelClass(document, cache); - - if (!model) { - vscode.window.showErrorMessage('Could not identify a model class in the selected file.'); - return; - } - const modelName = model?.name; - - // Get field descriptions from user - const fieldsDescription = await vscode.window.showInputBox({ - prompt: "Enter field descriptions to be processed by AI", - placeHolder: "e.g., title, description, project (relationship to Project), status (enum: todo, in-progress, done)", - ignoreFocusOut: true - }); - - if (!fieldsDescription) { - return; // User cancelled - } - try { await defineFieldsTool.processFieldDescriptions( fieldsDescription, - targetUri, + result.targetUri, cache, - modelName + model.name ); - } catch (error) { - vscode.window.showErrorMessage(`Failed to process field descriptions: ${error}`); - } - }); - disposables.push(defineFieldsCommand); + }, + URI_OPTIONS.MODEL_FILE + ); // Add Field Tool const addFieldTool = new AddFieldTool(); - const addFieldCommand = vscode.commands.registerCommand('slingr-vscode-extension.addField', async (uri?: vscode.Uri | AppTreeItem) => { - let targetUri: vscode.Uri; - - if (uri) { - // URI provided from context menu (right-click on file in explorer) - if (uri instanceof vscode.Uri) { - targetUri = uri; - } else { - // AppTreeItem case - check if it's a model with metadata - if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { - targetUri = uri.metadata.declaration.uri; - } else { - vscode.window.showErrorMessage('Please select a model file to add a field to.'); - return; - } - } - } else { - // Fallback to active editor if no URI provided - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please select a model file or open one in the editor to add a field.'); - return; - } - targetUri = activeEditor.document.uri; - } - - // Validate that it's a TypeScript file - if (!targetUri.fsPath.endsWith('.ts')) { - vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); - return; - } - - try { - await addFieldTool.addField(targetUri, cache); - } catch (error) { - vscode.window.showErrorMessage(`Failed to add field: ${error}`); - } - }); - disposables.push(addFieldCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.addField', + async (result: UriResolutionResult) => { + await addFieldTool.addField(result.targetUri, cache); + }, + URI_OPTIONS.MODEL_FILE + ); // Add Composition Tool const addCompositionTool = new AddCompositionTool(explorerProvider); - const addCompositionCommand = vscode.commands.registerCommand('slingr-vscode-extension.addComposition', async (uri?: vscode.Uri | AppTreeItem) => { - let targetUri: vscode.Uri; - let modelName: string | undefined; - - if (uri) { - // URI provided from context menu (right-click on file in explorer) - if (uri instanceof vscode.Uri) { - targetUri = uri; - } else { - // AppTreeItem case - check if it's a model with metadata - if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { - targetUri = uri.metadata.declaration.uri; - modelName = uri.metadata?.name; - } else { - vscode.window.showErrorMessage('Please select a model file to add a composition to.'); - return; - } + registerCommand( + disposables, + 'slingr-vscode-extension.addComposition', + async (result: UriResolutionResult) => { + if (!result.modelName) { + throw new Error('Model name could not be determined.'); } - } else { - throw new Error('URI must be provided to add a composition.'); - } - - // Validate that it's a TypeScript file - if (!targetUri.fsPath.endsWith('.ts')) { - vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); - return; - } - - try { - if (modelName) { - await addCompositionTool.addComposition(cache, modelName); - } - else{ - vscode.window.showErrorMessage('Model name could not be determined.'); - } - - } catch (error) { - vscode.window.showErrorMessage(`Failed to add composition: ${error}`); - } - }); - disposables.push(addCompositionCommand); + await addCompositionTool.addComposition(cache, result.modelName); + }, + URI_OPTIONS.EXPLICIT_MODEL_SELECTION + ); // Add Reference Tool - const addReferenceTool = new AddReferenceTool(explorerProvider); - const addReferenceCommand = vscode.commands.registerCommand('slingr-vscode-extension.addReference', async (uri?: vscode.Uri | AppTreeItem) => { - let targetUri: vscode.Uri; - let modelName: string | undefined; - - if (uri) { - // URI provided from context menu (right-click on file in explorer) - if (uri instanceof vscode.Uri) { - targetUri = uri; - } else { - // AppTreeItem case - check if it's a model with metadata - if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { - targetUri = uri.metadata.declaration.uri; - modelName = uri.metadata?.name; - } else { - vscode.window.showErrorMessage('Please select a model file to add a reference to.'); - return; - } + const addReferenceTool = new AddReferenceTool(explorerProvider); + registerCommand( + disposables, + 'slingr-vscode-extension.addReference', + async (result: UriResolutionResult) => { + if (!result.modelName) { + throw new Error('Model name could not be determined.'); } - } else { - throw new Error('URI must be provided to add a reference.'); - } - - // Validate that it's a TypeScript file - if (!targetUri.fsPath.endsWith('.ts')) { - vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); - return; - } - - try { - if (modelName) { - await addReferenceTool.addReference(cache, modelName); - } - else{ - vscode.window.showErrorMessage('Model name could not be determined.'); - } - - } catch (error) { - vscode.window.showErrorMessage(`Failed to add reference: ${error}`); - } - }); - disposables.push(addReferenceCommand); + await addReferenceTool.addReference(cache, result.modelName); + }, + URI_OPTIONS.EXPLICIT_MODEL_SELECTION + ); // New Folder Tool const newFolderTool = new NewFolderTool(); @@ -286,45 +157,14 @@ export function registerGeneralCommands( // Create Test Tool const createTestTool = new CreateTestTool(aiService); - const createTestCommand = vscode.commands.registerCommand('slingr-vscode-extension.createTest', async (uri?: vscode.Uri | AppTreeItem) => { - let targetUri: vscode.Uri; - - if (uri) { - // URI provided from context menu (right-click on file in explorer) - if (uri instanceof vscode.Uri) { - targetUri = uri; - } else { - // AppTreeItem case - check if it's a model with metadata - if (uri.itemType === 'model' && uri.metadata?.declaration?.uri) { - targetUri = uri.metadata.declaration.uri; - } else { - vscode.window.showErrorMessage('Please select a model file to create a test for.'); - return; - } - } - } else { - // Fallback to active editor if no URI provided - const activeEditor = vscode.window.activeTextEditor; - if (!activeEditor) { - vscode.window.showErrorMessage('Please select a model file or open one in the editor to create a test.'); - return; - } - targetUri = activeEditor.document.uri; - } - - // Validate that it's a TypeScript file - if (!targetUri.fsPath.endsWith('.ts')) { - vscode.window.showErrorMessage('Please select a TypeScript model file (.ts).'); - return; - } - - try { - await createTestTool.createTest(targetUri, cache); - } catch (error) { - vscode.window.showErrorMessage(`Failed to create test: ${error}`); - } - }); - disposables.push(createTestCommand); + registerCommand( + disposables, + 'slingr-vscode-extension.createTest', + async (result: UriResolutionResult) => { + await createTestTool.createTest(result.targetUri, cache); + }, + URI_OPTIONS.TYPESCRIPT_FILE + ); // General refactor command (placeholder for refactor controller integration) const refactorCommand = vscode.commands.registerCommand('slingr-vscode-extension.refactor', () => { From f705b608ec6af96340b226c5620334249e85a2a8 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 15 Sep 2025 10:41:29 -0300 Subject: [PATCH 15/36] Added changeCompositionToReference command. --- package.json | 47 ++- .../fields/changeCompositionToReference.ts | 385 ++++++++++++++++++ src/commands/models/newModel.ts | 42 ++ src/explorer/appTreeItem.ts | 3 + src/explorer/explorerProvider.ts | 39 +- src/quickInfoPanel/quickInfoProvider.ts | 2 +- src/refactor/refactorDisposables.ts | 2 + src/refactor/refactorInterfaces.ts | 2 +- .../tools/changeCompositionToReference.ts | 203 +++++++++ src/refactor/tools/deleteField.ts | 30 ++ src/services/sourceCodeService.ts | 166 ++++++++ 11 files changed, 886 insertions(+), 35 deletions(-) create mode 100644 src/commands/fields/changeCompositionToReference.ts create mode 100644 src/refactor/tools/changeCompositionToReference.ts diff --git a/package.json b/package.json index 30bce99..f476b86 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,10 @@ "command": "slingr-vscode-extension.changeReferenceToComposition", "title": "Change Reference to Composition" }, + { + "command": "slingr-vscode-extension.changeCompositionToReference", + "title": "Change Composition to Reference" + }, { "command": "slingr-vscode-extension.newModel", "title": "New Model" @@ -137,7 +141,7 @@ "view/item/context": [ { "command": "slingr-vscode-extension.newModel", - "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model')", + "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { @@ -147,37 +151,37 @@ }, { "command": "slingr-vscode-extension.defineFields", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.addField", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.addComposition", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.addReference", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.createTest", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.renameModel", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "1_modification" }, { "command": "slingr-vscode-extension.deleteModel", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "2_modification" }, { @@ -210,14 +214,19 @@ "when": "view == slingrExplorer && viewItem == 'referenceField'", "group": "3_modification" }, + { + "command": "slingr-vscode-extension.changeCompositionToReference", + "when": "view == slingrExplorer && viewItem == 'compositionField'", + "group": "3_modification" + }, { "command": "slingr-vscode-extension.createModelFromDescription", - "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model')", + "when": "view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')", "group": "0_creation" }, { "command": "slingr-vscode-extension.modifyModel", - "when": "view == slingrExplorer && viewItem == 'model'", + "when": "view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')", "group": "3_modification" } ], @@ -234,7 +243,7 @@ "slingr-vscode-extension.creation": [ { "command": "slingr-vscode-extension.newModel", - "when": "(view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model')) || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'folder' || viewItem == 'dataRoot' || viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "0_model" }, { @@ -249,44 +258,44 @@ }, { "command": "slingr-vscode-extension.defineFields", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field" }, { "command": "slingr-vscode-extension.addField", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field" }, { "command": "slingr-vscode-extension.addComposition", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field" }, { "command": "slingr-vscode-extension.addReference", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_field" }, { "command": "slingr-vscode-extension.createTest", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "2_test" }, { "command": "slingr-vscode-extension.modifyModel", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "3_modify" } ], "slingr-vscode-extension.refactorings": [ { "command": "slingr-vscode-extension.renameModel", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "1_modification" }, { "command": "slingr-vscode-extension.deleteModel", - "when": "(view == slingrExplorer && viewItem == 'model') || !viewItem", + "when": "(view == slingrExplorer && (viewItem == 'model' || viewItem == 'compositionField')) || !viewItem", "group": "2_modification" }, { diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts new file mode 100644 index 0000000..9246eed --- /dev/null +++ b/src/commands/fields/changeCompositionToReference.ts @@ -0,0 +1,385 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { FieldInfo, FieldTypeOption } from "../interfaces"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import * as path from "path"; + +/** + * Tool for converting composition relationships to reference relationships. + * + * This tool converts a @Composition field to a @Reference field by: + * 1. Finding the component model that is currently embedded + * 2. Extracting the component model to its own file + * 3. Converting the component model from PersistentComponentModel to PersistentModel + * 4. Converting the field from @Composition to @Reference + * 5. Adding the necessary imports for the new referenced model + */ +export class ChangeCompositionToReferenceTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private explorerProvider: ExplorerProvider; + private deleteFieldTool: DeleteFieldTool; + + constructor(explorerProvider: ExplorerProvider) { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.explorerProvider = explorerProvider; + this.deleteFieldTool = new DeleteFieldTool(); + } + + /** + * Converts a composition field to a reference field. + * + * @param cache - The metadata cache for context about existing models + * @param sourceModelName - The name of the model containing the composition field + * @param fieldName - The name of the composition field to convert + * @returns Promise that resolves when the conversion is complete + */ + public async changeCompositionToReference( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise { + try { + // Step 1: Validate the source model and composition field + const { sourceModel, document, compositionField, componentModel } = await this.validateCompositionField( + cache, + sourceModelName, + fieldName + ); + + // Step 2: Get confirmation from user + const shouldProceed = await this.confirmConversion(componentModel.name, sourceModelName); + if (!shouldProceed) { + return; // User cancelled + } + + // Step 3: Determine the target file path for the new independent model + const targetFilePath = await this.determineTargetFilePath(sourceModel, componentModel.name); + + // Step 4: Generate and create the independent model using existing tools + const modelFileUri = await this.generateAndCreateIndependentModel(componentModel, sourceModel, targetFilePath, cache); + + // Step 6: Remove the composition field from the source model + await this.removeCompositionField(document, compositionField, cache); + + // Step 7: Remove the component model from the source file + await this.removeComponentModel(document, componentModel, sourceModel, cache); + + // Step 8: Add the reference field to the source model + await this.addReferenceField(document, sourceModel.name, fieldName, componentModel.name, compositionField.type.endsWith('[]'), cache); + + // Step 9: Add import for the new model in the source file + const importEdit = new vscode.WorkspaceEdit(); + await this.sourceCodeService.addModelImport(document, componentModel.name, importEdit, cache); + await vscode.workspace.applyEdit(importEdit); + + // Step 10: Focus on the newly modified field + await this.sourceCodeService.focusOnElement(document, fieldName); + + // Step 11: Show success message + vscode.window.showInformationMessage( + `Composition converted to reference! The component model '${componentModel.name}' is now an independent model in its own file.` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); + console.error("Error changing composition to reference:", error); + } + } + + /** + * Validates that the specified field is a valid composition field. + */ + private async validateCompositionField( + cache: MetadataCache, + sourceModelName: string, + fieldName: string + ): Promise<{ + sourceModel: DecoratedClass; + document: vscode.TextDocument; + compositionField: PropertyMetadata; + componentModel: DecoratedClass; + }> { + // Get source model + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Source model '${sourceModelName}' not found in the project`); + } + + // Get field + const compositionField = sourceModel.properties[fieldName]; + if (!compositionField) { + throw new Error(`Field '${fieldName}' not found in model '${sourceModelName}'`); + } + + // Check if field has @Composition decorator + const hasCompositionDecorator = compositionField.decorators.some(d => d.name === "Composition"); + if (!hasCompositionDecorator) { + throw new Error(`Field '${fieldName}' is not a composition field`); + } + + // Extract component model name from the field type + const componentModelName = compositionField.type.replace('[]', ''); // Remove array suffix if present + const componentModel = cache.getModelByName(componentModelName); + if (!componentModel) { + throw new Error(`Component model '${componentModelName}' not found in the project`); + } + + // Verify that the component model is actually defined in the same file as the source model + if (componentModel.declaration.uri.fsPath !== sourceModel.declaration.uri.fsPath) { + throw new Error(`Component model '${componentModelName}' is not in the same file as the source model. This operation only works with embedded component models.`); + } + + // Open source document + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + if (!document) { + throw new Error(`Could not open document for model '${sourceModelName}'`); + } + + return { sourceModel, document, compositionField, componentModel }; + } + + /** + * Asks user for confirmation before proceeding with the conversion. + */ + private async confirmConversion(componentModelName: string, sourceModelName: string): Promise { + const message = `Convert composition to reference? The component model '${componentModelName}' will be moved to its own file and become an independent model.`; + + const choice = await vscode.window.showWarningMessage( + message, + { modal: true }, + "Convert", + "Cancel" + ); + + return choice === "Convert"; + } + + /** + * Determines the target file path for the new independent model. + */ + private async determineTargetFilePath(sourceModel: DecoratedClass, componentModelName: string): Promise { + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const fileName = `${componentModelName.toLowerCase()}.ts`; + return path.join(sourceDir, fileName); + } + + /** + * Creates the independent model by copying the component model's class body. + */ + private async generateAndCreateIndependentModel( + componentModel: DecoratedClass, + sourceModel: DecoratedClass, + targetFilePath: string, + cache: MetadataCache + ): Promise { + // Step 1: Get the source document to extract the class body + const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Step 2: Extract the complete class body from the component model + const classBody = this.sourceCodeService.extractClassBody(sourceDocument, componentModel.name); + + // Step 3: Get datasource from source model + const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); + const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; + + // Step 4: Extract existing model imports from the source file + const existingImports = this.sourceCodeService.extractModelImports(sourceDocument); + + // Step 5: Convert the class body for independent model use + const convertedClassBody = this.convertComponentClassBody(classBody); + + // Step 6: Generate the complete model file content + const modelFileContent = this.sourceCodeService.generateModelFileContent( + componentModel.name, + convertedClassBody, + "PersistentModel", // Change from PersistentComponentModel to PersistentModel + dataSource, + new Set(["Field"]), // Ensure Field is included + false // This is a standalone model (with export) + ); + + // Step 7: Create the new model file + const modelFileUri = vscode.Uri.file(targetFilePath); + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile(modelFileUri, encoder.encode(modelFileContent)); + + // Step 8: Add model imports to the new file if needed + if (existingImports.length > 0) { + await this.addModelImportsToNewFile(modelFileUri, existingImports); + } + + console.log(`Created independent model file: ${targetFilePath}`); + return modelFileUri; + } + + /** + * Converts a component model class body to work as an independent model. + * This mainly involves ensuring proper formatting and removing any component-specific elements. + */ + private convertComponentClassBody(classBody: string): string { + // For now, we can use the class body as-is since the main difference is in the + // class declaration (PersistentComponentModel vs PersistentModel) which is handled + // in generateModelFileContent. + + // Future enhancements could include: + // - Removing component-specific decorators if any + // - Adjusting field configurations if needed + // - Updating comments that reference "component" + + return classBody; + } + + /** + * Adds model imports to the newly created model file. + */ + private async addModelImportsToNewFile(modelFileUri: vscode.Uri, importStatements: string[]): Promise { + if (importStatements.length === 0) { + return; + } + + try { + const document = await vscode.workspace.openTextDocument(modelFileUri); + const edit = new vscode.WorkspaceEdit(); + + // Find the position after the slingr-framework import + const content = document.getText(); + const lines = content.split("\n"); + + let insertPosition = 1; // Default to after first line + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("from") && lines[i].includes("slingr-framework")) { + insertPosition = i + 1; + break; + } + } + + // Add each import statement + const importsText = importStatements.join("\n") + "\n"; + edit.insert(modelFileUri, new vscode.Position(insertPosition, 0), importsText); + + await vscode.workspace.applyEdit(edit); + } catch (error) { + console.warn("Could not add model imports to new file:", error); + } + } + + /** + * Removes the @Composition and @Field decorators from the field. + */ + private async removeCompositionField(document: vscode.TextDocument, field: PropertyMetadata, cache: MetadataCache): Promise { + // Find the model name that contains this field + const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); + let modelName = 'Unknown'; + + if (fileMetadata) { + for (const [className, classData] of Object.entries(fileMetadata.classes)) { + // Type assertion since we know the structure from cache + const classInfo = classData as DecoratedClass; + if (classInfo.properties[field.name] === field) { + modelName = className; + break; + } + } + } + + // Use the DeleteFieldTool to programmatically remove the field + const workspaceEdit = await this.deleteFieldTool.deleteFieldProgrammatically( + field, + modelName, + cache + ); + + // Apply the workspace edit + await vscode.workspace.applyEdit(workspaceEdit); + } + + /** + * Removes the component model from the source file. + */ + private async removeComponentModel( + document: vscode.TextDocument, + componentModel: DecoratedClass, + sourceModel: DecoratedClass, + cache: MetadataCache + ): Promise { + // Get the text range for the component model + const modelRange = componentModel.declaration.range; + + // Extend the range to include any preceding decorators and following whitespace + const extendedRange = new vscode.Range( + new vscode.Position(Math.max(0, modelRange.start.line - 5), 0), // Include decorators + new vscode.Position(modelRange.end.line + 2, 0) // Include trailing whitespace + ); + + // Create workspace edit to remove the component model + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.delete(document.uri, extendedRange); + + await vscode.workspace.applyEdit(workspaceEdit); + } + + /** + * Adds the reference field to the source model. + */ + private async addReferenceField( + document: vscode.TextDocument, + sourceModelName: string, + fieldName: string, + targetModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the reference field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Reference", + tsType: isArray ? `${targetModelName}[]` : targetModelName, + description: "Reference relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // References are typically optional + additionalConfig: { + relationshipType: "reference", + targetModel: targetModelName, + }, + }; + + // Generate the field code + const fieldCode = this.generateReferenceFieldCode(fieldInfo, targetModelName, isArray); + + // Insert the field + await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache, false); + } + + /** + * Generates the TypeScript code for the reference field. + */ + private generateReferenceFieldCode(fieldInfo: FieldInfo, targetModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add Reference decorator + lines.push("@Reference()"); + + // Add property declaration + const typeDeclaration = isArray ? `${targetModelName}[]` : targetModelName; + lines.push(`${fieldInfo.name}!: ${typeDeclaration};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/models/newModel.ts b/src/commands/models/newModel.ts index 483bec7..8d57dea 100644 --- a/src/commands/models/newModel.ts +++ b/src/commands/models/newModel.ts @@ -368,6 +368,48 @@ export class NewModelTool implements AIEnhancedTool { ); } + /** + * Creates a new model file programmatically without user interaction. + * + * @param modelName - The name of the model to create + * @param targetFilePath - The full file path where the model should be created + * @param docs - Optional documentation for the model + * @param dataSource - Optional datasource configuration + * @returns Promise that resolves when the model is created + */ + public async createModelProgrammatically( + modelName: string, + targetFilePath: string, + docs?: string, + dataSource?: string + ): Promise { + try { + // Generate model content + const modelContent = this.generateModelContent(modelName, docs); + + // Modify the content to include datasource if provided + let finalContent = modelContent; + if (dataSource) { + finalContent = finalContent.replace( + '@Model()', + `@Model({\n\tdataSource: ${dataSource}\n})` + ); + } + + // Create the file + const targetFileUri = await this.fileSystemService.createFile( + modelName, + targetFilePath, + finalContent, + false // Don't handle overwrite since we control the path + ); + + return targetFileUri; + } catch (error) { + throw new Error(`Failed to create model programmatically: ${error}`); + } + } + public toCamelCase(str: string): string { return str.charAt(0).toLowerCase() + str.slice(1); } diff --git a/src/explorer/appTreeItem.ts b/src/explorer/appTreeItem.ts index 9a47ee8..44146c1 100644 --- a/src/explorer/appTreeItem.ts +++ b/src/explorer/appTreeItem.ts @@ -45,6 +45,9 @@ export class AppTreeItem extends vscode.TreeItem { case "referenceField": iconFileName = "field.svg"; // You could create a specific icon for reference fields break; + case "compositionField": + iconFileName = "model-type.svg"; // You could create a specific icon for composition fields + break; case "modelActionsFolder": iconFileName = "action.svg"; break; diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index f29a7d1..2ba31fb 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -487,20 +487,26 @@ export class ExplorerProvider // --- DATA SOURCES ROOT --- if (element.itemType === "dataSourcesRoot") { - const dataSources = this.cache.getDataSources(); - return dataSources.map(ds => { - const item = new AppTreeItem(ds.name, vscode.TreeItemCollapsibleState.None, "dataSource", this.extensionUri, ds as any); - item.command = { - command: 'slingr-vscode-extension.handleTreeItemClick', - title: 'Handle Click', - arguments: [item] - }; - return item; - }); + const dataSources = this.cache.getDataSources(); + return dataSources.map((ds) => { + const item = new AppTreeItem( + ds.name, + vscode.TreeItemCollapsibleState.None, + "dataSource", + this.extensionUri, + ds as any + ); + item.command = { + command: "slingr-vscode-extension.handleTreeItemClick", + title: "Handle Click", + arguments: [item], + }; + return item; + }); } // --- Children of a specific Model --- - if (element.itemType === "model" && this.isDecoratedClass(element.metadata)) { + if ((element.itemType === "model" || element.itemType === "compositionField") && this.isDecoratedClass(element.metadata)) { const modelClass = element.metadata; const fields = Object.values(element.metadata.properties).filter((prop) => @@ -522,7 +528,7 @@ export class ExplorerProvider const compositionItem = new AppTreeItem( upperFieldName, vscode.TreeItemCollapsibleState.Collapsed, - "model", + "compositionField", this.extensionUri, relatedModel, element @@ -743,8 +749,13 @@ export class ExplorerProvider // Check if this is a reference field and adjust the itemType accordingly let actualItemType = itemType; - if (itemType === "field" && propData.decorators.some(d => d.name === "Reference")) { - actualItemType = "referenceField"; + if (itemType === "field") { + if (propData.decorators.some((d) => d.name === "Reference")) { + actualItemType = "referenceField"; + } + else if (propData.decorators.some((d) => d.name === "Composition")) { + actualItemType = "compositionField"; + } } const item = new AppTreeItem( diff --git a/src/quickInfoPanel/quickInfoProvider.ts b/src/quickInfoPanel/quickInfoProvider.ts index a365c4c..705dfff 100644 --- a/src/quickInfoPanel/quickInfoProvider.ts +++ b/src/quickInfoPanel/quickInfoProvider.ts @@ -162,7 +162,7 @@ export class QuickInfoProvider implements vscode.WebviewViewProvider { const { itemType, name, parentClassName } = data; let foundMetadata: MetadataItem | undefined; - if (itemType === 'field' && parentClassName) { + if ((itemType === 'field' || itemType === 'referenceField') && parentClassName) { const [parentClass] = this.cache.findMetadata( item => 'properties' in item && item.name === parentClassName ) as DecoratedClass[]; diff --git a/src/refactor/refactorDisposables.ts b/src/refactor/refactorDisposables.ts index 016f5bc..80d8b14 100644 --- a/src/refactor/refactorDisposables.ts +++ b/src/refactor/refactorDisposables.ts @@ -11,6 +11,7 @@ import { cache } from '../extension'; import { AppTreeItem } from '../explorer/appTreeItem'; import { AddDecoratorTool } from './tools/addDecorator'; import { ChangeReferenceToCompositionRefactorTool } from './tools/changeReferenceToComposition'; +import { ChangeCompositionToReferenceRefactorTool } from './tools/changeCompositionToReference'; import { isModelFile } from '../utils/metadata'; import { PropertyMetadata } from '../cache/cache'; import { fieldTypeConfig } from '../utils/fieldTypes'; @@ -38,6 +39,7 @@ export function getAllRefactorTools(): IRefactorTool[] { new ChangeFieldTypeTool(), new AddDecoratorTool(), new ChangeReferenceToCompositionRefactorTool(), + new ChangeCompositionToReferenceRefactorTool(), ]; } diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index b6a7957..5a84e3e 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -142,7 +142,7 @@ export interface ChangeReferenceToCompositionPayload { * - `ADD_DECORATOR`: A change that adds a decorator to a field. * - `CHANGE_REFERENCE_TO_COMPOSITION`: A change that converts a reference field to a composition field. */ -export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR' | 'CHANGE_REFERENCE_TO_COMPOSITION'; +export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR' | 'CHANGE_REFERENCE_TO_COMPOSITION' | 'CHANGE_COMPOSITION_TO_REFERENCE'; /** * Represents a single, atomic change to be applied as part of a refactoring operation. diff --git a/src/refactor/tools/changeCompositionToReference.ts b/src/refactor/tools/changeCompositionToReference.ts new file mode 100644 index 0000000..49dbf02 --- /dev/null +++ b/src/refactor/tools/changeCompositionToReference.ts @@ -0,0 +1,203 @@ +import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, +} from "../refactorInterfaces"; +import { MetadataCache, PropertyMetadata, DecoratedClass } from "../../cache/cache"; +import { ChangeCompositionToReferenceTool } from "../../commands/fields/changeCompositionToReference"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { isModelFile } from "../../utils/metadata"; + +/** + * Payload interface for changing a composition field to a reference field. + */ +export interface ChangeCompositionToReferencePayload { + sourceModelName: string; + fieldName: string; + fieldMetadata: PropertyMetadata; + isManual: boolean; +} + +/** + * Refactor tool for converting composition fields to reference fields. + * + * This tool allows users to convert @Composition fields to @Reference fields + * through the VS Code refactor menu. It validates that the field is indeed + * a composition field before allowing the conversion. + */ +export class ChangeCompositionToReferenceRefactorTool implements IRefactorTool { + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.changeCompositionToReference"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Change Composition to Reference"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["CHANGE_COMPOSITION_TO_REFERENCE"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Only allows conversion if this is a component model that has a parent with a composition field. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Must have class metadata (since composition fields are shown as models in explorer) + if (!context.metadata || !('name' in context.metadata)) { + return false; + } + + const componentModel = context.metadata as DecoratedClass; + + // Check if this is a component model by looking for a parent model with a composition field pointing to it + const parentFieldInfo = this.findParentCompositionField(context.cache, componentModel); + + return parentFieldInfo !== null; + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by creating a change object. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + if (!context.metadata || !('decorators' in context.metadata)) { + return undefined; + } + + // When right-clicking on a composition field (shown as a model in explorer), + // context.metadata is the component model, not the field metadata + const componentModel = context.metadata as DecoratedClass; + + // Find the parent model and field that has a composition relationship to this component model + const cache = context.cache; + const parentFieldInfo = this.findParentCompositionField(cache, componentModel); + + if (!parentFieldInfo) { + vscode.window.showErrorMessage("Could not find the parent model with composition field for this component model"); + return undefined; + } + + const payload: ChangeCompositionToReferencePayload = { + sourceModelName: parentFieldInfo.parentModel.name, + fieldName: parentFieldInfo.fieldName, + fieldMetadata: parentFieldInfo.fieldMetadata, + isManual: true, + }; + + return { + type: "CHANGE_COMPOSITION_TO_REFERENCE", + uri: context.uri, + description: `Change composition field '${parentFieldInfo.fieldName}' to reference in model '${parentFieldInfo.parentModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + * This delegates to the actual implementation tool. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ChangeCompositionToReferencePayload; + + // We don't actually prepare the edit here since the command tool handles everything + // This is more of a trigger for the actual implementation + const workspaceEdit = new vscode.WorkspaceEdit(); + + // Execute the actual command + setTimeout(async () => { + try { + // Get the explorer provider from the extension context + // For now, we'll create a mock explorer provider + const explorerProvider = { + refresh: () => {} + } as any; + + const tool = new ChangeCompositionToReferenceTool(explorerProvider); + await tool.changeCompositionToReference( + cache, + payload.sourceModelName, + payload.fieldName + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); + } + }, 100); + + return workspaceEdit; + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModel(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + prop => prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the parent model that has a composition field pointing to the given component model. + */ + private findParentCompositionField(cache: MetadataCache, componentModel: DecoratedClass): { + parentModel: DecoratedClass; + fieldName: string; + fieldMetadata: PropertyMetadata; + } | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + for (const [fieldName, fieldMetadata] of Object.entries(model.properties)) { + // Check if this field has a @Composition decorator and points to the component model + const hasCompositionDecorator = fieldMetadata.decorators.some(d => d.name === "Composition"); + if (hasCompositionDecorator) { + // Check if the field type matches the component model name (handle both singular and array types) + const fieldType = fieldMetadata.type.replace('[]', ''); // Remove array suffix if present + if (fieldType === componentModel.name) { + return { + parentModel: model, + fieldName: fieldName, + fieldMetadata: fieldMetadata + }; + } + } + } + } + + return null; + } +} diff --git a/src/refactor/tools/deleteField.ts b/src/refactor/tools/deleteField.ts index 4da57f7..008c10c 100644 --- a/src/refactor/tools/deleteField.ts +++ b/src/refactor/tools/deleteField.ts @@ -253,6 +253,36 @@ export class DeleteFieldTool implements IRefactorTool { return workspaceEdit; } + /** + * Deletes a field programmatically without user interaction. + * This is useful for automated refactoring operations. + * + * @param fieldMetadata The metadata of the field to delete + * @param modelName The name of the model containing the field + * @param cache The metadata cache + * @returns A promise that resolves to a WorkspaceEdit for the deletion + */ + public async deleteFieldProgrammatically( + fieldMetadata: PropertyMetadata, + modelName: string, + cache: MetadataCache + ): Promise { + const payload: DeleteFieldPayload = { + oldFieldMetadata: fieldMetadata, + modelName: modelName, + isManual: true // Use manual mode to actually remove the field declaration + }; + + const change: ChangeObject = { + type: 'DELETE_FIELD', + uri: fieldMetadata.declaration.uri, + description: `Delete field '${fieldMetadata.name}' programmatically.`, + payload + }; + + return await this.prepareEdit(change, cache); + } + /** * Executes a prompt to help fix broken field references after a field deletion. * diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index edb92ed..76cf250 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -389,6 +389,172 @@ export class SourceCodeService { } } + /** + * Extracts the complete class body (everything between the class braces) from a model. + * + * @param document - The document containing the model + * @param className - The name of the class to extract from + * @returns The class body content including proper indentation + */ + public extractClassBody(document: vscode.TextDocument, className: string): string { + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.findClassBoundaries(lines, className); + + // Find the opening brace of the class + let openBraceIndex = -1; + for (let i = classStartLine; i <= classEndLine; i++) { + if (lines[i].includes("{")) { + openBraceIndex = i; + break; + } + } + + if (openBraceIndex === -1) { + throw new Error(`Could not find opening brace for class ${className}`); + } + + // Extract content between the braces (excluding the braces themselves) + const classBodyLines = lines.slice(openBraceIndex + 1, classEndLine); + + // Remove any empty lines at the end + while (classBodyLines.length > 0 && classBodyLines[classBodyLines.length - 1].trim() === "") { + classBodyLines.pop(); + } + + return classBodyLines.join("\n"); + } + + /** + * Creates a complete model file with the given class body content. + * + * @param modelName - The name of the new model class + * @param classBody - The complete class body content + * @param baseClass - The base class to extend (default: "PersistentModel") + * @param dataSource - Optional datasource for the model + * @param existingImports - Set of imports that should be included + * @param isComponent - Whether this is a component model (affects export and class declaration) + * @returns The complete model file content + */ + public generateModelFileContent( + modelName: string, + classBody: string, + baseClass: string = "PersistentModel", + dataSource?: string, + existingImports?: Set, + isComponent: boolean = false + ): string { + const lines: string[] = []; + + // Determine required imports + const imports = new Set(["Model", "Field"]); + + // Add base class to imports (handle complex base classes like PersistentComponentModel) + const baseClassCore = baseClass.split('<')[0]; // Extract base class name before generic + imports.add(baseClassCore); + + // Add existing imports if provided + if (existingImports) { + existingImports.forEach(imp => imports.add(imp)); + } + + // Analyze the class body to determine additional needed imports + const bodyImports = this.extractImportsFromClassBody(classBody); + bodyImports.forEach(imp => imports.add(imp)); + + // Add import statement + const sortedImports = Array.from(imports).sort(); + lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); + lines.push(''); + + // Add model decorator + if (dataSource) { + lines.push(`@Model({`); + lines.push(`\tdataSource: ${dataSource}`); + lines.push(`})`); + } else { + lines.push(`@Model()`); + } + + // Add class declaration (export only if not a component model) + const exportKeyword = isComponent ? "" : "export "; + lines.push(`${exportKeyword}class ${modelName} extends ${baseClass} {`); + + // Add class body (if not empty) + if (classBody.trim()) { + lines.push(''); + lines.push(classBody); + lines.push(''); + } + + lines.push(`}`); + + return lines.join("\n"); + } + + /** + * Analyzes class body content to determine which imports are needed. + * + * @param classBody - The class body content to analyze + * @returns Set of import names that should be included + */ + private extractImportsFromClassBody(classBody: string): Set { + const imports = new Set(); + + // Look for decorator patterns + const decoratorPatterns = [ + /@Text\b/g, /@LongText\b/g, /@Email\b/g, /@Html\b/g, + /@Integer\b/g, /@Money\b/g, /@Number\b/g, /@Boolean\b/g, + /@Date\b/g, /@DateRange\b/g, /@Choice\b/g, + /@Reference\b/g, /@Composition\b/g, /@Relationship\b/g + ]; + + const decoratorNames = [ + "Text", "LongText", "Email", "Html", + "Integer", "Money", "Number", "Boolean", + "Date", "DateRange", "Choice", + "Reference", "Composition", "Relationship" + ]; + + decoratorPatterns.forEach((pattern, index) => { + if (pattern.test(classBody)) { + imports.add(decoratorNames[index]); + } + }); + + // Always include Field if there are any field declarations + if (classBody.includes("!:") || classBody.includes(":")) { + imports.add("Field"); + } + + return imports; + } + + /** + * Extracts all model imports from a document (excluding slingr-framework imports). + * + * @param document - The document to extract imports from + * @returns Array of import statements for other models + */ + public extractModelImports(document: vscode.TextDocument): string[] { + const content = document.getText(); + const lines = content.split("\n"); + const modelImports: string[] = []; + + for (const line of lines) { + // Look for import statements that are not from slingr-framework + if (line.includes("import") && + line.includes("from") && + !line.includes("slingr-framework") && + !line.includes("vscode") && + !line.includes("path") && + line.trim().startsWith("import")) { + modelImports.push(line); + } + } + + return modelImports; + } + /** * Focuses on an element in a document navigating to it and highlighting it. * This method can find and focus on various types of elements including: From 95630b1d7e414a45e132712ef66d75380074659a Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 15 Sep 2025 15:05:36 -0300 Subject: [PATCH 16/36] Fix: now the changeCompositionToReference command removes the model from the source file correctly. --- .../fields/changeCompositionToReference.ts | 244 +++++++++++++++--- src/services/sourceCodeService.ts | 221 ++++++++++++++++ 2 files changed, 425 insertions(+), 40 deletions(-) diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts index 9246eed..abc2e3e 100644 --- a/src/commands/fields/changeCompositionToReference.ts +++ b/src/commands/fields/changeCompositionToReference.ts @@ -69,21 +69,22 @@ export class ChangeCompositionToReferenceTool { // Step 4: Generate and create the independent model using existing tools const modelFileUri = await this.generateAndCreateIndependentModel(componentModel, sourceModel, targetFilePath, cache); - // Step 6: Remove the composition field from the source model - await this.removeCompositionField(document, compositionField, cache); + // Step 5: Extract related enums before removing the component model + const relatedEnums = await this.sourceCodeService.extractRelatedEnums(document, componentModel, + this.sourceCodeService.extractClassBody(document, componentModel.name)); - // Step 7: Remove the component model from the source file - await this.removeComponentModel(document, componentModel, sourceModel, cache); + // Step 6-8: Remove field, model, and enums in a single workspace edit to avoid coordinate issues + await this.removeFieldModelAndEnums(document, compositionField, componentModel, relatedEnums, cache); - // Step 8: Add the reference field to the source model + // Step 9: Add the reference field to the source model await this.addReferenceField(document, sourceModel.name, fieldName, componentModel.name, compositionField.type.endsWith('[]'), cache); - // Step 9: Add import for the new model in the source file + // Step 10: Add import for the new model in the source file const importEdit = new vscode.WorkspaceEdit(); await this.sourceCodeService.addModelImport(document, componentModel.name, importEdit, cache); await vscode.workspace.applyEdit(importEdit); - // Step 10: Focus on the newly modified field + // Step 11: Focus on the newly modified field await this.sourceCodeService.focusOnElement(document, fieldName); // Step 11: Show success message @@ -169,7 +170,7 @@ export class ChangeCompositionToReferenceTool { */ private async determineTargetFilePath(sourceModel: DecoratedClass, componentModelName: string): Promise { const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); - const fileName = `${componentModelName.toLowerCase()}.ts`; + const fileName = `${componentModelName}.ts`; return path.join(sourceDir, fileName); } @@ -188,17 +189,20 @@ export class ChangeCompositionToReferenceTool { // Step 2: Extract the complete class body from the component model const classBody = this.sourceCodeService.extractClassBody(sourceDocument, componentModel.name); - // Step 3: Get datasource from source model + // Step 3: Extract related enums from the source file (for Choice fields) + const relatedEnums = await this.sourceCodeService.extractRelatedEnums(sourceDocument, componentModel, classBody); + + // Step 4: Get datasource from source model const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; - // Step 4: Extract existing model imports from the source file + // Step 5: Extract existing model imports from the source file const existingImports = this.sourceCodeService.extractModelImports(sourceDocument); - // Step 5: Convert the class body for independent model use + // Step 6: Convert the class body for independent model use const convertedClassBody = this.convertComponentClassBody(classBody); - // Step 6: Generate the complete model file content + // Step 7: Generate the complete model file content const modelFileContent = this.sourceCodeService.generateModelFileContent( componentModel.name, convertedClassBody, @@ -208,12 +212,15 @@ export class ChangeCompositionToReferenceTool { false // This is a standalone model (with export) ); - // Step 7: Create the new model file + // Step 8: Add related enums to the file content + const finalFileContent = this.sourceCodeService.addEnumsToFileContent(modelFileContent, relatedEnums); + + // Step 9: Create the new model file const modelFileUri = vscode.Uri.file(targetFilePath); const encoder = new TextEncoder(); - await vscode.workspace.fs.writeFile(modelFileUri, encoder.encode(modelFileContent)); + await vscode.workspace.fs.writeFile(modelFileUri, encoder.encode(finalFileContent)); - // Step 8: Add model imports to the new file if needed + // Step 10: Add model imports to the new file if needed if (existingImports.length > 0) { await this.addModelImportsToNewFile(modelFileUri, existingImports); } @@ -274,58 +281,215 @@ export class ChangeCompositionToReferenceTool { } /** - * Removes the @Composition and @Field decorators from the field. + * Removes the composition field, component model, and unused enums in a single workspace edit + * to avoid coordinate invalidation issues. */ - private async removeCompositionField(document: vscode.TextDocument, field: PropertyMetadata, cache: MetadataCache): Promise { - // Find the model name that contains this field + private async removeFieldModelAndEnums( + document: vscode.TextDocument, + compositionField: PropertyMetadata, + componentModel: DecoratedClass, + relatedEnums: string[], + cache: MetadataCache + ): Promise { + const workspaceEdit = new vscode.WorkspaceEdit(); + + // Step 1: Get field deletion range (using DeleteFieldTool logic) const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); let modelName = 'Unknown'; if (fileMetadata) { for (const [className, classData] of Object.entries(fileMetadata.classes)) { - // Type assertion since we know the structure from cache const classInfo = classData as DecoratedClass; - if (classInfo.properties[field.name] === field) { + if (classInfo.properties[compositionField.name] === compositionField) { modelName = className; break; } } } - // Use the DeleteFieldTool to programmatically remove the field - const workspaceEdit = await this.deleteFieldTool.deleteFieldProgrammatically( - field, + // Get field deletion edit without applying it + const fieldDeletionEdit = await this.deleteFieldTool.deleteFieldProgrammatically( + compositionField, modelName, cache ); - // Apply the workspace edit + // Step 2: Get model deletion range + await this.sourceCodeService.deleteModelClassFromFile(document.uri, componentModel, workspaceEdit); + + // Step 3: Get enum deletion ranges + await this.addEnumDeletionsToWorkspaceEdit(document, relatedEnums, workspaceEdit, componentModel); + + // Step 4: Merge field deletion edits into the main workspace edit + this.mergeWorkspaceEdits(fieldDeletionEdit, workspaceEdit); + + // Step 5: Apply all deletions in a single operation await vscode.workspace.applyEdit(workspaceEdit); } /** - * Removes the component model from the source file. + * Adds enum deletion ranges to the workspace edit if the enums are no longer used. */ - private async removeComponentModel( + private async addEnumDeletionsToWorkspaceEdit( document: vscode.TextDocument, - componentModel: DecoratedClass, - sourceModel: DecoratedClass, - cache: MetadataCache + extractedEnums: string[], + workspaceEdit: vscode.WorkspaceEdit, + componentModel: DecoratedClass ): Promise { - // Get the text range for the component model - const modelRange = componentModel.declaration.range; + if (extractedEnums.length === 0) { + return; + } - // Extend the range to include any preceding decorators and following whitespace - const extendedRange = new vscode.Range( - new vscode.Position(Math.max(0, modelRange.start.line - 5), 0), // Include decorators - new vscode.Position(modelRange.end.line + 2, 0) // Include trailing whitespace - ); + const sourceContent = document.getText(); + + // Extract enum names from the enum definitions + const enumNames = extractedEnums.map(enumDef => { + const match = enumDef.match(/enum\s+(\w+)/); + return match ? match[1] : null; + }).filter(name => name !== null) as string[]; + + console.log(`Found ${enumNames.length} enums to check for deletion: ${enumNames.join(', ')}`); + + // Check each enum to see if it's still used in the source file + for (const enumName of enumNames) { + if (!this.isEnumStillUsedInFile(sourceContent, enumName, extractedEnums, componentModel)) { + console.log(`Enum "${enumName}" is not used anymore, scheduling for deletion`); + await this.addEnumDeletionToWorkspaceEdit(document, enumName, workspaceEdit); + } else { + console.log(`Enum "${enumName}" is still used, keeping it in source file`); + } + } + } - // Create workspace edit to remove the component model - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.delete(document.uri, extendedRange); + /** + * Checks if an enum is still referenced in the source file (excluding the extracted enums and component model). + */ + private isEnumStillUsedInFile(sourceContent: string, enumName: string, extractedEnums: string[], componentModel: DecoratedClass): boolean { + // Create a version of the source content without the extracted enums + let contentWithoutExtractedEnums = sourceContent; + for (const enumDef of extractedEnums) { + contentWithoutExtractedEnums = contentWithoutExtractedEnums.replace(enumDef, ''); + } - await vscode.workspace.applyEdit(workspaceEdit); + // Also remove the component model class from the content since we're extracting it + // This prevents false positives where the enum is only used in the component model + try { + const lines = contentWithoutExtractedEnums.split('\n'); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, componentModel.name); + + // Remove the component model class from the content + const linesWithoutComponentModel = [ + ...lines.slice(0, classStartLine), + ...lines.slice(classEndLine + 1) + ]; + contentWithoutExtractedEnums = linesWithoutComponentModel.join('\n'); + } catch (error) { + console.warn(`Could not remove component model "${componentModel.name}" from content for enum usage check:`, error); + } + + // Look for references to the enum name in the remaining content + const enumRefRegex = new RegExp(`\\b${enumName}\\b`, 'g'); + const matches = contentWithoutExtractedEnums.match(enumRefRegex); + + console.log(`Checking if enum "${enumName}" is still used: found ${matches ? matches.length : 0} references`); + + // If there are matches, the enum is still used + return matches !== null && matches.length > 0; + } + + /** + * Adds an enum deletion range to the workspace edit. + */ + private async addEnumDeletionToWorkspaceEdit( + document: vscode.TextDocument, + enumName: string, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + const sourceContent = document.getText(); + const lines = sourceContent.split('\n'); + + // Find the enum definition with better pattern matching + let enumStartLine = -1; + let enumEndLine = -1; + + // Look for the enum declaration line + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Match: "enum EnumName {" or "export enum EnumName {" + const enumMatch = line.match(new RegExp(`^(export\\s+)?enum\\s+${enumName}\\s*\\{`)); + if (enumMatch) { + enumStartLine = i; + + // Look for any preceding comments or empty lines that belong to this enum + for (let j = i - 1; j >= 0; j--) { + const prevLine = lines[j].trim(); + if (prevLine === '' || prevLine.startsWith('//') || prevLine.startsWith('/*') || prevLine.endsWith('*/')) { + enumStartLine = j; + } else { + break; + } + } + + // Find the closing brace + let braceCount = 0; + let foundOpenBrace = false; + + for (let j = i; j < lines.length; j++) { + const currentLine = lines[j]; + + for (const char of currentLine) { + if (char === '{') { + braceCount++; + foundOpenBrace = true; + } else if (char === '}') { + braceCount--; + if (foundOpenBrace && braceCount === 0) { + enumEndLine = j; + break; + } + } + } + + if (foundOpenBrace && braceCount === 0) { + break; + } + } + + break; // Found the enum, stop searching + } + } + + if (enumStartLine !== -1 && enumEndLine !== -1) { + // Include any trailing empty lines that belong to this enum + /* while (enumEndLine + 1 < lines.length && lines[enumEndLine + 1].trim() === '') { + enumEndLine++; + } */ + + // Create the range to delete (include the newline of the last line) + const rangeToDelete = new vscode.Range( + new vscode.Position(enumStartLine, 0), + new vscode.Position(enumEndLine + 1, 0) + ); + + workspaceEdit.delete(document.uri, rangeToDelete); + console.log(`Scheduled deletion of enum "${enumName}" from lines ${enumStartLine} to ${enumEndLine}`); + } else { + console.warn(`Could not find enum "${enumName}" for deletion`); + } + } + + /** + * Merges edits from one workspace edit into another. + */ + private mergeWorkspaceEdits(sourceEdit: vscode.WorkspaceEdit, targetEdit: vscode.WorkspaceEdit): void { + sourceEdit.entries().forEach(([uri, edits]) => { + edits.forEach(edit => { + if (edit instanceof vscode.TextEdit) { + targetEdit.replace(uri, edit.range, edit.newText); + } + }); + }); } /** diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 76cf250..7976a76 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -637,4 +637,225 @@ export class SourceCodeService { await vscode.window.showTextDocument(document, { preview: false }); } } + + /** + * Deletes a specific model class from a file that contains multiple models. + * + * @param fileUri - The URI of the file containing the model + * @param modelMetadata - The metadata of the model to delete + * @param workspaceEdit - The workspace edit to add the deletion to + */ + public async deleteModelClassFromFile( + fileUri: vscode.Uri, + modelMetadata: any, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const text = document.getText(); + const lines = text.split('\n'); + + // Find the class declaration range + const classDeclaration = modelMetadata.declaration; + const startLine = classDeclaration.range.start.line; + const endLine = classDeclaration.range.end.line; + + // Find the @Model decorator using cache information + let actualStartLine = startLine; + + // Check if the model has decorators in the cache + if (modelMetadata.decorators && modelMetadata.decorators.length > 0) { + // Find the @Model decorator specifically + const modelDecorator = modelMetadata.decorators.find((d: any) => d.name === "Model"); + if (modelDecorator && modelDecorator.position) { + // Use the decorator's range from cache for precise deletion + actualStartLine = Math.min(actualStartLine, modelDecorator.position.start.line); + } + } + + // Also look backwards to find any other decorators and comments that belong to this class + for (let i = actualStartLine - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.endsWith('*/')) { + // Empty lines, single-line comments, or comment blocks - continue looking + actualStartLine = i; + } else if (line.startsWith('@')) { + // Decorator - include it + actualStartLine = i; + } else { + // Found non-empty, non-comment, non-decorator line - stop here + break; + } + } + + // Look forward to find the complete class body (including closing brace) + let actualEndLine = endLine; + let braceCount = 0; + let foundOpenBrace = false; + + for (let i = startLine; i < lines.length; i++) { + const line = lines[i]; + + for (const char of line) { + if (char === '{') { + braceCount++; + foundOpenBrace = true; + } else if (char === '}') { + braceCount--; + if (foundOpenBrace && braceCount === 0) { + actualEndLine = i; + break; + } + } + } + + if (foundOpenBrace && braceCount === 0) { + break; + } + } + + // Include any trailing empty lines that belong to this class + while (actualEndLine + 1 < lines.length && lines[actualEndLine + 1].trim() === '') { + actualEndLine++; + } + + // Create the range to delete (include the newline of the last line) + const rangeToDelete = new vscode.Range( + new vscode.Position(actualStartLine, 0), + new vscode.Position(actualEndLine + 1, 0) + ); + + workspaceEdit.delete(fileUri, rangeToDelete); + + } catch (error) { + console.error(`Error deleting model class from file ${fileUri.fsPath}:`, error); + // Fallback: just comment out the class declaration + workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`); + } + } + + /** + * Extracts enums that are related to a model's Choice fields. + * This analyzes the model's properties and identifies any enums + * that are referenced in @Choice decorators. + * + * @param sourceDocument - The document containing the model + * @param componentModel - The model metadata to analyze + * @param classBody - The class body content (optional optimization) + * @returns Array of enum definition strings + */ + public async extractRelatedEnums( + sourceDocument: vscode.TextDocument, + componentModel: any, + classBody?: string + ): Promise { + const relatedEnums: string[] = []; + const sourceContent = sourceDocument.getText(); + + // Find all Choice fields in the component model + const choiceFields = Object.values(componentModel.properties || {}).filter((property: any) => + property.decorators?.some((decorator: any) => decorator.name === "Choice") + ); + + if (choiceFields.length === 0) { + return relatedEnums; + } + + // For each Choice field, try to find referenced enums + for (const field of choiceFields) { + const choiceDecorator = (field as any).decorators?.find((d: any) => d.name === "Choice"); + if (choiceDecorator) { + // Look for enum references in the property type declaration + const enumNames = this.extractEnumNamesFromChoiceProperty(field); + + for (const enumName of enumNames) { + // Find the enum definition in the source file + const enumDefinition = this.extractEnumDefinition(sourceContent, enumName); + if (enumDefinition && !relatedEnums.includes(enumDefinition)) { + relatedEnums.push(enumDefinition); + } + } + } + } + + return relatedEnums; + } + + /** + * Extracts enum names from a Choice field's property type. + * For Choice fields, the enum is specified in the property type declaration, not the decorator. + * Example: @Choice() status: TaskStatus = TaskStatus.Active; + */ + private extractEnumNamesFromChoiceProperty(property: any): string[] { + const enumNames: string[] = []; + + // The enum name is in the property's type field + if (property.type && typeof property.type === 'string') { + // Remove array brackets if present (e.g., "TaskStatus[]" -> "TaskStatus") + const cleanType = property.type.replace(/\[\]$/, ''); + + // Check if this looks like an enum (starts with uppercase, follows enum naming conventions) + // Also exclude common TypeScript types that aren't enums + const isCommonType = ['string', 'number', 'boolean', 'Date', 'any', 'object', 'void'].includes(cleanType); + const enumMatch = cleanType.match(/^[A-Z][a-zA-Z0-9_]*$/); + + if (enumMatch && !isCommonType) { + enumNames.push(cleanType); + console.log(`Found potential enum "${cleanType}" in Choice field "${property.name}"`); + } + } + + return enumNames; + } + + /** + * Extracts the complete enum definition from source content. + */ + private extractEnumDefinition(sourceContent: string, enumName: string): string | null { + // Create regex to match enum definition including export keyword + const enumRegex = new RegExp( + `(export\\s+)?enum\\s+${enumName}\\s*\\{[^}]*\\}`, + 'gs' + ); + + const match = enumRegex.exec(sourceContent); + if (match) { + return match[0]; + } + + return null; + } + + /** + * Adds enum definitions to the model file content. + */ + public addEnumsToFileContent(modelFileContent: string, enums: string[]): string { + if (enums.length === 0) { + return modelFileContent; + } + + const lines = modelFileContent.split('\n'); + + // Find the position to insert enums (after imports, before the model class) + let insertPosition = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('import ')) { + insertPosition = i + 1; + } else if (lines[i].trim() === '' && insertPosition > 0) { + // Found empty line after imports + insertPosition = i; + break; + } else if (lines[i].includes('@Model') || lines[i].includes('class ')) { + // Found the start of the model definition + break; + } + } + + // Insert enums with proper spacing + const enumContent = enums.join('\n\n') + '\n\n'; + lines.splice(insertPosition, 0, enumContent); + + return lines.join('\n'); + } + } From 502de3f2130124c6ae303a03cdf25d509f4ebb35 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 17 Sep 2025 08:46:01 -0300 Subject: [PATCH 17/36] Added support for selecting and reordering multiple fields in the explorer. --- src/explorer/explorerProvider.ts | 226 ++++++++++++++++++++++----- src/explorer/explorerRegistration.ts | 16 +- 2 files changed, 194 insertions(+), 48 deletions(-) diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index 2ba31fb..61fa292 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -47,14 +47,56 @@ export class ExplorerProvider dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken ): void | Thenable { + // Support multi-drag for fields only if (source.length > 1) { - // Multi-drag is not supported for reordering + // Check if all selected items are fields from the same model + const firstItem = source[0]; + if (firstItem.itemType !== "field" && firstItem.itemType !== "referenceField") { + return; // Multi-drag only supported for fields + } + + // Verify all items are fields from the same model + const modelFilePath = firstItem.parent?.metadata?.declaration.uri.fsPath; + const modelClassName = firstItem.parent?.metadata?.name; + + if (!modelFilePath || !modelClassName) { + return; + } + + const allFieldsFromSameModel = source.every(item => + (item.itemType === "field" || item.itemType === "referenceField") && + item.parent?.metadata?.declaration.uri.fsPath === modelFilePath && + item.parent?.metadata?.name === modelClassName + ); + + if (!allFieldsFromSameModel) { + return; // All fields must be from the same model + } + + // Create multi-field drag data + const fieldNames = source + .filter(item => item.metadata && "name" in item.metadata) + .map(item => (item.metadata as any).name); + + if (fieldNames.length > 0) { + dataTransfer.set( + FIELD_MIME_TYPE, + new vscode.DataTransferItem({ + fields: fieldNames, // Multiple fields + modelPath: modelFilePath, + modelClassName: modelClassName, + isMultiField: true + }) + ); + } return; } + + // Single item drag (existing logic) const draggedItem = source[0]; // We can drag fields, models, or folders - if (draggedItem.itemType === "field" && draggedItem.metadata && "name" in draggedItem.metadata) { + if ((draggedItem.itemType === "field" || draggedItem.itemType === "referenceField") && draggedItem.metadata && "name" in draggedItem.metadata) { // The parent of a field item is the 'modelFieldsFolder', which holds the model's metadata const modelFilePath = draggedItem.parent?.metadata?.declaration.uri.fsPath; const modelClassName = draggedItem.parent?.metadata?.name; @@ -174,6 +216,10 @@ export class ExplorerProvider private async handleFieldDrop(target: AppTreeItem | undefined, transferItem: vscode.DataTransferItem): Promise { const draggedData = transferItem.value; + // Check if this is a multi-field operation + const isMultiField = draggedData.isMultiField && draggedData.fields; + const fieldNames = isMultiField ? draggedData.fields : [draggedData.field]; + // Check if someone is trying to drop a composition model into a folder or data root if (target && (target.itemType === "folder" || target.itemType === "dataRoot" || target.itemType === "model")) { vscode.window.showWarningMessage( @@ -186,8 +232,8 @@ export class ExplorerProvider let targetFieldName: string | null = null; let targetModelPath: string | undefined = undefined; - if (target && target.itemType === "field" && target.metadata && "name" in target.metadata) { - // Dropping onto a regular field + if (target && (target.itemType === "field" || target.itemType === "referenceField") && target.metadata && "name" in target.metadata) { + // Dropping onto a regular field or reference field targetFieldName = target.metadata.name; targetModelPath = target.parent?.metadata?.declaration.uri.fsPath; } else if (target && target.itemType === "model" && target.parent && target.parent.itemType === "model") { @@ -215,59 +261,64 @@ export class ExplorerProvider } if (!target || !targetFieldName || !targetModelPath) { - vscode.window.showWarningMessage("A field can only be dropped onto another field or composition model."); + const fieldWord = isMultiField ? "Fields" : "A field"; + const verbWord = isMultiField ? "can" : "can"; + vscode.window.showWarningMessage(`${fieldWord} ${verbWord} only be dropped onto another field or composition model.`); return; } // Validate the drop operation if (draggedData.modelPath !== targetModelPath) { - vscode.window.showWarningMessage("Fields can only be reordered within the same model."); + const fieldWord = isMultiField ? "Fields" : "Fields"; + vscode.window.showWarningMessage(`${fieldWord} can only be reordered within the same model.`); return; } - if (draggedData.field === targetFieldName) { - return; // Dropped on itself + if (fieldNames.includes(targetFieldName)) { + return; // Dropped on one of the dragged fields } - // Perform the reordering - try { - // 1. Get the new text from ts-morph *without saving*. - const newText = await this.reorderFieldsAndGetText( - draggedData.modelPath, - draggedData.modelClassName, - draggedData.field, - targetFieldName - ); - - if (newText === null) { - vscode.window.showErrorMessage("Failed to reorder fields."); - return; - } - - // 2. Apply the changes to the editor and format. - const uri = vscode.Uri.file(draggedData.modelPath); - const document = await vscode.workspace.openTextDocument(uri); - const editor = await vscode.window.showTextDocument(document); + // For multi-field operations, we need to reorder multiple fields + if (isMultiField) { + try { + // Reorder multiple fields + const newText = await this.reorderMultipleFieldsAndGetText( + draggedData.modelPath, + draggedData.modelClassName, + fieldNames, + targetFieldName + ); - // Replace the entire document content with the new text. - await editor.edit((editBuilder) => { - const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)); - editBuilder.replace(fullRange, newText); - }); + if (newText === null) { + vscode.window.showErrorMessage("Failed to reorder fields."); + return; + } - // Execute the format command on the now-dirty file. - await vscode.commands.executeCommand("editor.action.formatDocument"); + await this.applyTextChanges(draggedData.modelPath, newText); + } catch (error: any) { + console.error("Error reordering multiple fields:", error); + vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + } + } else { + // Single field reordering (existing logic) + try { + const newText = await this.reorderFieldsAndGetText( + draggedData.modelPath, + draggedData.modelClassName, + fieldNames[0], + targetFieldName + ); - // 3. Save the document a single time. - await document.save(); + if (newText === null) { + vscode.window.showErrorMessage("Failed to reorder fields."); + return; + } - // 4. Refresh the tree. The cache will update from the single save event. - setTimeout(() => { - this.refresh(); - }, 200); - } catch (error: any) { - console.error("Error reordering fields:", error); - vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + await this.applyTextChanges(draggedData.modelPath, newText); + } catch (error: any) { + console.error("Error reordering fields:", error); + vscode.window.showErrorMessage(`An error occurred: ${error.message}`); + } } } @@ -463,6 +514,95 @@ export class ExplorerProvider return sourceFile.getFullText(); } + /** + * Reorders multiple fields in the model class file and returns the updated text. + * @param modelPath The path to the model class file. + * @param modelClassName The name of the model class to modify. + * @param sourceFieldNames Array of field names to move. + * @param targetFieldName The name of the field to move before. + * @returns The updated source code as a string, or null if an error occurs. + */ + private async reorderMultipleFieldsAndGetText( + modelPath: string, + modelClassName: string, + sourceFieldNames: string[], + targetFieldName: string + ): Promise { + const project = new Project(); + const sourceFile = project.addSourceFileAtPath(modelPath); + + // Find the specific class by name to handle multiple classes in the same file + const classDeclaration = sourceFile.getClass(modelClassName); + + if (!classDeclaration) { + console.error(`Class ${modelClassName} not found in ${modelPath}`); + return null; + } + + const targetProperty = classDeclaration.getProperty(targetFieldName); + if (!targetProperty) { + console.error(`Could not find target property ${targetFieldName} in ${classDeclaration.getName()}`); + return null; + } + + // Get all source properties and their structures + const sourceProperties: { property: any; structure: any }[] = []; + for (const fieldName of sourceFieldNames) { + const property = classDeclaration.getProperty(fieldName); + if (!property) { + console.error(`Could not find source property ${fieldName} in ${classDeclaration.getName()}`); + return null; + } + sourceProperties.push({ + property, + structure: property.getStructure() + }); + } + + const targetIndex = targetProperty.getChildIndex(); + + // Remove all source properties (in reverse order to maintain indices) + for (let i = sourceProperties.length - 1; i >= 0; i--) { + sourceProperties[i].property.remove(); + } + + // Insert all properties at the target position (in original order) + for (let i = 0; i < sourceProperties.length; i++) { + classDeclaration.insertProperty(targetIndex + i, sourceProperties[i].structure); + } + + return sourceFile.getFullText(); + } + + /** + * Applies text changes to a file using VS Code's editor API + * @param filePath The path to the file to modify + * @param newText The new text content + */ + private async applyTextChanges(filePath: string, newText: string): Promise { + // 2. Apply the changes to the editor and format. + const uri = vscode.Uri.file(filePath); + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document); + + // Replace the entire document content with the new text. + await editor.edit((editBuilder) => { + const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)); + editBuilder.replace(fullRange, newText); + }); + + // Execute the format command on the now-dirty file. + await vscode.commands.executeCommand("editor.action.formatDocument"); + + // 3. Save the document a single time. + await document.save(); + + // 4. Refresh the tree. The cache will update from the single save event. + setTimeout(() => { + this.refresh(); + }, 200); + } + /** * Returns the children of the given element in the tree. * If no element is provided, it returns the root items (Model and UI). diff --git a/src/explorer/explorerRegistration.ts b/src/explorer/explorerRegistration.ts index e07e2bc..3e48b1f 100644 --- a/src/explorer/explorerRegistration.ts +++ b/src/explorer/explorerRegistration.ts @@ -15,7 +15,8 @@ export function registerExplorer( const treeView = vscode.window.createTreeView('slingrExplorer', { treeDataProvider: explorerProvider, dragAndDropController: explorerProvider, - showCollapseAll: true + showCollapseAll: true, + canSelectMany: true }); // Double-click tracking variables @@ -53,10 +54,15 @@ export function registerExplorer( // Handle selection changes (for keyboard navigation and other selection events) const selectionDisposable = treeView.onDidChangeSelection(e => { - const selectedItem = e.selection?.[0] as AppTreeItem; - if (selectedItem) { - // Update info panel for keyboard navigation - quickInfoProvider.update(selectedItem.itemType, selectedItem.metadata); + const selectedItems = e.selection as AppTreeItem[]; + if (selectedItems && selectedItems.length > 0) { + // For single selection, update info panel + if (selectedItems.length === 1) { + quickInfoProvider.update(selectedItems[0].itemType, selectedItems[0].metadata); + } else { + // For multi-selection, clear the panel or show first item + quickInfoProvider.update('multipleSelection', undefined); + } } }); From 6be60da2b42a3a2f727bf345975344d9762d83ec Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 17 Sep 2025 10:31:41 -0300 Subject: [PATCH 18/36] Adds extractFields commands (to be adjusted). --- package.json | 56 +++++++ src/commands/commandHelpers.ts | 109 +++++++++++++ src/commands/commandRegistration.ts | 117 +++++++++++++- src/commands/fields/addField.ts | 63 ++++---- .../fields/extractFieldsToComposition.ts | 152 ++++++++++++++++++ .../fields/extractFieldsToEmbedded.ts | 100 ++++++++++++ src/commands/fields/extractFieldsToParent.ts | 114 +++++++++++++ .../fields/extractFieldsToReference.ts | 114 +++++++++++++ src/commands/interfaces.ts | 2 +- src/commands/models/addComposition.ts | 43 +++-- src/commands/models/newModel.ts | 2 + src/services/projectAnalysisService.ts | 5 +- 12 files changed, 828 insertions(+), 49 deletions(-) create mode 100644 src/commands/fields/extractFieldsToComposition.ts create mode 100644 src/commands/fields/extractFieldsToEmbedded.ts create mode 100644 src/commands/fields/extractFieldsToParent.ts create mode 100644 src/commands/fields/extractFieldsToReference.ts diff --git a/package.json b/package.json index f476b86..396e700 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,22 @@ "command": "slingr-vscode-extension.modifyModel", "title": "Modify Model" }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "title": "Extract Fields to Composition" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "title": "Extract Fields to Embedded" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "title": "Extract Fields to Parent" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "title": "Extract Fields to Reference" + }, { "command": "slingr.runInfraUpdate", "title": "Run Infrastructure Update" @@ -209,6 +225,26 @@ "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "3_modification" }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, { "command": "slingr-vscode-extension.changeReferenceToComposition", "when": "view == slingrExplorer && viewItem == 'referenceField'", @@ -322,6 +358,26 @@ "command": "slingr-vscode-extension.changeFieldType", "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", "group": "3_modification" + }, + { + "command": "slingr-vscode-extension.extractFieldsToComposition", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToEmbedded", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToParent", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" + }, + { + "command": "slingr-vscode-extension.extractFieldsToReference", + "when": "view == slingrExplorer && (viewItem == 'field' || viewItem == 'referenceField')", + "group": "4_extract" } ] }, diff --git a/src/commands/commandHelpers.ts b/src/commands/commandHelpers.ts index 22915b3..d05c16b 100644 --- a/src/commands/commandHelpers.ts +++ b/src/commands/commandHelpers.ts @@ -1,6 +1,22 @@ import * as vscode from 'vscode'; import { AppTreeItem } from '../explorer/appTreeItem'; +/** + * Interface for tree view multi-selection context + */ +export interface TreeViewContext { + /** The tree item that was clicked */ + clickedItem: AppTreeItem; + /** All selected tree items */ + selectedItems: AppTreeItem[]; + /** Filtered field items from the selection */ + fieldItems: AppTreeItem[]; + /** Model name from the field items */ + modelName: string; + /** Model file path */ + modelPath: string; +} + /** * Interface for URI resolution options */ @@ -46,6 +62,99 @@ const DEFAULT_URI_OPTIONS: UriResolutionOptions = { notModelErrorMessage: 'The selected file does not appear to be a model file.' }; +/** + * Detects if command arguments represent a tree view multi-selection context + */ +export function isTreeViewContext(firstArg?: any, secondArg?: any): boolean { + return firstArg && + typeof firstArg === 'object' && + 'itemType' in firstArg && + secondArg && + Array.isArray(secondArg); +} + +/** + * Validates and extracts tree view context from command arguments + */ +export function validateTreeViewContext(firstArg: any, secondArg: any): TreeViewContext { + if (!isTreeViewContext(firstArg, secondArg)) { + throw new Error('Invalid tree view context arguments'); + } + + const clickedItem = firstArg as AppTreeItem; + const selectedItems = secondArg as AppTreeItem[]; + + // Filter for field items with valid parent metadata + const fieldItems = selectedItems.filter(item => + (item.itemType === "field" || item.itemType === "referenceField") && + item.parent?.metadata && 'name' in item.parent.metadata + ); + + if (fieldItems.length === 0) { + throw new Error("Please select one or more fields to extract."); + } + + const modelName = fieldItems[0].parent!.metadata!.name; + const modelPath = fieldItems[0].parent!.metadata!.declaration.uri.fsPath; + + // Verify all fields are from the same model + const allFromSameModel = fieldItems.every(item => + item.parent?.metadata?.name === modelName + ); + + if (!allFromSameModel) { + throw new Error("All selected fields must be from the same model."); + } + + return { + clickedItem, + selectedItems, + fieldItems, + modelName, + modelPath + }; +} + +/** + * Creates a command handler that supports both tree view context and standard URI resolution + */ +export function createTreeViewAwareCommandHandler( + treeViewHandler: (context: TreeViewContext) => Promise, + standardHandler: (result: UriResolutionResult) => Promise, + uriOptions?: UriResolutionOptions +) { + return async (firstArg?: any, secondArg?: any) => { + try { + if (isTreeViewContext(firstArg, secondArg)) { + // Handle tree view multi-selection context + const context = validateTreeViewContext(firstArg, secondArg); + await treeViewHandler(context); + } else { + // Handle standard URI resolution context + const result = await resolveTargetUri(firstArg, uriOptions); + await standardHandler(result); + } + } catch (error) { + vscode.window.showErrorMessage(`${error}`); + } + }; +} + +/** + * Registers a command that supports both tree view context and standard URI resolution + */ +export function registerTreeViewAwareCommand( + disposables: vscode.Disposable[], + commandId: string, + treeViewHandler: (context: TreeViewContext) => Promise, + standardHandler: (result: UriResolutionResult) => Promise, + uriOptions?: UriResolutionOptions +): void { + const handler = createTreeViewAwareCommandHandler(treeViewHandler, standardHandler, uriOptions); + const command = vscode.commands.registerCommand(commandId, handler); + disposables.push(command); +} + /** * Resolves a URI from various input sources with validation */ diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index 167aacf..9a9bfe6 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -15,7 +15,11 @@ import { AddCompositionTool } from './models/addComposition'; import { AddReferenceTool } from './models/addReference'; import { AIService } from '../services/aiService'; import { ProjectAnalysisService } from '../services/projectAnalysisService'; -import { registerCommand, URI_OPTIONS, UriResolutionResult } from './commandHelpers'; +import { registerCommand, URI_OPTIONS, UriResolutionResult, resolveTargetUri, registerTreeViewAwareCommand, TreeViewContext } from './commandHelpers'; +import { ExtractFieldsToCompositionTool } from './fields/extractFieldsToComposition'; +import { ExtractFieldsToReferenceTool } from './fields/extractFieldsToReference'; +import { ExtractFieldsToEmbeddedTool } from './fields/extractFieldsToEmbedded'; +import { ExtractFieldsToParentTool } from './fields/extractFieldsToParent'; export function registerGeneralCommands( context: vscode.ExtensionContext, @@ -67,9 +71,10 @@ export function registerGeneralCommands( disposables, 'slingr-vscode-extension.defineFields', async (result: UriResolutionResult) => { - const document = result.document || await vscode.workspace.openTextDocument(result.targetUri); - - const model = await projectAnalysisService.findModelClass(document, cache); + if(!result.modelName) { + throw new Error('Model name could not be determined.'); + } + const model = cache.getModelByName(result.modelName); if (!model) { throw new Error('Could not identify a model class in the selected file.'); } @@ -101,13 +106,16 @@ export function registerGeneralCommands( disposables, 'slingr-vscode-extension.addField', async (result: UriResolutionResult) => { - await addFieldTool.addField(result.targetUri, cache); + if (!result.modelName) { + throw new Error('Model name could not be determined.'); + } + await addFieldTool.addField(result.targetUri, result.modelName, cache); }, URI_OPTIONS.MODEL_FILE ); // Add Composition Tool - const addCompositionTool = new AddCompositionTool(explorerProvider); + const addCompositionTool = new AddCompositionTool(); registerCommand( disposables, 'slingr-vscode-extension.addComposition', @@ -186,5 +194,102 @@ export function registerGeneralCommands( }); disposables.push(modifyModelCommand); + // Extract Fields to Composition + const extractFieldsToCompositionTool = new ExtractFieldsToCompositionTool(); + registerTreeViewAwareCommand( + disposables, + 'slingr-vscode-extension.extractFieldsToComposition', + async (context: TreeViewContext) => { + // Tree view context handler + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); + const editor = await vscode.window.showTextDocument(document); + await extractFieldsToCompositionTool.extractFieldsToComposition( + cache, + editor, + context.modelName, + { fieldItems: context.fieldItems } + ); + }, + async (result: UriResolutionResult) => { + // Standard URI resolution handler + const editor = vscode.window.activeTextEditor; + if (editor && result.modelName) { + await extractFieldsToCompositionTool.extractFieldsToComposition(cache, editor, result.modelName); + } else { + throw new Error('Could not determine model name or no active editor.'); + } + }, + URI_OPTIONS.MODEL_FILE + ); + + // Extract Fields to Reference + const extractFieldsToReferenceTool = new ExtractFieldsToReferenceTool(); + registerTreeViewAwareCommand( + disposables, + 'slingr-vscode-extension.extractFieldsToReference', + async (context: TreeViewContext) => { + // Tree view context handler + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); + const editor = await vscode.window.showTextDocument(document); + await extractFieldsToReferenceTool.extractFieldsToReference(cache, editor, context.modelName); + }, + async (result: UriResolutionResult) => { + // Standard URI resolution handler + const editor = vscode.window.activeTextEditor; + if (editor && result.modelName) { + await extractFieldsToReferenceTool.extractFieldsToReference(cache, editor, result.modelName); + } else { + throw new Error('Could not determine model name or no active editor.'); + } + }, + URI_OPTIONS.MODEL_FILE + ); + + // Extract Fields to Embedded Model + const extractFieldsToEmbeddedTool = new ExtractFieldsToEmbeddedTool(); + registerTreeViewAwareCommand( + disposables, + 'slingr-vscode-extension.extractFieldsToEmbedded', + async (context: TreeViewContext) => { + // Tree view context handler + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); + const editor = await vscode.window.showTextDocument(document); + await extractFieldsToEmbeddedTool.extractFieldsToEmbedded(cache, editor, context.modelName); + }, + async (result: UriResolutionResult) => { + // Standard URI resolution handler + const editor = vscode.window.activeTextEditor; + if (editor && result.modelName) { + await extractFieldsToEmbeddedTool.extractFieldsToEmbedded(cache, editor, result.modelName); + } else { + throw new Error('Could not determine model name or no active editor.'); + } + }, + URI_OPTIONS.MODEL_FILE + ); + + // Extract Fields to Parent Model + const extractFieldsToParentTool = new ExtractFieldsToParentTool(); + registerTreeViewAwareCommand( + disposables, + 'slingr-vscode-extension.extractFieldsToParent', + async (context: TreeViewContext) => { + // Tree view context handler + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); + const editor = await vscode.window.showTextDocument(document); + await extractFieldsToParentTool.extractFieldsToParent(cache, editor, context.modelName); + }, + async (result: UriResolutionResult) => { + // Standard URI resolution handler + const editor = vscode.window.activeTextEditor; + if (editor && result.modelName) { + await extractFieldsToParentTool.extractFieldsToParent(cache, editor, result.modelName); + } else { + throw new Error('Could not determine model name or no active editor.'); + } + }, + URI_OPTIONS.MODEL_FILE + ); + return disposables; } \ No newline at end of file diff --git a/src/commands/fields/addField.ts b/src/commands/fields/addField.ts index c6348bb..d581add 100644 --- a/src/commands/fields/addField.ts +++ b/src/commands/fields/addField.ts @@ -34,18 +34,18 @@ import { FileSystemService } from "../../services/fileSystemService"; * ``` */ export class AddFieldTool implements AIEnhancedTool { - private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; - private defineFieldsTool: DefineFieldsTool; + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private defineFieldsTool: DefineFieldsTool; constructor() { - this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); - this.defineFieldsTool = new DefineFieldsTool(); + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.defineFieldsTool = new DefineFieldsTool(); } /** @@ -59,25 +59,31 @@ export class AddFieldTool implements AIEnhancedTool { async processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, additionalContext?: any ): Promise { // The current addField method handles user interaction internally, // so we just call it with the provided parameters - await this.addField(targetUri, cache); + await this.addField(targetUri, modelName, cache); } /** * Adds a new field to an existing model file. * * @param targetUri - The URI of the model file where the field should be added + * @param modelName - The name of the model class to which the field will be added * @param cache - The metadata cache for context about existing models (optional) * @returns Promise that resolves when the field is added */ - public async addField(targetUri: vscode.Uri, cache?: MetadataCache): Promise { + public async addField(targetUri: vscode.Uri, modelName: string, cache?: MetadataCache): Promise { try { // Step 1: Validate target file - const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); + + if (!modelClass) { + throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); + } // Step 2: Get field information from user const fieldInfo = await this.gatherFieldInformation(modelClass, cache); @@ -92,7 +98,7 @@ export class AddFieldTool implements AIEnhancedTool { const fieldCode = this.generateFieldCode(fieldInfo); // Step 5: Insert field into model class - await this.sourceCodeService.insertField(document, modelClass.name,fieldInfo, fieldCode, cache); + await this.sourceCodeService.insertField(document, modelClass.name, fieldInfo, fieldCode, cache); // Step 5.5: If it's a Choice field, also create the enum if (fieldInfo.type.decorator === "Choice") { @@ -133,6 +139,7 @@ export class AddFieldTool implements AIEnhancedTool { * * @param targetUri - The URI of the model file where the field should be added * @param fieldInfo - Predefined field information + * @param modelName - The name of the model class to which the field will be added * @param cache - The metadata cache for context about existing models * @param silent - If true, suppresses success/error messages (defaults to false) * @returns Promise that resolves when the field is added @@ -140,28 +147,31 @@ export class AddFieldTool implements AIEnhancedTool { public async addFieldProgrammatically( targetUri: vscode.Uri, fieldInfo: FieldInfo, + modelName: string, cache: MetadataCache, silent: boolean = false ): Promise { try { // Step 1: Validate target file - const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, cache); + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); // Step 2: Check if field already exists - const existingFields = Object.keys(modelClass.properties || {}); - if (existingFields.includes(fieldInfo.name)) { - const message = `Field '${fieldInfo.name}' already exists in model ${modelClass.name}`; - if (!silent) { - vscode.window.showWarningMessage(message); + if (modelClass) { + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldInfo.name)) { + const message = `Field '${fieldInfo.name}' already exists in model ${modelClass.name}`; + if (!silent) { + vscode.window.showWarningMessage(message); + } + return; } - return; } // Step 3: Generate basic field structure const fieldCode = this.generateFieldCode(fieldInfo); // Step 4: Insert field into model class - await this.sourceCodeService.insertField(document, modelClass.name,fieldInfo, fieldCode, cache); + await this.sourceCodeService.insertField(document, modelName, fieldInfo, fieldCode, cache); // Step 5: If it's a Choice field, also create the enum if (fieldInfo.type.decorator === "Choice") { @@ -187,8 +197,9 @@ export class AddFieldTool implements AIEnhancedTool { */ private async validateAndPrepareTarget( targetUri: vscode.Uri, + modelName: string, cache?: MetadataCache - ): Promise<{ modelClass: DecoratedClass; document: vscode.TextDocument }> { + ): Promise<{ modelClass: DecoratedClass | null; document: vscode.TextDocument }> { // Ensure the file is a TypeScript file if (!targetUri.fsPath.endsWith(".ts")) { throw new Error("Target file must be a TypeScript file (.ts)"); @@ -202,11 +213,7 @@ export class AddFieldTool implements AIEnhancedTool { throw new Error("Metadata cache is required for field addition"); } - const modelClass = await this.projectAnalysisService.findModelClass(document, cache); - - if (!modelClass) { - throw new Error("No model class found in this file. Make sure the class has a @Model decorator."); - } + const modelClass = cache.getModelByName(modelName); return { modelClass, document }; } diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts new file mode 100644 index 0000000..3e8339a --- /dev/null +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { AddCompositionTool } from "../models/addComposition"; +import { AddFieldTool } from "./addField"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; +import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { TreeViewContext } from "../commandHelpers"; +import * as path from "path"; + +export class ExtractFieldsToCompositionTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private addCompositionTool: AddCompositionTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.addCompositionTool = new AddCompositionTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + public async extractFieldsToComposition( + cache: MetadataCache, + editor: vscode.TextEditor, + modelName: string, + treeViewContext?: { fieldItems: TreeViewContext["fieldItems"] } + ): Promise { + try { + const { document, selections } = editor; + const sourceModel = cache.getModelByName(modelName); + if (!sourceModel) { + throw new Error("Could not find a model class in the current file."); + } + + let selectedFields: PropertyMetadata[]; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem) => { + //to lowercase + const fieldItemName = fieldItem.label.toLowerCase(); + const field = Object.values(sourceModel.properties).find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${modelName}'`); + } + return field; + }); + } else { + // Editor context: use text selections + selectedFields = this.getSelectedFields(sourceModel, selections); + } + + if (selectedFields.length === 0) { + vscode.window.showInformationMessage("No fields selected."); + return; + } + + const compositionFieldName = await this.userInputService.showPrompt( + "Enter the name for the new composition field (e.g., 'address', 'contactInfo'):" + ); + if (!compositionFieldName) { + return; + } + + // Store field information before deletion + const fieldsToAdd = selectedFields.map((field) => this.propertyMetadataToFieldInfo(field)); + + // Use AddCompositionTool to create the composition relationship and inner model + const createdModelName = await this.addCompositionTool.addCompositionProgrammatically( + cache, + modelName, + compositionFieldName + ); + + const compModelUri = sourceModel.declaration.uri; + const newModelName = this.toPascalCase(compositionFieldName); + + // Add the extracted fields to the new composition model + for (const fieldInfo of fieldsToAdd) { + await this.addFieldTool.addFieldProgrammatically( + compModelUri, + fieldInfo, + newModelName, + cache, + true // Skip validation since we're programmatically adding + ); + } + + // Remove the fields from the source model + for (const field of selectedFields) { + const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); + if (deleteEdit) { + await vscode.workspace.applyEdit(deleteEdit); + } + } + + vscode.window.showInformationMessage( + `Fields extracted to new composition model '${createdModelName}' and linked via '${compositionFieldName}' field.` + ); + } catch (error) { + vscode.window.showErrorMessage(`Failed to extract fields to composition: ${error}`); + console.error("Error extracting fields to composition:", error); + } + } + + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); + } + } + } + return selectedFields; + } + + private propertyMetadataToFieldInfo(property: PropertyMetadata): FieldInfo { + const fieldType = + FIELD_TYPE_OPTIONS.find((o) => o.decorator === this.getDecoratorName(property.decorators)) || + FIELD_TYPE_OPTIONS[0]; + const fieldDecorator = property.decorators.find((d) => d.name === "Field"); + const isRequired = fieldDecorator?.arguments.some((arg: any) => arg.required === true) || false; + + return { + name: property.name, + type: fieldType, + required: isRequired, + }; + } + + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + private toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} diff --git a/src/commands/fields/extractFieldsToEmbedded.ts b/src/commands/fields/extractFieldsToEmbedded.ts new file mode 100644 index 0000000..7697209 --- /dev/null +++ b/src/commands/fields/extractFieldsToEmbedded.ts @@ -0,0 +1,100 @@ +// src/commands/fields/extractFieldsToEmbedded.ts +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; + +export class ExtractFieldsToEmbeddedTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + public async extractFieldsToEmbedded(cache: MetadataCache, editor: vscode.TextEditor, modelName:string): Promise { + try { + const { document, selections } = editor; + const sourceModel = cache.getModelByName(modelName); + if (!sourceModel) { + throw new Error("Could not find a model class in the current file."); + } + + const selectedFields = this.getSelectedFields(sourceModel, selections); + if (selectedFields.length === 0) { + vscode.window.showInformationMessage("No fields selected."); + return; + } + + const newModelName = await this.userInputService.showPrompt("Enter the name for the new embedded model:"); + if (!newModelName) return; + + // Create the new embedded model with the selected fields + const newModelContent = this.generateEmbeddedModelContent(newModelName, selectedFields); + await this.sourceCodeService.insertModel(document, newModelContent, sourceModel.name); + + // Remove the fields from the source model + for (const field of selectedFields) { + const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); + await vscode.workspace.applyEdit(deleteEdit); + } + + // Add the embedded field + const embeddedFieldInfo: FieldInfo = { + name: this.toCamelCase(newModelName), + type: { decorator: 'Embedded', label: 'Embedded', tsType: newModelName, description: 'Embedded Model' }, + required: false + }; + // This will require a new decorator and logic in AddFieldTool, for now, we'll add it manually + const fieldCode = `@Field()\n @Embedded()\n ${embeddedFieldInfo.name}!: ${newModelName};`; + await this.sourceCodeService.insertField(document, sourceModel.name, embeddedFieldInfo, fieldCode, cache, false); + + vscode.window.showInformationMessage(`Fields extracted to new embedded model '${newModelName}'.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to extract fields to embedded model: ${error}`); + } + } + + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); + } + } + } + return selectedFields; + } + + private generateEmbeddedModelContent(modelName: string, fields: PropertyMetadata[]): string { + let content = `\n@Model()\nclass ${modelName} {\n`; + for (const field of fields) { + for(const decorator of field.decorators) { + content += ` @${decorator.name}(${this.formatDecoratorArgs(decorator.arguments)})\n`; + } + content += ` ${field.name}!: ${field.type};\n\n`; + } + content += '}\n'; + return content; + } + + private formatDecoratorArgs(args: any[]): string { + if (!args || args.length === 0) { + return ''; + } + return JSON.stringify(args[0]).replace(/"/g, "'"); + } + + private toCamelCase(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1); + } +} \ No newline at end of file diff --git a/src/commands/fields/extractFieldsToParent.ts b/src/commands/fields/extractFieldsToParent.ts new file mode 100644 index 0000000..d7255bb --- /dev/null +++ b/src/commands/fields/extractFieldsToParent.ts @@ -0,0 +1,114 @@ +// src/commands/fields/extractFieldsToParent.ts +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import * as path from 'path'; + +export class ExtractFieldsToParentTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + public async extractFieldsToParent(cache: MetadataCache, editor: vscode.TextEditor, modelName:string): Promise { + try { + const { document, selections } = editor; + const sourceModel = cache.getModelByName(modelName); + if (!sourceModel) { + throw new Error("Could not find a model class in the current file."); + } + + const selectedFields = this.getSelectedFields(sourceModel, selections); + if (selectedFields.length === 0) { + vscode.window.showInformationMessage("No fields selected."); + return; + } + + const newModelName = await this.userInputService.showPrompt("Enter the name for the new abstract parent model:"); + if (!newModelName) return; + + // Create the new parent model + const newModelContent = this.generateParentModelContent(newModelName, selectedFields); + const targetFilePath = path.join(path.dirname(document.uri.fsPath), `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(targetFilePath); + await vscode.workspace.fs.writeFile(newModelUri, Buffer.from(newModelContent, 'utf8')); + + // Remove the fields from the source model + for (const field of selectedFields) { + const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); + await vscode.workspace.applyEdit(deleteEdit); + } + + // Update the source model to extend the new parent model + await this.updateSourceModelToExtend(document, sourceModel.name, newModelName); + + vscode.window.showInformationMessage(`Fields extracted to new parent model '${newModelName}'.`); + + } catch (error) { + vscode.window.showErrorMessage(`Failed to extract fields to parent model: ${error}`); + } + } + + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); + } + } + } + return selectedFields; + } + + private generateParentModelContent(modelName: string, fields: PropertyMetadata[]): string { + let content = `import { BaseModel, Field, Text, Model } from 'slingr-framework';\n\n`; // Add necessary imports + content += `@Model()\nexport abstract class ${modelName} extends BaseModel {\n`; + for (const field of fields) { + for(const decorator of field.decorators) { + content += ` @${decorator.name}(${this.formatDecoratorArgs(decorator.arguments)})\n`; + } + content += ` ${field.name}!: ${field.type};\n\n`; + } + content += '}\n'; + return content; + } + + private formatDecoratorArgs(args: any[]): string { + if (!args || args.length === 0) { + return ''; + } + return JSON.stringify(args[0]).replace(/"/g, "'"); + } + + private async updateSourceModelToExtend(document: vscode.TextDocument, sourceModelName: string, newParentName: string) { + const edit = new vscode.WorkspaceEdit(); + const text = document.getText(); + const regex = new RegExp(`(class ${sourceModelName} extends) (\\w+)`); + const match = text.match(regex); + + if (match) { + const index = match.index || 0; + const startPos = document.positionAt(index + match[1].length + 1); + const endPos = document.positionAt(index + match[1].length + 1 + match[2].length); + edit.replace(document.uri, new vscode.Range(startPos, endPos), newParentName); + + // Add import for the new parent model + const importStatement = `\nimport { ${newParentName} } from './${newParentName}';`; + const firstLine = document.lineAt(0); + edit.insert(document.uri, firstLine.range.start, importStatement); + + await vscode.workspace.applyEdit(edit); + } + } +} \ No newline at end of file diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts new file mode 100644 index 0000000..e4ca19d --- /dev/null +++ b/src/commands/fields/extractFieldsToReference.ts @@ -0,0 +1,114 @@ +// src/commands/fields/extractFieldsToReference.ts +import * as vscode from "vscode"; +import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; +import { UserInputService } from "../../services/userInputService"; +import { ProjectAnalysisService } from "../../services/projectAnalysisService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { NewModelTool } from "../models/newModel"; +import { AddFieldTool } from "./addField"; +import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; +import * as path from 'path'; + +export class ExtractFieldsToReferenceTool { + private userInputService: UserInputService; + private projectAnalysisService: ProjectAnalysisService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private newModelTool: NewModelTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.projectAnalysisService = new ProjectAnalysisService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.newModelTool = new NewModelTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + public async extractFieldsToReference(cache: MetadataCache, editor: vscode.TextEditor, modelName:string): Promise { + try { + const { document, selections } = editor; + const sourceModel = cache.getModelByName(modelName); + if (!sourceModel) { + throw new Error("Could not find a model class in the current file."); + } + + const selectedFields = this.getSelectedFields(sourceModel, selections); + if (selectedFields.length === 0) { + vscode.window.showInformationMessage("No fields selected."); + return; + } + + const newModelName = await this.userInputService.showPrompt("Enter the name for the new model:"); + if (!newModelName) return; + + const referenceFieldName = await this.userInputService.showPrompt("Enter the name for the new reference field:"); + if (!referenceFieldName) return; + + const targetFilePath = path.join(path.dirname(document.uri.fsPath), `${newModelName}.ts`); + + // Create the new model with the extracted fields + const newModelUri = await this.newModelTool.createModelProgrammatically(newModelName, targetFilePath, `Represents a reference model with fields from ${sourceModel.name}.`); + for (const field of selectedFields) { + const fieldInfo = this.propertyMetadataToFieldInfo(field); + await this.addFieldTool.addFieldProgrammatically(newModelUri, fieldInfo, modelName, cache, true); + } + + // Remove the fields from the source model + for (const field of selectedFields) { + const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); + await vscode.workspace.applyEdit(deleteEdit); + } + + // Add the reference relationship to the source model + const referenceFieldInfo: FieldInfo = { + name: referenceFieldName, + type: FIELD_TYPE_OPTIONS.find(o => o.decorator === "Relationship")!, + required: false, + additionalConfig: { + targetModel: newModelName, + relationshipType: 'reference' + } + }; + await this.addFieldTool.addFieldProgrammatically(document.uri, referenceFieldInfo,modelName, cache); + + vscode.window.showInformationMessage(`Fields extracted to new reference model '${newModelName}'.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to extract fields to reference: ${error}`); + } + } + + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); + } + } + } + return selectedFields; + } + + private propertyMetadataToFieldInfo(property: PropertyMetadata): FieldInfo { + const fieldType = FIELD_TYPE_OPTIONS.find(o => o.decorator === this.getDecoratorName(property.decorators)) || FIELD_TYPE_OPTIONS[0]; + const fieldDecorator = property.decorators.find(d => d.name === 'Field'); + const isRequired = fieldDecorator?.arguments.some((arg: any) => arg.required === true) || false; + + return { + name: property.name, + type: fieldType, + required: isRequired + }; + } + + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find(d => FIELD_TYPE_OPTIONS.some(o => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : 'Text'; + } +} \ No newline at end of file diff --git a/src/commands/interfaces.ts b/src/commands/interfaces.ts index 6f4796f..44ed54c 100644 --- a/src/commands/interfaces.ts +++ b/src/commands/interfaces.ts @@ -25,8 +25,8 @@ export interface AIEnhancedTool { processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, - additionalContext?: any ): Promise; } diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 3ddbdd3..6d826e7 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -16,14 +16,12 @@ export class AddCompositionTool { private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; - private explorerProvider: ExplorerProvider; - constructor(explorerProvider: ExplorerProvider) { + constructor() { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); - this.explorerProvider = explorerProvider; } /** @@ -44,28 +42,51 @@ export class AddCompositionTool { return; // User cancelled } - // Step 3: Determine inner model name and array status + await this.addCompositionProgrammatically(cache, modelName, fieldName); + } catch (error) { + vscode.window.showErrorMessage(`Failed to add composition: ${error}`); + console.error("Error adding composition:", error); + } + } + + /** + * Adds a composition relationship programmatically with a predefined field name. + * This method is used by other tools that need to create compositions without user interaction. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @param fieldName - The predefined field name for the composition + * @returns Promise that resolves with the created inner model name when the composition is added + */ + public async addCompositionProgrammatically(cache: MetadataCache, modelName: string, fieldName: string): Promise { + try { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Determine inner model name and array status const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); - // Step 4: Check if inner model already exists + // Step 3: Check if inner model already exists await this.validateInnerModelName(cache, innerModelName); - // Step 5: Create the inner model + // Step 4: Create the inner model await this.createInnerModel(document, innerModelName, modelClass.name, cache); - // Step 6: Add composition field to outer model + // Step 5: Add composition field to outer model await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); - // Step 7: Focus on the newly created field + // Step 6: Focus on the newly created field await this.sourceCodeService.focusOnElement(document, fieldName); - // Step 8: Show success message + // Step 7: Show success message vscode.window.showInformationMessage( `Composition relationship created successfully! Added ${innerModelName} model and ${fieldName} field.` ); + + return innerModelName; } catch (error) { - vscode.window.showErrorMessage(`Failed to add composition: ${error}`); - console.error("Error adding composition:", error); + console.error("Error adding composition programmatically:", error); + throw error; } } diff --git a/src/commands/models/newModel.ts b/src/commands/models/newModel.ts index 8d57dea..92a44c2 100644 --- a/src/commands/models/newModel.ts +++ b/src/commands/models/newModel.ts @@ -57,6 +57,7 @@ export class NewModelTool implements AIEnhancedTool { async processWithAI( userInput: string, targetUri: vscode.Uri, + modelName: string, cache: MetadataCache, additionalContext?: any ): Promise { @@ -363,6 +364,7 @@ export class NewModelTool implements AIEnhancedTool { await this.addFieldTool.addFieldProgrammatically( parentModelUri, fieldInfo, + newModelName, cache, true // silent mode - suppress success/error messages ); diff --git a/src/services/projectAnalysisService.ts b/src/services/projectAnalysisService.ts index 2b1ee78..f5e3a4d 100644 --- a/src/services/projectAnalysisService.ts +++ b/src/services/projectAnalysisService.ts @@ -6,14 +6,13 @@ import { PropertyMetadata } from "../cache/cache"; import { fieldTypeConfig } from "../utils/fieldTypes"; export class ProjectAnalysisService { - private fileSystemService: FileSystemService; constructor() { this.fileSystemService = new FileSystemService(); } - public async findModelClass( + public async selectModelClass( document: vscode.TextDocument, cache: MetadataCache ): Promise { @@ -33,7 +32,7 @@ export class ProjectAnalysisService { if (modelClasses.length > 1) { const selected = await vscode.window.showQuickPick( modelClasses.map((c) => c.name), - { placeHolder: 'Select a model class from this file' } + { placeHolder: "Select a model class from this file" } ); return modelClasses.find((c) => c.name === selected); } From 831025e722d7d95c2b64e9b725d7b6d80c75ea99 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 17 Sep 2025 15:26:47 -0300 Subject: [PATCH 19/36] The extractFieldToComposition command is now handled by the RefactorController. Adds multiple fields handling in the RefactorController. Adds some code refactors and utility methods in addField.ts and addComposition.ts --- src/commands/commandRegistration.ts | 97 --- src/commands/fields/addField.ts | 155 ++++ .../fields/changeReferenceToComposition.ts | 42 +- .../fields/extractFieldsToComposition.ts | 698 +++++++++++++++--- src/commands/models/addComposition.ts | 137 ++++ src/refactor/RefactorController.ts | 226 ++++-- src/refactor/refactorDisposables.ts | 13 +- src/refactor/refactorInterfaces.ts | 26 +- src/services/sourceCodeService.ts | 41 + 9 files changed, 1123 insertions(+), 312 deletions(-) diff --git a/src/commands/commandRegistration.ts b/src/commands/commandRegistration.ts index 9a9bfe6..e13bc08 100644 --- a/src/commands/commandRegistration.ts +++ b/src/commands/commandRegistration.ts @@ -194,102 +194,5 @@ export function registerGeneralCommands( }); disposables.push(modifyModelCommand); - // Extract Fields to Composition - const extractFieldsToCompositionTool = new ExtractFieldsToCompositionTool(); - registerTreeViewAwareCommand( - disposables, - 'slingr-vscode-extension.extractFieldsToComposition', - async (context: TreeViewContext) => { - // Tree view context handler - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); - const editor = await vscode.window.showTextDocument(document); - await extractFieldsToCompositionTool.extractFieldsToComposition( - cache, - editor, - context.modelName, - { fieldItems: context.fieldItems } - ); - }, - async (result: UriResolutionResult) => { - // Standard URI resolution handler - const editor = vscode.window.activeTextEditor; - if (editor && result.modelName) { - await extractFieldsToCompositionTool.extractFieldsToComposition(cache, editor, result.modelName); - } else { - throw new Error('Could not determine model name or no active editor.'); - } - }, - URI_OPTIONS.MODEL_FILE - ); - - // Extract Fields to Reference - const extractFieldsToReferenceTool = new ExtractFieldsToReferenceTool(); - registerTreeViewAwareCommand( - disposables, - 'slingr-vscode-extension.extractFieldsToReference', - async (context: TreeViewContext) => { - // Tree view context handler - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); - const editor = await vscode.window.showTextDocument(document); - await extractFieldsToReferenceTool.extractFieldsToReference(cache, editor, context.modelName); - }, - async (result: UriResolutionResult) => { - // Standard URI resolution handler - const editor = vscode.window.activeTextEditor; - if (editor && result.modelName) { - await extractFieldsToReferenceTool.extractFieldsToReference(cache, editor, result.modelName); - } else { - throw new Error('Could not determine model name or no active editor.'); - } - }, - URI_OPTIONS.MODEL_FILE - ); - - // Extract Fields to Embedded Model - const extractFieldsToEmbeddedTool = new ExtractFieldsToEmbeddedTool(); - registerTreeViewAwareCommand( - disposables, - 'slingr-vscode-extension.extractFieldsToEmbedded', - async (context: TreeViewContext) => { - // Tree view context handler - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); - const editor = await vscode.window.showTextDocument(document); - await extractFieldsToEmbeddedTool.extractFieldsToEmbedded(cache, editor, context.modelName); - }, - async (result: UriResolutionResult) => { - // Standard URI resolution handler - const editor = vscode.window.activeTextEditor; - if (editor && result.modelName) { - await extractFieldsToEmbeddedTool.extractFieldsToEmbedded(cache, editor, result.modelName); - } else { - throw new Error('Could not determine model name or no active editor.'); - } - }, - URI_OPTIONS.MODEL_FILE - ); - - // Extract Fields to Parent Model - const extractFieldsToParentTool = new ExtractFieldsToParentTool(); - registerTreeViewAwareCommand( - disposables, - 'slingr-vscode-extension.extractFieldsToParent', - async (context: TreeViewContext) => { - // Tree view context handler - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(context.modelPath)); - const editor = await vscode.window.showTextDocument(document); - await extractFieldsToParentTool.extractFieldsToParent(cache, editor, context.modelName); - }, - async (result: UriResolutionResult) => { - // Standard URI resolution handler - const editor = vscode.window.activeTextEditor; - if (editor && result.modelName) { - await extractFieldsToParentTool.extractFieldsToParent(cache, editor, result.modelName); - } else { - throw new Error('Could not determine model name or no active editor.'); - } - }, - URI_OPTIONS.MODEL_FILE - ); - return disposables; } \ No newline at end of file diff --git a/src/commands/fields/addField.ts b/src/commands/fields/addField.ts index d581add..e3dc465 100644 --- a/src/commands/fields/addField.ts +++ b/src/commands/fields/addField.ts @@ -192,6 +192,161 @@ export class AddFieldTool implements AIEnhancedTool { } } + /** + * Creates a WorkspaceEdit for adding a field programmatically without applying it. + * This method prepares all the necessary changes (field insertion, imports, enums) + * and returns them as a WorkspaceEdit that can be applied later or combined with other edits. + * + * @param targetUri - The URI of the model file where the field should be added + * @param fieldInfo - Predefined field information + * @param modelName - The name of the model class to which the field will be added + * @param cache - The metadata cache for context about existing models + * @param enumValues - For Choice fields, the enum values to use (if not provided, default values will be used) + * @returns Promise that resolves to a WorkspaceEdit containing all necessary changes + * @throws Error if validation fails or field already exists + * + */ + public async createAddFieldWorkspaceEdit( + targetUri: vscode.Uri, + fieldInfo: FieldInfo, + modelName: string, + cache: MetadataCache, + enumValues?: string[] + ): Promise { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(targetUri, modelName, cache); + + // Step 2: Check if field already exists + if (modelClass) { + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldInfo.name)) { + throw new Error(`Field '${fieldInfo.name}' already exists in model ${modelClass.name}`); + } + } + + // Step 3: Generate basic field structure + const fieldCode = this.generateFieldCode(fieldInfo); + + // Step 4: Create the workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Step 5: Add field insertion edit (delegate to source code service but intercept the edit) + await this.addFieldEditToWorkspace(edit, document, modelName, fieldInfo, fieldCode, cache); + + // Step 6: If it's a Choice field, also add enum creation edit + if (fieldInfo.type.decorator === "Choice") { + await this.addEnumEditToWorkspace(edit, document, fieldInfo, enumValues); + } + + return edit; + } + + /** + * Adds field insertion edits to the provided WorkspaceEdit. + * This mirrors the logic from sourceCodeService.insertField but adds to the edit instead of applying. + */ + private async addFieldEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + modelClassName: string, + fieldInfo: FieldInfo, + fieldCode: string, + cache?: MetadataCache + ): Promise { + const lines = document.getText().split("\n"); + const newImports = new Set(["Field", fieldInfo.type.decorator]); + + if (fieldInfo.type.decorator === "Composition") { + newImports.add("PersistentComponentModel"); + } + + // Add imports using source code service logic (we need to call a helper method) + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, newImports); + + // Add model import if needed + if (fieldInfo.additionalConfig?.targetModel) { + await this.sourceCodeService.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); + } + + // Find class boundaries and add field + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, modelClassName); + const indentation = detectIndentation(lines, 0, lines.length); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + + /** + * Adds enum creation edits to the provided WorkspaceEdit for Choice fields. + */ + private async addEnumEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + fieldInfo: FieldInfo, + enumValues?: string[] + ): Promise { + const enumName = this.generateEnumName(fieldInfo.name); + + // Use provided enum values or generate default ones + let values = enumValues; + if (!values || values.length === 0) { + values = this.generateDefaultEnumValues(fieldInfo.name); + } + + // Normalize the values + const normalizedValues = values.map(value => this.normalizeEnumValue(value)); + + // Generate enum code + const enumCode = this.generateEnumCode(enumName, normalizedValues); + + // Find insertion point at the end of the file + const content = document.getText(); + const lines = content.split("\n"); + + let insertionLine = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].trim()) { + insertionLine = i + 1; + break; + } + } + + const insertPosition = new vscode.Position(insertionLine, 0); + const codeToInsert = "\n" + enumCode + "\n"; + + edit.insert(document.uri, insertPosition, codeToInsert); + } + + /** + * Generates default enum values for a Choice field when none are provided. + */ + private generateDefaultEnumValues(fieldName: string): string[] { + const fieldLower = fieldName.toLowerCase(); + + // Generate context-appropriate default values + if (fieldLower.includes("status")) { + return ["Active", "Inactive", "Pending"]; + } + if (fieldLower.includes("type")) { + return ["TypeA", "TypeB", "TypeC"]; + } + if (fieldLower.includes("category")) { + return ["General", "Important", "Urgent"]; + } + if (fieldLower.includes("state")) { + return ["Open", "InProgress", "Closed"]; + } + if (fieldLower.includes("priority")) { + return ["Low", "Medium", "High"]; + } + if (fieldLower.includes("level")) { + return ["Basic", "Intermediate", "Advanced"]; + } + + // Default generic values + return ["Option1", "Option2", "Option3"]; + } + /** * Validates the target file and prepares it for field addition. */ diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index 9e07011..e627b7e 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -245,7 +245,7 @@ export class ChangeReferenceToCompositionTool { const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; // Step 4: Extract any enums from the target model file - const enumDefinitions = this.extractEnumDefinitions(targetDocument); + const enumDefinitions = this.sourceCodeService.extractEnumDefinitions(targetDocument); // Step 5: Check for enum name conflicts and resolve them const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); @@ -273,46 +273,6 @@ export class ChangeReferenceToCompositionTool { return componentModelParts; } - /** - * Extracts enum definitions from a document. - */ - private extractEnumDefinitions(document: vscode.TextDocument): string[] { - const content = document.getText(); - const lines = content.split('\n'); - const enumDefinitions: string[] = []; - - let currentEnum: string[] = []; - let inEnum = false; - let braceCount = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Check if we're starting an enum - if (line.trim().startsWith('export enum ') || line.trim().startsWith('enum ')) { - inEnum = true; - braceCount = 0; - } - - if (inEnum) { - currentEnum.push(line); - - // Count braces - const openBraces = (line.match(/{/g) || []).length; - const closeBraces = (line.match(/}/g) || []).length; - braceCount += openBraces - closeBraces; - - // If we've closed all braces, we're done with this enum - if (braceCount === 0 && line.includes('}')) { - inEnum = false; - enumDefinitions.push(currentEnum.join('\n')); - currentEnum = []; - } - } - } - - return enumDefinitions; - } /** * Resolves enum name conflicts between target and source files. diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 3e8339a..9158783 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -1,152 +1,666 @@ import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, + ExtractFieldsToCompositionPayload, +} from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; import { UserInputService } from "../../services/userInputService"; -import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; -import { FileSystemService } from "../../services/fileSystemService"; import { AddCompositionTool } from "../models/addComposition"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; -import { ExplorerProvider } from "../../explorer/explorerProvider"; import { TreeViewContext } from "../commandHelpers"; -import * as path from "path"; +import { isModelFile } from "../../utils/metadata"; -export class ExtractFieldsToCompositionTool { +/** + * Refactor tool for extracting multiple fields from a model to a new composition model. + * + * This tool allows users to select multiple fields and move them to a new composition + * model, creating a composition relationship between the source and new models. + * It provides preview functionality before applying changes. + */ +export class ExtractFieldsToCompositionTool implements IRefactorTool { private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; private addCompositionTool: AddCompositionTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; constructor() { this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); this.addCompositionTool = new AddCompositionTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); } - public async extractFieldsToComposition( - cache: MetadataCache, - editor: vscode.TextEditor, - modelName: string, - treeViewContext?: { fieldItems: TreeViewContext["fieldItems"] } - ): Promise { - try { - const { document, selections } = editor; - const sourceModel = cache.getModelByName(modelName); - if (!sourceModel) { - throw new Error("Could not find a model class in the current file."); - } + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToComposition"; + } - let selectedFields: PropertyMetadata[]; + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Composition"; + } - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - //to lowercase - const fieldItemName = fieldItem.label.toLowerCase(); - const field = Object.values(sourceModel.properties).find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${modelName}'`); - } - return field; - }); - } else { - // Editor context: use text selections - selectedFields = this.getSelectedFields(sourceModel, selections); - } + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_COMPOSITION"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; + } + + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by prompting user for field selection and composition name. + */ + async initiateManualRefactor( + context: ManualRefactorContext, + ): Promise { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; + } - if (selectedFields.length === 0) { - vscode.window.showInformationMessage("No fields selected."); - return; + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to composition"); + return undefined; + } + + let selectedFields: PropertyMetadata[] | undefined; + + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); + } + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields); + if (!selectedFields || selectedFields.length === 0) { + return undefined; } + } - const compositionFieldName = await this.userInputService.showPrompt( - "Enter the name for the new composition field (e.g., 'address', 'contactInfo'):" - ); - if (!compositionFieldName) { - return; + // Get the composition field name + const compositionFieldName = await this.userInputService.showPrompt( + "Enter the name for the new composition field (e.g., 'address', 'contactInfo'):" + ); + if (!compositionFieldName) { + return undefined; + } + + const payload: ExtractFieldsToCompositionPayload = { + sourceModelName: sourceModel.name, + compositionFieldName: compositionFieldName, + fieldsToExtract: selectedFields, + isManual: true, + }; + + return { + type: "EXTRACT_FIELDS_TO_COMPOSITION", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new composition '${compositionFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToCompositionPayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); } - // Store field information before deletion - const fieldsToAdd = selectedFields.map((field) => this.propertyMetadataToFieldInfo(field)); + const combinedEdit = new vscode.WorkspaceEdit(); - // Use AddCompositionTool to create the composition relationship and inner model - const createdModelName = await this.addCompositionTool.addCompositionProgrammatically( + // Step 1: Create the composition field and new model WITH the extracted fields already included + // Use PropertyMetadata directly to preserve all decorator information + const fieldsToAdd = payload.fieldsToExtract; // These are already PropertyMetadata objects + + // Create the composition with the fields included + const { edit: compositionEdit, innerModelName } = await this.createCompositionWithFields( cache, - modelName, - compositionFieldName + payload.sourceModelName, + payload.compositionFieldName, + fieldsToAdd ); - const compModelUri = sourceModel.declaration.uri; - const newModelName = this.toPascalCase(compositionFieldName); - - // Add the extracted fields to the new composition model - for (const fieldInfo of fieldsToAdd) { - await this.addFieldTool.addFieldProgrammatically( - compModelUri, - fieldInfo, - newModelName, - cache, - true // Skip validation since we're programmatically adding - ); - } + // Merge composition edit + this.mergeWorkspaceEdits(combinedEdit, compositionEdit); - // Remove the fields from the source model - for (const field of selectedFields) { + // Step 2: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); - if (deleteEdit) { - await vscode.workspace.applyEdit(deleteEdit); - } + this.mergeWorkspaceEdits(combinedEdit, deleteEdit); } - vscode.window.showInformationMessage( - `Fields extracted to new composition model '${createdModelName}' and linked via '${compositionFieldName}' field.` - ); + return combinedEdit; } catch (error) { - vscode.window.showErrorMessage(`Failed to extract fields to composition: ${error}`); - console.error("Error extracting fields to composition:", error); + vscode.window.showErrorMessage(`Failed to prepare extract fields to composition edit: ${error}`); + throw error; + } + } + + /** + * Creates a composition relationship with the extracted fields already included in the inner model. + */ + private async createCompositionWithFields( + cache: MetadataCache, + sourceModelName: string, + compositionFieldName: string, + fieldsToAdd: PropertyMetadata[] + ): Promise<{ edit: vscode.WorkspaceEdit; innerModelName: string }> { + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${sourceModelName}'`); + } + + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + const edit = new vscode.WorkspaceEdit(); + + // Determine inner model name and check if it should be an array + const { innerModelName, isArray } = this.determineInnerModelInfo(compositionFieldName); + + // Step 1: Add the inner model WITH the extracted fields already included + await this.addInnerModelWithFieldsToWorkspace(edit, document, innerModelName, sourceModelName, fieldsToAdd, cache); + + // Step 2: Add the composition field to the outer model + await this.addCompositionFieldToWorkspace( + edit, + document, + sourceModelName, + compositionFieldName, + innerModelName, + isArray, + cache + ); + + return { edit, innerModelName }; + } + + /** + * Generates inner model code with the specified fields already included. + */ + private generateInnerModelCodeWithFields( + innerModelName: string, + outerModelName: string, + dataSource: string | undefined, + fields: PropertyMetadata[] + ): string { + const lines: string[] = []; + + // Add model decorator + if (dataSource) { + lines.push(`@Model({`); + lines.push(`\tdataSource: ${dataSource}`); + lines.push(`})`); + } else { + lines.push(`@Model()`); } + + // Add class declaration + lines.push(`class ${innerModelName} extends PersistentComponentModel<${outerModelName}> {`); + lines.push(``); + + // Add each field using the enhanced method that preserves all decorator information + for (const property of fields) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(property); + lines.push(...fieldCode.split("\n").map((line) => (line ? `\t${line}` : ""))); + lines.push(``); // Empty line between fields + } + + lines.push(`}`); + + return lines.join("\n"); } - private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { - const selectedFields: PropertyMetadata[] = []; - for (const selection of selections) { - for (const field of Object.values(model.properties)) { - if (selection.intersection(field.declaration.range)) { - selectedFields.push(field); + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}({})`); + } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); } } } - return selectedFields; + + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); } - private propertyMetadataToFieldInfo(property: PropertyMetadata): FieldInfo { - const fieldType = - FIELD_TYPE_OPTIONS.find((o) => o.decorator === this.getDecoratorName(property.decorators)) || - FIELD_TYPE_OPTIONS[0]; - const fieldDecorator = property.decorators.find((d) => d.name === "Field"); - const isRequired = fieldDecorator?.arguments.some((arg: any) => arg.required === true) || false; + /** + * Extracts the dataSource from a model using the cache. + */ + private extractDataSourceFromModel(model: DecoratedClass, cache: MetadataCache): string | undefined { + const modelDecorator = model.decorators.find((d) => d.name === "Model"); + return modelDecorator?.arguments?.[0]?.dataSource; + } - return { - name: property.name, - type: fieldType, - required: isRequired, - }; + /** + * Generates an enum name from a field name for Choice fields. + */ + private generateEnumName(fieldName: string): string { + const pascalCase = fieldName.charAt(0).toUpperCase() + fieldName.slice(1); + return pascalCase; + } + + /** + * Shows user a quick pick to select which fields to extract. + */ + private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: "Select fields to extract to the new composition model", + title: "Extract Fields to Composition", + }); + + return selectedItems?.map((item) => item.field); } + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; + } + + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); + } + + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; + } + } + + return null; + } + + /** + * Gets the decorator name for a field's type. + */ private getDecoratorName(decorators: any[]): string { const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); return typeDecorator ? typeDecorator.name : "Text"; } + /** + * Determines the inner model name and whether the field should be an array. + */ + private determineInnerModelInfo(fieldName: string): { innerModelName: string; isArray: boolean } { + const singularName = this.toSingular(fieldName); + const innerModelName = this.toPascalCase(singularName); + const isArray = fieldName !== singularName; // If we converted from plural to singular, it's an array + + return { innerModelName, isArray }; + } + + /** + * Converts a potentially plural field name to singular using basic rules. + */ + private toSingular(fieldName: string): string { + if (!fieldName) { + return ""; + } + + // Rule 1: Handle "...ies" -> "...y" (e.g., "cities" -> "city") + if (fieldName.toLowerCase().endsWith("ies")) { + return fieldName.slice(0, -3) + "y"; + } + + // Rule 2: Handle "...es" -> "..." (e.g., "boxes" -> "box", "wishes" -> "wish") + if (fieldName.toLowerCase().endsWith("es")) { + const base = fieldName.slice(0, -2); + // Check if the base word ends in s, x, z, ch, sh + if (["s", "x", "z"].some((char) => base.endsWith(char)) || ["ch", "sh"].some((pair) => base.endsWith(pair))) { + return base; + } + } + + // Rule 3: Handle simple "...s" -> "..." (e.g., "cats" -> "cat") + if (fieldName.toLowerCase().endsWith("s") && !fieldName.toLowerCase().endsWith("ss")) { + return fieldName.slice(0, -1); + } + + // If no plural pattern was found, return the original string + return fieldName; + } + + /** + * Converts camelCase to PascalCase. + */ private toPascalCase(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } + + /** + * Adds inner model with fields to workspace edit. + */ + private async addInnerModelWithFieldsToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + fieldsToAdd: PropertyMetadata[], + cache: MetadataCache + ): Promise { + // Check if inner model already exists + const existingModel = cache.getModelByName(innerModelName); + if (existingModel) { + throw new Error(`A model named '${innerModelName}' already exists in the project`); + } + + // Get data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const dataSource = this.extractDataSourceFromModel(outerModelClass, cache); + + // Generate the inner model code with fields + const innerModelCode = this.generateInnerModelCodeWithFields( + innerModelName, + outerModelName, + dataSource, + fieldsToAdd + ); + + // Add required imports - collect from the PropertyMetadata decorators + const requiredImports = new Set(["Model", "Field", "PersistentComponentModel", "Composition"]); + // Add field-specific imports based on the decorators in PropertyMetadata + for (const property of fieldsToAdd) { + for (const decorator of property.decorators) { + requiredImports.add(decorator.name); + } + } + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find insertion point after the outer model + const lines = document.getText().split("\n"); + let insertionLine = lines.length; // Default to end of file + + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + insertionLine = classEndLine + 1; + } catch (error) { + console.warn(`Could not find model ${outerModelName}, inserting at end of file`); + } + + // Insert the inner model with appropriate spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${innerModelCode}\n`); + } + + /** + * Adds composition field to the outer model workspace edit. + */ + private async addCompositionFieldToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Check if composition field already exists + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const existingFields = Object.keys(outerModelClass.properties || {}); + if (existingFields.includes(fieldName)) { + throw new Error(`Field '${fieldName}' already exists in model ${outerModelName}`); + } + + // Create field info for the composition field + const fieldType = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Add required imports + const requiredImports = new Set(["Field", "Composition"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + const indentation = this.detectIndentation(lines, 0, lines.length); + const indentedFieldCode = this.applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + + /** + * Generates the composition field code. + */ + private generateCompositionFieldCode(fieldInfo: FieldInfo, innerModelName: string, isArray: boolean): string { + const lines: string[] = []; + + // Add Field decorator + if (fieldInfo.required) { + lines.push("@Field({"); + lines.push(" required: true"); + lines.push("})"); + } else { + lines.push("@Field({})"); + } + + // Add Composition decorator + lines.push("@Composition()"); + + // Add property declaration + const typeAnnotation = isArray ? `${innerModelName}[]` : innerModelName; + lines.push(`${fieldInfo.name}!: ${typeAnnotation};`); + + return lines.join("\n"); + } + + /** + * Simple indentation detection (copied from utils). + */ + private detectIndentation(lines: string[], startLine: number, endLine: number): string { + for (let i = startLine; i < Math.min(lines.length, endLine); i++) { + const line = lines[i]; + const match = line.match(/^(\s+)/); + if (match) { + return match[1]; + } + } + return "\t"; // Default to tab + } + + /** + * Apply indentation to code (copied from utils). + */ + private applyIndentation(code: string, indentation: string): string { + return code + .split("\n") + .map((line) => (line.trim() ? `${indentation}${line}` : line)) + .join("\n"); + } + + /** + * Merges two workspace edits into one. + */ + private mergeWorkspaceEdits(target: vscode.WorkspaceEdit, source: vscode.WorkspaceEdit): void { + // Merge text edits + source.entries().forEach(([uri, edits]) => { + const existing = target.get(uri) || []; + target.set(uri, [...existing, ...edits]); + }); + + // Merge file operations if any + if (source.size > 0) { + // Copy any file operations from source to target + // This is a simplified merge - in practice you might need more sophisticated merging + } + } } diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 6d826e7..82b7e0e 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -6,6 +6,7 @@ import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; /** * Tool for adding composition relationships to existing Model classes. @@ -90,6 +91,142 @@ export class AddCompositionTool { } } + /** + * Creates a WorkspaceEdit for adding a composition relationship programmatically without applying it. + * This method prepares all the necessary changes (inner model creation and composition field addition) + * and returns them as a WorkspaceEdit that can be applied later or combined with other edits. + * + * @param cache - The metadata cache for context about existing models + * @param modelName - The name of the model to which the composition is being added + * @param fieldName - The predefined field name for the composition + * @returns Promise that resolves to a WorkspaceEdit containing all necessary changes and the inner model name + * @throws Error if validation fails or models already exist + * + */ + public async createAddCompositionWorkspaceEdit( + cache: MetadataCache, + modelName: string, + fieldName: string + ): Promise<{ edit: vscode.WorkspaceEdit; innerModelName: string }> { + // Step 1: Validate target file + const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); + + // Step 2: Determine inner model name and array status + const { innerModelName, isArray } = this.determineInnerModelInfo(fieldName); + + // Step 3: Check if inner model already exists + await this.validateInnerModelName(cache, innerModelName); + + // Step 4: Check if composition field already exists + const existingFields = Object.keys(modelClass.properties || {}); + if (existingFields.includes(fieldName)) { + throw new Error(`Field '${fieldName}' already exists in model ${modelClass.name}`); + } + + // Step 5: Create the workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Step 6: Add inner model creation edit + await this.addInnerModelEditToWorkspace(edit, document, innerModelName, modelClass.name, cache); + + // Step 7: Add composition field edit + await this.addCompositionFieldEditToWorkspace(edit, document, modelClass.name, fieldName, innerModelName, isArray, cache); + + return { edit, innerModelName }; + } + + /** + * Adds inner model creation edits to the provided WorkspaceEdit. + */ + private async addInnerModelEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + innerModelName: string, + outerModelName: string, + cache: MetadataCache + ): Promise { + // Determine data source from outer model + const outerModelClass = cache.getModelByName(outerModelName); + if (!outerModelClass) { + throw new Error(`Could not find model metadata for '${outerModelName}'`); + } + + const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); + const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; + + // Generate the inner model code + const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName, dataSource); + + // Add required imports + const requiredImports = new Set(["Model", "Field", "Relationship", "PersistentComponentModel"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find insertion point after the outer model + const lines = document.getText().split("\n"); + let insertionLine = lines.length; // Default to end of file + + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + insertionLine = classEndLine + 1; + } catch (error) { + // If we can't find the specified model, fall back to end of file + console.warn(`Could not find model ${outerModelName}, inserting at end of file`); + } + + // Insert the inner model with appropriate spacing + const spacing = insertionLine < lines.length ? "\n\n" : "\n"; + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${innerModelCode}\n`); + } + + /** + * Adds composition field creation edits to the provided WorkspaceEdit. + */ + private async addCompositionFieldEditToWorkspace( + edit: vscode.WorkspaceEdit, + document: vscode.TextDocument, + outerModelName: string, + fieldName: string, + innerModelName: string, + isArray: boolean, + cache: MetadataCache + ): Promise { + // Create field info for the composition field + const fieldType: FieldTypeOption = { + label: "Relationship", + decorator: "Composition", + tsType: isArray ? `${innerModelName}[]` : innerModelName, + description: "Composition relationship", + }; + + const fieldInfo: FieldInfo = { + name: fieldName, + type: fieldType, + required: false, // Compositions are typically optional + additionalConfig: { + relationshipType: "composition", + targetModel: innerModelName, + targetModelPath: document.uri.fsPath, + }, + }; + + // Generate the field code + const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); + + // Add field insertion edits using the source code service approach + const lines = document.getText().split("\n"); + const requiredImports = new Set(["Field", "Composition"]); + + // Add imports + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Find class boundaries and add field + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); + const indentation = detectIndentation(lines, 0, lines.length); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + + edit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`); + } + /** * Validates the target file and prepares it for composition addition. */ diff --git a/src/refactor/RefactorController.ts b/src/refactor/RefactorController.ts index eaef374..1720d62 100644 --- a/src/refactor/RefactorController.ts +++ b/src/refactor/RefactorController.ts @@ -1,17 +1,24 @@ import * as vscode from "vscode"; -import { ChangeObject, IRefactorTool, ManualRefactorContext, DeleteModelPayload, RenameModelPayload } from "./refactorInterfaces"; +import { + ChangeObject, + IRefactorTool, + ManualRefactorContext, + DeleteModelPayload, + RenameModelPayload, +} from "./refactorInterfaces"; import { findNodeAtPosition } from "../utils/ast"; import { MetadataCache } from "../cache/cache"; import { AppTreeItem } from "../explorer/appTreeItem"; +import { isTreeViewContext, validateTreeViewContext, TreeViewContext } from "../commands/commandHelpers"; /** * Controls and orchestrates refactoring operations within the VS Code extension. - * + * * The RefactorController serves as the central coordinator for all refactoring activities, * managing both manual user-initiated refactors and automatic refactors detected through * metadata analysis. It maintains a collection of refactoring tools and handles the * complete refactoring workflow from detection to user approval and application. - * + * * @remarks * Key responsibilities include: * - Managing a registry of refactoring tools and their supported change types @@ -20,20 +27,20 @@ import { AppTreeItem } from "../explorer/appTreeItem"; * - Preparing and merging workspace edits while avoiding duplicate modifications * - Presenting changes to users through VS Code's built-in refactor preview UI * - Coordinating file operations including text edits and file deletions - * + * * The controller uses a change handler map to efficiently route different types of * changes to their appropriate refactoring tools. It includes safeguards to prevent * concurrent edit operations and provides comprehensive error handling throughout * the refactoring process. - * + * * @example * ```typescript * const tools = [new RenameActionTool(), new DeleteModelTool()]; * const controller = new RefactorController(tools, metadataCache); - * + * * // Handle manual refactor command * await controller.handleManualRefactorCommand('rename-action', treeItem); - * + * * // Process automatic refactors * await controller.proposeAutomaticRefactors(detectedChanges); * ``` @@ -66,15 +73,30 @@ export class RefactorController { * @param commandId - The identifier of the refactoring command to execute * @param context - Optional context providing either a URI or AppTreeItem for the refactoring target. * If not provided, uses the active text editor as the target. + * @param secondArg - Optional second argument, used for tree view multi-selection contexts * @returns A Promise that resolves when the refactoring operation is complete * @remarks * - Shows error message if the command ID is not recognized * - For AppTreeItem context, uses the item's metadata for refactoring scope * - For URI context or no context, uses the active editor's selection or cursor position + * - Detects tree view multi-selection context and passes field selection information * - Presents changes for user approval before applying them * - Shows information message if no changes are needed */ - public async handleManualRefactorCommand(commandId: string, context?: vscode.Uri | AppTreeItem | ManualRefactorContext, decoratorName?: string) { + public async handleManualRefactorCommand( + commandId: string, + context?: vscode.Uri | AppTreeItem | ManualRefactorContext, + secondArg?: any + ) { + console.log('[RefactorController] handleManualRefactorCommand called with:', { + commandId, + contextType: context ? typeof context : 'undefined', + contextItemType: context && typeof context === 'object' && 'itemType' in context ? context.itemType : 'N/A', + secondArgType: secondArg ? typeof secondArg : 'undefined', + secondArgIsArray: Array.isArray(secondArg), + secondArgLength: Array.isArray(secondArg) ? secondArg.length : 'N/A' + }); + const tool = this.tools.find((t) => t.getCommandId() === commandId); if (!tool) { vscode.window.showErrorMessage(`Unknown refactoring command: ${commandId}`); @@ -83,53 +105,108 @@ export class RefactorController { let refactorContext: ManualRefactorContext | undefined; - if (context instanceof AppTreeItem) { - if (!context.metadata) { - vscode.window.showInformationMessage("No metadata found for the selected item."); - return; + // Check if this is a tree view multi-selection context + if (context && typeof context === "object" && "itemType" in context && secondArg && Array.isArray(secondArg)) { + try { + if (isTreeViewContext(context, secondArg)) { + const treeContext = validateTreeViewContext(context, secondArg); + + // Create the refactor context from the tree view context + const targetModel = treeContext.fieldItems[0]?.parent?.metadata; + if (targetModel && "declaration" in targetModel) { + refactorContext = { + cache: this.cache, + uri: targetModel.declaration.uri, + range: targetModel.declaration.range, + metadata: targetModel, + treeViewContext: treeContext, + }; + } + } + } catch (error) { + // If tree view context validation fails, fall back to regular handling + console.warn("Failed to process tree view context:", error); } - refactorContext = { - cache: this.cache, - uri: context.metadata.declaration.uri, - range: context.metadata.declaration.range, - metadata: context.metadata, - }; - } else if (context instanceof vscode.Uri) { - const fileMeta = this.cache.getMetadataForFile(context.fsPath); - if (!fileMeta || Object.keys(fileMeta.classes).length === 0) { - vscode.window.showInformationMessage("No class found in the selected file to refactor."); - return; + } + // Check if this is a single field selection from tree view + else if (context instanceof AppTreeItem && + (context.itemType === "field" || context.itemType === "referenceField") && + context.parent?.metadata && + 'name' in context.parent.metadata) { + try { + // Create a tree view context for single field selection + const singleFieldTreeContext: TreeViewContext = { + clickedItem: context, + selectedItems: [context], + fieldItems: [context], + modelName: context.parent.metadata.name, + modelPath: context.parent.metadata.declaration.uri.fsPath + }; + + refactorContext = { + cache: this.cache, + uri: context.parent.metadata.declaration.uri, + range: context.parent.metadata.declaration.range, + metadata: context.parent.metadata, + treeViewContext: singleFieldTreeContext, + }; + } catch (error) { + console.warn("Failed to process single field tree view context:", error); } - // When triggered from file explorer, we assume the target is the first class in the file. - const targetClass = Object.values(fileMeta.classes)[0]; - refactorContext = { - cache: this.cache, - uri: context, - range: targetClass.declaration.range, - metadata: targetClass, - }; - } else if (context && 'cache' in context && 'uri' in context) { - refactorContext = context as ManualRefactorContext; - } else { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showInformationMessage("Cannot determine file for refactoring. Please open a file."); - return; + } + + // If not a tree view context or tree view processing failed, handle as before + if (!refactorContext) { + if (context instanceof AppTreeItem) { + if (!context.metadata) { + vscode.window.showInformationMessage("No metadata found for the selected item."); + return; + } + refactorContext = { + cache: this.cache, + uri: context.metadata.declaration.uri, + range: context.metadata.declaration.range, + metadata: context.metadata, + }; + } else if (context instanceof vscode.Uri) { + const fileMeta = this.cache.getMetadataForFile(context.fsPath); + if (!fileMeta || Object.keys(fileMeta.classes).length === 0) { + vscode.window.showInformationMessage("No class found in the selected file to refactor."); + return; + } + // When triggered from file explorer, we assume the target is the first class in the file. + const targetClass = Object.values(fileMeta.classes)[0]; + refactorContext = { + cache: this.cache, + uri: context, + range: targetClass.declaration.range, + metadata: targetClass, + }; + } else if (context && "cache" in context && "uri" in context) { + refactorContext = context as ManualRefactorContext; + } else { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showInformationMessage("Cannot determine file for refactoring. Please open a file."); + return; + } + const position = editor.selection.active; + refactorContext = { + cache: this.cache, + uri: editor.document.uri, + range: new vscode.Range(position, position), + metadata: await findNodeAtPosition(editor.document.uri, position), + }; } - const position = editor.selection.active; - refactorContext = { - cache: this.cache, - uri: editor.document.uri, - range: new vscode.Range(position, position), - metadata: await findNodeAtPosition(editor.document.uri, position), - }; } if (!refactorContext) { vscode.window.showErrorMessage("Could not determine the context for refactoring."); return; } - const changeObject = await (tool as any).initiateManualRefactor(refactorContext, decoratorName); + + // Call the tool with the refactor context (tree view context is included in the context) + const changeObject = await (tool as any).initiateManualRefactor(refactorContext); if (changeObject) { const workspaceEdit = await this.prepareWorkspaceEdit([changeObject]); if (!workspaceEdit) { @@ -138,7 +215,7 @@ export class RefactorController { const hasTextEdits = workspaceEdit.size > 0; let hasFileDeletions = false; - if (changeObject.type === 'DELETE_MODEL') { + if (changeObject.type === "DELETE_MODEL") { const deletePayload = changeObject.payload as DeleteModelPayload; hasFileDeletions = Array.isArray(deletePayload.urisToDelete) && deletePayload.urisToDelete.length > 0; } @@ -152,17 +229,17 @@ export class RefactorController { /** * Presents workspace changes to the user for approval and handles post-approval analysis. - * + * * This method applies the workspace edit with proper confirmation metadata on existing edits * to trigger VS Code's refactoring preview UI, and optionally runs AI analysis on the changes * after user approval to help identify and fix potential errors. - * + * * @param workspaceEdit - The VS Code WorkspaceEdit containing all file changes to be applied * @param changeObject - The primary change object being processed, used as an anchor for the preview * @param allChanges - Optional array of all changes for automatic refactors with multiple operations - * + * * @returns A Promise that resolves when the approval process and any follow-up analysis is complete - * + * * @remarks * - Annotates existing text edits with confirmation metadata to trigger VS Code's preview UI * - Prefers to annotate edits on the anchor URI when available, otherwise uses the first available edit @@ -173,10 +250,10 @@ export class RefactorController { private async presentChangesForApproval( workspaceEdit: vscode.WorkspaceEdit, changeObject: ChangeObject, - allChanges?: ChangeObject[] + allChanges?: ChangeObject[] ): Promise { const anchorUri = changeObject.uri; - const isDelete = changeObject.type.startsWith('DELETE_'); + const isDelete = changeObject.type.startsWith("DELETE_"); let uriForDummyChange = anchorUri; @@ -247,7 +324,7 @@ export class RefactorController { // We have to add the file renames and deletions from the original changes const changesToProcess = allChanges || [changeObject]; for (const change of changesToProcess) { - if (change.type === 'DELETE_MODEL') { + if (change.type === "DELETE_MODEL") { const deletePayload = change.payload as DeleteModelPayload; if (Array.isArray(deletePayload.urisToDelete)) { for (const uri of deletePayload.urisToDelete) { @@ -255,8 +332,8 @@ export class RefactorController { } } } - - if (change.type === 'RENAME_MODEL') { + + if (change.type === "RENAME_MODEL") { const renamePayload = change.payload as RenameModelPayload; if (renamePayload.newUri) { annotated.renameFile(change.uri, renamePayload.newUri); @@ -264,7 +341,7 @@ export class RefactorController { } } editToApply = annotated; - } + } } catch (e) { console.error("Error while annotating workspace edits for review:", e); } @@ -276,7 +353,7 @@ export class RefactorController { await vscode.workspace.saveAll(false); const changesToProcess = allChanges || [changeObject]; // Check for compilation errors after applying changes - const changesWithPrompts = changesToProcess.filter(change => { + const changesWithPrompts = changesToProcess.filter((change) => { const tool = this.changeHandlerMap.get(change.type); return tool?.executePrompt; }); @@ -324,14 +401,14 @@ export class RefactorController { /** * Proposes automatic refactoring suggestions based on detected changes. - * + * * This method analyzes the provided changes and prepares a workspace edit containing * potential refactoring operations. If changes are detected, it prompts the user for * permission to review the proposed refactors before applying them. - * + * * @param changes - Array of change objects representing detected modifications that could benefit from refactoring * @returns A promise that resolves when the refactoring proposal process is complete - * + * * @remarks * - Returns early if currently applying an edit or if no changes are provided * - Only proceeds with user confirmation before presenting changes for review @@ -357,14 +434,14 @@ export class RefactorController { /** * Prepares a workspace edit by processing an array of change objects and merging their edits. - * + * * This method iterates through the provided changes, uses the appropriate change handlers to generate * text edits, and ensures no duplicate edits are applied to the same range. It also handles file * deletions when specified in the change payload. - * + * * @param changes - Array of change objects to be processed into workspace edits * @returns A Promise that resolves to a WorkspaceEdit containing all merged changes, or undefined if an error occurs - * + * * @remarks * - Edits are deduplicated based on their exact range location (line and character positions) * - File deletions are processed with recursive and ignoreIfNotExists options @@ -380,7 +457,6 @@ export class RefactorController { const tool = this.changeHandlerMap.get(change.type); if (tool) { try { - const editFromTool = await tool.prepareEdit(change, this.cache); for (const [uri, textEdits] of editFromTool.entries()) { const uriString = uri.toString(); @@ -399,10 +475,9 @@ export class RefactorController { if (existingEdits.length > 0) { allUniqueEdits.set(uriString, existingEdits); } - } - if (change.type === 'DELETE_MODEL') { + if (change.type === "DELETE_MODEL") { const deletePayload = change.payload as DeleteModelPayload; if (Array.isArray(deletePayload.urisToDelete)) { for (const uri of deletePayload.urisToDelete) { @@ -411,13 +486,12 @@ export class RefactorController { } } - if (change.type === 'RENAME_MODEL') { + if (change.type === "RENAME_MODEL") { const renamePayload = change.payload as RenameModelPayload; if (renamePayload.newUri) { mergedEdit.renameFile(change.uri, renamePayload.newUri); } } - } catch (error) { vscode.window.showErrorMessage(`Error preparing refactor for '${change.description}': ${error}`); return undefined; @@ -433,10 +507,10 @@ export class RefactorController { /** * Collects all file URIs that were modified during the refactoring operation. - * + * * This method gathers URIs from both the workspace edit entries and the change objects * to create a comprehensive list of files that should be checked for compilation errors. - * + * * @param workspaceEdit - The workspace edit containing text modifications * @param changes - Array of change objects that triggered the refactoring * @returns A Set of unique URIs representing all modified files @@ -455,14 +529,14 @@ export class RefactorController { /** * Checks for compilation errors in the specified files. - * + * * This method uses VS Code's diagnostic API to detect compilation errors * in the provided file URIs. It's useful for determining whether a refactoring * operation has introduced any syntax or type errors that need attention. - * + * * @param uris - Set of file URIs to check for compilation errors * @returns A Promise that resolves to true if any compilation errors are found, false otherwise - * + * * @remarks * - Only checks for diagnostics with Error severity level * - Gracefully handles cases where diagnostics cannot be retrieved for a file @@ -472,7 +546,7 @@ export class RefactorController { for (const uri of uris) { try { const diagnostics = vscode.languages.getDiagnostics(uri); - const errors = diagnostics.filter(d => d.severity === vscode.DiagnosticSeverity.Error); + const errors = diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error); if (errors.length > 0) { return true; } @@ -486,7 +560,7 @@ export class RefactorController { /** * Retrieves the list of available refactor tools. - * + * * @returns An array of refactor tools that are currently registered with this controller. */ public getTools(): IRefactorTool[] { diff --git a/src/refactor/refactorDisposables.ts b/src/refactor/refactorDisposables.ts index 80d8b14..436561c 100644 --- a/src/refactor/refactorDisposables.ts +++ b/src/refactor/refactorDisposables.ts @@ -12,6 +12,7 @@ import { AppTreeItem } from '../explorer/appTreeItem'; import { AddDecoratorTool } from './tools/addDecorator'; import { ChangeReferenceToCompositionRefactorTool } from './tools/changeReferenceToComposition'; import { ChangeCompositionToReferenceRefactorTool } from './tools/changeCompositionToReference'; +import { ExtractFieldsToCompositionTool } from '../commands/fields/extractFieldsToComposition'; import { isModelFile } from '../utils/metadata'; import { PropertyMetadata } from '../cache/cache'; import { fieldTypeConfig } from '../utils/fieldTypes'; @@ -29,6 +30,7 @@ import { fieldTypeConfig } from '../utils/fieldTypes'; * - RenameFieldTool: Handles field renaming operations * - DeleteFieldTool: Handles field deletion operations * - ChangeFieldTypeTool: Handles field type modification operations + * - ExtractFieldsToCompositionTool: Handles extracting fields to composition models */ export function getAllRefactorTools(): IRefactorTool[] { return [ @@ -40,6 +42,7 @@ export function getAllRefactorTools(): IRefactorTool[] { new AddDecoratorTool(), new ChangeReferenceToCompositionRefactorTool(), new ChangeCompositionToReferenceRefactorTool(), + new ExtractFieldsToCompositionTool(), ]; } @@ -60,12 +63,14 @@ export function registerRefactorCommands(controller: RefactorController, context for (const tool of controller.getTools()) { disposables.push( - vscode.commands.registerCommand(tool.getCommandId(), (context?: vscode.Uri | AppTreeItem | ManualRefactorContext, decoratorName?: string) => { - // The command can now be called with more complex arguments from CodeActions + vscode.commands.registerCommand(tool.getCommandId(), (context?: vscode.Uri | AppTreeItem | ManualRefactorContext, secondArg?: any) => { + // The command can now be called with more complex arguments from CodeActions or tree view multi-selection if (context && 'cache' in context && 'uri' in context) { - controller.handleManualRefactorCommand(tool.getCommandId(), context, decoratorName); + // ManualRefactorContext case - secondArg might be decoratorName + controller.handleManualRefactorCommand(tool.getCommandId(), context, secondArg); } else { - controller.handleManualRefactorCommand(tool.getCommandId(), context); + // Tree view context case - secondArg might be the selected items array + controller.handleManualRefactorCommand(tool.getCommandId(), context, secondArg); } }) ); diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index 5a84e3e..af4d15f 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { DecoratedClass, DecoratorMetadata, FileMetadata, MetadataCache, PropertyMetadata } from '../cache/cache'; +import { TreeViewContext } from '../commands/commandHelpers'; /** * Context object containing all necessary information for performing refactoring operations. @@ -129,6 +130,23 @@ export interface ChangeReferenceToCompositionPayload { isManual: boolean; } +/** + * Payload interface for extracting fields to a composition model. + * This involves moving selected fields from a source model to a new composition model + * and creating a composition relationship between them. + * + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} compositionFieldName - The name of the new composition field to be created + * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted + * @property {boolean} isManual - Whether the extraction was initiated manually by the user + */ +export interface ExtractFieldsToCompositionPayload { + sourceModelName: string; + compositionFieldName: string; + fieldsToExtract: PropertyMetadata[]; + isManual: boolean; +} + /** * Represents the specific type of refactoring change being applied. @@ -141,8 +159,9 @@ export interface ChangeReferenceToCompositionPayload { * - `CHANGE_FIELD_TYPE`: A change that modifies the data type of a field. * - `ADD_DECORATOR`: A change that adds a decorator to a field. * - `CHANGE_REFERENCE_TO_COMPOSITION`: A change that converts a reference field to a composition field. + * - `EXTRACT_FIELDS_TO_COMPOSITION`: A change that extracts selected fields to a new composition model. */ -export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR' | 'CHANGE_REFERENCE_TO_COMPOSITION' | 'CHANGE_COMPOSITION_TO_REFERENCE'; +export type ChangeType = 'RENAME_MODEL' | 'DELETE_MODEL' | 'RENAME_FIELD' | 'DELETE_FIELD' | 'CHANGE_FIELD_TYPE'| 'ADD_DECORATOR' | 'CHANGE_REFERENCE_TO_COMPOSITION' | 'CHANGE_COMPOSITION_TO_REFERENCE' | 'EXTRACT_FIELDS_TO_COMPOSITION'; /** * Represents a single, atomic change to be applied as part of a refactoring operation. @@ -165,7 +184,8 @@ export interface ChangeObject { | DeleteFieldPayload | ChangeFieldTypePayload | AddDecoratorPayload - | ChangeReferenceToCompositionPayload; + | ChangeReferenceToCompositionPayload + | ExtractFieldsToCompositionPayload; } @@ -183,6 +203,8 @@ export interface ManualRefactorContext { uri: vscode.Uri; range: vscode.Range; metadata?: DecoratedClass | PropertyMetadata; + treeViewContext?: TreeViewContext; + } /** diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 7976a76..cd383f8 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -858,4 +858,45 @@ export class SourceCodeService { return lines.join('\n'); } + /** + * Extracts enum definitions from a document. + */ + public extractEnumDefinitions(document: vscode.TextDocument): string[] { + const content = document.getText(); + const lines = content.split('\n'); + const enumDefinitions: string[] = []; + + let currentEnum: string[] = []; + let inEnum = false; + let braceCount = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we're starting an enum + if (line.trim().startsWith('export enum ') || line.trim().startsWith('enum ')) { + inEnum = true; + braceCount = 0; + } + + if (inEnum) { + currentEnum.push(line); + + // Count braces + const openBraces = (line.match(/{/g) || []).length; + const closeBraces = (line.match(/}/g) || []).length; + braceCount += openBraces - closeBraces; + + // If we've closed all braces, we're done with this enum + if (braceCount === 0 && line.includes('}')) { + inEnum = false; + enumDefinitions.push(currentEnum.join('\n')); + currentEnum = []; + } + } + } + + return enumDefinitions; + } + } From 7fcc76189805dd3dc407f357bbf894a64fc785e3 Mon Sep 17 00:00:00 2001 From: Luciano Date: Fri, 19 Sep 2025 09:46:48 -0300 Subject: [PATCH 20/36] Adds interfaces --- src/refactor/refactorInterfaces.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index aaf0f9a..a303d76 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -64,6 +64,7 @@ export interface CreateModelPayload { export interface DeleteFieldPayload { oldFieldMetadata: PropertyMetadata; modelName: string; + isManual: boolean; } export interface ChangeFieldTypePayload extends BasePayload { @@ -250,6 +251,11 @@ export type ChangePayloadMap = { 'ADD_DECORATOR': AddDecoratorPayload; 'RENAME_DATA_SOURCE': RenameDataSourcePayload; 'DELETE_DATA_SOURCE': DeleteDataSourcePayload; + 'CHANGE_REFERENCE_TO_COMPOSITION': ChangeReferenceToCompositionPayload; + 'CHANGE_COMPOSITION_TO_REFERENCE': ChangeReferenceToCompositionPayload; + 'EXTRACT_FIELDS_TO_COMPOSITION': ExtractFieldsToCompositionPayload; + 'EXTRACT_FIELDS_TO_REFERENCE': ExtractFieldsToReferencePayload; + // Add more change types and their payloads as needed }; /** From 5938c7a69511a1bd275e3030f4c17082a0d9044c Mon Sep 17 00:00:00 2001 From: Luciano Date: Fri, 19 Sep 2025 12:58:37 -0300 Subject: [PATCH 21/36] Fixed RefactorController workspaceEdit changes management and updated the creation of edit in every refactor command. --- .../fields/extractFieldsToReference.ts | 917 +++++++++--------- src/refactor/RefactorController.ts | 72 +- src/refactor/refactorInterfaces.ts | 40 +- src/refactor/tools/addDecorator.ts | 2 +- .../tools/changeCompositionToReference.ts | 2 +- src/refactor/tools/changeFieldType.ts | 12 +- src/refactor/tools/deleteDataSource.ts | 2 +- src/refactor/tools/deleteField.ts | 8 +- src/refactor/tools/deleteModel.ts | 10 +- src/refactor/tools/renameDataSource.ts | 6 +- src/refactor/tools/renameField.ts | 4 +- src/refactor/tools/renameModel.ts | 4 +- src/services/sourceCodeService.ts | 268 ++--- 13 files changed, 710 insertions(+), 637 deletions(-) diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts index aa08b1d..fd702c0 100644 --- a/src/commands/fields/extractFieldsToReference.ts +++ b/src/commands/fields/extractFieldsToReference.ts @@ -15,7 +15,7 @@ import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; import { TreeViewContext } from "../commandHelpers"; import { isModelFile } from "../../utils/metadata"; -import * as path from 'path'; +import * as path from "path"; /** * Refactor tool for extracting multiple fields from a model to a new reference model. @@ -25,502 +25,531 @@ import * as path from 'path'; * It provides preview functionality before applying changes. */ export class ExtractFieldsToReferenceTool implements IRefactorTool { - private userInputService: UserInputService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; - private newModelTool: NewModelTool; - private addFieldTool: AddFieldTool; - private deleteFieldTool: DeleteFieldTool; - - constructor() { - this.userInputService = new UserInputService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); - this.newModelTool = new NewModelTool(); - this.addFieldTool = new AddFieldTool(); - this.deleteFieldTool = new DeleteFieldTool(); + private userInputService: UserInputService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private newModelTool: NewModelTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; + + constructor() { + this.userInputService = new UserInputService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.newModelTool = new NewModelTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + } + + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToReference"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Reference"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_REFERENCE"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; } - /** - * Returns the VS Code command identifier for this refactor tool. - */ - getCommandId(): string { - return "slingr-vscode-extension.extractFieldsToReference"; + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; } - /** - * Returns the human-readable title shown in refactor menus. - */ - getTitle(): string { - return "Extract Fields to Reference"; + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by prompting user for field selection, new model name, and reference field name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; } - /** - * Returns the types of changes this tool handles. - */ - getHandledChangeTypes(): string[] { - return ["EXTRACT_FIELDS_TO_REFERENCE"]; + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to reference"); + return undefined; } - /** - * Determines if this tool can handle a manual refactor trigger. - * Allows extraction when multiple fields are selected in a model file. - */ - async canHandleManualTrigger(context: ManualRefactorContext): Promise { - // Must be in a model file - if (!isModelFile(context.uri)) { - return false; - } + let selectedFields: PropertyMetadata[] | undefined; - // Get the source model from context - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - return false; - } + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - // For manual trigger, we allow it if there are fields in the model - return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); + } + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields); + if (!selectedFields || selectedFields.length === 0) { + return undefined; + } } - /** - * This tool doesn't detect automatic changes. - */ - analyze(): ChangeObject[] { - return []; + // Get the new model name + const newModelName = await this.userInputService.showPrompt("Enter the name for the new reference model:"); + if (!newModelName) { + return undefined; } - /** - * Initiates the manual refactor by prompting user for field selection, new model name, and reference field name. - */ - async initiateManualRefactor( - context: ManualRefactorContext, - ): Promise { - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - vscode.window.showErrorMessage("Could not find a model in the current context"); - return undefined; - } - - // Get all fields in the model - const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; - if (allFields.length < 2) { - vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to reference"); - return undefined; - } - - let selectedFields: PropertyMetadata[] | undefined; - - const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - const fieldItemName = fieldItem.label.toLowerCase(); - const field = allFields.find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); - } - return field; - }); - } else { - // Let user select which fields to extract - selectedFields = await this.selectFieldsForExtraction(allFields); - if (!selectedFields || selectedFields.length === 0) { - return undefined; - } - } - - // Get the new model name - const newModelName = await this.userInputService.showPrompt( - "Enter the name for the new reference model:" - ); - if (!newModelName) { - return undefined; - } - - // Get the reference field name - const referenceFieldName = await this.userInputService.showPrompt( - "Enter the name for the new reference field (e.g., 'user', 'category'):" - ); - if (!referenceFieldName) { - return undefined; - } - - const payload: ExtractFieldsToReferencePayload = { - sourceModelName: sourceModel.name, - newModelName: newModelName, - referenceFieldName: referenceFieldName, - fieldsToExtract: selectedFields, - isManual: true, - }; - - return { - type: "EXTRACT_FIELDS_TO_REFERENCE", - uri: context.uri, - description: `Extract ${selectedFields.length} field(s) to new reference model '${newModelName}' with reference field '${referenceFieldName}' in model '${sourceModel.name}'`, - payload, - }; + // Get the reference field name + const referenceFieldName = await this.userInputService.showPrompt( + "Enter the name for the new reference field (e.g., 'user', 'category'):" + ); + if (!referenceFieldName) { + return undefined; } - /** - * Prepares the workspace edit for the refactor operation. - */ - async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { - const payload = change.payload as ExtractFieldsToReferencePayload; - - try { - const sourceModel = cache.getModelByName(payload.sourceModelName); - if (!sourceModel) { - throw new Error(`Could not find source model '${payload.sourceModelName}'`); - } - - const combinedEdit = new vscode.WorkspaceEdit(); - - const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); - const newModelPath = path.join(sourceDir, `${payload.newModelName}.ts`); - const newModelUri = vscode.Uri.file(newModelPath); - - // Step 1: Create the new reference model with the extracted fields - const { edit: edit } = await this.createReferenceModelWithFields( - sourceModel, - payload.newModelName, - payload.fieldsToExtract, - cache, - newModelUri - ); - - // Step 2: Add the reference field to the source model - await this.addReferenceFieldToSourceModel( - edit, - sourceModel, - payload.referenceFieldName, - payload.newModelName, - cache - ); - - // Step 3: Remove the fields from the source model - for (const field of payload.fieldsToExtract) { - await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); - } - - return edit; - } catch (error) { - vscode.window.showErrorMessage(`Failed to prepare extract fields to reference edit: ${error}`); - throw error; - } + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToReferencePayload = { + sourceModelName: sourceModel.name, + newModelName: newModelName, + referenceFieldName: referenceFieldName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_REFERENCE", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new reference model '${newModelName}' with reference field '${referenceFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToReferencePayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Get the URI from the payload + const newModelUri = payload.urisToCreate![0].uri; + + // Generate the complete file content + const completeFileContent = this.generateCompleteReferenceModelFile( + payload.newModelName, + payload.fieldsToExtract, + sourceModel, + cache + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new model file ${path.basename(newModelUri.fsPath)}`, + description: `Creating new model file for ${payload.newModelName}`, + needsConfirmation: true, + }; + + // Create the file with content + edit.createFile( + newModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Add the reference field to the source model + await this.addReferenceFieldToSourceModel( + edit, + sourceModel, + payload.referenceFieldName, + payload.newModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to reference edit: ${error}`); + throw error; } - - /** - * Helper method to get selected fields from editor selections. - */ - private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { - const selectedFields: PropertyMetadata[] = []; - for (const selection of selections) { - for (const field of Object.values(model.properties)) { - if (selection.intersection(field.declaration.range)) { - selectedFields.push(field); - } - } + } + + /** + * Helper method to get selected fields from editor selections. + */ + private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { + const selectedFields: PropertyMetadata[] = []; + for (const selection of selections) { + for (const field of Object.values(model.properties)) { + if (selection.intersection(field.declaration.range)) { + selectedFields.push(field); } - return selectedFields; + } + } + return selectedFields; + } + + /** + * Shows user a quick pick to select which fields to extract. + */ + private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: "Select fields to extract to the new reference model", + title: "Extract Fields to Reference", + }); + + return selectedItems?.map((item) => item.field); + } + + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; } - /** - * Shows user a quick pick to select which fields to extract. - */ - private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { - const fieldItems = allFields.map((field) => ({ - label: field.name, - description: this.getFieldTypeDescription(field), - field: field, - })); - - const selectedItems = await vscode.window.showQuickPick(fieldItems, { - canPickMany: true, - placeHolder: "Select fields to extract to the new reference model", - title: "Extract Fields to Reference", - }); - - return selectedItems?.map((item) => item.field); + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); } - /** - * Gets a description of the field type for display in the quick pick. - */ - private getFieldTypeDescription(field: PropertyMetadata): string { - const decoratorName = this.getDecoratorName(field.decorators); - return `@${decoratorName}`; + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } } - /** - * Gets the source model from the refactor context, handling different context types. - */ - private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { - // Case 1: metadata is already a DecoratedClass (model) - if (context.metadata && "properties" in context.metadata) { - return context.metadata as DecoratedClass; - } + return null; + } - // Case 2: metadata is a PropertyMetadata (field) - find the containing model - if (context.metadata && "decorators" in context.metadata) { - const fieldMetadata = context.metadata as PropertyMetadata; - return this.findSourceModelForField(context.cache, fieldMetadata); - } + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); - // Case 3: no specific metadata - try to find model at the range - return this.findModelAtRange(context.cache, context.uri, context.range); + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; + } } - /** - * Finds the model that contains the given field. - */ - private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - const fieldInModel = Object.values(model.properties).find( - (prop) => - prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line - ); - - if (fieldInModel) { - return model; - } - } - - return null; + return null; + } + + /** + * Gets the decorator name for a field's type. + */ + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + /** + * Creates a new reference model in a separate file with the extracted fields. + */ + private async createReferenceModelWithFields( + sourceModel: DecoratedClass, + newModelName: string, + fieldsToExtract: PropertyMetadata[], + cache: MetadataCache, + newModelUri: vscode.Uri + ): Promise<{ edit: vscode.WorkspaceEdit; newModelUri: vscode.Uri }> { + // Check if model with this name already exists + const existingModel = cache.getModelByName(newModelName); + if (existingModel) { + throw new Error(`A model named '${newModelName}' already exists in the project`); } - /** - * Finds the model class that contains the given range. - */ - private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { - return model; - } - } - - return null; + const edit = new vscode.WorkspaceEdit(); + + // Generate the complete file content including imports and model + const completeFileContent = this.generateCompleteReferenceModelFile( + newModelName, + fieldsToExtract, + sourceModel, + cache + ); + + edit.createFile(newModelUri, { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }); + + return { edit, newModelUri }; + } + + /** + * Generates the complete file content for the new reference model, including imports. + */ + private generateCompleteReferenceModelFile( + modelName: string, + fieldsToExtract: PropertyMetadata[], + sourceModel: DecoratedClass, + cache: MetadataCache + ): string { + const lines: string[] = []; + + // Extract data source from source model + const dataSource = this.extractDataSourceFromModel(sourceModel, cache); + + // Generate imports + const requiredImports = new Set(["Model", "Field", "PersistentModel"]); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + requiredImports.add(decorator.name); + } } - /** - * Gets the decorator name for a field's type. - */ - private getDecoratorName(decorators: any[]): string { - const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); - return typeDecorator ? typeDecorator.name : "Text"; + // Add the import statement + const importList = Array.from(requiredImports).sort(); + lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); + + //Add dataSource import + lines.push(`import { ${dataSource} } from '../dataSources/datasource';`); + lines.push(""); // Empty line after imports + + // Add model decorator and class + if (dataSource) { + lines.push(`@Model({`); + lines.push(` dataSource: ${dataSource}`); + lines.push(`})`); + } else { + lines.push(`@Model()`); } - /** - * Creates a new reference model in a separate file with the extracted fields. - */ - private async createReferenceModelWithFields( - sourceModel: DecoratedClass, - newModelName: string, - fieldsToExtract: PropertyMetadata[], - cache: MetadataCache, - newModelUri: vscode.Uri - ): Promise<{ edit: vscode.WorkspaceEdit; newModelUri: vscode.Uri }> { - // Check if model with this name already exists - const existingModel = cache.getModelByName(newModelName); - if (existingModel) { - throw new Error(`A model named '${newModelName}' already exists in the project`); - } - - const edit = new vscode.WorkspaceEdit(); - - // Generate the complete file content including imports and model - const completeFileContent = this.generateCompleteReferenceModelFile(newModelName, fieldsToExtract, sourceModel, cache); + lines.push(`export class ${modelName} extends PersistentModel {`); + lines.push(""); - edit.createFile(newModelUri, {overwrite: false, ignoreIfExists: true, contents: Buffer.from(completeFileContent, 'utf8')}); - - return { edit, newModelUri }; + // Add each field using PropertyMetadata to preserve all decorator information + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); + lines.push(""); } - /** - * Generates the complete file content for the new reference model, including imports. - */ - private generateCompleteReferenceModelFile( - modelName: string, - fieldsToExtract: PropertyMetadata[], - sourceModel: DecoratedClass, - cache: MetadataCache - ): string { - const lines: string[] = []; - - // Extract data source from source model - const dataSource = this.extractDataSourceFromModel(sourceModel, cache); - - // Generate imports - const requiredImports = new Set(["Model", "Field", "PersistentModel"]); - for (const field of fieldsToExtract) { - for (const decorator of field.decorators) { - requiredImports.add(decorator.name); + lines.push("}"); + lines.push(""); // Empty line at end + + return lines.join("\n"); + } + + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } } - } - - // Add the import statement - const importList = Array.from(requiredImports).sort(); - lines.push(`import { ${importList.join(', ')} } from 'slingr-framework';`); - - - //Add dataSource import - lines.push(`import { ${dataSource} } from '../datasources/datasource';`); - lines.push(''); // Empty line after imports - - // Add model decorator and class - if (dataSource) { - lines.push(`@Model({`); - lines.push(` dataSource: ${dataSource}`); - lines.push(`})`); + } + lines.push("})"); } else { - lines.push(`@Model()`); + lines.push(`@${decorator.name}({})`); } - - lines.push(`export class ${modelName} extends PersistentModel {`); - lines.push(""); - - // Add each field using PropertyMetadata to preserve all decorator information - for (const field of fieldsToExtract) { - const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); - lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); - lines.push(""); - } - - lines.push("}"); - lines.push(''); // Empty line at end - - return lines.join("\n"); - } - - /** - * Generates field code directly from PropertyMetadata, preserving all decorator information. - */ - private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { - const lines: string[] = []; - - // Add all decorators in the same order as the original - for (const decorator of property.decorators) { - if (decorator.name === "Field" || decorator.name === "Relationship") { - // Handle Field and Relationship decorators with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - lines.push(`@${decorator.name}({`); - const args = decorator.arguments[0]; // Usually the first argument contains the options object - if (typeof args === "object" && args !== null) { - // Format each property of the arguments object - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}({})`); - } - } else { - // Handle type decorators (Text, Choice, etc.) with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - const args = decorator.arguments[0]; - if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { - lines.push(`@${decorator.name}({`); - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else if (Array.isArray(value)) { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}()`); - } - } else { - lines.push(`@${decorator.name}()`); - } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } } + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); } - - // Add property declaration using the original type - lines.push(`${property.name}!: ${property.type};`); - - return lines.join("\n"); + } } - /** - * Extracts the dataSource from a model using the cache. - */ - private extractDataSourceFromModel(model: DecoratedClass, cache: MetadataCache): string | undefined { - const modelDecorator = model.decorators.find((d) => d.name === "Model"); - return modelDecorator?.arguments?.[0]?.dataSource; + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); + } + + /** + * Extracts the dataSource from a model using the cache. + */ + private extractDataSourceFromModel(model: DecoratedClass, cache: MetadataCache): string | undefined { + const modelDecorator = model.decorators.find((d) => d.name === "Model"); + return modelDecorator?.arguments?.[0]?.dataSource; + } + + /** + * Adds a reference field to the source model. + */ + private async addReferenceFieldToSourceModel( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + referenceFieldName: string, + targetModelName: string, + cache: MetadataCache + ): Promise { + // Check if reference field already exists + const existingFields = Object.keys(sourceModel.properties || {}); + if (existingFields.includes(referenceFieldName)) { + throw new Error(`Field '${referenceFieldName}' already exists in model ${sourceModel.name}`); } - /** - * Adds a reference field to the source model. - */ - private async addReferenceFieldToSourceModel( - edit: vscode.WorkspaceEdit, - sourceModel: DecoratedClass, - referenceFieldName: string, - targetModelName: string, - cache: MetadataCache - ): Promise { - // Check if reference field already exists - const existingFields = Object.keys(sourceModel.properties || {}); - if (existingFields.includes(referenceFieldName)) { - throw new Error(`Field '${referenceFieldName}' already exists in model ${sourceModel.name}`); - } + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); - const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + // Generate the reference field code + const fieldCode = this.generateReferenceFieldCode(referenceFieldName, targetModelName); - // Generate the reference field code - const fieldCode = this.generateReferenceFieldCode(referenceFieldName, targetModelName); + // Add required imports + const requiredImports = new Set(["Field", "Reference"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); - // Add required imports - const requiredImports = new Set(["Field", "Reference"]); - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + // Add import for the target model + await this.sourceCodeService.addModelImport(document, targetModelName, edit, cache); - // Find class boundaries and add field - const lines = document.getText().split("\n"); - const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModel.name); + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModel.name); - edit.insert(sourceModel.declaration.uri, new vscode.Position(classEndLine, 0), `\n${fieldCode}\n`); - } + edit.insert(sourceModel.declaration.uri, new vscode.Position(classEndLine, 0), `\n${fieldCode}\n`); + } - /** - * Generates the reference field code. - */ - private generateReferenceFieldCode(fieldName: string, targetModelName: string): string { - const lines: string[] = []; + /** + * Generates the reference field code. + */ + private generateReferenceFieldCode(fieldName: string, targetModelName: string): string { + const lines: string[] = []; - // Add Field decorator - lines.push(" @Field({})"); + // Add Field decorator + lines.push(" @Field({})"); - // Add Reference decorator - lines.push(" @Reference()"); - - // Add property declaration - lines.push(` ${fieldName}!: ${targetModelName};`); - - return lines.join("\n"); - } + // Add Reference decorator + lines.push(" @Reference()"); + // Add property declaration + lines.push(` ${fieldName}!: ${targetModelName};`); -} \ No newline at end of file + return lines.join("\n"); + } +} diff --git a/src/refactor/RefactorController.ts b/src/refactor/RefactorController.ts index fc29590..f64ae85 100644 --- a/src/refactor/RefactorController.ts +++ b/src/refactor/RefactorController.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { ChangeObject, IRefactorTool, ManualRefactorContext, DeleteModelPayload, RenameModelPayload } from "./refactorInterfaces"; +import { ChangeObject, IRefactorTool, ManualRefactorContext, DeleteModelPayload, RenameModelPayload, ExtractFieldsToReferencePayload } from "./refactorInterfaces"; import { findNodeAtPosition } from "../utils/ast"; import { MetadataCache } from "../cache/cache"; import { AppTreeItem } from "../explorer/appTreeItem"; @@ -219,16 +219,16 @@ export class RefactorController { this.isApplyingEdit = true; try { - const success = await vscode.workspace.applyEdit(confirmedEdit); + const success = await vscode.workspace.applyEdit(workspaceEdit,{isRefactoring: true}); if (success) { await vscode.workspace.saveAll(false); const changesToProcess = allChanges || [changeObject]; + // Check for compilation errors after applying changes const changesWithPrompts = changesToProcess.filter(change => { const tool = this.changeHandlerMap.get(change.type); return tool?.executePrompt; }); - // Ask user if they want to execute prompts to analyze changes and fix errors if (changesWithPrompts.length > 0) { const modifiedUris = this.collectModifiedUris(workspaceEdit, changesToProcess); @@ -244,16 +244,17 @@ export class RefactorController { "No, Skip Analysis" ); - if (promptConfirmation === "Yes, Analyze Changes") { - // Execute custom prompts for the changes - for (const change of changesWithPrompts) { - const tool = this.changeHandlerMap.get(change.type); - if (tool?.executePrompt) { - try { - await tool.executePrompt(change); - } catch (error) { - console.error(`Error executing prompt for change ${change.type}:`, error); - vscode.window.showWarningMessage(`Failed to execute analysis for ${change.description}: ${error}`); + if (promptConfirmation === "Yes, Analyze Errors") { + // Execute custom prompts for the changes + for (const change of changesWithPrompts) { + const tool = this.changeHandlerMap.get(change.type); + if (tool?.executePrompt) { + try { + await tool.executePrompt(change); + } catch (error) { + console.error(`Error executing prompt for change ${change.type}:`, error); + vscode.window.showWarningMessage(`Failed to execute analysis for ${change.description}: ${error}`); + } } } } @@ -322,13 +323,17 @@ export class RefactorController { const mergedEdit = new vscode.WorkspaceEdit(); const modifiedRanges = new Set(); const allUniqueEdits = new Map(); + const fileOperations = new Set(); // Track file operations to avoid duplicates + let editFromTool: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); for (const change of changes) { const tool = this.changeHandlerMap.get(change.type); if (tool) { try { - const editFromTool = await tool.prepareEdit(change, this.cache); + editFromTool = await tool.prepareEdit(change, this.cache); + + // Handle text edits with deduplication for (const [uri, textEdits] of editFromTool.entries()) { const uriString = uri.toString(); const existingEdits = allUniqueEdits.get(uriString) || []; @@ -340,23 +345,49 @@ export class RefactorController { existingEdits.push(edit); } } - // Note: modifiedRanges was not part of the original payload interface - // change.payload.modifiedRanges = Array.from(modifiedRanges); if (existingEdits.length > 0) { allUniqueEdits.set(uriString, existingEdits); } + } + // Handle file creation operations from change payload + if ('urisToCreate' in change.payload && Array.isArray(change.payload.urisToCreate)) { + for (const createInfo of change.payload.urisToCreate) { + const createOpId = `CREATE::${createInfo.uri.toString()}`; + if (!fileOperations.has(createOpId)) { + fileOperations.add(createOpId); + // If content is provided, create file with content, otherwise just create the file + if (createInfo.content !== undefined) { + mergedEdit.createFile(createInfo.uri, { + ignoreIfExists: true, + contents: Buffer.from(createInfo.content, 'utf8') + }); + } else { + mergedEdit.createFile(createInfo.uri, { ignoreIfExists: true }); + } + } + } } + // Handle delete operations from change payload if ('urisToDelete' in change.payload && Array.isArray((change.payload as any).urisToDelete)) { for (const uri of (change.payload as any).urisToDelete) { - mergedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); + const deleteOpId = `DELETE::${uri.toString()}`; + if (!fileOperations.has(deleteOpId)) { + fileOperations.add(deleteOpId); + mergedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); + } } } + // Handle rename operations from change payload if ('newUri' in change.payload && (change.payload as any).newUri) { - mergedEdit.renameFile(change.uri, (change.payload as any).newUri); + const renameOpId = `RENAME::${change.uri.toString()}::${(change.payload as any).newUri.toString()}`; + if (!fileOperations.has(renameOpId)) { + fileOperations.add(renameOpId); + mergedEdit.renameFile(change.uri, (change.payload as any).newUri); + } } } catch (error) { @@ -366,10 +397,11 @@ export class RefactorController { } } + // Apply all unique text edits for (const [uriString, edits] of allUniqueEdits) { mergedEdit.set(vscode.Uri.parse(uriString), edits); } - return mergedEdit; + return editFromTool; } /** @@ -476,4 +508,4 @@ export class RefactorController { public getTools(): IRefactorTool[] { return this.tools; } -} +} \ No newline at end of file diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index a303d76..0d0ebbb 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -1,6 +1,15 @@ import * as vscode from 'vscode'; import { DataSourceMetadata, DecoratedClass, DecoratorMetadata, FileMetadata, MetadataCache, PropertyMetadata } from '../cache/cache'; import { TreeViewContext } from '../commands/commandHelpers'; +import { ChangeCompositionToReferencePayload } from './tools/changeCompositionToReference'; + +/** + * Information about a file to be created. + */ +export interface FileCreationInfo { + uri: vscode.Uri; + content?: string; // Optional content for the file +} /** * Common properties shared across all refactoring payloads. @@ -174,27 +183,7 @@ export interface BaseExtractFieldsPayload { sourceModelName: string; fieldsToExtract: PropertyMetadata[]; isManual: boolean; -} -export interface ExtractFieldsToCompositionPayload extends BaseExtractFieldsPayload { - compositionFieldName: string; -} - - -/** - * Payload interface for extracting fields to a reference model. - * This involves moving selected fields from a source model to a new reference model in a separate file - * and creating a reference relationship between them. - * - * @property {string} sourceModelName - The name of the source model containing the fields to extract - * @property {string} newModelName - The name of the new reference model to be created - * @property {string} referenceFieldName - The name of the new reference field to be created - * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted - * @property {boolean} isManual - Whether the extraction was initiated manually by the user - */ -export interface ExtractFieldsToReferencePayload { - sourceModelName: string; - newModelName: string; - referenceFieldName: string; + urisToCreate?: FileCreationInfo[]; } /** @@ -220,15 +209,12 @@ export interface ExtractFieldsToCompositionPayload extends BaseExtractFieldsPayl * @property {string} sourceModelName - The name of the source model containing the fields to extract * @property {string} newModelName - The name of the new reference model to be created * @property {string} referenceFieldName - The name of the new reference field to be created - * @property {PropertyMetadata[]} fieldsToExtract - The field metadata for all fields being extracted - * @property {boolean} isManual - Whether the extraction was initiated manually by the user + * @property {fileCreationInfo?: FileCreationInfo} - Optional info for creating the new model file */ -export interface ExtractFieldsToReferencePayload { +export interface ExtractFieldsToReferencePayload extends BaseExtractFieldsPayload { sourceModelName: string; newModelName: string; referenceFieldName: string; - fieldsToExtract: PropertyMetadata[]; - isManual: boolean; } export interface DeleteDataSourcePayload extends BasePayload { @@ -252,7 +238,7 @@ export type ChangePayloadMap = { 'RENAME_DATA_SOURCE': RenameDataSourcePayload; 'DELETE_DATA_SOURCE': DeleteDataSourcePayload; 'CHANGE_REFERENCE_TO_COMPOSITION': ChangeReferenceToCompositionPayload; - 'CHANGE_COMPOSITION_TO_REFERENCE': ChangeReferenceToCompositionPayload; + 'CHANGE_COMPOSITION_TO_REFERENCE': ChangeCompositionToReferencePayload; 'EXTRACT_FIELDS_TO_COMPOSITION': ExtractFieldsToCompositionPayload; 'EXTRACT_FIELDS_TO_REFERENCE': ExtractFieldsToReferencePayload; // Add more change types and their payloads as needed diff --git a/src/refactor/tools/addDecorator.ts b/src/refactor/tools/addDecorator.ts index feac7de..fe4dce3 100644 --- a/src/refactor/tools/addDecorator.ts +++ b/src/refactor/tools/addDecorator.ts @@ -121,7 +121,7 @@ export class AddDecoratorTool implements IRefactorTool { const indentation = fieldLine.text.substring(0, fieldLine.firstNonWhitespaceCharacterIndex); const textToInsert = `@${decoratorName}()\n${indentation}`; const insertPosition = new vscode.Position(fieldMetadata.declaration.range.start.line, fieldMetadata.declaration.range.start.character); - workspaceEdit.insert(fieldMetadata.declaration.uri, insertPosition, textToInsert); + workspaceEdit.insert(fieldMetadata.declaration.uri, insertPosition, textToInsert, {label: `Add @${decoratorName} decorator to '${fieldMetadata.name}'`, needsConfirmation: true}); return workspaceEdit; } diff --git a/src/refactor/tools/changeCompositionToReference.ts b/src/refactor/tools/changeCompositionToReference.ts index 49dbf02..af2e93d 100644 --- a/src/refactor/tools/changeCompositionToReference.ts +++ b/src/refactor/tools/changeCompositionToReference.ts @@ -136,7 +136,7 @@ export class ChangeCompositionToReferenceRefactorTool implements IRefactorTool { } as any; const tool = new ChangeCompositionToReferenceTool(explorerProvider); - await tool.changeCompositionToReference( + await tool.changeCompositionToReference( cache, payload.sourceModelName, payload.fieldName diff --git a/src/refactor/tools/changeFieldType.ts b/src/refactor/tools/changeFieldType.ts index 7c2d6d7..f78e641 100644 --- a/src/refactor/tools/changeFieldType.ts +++ b/src/refactor/tools/changeFieldType.ts @@ -280,18 +280,18 @@ export class ChangeFieldTypeTool implements IRefactorTool { : `@${newType}()`; if (isReplacing) { - workspaceEdit.replace(change.uri, positionToActOn, decoratorString); + workspaceEdit.replace(change.uri, positionToActOn, decoratorString, {label: `Change field '${field.name}' type to '${newType}'`, needsConfirmation: true}); } else { const document = await vscode.workspace.openTextDocument(change.uri); const decoratorLine = document.lineAt(positionToActOn.start.line); const indentation = decoratorLine.text.substring(0, decoratorLine.firstNonWhitespaceCharacterIndex); const textToInsert = `${decoratorString}\n${indentation}`; - workspaceEdit.insert(change.uri, positionToActOn.start, textToInsert); + workspaceEdit.insert(change.uri, positionToActOn.start, textToInsert, {label: `Add @${newType} decorator to '${field.name}'`, needsConfirmation: true} ); } const typeCorrectionEdit = await this.validateAndCorrectType(field, newType, change.uri); if (typeCorrectionEdit) { - workspaceEdit.replace(change.uri, typeCorrectionEdit.range, typeCorrectionEdit.newText); + workspaceEdit.replace(change.uri, typeCorrectionEdit.range, typeCorrectionEdit.newText, {label: `Replace type of '${field.name}' to '${typeCorrectionEdit.newText}'`, needsConfirmation: true}); } } return workspaceEdit; @@ -338,17 +338,17 @@ export class ChangeFieldTypeTool implements IRefactorTool { const enumString = `\n\nexport enum ${enumName} {\n${enumMembers}\n}`; const document = await vscode.workspace.openTextDocument(uri); const endOfFile = document.lineAt(document.lineCount - 1).range.end; - workspaceEdit.insert(uri, endOfFile, enumString); + workspaceEdit.insert(uri, endOfFile, enumString, {label: `Create enum '${enumName}'`, needsConfirmation: true}); // Generate the new @Choice decorator const labels = values.map(v => ` ${v}: "${this.toTitleCase(v)}"`).join(',\n'); const decoratorString = `@Choice<${enumName}>({\n labels: {\n${labels}\n }\n })`; - workspaceEdit.replace(uri, decoratorPosition, decoratorString); + workspaceEdit.replace(uri, decoratorPosition, decoratorString, {label: `Change field '${field.name}' type to 'Choice'`, needsConfirmation: true}); // Create an edit to change the property's type from 'string' to the new enum name const typeCorrectionEdit = await this.validateAndCorrectType(field, enumName, uri, true); if (typeCorrectionEdit) { - workspaceEdit.replace(uri, typeCorrectionEdit.range, typeCorrectionEdit.newText); + workspaceEdit.replace(uri, typeCorrectionEdit.range, typeCorrectionEdit.newText, {label: `Change type of '${field.name}' to '${enumName}'`, needsConfirmation: true}); } } diff --git a/src/refactor/tools/deleteDataSource.ts b/src/refactor/tools/deleteDataSource.ts index 2516557..5607b73 100644 --- a/src/refactor/tools/deleteDataSource.ts +++ b/src/refactor/tools/deleteDataSource.ts @@ -79,7 +79,7 @@ export class DeleteDataSourceTool implements IRefactorTool { async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.deleteFile(change.uri); + workspaceEdit.deleteFile(change.uri, {}, {label: `Delete data source file`, needsConfirmation: true}); return workspaceEdit; } } \ No newline at end of file diff --git a/src/refactor/tools/deleteField.ts b/src/refactor/tools/deleteField.ts index 403d923..62d011c 100644 --- a/src/refactor/tools/deleteField.ts +++ b/src/refactor/tools/deleteField.ts @@ -236,8 +236,8 @@ export class DeleteFieldTool implements IRefactorTool { continue; } - workspaceEdit.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */'); - edit?.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */'); + workspaceEdit.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */', {label: `Reference to deleted field '${field.name}'`, needsConfirmation: true}); + edit?.replace(ref.uri, ref.range, '/* DELETED_FIELD_REFERENCE */',{label: `Reference to deleted field '${field.name}'`, needsConfirmation: true}); } } @@ -255,8 +255,8 @@ export class DeleteFieldTool implements IRefactorTool { const endLine = doc.lineAt(field.declaration.range.end.line); const fullRangeToDelete = new vscode.Range(startPosition, endLine.rangeIncludingLineBreak.end); - workspaceEdit.delete(field.declaration.uri, fullRangeToDelete); - edit?.delete(field.declaration.uri, fullRangeToDelete); + workspaceEdit.delete(field.declaration.uri, fullRangeToDelete, {label: `Delete field '${field.name}'`, needsConfirmation: true} ); + edit?.delete(field.declaration.uri, fullRangeToDelete, {label: `Delete field '${field.name}'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/refactor/tools/deleteModel.ts b/src/refactor/tools/deleteModel.ts index c40f5f8..7d0a75c 100644 --- a/src/refactor/tools/deleteModel.ts +++ b/src/refactor/tools/deleteModel.ts @@ -257,11 +257,11 @@ export class DeleteModelTool implements IRefactorTool { const doc = await vscode.workspace.openTextDocument(ref.uri); const line = doc.lineAt(ref.range.start.line); if (!line.isEmptyOrWhitespace) { - workspaceEdit.delete(ref.uri, line.rangeIncludingLineBreak); + workspaceEdit.delete(ref.uri, line.rangeIncludingLineBreak, {label: `Delete reference to deleted model '${deletedModelName}'`, needsConfirmation: true}); } } catch (e) { console.error(`Could not process reference in ${ref.uri.fsPath}:`, e); - workspaceEdit.replace(ref.uri, ref.range, "/* DELETED_REFERENCE */"); + workspaceEdit.replace(ref.uri, ref.range, "/* DELETED_REFERENCE */", {label: `Reference to deleted model '${deletedModelName}'`, needsConfirmation: true} ); } } @@ -349,12 +349,12 @@ export class DeleteModelTool implements IRefactorTool { new vscode.Position(actualEndLine + 1, 0) ); - workspaceEdit.delete(fileUri, rangeToDelete); + workspaceEdit.delete(fileUri, rangeToDelete, {label: `Delete model class '${modelMetadata.name}'`, needsConfirmation: true}); } catch (error) { console.error(`Error deleting model class from file ${fileUri.fsPath}:`, error); // Fallback: just comment out the class declaration - workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`); + workspaceEdit.replace(fileUri, modelMetadata.declaration.range, `/* DELETED_MODEL: ${modelMetadata.name} */`, {label: `Comment out model class '${modelMetadata.name}'`, needsConfirmation: true} ); } } @@ -434,7 +434,7 @@ export class DeleteModelTool implements IRefactorTool { // Apply all deletions for (const range of rangesToDelete) { - workspaceEdit.delete(field.declaration.uri, range); + workspaceEdit.delete(field.declaration.uri, range, {label: `Delete decorator for field '${field.name}'`, needsConfirmation: true} ); } } catch (e) { diff --git a/src/refactor/tools/renameDataSource.ts b/src/refactor/tools/renameDataSource.ts index b1346ec..361eb84 100644 --- a/src/refactor/tools/renameDataSource.ts +++ b/src/refactor/tools/renameDataSource.ts @@ -119,19 +119,19 @@ export class RenameDataSourceTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to data source '${oldName}'`, needsConfirmation: true} ); } // If it's a manual rename, we also need to change the declaration if (isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename data source declaration from '${oldName}' to '${newName}'`, needsConfirmation: true} ); } // Rename the file if its name matches the old data source name const oldFileName = oldUri.path.split('/').pop()?.replace('.ts', ''); if (oldFileName === oldName) { const newUri = vscode.Uri.joinPath(oldUri, '..', `${newName}.ts`); - workspaceEdit.renameFile(oldUri, newUri); + workspaceEdit.renameFile(oldUri, newUri, {}, {label: `Rename data source file from '${oldName}.ts' to '${newName}.ts'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/refactor/tools/renameField.ts b/src/refactor/tools/renameField.ts index eb0fb45..5c44b8d 100644 --- a/src/refactor/tools/renameField.ts +++ b/src/refactor/tools/renameField.ts @@ -225,11 +225,11 @@ export class RenameFieldTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to field '${oldFieldMetadata.name}'`, needsConfirmation: true} ); } if (change.payload.isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename field declaration from '${oldFieldMetadata.name}' to '${newName}'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/refactor/tools/renameModel.ts b/src/refactor/tools/renameModel.ts index a076dc6..930581d 100644 --- a/src/refactor/tools/renameModel.ts +++ b/src/refactor/tools/renameModel.ts @@ -186,11 +186,11 @@ export class RenameModelTool implements IRefactorTool { if (ref.uri.fsPath === declarationUri.fsPath && areRangesEqual(ref.range, declarationRange)) { continue; } - workspaceEdit.replace(ref.uri, ref.range, newName); + workspaceEdit.replace(ref.uri, ref.range, newName, {label: `Update reference to model '${oldModelMetadata.name}'`, needsConfirmation: true} ); } if (change.payload.isManual) { - workspaceEdit.replace(declarationUri, declarationRange, newName); + workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename model declaration from '${oldModelMetadata.name}' to '${newName}'`, needsConfirmation: true} ); } return workspaceEdit; diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index cd383f8..38e550f 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -7,6 +7,7 @@ import { FileSystemService } from "./fileSystemService"; import { ProjectAnalysisService } from "./projectAnalysisService"; export class SourceCodeService { + private fileSystemService: FileSystemService; private projectAnalysisService: ProjectAnalysisService; constructor() { @@ -25,14 +26,14 @@ export class SourceCodeService { const edit = new vscode.WorkspaceEdit(); const lines = document.getText().split("\n"); const newImports = new Set(["Field", fieldInfo.type.decorator]); - if(fieldInfo.type.decorator === "Composition") { + if (fieldInfo.type.decorator === "Composition") { newImports.add("PersistentComponentModel"); } await this.ensureSlingrFrameworkImports(document, edit, newImports); if (importModel && fieldInfo.additionalConfig) { - await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); + await this.addModelImport(document, fieldInfo.additionalConfig.targetModel, edit, cache); } const { classEndLine } = this.findClassBoundaries(lines, modelClassName); @@ -121,6 +122,7 @@ export class SourceCodeService { } } + /** * Adds an import for a target model type. */ @@ -391,7 +393,7 @@ export class SourceCodeService { /** * Extracts the complete class body (everything between the class braces) from a model. - * + * * @param document - The document containing the model * @param className - The name of the class to extract from * @returns The class body content including proper indentation @@ -399,7 +401,7 @@ export class SourceCodeService { public extractClassBody(document: vscode.TextDocument, className: string): string { const lines = document.getText().split("\n"); const { classStartLine, classEndLine } = this.findClassBoundaries(lines, className); - + // Find the opening brace of the class let openBraceIndex = -1; for (let i = classStartLine; i <= classEndLine; i++) { @@ -408,25 +410,25 @@ export class SourceCodeService { break; } } - + if (openBraceIndex === -1) { throw new Error(`Could not find opening brace for class ${className}`); } - + // Extract content between the braces (excluding the braces themselves) const classBodyLines = lines.slice(openBraceIndex + 1, classEndLine); - + // Remove any empty lines at the end while (classBodyLines.length > 0 && classBodyLines[classBodyLines.length - 1].trim() === "") { classBodyLines.pop(); } - + return classBodyLines.join("\n"); } /** * Creates a complete model file with the given class body content. - * + * * @param modelName - The name of the new model class * @param classBody - The complete class body content * @param baseClass - The base class to extend (default: "PersistentModel") @@ -444,28 +446,28 @@ export class SourceCodeService { isComponent: boolean = false ): string { const lines: string[] = []; - + // Determine required imports const imports = new Set(["Model", "Field"]); - + // Add base class to imports (handle complex base classes like PersistentComponentModel) - const baseClassCore = baseClass.split('<')[0]; // Extract base class name before generic + const baseClassCore = baseClass.split("<")[0]; // Extract base class name before generic imports.add(baseClassCore); - + // Add existing imports if provided if (existingImports) { - existingImports.forEach(imp => imports.add(imp)); + existingImports.forEach((imp) => imports.add(imp)); } - + // Analyze the class body to determine additional needed imports const bodyImports = this.extractImportsFromClassBody(classBody); - bodyImports.forEach(imp => imports.add(imp)); - + bodyImports.forEach((imp) => imports.add(imp)); + // Add import statement const sortedImports = Array.from(imports).sort(); lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); - lines.push(''); - + lines.push(""); + // Add model decorator if (dataSource) { lines.push(`@Model({`); @@ -474,64 +476,84 @@ export class SourceCodeService { } else { lines.push(`@Model()`); } - + // Add class declaration (export only if not a component model) const exportKeyword = isComponent ? "" : "export "; lines.push(`${exportKeyword}class ${modelName} extends ${baseClass} {`); - + // Add class body (if not empty) if (classBody.trim()) { - lines.push(''); + lines.push(""); lines.push(classBody); - lines.push(''); + lines.push(""); } - + lines.push(`}`); - + return lines.join("\n"); } /** * Analyzes class body content to determine which imports are needed. - * + * * @param classBody - The class body content to analyze * @returns Set of import names that should be included */ private extractImportsFromClassBody(classBody: string): Set { const imports = new Set(); - + // Look for decorator patterns const decoratorPatterns = [ - /@Text\b/g, /@LongText\b/g, /@Email\b/g, /@Html\b/g, - /@Integer\b/g, /@Money\b/g, /@Number\b/g, /@Boolean\b/g, - /@Date\b/g, /@DateRange\b/g, /@Choice\b/g, - /@Reference\b/g, /@Composition\b/g, /@Relationship\b/g + /@Text\b/g, + /@LongText\b/g, + /@Email\b/g, + /@Html\b/g, + /@Integer\b/g, + /@Money\b/g, + /@Number\b/g, + /@Boolean\b/g, + /@Date\b/g, + /@DateRange\b/g, + /@Choice\b/g, + /@Reference\b/g, + /@Composition\b/g, + /@Relationship\b/g, ]; - + const decoratorNames = [ - "Text", "LongText", "Email", "Html", - "Integer", "Money", "Number", "Boolean", - "Date", "DateRange", "Choice", - "Reference", "Composition", "Relationship" + "Text", + "LongText", + "Email", + "Html", + "Integer", + "Money", + "Number", + "Boolean", + "Date", + "DateRange", + "Choice", + "Reference", + "Composition", + "Relationship", ]; - + decoratorPatterns.forEach((pattern, index) => { if (pattern.test(classBody)) { imports.add(decoratorNames[index]); } }); - + // Always include Field if there are any field declarations if (classBody.includes("!:") || classBody.includes(":")) { imports.add("Field"); } - + return imports; } /** * Extracts all model imports from a document (excluding slingr-framework imports). - * + * * @param document - The document to extract imports from * @returns Array of import statements for other models */ @@ -539,19 +561,21 @@ export class SourceCodeService { const content = document.getText(); const lines = content.split("\n"); const modelImports: string[] = []; - + for (const line of lines) { // Look for import statements that are not from slingr-framework - if (line.includes("import") && - line.includes("from") && - !line.includes("slingr-framework") && - !line.includes("vscode") && - !line.includes("path") && - line.trim().startsWith("import")) { + if ( + line.includes("import") && + line.includes("from") && + !line.includes("slingr-framework") && + !line.includes("vscode") && + !line.includes("path") && + line.trim().startsWith("import") + ) { modelImports.push(line); } } - + return modelImports; } @@ -578,36 +602,43 @@ export class SourceCodeService { // Look for different patterns in order of specificity for (let i = 0; i < lines.length; i++) { const line = lines[i]; - + // Pattern 1: Property declarations (fieldName!: Type or fieldName: Type) if (line.includes(`${elementName}!:`) || line.includes(`${elementName}:`)) { elementLine = i; elementIndex = line.indexOf(elementName); break; } - + // Pattern 2: Method declarations (methodName() or methodName( - if (line.includes(`${elementName}(`) && (line.includes('function') || line.includes('){') || line.includes(') {'))) { + if ( + line.includes(`${elementName}(`) && + (line.includes("function") || line.includes("){") || line.includes(") {")) + ) { elementLine = i; elementIndex = line.indexOf(elementName); break; } - + // Pattern 3: Class declarations (class ClassName) if (line.includes(`class ${elementName}`)) { elementLine = i; elementIndex = line.indexOf(elementName); break; } - + // Pattern 4: Variable declarations (const elementName, let elementName, var elementName) - if ((line.includes(`const ${elementName}`) || line.includes(`let ${elementName}`) || line.includes(`var ${elementName}`)) && - (line.includes('=') || line.includes(';'))) { + if ( + (line.includes(`const ${elementName}`) || + line.includes(`let ${elementName}`) || + line.includes(`var ${elementName}`)) && + (line.includes("=") || line.includes(";")) + ) { elementLine = i; elementIndex = line.indexOf(elementName); break; } - + // Pattern 5: General word boundary match (as fallback) const wordBoundaryRegex = new RegExp(`\\b${elementName}\\b`); if (wordBoundaryRegex.test(line)) { @@ -640,29 +671,29 @@ export class SourceCodeService { /** * Deletes a specific model class from a file that contains multiple models. - * + * * @param fileUri - The URI of the file containing the model * @param modelMetadata - The metadata of the model to delete * @param workspaceEdit - The workspace edit to add the deletion to */ public async deleteModelClassFromFile( - fileUri: vscode.Uri, - modelMetadata: any, + fileUri: vscode.Uri, + modelMetadata: any, workspaceEdit: vscode.WorkspaceEdit ): Promise { try { const document = await vscode.workspace.openTextDocument(fileUri); const text = document.getText(); - const lines = text.split('\n'); - + const lines = text.split("\n"); + // Find the class declaration range const classDeclaration = modelMetadata.declaration; const startLine = classDeclaration.range.start.line; const endLine = classDeclaration.range.end.line; - + // Find the @Model decorator using cache information let actualStartLine = startLine; - + // Check if the model has decorators in the cache if (modelMetadata.decorators && modelMetadata.decorators.length > 0) { // Find the @Model decorator specifically @@ -672,14 +703,14 @@ export class SourceCodeService { actualStartLine = Math.min(actualStartLine, modelDecorator.position.start.line); } } - + // Also look backwards to find any other decorators and comments that belong to this class for (let i = actualStartLine - 1; i >= 0; i--) { const line = lines[i].trim(); - if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.endsWith('*/')) { + if (line === "" || line.startsWith("//") || line.startsWith("/*") || line.endsWith("*/")) { // Empty lines, single-line comments, or comment blocks - continue looking actualStartLine = i; - } else if (line.startsWith('@')) { + } else if (line.startsWith("@")) { // Decorator - include it actualStartLine = i; } else { @@ -687,20 +718,20 @@ export class SourceCodeService { break; } } - + // Look forward to find the complete class body (including closing brace) let actualEndLine = endLine; let braceCount = 0; let foundOpenBrace = false; - + for (let i = startLine; i < lines.length; i++) { const line = lines[i]; - + for (const char of line) { - if (char === '{') { + if (char === "{") { braceCount++; foundOpenBrace = true; - } else if (char === '}') { + } else if (char === "}") { braceCount--; if (foundOpenBrace && braceCount === 0) { actualEndLine = i; @@ -708,25 +739,24 @@ export class SourceCodeService { } } } - + if (foundOpenBrace && braceCount === 0) { break; } } - + // Include any trailing empty lines that belong to this class - while (actualEndLine + 1 < lines.length && lines[actualEndLine + 1].trim() === '') { + while (actualEndLine + 1 < lines.length && lines[actualEndLine + 1].trim() === "") { actualEndLine++; } - + // Create the range to delete (include the newline of the last line) const rangeToDelete = new vscode.Range( new vscode.Position(actualStartLine, 0), new vscode.Position(actualEndLine + 1, 0) ); - + workspaceEdit.delete(fileUri, rangeToDelete); - } catch (error) { console.error(`Error deleting model class from file ${fileUri.fsPath}:`, error); // Fallback: just comment out the class declaration @@ -738,36 +768,36 @@ export class SourceCodeService { * Extracts enums that are related to a model's Choice fields. * This analyzes the model's properties and identifies any enums * that are referenced in @Choice decorators. - * + * * @param sourceDocument - The document containing the model * @param componentModel - The model metadata to analyze * @param classBody - The class body content (optional optimization) * @returns Array of enum definition strings */ public async extractRelatedEnums( - sourceDocument: vscode.TextDocument, - componentModel: any, + sourceDocument: vscode.TextDocument, + componentModel: any, classBody?: string ): Promise { const relatedEnums: string[] = []; const sourceContent = sourceDocument.getText(); - + // Find all Choice fields in the component model - const choiceFields = Object.values(componentModel.properties || {}).filter((property: any) => + const choiceFields = Object.values(componentModel.properties || {}).filter((property: any) => property.decorators?.some((decorator: any) => decorator.name === "Choice") ); - + if (choiceFields.length === 0) { return relatedEnums; } - + // For each Choice field, try to find referenced enums for (const field of choiceFields) { const choiceDecorator = (field as any).decorators?.find((d: any) => d.name === "Choice"); if (choiceDecorator) { // Look for enum references in the property type declaration const enumNames = this.extractEnumNamesFromChoiceProperty(field); - + for (const enumName of enumNames) { // Find the enum definition in the source file const enumDefinition = this.extractEnumDefinition(sourceContent, enumName); @@ -777,7 +807,7 @@ export class SourceCodeService { } } } - + return relatedEnums; } @@ -788,23 +818,23 @@ export class SourceCodeService { */ private extractEnumNamesFromChoiceProperty(property: any): string[] { const enumNames: string[] = []; - + // The enum name is in the property's type field - if (property.type && typeof property.type === 'string') { + if (property.type && typeof property.type === "string") { // Remove array brackets if present (e.g., "TaskStatus[]" -> "TaskStatus") - const cleanType = property.type.replace(/\[\]$/, ''); - + const cleanType = property.type.replace(/\[\]$/, ""); + // Check if this looks like an enum (starts with uppercase, follows enum naming conventions) // Also exclude common TypeScript types that aren't enums - const isCommonType = ['string', 'number', 'boolean', 'Date', 'any', 'object', 'void'].includes(cleanType); + const isCommonType = ["string", "number", "boolean", "Date", "any", "object", "void"].includes(cleanType); const enumMatch = cleanType.match(/^[A-Z][a-zA-Z0-9_]*$/); - + if (enumMatch && !isCommonType) { enumNames.push(cleanType); console.log(`Found potential enum "${cleanType}" in Choice field "${property.name}"`); } } - + return enumNames; } @@ -813,16 +843,13 @@ export class SourceCodeService { */ private extractEnumDefinition(sourceContent: string, enumName: string): string | null { // Create regex to match enum definition including export keyword - const enumRegex = new RegExp( - `(export\\s+)?enum\\s+${enumName}\\s*\\{[^}]*\\}`, - 'gs' - ); - + const enumRegex = new RegExp(`(export\\s+)?enum\\s+${enumName}\\s*\\{[^}]*\\}`, "gs"); + const match = enumRegex.exec(sourceContent); if (match) { return match[0]; } - + return null; } @@ -833,29 +860,29 @@ export class SourceCodeService { if (enums.length === 0) { return modelFileContent; } - - const lines = modelFileContent.split('\n'); - + + const lines = modelFileContent.split("\n"); + // Find the position to insert enums (after imports, before the model class) let insertPosition = 0; for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('import ')) { + if (lines[i].startsWith("import ")) { insertPosition = i + 1; - } else if (lines[i].trim() === '' && insertPosition > 0) { + } else if (lines[i].trim() === "" && insertPosition > 0) { // Found empty line after imports insertPosition = i; break; - } else if (lines[i].includes('@Model') || lines[i].includes('class ')) { + } else if (lines[i].includes("@Model") || lines[i].includes("class ")) { // Found the start of the model definition break; } } - + // Insert enums with proper spacing - const enumContent = enums.join('\n\n') + '\n\n'; + const enumContent = enums.join("\n\n") + "\n\n"; lines.splice(insertPosition, 0, enumContent); - - return lines.join('\n'); + + return lines.join("\n"); } /** @@ -863,40 +890,39 @@ export class SourceCodeService { */ public extractEnumDefinitions(document: vscode.TextDocument): string[] { const content = document.getText(); - const lines = content.split('\n'); + const lines = content.split("\n"); const enumDefinitions: string[] = []; - + let currentEnum: string[] = []; let inEnum = false; let braceCount = 0; - + for (let i = 0; i < lines.length; i++) { const line = lines[i]; - + // Check if we're starting an enum - if (line.trim().startsWith('export enum ') || line.trim().startsWith('enum ')) { + if (line.trim().startsWith("export enum ") || line.trim().startsWith("enum ")) { inEnum = true; braceCount = 0; } - + if (inEnum) { currentEnum.push(line); - + // Count braces const openBraces = (line.match(/{/g) || []).length; const closeBraces = (line.match(/}/g) || []).length; braceCount += openBraces - closeBraces; - + // If we've closed all braces, we're done with this enum - if (braceCount === 0 && line.includes('}')) { + if (braceCount === 0 && line.includes("}")) { inEnum = false; - enumDefinitions.push(currentEnum.join('\n')); + enumDefinitions.push(currentEnum.join("\n")); currentEnum = []; } } } - + return enumDefinitions; } - } From b70559cfbe562653e78bb8d71898c6e0d46c78ae Mon Sep 17 00:00:00 2001 From: Luciano Date: Fri, 19 Sep 2025 13:40:29 -0300 Subject: [PATCH 22/36] Fixed extractFieldsToCompositionTool to add correctly the edits and updated explorer to detect new 'Composition' decorators. --- .../fields/extractFieldsToComposition.ts | 17 +++++++---------- src/explorer/explorerProvider.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index d39603d..21c9fed 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -155,6 +155,8 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { */ async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { const payload = change.payload as ExtractFieldsToCompositionPayload; + let edit = new vscode.WorkspaceEdit(); + let innerModelName = ""; try { const sourceModel = cache.getModelByName(payload.sourceModelName); @@ -169,23 +171,22 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { const fieldsToAdd = payload.fieldsToExtract; // These are already PropertyMetadata objects // Create the composition with the fields included - const { edit: compositionEdit, innerModelName } = await this.createCompositionWithFields( + const result = await this.createCompositionWithFields( cache, payload.sourceModelName, payload.compositionFieldName, fieldsToAdd ); + edit = result.edit; + innerModelName = result.innerModelName; - // Merge composition edit - this.mergeWorkspaceEdits(combinedEdit, compositionEdit); // Step 2: Remove the fields from the source model for (const field of payload.fieldsToExtract) { - const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); - this.mergeWorkspaceEdits(combinedEdit, deleteEdit); + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache,edit); } - return combinedEdit; + return edit; } catch (error) { vscode.window.showErrorMessage(`Failed to prepare extract fields to composition edit: ${error}`); throw error; @@ -586,10 +587,6 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { // Generate the field code const fieldCode = this.generateCompositionFieldCode(fieldInfo, innerModelName, isArray); - // Add required imports - const requiredImports = new Set(["Field", "Composition"]); - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); - // Find class boundaries and add field const lines = document.getText().split("\n"); const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); diff --git a/src/explorer/explorerProvider.ts b/src/explorer/explorerProvider.ts index 6b66d5b..436d492 100644 --- a/src/explorer/explorerProvider.ts +++ b/src/explorer/explorerProvider.ts @@ -1044,7 +1044,16 @@ export class ExplorerProvider if (hasFieldDecorator) { // Check if this property has a @Relationship decorator with type: "Composition" const relationshipDecorator = property.decorators.find((d) => d.name === "Relationship"); + const compositionDecorator = property.decorators.find((d) => d.name === "Composition"); + // If there's a @Composition decorator, we can directly consider it + if (compositionDecorator) { + const baseType = this.extractBaseTypeFromArrayType(property.type); + compositionModels.add(baseType); + continue; // No need to check further + } + + // If there's a @Relationship decorator, check its arguments if (relationshipDecorator) { // Check if the relationship decorator has type: "Composition" or "composition" const hasCompositionType = relationshipDecorator.arguments.some( From 12545fd330232363daada4fed978915b0d4ae457 Mon Sep 17 00:00:00 2001 From: Luciano Date: Fri, 19 Sep 2025 21:19:23 -0300 Subject: [PATCH 23/36] Fixed changeCompositionToReference tool. --- .../fields/changeCompositionToReference.ts | 106 ++++++++---------- src/refactor/RefactorController.ts | 1 + .../tools/changeCompositionToReference.ts | 14 +-- 3 files changed, 53 insertions(+), 68 deletions(-) diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts index abc2e3e..dbbc0e1 100644 --- a/src/commands/fields/changeCompositionToReference.ts +++ b/src/commands/fields/changeCompositionToReference.ts @@ -7,6 +7,7 @@ import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; import * as path from "path"; /** @@ -24,15 +25,13 @@ export class ChangeCompositionToReferenceTool { private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; - private explorerProvider: ExplorerProvider; private deleteFieldTool: DeleteFieldTool; - constructor(explorerProvider: ExplorerProvider) { + constructor() { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); - this.explorerProvider = explorerProvider; this.deleteFieldTool = new DeleteFieldTool(); } @@ -42,13 +41,16 @@ export class ChangeCompositionToReferenceTool { * @param cache - The metadata cache for context about existing models * @param sourceModelName - The name of the model containing the composition field * @param fieldName - The name of the composition field to convert - * @returns Promise that resolves when the conversion is complete + * @returns Promise that resolves to a WorkspaceEdit containing all changes needed for the conversion */ public async changeCompositionToReference( cache: MetadataCache, sourceModelName: string, fieldName: string - ): Promise { + ): Promise { + + const edit = new vscode.WorkspaceEdit(); + try { // Step 1: Validate the source model and composition field const { sourceModel, document, compositionField, componentModel } = await this.validateCompositionField( @@ -57,43 +59,39 @@ export class ChangeCompositionToReferenceTool { fieldName ); - // Step 2: Get confirmation from user - const shouldProceed = await this.confirmConversion(componentModel.name, sourceModelName); - if (!shouldProceed) { - return; // User cancelled - } - // Step 3: Determine the target file path for the new independent model const targetFilePath = await this.determineTargetFilePath(sourceModel, componentModel.name); // Step 4: Generate and create the independent model using existing tools - const modelFileUri = await this.generateAndCreateIndependentModel(componentModel, sourceModel, targetFilePath, cache); - + await this.generateAndCreateIndependentModel(componentModel, sourceModel, targetFilePath, cache, edit); + // Step 5: Extract related enums before removing the component model const relatedEnums = await this.sourceCodeService.extractRelatedEnums(document, componentModel, this.sourceCodeService.extractClassBody(document, componentModel.name)); // Step 6-8: Remove field, model, and enums in a single workspace edit to avoid coordinate issues - await this.removeFieldModelAndEnums(document, compositionField, componentModel, relatedEnums, cache); + await this.removeFieldModelAndEnums(document, compositionField, componentModel, relatedEnums, cache, edit); // Step 9: Add the reference field to the source model - await this.addReferenceField(document, sourceModel.name, fieldName, componentModel.name, compositionField.type.endsWith('[]'), cache); + await this.addReferenceField(document, sourceModel.name, fieldName, componentModel.name, compositionField.type.endsWith('[]'), cache, edit); // Step 10: Add import for the new model in the source file - const importEdit = new vscode.WorkspaceEdit(); - await this.sourceCodeService.addModelImport(document, componentModel.name, importEdit, cache); - await vscode.workspace.applyEdit(importEdit); + await this.sourceCodeService.addModelImport(document, componentModel.name, edit, cache); // Step 11: Focus on the newly modified field await this.sourceCodeService.focusOnElement(document, fieldName); - // Step 11: Show success message + // Step 12: Show success message vscode.window.showInformationMessage( `Composition converted to reference! The component model '${componentModel.name}' is now an independent model in its own file.` ); + + // Return the consolidated workspace edit containing all changes + return edit; } catch (error) { vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); console.error("Error changing composition to reference:", error); + return edit; // Return the edit even if there was an error } } @@ -149,21 +147,6 @@ export class ChangeCompositionToReferenceTool { return { sourceModel, document, compositionField, componentModel }; } - /** - * Asks user for confirmation before proceeding with the conversion. - */ - private async confirmConversion(componentModelName: string, sourceModelName: string): Promise { - const message = `Convert composition to reference? The component model '${componentModelName}' will be moved to its own file and become an independent model.`; - - const choice = await vscode.window.showWarningMessage( - message, - { modal: true }, - "Convert", - "Cancel" - ); - - return choice === "Convert"; - } /** * Determines the target file path for the new independent model. @@ -181,8 +164,10 @@ export class ChangeCompositionToReferenceTool { componentModel: DecoratedClass, sourceModel: DecoratedClass, targetFilePath: string, - cache: MetadataCache - ): Promise { + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + // Step 1: Get the source document to extract the class body const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); @@ -198,6 +183,7 @@ export class ChangeCompositionToReferenceTool { // Step 5: Extract existing model imports from the source file const existingImports = this.sourceCodeService.extractModelImports(sourceDocument); + const existingImportsSet = new Set(existingImports); // Step 6: Convert the class body for independent model use const convertedClassBody = this.convertComponentClassBody(classBody); @@ -206,27 +192,21 @@ export class ChangeCompositionToReferenceTool { const modelFileContent = this.sourceCodeService.generateModelFileContent( componentModel.name, convertedClassBody, - "PersistentModel", // Change from PersistentComponentModel to PersistentModel + "PersistentModel", // Convert from PersistentComponentModel to PersistentModel dataSource, - new Set(["Field"]), // Ensure Field is included - false // This is a standalone model (with export) + existingImportsSet, + false // isComponent = false since this is now an independent model ); // Step 8: Add related enums to the file content const finalFileContent = this.sourceCodeService.addEnumsToFileContent(modelFileContent, relatedEnums); - // Step 9: Create the new model file + // Step 9: Create the workspace edit to create the new model file const modelFileUri = vscode.Uri.file(targetFilePath); - const encoder = new TextEncoder(); - await vscode.workspace.fs.writeFile(modelFileUri, encoder.encode(finalFileContent)); + workspaceEdit.createFile(modelFileUri, { ignoreIfExists: true }, {label: 'Create independent model file', needsConfirmation: true}); + workspaceEdit.insert(modelFileUri, new vscode.Position(0, 0), finalFileContent, {label: 'Insert model content', needsConfirmation: true}); - // Step 10: Add model imports to the new file if needed - if (existingImports.length > 0) { - await this.addModelImportsToNewFile(modelFileUri, existingImports); - } - - console.log(`Created independent model file: ${targetFilePath}`); - return modelFileUri; + console.log(`Prepared workspace edit to create independent model file: ${targetFilePath}`); } /** @@ -289,9 +269,9 @@ export class ChangeCompositionToReferenceTool { compositionField: PropertyMetadata, componentModel: DecoratedClass, relatedEnums: string[], - cache: MetadataCache + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit ): Promise { - const workspaceEdit = new vscode.WorkspaceEdit(); // Step 1: Get field deletion range (using DeleteFieldTool logic) const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); @@ -323,8 +303,7 @@ export class ChangeCompositionToReferenceTool { // Step 4: Merge field deletion edits into the main workspace edit this.mergeWorkspaceEdits(fieldDeletionEdit, workspaceEdit); - // Step 5: Apply all deletions in a single operation - await vscode.workspace.applyEdit(workspaceEdit); + console.log("Prepared workspace edit to remove field, model, and unused enums"); } /** @@ -472,7 +451,7 @@ export class ChangeCompositionToReferenceTool { new vscode.Position(enumEndLine + 1, 0) ); - workspaceEdit.delete(document.uri, rangeToDelete); + workspaceEdit.delete(document.uri, rangeToDelete, {label: `Delete unused enum ${enumName}`, needsConfirmation: true}); console.log(`Scheduled deletion of enum "${enumName}" from lines ${enumStartLine} to ${enumEndLine}`); } else { console.warn(`Could not find enum "${enumName}" for deletion`); @@ -486,7 +465,7 @@ export class ChangeCompositionToReferenceTool { sourceEdit.entries().forEach(([uri, edits]) => { edits.forEach(edit => { if (edit instanceof vscode.TextEdit) { - targetEdit.replace(uri, edit.range, edit.newText); + targetEdit.replace(uri, edit.range, edit.newText, {label: 'Merge field deletion edits', needsConfirmation: true}); } }); }); @@ -501,7 +480,8 @@ export class ChangeCompositionToReferenceTool { fieldName: string, targetModelName: string, isArray: boolean, - cache: MetadataCache + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit ): Promise { // Create field info for the reference field const fieldType: FieldTypeOption = { @@ -524,8 +504,20 @@ export class ChangeCompositionToReferenceTool { // Generate the field code const fieldCode = this.generateReferenceFieldCode(fieldInfo, targetModelName, isArray); + // Create the field insertion edits manually and merge into main workspace edit + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + + // Apply proper indentation + const indentation = detectIndentation(lines, classStartLine, classEndLine); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + // Insert the field - await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache, false); + workspaceEdit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`, {label: `Add reference field ${fieldName}`, needsConfirmation: true}); + + // Add necessary imports to the workspace edit + const newImports = new Set(["Field", "Reference"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, workspaceEdit, newImports); } /** diff --git a/src/refactor/RefactorController.ts b/src/refactor/RefactorController.ts index f64ae85..270d679 100644 --- a/src/refactor/RefactorController.ts +++ b/src/refactor/RefactorController.ts @@ -401,6 +401,7 @@ export class RefactorController { for (const [uriString, edits] of allUniqueEdits) { mergedEdit.set(vscode.Uri.parse(uriString), edits); } + // Now it's just returning the edit from the tool. return editFromTool; } diff --git a/src/refactor/tools/changeCompositionToReference.ts b/src/refactor/tools/changeCompositionToReference.ts index af2e93d..f8c3ad7 100644 --- a/src/refactor/tools/changeCompositionToReference.ts +++ b/src/refactor/tools/changeCompositionToReference.ts @@ -124,19 +124,12 @@ export class ChangeCompositionToReferenceRefactorTool implements IRefactorTool { // We don't actually prepare the edit here since the command tool handles everything // This is more of a trigger for the actual implementation - const workspaceEdit = new vscode.WorkspaceEdit(); + let workspaceEdit = new vscode.WorkspaceEdit(); // Execute the actual command - setTimeout(async () => { try { - // Get the explorer provider from the extension context - // For now, we'll create a mock explorer provider - const explorerProvider = { - refresh: () => {} - } as any; - - const tool = new ChangeCompositionToReferenceTool(explorerProvider); - await tool.changeCompositionToReference( + const tool = new ChangeCompositionToReferenceTool(); + workspaceEdit = await tool.changeCompositionToReference( cache, payload.sourceModelName, payload.fieldName @@ -144,7 +137,6 @@ export class ChangeCompositionToReferenceRefactorTool implements IRefactorTool { } catch (error) { vscode.window.showErrorMessage(`Failed to change composition to reference: ${error}`); } - }, 100); return workspaceEdit; } From 7c3210bbb19eb77446288afad0cc69de634c3749 Mon Sep 17 00:00:00 2001 From: Luciano Date: Fri, 19 Sep 2025 22:36:53 -0300 Subject: [PATCH 24/36] Updated changeReferenceToComposition tool to show edits preview --- .../fields/changeReferenceToComposition.ts | 152 +++++++++++++----- .../tools/changeReferenceToComposition.ts | 66 +++----- 2 files changed, 139 insertions(+), 79 deletions(-) diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index e627b7e..20be908 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -7,6 +7,7 @@ import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; +import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; import * as path from "path"; /** @@ -23,15 +24,13 @@ export class ChangeReferenceToCompositionTool { private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; - private explorerProvider: ExplorerProvider; private deleteFieldTool: DeleteFieldTool; - constructor(explorerProvider: ExplorerProvider) { + constructor() { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); - this.explorerProvider = explorerProvider; this.deleteFieldTool = new DeleteFieldTool(); } @@ -41,13 +40,15 @@ export class ChangeReferenceToCompositionTool { * @param cache - The metadata cache for context about existing models * @param sourceModelName - The name of the model containing the reference field * @param fieldName - The name of the reference field to convert - * @returns Promise that resolves when the conversion is complete + * @returns Promise that resolves to a WorkspaceEdit containing all changes needed for the conversion */ public async changeReferenceToComposition( cache: MetadataCache, sourceModelName: string, fieldName: string - ): Promise { + ): Promise { + + const edit = new vscode.WorkspaceEdit(); try { // Step 1: Validate the source model and reference field const { sourceModel, document, referenceField, targetModel } = await this.validateReferenceField( @@ -59,47 +60,47 @@ export class ChangeReferenceToCompositionTool { // Step 2: Check if target model is referenced by other fields const isReferencedElsewhere = this.isModelReferencedElsewhere(cache, targetModel.name, sourceModelName, fieldName); - // Step 3: Inform user about the action and get confirmation - const shouldProceed = await this.confirmConversion(targetModel.name, isReferencedElsewhere); - if (!shouldProceed) { - return; // User cancelled - } // Step 4: Create the component model content based on the target model const componentModelCode = await this.generateComponentModelCode(targetModel, sourceModel, cache); // Step 5: Remove the reference field decorators - await this.removeReferenceField(document, referenceField, cache); + await this.removeReferenceField(document, referenceField, cache, edit); // Step 6: Add the component model to the source file - await this.addComponentModel(document, componentModelCode, sourceModel.name, cache); + await this.addComponentModel(document, componentModelCode, sourceModel.name, cache, edit); // Step 6.1: Remove the import for the target model since it's now defined in the same file - await this.fileSystemService.removeModelImport(document, targetModel.name); + await this.removeModelImport(document, targetModel.name, edit); // Step 7: Add the composition field - await this.addCompositionField(document, sourceModel.name, fieldName, targetModel.name, false, cache); + await this.addCompositionField(document, sourceModel.name, fieldName, targetModel.name, false, cache, edit); // Step 8: Delete the target model file if not referenced elsewhere if (!isReferencedElsewhere) { - await this.deleteTargetModelFile(targetModel); + await this.deleteTargetModelFile(targetModel, edit); } - // Refresh the explorer to reflect changes - //this.explorerProvider.refresh(); + // Add necessary imports to the workspace edit + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "PersistentComponentModel", "Field", "Composition"])); // Step 9: Focus on the newly modified field await this.sourceCodeService.focusOnElement(document, fieldName); // Step 10: Show success message const message = isReferencedElsewhere - ? `Reference converted to composition! The original ${targetModel.name} model was kept as it's referenced elsewhere.` + ? `Reference converted to composition! The origin + private async addEnumDeletionToWorkspacal ${targetModel.name} model was kept as it's referenced elsewhere.` : `Reference converted to composition! The original ${targetModel.name} model was deleted and recreated as a component.`; vscode.window.showInformationMessage(message); + + // Return the consolidated workspace edit containing all changes + return edit; } catch (error) { vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); console.error("Error changing reference to composition:", error); + return edit; // Return the edit even if there was an error } } @@ -382,7 +383,12 @@ export class ChangeReferenceToCompositionTool { /** * Removes the @Reference and @Field decorators from the field. */ - private async removeReferenceField(document: vscode.TextDocument, field: PropertyMetadata, cache: MetadataCache): Promise { + private async removeReferenceField( + document: vscode.TextDocument, + field: PropertyMetadata, + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { // Find the model name that contains this field const fileMetadata = cache.getMetadataForFile(document.uri.fsPath); let modelName = 'Unknown'; @@ -399,14 +405,12 @@ export class ChangeReferenceToCompositionTool { } // Use the DeleteFieldTool to programmatically remove the field - const workspaceEdit = await this.deleteFieldTool.deleteFieldProgrammatically( + await this.deleteFieldTool.deleteFieldProgrammatically( field, modelName, - cache + cache, + workspaceEdit ); - - // Apply the workspace edit - await vscode.workspace.applyEdit(workspaceEdit); } /** @@ -416,16 +420,27 @@ export class ChangeReferenceToCompositionTool { document: vscode.TextDocument, componentModelCode: string, sourceModelName: string, - cache: MetadataCache + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit ): Promise { - const newImports = new Set(["Model", "PersistentComponentModel"]); - await this.sourceCodeService.insertModel( - document, - componentModelCode, - sourceModelName, // Insert after the source model - newImports - ); + // Manually implement model insertion to use our workspace edit + const lines = document.getText().split("\n"); + + // Find the position to insert the model (after the source model) + let insertPosition = lines.length; // Default to end of file + if (sourceModelName) { + try { + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + insertPosition = classEndLine + 1; + } catch (error) { + console.warn(`Could not find source model "${sourceModelName}", inserting at end of file`); + } + } + + // Insert the component model with proper spacing + const modelWithSpacing = `\n${componentModelCode}\n`; + workspaceEdit.insert(document.uri, new vscode.Position(insertPosition, 0), modelWithSpacing, {label: 'Add component model', needsConfirmation: true}); } /** @@ -437,7 +452,8 @@ export class ChangeReferenceToCompositionTool { fieldName: string, targetModelName: string, isArray: boolean, - cache: MetadataCache + cache: MetadataCache, + workspaceEdit: vscode.WorkspaceEdit ): Promise { // Create field info for the composition field const fieldType: FieldTypeOption = { @@ -461,8 +477,20 @@ export class ChangeReferenceToCompositionTool { // Generate the field code const fieldCode = this.generateCompositionFieldCode(fieldInfo, targetModelName, isArray); + // Create the field insertion edits manually and merge into main workspace edit + const lines = document.getText().split("\n"); + const { classStartLine, classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModelName); + + // Apply proper indentation + const indentation = detectIndentation(lines, classStartLine, classEndLine); + const indentedFieldCode = applyIndentation(fieldCode, indentation); + // Insert the field - await this.sourceCodeService.insertField(document, sourceModelName, fieldInfo, fieldCode, cache, false); + workspaceEdit.insert(document.uri, new vscode.Position(classEndLine, 0), `\n${indentedFieldCode}\n`, {label: `Add composition field ${fieldName}`, needsConfirmation: true}); + + // Add necessary imports to the workspace edit + const newImports = new Set(["Composition"]); + //await this.sourceCodeService.ensureSlingrFrameworkImports(document, workspaceEdit, newImports); } /** @@ -484,15 +512,63 @@ export class ChangeReferenceToCompositionTool { return lines.join("\n"); } + + /** + * Removes model import from the document. + */ + private async removeModelImport( + document: vscode.TextDocument, + modelName: string, + workspaceEdit: vscode.WorkspaceEdit + ): Promise { + const content = document.getText(); + const lines = content.split("\n"); + + // Find and remove import lines that contain the model name + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for import statements that import the specific model + if (line.trim().startsWith('import ') && line.includes(modelName)) { + // Check if this import only imports the target model + const importMatch = line.match(/import\s+{([^}]+)}\s+from/); + if (importMatch) { + const imports = importMatch[1].split(',').map(imp => imp.trim()); + + if (imports.length === 1 && imports[0] === modelName) { + // Remove the entire import line + const range = new vscode.Range( + new vscode.Position(i, 0), + new vscode.Position(i + 1, 0) + ); + workspaceEdit.delete(document.uri, range, {label: `Remove import for ${modelName}`, needsConfirmation: true}); + } else if (imports.includes(modelName)) { + // Remove just the model name from the import + const newImports = imports.filter(imp => imp !== modelName); + const newImportLine = line.replace( + /import\s+{[^}]+}/, + `import { ${newImports.join(', ')} }` + ); + const range = new vscode.Range( + new vscode.Position(i, 0), + new vscode.Position(i, line.length) + ); + workspaceEdit.replace(document.uri, range, newImportLine, {label: `Update import removing ${modelName}`, needsConfirmation: true}); + } + } + } + } + } + /** * Deletes the target model file if it's safe to do so. */ - private async deleteTargetModelFile(targetModel: DecoratedClass): Promise { + private async deleteTargetModelFile(targetModel: DecoratedClass, workspaceEdit: vscode.WorkspaceEdit): Promise { try { - await vscode.workspace.fs.delete(targetModel.declaration.uri); - console.log(`Deleted target model file: ${targetModel.declaration.uri.fsPath}`); + workspaceEdit.deleteFile(targetModel.declaration.uri, { ignoreIfNotExists: true }, {label: `Delete original model file ${targetModel.name}`, needsConfirmation: true}); + console.log(`Scheduled deletion of target model file: ${targetModel.declaration.uri.fsPath}`); } catch (error) { - console.warn(`Could not delete target model file: ${error}`); + console.warn(`Could not schedule deletion of target model file: ${error}`); // Don't throw error here as the conversion was successful } } diff --git a/src/refactor/tools/changeReferenceToComposition.ts b/src/refactor/tools/changeReferenceToComposition.ts index c6c80b5..c655730 100644 --- a/src/refactor/tools/changeReferenceToComposition.ts +++ b/src/refactor/tools/changeReferenceToComposition.ts @@ -1,9 +1,5 @@ import * as vscode from "vscode"; -import { - IRefactorTool, - ChangeObject, - ManualRefactorContext, -} from "../refactorInterfaces"; +import { IRefactorTool, ChangeObject, ManualRefactorContext } from "../refactorInterfaces"; import { MetadataCache, PropertyMetadata, DecoratedClass } from "../../cache/cache"; import { ChangeReferenceToCompositionTool } from "../../commands/fields/changeReferenceToComposition"; import { ExplorerProvider } from "../../explorer/explorerProvider"; @@ -21,13 +17,12 @@ export interface ChangeReferenceToCompositionPayload { /** * Refactor tool for converting reference fields to composition fields. - * + * * This tool allows users to convert @Reference fields to @Composition fields * through the VS Code refactor menu. It validates that the field is indeed * a reference field before allowing the conversion. */ export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { - /** * Returns the VS Code command identifier for this refactor tool. */ @@ -60,15 +55,15 @@ export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { } // Must have field metadata - if (!context.metadata || !('decorators' in context.metadata)) { + if (!context.metadata || !("decorators" in context.metadata)) { return false; } const fieldMetadata = context.metadata as PropertyMetadata; - + // Check if this field has a @Reference decorator - const hasReferenceDecorator = fieldMetadata.decorators.some(d => d.name === "Reference"); - + const hasReferenceDecorator = fieldMetadata.decorators.some((d) => d.name === "Reference"); + return hasReferenceDecorator; } @@ -83,16 +78,16 @@ export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { * Initiates the manual refactor by creating a change object. */ async initiateManualRefactor(context: ManualRefactorContext): Promise { - if (!context.metadata || !('decorators' in context.metadata)) { + if (!context.metadata || !("decorators" in context.metadata)) { return undefined; } const fieldMetadata = context.metadata as PropertyMetadata; - + // Find the model that contains this field const cache = context.cache; const sourceModel = this.findSourceModel(cache, fieldMetadata); - + if (!sourceModel) { vscode.window.showErrorMessage("Could not find the model containing this field"); return undefined; @@ -119,30 +114,18 @@ export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { */ async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { const payload = change.payload as ChangeReferenceToCompositionPayload; - + // We don't actually prepare the edit here since the command tool handles everything // This is more of a trigger for the actual implementation - const workspaceEdit = new vscode.WorkspaceEdit(); - + let workspaceEdit = new vscode.WorkspaceEdit(); + // Execute the actual command - setTimeout(async () => { - try { - // Get the explorer provider from the extension context - // For now, we'll create a mock explorer provider - const explorerProvider = { - refresh: () => {} - } as any; - - const tool = new ChangeReferenceToCompositionTool(explorerProvider); - await tool.changeReferenceToComposition( - cache, - payload.sourceModelName, - payload.fieldName - ); - } catch (error) { - vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); - } - }, 100); + try { + const tool = new ChangeReferenceToCompositionTool(); + workspaceEdit = await tool.changeReferenceToComposition(cache, payload.sourceModelName, payload.fieldName); + } catch (error) { + vscode.window.showErrorMessage(`Failed to change reference to composition: ${error}`); + } return workspaceEdit; } @@ -152,19 +135,20 @@ export class ChangeReferenceToCompositionRefactorTool implements IRefactorTool { */ private findSourceModel(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { const allModels = cache.getDataModelClasses(); - + for (const model of allModels) { const fieldInModel = Object.values(model.properties).find( - prop => prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line ); - + if (fieldInModel) { return model; } } - + return null; } } From e33eb8a94c2b76bce40ef42f2fc802939994fdd7 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 22 Sep 2025 08:55:49 -0300 Subject: [PATCH 25/36] Adds the ExtractFieldsToParentTool and ExtractFieldsToEmbeddedTool --- .../fields/extractFieldsToEmbedded.ts | 534 +++++++++++++++-- src/commands/fields/extractFieldsToParent.ts | 544 +++++++++++++++--- src/refactor/refactorDisposables.ts | 4 + src/refactor/refactorInterfaces.ts | 25 + 4 files changed, 964 insertions(+), 143 deletions(-) diff --git a/src/commands/fields/extractFieldsToEmbedded.ts b/src/commands/fields/extractFieldsToEmbedded.ts index 7697209..dca0625 100644 --- a/src/commands/fields/extractFieldsToEmbedded.ts +++ b/src/commands/fields/extractFieldsToEmbedded.ts @@ -1,100 +1,522 @@ // src/commands/fields/extractFieldsToEmbedded.ts import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, + ExtractFieldsToEmbeddedPayload, +} from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; import { UserInputService } from "../../services/userInputService"; -import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; +import { TreeViewContext } from "../commandHelpers"; +import { isModelFile } from "../../utils/metadata"; +import * as path from "path"; -export class ExtractFieldsToEmbeddedTool { +/** + * Refactor tool for extracting multiple fields from a model to a new embedded model. + * + * This tool allows users to select multiple fields and move them to a new embedded + * model in a separate file, creating an embedded relationship between the source and new models. + * The embedded model extends BaseModel and has no dataSource. + */ +export class ExtractFieldsToEmbeddedTool implements IRefactorTool { private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; private deleteFieldTool: DeleteFieldTool; constructor() { this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); this.deleteFieldTool = new DeleteFieldTool(); } - public async extractFieldsToEmbedded(cache: MetadataCache, editor: vscode.TextEditor, modelName:string): Promise { + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToEmbedded"; + } + + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Embedded"; + } + + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_EMBEDDED"]; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; + } + + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by prompting user for field selection, new model name, and embedded field name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; + } + + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to embedded"); + return undefined; + } + + let selectedFields: PropertyMetadata[] | undefined; + + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); + } + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields); + if (!selectedFields || selectedFields.length === 0) { + return undefined; + } + } + + // Get the new model name + const newModelName = await this.userInputService.showPrompt("Enter the name for the new embedded model:"); + if (!newModelName) { + return undefined; + } + + // Get the embedded field name + const embeddedFieldName = await this.userInputService.showPrompt( + "Enter the name for the new embedded field (e.g., 'address', 'profile'):" + ); + if (!embeddedFieldName) { + return undefined; + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToEmbeddedPayload = { + sourceModelName: sourceModel.name, + newModelName: newModelName, + embeddedFieldName: embeddedFieldName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_EMBEDDED", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new embedded model '${newModelName}' with embedded field '${embeddedFieldName}' in model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToEmbeddedPayload; + try { - const { document, selections } = editor; - const sourceModel = cache.getModelByName(modelName); + const sourceModel = cache.getModelByName(payload.sourceModelName); if (!sourceModel) { - throw new Error("Could not find a model class in the current file."); + throw new Error(`Could not find source model '${payload.sourceModelName}'`); } - const selectedFields = this.getSelectedFields(sourceModel, selections); - if (selectedFields.length === 0) { - vscode.window.showInformationMessage("No fields selected."); - return; - } + const edit = new vscode.WorkspaceEdit(); - const newModelName = await this.userInputService.showPrompt("Enter the name for the new embedded model:"); - if (!newModelName) return; + // Get the URI from the payload + const newModelUri = payload.urisToCreate![0].uri; - // Create the new embedded model with the selected fields - const newModelContent = this.generateEmbeddedModelContent(newModelName, selectedFields); - await this.sourceCodeService.insertModel(document, newModelContent, sourceModel.name); + // Generate the complete file content + const completeFileContent = this.generateCompleteEmbeddedModelFile( + payload.newModelName, + payload.fieldsToExtract + ); - // Remove the fields from the source model - for (const field of selectedFields) { - const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); - await vscode.workspace.applyEdit(deleteEdit); - } - - // Add the embedded field - const embeddedFieldInfo: FieldInfo = { - name: this.toCamelCase(newModelName), - type: { decorator: 'Embedded', label: 'Embedded', tsType: newModelName, description: 'Embedded Model' }, - required: false + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new embedded model file ${path.basename(newModelUri.fsPath)}`, + description: `Creating new embedded model file for ${payload.newModelName}`, + needsConfirmation: true, }; - // This will require a new decorator and logic in AddFieldTool, for now, we'll add it manually - const fieldCode = `@Field()\n @Embedded()\n ${embeddedFieldInfo.name}!: ${newModelName};`; - await this.sourceCodeService.insertField(document, sourceModel.name, embeddedFieldInfo, fieldCode, cache, false); - vscode.window.showInformationMessage(`Fields extracted to new embedded model '${newModelName}'.`); + // Create the file with content + edit.createFile( + newModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Add the embedded field to the source model + await this.addEmbeddedFieldToSourceModel( + edit, + sourceModel, + payload.embeddedFieldName, + payload.newModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; } catch (error) { - vscode.window.showErrorMessage(`Failed to extract fields to embedded model: ${error}`); + vscode.window.showErrorMessage(`Failed to prepare extract fields to embedded edit: ${error}`); + throw error; } } - private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { - const selectedFields: PropertyMetadata[] = []; - for (const selection of selections) { - for (const field of Object.values(model.properties)) { - if (selection.intersection(field.declaration.range)) { - selectedFields.push(field); - } + /** + * Shows user a quick pick to select which fields to extract. + */ + private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: "Select fields to extract to the new embedded model", + title: "Extract Fields to Embedded", + }); + + return selectedItems?.map((item) => item.field); + } + + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; + } + + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); + } + + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; } } - return selectedFields; + + return null; + } + + /** + * Gets the decorator name for a field's type. + */ + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + /** + * Generates the complete file content for the new embedded model, including imports. + * Embedded models extend BaseModel and have no dataSource. + */ + private generateCompleteEmbeddedModelFile( + modelName: string, + fieldsToExtract: PropertyMetadata[] + ): string { + const lines: string[] = []; + + // Generate imports for embedded model (no dataSource import needed) + const requiredImports = new Set(["Field", "BaseModel", "Model"]); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + requiredImports.add(decorator.name); + } + } + // Add the import statement + const importList = Array.from(requiredImports).sort(); + lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); + lines.push(""); // Empty line after imports + + // Add model decorator + lines.push(`@Model()`); + + // Add class + lines.push(`export class ${modelName} extends BaseModel {`); + lines.push(""); + + // Add each field using PropertyMetadata to preserve all decorator information + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); + lines.push(""); + } + + lines.push("}"); + lines.push(""); // Empty line at end + + return lines.join("\n"); } - private generateEmbeddedModelContent(modelName: string, fields: PropertyMetadata[]): string { - let content = `\n@Model()\nclass ${modelName} {\n`; - for (const field of fields) { - for(const decorator of field.decorators) { - content += ` @${decorator.name}(${this.formatDecoratorArgs(decorator.arguments)})\n`; + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}({})`); + } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); + } } - content += ` ${field.name}!: ${field.type};\n\n`; } - content += '}\n'; - return content; + + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); + } + + /** + * Adds an embedded field to the source model. + */ + private async addEmbeddedFieldToSourceModel( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + embeddedFieldName: string, + targetModelName: string, + cache: MetadataCache + ): Promise { + // Check if embedded field already exists + const existingFields = Object.keys(sourceModel.properties || {}); + if (existingFields.includes(embeddedFieldName)) { + throw new Error(`Field '${embeddedFieldName}' already exists in model ${sourceModel.name}`); + } + + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Generate the embedded field code + const fieldCode = this.generateEmbeddedFieldCode(embeddedFieldName, targetModelName); + + // Add required imports + const requiredImports = new Set(["Field", "Embedded"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Add import for the target model + await this.sourceCodeService.addModelImport(document, targetModelName, edit, cache); + + // Find class boundaries and add field + const lines = document.getText().split("\n"); + const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, sourceModel.name); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Add embedded field ${embeddedFieldName}`, + description: `Adding embedded field ${embeddedFieldName} of type ${targetModelName}`, + needsConfirmation: true, + }; + + edit.insert(sourceModel.declaration.uri, new vscode.Position(classEndLine, 0), `\n${fieldCode}\n`, metadata); } - private formatDecoratorArgs(args: any[]): string { - if (!args || args.length === 0) { - return ''; + /** + * Public method for programmatic usage of the extract fields to embedded functionality. + */ + public async extractFieldsToEmbedded( + cache: MetadataCache, + sourceModelName: string, + fieldsToExtract: PropertyMetadata[], + newModelName: string, + embeddedFieldName: string + ): Promise { + const sourceModel = cache.getModelByName(sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${sourceModelName}'`); } - return JSON.stringify(args[0]).replace(/"/g, "'"); + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newModelPath = path.join(sourceDir, `${newModelName}.ts`); + const newModelUri = vscode.Uri.file(newModelPath); + + const payload: ExtractFieldsToEmbeddedPayload = { + sourceModelName: sourceModelName, + newModelName: newModelName, + embeddedFieldName: embeddedFieldName, + fieldsToExtract: fieldsToExtract, + isManual: false, + urisToCreate: [ + { + uri: newModelUri, + }, + ], + }; + + const changeObject: ChangeObject = { + type: "EXTRACT_FIELDS_TO_EMBEDDED", + uri: sourceModel.declaration.uri, + description: `Extract ${fieldsToExtract.length} field(s) to new embedded model '${newModelName}' with embedded field '${embeddedFieldName}' in model '${sourceModelName}'`, + payload, + }; + + return await this.prepareEdit(changeObject, cache); } - private toCamelCase(str: string): string { - return str.charAt(0).toLowerCase() + str.slice(1); + /** + * Generates the embedded field code with only @Embedded() decorator. + */ + private generateEmbeddedFieldCode(fieldName: string, targetModelName: string): string { + const lines: string[] = []; + + // Add Embedded decorator (no arguments needed) + lines.push(" @Embedded()"); + + // Add property declaration + lines.push(` ${fieldName}!: ${targetModelName};`); + + return lines.join("\n"); } } \ No newline at end of file diff --git a/src/commands/fields/extractFieldsToParent.ts b/src/commands/fields/extractFieldsToParent.ts index d7255bb..93bc734 100644 --- a/src/commands/fields/extractFieldsToParent.ts +++ b/src/commands/fields/extractFieldsToParent.ts @@ -1,114 +1,484 @@ -// src/commands/fields/extractFieldsToParent.ts import * as vscode from "vscode"; +import { + IRefactorTool, + ChangeObject, + ManualRefactorContext, + ExtractFieldsToParentPayload, +} from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; import { UserInputService } from "../../services/userInputService"; -import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; +import { FileSystemService } from "../../services/fileSystemService"; +import { NewModelTool } from "../models/newModel"; +import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; -import * as path from 'path'; - -export class ExtractFieldsToParentTool { - private userInputService: UserInputService; - private projectAnalysisService: ProjectAnalysisService; - private sourceCodeService: SourceCodeService; - private deleteFieldTool: DeleteFieldTool; - - constructor() { - this.userInputService = new UserInputService(); - this.projectAnalysisService = new ProjectAnalysisService(); - this.sourceCodeService = new SourceCodeService(); - this.deleteFieldTool = new DeleteFieldTool(); - } - - public async extractFieldsToParent(cache: MetadataCache, editor: vscode.TextEditor, modelName:string): Promise { - try { - const { document, selections } = editor; - const sourceModel = cache.getModelByName(modelName); - if (!sourceModel) { - throw new Error("Could not find a model class in the current file."); - } +import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; +import { TreeViewContext } from "../commandHelpers"; +import { isModelFile } from "../../utils/metadata"; +import * as path from "path"; - const selectedFields = this.getSelectedFields(sourceModel, selections); - if (selectedFields.length === 0) { - vscode.window.showInformationMessage("No fields selected."); - return; - } +/** + * Refactor tool for extracting multiple fields from a model to a new abstract parent model. + * + * This tool allows users to select multiple fields and move them to a new abstract parent + * model extending BaseModel. The source model will then extend from this new parent model + * instead of its current parent. It provides preview functionality before applying changes. + */ +export class ExtractFieldsToParentTool implements IRefactorTool { + private userInputService: UserInputService; + private sourceCodeService: SourceCodeService; + private fileSystemService: FileSystemService; + private newModelTool: NewModelTool; + private addFieldTool: AddFieldTool; + private deleteFieldTool: DeleteFieldTool; - const newModelName = await this.userInputService.showPrompt("Enter the name for the new abstract parent model:"); - if (!newModelName) return; + constructor() { + this.userInputService = new UserInputService(); + this.sourceCodeService = new SourceCodeService(); + this.fileSystemService = new FileSystemService(); + this.newModelTool = new NewModelTool(); + this.addFieldTool = new AddFieldTool(); + this.deleteFieldTool = new DeleteFieldTool(); + } - // Create the new parent model - const newModelContent = this.generateParentModelContent(newModelName, selectedFields); - const targetFilePath = path.join(path.dirname(document.uri.fsPath), `${newModelName}.ts`); - const newModelUri = vscode.Uri.file(targetFilePath); - await vscode.workspace.fs.writeFile(newModelUri, Buffer.from(newModelContent, 'utf8')); + /** + * Returns the VS Code command identifier for this refactor tool. + */ + getCommandId(): string { + return "slingr-vscode-extension.extractFieldsToParent"; + } - // Remove the fields from the source model - for (const field of selectedFields) { - const deleteEdit = await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache); - await vscode.workspace.applyEdit(deleteEdit); - } + /** + * Returns the human-readable title shown in refactor menus. + */ + getTitle(): string { + return "Extract Fields to Parent"; + } - // Update the source model to extend the new parent model - await this.updateSourceModelToExtend(document, sourceModel.name, newModelName); - - vscode.window.showInformationMessage(`Fields extracted to new parent model '${newModelName}'.`); + /** + * Returns the types of changes this tool handles. + */ + getHandledChangeTypes(): string[] { + return ["EXTRACT_FIELDS_TO_PARENT"]; + } - } catch (error) { - vscode.window.showErrorMessage(`Failed to extract fields to parent model: ${error}`); - } + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; } - private getSelectedFields(model: DecoratedClass, selections: readonly vscode.Selection[]): PropertyMetadata[] { - const selectedFields: PropertyMetadata[] = []; - for (const selection of selections) { - for (const field of Object.values(model.properties)) { - if (selection.intersection(field.declaration.range)) { - selectedFields.push(field); - } - } + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; + } + + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * This tool doesn't detect automatic changes. + */ + analyze(): ChangeObject[] { + return []; + } + + /** + * Initiates the manual refactor by prompting user for field selection and new parent model name. + */ + async initiateManualRefactor(context: ManualRefactorContext): Promise { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; + } + + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to parent"); + return undefined; + } + + let selectedFields: PropertyMetadata[] | undefined; + + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); } - return selectedFields; + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields); + if (!selectedFields || selectedFields.length === 0) { + return undefined; + } + } + + // Get the new parent model name + const newParentModelName = await this.userInputService.showPrompt("Enter the name for the new abstract parent model:"); + if (!newParentModelName) { + return undefined; + } + + const sourceDir = path.dirname(sourceModel.declaration.uri.fsPath); + const newParentModelPath = path.join(sourceDir, `${newParentModelName}.ts`); + const newParentModelUri = vscode.Uri.file(newParentModelPath); + + const payload: ExtractFieldsToParentPayload = { + sourceModelName: sourceModel.name, + newParentModelName: newParentModelName, + fieldsToExtract: selectedFields, + isManual: true, + urisToCreate: [ + { + uri: newParentModelUri, + }, + ], + }; + + return { + type: "EXTRACT_FIELDS_TO_PARENT", + uri: context.uri, + description: `Extract ${selectedFields.length} field(s) to new abstract parent model '${newParentModelName}' for model '${sourceModel.name}'`, + payload, + }; + } + + /** + * Prepares the workspace edit for the refactor operation. + */ + async prepareEdit(change: ChangeObject, cache: MetadataCache): Promise { + const payload = change.payload as ExtractFieldsToParentPayload; + + try { + const sourceModel = cache.getModelByName(payload.sourceModelName); + if (!sourceModel) { + throw new Error(`Could not find source model '${payload.sourceModelName}'`); + } + + const edit = new vscode.WorkspaceEdit(); + + // Get the URI from the payload + const newParentModelUri = payload.urisToCreate![0].uri; + + // Generate the complete file content for the abstract parent model + const completeFileContent = this.generateCompleteParentModelFile( + payload.newParentModelName, + payload.fieldsToExtract, + sourceModel, + cache + ); + + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Create new abstract parent model file ${path.basename(newParentModelUri.fsPath)}`, + description: `Creating new abstract parent model file for ${payload.newParentModelName}`, + needsConfirmation: true, + }; + + // Create the file with content + edit.createFile( + newParentModelUri, + { + overwrite: false, + ignoreIfExists: true, + contents: Buffer.from(completeFileContent, "utf8"), + }, + metadata + ); + + // Step 2: Update the source model to extend from the new parent model + await this.updateSourceModelToExtendParent( + edit, + sourceModel, + payload.newParentModelName, + cache + ); + + // Step 3: Remove the fields from the source model + for (const field of payload.fieldsToExtract) { + await this.deleteFieldTool.deleteFieldProgrammatically(field, sourceModel.name, cache, edit); + } + + return edit; + } catch (error) { + vscode.window.showErrorMessage(`Failed to prepare extract fields to parent edit: ${error}`); + throw error; } + } + + /** + * Shows user a quick pick to select which fields to extract. + */ + private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); - private generateParentModelContent(modelName: string, fields: PropertyMetadata[]): string { - let content = `import { BaseModel, Field, Text, Model } from 'slingr-framework';\n\n`; // Add necessary imports - content += `@Model()\nexport abstract class ${modelName} extends BaseModel {\n`; - for (const field of fields) { - for(const decorator of field.decorators) { - content += ` @${decorator.name}(${this.formatDecoratorArgs(decorator.arguments)})\n`; + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: "Select fields to extract to the new abstract parent model", + title: "Extract Fields to Parent", + }); + + return selectedItems?.map((item) => item.field); + } + + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; + } + + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); + } + + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; + } + } + + return null; + } + + /** + * Gets the decorator name for a field's type. + */ + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + /** + * Generates the complete file content for the new abstract parent model, including imports. + */ + private generateCompleteParentModelFile( + modelName: string, + fieldsToExtract: PropertyMetadata[], + sourceModel: DecoratedClass, + cache: MetadataCache + ): string { + const lines: string[] = []; + + // Generate imports + const requiredImports = new Set(["BaseModel", "Field"]); + for (const field of fieldsToExtract) { + for (const decorator of field.decorators) { + requiredImports.add(decorator.name); + } + } + + // Add the import statement + const importList = Array.from(requiredImports).sort(); + lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); + lines.push(""); // Empty line after imports + + // Add model decorator + lines.push(`@Model()`); + + // Add abstract model class extending BaseModel + lines.push(`export abstract class ${modelName} extends BaseModel {`); + lines.push(""); + + // Add each field using PropertyMetadata to preserve all decorator information + for (const field of fieldsToExtract) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); + lines.push(""); + } + + lines.push("}"); + lines.push(""); // Empty line at end + + return lines.join("\n"); + } + + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}({})`); + } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } } - content += ` ${field.name}!: ${field.type};\n\n`; + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); } - content += '}\n'; - return content; + } } - private formatDecoratorArgs(args: any[]): string { - if (!args || args.length === 0) { - return ''; - } - return JSON.stringify(args[0]).replace(/"/g, "'"); + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); + } + + /** + * Updates the source model to extend from the new parent model instead of its current parent. + */ + private async updateSourceModelToExtendParent( + edit: vscode.WorkspaceEdit, + sourceModel: DecoratedClass, + newParentModelName: string, + cache: MetadataCache + ): Promise { + const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); + + // Add import for the parent model + await this.sourceCodeService.addModelImport(document, newParentModelName, edit, cache); + + // Find the class declaration line and update it to extend from the new parent + const lines = document.getText().split("\n"); + const classLine = this.findClassDeclarationLine(lines, sourceModel.name); + + if (classLine === -1) { + throw new Error(`Could not find class declaration for ${sourceModel.name}`); } - private async updateSourceModelToExtend(document: vscode.TextDocument, sourceModelName: string, newParentName: string) { - const edit = new vscode.WorkspaceEdit(); - const text = document.getText(); - const regex = new RegExp(`(class ${sourceModelName} extends) (\\w+)`); - const match = text.match(regex); + const currentLine = lines[classLine]; + const newLine = this.updateClassExtension(currentLine, sourceModel.name, newParentModelName); - if (match) { - const index = match.index || 0; - const startPos = document.positionAt(index + match[1].length + 1); - const endPos = document.positionAt(index + match[1].length + 1 + match[2].length); - edit.replace(document.uri, new vscode.Range(startPos, endPos), newParentName); + const lineRange = new vscode.Range( + new vscode.Position(classLine, 0), + new vscode.Position(classLine, currentLine.length) + ); - // Add import for the new parent model - const importStatement = `\nimport { ${newParentName} } from './${newParentName}';`; - const firstLine = document.lineAt(0); - edit.insert(document.uri, firstLine.range.start, importStatement); + const metadata: vscode.WorkspaceEditEntryMetadata = { + label: `Update ${sourceModel.name} to extend ${newParentModelName}`, + description: `Changing class inheritance for ${sourceModel.name}`, + needsConfirmation: true, + }; - await vscode.workspace.applyEdit(edit); - } + edit.replace(sourceModel.declaration.uri, lineRange, newLine, metadata); + } + + /** + * Finds the line number of the class declaration. + */ + private findClassDeclarationLine(lines: string[], className: string): number { + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line.includes(`class ${className}`) && line.includes("extends")) { + return i; + } } + return -1; + } + + /** + * Updates the class extension to use the new parent model. + */ + private updateClassExtension(currentLine: string, className: string, newParentModelName: string): string { + // Replace the current extends clause with the new parent + const extendsPattern = /extends\s+\w+/; + return currentLine.replace(extendsPattern, `extends ${newParentModelName}`); + } } \ No newline at end of file diff --git a/src/refactor/refactorDisposables.ts b/src/refactor/refactorDisposables.ts index 5012c64..17816ca 100644 --- a/src/refactor/refactorDisposables.ts +++ b/src/refactor/refactorDisposables.ts @@ -19,6 +19,8 @@ import { PropertyMetadata } from '../cache/cache'; import { fieldTypeConfig } from '../utils/fieldTypes'; import { RenameDataSourceTool } from './tools/renameDataSource'; import { DeleteDataSourceTool } from './tools/deleteDataSource'; +import { ExtractFieldsToEmbeddedTool } from '../commands/fields/extractFieldsToEmbedded'; +import { ExtractFieldsToParentTool } from '../commands/fields/extractFieldsToParent'; /** * Returns an array of all available refactor tools for the application. @@ -50,6 +52,8 @@ export function getAllRefactorTools(): IRefactorTool[] { new RenameDataSourceTool(), new DeleteDataSourceTool(), new ExtractFieldsToReferenceTool(), + new ExtractFieldsToEmbeddedTool(), + new ExtractFieldsToParentTool() ]; } diff --git a/src/refactor/refactorInterfaces.ts b/src/refactor/refactorInterfaces.ts index 0d0ebbb..7834c7f 100644 --- a/src/refactor/refactorInterfaces.ts +++ b/src/refactor/refactorInterfaces.ts @@ -217,6 +217,29 @@ export interface ExtractFieldsToReferencePayload extends BaseExtractFieldsPayloa referenceFieldName: string; } +/** + * Payload for extracting fields to a new embedded model. + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} newModelName - The name of the new embedded model to be created + * @property {string} embeddedFieldName - The name of the new embedded field to be created + * @property {fileCreationInfo?: FileCreationInfo} - Optional info for creating the new model file + */ +export interface ExtractFieldsToEmbeddedPayload extends BaseExtractFieldsPayload { + sourceModelName: string; + newModelName: string; + embeddedFieldName: string; +} + +/** + * Payload for extracting fields to a new parent model. + * @property {string} sourceModelName - The name of the source model containing the fields to extract + * @property {string} newParentModelName - The name of the new abstract parent model to be created + */ +export interface ExtractFieldsToParentPayload extends BaseExtractFieldsPayload { + sourceModelName: string; + newParentModelName: string; +} + export interface DeleteDataSourcePayload extends BasePayload { dataSourceName: string; urisToDelete: vscode.Uri[]; @@ -241,6 +264,8 @@ export type ChangePayloadMap = { 'CHANGE_COMPOSITION_TO_REFERENCE': ChangeCompositionToReferencePayload; 'EXTRACT_FIELDS_TO_COMPOSITION': ExtractFieldsToCompositionPayload; 'EXTRACT_FIELDS_TO_REFERENCE': ExtractFieldsToReferencePayload; + 'EXTRACT_FIELDS_TO_EMBEDDED': ExtractFieldsToEmbeddedPayload; + 'EXTRACT_FIELDS_TO_PARENT': ExtractFieldsToParentPayload; // Add more change types and their payloads as needed }; From d05b575b41f43cff60619a677d3d26026c025514 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 22 Sep 2025 10:39:25 -0300 Subject: [PATCH 26/36] Added `extractFieldsController` to better organization and logic reuse. --- .../fields/extractFieldsController.ts | 264 ++++++++++++++++++ .../fields/extractFieldsToComposition.ts | 230 +-------------- .../fields/extractFieldsToEmbedded.ts | 235 +--------------- src/commands/fields/extractFieldsToParent.ts | 237 +--------------- .../fields/extractFieldsToReference.ts | 241 +--------------- 5 files changed, 292 insertions(+), 915 deletions(-) create mode 100644 src/commands/fields/extractFieldsController.ts diff --git a/src/commands/fields/extractFieldsController.ts b/src/commands/fields/extractFieldsController.ts new file mode 100644 index 0000000..501423a --- /dev/null +++ b/src/commands/fields/extractFieldsController.ts @@ -0,0 +1,264 @@ +import { WorkspaceEdit } from "vscode"; +import { DecoratedClass, FileMetadata, MetadataCache, PropertyMetadata } from "../../cache/cache"; +import { ChangeObject, IRefactorTool, ManualRefactorContext } from "../../refactor/refactorInterfaces"; +import { TreeViewContext } from "../commandHelpers"; +import { UserInputService } from "../../services/userInputService"; +import { SourceCodeService } from "../../services/sourceCodeService"; +import { isModelFile } from "../../utils/metadata"; +import * as vscode from "vscode"; +import { FIELD_TYPE_OPTIONS } from "../interfaces"; + +export abstract class ExtractFieldsController implements IRefactorTool { + protected userInputService: UserInputService; + protected sourceCodeService: SourceCodeService; + + constructor() { + this.userInputService = new UserInputService(); + this.sourceCodeService = new SourceCodeService(); + } + + // Abstract methods - must be implemented by subclasses + abstract getCommandId(): string; + abstract getTitle(): string; + abstract getHandledChangeTypes(): string[]; + abstract prepareEdit(change: ChangeObject, cache: MetadataCache): Promise; + // Abstract method for manual refactor - each tool implements its own prompting logic + abstract initiateManualRefactor(context: ManualRefactorContext): Promise; + + // Concrete methods - shared logic implemented in base class + + /** + * This tool doesn't detect automatic changes. + */ + analyze( + oldFileMeta?: FileMetadata, + newFileMeta?: FileMetadata, + accumulatedChanges?: ChangeObject[] + ): ChangeObject[] { + return []; + } + + /** + * Determines if this tool can handle a manual refactor trigger. + * Allows extraction when multiple fields are selected in a model file. + */ + async canHandleManualTrigger(context: ManualRefactorContext): Promise { + // Must be in a model file + if (!isModelFile(context.uri)) { + return false; + } + + // Get the source model from context + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + return false; + } + + // For manual trigger, we allow it if there are fields in the model + return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract + } + + /** + * Common field selection and validation logic for manual refactors. + */ + protected async getSelectedFieldsFromContext( + context: ManualRefactorContext, + targetType: string + ): Promise<{ sourceModel: DecoratedClass; selectedFields: PropertyMetadata[] } | undefined> { + const sourceModel = this.getSourceModelFromContext(context); + if (!sourceModel) { + vscode.window.showErrorMessage("Could not find a model in the current context"); + return undefined; + } + + // Get all fields in the model + const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; + if (allFields.length < 2) { + vscode.window.showErrorMessage(`Model must have at least 2 fields to extract some to ${targetType}`); + return undefined; + } + + let selectedFields: PropertyMetadata[] | undefined; + + const treeViewContext = context.treeViewContext as TreeViewContext | undefined; + + if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { + // Tree view context: use the selected field items + selectedFields = treeViewContext.fieldItems.map((fieldItem: any) => { + const fieldItemName = fieldItem.label.toLowerCase(); + const field = allFields.find((prop) => prop.name === fieldItemName); + if (!field) { + throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); + } + return field; + }); + } else { + // Let user select which fields to extract + selectedFields = await this.selectFieldsForExtraction(allFields, targetType); + if (!selectedFields || selectedFields.length === 0) { + return undefined; + } + } + + return { sourceModel, selectedFields }; + } + + /** + * Shows user a quick pick to select which fields to extract. + */ + public async selectFieldsForExtraction(allFields: PropertyMetadata[], type:string): Promise { + const fieldItems = allFields.map((field) => ({ + label: field.name, + description: this.getFieldTypeDescription(field), + field: field, + })); + + const typeUpper = type.charAt(0).toUpperCase() + type.slice(1); + + const selectedItems = await vscode.window.showQuickPick(fieldItems, { + canPickMany: true, + placeHolder: `Select fields to extract to the new ${type} model`, + title: `Extract Fields to ${typeUpper}`, + }); + + return selectedItems?.map((item) => item.field); + } + + /** + * Gets a description of the field type for display in the quick pick. + */ + private getFieldTypeDescription(field: PropertyMetadata): string { + const decoratorName = this.getDecoratorName(field.decorators); + return `@${decoratorName}`; + } + + /** + * Gets the decorator name for a field's type. + */ + private getDecoratorName(decorators: any[]): string { + const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); + return typeDecorator ? typeDecorator.name : "Text"; + } + + /** + * Gets the source model from the refactor context, handling different context types. + */ + protected getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { + // Case 1: metadata is already a DecoratedClass (model) + if (context.metadata && "properties" in context.metadata) { + return context.metadata as DecoratedClass; + } + + // Case 2: metadata is a PropertyMetadata (field) - find the containing model + if (context.metadata && "decorators" in context.metadata) { + const fieldMetadata = context.metadata as PropertyMetadata; + return this.findSourceModelForField(context.cache, fieldMetadata); + } + + // Case 3: no specific metadata - try to find model at the range + return this.findModelAtRange(context.cache, context.uri, context.range); + } + + /** + * Finds the model that contains the given field. + */ + private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + const fieldInModel = Object.values(model.properties).find( + (prop) => + prop.name === fieldMetadata.name && + prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && + prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line + ); + + if (fieldInModel) { + return model; + } + } + + return null; + } + + /** + * Finds the model class that contains the given range. + */ + private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { + const allModels = cache.getDataModelClasses(); + + for (const model of allModels) { + if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { + return model; + } + } + + return null; + } + + /** + * Generates field code directly from PropertyMetadata, preserving all decorator information. + */ + protected generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { + const lines: string[] = []; + + // Add all decorators in the same order as the original + for (const decorator of property.decorators) { + if (decorator.name === "Field" || decorator.name === "Relationship") { + // Handle Field and Relationship decorators with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + lines.push(`@${decorator.name}({`); + const args = decorator.arguments[0]; // Usually the first argument contains the options object + if (typeof args === "object" && args !== null) { + // Format each property of the arguments object + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}({})`); + } + } else { + // Handle type decorators (Text, Choice, etc.) with their arguments + if (decorator.arguments && decorator.arguments.length > 0) { + const args = decorator.arguments[0]; + if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { + lines.push(`@${decorator.name}({`); + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + lines.push(` ${key}: "${value}",`); + } else if (typeof value === "boolean") { + lines.push(` ${key}: ${value},`); + } else if (typeof value === "number") { + lines.push(` ${key}: ${value},`); + } else if (Array.isArray(value)) { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } else { + lines.push(` ${key}: ${JSON.stringify(value)},`); + } + } + lines.push("})"); + } else { + lines.push(`@${decorator.name}()`); + } + } else { + lines.push(`@${decorator.name}()`); + } + } + } + + // Add property declaration using the original type + lines.push(`${property.name}!: ${property.type};`); + + return lines.join("\n"); + } +} diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 21c9fed..0b309e9 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -1,20 +1,16 @@ import * as vscode from "vscode"; import { - IRefactorTool, ChangeObject, ManualRefactorContext, ExtractFieldsToCompositionPayload, } from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; -import { UserInputService } from "../../services/userInputService"; -import { SourceCodeService } from "../../services/sourceCodeService"; import { AddCompositionTool } from "../models/addComposition"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; -import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; -import { TreeViewContext } from "../commandHelpers"; -import { isModelFile } from "../../utils/metadata"; +import { FieldInfo } from "../interfaces"; import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; +import { ExtractFieldsController } from "./extractFieldsController"; /** * Refactor tool for extracting multiple fields from a model to a new composition model. @@ -23,16 +19,13 @@ import { detectIndentation, applyIndentation } from "../../utils/detectIndentati * model, creating a composition relationship between the source and new models. * It provides preview functionality before applying changes. */ -export class ExtractFieldsToCompositionTool implements IRefactorTool { - private userInputService: UserInputService; - private sourceCodeService: SourceCodeService; +export class ExtractFieldsToCompositionTool extends ExtractFieldsController { private addCompositionTool: AddCompositionTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; constructor() { - this.userInputService = new UserInputService(); - this.sourceCodeService = new SourceCodeService(); + super(); this.addCompositionTool = new AddCompositionTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); @@ -59,73 +52,18 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { return ["EXTRACT_FIELDS_TO_COMPOSITION"]; } - /** - * Determines if this tool can handle a manual refactor trigger. - * Allows extraction when multiple fields are selected in a model file. - */ - async canHandleManualTrigger(context: ManualRefactorContext): Promise { - // Must be in a model file - if (!isModelFile(context.uri)) { - return false; - } - - // Get the source model from context - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - return false; - } - - // For manual trigger, we allow it if there are fields in the model - return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract - } - - /** - * This tool doesn't detect automatic changes. - */ - analyze(): ChangeObject[] { - return []; - } - /** * Initiates the manual refactor by prompting user for field selection and composition name. */ async initiateManualRefactor( context: ManualRefactorContext, ): Promise { - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - vscode.window.showErrorMessage("Could not find a model in the current context"); + const fieldSelection = await this.getSelectedFieldsFromContext(context, "composition"); + if (!fieldSelection) { return undefined; } - // Get all fields in the model - const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; - if (allFields.length < 2) { - vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to composition"); - return undefined; - } - - let selectedFields: PropertyMetadata[] | undefined; - - const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - const fieldItemName = fieldItem.label.toLowerCase(); - const field = allFields.find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); - } - return field; - }); - } else { - // Let user select which fields to extract - selectedFields = await this.selectFieldsForExtraction(allFields); - if (!selectedFields || selectedFields.length === 0) { - return undefined; - } - } + const { sourceModel, selectedFields } = fieldSelection; // Get the composition field name const compositionFieldName = await this.userInputService.showPrompt( @@ -266,71 +204,7 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { return lines.join("\n"); } - /** - * Generates field code directly from PropertyMetadata, preserving all decorator information. - */ - private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { - const lines: string[] = []; - // Add all decorators in the same order as the original - for (const decorator of property.decorators) { - if (decorator.name === "Field" || decorator.name === "Relationship") { - // Handle Field and Relationship decorators with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - lines.push(`@${decorator.name}({`); - const args = decorator.arguments[0]; // Usually the first argument contains the options object - if (typeof args === "object" && args !== null) { - // Format each property of the arguments object - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}({})`); - } - } else { - // Handle type decorators (Text, Choice, etc.) with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - const args = decorator.arguments[0]; - if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { - lines.push(`@${decorator.name}({`); - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else if (Array.isArray(value)) { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}()`); - } - } else { - lines.push(`@${decorator.name}()`); - } - } - } - - // Add property declaration using the original type - lines.push(`${property.name}!: ${property.type};`); - - return lines.join("\n"); - } /** * Extracts the dataSource from a model using the cache. @@ -348,96 +222,6 @@ export class ExtractFieldsToCompositionTool implements IRefactorTool { return pascalCase; } - /** - * Shows user a quick pick to select which fields to extract. - */ - private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { - const fieldItems = allFields.map((field) => ({ - label: field.name, - description: this.getFieldTypeDescription(field), - field: field, - })); - - const selectedItems = await vscode.window.showQuickPick(fieldItems, { - canPickMany: true, - placeHolder: "Select fields to extract to the new composition model", - title: "Extract Fields to Composition", - }); - - return selectedItems?.map((item) => item.field); - } - - /** - * Gets a description of the field type for display in the quick pick. - */ - private getFieldTypeDescription(field: PropertyMetadata): string { - const decoratorName = this.getDecoratorName(field.decorators); - return `@${decoratorName}`; - } - - /** - * Gets the source model from the refactor context, handling different context types. - */ - private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { - // Case 1: metadata is already a DecoratedClass (model) - if (context.metadata && "properties" in context.metadata) { - return context.metadata as DecoratedClass; - } - - // Case 2: metadata is a PropertyMetadata (field) - find the containing model - if (context.metadata && "decorators" in context.metadata) { - const fieldMetadata = context.metadata as PropertyMetadata; - return this.findSourceModelForField(context.cache, fieldMetadata); - } - - // Case 3: no specific metadata - try to find model at the range - return this.findModelAtRange(context.cache, context.uri, context.range); - } - - /** - * Finds the model that contains the given field. - */ - private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - const fieldInModel = Object.values(model.properties).find( - (prop) => - prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line - ); - - if (fieldInModel) { - return model; - } - } - - return null; - } - - /** - * Finds the model class that contains the given range. - */ - private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { - return model; - } - } - - return null; - } - - /** - * Gets the decorator name for a field's type. - */ - private getDecoratorName(decorators: any[]): string { - const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); - return typeDecorator ? typeDecorator.name : "Text"; - } /** * Determines the inner model name and whether the field should be an array. diff --git a/src/commands/fields/extractFieldsToEmbedded.ts b/src/commands/fields/extractFieldsToEmbedded.ts index dca0625..fe25322 100644 --- a/src/commands/fields/extractFieldsToEmbedded.ts +++ b/src/commands/fields/extractFieldsToEmbedded.ts @@ -1,19 +1,13 @@ // src/commands/fields/extractFieldsToEmbedded.ts import * as vscode from "vscode"; import { - IRefactorTool, ChangeObject, ManualRefactorContext, ExtractFieldsToEmbeddedPayload, } from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; -import { UserInputService } from "../../services/userInputService"; -import { SourceCodeService } from "../../services/sourceCodeService"; -import { FileSystemService } from "../../services/fileSystemService"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; -import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; -import { TreeViewContext } from "../commandHelpers"; -import { isModelFile } from "../../utils/metadata"; +import { ExtractFieldsController } from "./extractFieldsController"; import * as path from "path"; /** @@ -23,16 +17,11 @@ import * as path from "path"; * model in a separate file, creating an embedded relationship between the source and new models. * The embedded model extends BaseModel and has no dataSource. */ -export class ExtractFieldsToEmbeddedTool implements IRefactorTool { - private userInputService: UserInputService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; +export class ExtractFieldsToEmbeddedTool extends ExtractFieldsController { private deleteFieldTool: DeleteFieldTool; constructor() { - this.userInputService = new UserInputService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); + super(); this.deleteFieldTool = new DeleteFieldTool(); } @@ -57,71 +46,16 @@ export class ExtractFieldsToEmbeddedTool implements IRefactorTool { return ["EXTRACT_FIELDS_TO_EMBEDDED"]; } - /** - * Determines if this tool can handle a manual refactor trigger. - * Allows extraction when multiple fields are selected in a model file. - */ - async canHandleManualTrigger(context: ManualRefactorContext): Promise { - // Must be in a model file - if (!isModelFile(context.uri)) { - return false; - } - - // Get the source model from context - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - return false; - } - - // For manual trigger, we allow it if there are fields in the model - return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract - } - - /** - * This tool doesn't detect automatic changes. - */ - analyze(): ChangeObject[] { - return []; - } - /** * Initiates the manual refactor by prompting user for field selection, new model name, and embedded field name. */ async initiateManualRefactor(context: ManualRefactorContext): Promise { - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - vscode.window.showErrorMessage("Could not find a model in the current context"); - return undefined; - } - - // Get all fields in the model - const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; - if (allFields.length < 2) { - vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to embedded"); + const fieldSelection = await this.getSelectedFieldsFromContext(context, "embedded"); + if (!fieldSelection) { return undefined; } - let selectedFields: PropertyMetadata[] | undefined; - - const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - const fieldItemName = fieldItem.label.toLowerCase(); - const field = allFields.find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); - } - return field; - }); - } else { - // Let user select which fields to extract - selectedFields = await this.selectFieldsForExtraction(allFields); - if (!selectedFields || selectedFields.length === 0) { - return undefined; - } - } + const { sourceModel, selectedFields } = fieldSelection; // Get the new model name const newModelName = await this.userInputService.showPrompt("Enter the name for the new embedded model:"); @@ -223,97 +157,6 @@ export class ExtractFieldsToEmbeddedTool implements IRefactorTool { } } - /** - * Shows user a quick pick to select which fields to extract. - */ - private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { - const fieldItems = allFields.map((field) => ({ - label: field.name, - description: this.getFieldTypeDescription(field), - field: field, - })); - - const selectedItems = await vscode.window.showQuickPick(fieldItems, { - canPickMany: true, - placeHolder: "Select fields to extract to the new embedded model", - title: "Extract Fields to Embedded", - }); - - return selectedItems?.map((item) => item.field); - } - - /** - * Gets a description of the field type for display in the quick pick. - */ - private getFieldTypeDescription(field: PropertyMetadata): string { - const decoratorName = this.getDecoratorName(field.decorators); - return `@${decoratorName}`; - } - - /** - * Gets the source model from the refactor context, handling different context types. - */ - private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { - // Case 1: metadata is already a DecoratedClass (model) - if (context.metadata && "properties" in context.metadata) { - return context.metadata as DecoratedClass; - } - - // Case 2: metadata is a PropertyMetadata (field) - find the containing model - if (context.metadata && "decorators" in context.metadata) { - const fieldMetadata = context.metadata as PropertyMetadata; - return this.findSourceModelForField(context.cache, fieldMetadata); - } - - // Case 3: no specific metadata - try to find model at the range - return this.findModelAtRange(context.cache, context.uri, context.range); - } - - /** - * Finds the model that contains the given field. - */ - private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - const fieldInModel = Object.values(model.properties).find( - (prop) => - prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line - ); - - if (fieldInModel) { - return model; - } - } - - return null; - } - - /** - * Finds the model class that contains the given range. - */ - private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { - return model; - } - } - - return null; - } - - /** - * Gets the decorator name for a field's type. - */ - private getDecoratorName(decorators: any[]): string { - const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); - return typeDecorator ? typeDecorator.name : "Text"; - } - /** * Generates the complete file content for the new embedded model, including imports. * Embedded models extend BaseModel and have no dataSource. @@ -356,72 +199,6 @@ export class ExtractFieldsToEmbeddedTool implements IRefactorTool { return lines.join("\n"); } - /** - * Generates field code directly from PropertyMetadata, preserving all decorator information. - */ - private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { - const lines: string[] = []; - - // Add all decorators in the same order as the original - for (const decorator of property.decorators) { - if (decorator.name === "Field" || decorator.name === "Relationship") { - // Handle Field and Relationship decorators with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - lines.push(`@${decorator.name}({`); - const args = decorator.arguments[0]; // Usually the first argument contains the options object - if (typeof args === "object" && args !== null) { - // Format each property of the arguments object - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}({})`); - } - } else { - // Handle type decorators (Text, Choice, etc.) with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - const args = decorator.arguments[0]; - if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { - lines.push(`@${decorator.name}({`); - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else if (Array.isArray(value)) { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}()`); - } - } else { - lines.push(`@${decorator.name}()`); - } - } - } - - // Add property declaration using the original type - lines.push(`${property.name}!: ${property.type};`); - - return lines.join("\n"); - } - /** * Adds an embedded field to the source model. */ diff --git a/src/commands/fields/extractFieldsToParent.ts b/src/commands/fields/extractFieldsToParent.ts index 93bc734..aa87448 100644 --- a/src/commands/fields/extractFieldsToParent.ts +++ b/src/commands/fields/extractFieldsToParent.ts @@ -1,20 +1,14 @@ import * as vscode from "vscode"; import { - IRefactorTool, ChangeObject, ManualRefactorContext, ExtractFieldsToParentPayload, } from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; -import { UserInputService } from "../../services/userInputService"; -import { SourceCodeService } from "../../services/sourceCodeService"; -import { FileSystemService } from "../../services/fileSystemService"; import { NewModelTool } from "../models/newModel"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; -import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; -import { TreeViewContext } from "../commandHelpers"; -import { isModelFile } from "../../utils/metadata"; +import { ExtractFieldsController } from "./extractFieldsController"; import * as path from "path"; /** @@ -24,18 +18,13 @@ import * as path from "path"; * model extending BaseModel. The source model will then extend from this new parent model * instead of its current parent. It provides preview functionality before applying changes. */ -export class ExtractFieldsToParentTool implements IRefactorTool { - private userInputService: UserInputService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; +export class ExtractFieldsToParentTool extends ExtractFieldsController { private newModelTool: NewModelTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; constructor() { - this.userInputService = new UserInputService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); + super(); this.newModelTool = new NewModelTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); @@ -62,71 +51,16 @@ export class ExtractFieldsToParentTool implements IRefactorTool { return ["EXTRACT_FIELDS_TO_PARENT"]; } - /** - * Determines if this tool can handle a manual refactor trigger. - * Allows extraction when multiple fields are selected in a model file. - */ - async canHandleManualTrigger(context: ManualRefactorContext): Promise { - // Must be in a model file - if (!isModelFile(context.uri)) { - return false; - } - - // Get the source model from context - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - return false; - } - - // For manual trigger, we allow it if there are fields in the model - return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract - } - - /** - * This tool doesn't detect automatic changes. - */ - analyze(): ChangeObject[] { - return []; - } - /** * Initiates the manual refactor by prompting user for field selection and new parent model name. */ async initiateManualRefactor(context: ManualRefactorContext): Promise { - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - vscode.window.showErrorMessage("Could not find a model in the current context"); - return undefined; - } - - // Get all fields in the model - const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; - if (allFields.length < 2) { - vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to parent"); + const fieldSelection = await this.getSelectedFieldsFromContext(context, "parent"); + if (!fieldSelection) { return undefined; } - let selectedFields: PropertyMetadata[] | undefined; - - const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - const fieldItemName = fieldItem.label.toLowerCase(); - const field = allFields.find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); - } - return field; - }); - } else { - // Let user select which fields to extract - selectedFields = await this.selectFieldsForExtraction(allFields); - if (!selectedFields || selectedFields.length === 0) { - return undefined; - } - } + const { sourceModel, selectedFields } = fieldSelection; // Get the new parent model name const newParentModelName = await this.userInputService.showPrompt("Enter the name for the new abstract parent model:"); @@ -220,97 +154,6 @@ export class ExtractFieldsToParentTool implements IRefactorTool { } } - /** - * Shows user a quick pick to select which fields to extract. - */ - private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { - const fieldItems = allFields.map((field) => ({ - label: field.name, - description: this.getFieldTypeDescription(field), - field: field, - })); - - const selectedItems = await vscode.window.showQuickPick(fieldItems, { - canPickMany: true, - placeHolder: "Select fields to extract to the new abstract parent model", - title: "Extract Fields to Parent", - }); - - return selectedItems?.map((item) => item.field); - } - - /** - * Gets a description of the field type for display in the quick pick. - */ - private getFieldTypeDescription(field: PropertyMetadata): string { - const decoratorName = this.getDecoratorName(field.decorators); - return `@${decoratorName}`; - } - - /** - * Gets the source model from the refactor context, handling different context types. - */ - private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { - // Case 1: metadata is already a DecoratedClass (model) - if (context.metadata && "properties" in context.metadata) { - return context.metadata as DecoratedClass; - } - - // Case 2: metadata is a PropertyMetadata (field) - find the containing model - if (context.metadata && "decorators" in context.metadata) { - const fieldMetadata = context.metadata as PropertyMetadata; - return this.findSourceModelForField(context.cache, fieldMetadata); - } - - // Case 3: no specific metadata - try to find model at the range - return this.findModelAtRange(context.cache, context.uri, context.range); - } - - /** - * Finds the model that contains the given field. - */ - private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - const fieldInModel = Object.values(model.properties).find( - (prop) => - prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line - ); - - if (fieldInModel) { - return model; - } - } - - return null; - } - - /** - * Finds the model class that contains the given range. - */ - private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { - return model; - } - } - - return null; - } - - /** - * Gets the decorator name for a field's type. - */ - private getDecoratorName(decorators: any[]): string { - const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); - return typeDecorator ? typeDecorator.name : "Text"; - } - /** * Generates the complete file content for the new abstract parent model, including imports. */ @@ -323,7 +166,7 @@ export class ExtractFieldsToParentTool implements IRefactorTool { const lines: string[] = []; // Generate imports - const requiredImports = new Set(["BaseModel", "Field"]); + const requiredImports = new Set(["BaseModel", "Field", "Model"]); for (const field of fieldsToExtract) { for (const decorator of field.decorators) { requiredImports.add(decorator.name); @@ -355,72 +198,6 @@ export class ExtractFieldsToParentTool implements IRefactorTool { return lines.join("\n"); } - /** - * Generates field code directly from PropertyMetadata, preserving all decorator information. - */ - private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { - const lines: string[] = []; - - // Add all decorators in the same order as the original - for (const decorator of property.decorators) { - if (decorator.name === "Field" || decorator.name === "Relationship") { - // Handle Field and Relationship decorators with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - lines.push(`@${decorator.name}({`); - const args = decorator.arguments[0]; // Usually the first argument contains the options object - if (typeof args === "object" && args !== null) { - // Format each property of the arguments object - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}({})`); - } - } else { - // Handle type decorators (Text, Choice, etc.) with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - const args = decorator.arguments[0]; - if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { - lines.push(`@${decorator.name}({`); - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else if (Array.isArray(value)) { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}()`); - } - } else { - lines.push(`@${decorator.name}()`); - } - } - } - - // Add property declaration using the original type - lines.push(`${property.name}!: ${property.type};`); - - return lines.join("\n"); - } - /** * Updates the source model to extend from the new parent model instead of its current parent. */ diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts index fd702c0..f63b2ea 100644 --- a/src/commands/fields/extractFieldsToReference.ts +++ b/src/commands/fields/extractFieldsToReference.ts @@ -1,20 +1,14 @@ import * as vscode from "vscode"; import { - IRefactorTool, ChangeObject, ManualRefactorContext, ExtractFieldsToReferencePayload, } from "../../refactor/refactorInterfaces"; import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; -import { UserInputService } from "../../services/userInputService"; -import { SourceCodeService } from "../../services/sourceCodeService"; -import { FileSystemService } from "../../services/fileSystemService"; import { NewModelTool } from "../models/newModel"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; -import { FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; -import { TreeViewContext } from "../commandHelpers"; -import { isModelFile } from "../../utils/metadata"; +import { ExtractFieldsController } from "./extractFieldsController"; import * as path from "path"; /** @@ -24,18 +18,13 @@ import * as path from "path"; * model in a separate file, creating a reference relationship between the source and new models. * It provides preview functionality before applying changes. */ -export class ExtractFieldsToReferenceTool implements IRefactorTool { - private userInputService: UserInputService; - private sourceCodeService: SourceCodeService; - private fileSystemService: FileSystemService; +export class ExtractFieldsToReferenceTool extends ExtractFieldsController { private newModelTool: NewModelTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; constructor() { - this.userInputService = new UserInputService(); - this.sourceCodeService = new SourceCodeService(); - this.fileSystemService = new FileSystemService(); + super(); this.newModelTool = new NewModelTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); @@ -62,82 +51,25 @@ export class ExtractFieldsToReferenceTool implements IRefactorTool { return ["EXTRACT_FIELDS_TO_REFERENCE"]; } - /** - * Determines if this tool can handle a manual refactor trigger. - * Allows extraction when multiple fields are selected in a model file. - */ - async canHandleManualTrigger(context: ManualRefactorContext): Promise { - // Must be in a model file - if (!isModelFile(context.uri)) { - return false; - } - - // Get the source model from context - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - return false; - } - - // For manual trigger, we allow it if there are fields in the model - return Object.keys(sourceModel.properties || {}).length > 1; // Need at least 2 fields to extract - } - - /** - * This tool doesn't detect automatic changes. - */ - analyze(): ChangeObject[] { - return []; - } - /** * Initiates the manual refactor by prompting user for field selection, new model name, and reference field name. */ async initiateManualRefactor(context: ManualRefactorContext): Promise { - const sourceModel = this.getSourceModelFromContext(context); - if (!sourceModel) { - vscode.window.showErrorMessage("Could not find a model in the current context"); - return undefined; - } - - // Get all fields in the model - const allFields = Object.values(sourceModel.properties) as PropertyMetadata[]; - if (allFields.length < 2) { - vscode.window.showErrorMessage("Model must have at least 2 fields to extract some to reference"); + const fieldSelection = await this.getSelectedFieldsFromContext(context, "reference"); + if (!fieldSelection) { return undefined; } - let selectedFields: PropertyMetadata[] | undefined; + const { sourceModel, selectedFields } = fieldSelection; - const treeViewContext = context.treeViewContext as TreeViewContext | undefined; - - if (treeViewContext?.fieldItems && treeViewContext.fieldItems.length > 0) { - // Tree view context: use the selected field items - selectedFields = treeViewContext.fieldItems.map((fieldItem) => { - const fieldItemName = fieldItem.label.toLowerCase(); - const field = allFields.find((prop) => prop.name === fieldItemName); - if (!field) { - throw new Error(`Could not find field '${fieldItem.label}' in model '${context.metadata?.name}'`); - } - return field; - }); - } else { - // Let user select which fields to extract - selectedFields = await this.selectFieldsForExtraction(allFields); - if (!selectedFields || selectedFields.length === 0) { - return undefined; - } - } - - // Get the new model name + // Get the new reference model name const newModelName = await this.userInputService.showPrompt("Enter the name for the new reference model:"); if (!newModelName) { return undefined; } // Get the reference field name - const referenceFieldName = await this.userInputService.showPrompt( - "Enter the name for the new reference field (e.g., 'user', 'category'):" - ); + const referenceFieldName = await this.userInputService.showPrompt("Enter the name for the reference field:"); if (!referenceFieldName) { return undefined; } @@ -245,97 +177,6 @@ export class ExtractFieldsToReferenceTool implements IRefactorTool { return selectedFields; } - /** - * Shows user a quick pick to select which fields to extract. - */ - private async selectFieldsForExtraction(allFields: PropertyMetadata[]): Promise { - const fieldItems = allFields.map((field) => ({ - label: field.name, - description: this.getFieldTypeDescription(field), - field: field, - })); - - const selectedItems = await vscode.window.showQuickPick(fieldItems, { - canPickMany: true, - placeHolder: "Select fields to extract to the new reference model", - title: "Extract Fields to Reference", - }); - - return selectedItems?.map((item) => item.field); - } - - /** - * Gets a description of the field type for display in the quick pick. - */ - private getFieldTypeDescription(field: PropertyMetadata): string { - const decoratorName = this.getDecoratorName(field.decorators); - return `@${decoratorName}`; - } - - /** - * Gets the source model from the refactor context, handling different context types. - */ - private getSourceModelFromContext(context: ManualRefactorContext): DecoratedClass | null { - // Case 1: metadata is already a DecoratedClass (model) - if (context.metadata && "properties" in context.metadata) { - return context.metadata as DecoratedClass; - } - - // Case 2: metadata is a PropertyMetadata (field) - find the containing model - if (context.metadata && "decorators" in context.metadata) { - const fieldMetadata = context.metadata as PropertyMetadata; - return this.findSourceModelForField(context.cache, fieldMetadata); - } - - // Case 3: no specific metadata - try to find model at the range - return this.findModelAtRange(context.cache, context.uri, context.range); - } - - /** - * Finds the model that contains the given field. - */ - private findSourceModelForField(cache: MetadataCache, fieldMetadata: PropertyMetadata): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - const fieldInModel = Object.values(model.properties).find( - (prop) => - prop.name === fieldMetadata.name && - prop.declaration.uri.fsPath === fieldMetadata.declaration.uri.fsPath && - prop.declaration.range.start.line === fieldMetadata.declaration.range.start.line - ); - - if (fieldInModel) { - return model; - } - } - - return null; - } - - /** - * Finds the model class that contains the given range. - */ - private findModelAtRange(cache: MetadataCache, uri: vscode.Uri, range: vscode.Range): DecoratedClass | null { - const allModels = cache.getDataModelClasses(); - - for (const model of allModels) { - if (model.declaration.uri.fsPath === uri.fsPath && model.declaration.range.contains(range)) { - return model; - } - } - - return null; - } - - /** - * Gets the decorator name for a field's type. - */ - private getDecoratorName(decorators: any[]): string { - const typeDecorator = decorators.find((d) => FIELD_TYPE_OPTIONS.some((o) => o.decorator === d.name)); - return typeDecorator ? typeDecorator.name : "Text"; - } - /** * Creates a new reference model in a separate file with the extracted fields. */ @@ -426,72 +267,6 @@ export class ExtractFieldsToReferenceTool implements IRefactorTool { return lines.join("\n"); } - /** - * Generates field code directly from PropertyMetadata, preserving all decorator information. - */ - private generateFieldCodeFromPropertyMetadata(property: PropertyMetadata): string { - const lines: string[] = []; - - // Add all decorators in the same order as the original - for (const decorator of property.decorators) { - if (decorator.name === "Field" || decorator.name === "Relationship") { - // Handle Field and Relationship decorators with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - lines.push(`@${decorator.name}({`); - const args = decorator.arguments[0]; // Usually the first argument contains the options object - if (typeof args === "object" && args !== null) { - // Format each property of the arguments object - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}({})`); - } - } else { - // Handle type decorators (Text, Choice, etc.) with their arguments - if (decorator.arguments && decorator.arguments.length > 0) { - const args = decorator.arguments[0]; - if (typeof args === "object" && args !== null && Object.keys(args).length > 0) { - lines.push(`@${decorator.name}({`); - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - lines.push(` ${key}: "${value}",`); - } else if (typeof value === "boolean") { - lines.push(` ${key}: ${value},`); - } else if (typeof value === "number") { - lines.push(` ${key}: ${value},`); - } else if (Array.isArray(value)) { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } else { - lines.push(` ${key}: ${JSON.stringify(value)},`); - } - } - lines.push("})"); - } else { - lines.push(`@${decorator.name}()`); - } - } else { - lines.push(`@${decorator.name}()`); - } - } - } - - // Add property declaration using the original type - lines.push(`${property.name}!: ${property.type};`); - - return lines.join("\n"); - } - /** * Extracts the dataSource from a model using the cache. */ From 35a32bb95a604935094c7cb6828e2d407e21f9a2 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 22 Sep 2025 12:21:41 -0300 Subject: [PATCH 27/36] Changed `PersistentModel` and `PersistentComponentModel` instances into `BaseModel` --- src/commands/fields/addField.ts | 4 - .../fields/changeCompositionToReference.ts | 30 +-- .../fields/changeReferenceToComposition.ts | 12 +- .../fields/extractFieldsToComposition.ts | 6 +- .../fields/extractFieldsToReference.ts | 4 +- src/commands/models/addComposition.ts | 36 ++- src/commands/models/addReference.ts | 4 +- src/services/sourceCodeService.ts | 239 ++++++++++++++++-- src/test/addComposition.test.ts | 4 +- .../changeCompositionToReference.test.ts | 2 +- src/test/refactor/deleteModel.test.ts | 4 +- 11 files changed, 268 insertions(+), 77 deletions(-) diff --git a/src/commands/fields/addField.ts b/src/commands/fields/addField.ts index e3dc465..9929981 100644 --- a/src/commands/fields/addField.ts +++ b/src/commands/fields/addField.ts @@ -255,10 +255,6 @@ export class AddFieldTool implements AIEnhancedTool { ): Promise { const lines = document.getText().split("\n"); const newImports = new Set(["Field", fieldInfo.type.decorator]); - - if (fieldInfo.type.decorator === "Composition") { - newImports.add("PersistentComponentModel"); - } // Add imports using source code service logic (we need to call a helper method) await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, newImports); diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts index dbbc0e1..4b82ae4 100644 --- a/src/commands/fields/changeCompositionToReference.ts +++ b/src/commands/fields/changeCompositionToReference.ts @@ -13,12 +13,7 @@ import * as path from "path"; /** * Tool for converting composition relationships to reference relationships. * - * This tool converts a @Composition field to a @Reference field by: - * 1. Finding the component model that is currently embedded - * 2. Extracting the component model to its own file - * 3. Converting the component model from PersistentComponentModel to PersistentModel - * 4. Converting the field from @Composition to @Reference - * 5. Adding the necessary imports for the new referenced model + * This tool converts a @Composition field to a @Reference */ export class ChangeCompositionToReferenceTool { private userInputService: UserInputService; @@ -181,21 +176,19 @@ export class ChangeCompositionToReferenceTool { const sourceModelDecorator = cache.getModelDecoratorByName("Model", sourceModel); const dataSource = sourceModelDecorator?.arguments?.[0]?.dataSource; - // Step 5: Extract existing model imports from the source file - const existingImports = this.sourceCodeService.extractModelImports(sourceDocument); - const existingImportsSet = new Set(existingImports); - - // Step 6: Convert the class body for independent model use + // Step 5: Convert the class body for independent model use const convertedClassBody = this.convertComponentClassBody(classBody); - // Step 7: Generate the complete model file content - const modelFileContent = this.sourceCodeService.generateModelFileContent( + // Step 6: Generate the complete model file content + const modelFileContent = await this.sourceCodeService.generateModelFileContent( componentModel.name, convertedClassBody, - "PersistentModel", // Convert from PersistentComponentModel to PersistentModel + "BaseModel", dataSource, - existingImportsSet, - false // isComponent = false since this is now an independent model + undefined, + false, + targetFilePath, + cache ); // Step 8: Add related enums to the file content @@ -214,10 +207,7 @@ export class ChangeCompositionToReferenceTool { * This mainly involves ensuring proper formatting and removing any component-specific elements. */ private convertComponentClassBody(classBody: string): string { - // For now, we can use the class body as-is since the main difference is in the - // class declaration (PersistentComponentModel vs PersistentModel) which is handled - // in generateModelFileContent. - + // Future enhancements could include: // - Removing component-specific decorators if any // - Adjusting field configurations if needed diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index 20be908..7035536 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -82,7 +82,7 @@ export class ChangeReferenceToCompositionTool { } // Add necessary imports to the workspace edit - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "PersistentComponentModel", "Field", "Composition"])); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "BaseModel", "Field", "Composition"])); // Step 9: Focus on the newly modified field await this.sourceCodeService.focusOnElement(document, fieldName); @@ -253,13 +253,15 @@ export class ChangeReferenceToCompositionTool { const resolvedEnums = await this.resolveEnumConflicts(enumDefinitions, sourceDocument, classBody, sourceModel.name); // Step 6: Generate the complete component model content - let componentModelCode = this.sourceCodeService.generateModelFileContent( + let componentModelCode = await this.sourceCodeService.generateModelFileContent( targetModel.name, resolvedEnums.updatedClassBody, - `PersistentComponentModel<${sourceModel.name}>`, // Use component model base class + `BaseModel`, // Use component model base class dataSource, - new Set(["Field", "PersistentComponentModel"]), // Ensure required imports - true // This is a component model (no export keyword) + new Set(["Field", "BaseModel"]), // Ensure required imports + true, // This is a component model (no export keyword) + targetModel.declaration.uri.fsPath, + cache ); // Step 7: Extract only the component model part (remove imports and add enums) diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 0b309e9..120fba6 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -173,7 +173,6 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { */ private generateInnerModelCodeWithFields( innerModelName: string, - outerModelName: string, dataSource: string | undefined, fields: PropertyMetadata[] ): string { @@ -189,7 +188,7 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { } // Add class declaration - lines.push(`class ${innerModelName} extends PersistentComponentModel<${outerModelName}> {`); + lines.push(`class ${innerModelName} extends BaseModel {`); lines.push(``); // Add each field using the enhanced method that preserves all decorator information @@ -300,13 +299,12 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { // Generate the inner model code with fields const innerModelCode = this.generateInnerModelCodeWithFields( innerModelName, - outerModelName, dataSource, fieldsToAdd ); // Add required imports - collect from the PropertyMetadata decorators - const requiredImports = new Set(["Model", "Field", "PersistentComponentModel", "Composition"]); + const requiredImports = new Set(["Model", "Field", "Composition"]); // Add field-specific imports based on the decorators in PropertyMetadata for (const property of fieldsToAdd) { for (const decorator of property.decorators) { diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts index f63b2ea..bee5c3a 100644 --- a/src/commands/fields/extractFieldsToReference.ts +++ b/src/commands/fields/extractFieldsToReference.ts @@ -227,7 +227,7 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { const dataSource = this.extractDataSourceFromModel(sourceModel, cache); // Generate imports - const requiredImports = new Set(["Model", "Field", "PersistentModel"]); + const requiredImports = new Set(["Model", "Field", "BaseModel"]); for (const field of fieldsToExtract) { for (const decorator of field.decorators) { requiredImports.add(decorator.name); @@ -251,7 +251,7 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { lines.push(`@Model()`); } - lines.push(`export class ${modelName} extends PersistentModel {`); + lines.push(`export class ${modelName} extends BaseModel {`); lines.push(""); // Add each field using PropertyMetadata to preserve all decorator information diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 82b7e0e..17883bd 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -59,7 +59,12 @@ export class AddCompositionTool { * @param fieldName - The predefined field name for the composition * @returns Promise that resolves with the created inner model name when the composition is added */ - public async addCompositionProgrammatically(cache: MetadataCache, modelName: string, fieldName: string): Promise { + public async addCompositionProgrammatically( + cache: MetadataCache, + modelName: string, + fieldName: string + ): Promise { + const edit = new vscode.WorkspaceEdit(); try { // Step 1: Validate target file const { modelClass, document } = await this.validateAndPrepareTarget(modelName, cache); @@ -76,6 +81,13 @@ export class AddCompositionTool { // Step 5: Add composition field to outer model await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); + // Add required imports + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel"]); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Apply the edit + await vscode.workspace.applyEdit(edit); + // Step 6: Focus on the newly created field await this.sourceCodeService.focusOnElement(document, fieldName); @@ -101,7 +113,7 @@ export class AddCompositionTool { * @param fieldName - The predefined field name for the composition * @returns Promise that resolves to a WorkspaceEdit containing all necessary changes and the inner model name * @throws Error if validation fails or models already exist - * + * */ public async createAddCompositionWorkspaceEdit( cache: MetadataCache, @@ -130,7 +142,15 @@ export class AddCompositionTool { await this.addInnerModelEditToWorkspace(edit, document, innerModelName, modelClass.name, cache); // Step 7: Add composition field edit - await this.addCompositionFieldEditToWorkspace(edit, document, modelClass.name, fieldName, innerModelName, isArray, cache); + await this.addCompositionFieldEditToWorkspace( + edit, + document, + modelClass.name, + fieldName, + innerModelName, + isArray, + cache + ); return { edit, innerModelName }; } @@ -158,7 +178,7 @@ export class AddCompositionTool { const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName, dataSource); // Add required imports - const requiredImports = new Set(["Model", "Field", "Relationship", "PersistentComponentModel"]); + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel"]); await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); // Find insertion point after the outer model @@ -214,10 +234,10 @@ export class AddCompositionTool { // Add field insertion edits using the source code service approach const lines = document.getText().split("\n"); - const requiredImports = new Set(["Field", "Composition"]); + //const requiredImports = new Set(["Field", "Composition", "BaseModel"]); // Add imports - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + //await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); // Find class boundaries and add field const { classEndLine } = this.sourceCodeService.findClassBoundaries(lines, outerModelName); @@ -382,7 +402,7 @@ export class AddCompositionTool { lines.push(`@Model()`); } lines.push(`})`); - lines.push(`class ${innerModelName} extends PersistentComponentModel<${outerModelName}> {`); + lines.push(`class ${innerModelName} extends BaseModel {`); lines.push(``); lines.push(`}`); @@ -444,6 +464,4 @@ export class AddCompositionTool { return lines.join("\n"); } - - } diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index a3e415f..c4ed885 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -331,7 +331,7 @@ export class AddReferenceTool { const lines: string[] = []; // Add basic framework imports - lines.push(`import { Model, PersistentModel, Field } from 'slingr-framework';`); + lines.push(`import { Model, BaseModel, Field } from 'slingr-framework';`); // Add datasource import if needed if (dataSource) { @@ -351,7 +351,7 @@ export class AddReferenceTool { } else { lines.push(`@Model()`); } - lines.push(`export class ${modelName} extends PersistentModel {`); + lines.push(`export class ${modelName} extends BaseModel {`); lines.push(``); lines.push(`\t@Field({})`); lines.push(`\tname!: string;`); diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index 38e550f..c68d4ae 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -26,9 +26,6 @@ export class SourceCodeService { const edit = new vscode.WorkspaceEdit(); const lines = document.getText().split("\n"); const newImports = new Set(["Field", fieldInfo.type.decorator]); - if (fieldInfo.type.decorator === "Composition") { - newImports.add("PersistentComponentModel"); - } await this.ensureSlingrFrameworkImports(document, edit, newImports); @@ -157,27 +154,75 @@ export class SourceCodeService { // Determine the import path let importPath = `./${targetModel}`; - if (cache) { - // Find the file path for the target model - const targetModelFilePath = this.findModelFilePath(cache, targetModel); - - if (targetModelFilePath) { - // Calculate relative path from current file to target model file - const currentFilePath = document.uri.fsPath; - const relativePath = path.relative(path.dirname(currentFilePath), targetModelFilePath); - importPath = relativePath.replace(/\.ts$/, "").replace(/\\/g, "/"); - if (!importPath.startsWith(".")) { - importPath = "./" + importPath; - } - } - } - // Create the import statement const importStatement = `import { ${targetModel} } from '${importPath}';`; edit.insert(document.uri, new vscode.Position(insertLine, 0), importStatement + "\n"); } + /** + * Adds a datasource import to a document. + * + * @param document - The document to add the import to + * @param dataSourceName - The name of the datasource to import + * @param edit - The workspace edit to add changes to + * @param cache - Optional metadata cache to lookup datasource information + * @returns Promise + */ + public async addDataSourceImport( + document: vscode.TextDocument, + dataSourceName: string, + edit: vscode.WorkspaceEdit, + cache?: MetadataCache + ): Promise { + const content = document.getText(); + const lines = content.split("\n"); + + // Clean up the datasource name (remove quotes if it's a string literal) + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + + // Check if the datasource is already imported + const existingImport = lines.find( + (line) => line.includes("import") && line.includes(cleanDataSourceName) && !line.includes("slingr-framework") + ); + + if (existingImport) { + return; // Already imported + } + + // Find the best place to insert the import (after slingr-framework imports, before model imports) + let insertLine = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("import") && lines[i].includes("slingr-framework")) { + insertLine = i + 1; + // Look for empty line after slingr-framework import + if (i + 1 < lines.length && lines[i + 1].trim() === "") { + insertLine = i + 1; + break; + } + } else if (lines[i].startsWith("import ") && !lines[i].includes("slingr-framework")) { + // Found other imports, insert before them + break; + } else if (lines[i].includes("@Model") || lines[i].includes("export class")) { + // Found the start of the model definition + break; + } + } + + // Get the datasource import using our findDataSourcePath method + try { + const dataSourceImport = await this.findDataSourcePath(cleanDataSourceName, document.uri.fsPath, cache); + if (dataSourceImport) { + edit.insert(document.uri, new vscode.Position(insertLine, 0), dataSourceImport + "\n"); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const importStatement = `import { ${cleanDataSourceName} } from '../dataSources/${cleanDataSourceName}';`; + edit.insert(document.uri, new vscode.Position(insertLine, 0), importStatement + "\n"); + } + } + /** * Updates import statements in a file to reflect a folder rename. * @@ -335,12 +380,123 @@ export class SourceCodeService { return undefined; } + /** + * Finds the datasource path by name in the workspace. + * + * @param dataSourceName - The name of the datasource to find + * @param fromFilePath - The file path from which to calculate relative import path + * @param cache - Optional metadata cache to lookup datasource information + * @returns The import statement for the datasource or null if not found + */ + public async findDataSourcePath(dataSourceName: string, fromFilePath: string, cache?: MetadataCache): Promise { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + return null; + } + + // Clean up the datasource name (remove quotes if it's a string literal) + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + + // First, try to find the datasource in the cache + if (cache) { + const dataSources = cache.getDataSources(); + const targetDataSource = dataSources.find(ds => ds.name === cleanDataSourceName); + + if (targetDataSource) { + // Use the actual file path from the cache + const dataSourceFilePath = targetDataSource.declaration.uri.fsPath; + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourceFilePath); + + // Remove file extension for import + const importPath = relativePath.replace(/\.(ts|js)$/, "").replace(/\\/g, "/"); + + // Ensure the path starts with './' if it's a relative path + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}';`; + } + } + + // Fallback: Look for datasource file in src/dataSources directory + const dataSourcesDir = path.join(workspaceFolder.uri.fsPath, "src", "dataSources"); + + // Try common file extensions for datasource files + const possibleExtensions = [".ts", ".js"]; + + for (const ext of possibleExtensions) { + const dataSourceFile = path.join(dataSourcesDir, cleanDataSourceName + ext); + + try { + // Check if the file exists + await vscode.workspace.fs.stat(vscode.Uri.file(dataSourceFile)); + + // Calculate relative path from the target file to the datasource + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourceFile); + + // Remove file extension for import + const importPath = relativePath.replace(/\.(ts|js)$/, "").replace(/\\/g, "/"); + + // Ensure the path starts with './' if it's a relative path + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}';`; + } catch (error) { + // File doesn't exist, continue to next extension + continue; + } + } + + // If no file found, create a generic import based on standard structure + const fromFileDir = path.dirname(fromFilePath); + const relativePath = path.relative(fromFileDir, dataSourcesDir); + const importPath = relativePath.replace(/\\/g, "/"); + const finalImportPath = importPath.startsWith(".") ? importPath : "./" + importPath; + + return `import { ${cleanDataSourceName} } from '${finalImportPath}/${cleanDataSourceName}';`; + } catch (error) { + console.warn("Could not find datasource path:", error); + return null; + } + } + + /** + * Gets the actual file path where a datasource is defined using the cache. + * This is useful when you need to know the physical location of a datasource. + * + * @param dataSourceName - The name of the datasource to find + * @param cache - The metadata cache to lookup datasource information + * @returns The file path where the datasource is defined, or null if not found + */ + public getDataSourceFilePath(dataSourceName: string, cache: MetadataCache): string | null { + try { + const cleanDataSourceName = dataSourceName.replace(/['"]/g, ""); + const dataSources = cache.getDataSources(); + const targetDataSource = dataSources.find(ds => ds.name === cleanDataSourceName); + + return targetDataSource ? targetDataSource.declaration.uri.fsPath : null; + } catch (error) { + console.warn("Could not find datasource in cache:", error); + return null; + } + } + /** * Extracts the datasource import from the source model file. */ - public async extractImport(sourceModel: DecoratedClass, importName: string): Promise { + public async extractImport(sourceModel: DecoratedClass, importName: string, cache?: MetadataCache): Promise { try { - // Read the source model file to extract datasource imports + // First, try to use the cache to find the datasource and generate the import + if (cache) { + const dataSourceImport = await this.findDataSourcePath(importName, sourceModel.declaration.uri.fsPath, cache); + if (dataSourceImport) { + return dataSourceImport; + } + } + + // Fallback: Read the source model file to extract datasource imports const document = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); const content = document.getText(); const lines = content.split("\n"); @@ -431,26 +587,30 @@ export class SourceCodeService { * * @param modelName - The name of the new model class * @param classBody - The complete class body content - * @param baseClass - The base class to extend (default: "PersistentModel") + * @param baseClass - The base class to extend (default: "BaseModel") * @param dataSource - Optional datasource for the model * @param existingImports - Set of imports that should be included * @param isComponent - Whether this is a component model (affects export and class declaration) + * @param targetFilePath - Optional path where the model file will be created (for accurate relative import calculation) + * @param cache - Optional metadata cache to lookup datasource information * @returns The complete model file content */ - public generateModelFileContent( + public async generateModelFileContent( modelName: string, classBody: string, - baseClass: string = "PersistentModel", + baseClass: string = "BaseModel", dataSource?: string, existingImports?: Set, - isComponent: boolean = false - ): string { + isComponent: boolean = false, + targetFilePath?: string, + cache?: MetadataCache + ): Promise { const lines: string[] = []; // Determine required imports const imports = new Set(["Model", "Field"]); - // Add base class to imports (handle complex base classes like PersistentComponentModel) + // Add base class to imports const baseClassCore = baseClass.split("<")[0]; // Extract base class name before generic imports.add(baseClassCore); @@ -468,6 +628,31 @@ export class SourceCodeService { lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); lines.push(""); + // Add datasource import if applicable + if (dataSource) { + if (targetFilePath) { + // Use the new findDataSourcePath function for accurate import resolution + try { + const dataSourceImport = await this.findDataSourcePath(dataSource, targetFilePath, cache); + if (dataSourceImport) { + lines.push(dataSourceImport); + lines.push(""); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const cleanDataSource = dataSource.replace(/['"]/g, ""); + lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); + lines.push(""); + } + } else { + // Fallback to generic import pattern when no target file path is provided + const cleanDataSource = dataSource.replace(/['"]/g, ""); + lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); + lines.push(""); + } + } + // Add model decorator if (dataSource) { lines.push(`@Model({`); @@ -493,6 +678,8 @@ export class SourceCodeService { return lines.join("\n"); } + + /** * Analyzes class body content to determine which imports are needed. * diff --git a/src/test/addComposition.test.ts b/src/test/addComposition.test.ts index 662e3a7..101890f 100644 --- a/src/test/addComposition.test.ts +++ b/src/test/addComposition.test.ts @@ -318,7 +318,7 @@ profile!: Profile;`; assert.ok(modifiedContent.includes('addresses!: Address[]'), 'Field declaration should be present'); // Verify the inner model was created - assert.ok(modifiedContent.includes('class Address extends PersistentComponentModel'), 'Inner model should be created'); + assert.ok(modifiedContent.includes('class Address extends BaseModel'), 'Inner model should be created'); assert.ok(modifiedContent.includes('@Model()'), 'Model decorator should be present on inner model'); // Verify original file is unchanged (since we didn't apply the edit) @@ -365,7 +365,7 @@ profile!: Profile;`; assert.ok(!modifiedContent.includes('profile!: Profile[]'), 'Should not be array for singular field'); // Verify the inner model was created - assert.ok(modifiedContent.includes('class Profile extends PersistentComponentModel'), 'Inner model should be created'); + assert.ok(modifiedContent.includes('class Profile extends BaseModel'), 'Inner model should be created'); }); test('should throw error when composition field already exists', async () => { diff --git a/src/test/refactor/changeCompositionToReference.test.ts b/src/test/refactor/changeCompositionToReference.test.ts index 2b5188e..e4d3513 100644 --- a/src/test/refactor/changeCompositionToReference.test.ts +++ b/src/test/refactor/changeCompositionToReference.test.ts @@ -14,7 +14,7 @@ if (typeof suite !== 'undefined') { mockExplorerProvider = { refresh: () => {} }; - changeCompositionToReferenceTool = new ChangeCompositionToReferenceTool(mockExplorerProvider); + changeCompositionToReferenceTool = new ChangeCompositionToReferenceTool(); }); const createMockPropertyMetadata = (name: string, type: string, decoratorNames: string[]): PropertyMetadata => { diff --git a/src/test/refactor/deleteModel.test.ts b/src/test/refactor/deleteModel.test.ts index d05c2a7..62b46f8 100644 --- a/src/test/refactor/deleteModel.test.ts +++ b/src/test/refactor/deleteModel.test.ts @@ -582,7 +582,7 @@ if (typeof suite !== 'undefined') { const mockFileContent = `import { Model, Field } from '@slingr/platform'; @Model() -export class User extends PersistentModel { +export class User extends BaseModel { @Field() name: string; @@ -591,7 +591,7 @@ export class User extends PersistentModel { } @Model() -export class Order extends PersistentModel { +export class Order extends BaseModel { @Field() orderNumber: string; }`; From 8bcdcd2d2739038fdb3cba484b3bc6c579a9cb74 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 22 Sep 2025 12:30:23 -0300 Subject: [PATCH 28/36] Adds primary key when creating new reference or composition model --- src/commands/models/addComposition.ts | 6 +++++- src/commands/models/addReference.ts | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 17883bd..67409da 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -82,7 +82,7 @@ export class AddCompositionTool { await this.addCompositionField(document, modelClass.name, fieldName, innerModelName, isArray, cache); // Add required imports - const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel"]); + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel", "UUID", "PrimaryKey"]); await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); // Apply the edit @@ -404,6 +404,10 @@ export class AddCompositionTool { lines.push(`})`); lines.push(`class ${innerModelName} extends BaseModel {`); lines.push(``); + lines.push(`\t@Field({})`); + lines.push(`\t@UUID()`); + lines.push(`\t@PrimaryKey()`); + lines.push(`\tid!: string`); lines.push(`}`); return lines.join("\n"); diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index c4ed885..13b7852 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -331,7 +331,7 @@ export class AddReferenceTool { const lines: string[] = []; // Add basic framework imports - lines.push(`import { Model, BaseModel, Field } from 'slingr-framework';`); + lines.push(`import { Model, BaseModel, Field, UUID, PrimaryKey } from 'slingr-framework';`); // Add datasource import if needed if (dataSource) { @@ -354,8 +354,10 @@ export class AddReferenceTool { lines.push(`export class ${modelName} extends BaseModel {`); lines.push(``); lines.push(`\t@Field({})`); - lines.push(`\tname!: string;`); - lines.push(``); + lines.push(`\t@Field({})`); + lines.push(`\t@UUID()`); + lines.push(`\t@PrimaryKey()`); + lines.push(`\tid!: string`); lines.push(`}`); lines.push(``); From bc9b042c773874742021ef09832ea081bcace006 Mon Sep 17 00:00:00 2001 From: Luciano Date: Mon, 22 Sep 2025 12:50:51 -0300 Subject: [PATCH 29/36] Fixed double Fileld decorator --- src/commands/models/addReference.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index 13b7852..ebb9dec 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -354,7 +354,6 @@ export class AddReferenceTool { lines.push(`export class ${modelName} extends BaseModel {`); lines.push(``); lines.push(`\t@Field({})`); - lines.push(`\t@Field({})`); lines.push(`\t@UUID()`); lines.push(`\t@PrimaryKey()`); lines.push(`\tid!: string`); From c00c092c4a28a3d06cefa26b8072e05aca10ac47 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 15:08:47 -0300 Subject: [PATCH 30/36] Added the modelService for code consistency using the new standard model definition and updated some commands to use this. --- .../fields/changeCompositionToReference.ts | 13 +- .../fields/changeReferenceToComposition.ts | 12 +- src/commands/models/newModel.ts | 195 +++++++++--------- src/services/modelService.ts | 171 +++++++++++++++ src/services/sourceCodeService.ts | 100 +-------- 5 files changed, 290 insertions(+), 201 deletions(-) create mode 100644 src/services/modelService.ts diff --git a/src/commands/fields/changeCompositionToReference.ts b/src/commands/fields/changeCompositionToReference.ts index 4b82ae4..4b188da 100644 --- a/src/commands/fields/changeCompositionToReference.ts +++ b/src/commands/fields/changeCompositionToReference.ts @@ -9,6 +9,7 @@ import { ExplorerProvider } from "../../explorer/explorerProvider"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; import * as path from "path"; +import { ModelService } from "../../services/modelService"; /** * Tool for converting composition relationships to reference relationships. @@ -19,6 +20,7 @@ export class ChangeCompositionToReferenceTool { private userInputService: UserInputService; private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; + private modelService: ModelService; private fileSystemService: FileSystemService; private deleteFieldTool: DeleteFieldTool; @@ -26,6 +28,7 @@ export class ChangeCompositionToReferenceTool { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); + this.modelService = new ModelService(); this.fileSystemService = new FileSystemService(); this.deleteFieldTool = new DeleteFieldTool(); } @@ -180,15 +183,16 @@ export class ChangeCompositionToReferenceTool { const convertedClassBody = this.convertComponentClassBody(classBody); // Step 6: Generate the complete model file content - const modelFileContent = await this.sourceCodeService.generateModelFileContent( + const docs = cache.getModelDecoratorByName("Model", componentModel)?.arguments?.[0]?.docs; + const modelFileContent = await this.modelService.generateModelFileContent( componentModel.name, convertedClassBody, - "BaseModel", dataSource, undefined, false, targetFilePath, - cache + cache, + docs ); // Step 8: Add related enums to the file content @@ -431,9 +435,6 @@ export class ChangeCompositionToReferenceTool { if (enumStartLine !== -1 && enumEndLine !== -1) { // Include any trailing empty lines that belong to this enum - /* while (enumEndLine + 1 < lines.length && lines[enumEndLine + 1].trim() === '') { - enumEndLine++; - } */ // Create the range to delete (include the newline of the last line) const rangeToDelete = new vscode.Range( diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index 7035536..a51515e 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -9,6 +9,7 @@ import { ExplorerProvider } from "../../explorer/explorerProvider"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; import * as path from "path"; +import { ModelService } from "../../services/modelService"; /** * Tool for converting reference relationships to composition relationships. @@ -23,6 +24,7 @@ export class ChangeReferenceToCompositionTool { private userInputService: UserInputService; private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; + private modelService: ModelService; private fileSystemService: FileSystemService; private deleteFieldTool: DeleteFieldTool; @@ -30,6 +32,7 @@ export class ChangeReferenceToCompositionTool { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); + this.modelService = new ModelService(); this.fileSystemService = new FileSystemService(); this.deleteFieldTool = new DeleteFieldTool(); } @@ -253,15 +256,16 @@ export class ChangeReferenceToCompositionTool { const resolvedEnums = await this.resolveEnumConflicts(enumDefinitions, sourceDocument, classBody, sourceModel.name); // Step 6: Generate the complete component model content - let componentModelCode = await this.sourceCodeService.generateModelFileContent( + const docs = cache.getModelDecoratorByName("Model", targetModel)?.arguments?.[0]?.docs; + let componentModelCode = await this.modelService.generateModelFileContent( targetModel.name, resolvedEnums.updatedClassBody, - `BaseModel`, // Use component model base class dataSource, - new Set(["Field", "BaseModel"]), // Ensure required imports + undefined, true, // This is a component model (no export keyword) targetModel.declaration.uri.fsPath, - cache + cache, + docs ); // Step 7: Extract only the component model part (remove imports and add enums) diff --git a/src/commands/models/newModel.ts b/src/commands/models/newModel.ts index 92a44c2..59afb7b 100644 --- a/src/commands/models/newModel.ts +++ b/src/commands/models/newModel.ts @@ -5,7 +5,9 @@ import { AddFieldTool } from "../fields/addField"; import { MetadataCache } from "../../cache/cache"; import { AIEnhancedTool, FieldInfo, FIELD_TYPE_OPTIONS } from "../interfaces"; import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; import path from "path"; +import { SourceCodeService } from "../../services/sourceCodeService"; /** * Tool for creating new Model classes with the @Model decorator and extending BaseModel. @@ -36,14 +38,17 @@ import path from "path"; */ export class NewModelTool implements AIEnhancedTool { private fileSystemService: FileSystemService; + private sourceCodeService: SourceCodeService; private defineFieldsTool: DefineFieldsTool; private addFieldTool: AddFieldTool; + private addModelService: ModelService; constructor() { this.fileSystemService = new FileSystemService(); + this.sourceCodeService = new SourceCodeService(); this.defineFieldsTool = new DefineFieldsTool(); this.addFieldTool = new AddFieldTool(); - + this.addModelService = new ModelService(); } /** @@ -87,33 +92,38 @@ export class NewModelTool implements AIEnhancedTool { finalTargetUri = this.fileSystemService.resolveTargetUri(targetUri); } try { - // Step 1: Get model name from user - const modelName = await vscode.window.showInputBox({ - prompt: "Enter the name of the new model (PascalCase)", - placeHolder: "e.g., Task, User, Project", - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return "Model name is required"; - } - if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { - return "Model name must be in PascalCase (e.g., Task, UserProfile)"; - } - return null; - }, - }); - - if (!modelName) { - return; // User cancelled + // Step 1: Get model name from user (with loop for duplicate checking) + let modelName: string | undefined; + let existingModels: string[] = []; + + // Get existing model names if cache is available + if (cache) { + existingModels = cache.getDataModelClasses().map((m) => m.name); } - // Check if model name already exists in cache - if (cache) { - const existingModels = cache.getDataModelClasses().map((m) => m.name); - if (existingModels.includes(modelName)) { - vscode.window.showErrorMessage(`A model named ${modelName} already exists. Please choose a different name.`); - return; // Stop the process if model already exists + do { + modelName = await vscode.window.showInputBox({ + prompt: "Enter the name of the new model (PascalCase)", + placeHolder: "e.g., Task, User, Project", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Model name is required"; + } + if (!/^[A-Z][a-zA-Z0-9]*$/.test(value.trim())) { + return "Model name must be in PascalCase (e.g., Task, UserProfile)"; + } + // Check if model name already exists + if (cache && existingModels.includes(value.trim())) { + return `A model named ${value.trim()} already exists. Please choose a different name.`; + } + return null; + }, + }); + + if (!modelName) { + return; // User cancelled } - } + } while (cache && existingModels.includes(modelName.trim())); // Step 2: Get optional documentation const docs = await vscode.window.showInputBox({ @@ -137,10 +147,38 @@ export class NewModelTool implements AIEnhancedTool { return; // User pressed Esc } - // Step 4: Determine target directory + // Step 4: Ask user to select a datasource (optional) + let selectedDataSource: string | undefined; + if (cache) { + const dataSources = cache.getDataSources(); + if (dataSources.length > 0) { + const dataSourceOptions = [ + { label: "None (Skip datasource)", description: "Create model without specifying a datasource", dataSourceName: undefined }, + ...dataSources.map(ds => ({ + label: ds.name, + description: `Type: ${ds.type}`, + dataSourceName: ds.name + })) + ]; + + const selectedOption = await vscode.window.showQuickPick(dataSourceOptions, { + placeHolder: "Select datasource or skip" + }); + + // Check if user cancelled + if (selectedOption === undefined) { + return; // User pressed Esc + } + + // Set the selected datasource (undefined if "None" was selected) + selectedDataSource = selectedOption.dataSourceName; + } + } + + // Step 5: Determine target directory let targetDirectory = this.fileSystemService.determineTargetDirectory(finalTargetUri); - // Step 5: Check if file already exists and handle overwrite + // Step 6: Check if file already exists and handle overwrite const filePath = path.join(targetDirectory, `${modelName}.ts`); const fileUri = vscode.Uri.file(filePath); const fileExists = await this.fileSystemService.fileExists(fileUri); @@ -155,16 +193,23 @@ export class NewModelTool implements AIEnhancedTool { } } - // Step 6: Generate model content - const modelContent = this.generateModelContent( + // Step 7: Generate model content and create file using AddModelService + const edit = new vscode.WorkspaceEdit(); + await this.addModelService.addModelToWorkspaceEdit( + edit, + filePath, modelName, - docs?.trim() || null, - fieldsInfo?.trim() || null, - targetDirectory + undefined, + selectedDataSource, // Use the selected datasource + undefined, // No existing imports + false, // Not a component + cache, + docs // Pass the description if provided ); - - // Step 7: Create the file (without handling overwrite since we already did) - const targetFileUri = await this.fileSystemService.createFile(modelName, filePath, modelContent, false); + + // Apply the workspace edit + await vscode.workspace.applyEdit(edit); + const targetFileUri = vscode.Uri.file(filePath); // Step 8: Open the new file const document = await vscode.workspace.openTextDocument(targetFileUri); @@ -203,6 +248,10 @@ export class NewModelTool implements AIEnhancedTool { ? `Model ${modelName} created and fields processed successfully!` : `Model ${modelName} created successfully!`; + if (selectedDataSource) { + successMessage += ` Using datasource: ${selectedDataSource}.`; + } + if (parentModelInfo) { successMessage += ` Composition relationship added to ${parentModelInfo.name}.`; } @@ -214,46 +263,8 @@ export class NewModelTool implements AIEnhancedTool { } } - /** - * Generates the TypeScript content for a new model class. - * - * @param modelName - The name of the model class - * @param docs - Optional documentation string - * @param fieldsInfo - Optional field information (to be processed later by AI) - * @param targetDirectory - The directory where the model file will be created - * @returns The complete TypeScript content for the model file - */ - private generateModelContent( - modelName: string, - docs?: string | null, - fieldsInfo?: string | null, - targetDirectory?: string - ): string { - const lines: string[] = []; - - // Add imports with dynamically calculated relative paths - lines.push(`import { Model, Field } from 'slingr-framework';`); - lines.push("import { BaseModel } from 'slingr-framework';"); - lines.push(""); - - // Add documentation comment if provided - if (docs) { - lines.push("/**"); - lines.push(` * ${docs}`); - lines.push(" */"); - } - - // Add Model decorator - lines.push(`@Model()`); - // Add class declaration - lines.push(`export class ${modelName} extends BaseModel {`); - lines.push("}"); - lines.push(""); - - return lines.join("\n"); - } /** * Detects if the command is being executed from a model context. @@ -386,27 +397,25 @@ export class NewModelTool implements AIEnhancedTool { dataSource?: string ): Promise { try { - // Generate model content - const modelContent = this.generateModelContent(modelName, docs); - - // Modify the content to include datasource if provided - let finalContent = modelContent; - if (dataSource) { - finalContent = finalContent.replace( - '@Model()', - `@Model({\n\tdataSource: ${dataSource}\n})` - ); - } - - // Create the file - const targetFileUri = await this.fileSystemService.createFile( - modelName, - targetFilePath, - finalContent, - false // Don't handle overwrite since we control the path + // Create workspace edit + const edit = new vscode.WorkspaceEdit(); + + // Use AddModelService to generate the model + await this.addModelService.addModelToWorkspaceEdit( + edit, + targetFilePath, + modelName, + "", // Empty class body for now + dataSource, + undefined, // No existing imports + false, // Not a component + undefined // No cache ); - - return targetFileUri; + + // Apply the workspace edit + await vscode.workspace.applyEdit(edit); + + return vscode.Uri.file(targetFilePath); } catch (error) { throw new Error(`Failed to create model programmatically: ${error}`); } diff --git a/src/services/modelService.ts b/src/services/modelService.ts new file mode 100644 index 0000000..f2600e7 --- /dev/null +++ b/src/services/modelService.ts @@ -0,0 +1,171 @@ +import * as vscode from "vscode"; +import { MetadataCache } from "../cache/cache"; +import { SourceCodeService } from "./sourceCodeService"; + +export class ModelService { + private sourceCodeService: SourceCodeService; + constructor() { + this.sourceCodeService = new SourceCodeService(); + } + + /** + * Adds a complete model file to the workspace edit. + * + * @param edit - The workspace edit to add the changes to + * @param targetFilePath - The path where the model file will be created + * @param modelName - The name of the new model class + * @param classBody - The complete class body content + * @param dataSource - Optional datasource for the model + * @param existingImports - Set of imports that should be included + * @param isComponent - Whether this is a component model (affects export and class declaration) + * @param cache - Optional metadata cache to lookup datasource information + * @param docs - Optional documentation string for the model + */ + public async addModelToWorkspaceEdit( + edit: vscode.WorkspaceEdit, + targetFilePath: string, + modelName: string, + classBody?: string, + dataSource?: string, + existingImports?: Set, + isComponent: boolean = false, + cache?: MetadataCache, + docs?: string + ): Promise { + const lines: string[] = []; + + // Determine required imports + const imports = new Set(["BaseModel", "UUID", "Model", "Field"]); + + // Add existing imports if provided + if (existingImports) { + existingImports.forEach((imp) => imports.add(imp)); + } + + if (classBody) { + // Analyze the class body to determine additional needed imports + const bodyImports = this.sourceCodeService.extractImportsFromClassBody(classBody); + bodyImports.forEach((imp) => imports.add(imp)); + } + + // Add import statement + const sortedImports = Array.from(imports).sort(); + lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); + + // Add datasource import if applicable + if (dataSource) { + // Use the new findDataSourcePath function for accurate import resolution + try { + const dataSourceImport = await this.sourceCodeService.findDataSourcePath(dataSource, targetFilePath, cache); + if (dataSourceImport) { + lines.push(dataSourceImport); + lines.push(""); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const cleanDataSource = dataSource.replace(/['"]/g, ""); + lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); + lines.push(""); + } + } + + // Add model decorator + if (dataSource) { + lines.push(`@Model({`); + if (docs) { + lines.push(`\tdataSource: ${dataSource},`); + lines.push(`\tdocs: "${docs}"`); + } else { + lines.push(`\tdataSource: ${dataSource}`); + } + lines.push(`})`); + } else if (docs) { + lines.push(`@Model({`); + lines.push(`\tdocs: "${docs}"`); + lines.push(`})`); + } + else { + lines.push(`@Model()`); + } + + // Add class declaration (export only if not a component model) + const exportKeyword = isComponent ? "" : "export "; + lines.push(`${exportKeyword}class ${modelName} extends BaseModel {`); + lines.push(``); + lines.push(`\t@Field({`); + lines.push(`\t\tprimaryKey: true,`); + lines.push(`\t})`); + lines.push(`\t@UUID({`); + lines.push(`\t\tgenerated: true,`); + lines.push(`\t})`); + lines.push(`\tid!: string`); + + // Add class body (if not empty) + if (classBody && classBody.trim()) { + lines.push(""); + lines.push(classBody); + lines.push(""); + } + + lines.push(`}`); + + // Create the file content and add it to the workspace edit + const fileContent = lines.join("\n"); + const uri = vscode.Uri.file(targetFilePath); + edit.createFile(uri, { ignoreIfExists: false }); + edit.insert(uri, new vscode.Position(0, 0), fileContent); + } + + /** + * Generates model file content as a string (for backward compatibility or other use cases). + * + * @param modelName - The name of the new model class + * @param classBody - The complete class body content + * @param dataSource - Optional datasource for the model + * @param existingImports - Set of imports that should be included + * @param isComponent - Whether this is a component model (affects export and class declaration) + * @param targetFilePath - Optional path where the model file will be created (for accurate relative import calculation) + * @param cache - Optional metadata cache to lookup datasource information + * @returns The complete model file content + */ + public async generateModelFileContent( + modelName: string, + classBody: string, + dataSource?: string, + existingImports?: Set, + isComponent: boolean = false, + targetFilePath?: string, + cache?: MetadataCache, + docs?: string + ): Promise { + // Create a temporary workspace edit to generate the content + const tempEdit = new vscode.WorkspaceEdit(); + const tempFilePath = targetFilePath || "/tmp/temp-model.ts"; + + await this.addModelToWorkspaceEdit( + tempEdit, + tempFilePath, + modelName, + classBody, + dataSource, + existingImports, + isComponent, + cache, + docs + ); + + // Extract the content from the workspace edit + const uri = vscode.Uri.file(tempFilePath); + const edits = tempEdit.get(uri); + + // Find the insert edit and return its content + for (const edit of edits) { + if (edit instanceof vscode.TextEdit && edit.range.isEmpty) { + return edit.newText; + } + } + + return ""; + } +} diff --git a/src/services/sourceCodeService.ts b/src/services/sourceCodeService.ts index c68d4ae..ac30af8 100644 --- a/src/services/sourceCodeService.ts +++ b/src/services/sourceCodeService.ts @@ -486,7 +486,7 @@ export class SourceCodeService { /** * Extracts the datasource import from the source model file. */ - public async extractImport(sourceModel: DecoratedClass, importName: string, cache?: MetadataCache): Promise { + public async extractDataSourceImport(sourceModel: DecoratedClass, importName: string, cache?: MetadataCache): Promise { try { // First, try to use the cache to find the datasource and generate the import if (cache) { @@ -582,102 +582,6 @@ export class SourceCodeService { return classBodyLines.join("\n"); } - /** - * Creates a complete model file with the given class body content. - * - * @param modelName - The name of the new model class - * @param classBody - The complete class body content - * @param baseClass - The base class to extend (default: "BaseModel") - * @param dataSource - Optional datasource for the model - * @param existingImports - Set of imports that should be included - * @param isComponent - Whether this is a component model (affects export and class declaration) - * @param targetFilePath - Optional path where the model file will be created (for accurate relative import calculation) - * @param cache - Optional metadata cache to lookup datasource information - * @returns The complete model file content - */ - public async generateModelFileContent( - modelName: string, - classBody: string, - baseClass: string = "BaseModel", - dataSource?: string, - existingImports?: Set, - isComponent: boolean = false, - targetFilePath?: string, - cache?: MetadataCache - ): Promise { - const lines: string[] = []; - - // Determine required imports - const imports = new Set(["Model", "Field"]); - - // Add base class to imports - const baseClassCore = baseClass.split("<")[0]; // Extract base class name before generic - imports.add(baseClassCore); - - // Add existing imports if provided - if (existingImports) { - existingImports.forEach((imp) => imports.add(imp)); - } - - // Analyze the class body to determine additional needed imports - const bodyImports = this.extractImportsFromClassBody(classBody); - bodyImports.forEach((imp) => imports.add(imp)); - - // Add import statement - const sortedImports = Array.from(imports).sort(); - lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); - lines.push(""); - - // Add datasource import if applicable - if (dataSource) { - if (targetFilePath) { - // Use the new findDataSourcePath function for accurate import resolution - try { - const dataSourceImport = await this.findDataSourcePath(dataSource, targetFilePath, cache); - if (dataSourceImport) { - lines.push(dataSourceImport); - lines.push(""); - } - } catch (error) { - console.warn("Could not resolve datasource import, using fallback:", error); - // Fallback to generic import - const cleanDataSource = dataSource.replace(/['"]/g, ""); - lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); - lines.push(""); - } - } else { - // Fallback to generic import pattern when no target file path is provided - const cleanDataSource = dataSource.replace(/['"]/g, ""); - lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); - lines.push(""); - } - } - - // Add model decorator - if (dataSource) { - lines.push(`@Model({`); - lines.push(`\tdataSource: ${dataSource}`); - lines.push(`})`); - } else { - lines.push(`@Model()`); - } - - // Add class declaration (export only if not a component model) - const exportKeyword = isComponent ? "" : "export "; - lines.push(`${exportKeyword}class ${modelName} extends ${baseClass} {`); - - // Add class body (if not empty) - if (classBody.trim()) { - lines.push(""); - lines.push(classBody); - lines.push(""); - } - - lines.push(`}`); - - return lines.join("\n"); - } - /** @@ -686,7 +590,7 @@ export class SourceCodeService { * @param classBody - The class body content to analyze * @returns Set of import names that should be included */ - private extractImportsFromClassBody(classBody: string): Set { + public extractImportsFromClassBody(classBody: string): Set { const imports = new Set(); // Look for decorator patterns From 7305afeedf97ebc14f5a97958ebf2c3b67d393ef Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 15:10:05 -0300 Subject: [PATCH 31/36] Fixed error when calling model commands. --- src/commands/commandHelpers.ts | 15 ++++++ src/refactor/RefactorController.ts | 75 +++++------------------------- src/refactor/tools/deleteModel.ts | 9 ++++ src/refactor/tools/renameModel.ts | 5 ++ 4 files changed, 41 insertions(+), 63 deletions(-) diff --git a/src/commands/commandHelpers.ts b/src/commands/commandHelpers.ts index d05c16b..4ae7bb4 100644 --- a/src/commands/commandHelpers.ts +++ b/src/commands/commandHelpers.ts @@ -175,6 +175,21 @@ export async function resolveTargetUri( if ((uri.itemType === 'model' || uri.itemType === 'compositionField') && uri.metadata?.declaration?.uri) { targetUri = uri.metadata.declaration.uri; modelName = uri.metadata?.name; + } else if (uri.itemType === 'folder' && uri.folderPath) { + // Handle folder context - create a URI for the folder + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + const basePath = vscode.Uri.joinPath(workspaceFolder.uri, 'src', 'data'); + targetUri = vscode.Uri.joinPath(basePath, ...uri.folderPath.split(/[\/\\]/)); + } else if (uri.itemType === 'dataRoot') { + // Handle data root context - use the data folder + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + throw new Error('No workspace folder found'); + } + targetUri = vscode.Uri.joinPath(workspaceFolder.uri, 'src', 'data'); } else { throw new Error(opts.noUriErrorMessage!); } diff --git a/src/refactor/RefactorController.ts b/src/refactor/RefactorController.ts index 270d679..d55e4be 100644 --- a/src/refactor/RefactorController.ts +++ b/src/refactor/RefactorController.ts @@ -146,13 +146,6 @@ export class RefactorController { return; } - const hasFileOps = ('urisToDelete' in changeObject.payload && (changeObject.payload as any).urisToDelete?.length > 0) || - ('newUri' in changeObject.payload && !!(changeObject.payload as any).newUri); - - if (workspaceEdit.size === 0 && !hasFileOps) { - vscode.window.showInformationMessage("No changes were needed for this refactoring."); - return; - } await this.presentChangesForApproval(workspaceEdit, changeObject); } } @@ -183,46 +176,16 @@ export class RefactorController { changeObject: ChangeObject, allChanges?: ChangeObject[] ): Promise { - // Create a new workspace edit with confirmation metadata - const confirmedEdit = new vscode.WorkspaceEdit(); - const metadata: vscode.WorkspaceEditEntryMetadata = { - needsConfirmation: true, - label: "Review Refactoring Changes", - }; - - // Copy all text edits with confirmation metadata - for (const [uri, textEdits] of workspaceEdit.entries()) { - for (const edit of textEdits) { - confirmedEdit.replace(uri, edit.range, edit.newText, metadata); - } - } - - // Add file operations from change payloads to the workspace edit - const changesToProcess = allChanges || [changeObject]; - for (const change of changesToProcess) { - if (change.type === 'DELETE_MODEL') { - const deletePayload = change.payload as DeleteModelPayload; - if (Array.isArray(deletePayload.urisToDelete)) { - for (const uri of deletePayload.urisToDelete) { - confirmedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); - } - } - } - - if (change.type === 'RENAME_MODEL') { - const renamePayload = change.payload as RenameModelPayload; - if (renamePayload.newUri) { - confirmedEdit.renameFile(change.uri, renamePayload.newUri); - } - } - } - this.isApplyingEdit = true; try { - const success = await vscode.workspace.applyEdit(workspaceEdit,{isRefactoring: true}); + let success; + const changesToProcess = allChanges || [changeObject]; + + // Apply the workspace edit directly with refactoring flag + // VS Code will automatically show the refactor preview for file operations and text edits + success = await vscode.workspace.applyEdit(workspaceEdit, { isRefactoring: true }); if (success) { await vscode.workspace.saveAll(false); - const changesToProcess = allChanges || [changeObject]; // Check for compilation errors after applying changes const changesWithPrompts = changesToProcess.filter(change => { const tool = this.changeHandlerMap.get(change.type); @@ -370,25 +333,7 @@ export class RefactorController { } } - // Handle delete operations from change payload - if ('urisToDelete' in change.payload && Array.isArray((change.payload as any).urisToDelete)) { - for (const uri of (change.payload as any).urisToDelete) { - const deleteOpId = `DELETE::${uri.toString()}`; - if (!fileOperations.has(deleteOpId)) { - fileOperations.add(deleteOpId); - mergedEdit.deleteFile(uri, { recursive: true, ignoreIfNotExists: true }); - } - } - } - // Handle rename operations from change payload - if ('newUri' in change.payload && (change.payload as any).newUri) { - const renameOpId = `RENAME::${change.uri.toString()}::${(change.payload as any).newUri.toString()}`; - if (!fileOperations.has(renameOpId)) { - fileOperations.add(renameOpId); - mergedEdit.renameFile(change.uri, (change.payload as any).newUri); - } - } } catch (error) { vscode.window.showErrorMessage(`Error preparing refactor for '${change.description}': ${error}`); @@ -475,7 +420,9 @@ export class RefactorController { if (hasRelevantChange) { disposable.dispose(); - if (timeout) clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } this.checkForCompilationErrors(uris).then(hasErrors => { resolve(hasErrors); @@ -494,7 +441,9 @@ export class RefactorController { this.checkForCompilationErrors(uris).then(hasErrors => { if (hasErrors) { disposable.dispose(); - if (timeout) clearTimeout(timeout); + if (timeout) { + clearTimeout(timeout); + } resolve(true); } }); diff --git a/src/refactor/tools/deleteModel.ts b/src/refactor/tools/deleteModel.ts index 7d0a75c..61d33e1 100644 --- a/src/refactor/tools/deleteModel.ts +++ b/src/refactor/tools/deleteModel.ts @@ -264,6 +264,15 @@ export class DeleteModelTool implements IRefactorTool { workspaceEdit.replace(ref.uri, ref.range, "/* DELETED_REFERENCE */", {label: `Reference to deleted model '${deletedModelName}'`, needsConfirmation: true} ); } } + + // Add file deletion operations to the workspace edit + for (const uri of urisToDelete) { + workspaceEdit.deleteFile(uri, { + recursive: true, + ignoreIfNotExists: true + }, + {label: `Delete file or directory '${uri.fsPath}'`, needsConfirmation: true} ); + } await this.cleanupRelationshipFields(deletedModelName, workspaceEdit, cache); return workspaceEdit; diff --git a/src/refactor/tools/renameModel.ts b/src/refactor/tools/renameModel.ts index 930581d..747f43a 100644 --- a/src/refactor/tools/renameModel.ts +++ b/src/refactor/tools/renameModel.ts @@ -193,6 +193,11 @@ export class RenameModelTool implements IRefactorTool { workspaceEdit.replace(declarationUri, declarationRange, newName, {label: `Rename model declaration from '${oldModelMetadata.name}' to '${newName}'`, needsConfirmation: true} ); } + // Add file rename operation if a new URI is specified + if (payload.newUri) { + workspaceEdit.renameFile(change.uri, payload.newUri, {}, {label: `Rename model file to match new class name`, needsConfirmation: true} ); + } + return workspaceEdit; } } \ No newline at end of file From ff9deee22ec45c8387bdb1b41e683792b46b42dd Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 15:14:43 -0300 Subject: [PATCH 32/36] Fixed references duplication in cache --- src/cache/cache.ts | 84 +++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/src/cache/cache.ts b/src/cache/cache.ts index f9b54ee..830ba21 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -124,6 +124,7 @@ export class MetadataCache { public readonly onInfrastructureStatusChange: vscode.Event = this._onInfrastructureStatusChange.event; public isInfrastructureUpdateNeeded: boolean = false; private outOfSyncDataSources: Set = new Set(); + private isBuildingReferences = false; /** * Initializes the cache and the ts-morph project. @@ -317,7 +318,6 @@ export class MetadataCache { return; } - //this.isProcessingQueue = true; const { uri, type } = this.fileChangeQueue.shift()!; const filePath = uri.fsPath.replace(/\\/g, '/'); @@ -380,7 +380,8 @@ export class MetadataCache { this.cache[filePath] = newFileMeta; } - this.buildAllReferences(); + // Rebuild all references since this file change might affect references in other files + await this.buildAllReferences(); let fileType: CacheFileUpdateType = 'unknown'; if (filePath.includes('/src/dataSources/')) { fileType = 'dataSource'; @@ -484,7 +485,8 @@ export class MetadataCache { } // Build references and fire update event (same as processQueue) - this.buildAllReferences(); + // Rebuild all references since this file change might affect references in other files + await this.buildAllReferences(); this._onDidUpdate.fire({ type: 'dataSource', uri: uri }); // Fire infrastructure status change event AFTER cache has been updated @@ -833,32 +835,42 @@ export class MetadataCache { * @param targetFilePath Optional file path to rebuild references for. If not provided, rebuilds all. */ private async buildAllReferences(targetFilePath?: string): Promise { - - // If targetFilePath is provided, only rebuild references for that specific file - if (targetFilePath) { - this.buildReferencesForFile(targetFilePath); + // Prevent concurrent reference building operations + if (this.isBuildingReferences) { return; } + + this.isBuildingReferences = true; + + try { + // If targetFilePath is provided, only rebuild references for that specific file + if (targetFilePath) { + this.buildReferencesForFile(targetFilePath); + return; + } - // Full rebuild - clear all references first - let totalItems = 0; - for (const file of Object.values(this.cache)) { - for (const cls of Object.values(file.classes)) { - cls.references = []; - totalItems++; - for (const prop of Object.values(cls.properties)) { - prop.references = []; + // Full rebuild - clear all references first + let totalItems = 0; + for (const file of Object.values(this.cache)) { + for (const cls of Object.values(file.classes)) { + cls.references = []; + totalItems++; + for (const prop of Object.values(cls.properties)) { + prop.references = []; + totalItems++; + } + } + for (const ds of Object.values(file.dataSources)) { + ds.references = []; totalItems++; } } - for (const ds of Object.values(file.dataSources)) { - ds.references = []; - totalItems++; - } - } - // single pass through all source files - await this.buildReferencesOptimized(); + // single pass through all source files + await this.buildReferencesOptimized(); + } finally { + this.isBuildingReferences = false; + } } /** @@ -886,7 +898,9 @@ export class MetadataCache { for (const file of Object.values(this.cache)) { const sourceFile = this.tsMorphProject.getSourceFile(file.uri.fsPath); - if (!sourceFile) continue; + if (!sourceFile) { + continue; + } // Collect classes and their properties for (const cls of Object.values(file.classes)) { @@ -934,6 +948,17 @@ export class MetadataCache { return; } + // Clear existing references for this file before rebuilding + for (const cls of Object.values(file.classes)) { + cls.references = []; + for (const prop of Object.values(cls.properties)) { + prop.references = []; + } + } + for (const ds of Object.values(file.dataSources)) { + ds.references = []; + } + for (const cls of Object.values(file.classes)) { const classNode = sourceFile.getClass(cls.name); if (!classNode) { @@ -990,7 +1015,18 @@ export class MetadataCache { preciseRange ); - metadataObject.references.push(refLocation); + // Check for duplicates before adding + const isDuplicate = metadataObject.references.some(existing => + existing.uri.fsPath === refLocation.uri.fsPath && + existing.range.start.line === refLocation.range.start.line && + existing.range.start.character === refLocation.range.start.character && + existing.range.end.line === refLocation.range.end.line && + existing.range.end.character === refLocation.range.end.character + ); + + if (!isDuplicate) { + metadataObject.references.push(refLocation); + } } } } catch (error) { From 1ddb1f5173b133e13d6d3290a5ee68cec32184f9 Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 15:55:12 -0300 Subject: [PATCH 33/36] Updated the addFieldsToComposition to include the new persistent model declaration --- .../fields/extractFieldsToComposition.ts | 91 +++++++------------ 1 file changed, 32 insertions(+), 59 deletions(-) diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 120fba6..9508acf 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -11,6 +11,7 @@ import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { FieldInfo } from "../interfaces"; import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; /** * Refactor tool for extracting multiple fields from a model to a new composition model. @@ -23,12 +24,14 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { private addCompositionTool: AddCompositionTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; constructor() { super(); this.addCompositionTool = new AddCompositionTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); } /** @@ -168,43 +171,6 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { return { edit, innerModelName }; } - /** - * Generates inner model code with the specified fields already included. - */ - private generateInnerModelCodeWithFields( - innerModelName: string, - dataSource: string | undefined, - fields: PropertyMetadata[] - ): string { - const lines: string[] = []; - - // Add model decorator - if (dataSource) { - lines.push(`@Model({`); - lines.push(`\tdataSource: ${dataSource}`); - lines.push(`})`); - } else { - lines.push(`@Model()`); - } - - // Add class declaration - lines.push(`class ${innerModelName} extends BaseModel {`); - lines.push(``); - - // Add each field using the enhanced method that preserves all decorator information - for (const property of fields) { - const fieldCode = this.generateFieldCodeFromPropertyMetadata(property); - lines.push(...fieldCode.split("\n").map((line) => (line ? `\t${line}` : ""))); - lines.push(``); // Empty line between fields - } - - lines.push(`}`); - - return lines.join("\n"); - } - - - /** * Extracts the dataSource from a model using the cache. */ @@ -213,15 +179,6 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { return modelDecorator?.arguments?.[0]?.dataSource; } - /** - * Generates an enum name from a field name for Choice fields. - */ - private generateEnumName(fieldName: string): string { - const pascalCase = fieldName.charAt(0).toUpperCase() + fieldName.slice(1); - return pascalCase; - } - - /** * Determines the inner model name and whether the field should be an array. */ @@ -296,22 +253,38 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { const dataSource = this.extractDataSourceFromModel(outerModelClass, cache); - // Generate the inner model code with fields - const innerModelCode = this.generateInnerModelCodeWithFields( - innerModelName, - dataSource, - fieldsToAdd - ); + // Generate class body from fields + const classBodyLines: string[] = []; + for (const property of fieldsToAdd) { + const fieldCode = this.generateFieldCodeFromPropertyMetadata(property); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields + } + const classBody = classBodyLines.join("\n"); - // Add required imports - collect from the PropertyMetadata decorators - const requiredImports = new Set(["Model", "Field", "Composition"]); - // Add field-specific imports based on the decorators in PropertyMetadata + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); for (const property of fieldsToAdd) { for (const decorator of property.decorators) { - requiredImports.add(decorator.name); + existingImports.add(decorator.name); } } - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); + + // Ensure required imports are added to the document + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, existingImports); + + // Generate the model content without imports (since we're adding to existing file) + const modelContent = await this.modelService.generateModelFileContent( + innerModelName, + classBody, + dataSource, + existingImports, + true, // isComponent = true since it's an inner model + document.uri.fsPath, + cache, + undefined, // no docs + false // includeImports = false since we're adding to existing file + ); // Find insertion point after the outer model const lines = document.getText().split("\n"); @@ -324,9 +297,9 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { console.warn(`Could not find model ${outerModelName}, inserting at end of file`); } - // Insert the inner model with appropriate spacing + // Add the model content at the correct position with proper spacing const spacing = insertionLine < lines.length ? "\n\n" : "\n"; - edit.insert(document.uri, new vscode.Position(insertionLine, 0), `${spacing}${innerModelCode}\n`); + edit.insert(document.uri, new vscode.Position(insertionLine, 0), `\n${modelContent}\n`); } /** From 13374763ceffadc0069130a1ac74898ccca333cc Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 16:03:03 -0300 Subject: [PATCH 34/36] Updated extractFieldToReference --- .../fields/extractFieldsToReference.ts | 86 +++++++++---------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/src/commands/fields/extractFieldsToReference.ts b/src/commands/fields/extractFieldsToReference.ts index bee5c3a..127946a 100644 --- a/src/commands/fields/extractFieldsToReference.ts +++ b/src/commands/fields/extractFieldsToReference.ts @@ -9,6 +9,7 @@ import { NewModelTool } from "../models/newModel"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; import * as path from "path"; /** @@ -22,12 +23,14 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { private newModelTool: NewModelTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; constructor() { super(); this.newModelTool = new NewModelTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); } /** @@ -116,12 +119,13 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { // Get the URI from the payload const newModelUri = payload.urisToCreate![0].uri; - // Generate the complete file content - const completeFileContent = this.generateCompleteReferenceModelFile( + // Generate the complete file content using ModelService + const completeFileContent = await this.generateCompleteReferenceModelFileWithService( payload.newModelName, payload.fieldsToExtract, sourceModel, - cache + cache, + newModelUri.fsPath ); const metadata: vscode.WorkspaceEditEntryMetadata = { @@ -195,12 +199,13 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { const edit = new vscode.WorkspaceEdit(); - // Generate the complete file content including imports and model - const completeFileContent = this.generateCompleteReferenceModelFile( + // Generate the complete file content including imports and model using ModelService + const completeFileContent = await this.generateCompleteReferenceModelFileWithService( newModelName, fieldsToExtract, sourceModel, - cache + cache, + newModelUri.fsPath ); edit.createFile(newModelUri, { @@ -213,60 +218,51 @@ export class ExtractFieldsToReferenceTool extends ExtractFieldsController { } /** - * Generates the complete file content for the new reference model, including imports. + * Generates the complete file content for the new reference model using ModelService. */ - private generateCompleteReferenceModelFile( + private async generateCompleteReferenceModelFileWithService( modelName: string, fieldsToExtract: PropertyMetadata[], sourceModel: DecoratedClass, - cache: MetadataCache - ): string { - const lines: string[] = []; - + cache: MetadataCache, + targetFilePath: string + ): Promise { // Extract data source from source model const dataSource = this.extractDataSourceFromModel(sourceModel, cache); - // Generate imports - const requiredImports = new Set(["Model", "Field", "BaseModel"]); + // Generate class body from fields + const classBodyLines: string[] = []; for (const field of fieldsToExtract) { - for (const decorator of field.decorators) { - requiredImports.add(decorator.name); - } - } - - // Add the import statement - const importList = Array.from(requiredImports).sort(); - lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); - - //Add dataSource import - lines.push(`import { ${dataSource} } from '../dataSources/datasource';`); - lines.push(""); // Empty line after imports - - // Add model decorator and class - if (dataSource) { - lines.push(`@Model({`); - lines.push(` dataSource: ${dataSource}`); - lines.push(`})`); - } else { - lines.push(`@Model()`); + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields } + const classBody = classBodyLines.join("\n"); - lines.push(`export class ${modelName} extends BaseModel {`); - lines.push(""); - - // Add each field using PropertyMetadata to preserve all decorator information + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); for (const field of fieldsToExtract) { - const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); - lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); - lines.push(""); + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } } - lines.push("}"); - lines.push(""); // Empty line at end - - return lines.join("\n"); + // Use ModelService to generate the complete file content + return await this.modelService.generateModelFileContent( + modelName, + classBody, + dataSource, + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + cache, + undefined, // no docs + true // includeImports = true since this is a new file + ); } + + /** * Extracts the dataSource from a model using the cache. */ From ccf7c6bc29576aa1ddf3c8ba72b1da7efeabe31a Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 16:50:29 -0300 Subject: [PATCH 35/36] Updated the rest of the tools --- .../fields/extractFieldsToComposition.ts | 16 --- .../fields/extractFieldsToEmbedded.ts | 73 +++++------ src/commands/fields/extractFieldsToParent.ts | 74 ++++++------ src/commands/models/addComposition.ts | 53 ++++---- src/commands/models/addReference.ts | 47 +------- src/services/modelService.ts | 113 +++++++++++------- 6 files changed, 183 insertions(+), 193 deletions(-) diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 9508acf..61fb0a5 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -376,20 +376,4 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { return lines.join("\n"); } - /** - * Merges two workspace edits into one. - */ - private mergeWorkspaceEdits(target: vscode.WorkspaceEdit, source: vscode.WorkspaceEdit): void { - // Merge text edits - source.entries().forEach(([uri, edits]) => { - const existing = target.get(uri) || []; - target.set(uri, [...existing, ...edits]); - }); - - // Merge file operations if any - if (source.size > 0) { - // Copy any file operations from source to target - // This is a simplified merge - in practice you might need more sophisticated merging - } - } } diff --git a/src/commands/fields/extractFieldsToEmbedded.ts b/src/commands/fields/extractFieldsToEmbedded.ts index fe25322..e075a27 100644 --- a/src/commands/fields/extractFieldsToEmbedded.ts +++ b/src/commands/fields/extractFieldsToEmbedded.ts @@ -8,6 +8,7 @@ import { import { MetadataCache, DecoratedClass, PropertyMetadata } from "../../cache/cache"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; import * as path from "path"; /** @@ -19,10 +20,12 @@ import * as path from "path"; */ export class ExtractFieldsToEmbeddedTool extends ExtractFieldsController { private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; constructor() { super(); this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); } /** @@ -113,10 +116,11 @@ export class ExtractFieldsToEmbeddedTool extends ExtractFieldsController { // Get the URI from the payload const newModelUri = payload.urisToCreate![0].uri; - // Generate the complete file content - const completeFileContent = this.generateCompleteEmbeddedModelFile( + // Generate the complete file content using ModelService + const completeFileContent = await this.generateCompleteEmbeddedModelFileWithService( payload.newModelName, - payload.fieldsToExtract + payload.fieldsToExtract, + newModelUri.fsPath ); const metadata: vscode.WorkspaceEditEntryMetadata = { @@ -158,45 +162,46 @@ export class ExtractFieldsToEmbeddedTool extends ExtractFieldsController { } /** - * Generates the complete file content for the new embedded model, including imports. + * Generates the complete file content for the new embedded model using ModelService. * Embedded models extend BaseModel and have no dataSource. */ - private generateCompleteEmbeddedModelFile( + private async generateCompleteEmbeddedModelFileWithService( modelName: string, - fieldsToExtract: PropertyMetadata[] - ): string { - const lines: string[] = []; - - // Generate imports for embedded model (no dataSource import needed) - const requiredImports = new Set(["Field", "BaseModel", "Model"]); + fieldsToExtract: PropertyMetadata[], + targetFilePath: string + ): Promise { + // Generate class body from fields + const classBodyLines: string[] = []; for (const field of fieldsToExtract) { - for (const decorator of field.decorators) { - requiredImports.add(decorator.name); - } + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields } - // Add the import statement - const importList = Array.from(requiredImports).sort(); - lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); - lines.push(""); // Empty line after imports - - // Add model decorator - lines.push(`@Model()`); - - // Add class - lines.push(`export class ${modelName} extends BaseModel {`); - lines.push(""); + const classBody = classBodyLines.join("\n"); - // Add each field using PropertyMetadata to preserve all decorator information + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); for (const field of fieldsToExtract) { - const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); - lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); - lines.push(""); + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } } - - lines.push("}"); - lines.push(""); // Empty line at end - - return lines.join("\n"); + + + // Use ModelService to generate the complete file content + // Embedded models have no dataSource, so we pass undefined + return await this.modelService.generateModelFileContent( + modelName, + classBody, + undefined, // no dataSource for embedded models + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + undefined, // no cache needed for embedded models + undefined, // no docs + true, // includeImports = true since this is a new file + false // includeDefaultId = false for embedded models + ); } /** diff --git a/src/commands/fields/extractFieldsToParent.ts b/src/commands/fields/extractFieldsToParent.ts index aa87448..160fec2 100644 --- a/src/commands/fields/extractFieldsToParent.ts +++ b/src/commands/fields/extractFieldsToParent.ts @@ -9,6 +9,7 @@ import { NewModelTool } from "../models/newModel"; import { AddFieldTool } from "./addField"; import { DeleteFieldTool } from "../../refactor/tools/deleteField"; import { ExtractFieldsController } from "./extractFieldsController"; +import { ModelService } from "../../services/modelService"; import * as path from "path"; /** @@ -22,12 +23,14 @@ export class ExtractFieldsToParentTool extends ExtractFieldsController { private newModelTool: NewModelTool; private addFieldTool: AddFieldTool; private deleteFieldTool: DeleteFieldTool; + private modelService: ModelService; constructor() { super(); this.newModelTool = new NewModelTool(); this.addFieldTool = new AddFieldTool(); this.deleteFieldTool = new DeleteFieldTool(); + this.modelService = new ModelService(); } /** @@ -109,12 +112,11 @@ export class ExtractFieldsToParentTool extends ExtractFieldsController { // Get the URI from the payload const newParentModelUri = payload.urisToCreate![0].uri; - // Generate the complete file content for the abstract parent model - const completeFileContent = this.generateCompleteParentModelFile( + // Generate the complete file content for the abstract parent model using ModelService + const completeFileContent = await this.generateCompleteParentModelFileWithService( payload.newParentModelName, payload.fieldsToExtract, - sourceModel, - cache + newParentModelUri.fsPath ); const metadata: vscode.WorkspaceEditEntryMetadata = { @@ -155,47 +157,49 @@ export class ExtractFieldsToParentTool extends ExtractFieldsController { } /** - * Generates the complete file content for the new abstract parent model, including imports. + * Generates the complete file content for the new abstract parent model using ModelService. */ - private generateCompleteParentModelFile( + private async generateCompleteParentModelFileWithService( modelName: string, fieldsToExtract: PropertyMetadata[], - sourceModel: DecoratedClass, - cache: MetadataCache - ): string { - const lines: string[] = []; - - // Generate imports - const requiredImports = new Set(["BaseModel", "Field", "Model"]); + targetFilePath: string + ): Promise { + // Generate class body from fields + const classBodyLines: string[] = []; for (const field of fieldsToExtract) { - for (const decorator of field.decorators) { - requiredImports.add(decorator.name); - } + const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); + classBodyLines.push(fieldCode); + classBodyLines.push(""); // Empty line between fields } + const classBody = classBodyLines.join("\n"); - // Add the import statement - const importList = Array.from(requiredImports).sort(); - lines.push(`import { ${importList.join(", ")} } from 'slingr-framework';`); - lines.push(""); // Empty line after imports - - // Add model decorator - lines.push(`@Model()`); - - // Add abstract model class extending BaseModel - lines.push(`export abstract class ${modelName} extends BaseModel {`); - lines.push(""); - - // Add each field using PropertyMetadata to preserve all decorator information + // Collect required imports from PropertyMetadata decorators + const existingImports = new Set(); for (const field of fieldsToExtract) { - const fieldCode = this.generateFieldCodeFromPropertyMetadata(field); - lines.push(...fieldCode.split("\n").map((line) => (line ? ` ${line}` : ""))); - lines.push(""); + for (const decorator of field.decorators) { + existingImports.add(decorator.name); + } } - lines.push("}"); - lines.push(""); // Empty line at end + // Use ModelService to generate the complete file content + // Abstract parent models have no dataSource and no default id field + const modelContent = await this.modelService.generateModelFileContent( + modelName, + classBody, + undefined, // no dataSource for abstract parent models + existingImports, + false, // isComponent = false since it's a separate model file + targetFilePath, + undefined, // no cache needed for abstract parent models + undefined, // no docs + true, // includeImports = true since this is a new file + ); - return lines.join("\n"); + // Replace the class declaration to make it abstract + return modelContent.replace( + `export class ${modelName} extends BaseModel {`, + `export abstract class ${modelName} extends BaseModel {` + ); } /** diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index 67409da..e7b8dba 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -5,6 +5,7 @@ import { UserInputService } from "../../services/userInputService"; import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; import { detectIndentation, applyIndentation } from "../../utils/detectIndentation"; @@ -17,12 +18,14 @@ export class AddCompositionTool { private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; + private modelService: ModelService; constructor() { this.userInputService = new UserInputService(); this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); + this.modelService = new ModelService(); } /** @@ -174,8 +177,8 @@ export class AddCompositionTool { const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; - // Generate the inner model code - const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName, dataSource); + // Generate the inner model code using ModelService + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath); // Add required imports const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel"]); @@ -377,40 +380,40 @@ export class AddCompositionTool { const outerModelDecorator = cache.getModelDecoratorByName("Model", outerModelClass); const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; - // Generate the inner model code - const innerModelCode = this.generateInnerModelCode(innerModelName, outerModelName, dataSource); + // Generate the inner model code using ModelService + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath); // Use the new insertModel method to insert after the outer model await this.sourceCodeService.insertModel( document, innerModelCode, outerModelName, // Insert after the outer model - new Set(["Model", "Field", "Relationship"]) // Ensure required decorators are imported + new Set(["Model", "Field", "Relationship", "UUID"]) // Ensure required decorators are imported ); } /** - * Generates the TypeScript code for the inner model. + * Generates the TypeScript code for the inner model using ModelService. */ - private generateInnerModelCode(innerModelName: string, outerModelName: string, dataSource: string): string { - const lines: string[] = []; - - if (dataSource) { - lines.push(`@Model({`); - lines.push(`\tdataSource: ${dataSource}`); - } else { - lines.push(`@Model()`); - } - lines.push(`})`); - lines.push(`class ${innerModelName} extends BaseModel {`); - lines.push(``); - lines.push(`\t@Field({})`); - lines.push(`\t@UUID()`); - lines.push(`\t@PrimaryKey()`); - lines.push(`\tid!: string`); - lines.push(`}`); - - return lines.join("\n"); + private async generateInnerModelCodeWithService( + innerModelName: string, + dataSource: string | undefined, + targetFilePath: string + ): Promise { + // Use ModelService to generate the complete model content + // Inner models are components (not exported) and need the default ID field + return await this.modelService.generateModelFileContent( + innerModelName, + "", // empty class body for inner models + dataSource, + new Set(), // no additional imports + true, // isComponent = true since it's an inner model + targetFilePath, + undefined, // no cache needed + undefined, // no docs + false, // includeImports = false since we're adding to existing file + true // includeDefaultId = true for inner models + ); } /** diff --git a/src/commands/models/addReference.ts b/src/commands/models/addReference.ts index ebb9dec..bd68d92 100644 --- a/src/commands/models/addReference.ts +++ b/src/commands/models/addReference.ts @@ -5,6 +5,7 @@ import { UserInputService } from "../../services/userInputService"; import { ProjectAnalysisService } from "../../services/projectAnalysisService"; import { SourceCodeService } from "../../services/sourceCodeService"; import { FileSystemService } from "../../services/fileSystemService"; +import { ModelService } from "../../services/modelService"; import { ExplorerProvider } from "../../explorer/explorerProvider"; import * as path from "path"; @@ -19,6 +20,7 @@ export class AddReferenceTool { private projectAnalysisService: ProjectAnalysisService; private sourceCodeService: SourceCodeService; private fileSystemService: FileSystemService; + private modelService: ModelService; private explorerProvider: ExplorerProvider; constructor(explorerProvider: ExplorerProvider) { @@ -26,6 +28,7 @@ export class AddReferenceTool { this.projectAnalysisService = new ProjectAnalysisService(); this.sourceCodeService = new SourceCodeService(); this.fileSystemService = new FileSystemService(); + this.modelService = new ModelService(); this.explorerProvider = explorerProvider; } @@ -301,8 +304,10 @@ export class AddReferenceTool { const sourceModelDir = path.dirname(sourceModel.declaration.uri.fsPath); const targetDir = sourceModelDir; + const targetFilePath = path.join(targetDir, `${finalModelName}.ts`); + // Generate model content - const modelContent = await this.generateNewModelContent(finalModelName, sourceModel, dataSource); + const modelContent = await this.modelService.generateModelFileContent(finalModelName, '', dataSource, undefined, false, targetFilePath, cache, undefined, true, true); // Create the file const fileName = `${finalModelName}.ts`; @@ -320,48 +325,8 @@ export class AddReferenceTool { } } - /** - * Generates the TypeScript code for a new referenced model. - */ - private async generateNewModelContent( - modelName: string, - sourceModel: DecoratedClass, - dataSource?: string - ): Promise { - const lines: string[] = []; - // Add basic framework imports - lines.push(`import { Model, BaseModel, Field, UUID, PrimaryKey } from 'slingr-framework';`); - // Add datasource import if needed - if (dataSource) { - const dataSourceImport = await this.sourceCodeService.extractImport(sourceModel, dataSource); - if (dataSourceImport) { - lines.push(dataSourceImport); - } - } - - lines.push(``); - - // Add model decorator and class - if (dataSource) { - lines.push(`@Model({`); - lines.push(`\tdataSource: ${dataSource}`); - lines.push(`})`); - } else { - lines.push(`@Model()`); - } - lines.push(`export class ${modelName} extends BaseModel {`); - lines.push(``); - lines.push(`\t@Field({})`); - lines.push(`\t@UUID()`); - lines.push(`\t@PrimaryKey()`); - lines.push(`\tid!: string`); - lines.push(`}`); - lines.push(``); - - return lines.join("\n"); - } diff --git a/src/services/modelService.ts b/src/services/modelService.ts index f2600e7..b5654b5 100644 --- a/src/services/modelService.ts +++ b/src/services/modelService.ts @@ -20,6 +20,8 @@ export class ModelService { * @param isComponent - Whether this is a component model (affects export and class declaration) * @param cache - Optional metadata cache to lookup datasource information * @param docs - Optional documentation string for the model + * @param includeImports - Whether to include import statements (default: true) + * @param includeDefaultId - Whether to include the default id field (default: true) */ public async addModelToWorkspaceEdit( edit: vscode.WorkspaceEdit, @@ -30,44 +32,52 @@ export class ModelService { existingImports?: Set, isComponent: boolean = false, cache?: MetadataCache, - docs?: string + docs?: string, + includeImports: boolean = true, + includeDefaultId: boolean = true ): Promise { const lines: string[] = []; - // Determine required imports - const imports = new Set(["BaseModel", "UUID", "Model", "Field"]); - - // Add existing imports if provided - if (existingImports) { - existingImports.forEach((imp) => imports.add(imp)); - } + if (includeImports) { + // Determine required imports + const imports = new Set(["BaseModel", "Model", "Field"]); + + // Add UUID import only if we're including the default id field + if (includeDefaultId) { + imports.add("UUID"); + } - if (classBody) { - // Analyze the class body to determine additional needed imports - const bodyImports = this.sourceCodeService.extractImportsFromClassBody(classBody); - bodyImports.forEach((imp) => imports.add(imp)); - } + // Add existing imports if provided + if (existingImports) { + existingImports.forEach((imp) => imports.add(imp)); + } - // Add import statement - const sortedImports = Array.from(imports).sort(); - lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); + if (classBody) { + // Analyze the class body to determine additional needed imports + const bodyImports = this.sourceCodeService.extractImportsFromClassBody(classBody); + bodyImports.forEach((imp) => imports.add(imp)); + } - // Add datasource import if applicable - if (dataSource) { - // Use the new findDataSourcePath function for accurate import resolution - try { - const dataSourceImport = await this.sourceCodeService.findDataSourcePath(dataSource, targetFilePath, cache); - if (dataSourceImport) { - lines.push(dataSourceImport); - lines.push(""); + // Add import statement + const sortedImports = Array.from(imports).sort(); + lines.push(`import { ${sortedImports.join(", ")} } from "slingr-framework";`); + + // Add datasource import if applicable + if (dataSource) { + // Use the new findDataSourcePath function for accurate import resolution + try { + const dataSourceImport = await this.sourceCodeService.findDataSourcePath(dataSource, targetFilePath, cache); + if (dataSourceImport) { + lines.push(dataSourceImport); + } + } catch (error) { + console.warn("Could not resolve datasource import, using fallback:", error); + // Fallback to generic import + const cleanDataSource = dataSource.replace(/['"]/g, ""); + lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); } - } catch (error) { - console.warn("Could not resolve datasource import, using fallback:", error); - // Fallback to generic import - const cleanDataSource = dataSource.replace(/['"]/g, ""); - lines.push(`import { ${cleanDataSource} } from '../dataSources/${cleanDataSource}';`); - lines.push(""); } + lines.push(""); } // Add model decorator @@ -92,19 +102,31 @@ export class ModelService { // Add class declaration (export only if not a component model) const exportKeyword = isComponent ? "" : "export "; lines.push(`${exportKeyword}class ${modelName} extends BaseModel {`); - lines.push(``); - lines.push(`\t@Field({`); - lines.push(`\t\tprimaryKey: true,`); - lines.push(`\t})`); - lines.push(`\t@UUID({`); - lines.push(`\t\tgenerated: true,`); - lines.push(`\t})`); - lines.push(`\tid!: string`); + + // Add default id field if requested + if (includeDefaultId) { + lines.push(``); + lines.push(`\t@Field({`); + lines.push(`\t\tprimaryKey: true,`); + lines.push(`\t})`); + lines.push(`\t@UUID({`); + lines.push(`\t\tgenerated: true,`); + lines.push(`\t})`); + lines.push(`\tid!: string`); + } // Add class body (if not empty) if (classBody && classBody.trim()) { lines.push(""); - lines.push(classBody); + // Split class body into lines and indent each line + const classBodyLines = classBody.split('\n'); + for (const line of classBodyLines) { + if (line.trim()) { + lines.push(`\t${line}`); + } else { + lines.push(''); // Keep empty lines as empty + } + } lines.push(""); } @@ -127,6 +149,9 @@ export class ModelService { * @param isComponent - Whether this is a component model (affects export and class declaration) * @param targetFilePath - Optional path where the model file will be created (for accurate relative import calculation) * @param cache - Optional metadata cache to lookup datasource information + * @param docs - Optional documentation string for the model + * @param includeImports - Whether to include import statements (default: true) + * @param includeDefaultId - Whether to include the default id field (default: true) * @returns The complete model file content */ public async generateModelFileContent( @@ -137,11 +162,13 @@ export class ModelService { isComponent: boolean = false, targetFilePath?: string, cache?: MetadataCache, - docs?: string + docs?: string, + includeImports: boolean = true, + includeDefaultId: boolean = true ): Promise { // Create a temporary workspace edit to generate the content const tempEdit = new vscode.WorkspaceEdit(); - const tempFilePath = targetFilePath || "/tmp/temp-model.ts"; + const tempFilePath = targetFilePath || ""; await this.addModelToWorkspaceEdit( tempEdit, @@ -152,7 +179,9 @@ export class ModelService { existingImports, isComponent, cache, - docs + docs, + includeImports, + includeDefaultId ); // Extract the content from the workspace edit From 522165be5d34d48b4e7b1f399135df06d09f5e1e Mon Sep 17 00:00:00 2001 From: Luciano Date: Wed, 24 Sep 2025 17:05:21 -0300 Subject: [PATCH 36/36] Adds ownerReference when creating composition models --- .../fields/changeReferenceToComposition.ts | 32 ++++++++++++++--- .../fields/extractFieldsToComposition.ts | 25 +++++++++++++ src/commands/models/addComposition.ts | 36 +++++++++++++++---- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/commands/fields/changeReferenceToComposition.ts b/src/commands/fields/changeReferenceToComposition.ts index a51515e..f3fa2cc 100644 --- a/src/commands/fields/changeReferenceToComposition.ts +++ b/src/commands/fields/changeReferenceToComposition.ts @@ -85,7 +85,7 @@ export class ChangeReferenceToCompositionTool { } // Add necessary imports to the workspace edit - await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "BaseModel", "Field", "Composition"])); + await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, new Set(["Model", "BaseModel", "Field", "Composition", "OwnerReference"])); // Step 9: Focus on the newly modified field await this.sourceCodeService.focusOnElement(document, fieldName); @@ -255,11 +255,15 @@ export class ChangeReferenceToCompositionTool { const sourceDocument = await vscode.workspace.openTextDocument(sourceModel.declaration.uri); const resolvedEnums = await this.resolveEnumConflicts(enumDefinitions, sourceDocument, classBody, sourceModel.name); - // Step 6: Generate the complete component model content + // Step 6: Add owner field to the class body + const ownerFieldCode = this.generateOwnerFieldCode(sourceModel.name); + const updatedClassBody = resolvedEnums.updatedClassBody + '\n\n' + ownerFieldCode; + + // Step 7: Generate the complete component model content const docs = cache.getModelDecoratorByName("Model", targetModel)?.arguments?.[0]?.docs; let componentModelCode = await this.modelService.generateModelFileContent( targetModel.name, - resolvedEnums.updatedClassBody, + updatedClassBody, dataSource, undefined, true, // This is a component model (no export keyword) @@ -268,10 +272,10 @@ export class ChangeReferenceToCompositionTool { docs ); - // Step 7: Extract only the component model part (remove imports and add enums) + // Step 8: Extract only the component model part (remove imports and add enums) const componentModelParts = this.extractComponentModelFromFileContent(componentModelCode); - // Step 8: Add enum definitions if any exist + // Step 9: Add enum definitions if any exist if (resolvedEnums.enumDefinitions.length > 0) { const enumsContent = resolvedEnums.enumDefinitions.join('\n\n'); return `${enumsContent}\n\n${componentModelParts}`; @@ -566,6 +570,24 @@ export class ChangeReferenceToCompositionTool { } } + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } + /** * Deletes the target model file if it's safe to do so. */ diff --git a/src/commands/fields/extractFieldsToComposition.ts b/src/commands/fields/extractFieldsToComposition.ts index 61fb0a5..8d07074 100644 --- a/src/commands/fields/extractFieldsToComposition.ts +++ b/src/commands/fields/extractFieldsToComposition.ts @@ -260,6 +260,11 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { classBodyLines.push(fieldCode); classBodyLines.push(""); // Empty line between fields } + + // Add owner field + const ownerFieldCode = this.generateOwnerFieldCode(outerModelName); + classBodyLines.push(ownerFieldCode); + const classBody = classBodyLines.join("\n"); // Collect required imports from PropertyMetadata decorators @@ -269,6 +274,8 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { existingImports.add(decorator.name); } } + // Add OwnerReference import for the owner field + existingImports.add("OwnerReference"); // Ensure required imports are added to the document await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, existingImports); @@ -376,4 +383,22 @@ export class ExtractFieldsToCompositionTool extends ExtractFieldsController { return lines.join("\n"); } + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } + } diff --git a/src/commands/models/addComposition.ts b/src/commands/models/addComposition.ts index e7b8dba..85e8512 100644 --- a/src/commands/models/addComposition.ts +++ b/src/commands/models/addComposition.ts @@ -178,10 +178,10 @@ export class AddCompositionTool { const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; // Generate the inner model code using ModelService - const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath); + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath, outerModelName); // Add required imports - const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel"]); + const requiredImports = new Set(["Model", "Field", "Relationship", "BaseModel", "OwnerReference"]); await this.sourceCodeService.ensureSlingrFrameworkImports(document, edit, requiredImports); // Find insertion point after the outer model @@ -381,14 +381,14 @@ export class AddCompositionTool { const dataSource = outerModelDecorator?.arguments?.[0]?.dataSource; // Generate the inner model code using ModelService - const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath); + const innerModelCode = await this.generateInnerModelCodeWithService(innerModelName, dataSource, document.uri.fsPath, outerModelName); // Use the new insertModel method to insert after the outer model await this.sourceCodeService.insertModel( document, innerModelCode, outerModelName, // Insert after the outer model - new Set(["Model", "Field", "Relationship", "UUID"]) // Ensure required decorators are imported + new Set(["Model", "Field", "Relationship", "UUID", "OwnerReference"]) // Ensure required decorators are imported ); } @@ -398,15 +398,19 @@ export class AddCompositionTool { private async generateInnerModelCodeWithService( innerModelName: string, dataSource: string | undefined, - targetFilePath: string + targetFilePath: string, + outerModelName: string ): Promise { + // Generate owner field code + const ownerFieldCode = this.generateOwnerFieldCode(outerModelName); + // Use ModelService to generate the complete model content // Inner models are components (not exported) and need the default ID field return await this.modelService.generateModelFileContent( innerModelName, - "", // empty class body for inner models + ownerFieldCode, // include owner field in the class body dataSource, - new Set(), // no additional imports + new Set(["OwnerReference"]), // add OwnerReference to imports true, // isComponent = true since it's an inner model targetFilePath, undefined, // no cache needed @@ -471,4 +475,22 @@ export class AddCompositionTool { return lines.join("\n"); } + + /** + * Generates the TypeScript code for the owner field. + */ + private generateOwnerFieldCode(ownerModelName: string): string { + const lines: string[] = []; + + // Add Field decorator + lines.push("@Field({})"); + + // Add OwnerReference decorator + lines.push("@OwnerReference()"); + + // Add property declaration + lines.push(`owner!: ${ownerModelName};`); + + return lines.join("\n"); + } }