diff --git a/package.json b/package.json index 9af2e3d0..817fd106 100644 --- a/package.json +++ b/package.json @@ -651,6 +651,11 @@ } ], "file/newFile": [ + { + "command": "vscode-objectscript.ccs.createItem", + "when": "workspaceFolderCount != 0", + "group": "file" + }, { "command": "vscode-objectscript.newFile.kpi", "when": "workspaceFolderCount != 0", @@ -883,6 +888,11 @@ "command": "vscode-objectscript.ccs.followSourceAnalysisLink", "title": "Follow Source Analysis Link" }, + { + "category": "Consistem", + "command": "vscode-objectscript.ccs.createItem", + "title": "Create Item" + }, { "category": "ObjectScript", "command": "vscode-objectscript.compile", diff --git a/src/ccs/commands/createItem.ts b/src/ccs/commands/createItem.ts new file mode 100644 index 00000000..cf5d4427 --- /dev/null +++ b/src/ccs/commands/createItem.ts @@ -0,0 +1,304 @@ +import * as vscode from "vscode"; +import * as path from "path"; + +import { AtelierAPI } from "../../api"; +import { getWsFolder, handleError } from "../../utils"; +import { logDebug, logError, logInfo } from "../core/logging"; +import { SourceControlApi } from "../sourcecontrol/client"; +import { CreateItemClient } from "../sourcecontrol/clients/createItemClient"; +import { getCcsSettings } from "../config/settings"; + +interface PromptForItemNameOptions { + initialValue?: string; + validationMessage?: string; +} + +async function promptForItemName(options: PromptForItemNameOptions = {}): Promise { + const hasValidExt = (s: string) => /\.cls$/i.test(s) || /\.mac$/i.test(s); + const hasBadChars = (s: string) => /[\\/]/.test(s) || /\s/.test(s); + + const ib = vscode.window.createInputBox(); + ib.title = "Criar Item Consistem"; + ib.prompt = "Informe o nome da classe ou rotina a ser criada (.cls ou .mac)"; + ib.placeholder = "MeuPacote.MinhaClasse.cls ou MINHAROTINA.mac"; + ib.ignoreFocusOut = true; + if (options.initialValue) { + ib.value = options.initialValue; + } + if (options.validationMessage) { + ib.validationMessage = { + message: options.validationMessage, + severity: vscode.InputBoxValidationSeverity.Error, + }; + } + + return await new Promise((resolve) => { + const disposeAll = () => { + ib.dispose(); + d1.dispose(); + d2.dispose(); + d3.dispose(); + }; + + // Do not show an error while typing (silent mode) + const d1 = ib.onDidChangeValue(() => { + ib.validationMessage = undefined; + }); + + // When pressing Enter, validate EVERYTHING and highlight in red if invalid + const d2 = ib.onDidAccept(() => { + const name = ib.value.trim(); + + if (!name) { + ib.validationMessage = { + message: "Informe o nome do item", + severity: vscode.InputBoxValidationSeverity.Error, + }; + return; + } + if (hasBadChars(name)) { + ib.validationMessage = { + message: "Nome inválido: não use espaços nem separadores de caminho (\\ ou /)", + severity: vscode.InputBoxValidationSeverity.Error, + }; + return; + } + if (!hasValidExt(name)) { + ib.validationMessage = { + message: "Inclua uma extensão válida: .cls ou .mac", + severity: vscode.InputBoxValidationSeverity.Error, + }; + return; + } + + resolve(name); + disposeAll(); + }); + + const d3 = ib.onDidHide(() => { + resolve(undefined); + disposeAll(); + }); + + ib.show(); + }); +} + +function ensureWorkspaceConnection(folder: vscode.WorkspaceFolder): AtelierAPI | undefined { + const api = new AtelierAPI(folder.uri); + if (!api.active) { + void vscode.window.showErrorMessage("Workspace folder is not connected to an InterSystems server."); + return undefined; + } + + const { host, port } = api.config; + if (!host || !port || !api.ns) { + void vscode.window.showErrorMessage( + "Workspace folder does not have a fully configured InterSystems server connection." + ); + return undefined; + } + + return api; +} + +async function openCreatedFile(filePath: string): Promise { + // Ensure file exists before opening to avoid noisy errors + const uri = vscode.Uri.file(filePath); + await vscode.workspace.fs.stat(uri); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); +} + +function extractModuleName(filePath: string, ws: vscode.WorkspaceFolder): string | undefined { + const rel = path.relative(ws.uri.fsPath, filePath); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return undefined; + + const parts = rel.split(path.sep).filter(Boolean); + // Drop filename + parts.pop(); + if (!parts.length) return undefined; + + // Ignore common code folders + const ignored = new Set(["src", "classes", "classescls", "mac", "int", "inc", "cls", "udl"]); + for (let i = parts.length - 1; i >= 0; i--) { + const seg = parts[i]; + if (!seg) continue; + if (seg.endsWith(":")) continue; // Windows drive guard (e.g., "C:") + if (ignored.has(seg.toLowerCase())) continue; + return seg; + } + return undefined; +} + +function isTimeoutError(err: unknown): boolean { + return typeof err === "object" && err !== null && (err as any).code === "ECONNABORTED"; +} + +async function withTimeoutRetry(fn: () => Promise, attempts = 2, delayMs = 300): Promise { + try { + return await fn(); + } catch (e) { + if (!isTimeoutError(e) || attempts <= 0) throw e; + await new Promise((r) => setTimeout(r, delayMs)); + return withTimeoutRetry(fn, attempts - 1, delayMs); + } +} + +function getErrorMessage(err: unknown): string | undefined { + // Try to extract a meaningful message without hard axios dependency + const anyErr = err as any; + if (anyErr?.response?.data) { + const d = anyErr.response.data; + if (typeof d === "string" && d.trim()) return d.trim(); + if (typeof d?.error === "string" && d.error.trim()) return d.error.trim(); + if (typeof d?.message === "string" && d.message.trim()) return d.message.trim(); + if (typeof d?.Message === "string" && d.Message.trim()) return d.Message.trim(); + } + if (typeof anyErr?.message === "string" && anyErr.message.trim()) return anyErr.message.trim(); + return undefined; +} + +function getApiValidationMessage(err: unknown): string | undefined { + const anyErr = err as any; + const response = anyErr?.response; + if (!response?.data) return undefined; + + const status = typeof response.status === "number" ? response.status : undefined; + if (typeof status === "number" && status >= 500) { + return undefined; + } + + const data = response.data; + if (typeof data === "string" && data.trim()) return data.trim(); + if (typeof data?.error === "string" && data.error.trim()) return data.error.trim(); + if (typeof data?.message === "string" && data.message.trim()) return data.message.trim(); + if (typeof data?.Message === "string" && data.Message.trim()) return data.Message.trim(); + return undefined; +} + +export async function createItem(): Promise { + const workspaceFolder = await getWsFolder( + "Pick the workspace folder where you want to create the item", + false, + false, + false, + true + ); + + if (workspaceFolder === undefined) { + void vscode.window.showErrorMessage("No workspace folders are open."); + return; + } + if (!workspaceFolder) { + return; + } + + const api = ensureWorkspaceConnection(workspaceFolder); + if (!api) { + return; + } + + const ns = api.ns; + if (!ns) { + void vscode.window.showErrorMessage("Unable to determine active namespace for this workspace."); + return; + } + const namespace = ns.toUpperCase(); + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = SourceControlApi.fromAtelierApi(api); + } catch (error) { + handleError(error, "Failed to connect to the InterSystems SourceControl API."); + return; + } + + const createItemClient = new CreateItemClient(sourceControlApi); + + // Use configured requestTimeout to scale retry backoff (10%, clamped 150–500ms) + const { requestTimeout } = getCcsSettings(); + const backoff = Math.min(500, Math.max(150, Math.floor(requestTimeout * 0.1))); + + let lastValue: string | undefined; + let lastValidationMessage: string | undefined; + + while (true) { + const itemName = await promptForItemName({ initialValue: lastValue, validationMessage: lastValidationMessage }); + if (!itemName) { + return; + } + + lastValue = itemName; + lastValidationMessage = undefined; + + logDebug("Consistem createItem invoked", { namespace, itemName }); + + try { + const { data, status } = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Creating item...", + cancellable: false, + }, + async () => withTimeoutRetry(() => createItemClient.create(namespace, itemName), 2, backoff) + ); + + if (data.error) { + logError("Consistem createItem failed", { namespace, itemName, status, error: data.error }); + lastValidationMessage = data.error; + continue; + } + + if (status < 200 || status >= 300) { + const message = `Item creation failed with status ${status}.`; + logError("Consistem createItem failed", { namespace, itemName, status }); + void vscode.window.showErrorMessage(message); + return; + } + + if (!data.file) { + const message = "Item created on server but no file path was returned."; + logError("Consistem createItem missing file path", { namespace, itemName, response: data }); + void vscode.window.showErrorMessage(message); + return; + } + + try { + await openCreatedFile(data.file); + } catch (openErr) { + logError("Failed to open created file", { file: data.file, error: openErr }); + void vscode.window.showWarningMessage("Item created, but the returned file could not be opened."); + } + + const createdNamespace = data.namespace ?? namespace; + const createdItem = (data as any).itemIdCriado ?? itemName; + const moduleName = extractModuleName(data.file, workspaceFolder); + const location = moduleName ? `${createdNamespace}/${moduleName}` : createdNamespace; + const successMessage = `Item created successfully in ${location}: ${createdItem}`; + logInfo("Consistem createItem succeeded", { + namespace: createdNamespace, + module: moduleName, + itemName: createdItem, + file: data.file, + }); + void vscode.window.showInformationMessage(successMessage); + return; + } catch (error) { + const apiValidationMessage = getApiValidationMessage(error); + if (apiValidationMessage) { + logError("Consistem createItem API validation failed", { namespace, itemName, error: apiValidationMessage }); + lastValidationMessage = apiValidationMessage; + continue; + } + + const errorMessage = + (CreateItemClient as any).getErrorMessage?.(error) ?? + getErrorMessage(error) ?? + (isTimeoutError(error) ? "Item creation timed out." : "Item creation failed."); + logError("Consistem createItem encountered an unexpected error", error); + void vscode.window.showErrorMessage(errorMessage); + return; + } + } +} diff --git a/src/ccs/config/schema.md b/src/ccs/config/schema.md index 34d7f689..a7ef02bb 100644 --- a/src/ccs/config/schema.md +++ b/src/ccs/config/schema.md @@ -6,7 +6,7 @@ para o fork da Consistem. | Chave | Tipo | Padrão | Descrição | | ---------------- | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | | `endpoint` | `string` | `undefined` | URL base alternativa para a API. Se não definida, a URL é derivada da conexão ativa do Atelier. | -| `requestTimeout` | `number` | `500` | Tempo limite (ms) aplicado às chamadas HTTP do módulo. Valores menores ou inválidos são normalizados para zero. | +| `requestTimeout` | `number` | `5000` | Tempo limite (ms) aplicado às chamadas HTTP do módulo. Valores menores ou inválidos são normalizados para zero. | | `debugLogging` | `boolean` | `false` | Quando verdadeiro, registra mensagens detalhadas no `ObjectScript` Output Channel. | | `flags` | `Record` | `{}` | Feature flags opcionais que podem ser lidas pelas features do módulo. | diff --git a/src/ccs/config/settings.ts b/src/ccs/config/settings.ts index e641142e..4160f62e 100644 --- a/src/ccs/config/settings.ts +++ b/src/ccs/config/settings.ts @@ -8,7 +8,7 @@ export interface CcsSettings { } const CCS_CONFIGURATION_SECTION = "objectscript.ccs"; -const DEFAULT_TIMEOUT = 500; +const DEFAULT_TIMEOUT = 5000; export function getCcsSettings(): CcsSettings { const configuration = vscode.workspace.getConfiguration(CCS_CONFIGURATION_SECTION); diff --git a/src/ccs/core/types.ts b/src/ccs/core/types.ts index 707eb8ba..20e19b92 100644 --- a/src/ccs/core/types.ts +++ b/src/ccs/core/types.ts @@ -19,3 +19,14 @@ export interface GlobalDocumentationResponse { content?: string | string[] | Record | null; message?: string; } + +export interface CreateItemResponse { + item?: Record; + name?: string; + documentName?: string; + namespace?: string; + module?: string; + message?: string; + path?: string; + uri?: string; +} diff --git a/src/ccs/index.ts b/src/ccs/index.ts index b363194d..6a3a8cc6 100644 --- a/src/ccs/index.ts +++ b/src/ccs/index.ts @@ -26,3 +26,4 @@ export { followSourceAnalysisLink, followSourceAnalysisLinkCommand, } from "./providers/SourceAnalysisLinkProvider"; +export { createItem } from "./commands/createItem"; diff --git a/src/ccs/sourcecontrol/clients/createItemClient.ts b/src/ccs/sourcecontrol/clients/createItemClient.ts new file mode 100644 index 00000000..1624ea5c --- /dev/null +++ b/src/ccs/sourcecontrol/clients/createItemClient.ts @@ -0,0 +1,58 @@ +import axios from "axios"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; + +export interface CreateItemRequestBody { + itemName: string; +} + +export interface CreateItemResponse { + namespace?: string; + itemIdCriado?: string; + file?: string; + error?: string; +} + +export interface CreateItemResult { + status: number; + data: CreateItemResponse; +} + +export class CreateItemClient { + public constructor(private readonly api: SourceControlApi) {} + + public async create(namespace: string, itemName: string): Promise { + const response = await this.api.post( + ROUTES.createItem(namespace), + { itemName }, + { + validateStatus: () => true, + } + ); + + return { + status: response.status, + data: response.data ?? {}, + }; + } + + public static getErrorMessage(error: unknown): string | undefined { + if (axios.isAxiosError(error) && error.response) { + const data = error.response.data as Partial | undefined; + if (data?.error && typeof data.error === "string") { + return data.error; + } + } + + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as any).message === "string" + ) { + return (error as { message: string }).message; + } + + return undefined; + } +} diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts index 1561570f..d6de6c18 100644 --- a/src/ccs/sourcecontrol/routes.ts +++ b/src/ccs/sourcecontrol/routes.ts @@ -4,6 +4,7 @@ export const ROUTES = { resolveContextExpression: () => `/resolveContextExpression`, getGlobalDocumentation: () => `/getGlobalDocumentation`, resolveDefinition: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/resolveDefinition`, + createItem: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/createItem`, } as const; export type RouteKey = keyof typeof ROUTES; diff --git a/src/extension.ts b/src/extension.ts index 67a31aa6..04d2e29e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -172,6 +172,7 @@ import { followSourceAnalysisLink, followSourceAnalysisLinkCommand, type SourceAnalysisLinkArgs, + createItem, resolveContextExpression, showGlobalDocumentation, } from "./ccs"; @@ -1294,6 +1295,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("resolveContextExpression"); void resolveContextExpression(); }), + vscode.commands.registerCommand("vscode-objectscript.ccs.createItem", async () => { + sendCommandTelemetryEvent("ccs.createItem"); + await createItem(); + }), vscode.commands.registerCommand("vscode-objectscript.ccs.goToDefinition", async () => { sendCommandTelemetryEvent("ccs.goToDefinition"); await goToDefinitionLocalFirst();