Skip to content

Commit 10951a2

Browse files
authored
Add Consistem: Create Item workflow integrated with IRIS /namespaces/{ns}/createItem (#42)
* Add `Consistem: Create Item` workflow integrated with IRIS `/namespaces/{ns}/createItem` * Show API validation errors inline in `Consistem: Create Item` input
1 parent 9f8fb57 commit 10951a2

File tree

9 files changed

+392
-2
lines changed

9 files changed

+392
-2
lines changed

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@
651651
}
652652
],
653653
"file/newFile": [
654+
{
655+
"command": "vscode-objectscript.ccs.createItem",
656+
"when": "workspaceFolderCount != 0",
657+
"group": "file"
658+
},
654659
{
655660
"command": "vscode-objectscript.newFile.kpi",
656661
"when": "workspaceFolderCount != 0",
@@ -883,6 +888,11 @@
883888
"command": "vscode-objectscript.ccs.followSourceAnalysisLink",
884889
"title": "Follow Source Analysis Link"
885890
},
891+
{
892+
"category": "Consistem",
893+
"command": "vscode-objectscript.ccs.createItem",
894+
"title": "Create Item"
895+
},
886896
{
887897
"category": "ObjectScript",
888898
"command": "vscode-objectscript.compile",

src/ccs/commands/createItem.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import * as vscode from "vscode";
2+
import * as path from "path";
3+
4+
import { AtelierAPI } from "../../api";
5+
import { getWsFolder, handleError } from "../../utils";
6+
import { logDebug, logError, logInfo } from "../core/logging";
7+
import { SourceControlApi } from "../sourcecontrol/client";
8+
import { CreateItemClient } from "../sourcecontrol/clients/createItemClient";
9+
import { getCcsSettings } from "../config/settings";
10+
11+
interface PromptForItemNameOptions {
12+
initialValue?: string;
13+
validationMessage?: string;
14+
}
15+
16+
async function promptForItemName(options: PromptForItemNameOptions = {}): Promise<string | undefined> {
17+
const hasValidExt = (s: string) => /\.cls$/i.test(s) || /\.mac$/i.test(s);
18+
const hasBadChars = (s: string) => /[\\/]/.test(s) || /\s/.test(s);
19+
20+
const ib = vscode.window.createInputBox();
21+
ib.title = "Criar Item Consistem";
22+
ib.prompt = "Informe o nome da classe ou rotina a ser criada (.cls ou .mac)";
23+
ib.placeholder = "MeuPacote.MinhaClasse.cls ou MINHAROTINA.mac";
24+
ib.ignoreFocusOut = true;
25+
if (options.initialValue) {
26+
ib.value = options.initialValue;
27+
}
28+
if (options.validationMessage) {
29+
ib.validationMessage = {
30+
message: options.validationMessage,
31+
severity: vscode.InputBoxValidationSeverity.Error,
32+
};
33+
}
34+
35+
return await new Promise<string | undefined>((resolve) => {
36+
const disposeAll = () => {
37+
ib.dispose();
38+
d1.dispose();
39+
d2.dispose();
40+
d3.dispose();
41+
};
42+
43+
// Do not show an error while typing (silent mode)
44+
const d1 = ib.onDidChangeValue(() => {
45+
ib.validationMessage = undefined;
46+
});
47+
48+
// When pressing Enter, validate EVERYTHING and highlight in red if invalid
49+
const d2 = ib.onDidAccept(() => {
50+
const name = ib.value.trim();
51+
52+
if (!name) {
53+
ib.validationMessage = {
54+
message: "Informe o nome do item",
55+
severity: vscode.InputBoxValidationSeverity.Error,
56+
};
57+
return;
58+
}
59+
if (hasBadChars(name)) {
60+
ib.validationMessage = {
61+
message: "Nome inválido: não use espaços nem separadores de caminho (\\ ou /)",
62+
severity: vscode.InputBoxValidationSeverity.Error,
63+
};
64+
return;
65+
}
66+
if (!hasValidExt(name)) {
67+
ib.validationMessage = {
68+
message: "Inclua uma extensão válida: .cls ou .mac",
69+
severity: vscode.InputBoxValidationSeverity.Error,
70+
};
71+
return;
72+
}
73+
74+
resolve(name);
75+
disposeAll();
76+
});
77+
78+
const d3 = ib.onDidHide(() => {
79+
resolve(undefined);
80+
disposeAll();
81+
});
82+
83+
ib.show();
84+
});
85+
}
86+
87+
function ensureWorkspaceConnection(folder: vscode.WorkspaceFolder): AtelierAPI | undefined {
88+
const api = new AtelierAPI(folder.uri);
89+
if (!api.active) {
90+
void vscode.window.showErrorMessage("Workspace folder is not connected to an InterSystems server.");
91+
return undefined;
92+
}
93+
94+
const { host, port } = api.config;
95+
if (!host || !port || !api.ns) {
96+
void vscode.window.showErrorMessage(
97+
"Workspace folder does not have a fully configured InterSystems server connection."
98+
);
99+
return undefined;
100+
}
101+
102+
return api;
103+
}
104+
105+
async function openCreatedFile(filePath: string): Promise<void> {
106+
// Ensure file exists before opening to avoid noisy errors
107+
const uri = vscode.Uri.file(filePath);
108+
await vscode.workspace.fs.stat(uri);
109+
const document = await vscode.workspace.openTextDocument(uri);
110+
await vscode.window.showTextDocument(document, { preview: false });
111+
}
112+
113+
function extractModuleName(filePath: string, ws: vscode.WorkspaceFolder): string | undefined {
114+
const rel = path.relative(ws.uri.fsPath, filePath);
115+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return undefined;
116+
117+
const parts = rel.split(path.sep).filter(Boolean);
118+
// Drop filename
119+
parts.pop();
120+
if (!parts.length) return undefined;
121+
122+
// Ignore common code folders
123+
const ignored = new Set(["src", "classes", "classescls", "mac", "int", "inc", "cls", "udl"]);
124+
for (let i = parts.length - 1; i >= 0; i--) {
125+
const seg = parts[i];
126+
if (!seg) continue;
127+
if (seg.endsWith(":")) continue; // Windows drive guard (e.g., "C:")
128+
if (ignored.has(seg.toLowerCase())) continue;
129+
return seg;
130+
}
131+
return undefined;
132+
}
133+
134+
function isTimeoutError(err: unknown): boolean {
135+
return typeof err === "object" && err !== null && (err as any).code === "ECONNABORTED";
136+
}
137+
138+
async function withTimeoutRetry<T>(fn: () => Promise<T>, attempts = 2, delayMs = 300): Promise<T> {
139+
try {
140+
return await fn();
141+
} catch (e) {
142+
if (!isTimeoutError(e) || attempts <= 0) throw e;
143+
await new Promise((r) => setTimeout(r, delayMs));
144+
return withTimeoutRetry(fn, attempts - 1, delayMs);
145+
}
146+
}
147+
148+
function getErrorMessage(err: unknown): string | undefined {
149+
// Try to extract a meaningful message without hard axios dependency
150+
const anyErr = err as any;
151+
if (anyErr?.response?.data) {
152+
const d = anyErr.response.data;
153+
if (typeof d === "string" && d.trim()) return d.trim();
154+
if (typeof d?.error === "string" && d.error.trim()) return d.error.trim();
155+
if (typeof d?.message === "string" && d.message.trim()) return d.message.trim();
156+
if (typeof d?.Message === "string" && d.Message.trim()) return d.Message.trim();
157+
}
158+
if (typeof anyErr?.message === "string" && anyErr.message.trim()) return anyErr.message.trim();
159+
return undefined;
160+
}
161+
162+
function getApiValidationMessage(err: unknown): string | undefined {
163+
const anyErr = err as any;
164+
const response = anyErr?.response;
165+
if (!response?.data) return undefined;
166+
167+
const status = typeof response.status === "number" ? response.status : undefined;
168+
if (typeof status === "number" && status >= 500) {
169+
return undefined;
170+
}
171+
172+
const data = response.data;
173+
if (typeof data === "string" && data.trim()) return data.trim();
174+
if (typeof data?.error === "string" && data.error.trim()) return data.error.trim();
175+
if (typeof data?.message === "string" && data.message.trim()) return data.message.trim();
176+
if (typeof data?.Message === "string" && data.Message.trim()) return data.Message.trim();
177+
return undefined;
178+
}
179+
180+
export async function createItem(): Promise<void> {
181+
const workspaceFolder = await getWsFolder(
182+
"Pick the workspace folder where you want to create the item",
183+
false,
184+
false,
185+
false,
186+
true
187+
);
188+
189+
if (workspaceFolder === undefined) {
190+
void vscode.window.showErrorMessage("No workspace folders are open.");
191+
return;
192+
}
193+
if (!workspaceFolder) {
194+
return;
195+
}
196+
197+
const api = ensureWorkspaceConnection(workspaceFolder);
198+
if (!api) {
199+
return;
200+
}
201+
202+
const ns = api.ns;
203+
if (!ns) {
204+
void vscode.window.showErrorMessage("Unable to determine active namespace for this workspace.");
205+
return;
206+
}
207+
const namespace = ns.toUpperCase();
208+
209+
let sourceControlApi: SourceControlApi;
210+
try {
211+
sourceControlApi = SourceControlApi.fromAtelierApi(api);
212+
} catch (error) {
213+
handleError(error, "Failed to connect to the InterSystems SourceControl API.");
214+
return;
215+
}
216+
217+
const createItemClient = new CreateItemClient(sourceControlApi);
218+
219+
// Use configured requestTimeout to scale retry backoff (10%, clamped 150–500ms)
220+
const { requestTimeout } = getCcsSettings();
221+
const backoff = Math.min(500, Math.max(150, Math.floor(requestTimeout * 0.1)));
222+
223+
let lastValue: string | undefined;
224+
let lastValidationMessage: string | undefined;
225+
226+
while (true) {
227+
const itemName = await promptForItemName({ initialValue: lastValue, validationMessage: lastValidationMessage });
228+
if (!itemName) {
229+
return;
230+
}
231+
232+
lastValue = itemName;
233+
lastValidationMessage = undefined;
234+
235+
logDebug("Consistem createItem invoked", { namespace, itemName });
236+
237+
try {
238+
const { data, status } = await vscode.window.withProgress(
239+
{
240+
location: vscode.ProgressLocation.Notification,
241+
title: "Creating item...",
242+
cancellable: false,
243+
},
244+
async () => withTimeoutRetry(() => createItemClient.create(namespace, itemName), 2, backoff)
245+
);
246+
247+
if (data.error) {
248+
logError("Consistem createItem failed", { namespace, itemName, status, error: data.error });
249+
lastValidationMessage = data.error;
250+
continue;
251+
}
252+
253+
if (status < 200 || status >= 300) {
254+
const message = `Item creation failed with status ${status}.`;
255+
logError("Consistem createItem failed", { namespace, itemName, status });
256+
void vscode.window.showErrorMessage(message);
257+
return;
258+
}
259+
260+
if (!data.file) {
261+
const message = "Item created on server but no file path was returned.";
262+
logError("Consistem createItem missing file path", { namespace, itemName, response: data });
263+
void vscode.window.showErrorMessage(message);
264+
return;
265+
}
266+
267+
try {
268+
await openCreatedFile(data.file);
269+
} catch (openErr) {
270+
logError("Failed to open created file", { file: data.file, error: openErr });
271+
void vscode.window.showWarningMessage("Item created, but the returned file could not be opened.");
272+
}
273+
274+
const createdNamespace = data.namespace ?? namespace;
275+
const createdItem = (data as any).itemIdCriado ?? itemName;
276+
const moduleName = extractModuleName(data.file, workspaceFolder);
277+
const location = moduleName ? `${createdNamespace}/${moduleName}` : createdNamespace;
278+
const successMessage = `Item created successfully in ${location}: ${createdItem}`;
279+
logInfo("Consistem createItem succeeded", {
280+
namespace: createdNamespace,
281+
module: moduleName,
282+
itemName: createdItem,
283+
file: data.file,
284+
});
285+
void vscode.window.showInformationMessage(successMessage);
286+
return;
287+
} catch (error) {
288+
const apiValidationMessage = getApiValidationMessage(error);
289+
if (apiValidationMessage) {
290+
logError("Consistem createItem API validation failed", { namespace, itemName, error: apiValidationMessage });
291+
lastValidationMessage = apiValidationMessage;
292+
continue;
293+
}
294+
295+
const errorMessage =
296+
(CreateItemClient as any).getErrorMessage?.(error) ??
297+
getErrorMessage(error) ??
298+
(isTimeoutError(error) ? "Item creation timed out." : "Item creation failed.");
299+
logError("Consistem createItem encountered an unexpected error", error);
300+
void vscode.window.showErrorMessage(errorMessage);
301+
return;
302+
}
303+
}
304+
}

src/ccs/config/schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ para o fork da Consistem.
66
| Chave | Tipo | Padrão | Descrição |
77
| ---------------- | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
88
| `endpoint` | `string` | `undefined` | URL base alternativa para a API. Se não definida, a URL é derivada da conexão ativa do Atelier. |
9-
| `requestTimeout` | `number` | `500` | Tempo limite (ms) aplicado às chamadas HTTP do módulo. Valores menores ou inválidos são normalizados para zero. |
9+
| `requestTimeout` | `number` | `5000` | Tempo limite (ms) aplicado às chamadas HTTP do módulo. Valores menores ou inválidos são normalizados para zero. |
1010
| `debugLogging` | `boolean` | `false` | Quando verdadeiro, registra mensagens detalhadas no `ObjectScript` Output Channel. |
1111
| `flags` | `Record<string, boolean>` | `{}` | Feature flags opcionais que podem ser lidas pelas features do módulo. |
1212

src/ccs/config/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface CcsSettings {
88
}
99

1010
const CCS_CONFIGURATION_SECTION = "objectscript.ccs";
11-
const DEFAULT_TIMEOUT = 500;
11+
const DEFAULT_TIMEOUT = 5000;
1212

1313
export function getCcsSettings(): CcsSettings {
1414
const configuration = vscode.workspace.getConfiguration(CCS_CONFIGURATION_SECTION);

src/ccs/core/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,14 @@ export interface GlobalDocumentationResponse {
1919
content?: string | string[] | Record<string, unknown> | null;
2020
message?: string;
2121
}
22+
23+
export interface CreateItemResponse {
24+
item?: Record<string, unknown>;
25+
name?: string;
26+
documentName?: string;
27+
namespace?: string;
28+
module?: string;
29+
message?: string;
30+
path?: string;
31+
uri?: string;
32+
}

src/ccs/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export {
2626
followSourceAnalysisLink,
2727
followSourceAnalysisLinkCommand,
2828
} from "./providers/SourceAnalysisLinkProvider";
29+
export { createItem } from "./commands/createItem";

0 commit comments

Comments
 (0)