diff --git a/src/api/ccs/sourceControl.ts b/src/api/ccs/sourceControl.ts deleted file mode 100644 index fd65ad88..00000000 --- a/src/api/ccs/sourceControl.ts +++ /dev/null @@ -1,53 +0,0 @@ -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; -import * as https from "https"; -import * as vscode from "vscode"; - -import { AtelierAPI } from "../"; - -export class SourceControlApi { - private readonly client: AxiosInstance; - - private constructor(client: AxiosInstance) { - this.client = client; - } - - public static fromAtelierApi(api: AtelierAPI): SourceControlApi { - const { host, port, username, password, https: useHttps, pathPrefix } = api.config; - - if (!host || !port) { - throw new Error("No active InterSystems server connection for this file."); - } - - const normalizedPrefix = pathPrefix ? (pathPrefix.startsWith("/") ? pathPrefix : `/${pathPrefix}`) : ""; - const baseUrl = `${useHttps ? "https" : "http"}://${host}:${port}${encodeURI(normalizedPrefix)}`; - - const httpsAgent = new https.Agent({ - rejectUnauthorized: vscode.workspace.getConfiguration("http").get("proxyStrictSSL"), - }); - - const client = axios.create({ - baseURL: `${baseUrl}/api/sourcecontrol/vscode`, - headers: { - "Content-Type": "application/json", - }, - httpsAgent, - auth: - typeof username === "string" && typeof password === "string" - ? { - username, - password, - } - : undefined, - }); - - return new SourceControlApi(client); - } - - public post>( - endpoint: string, - data?: unknown, - config?: AxiosRequestConfig - ): Promise { - return this.client.post(endpoint, data, config); - } -} diff --git a/src/commands/ccs/contextHelp.ts b/src/ccs/commands/contextHelp.ts similarity index 67% rename from src/commands/ccs/contextHelp.ts rename to src/ccs/commands/contextHelp.ts index 7e78c0ca..40861e76 100644 --- a/src/commands/ccs/contextHelp.ts +++ b/src/ccs/commands/contextHelp.ts @@ -1,15 +1,10 @@ import * as path from "path"; import * as vscode from "vscode"; -import { AtelierAPI } from "../../api"; -import { SourceControlApi } from "../../api/ccs/sourceControl"; +import { ContextExpressionClient } from "../sourcecontrol/clients/contextExpressionClient"; import { handleError } from "../../utils"; -interface ResolveContextExpressionResponse { - status?: string; - textExpression?: string; - message?: string; -} +const sharedClient = new ContextExpressionClient(); export async function resolveContextExpression(): Promise { const editor = vscode.window.activeTextEditor; @@ -28,23 +23,11 @@ export async function resolveContextExpression(): Promise { } const routine = path.basename(document.fileName); - const api = new AtelierAPI(document.uri); - - let sourceControlApi: SourceControlApi; - try { - sourceControlApi = SourceControlApi.fromAtelierApi(api); - } catch (error) { - void vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error)); - return; - } try { - const response = await sourceControlApi.post("/resolveContextExpression", { - routine, - contextExpression, - }); + const response = await sharedClient.resolve(document, { routine, contextExpression }); + const data = response ?? {}; - const data = response.data ?? {}; if (typeof data.status === "string" && data.status.toLowerCase() === "success" && data.textExpression) { const eol = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n"; const textExpression = data.textExpression.replace(/\r?\n/g, eol); diff --git a/src/ccs/config/schema.md b/src/ccs/config/schema.md new file mode 100644 index 00000000..34d7f689 --- /dev/null +++ b/src/ccs/config/schema.md @@ -0,0 +1,13 @@ +# Configuração do módulo CCS + +As opções abaixo ficam no escopo `objectscript.ccs` e controlam as integrações específicas +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. | +| `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. | + +Essas configurações não exigem reload da janela; toda leitura é feita sob demanda. diff --git a/src/ccs/config/settings.ts b/src/ccs/config/settings.ts new file mode 100644 index 00000000..e641142e --- /dev/null +++ b/src/ccs/config/settings.ts @@ -0,0 +1,51 @@ +import * as vscode from "vscode"; + +export interface CcsSettings { + endpoint?: string; + requestTimeout: number; + debugLogging: boolean; + flags: Record; +} + +const CCS_CONFIGURATION_SECTION = "objectscript.ccs"; +const DEFAULT_TIMEOUT = 500; + +export function getCcsSettings(): CcsSettings { + const configuration = vscode.workspace.getConfiguration(CCS_CONFIGURATION_SECTION); + const endpoint = sanitizeEndpoint(configuration.get("endpoint")); + const requestTimeout = coerceTimeout(configuration.get("requestTimeout")); + const debugLogging = Boolean(configuration.get("debugLogging")); + const flags = configuration.get>("flags") ?? {}; + + return { + endpoint, + requestTimeout, + debugLogging, + flags, + }; +} + +export function isFlagEnabled(flag: string, settings: CcsSettings = getCcsSettings()): boolean { + return Boolean(settings.flags?.[flag]); +} + +function sanitizeEndpoint(endpoint?: string): string | undefined { + if (!endpoint) { + return undefined; + } + + const trimmed = endpoint.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.replace(/\/+$/, ""); +} + +function coerceTimeout(timeout: number | undefined): number { + if (typeof timeout !== "number" || Number.isNaN(timeout)) { + return DEFAULT_TIMEOUT; + } + + return Math.max(0, Math.floor(timeout)); +} diff --git a/src/ccs/core/http.ts b/src/ccs/core/http.ts new file mode 100644 index 00000000..50254aea --- /dev/null +++ b/src/ccs/core/http.ts @@ -0,0 +1,81 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from "axios"; +import * as https from "https"; +import * as vscode from "vscode"; + +import { logDebug, logError } from "./logging"; +import { getCcsSettings } from "../config/settings"; + +interface CreateClientOptions { + baseURL: string; + auth?: AxiosRequestConfig["auth"]; + defaultTimeout?: number; +} + +export function createHttpClient(options: CreateClientOptions): AxiosInstance { + const { baseURL, auth, defaultTimeout } = options; + const strictSSL = vscode.workspace.getConfiguration("http").get("proxyStrictSSL"); + const httpsAgent = new https.Agent({ rejectUnauthorized: strictSSL }); + const timeout = typeof defaultTimeout === "number" ? defaultTimeout : getCcsSettings().requestTimeout; + + const client = axios.create({ + baseURL, + auth, + timeout, + headers: { "Content-Type": "application/json" }, + httpsAgent, + }); + + attachLogging(client); + + return client; +} + +function attachLogging(client: AxiosInstance): void { + client.interceptors.request.use((config) => { + logDebug(`HTTP ${config.method?.toUpperCase()} ${resolveFullUrl(client, config)}`); + return config; + }); + + client.interceptors.response.use( + (response) => { + logDebug(`HTTP ${response.status} ${resolveFullUrl(client, response.config)}`); + return response; + }, + (error: AxiosError) => { + if (axios.isCancel(error)) { + logDebug("HTTP request cancelled"); + return Promise.reject(error); + } + + const status = error.response?.status; + const url = resolveFullUrl(client, error.config ?? {}); + const message = typeof status === "number" ? `HTTP ${status} ${url}` : `HTTP request failed ${url}`; + logError(message, error); + return Promise.reject(error); + } + ); +} + +function resolveFullUrl(client: AxiosInstance, config: AxiosRequestConfig | InternalAxiosRequestConfig): string { + const base = config.baseURL ?? client.defaults.baseURL ?? ""; + const url = config.url ?? ""; + if (!base) { + return url; + } + + if (/^https?:/i.test(url)) { + return url; + } + + return `${base}${url}`; +} + +export function createAbortSignal(token: vscode.CancellationToken): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const subscription = token.onCancellationRequested(() => controller.abort()); + + return { + signal: controller.signal, + dispose: () => subscription.dispose(), + }; +} diff --git a/src/ccs/core/logging.ts b/src/ccs/core/logging.ts new file mode 100644 index 00000000..4c23968b --- /dev/null +++ b/src/ccs/core/logging.ts @@ -0,0 +1,52 @@ +import { inspect } from "util"; + +import { outputChannel } from "../../utils"; +import { getCcsSettings } from "../config/settings"; + +type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; + +const PREFIX = "[CCS]"; + +export function logDebug(message: string, ...details: unknown[]): void { + if (!getCcsSettings().debugLogging) { + return; + } + writeLog("DEBUG", message, details); +} + +export function logInfo(message: string, ...details: unknown[]): void { + writeLog("INFO", message, details); +} + +export function logWarn(message: string, ...details: unknown[]): void { + writeLog("WARN", message, details); +} + +export function logError(message: string, error?: unknown): void { + const details = error ? [formatError(error)] : []; + writeLog("ERROR", message, details); +} + +function writeLog(level: LogLevel, message: string, details: unknown[]): void { + const timestamp = new Date().toISOString(); + outputChannel.appendLine(`${PREFIX} ${timestamp} ${level}: ${message}`); + if (details.length > 0) { + for (const detail of details) { + outputChannel.appendLine(`${PREFIX} ${stringify(detail)}`); + } + } +} + +function stringify(value: unknown): string { + if (typeof value === "string") { + return value; + } + return inspect(value, { depth: 4, breakLength: Infinity }); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ""}`; + } + return stringify(error); +} diff --git a/src/ccs/core/types.ts b/src/ccs/core/types.ts new file mode 100644 index 00000000..0ded60a9 --- /dev/null +++ b/src/ccs/core/types.ts @@ -0,0 +1,10 @@ +export interface ResolveContextExpressionResponse { + status?: string; + textExpression?: string; + message?: string; +} + +export interface SourceControlError { + message: string; + cause?: unknown; +} diff --git a/src/ccs/index.ts b/src/ccs/index.ts new file mode 100644 index 00000000..4bc24a85 --- /dev/null +++ b/src/ccs/index.ts @@ -0,0 +1,5 @@ +export { getCcsSettings, isFlagEnabled, type CcsSettings } from "./config/settings"; +export { logDebug, logError, logInfo, logWarn } from "./core/logging"; +export { SourceControlApi } from "./sourcecontrol/client"; +export { resolveContextExpression } from "./commands/contextHelp"; +export { ContextExpressionClient } from "./sourcecontrol/clients/contextExpressionClient"; diff --git a/src/ccs/sourcecontrol/client.ts b/src/ccs/sourcecontrol/client.ts new file mode 100644 index 00000000..ba4c05af --- /dev/null +++ b/src/ccs/sourcecontrol/client.ts @@ -0,0 +1,57 @@ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +import { AtelierAPI } from "../../api"; +import { getCcsSettings } from "../config/settings"; +import { createHttpClient } from "../core/http"; +import { logDebug } from "../core/logging"; +import { BASE_PATH } from "./routes"; + +export class SourceControlApi { + private readonly client: AxiosInstance; + + private constructor(client: AxiosInstance) { + this.client = client; + } + + public static fromAtelierApi(api: AtelierAPI): SourceControlApi { + const { host, port, username, password, https: useHttps, pathPrefix } = api.config; + + if (!host || !port) { + throw new Error("No active InterSystems server connection for this file."); + } + + const normalizedPrefix = pathPrefix ? (pathPrefix.startsWith("/") ? pathPrefix : `/${pathPrefix}`) : ""; + const trimmedPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix.slice(0, -1) : normalizedPrefix; + const encodedPrefix = encodeURI(trimmedPrefix); + const protocol = useHttps ? "https" : "http"; + const defaultBaseUrl = `${protocol}://${host}:${port}${encodedPrefix}${BASE_PATH}`; + + const { endpoint, requestTimeout } = getCcsSettings(); + const baseURL = endpoint ?? defaultBaseUrl; + const auth = + typeof username === "string" && typeof password === "string" + ? { + username, + password, + } + : undefined; + + logDebug("Creating SourceControl API client", { baseURL, hasAuth: Boolean(auth) }); + + const client = createHttpClient({ + baseURL, + auth, + defaultTimeout: requestTimeout, + }); + + return new SourceControlApi(client); + } + + public post>( + route: string, + data?: unknown, + config?: AxiosRequestConfig + ): Promise { + return this.client.post(route, data, config); + } +} diff --git a/src/ccs/sourcecontrol/clients/contextExpressionClient.ts b/src/ccs/sourcecontrol/clients/contextExpressionClient.ts new file mode 100644 index 00000000..50461039 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/contextExpressionClient.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; + +import { AtelierAPI } from "../../../api"; +import { getCcsSettings } from "../../config/settings"; +import { logDebug } from "../../core/logging"; +import { ResolveContextExpressionResponse } from "../../core/types"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; + +interface ResolveContextExpressionPayload { + routine: string; + contextExpression: string; +} + +export class ContextExpressionClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + public async resolve( + document: vscode.TextDocument, + payload: ResolveContextExpressionPayload + ): Promise { + const api = new AtelierAPI(document.uri); + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client for context expression", error); + throw error; + } + + const { requestTimeout } = getCcsSettings(); + + try { + const response = await sourceControlApi.post( + ROUTES.resolveContextExpression(), + payload, + { + timeout: requestTimeout, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + return response.data ?? {}; + } catch (error) { + logDebug("Context expression resolution failed", error); + throw error; + } + } +} diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts new file mode 100644 index 00000000..b6a6736e --- /dev/null +++ b/src/ccs/sourcecontrol/routes.ts @@ -0,0 +1,7 @@ +export const BASE_PATH = "/api/sourcecontrol/vscode" as const; + +export const ROUTES = { + resolveContextExpression: () => `/resolveContextExpression`, +} as const; + +export type RouteKey = keyof typeof ROUTES; diff --git a/src/extension.ts b/src/extension.ts index 8df09db5..6ed8fb06 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -162,7 +162,7 @@ import { import { WorkspaceNode, NodeBase } from "./explorer/nodes"; import { showPlanWebview } from "./commands/showPlanPanel"; import { isfsConfig } from "./utils/FileProviderUtil"; -import { resolveContextExpression } from "./commands/ccs/contextHelp"; +import { resolveContextExpression } from "./ccs"; const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; const extensionVersion = packageJson.version;