const fs = require("fs"); const path = require("path"); const vscode = require("vscode"); const { applyFormUpdates, createSampleConfigYaml, extractYamlComments, getEditableSchemaFields, parseBatchArrayValue, parseSchemaContent, parseTopLevelYaml, unquoteScalar, validateParsedConfig } = require("./configValidation"); const { isTemplatePath, joinArrayIndexPath, joinArrayTemplatePath, joinPropertyPath } = require("./configPath"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); /** * Activate the GFramework config extension. * The current tool focuses on workspace file navigation, lightweight * validation, and a schema-aware form preview for common editing workflows. * * @param {vscode.ExtensionContext} context Extension context. */ function activate(context) { const diagnostics = vscode.languages.createDiagnosticCollection("gframeworkConfig"); const provider = new ConfigTreeDataProvider(); context.subscriptions.push(diagnostics); context.subscriptions.push( vscode.window.registerTreeDataProvider("gframeworkConfigExplorer", provider), vscode.commands.registerCommand("gframeworkConfig.refresh", async () => { provider.refresh(); await validateAllConfigs(diagnostics); }), vscode.commands.registerCommand("gframeworkConfig.openRaw", async (item) => { await openRawFile(item); }), vscode.commands.registerCommand("gframeworkConfig.openSchema", async (item) => { await openSchemaFile(item); }), vscode.commands.registerCommand("gframeworkConfig.openFormPreview", async (item) => { await openFormPreview(item, diagnostics); }), vscode.commands.registerCommand("gframeworkConfig.batchEditDomain", async (item) => { await openBatchEdit(item, diagnostics, provider); }), vscode.commands.registerCommand("gframeworkConfig.validateAll", async () => { await validateAllConfigs(diagnostics); }), vscode.workspace.onDidSaveTextDocument(async (document) => { const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { return; } if (!isConfigFile(document.uri, workspaceRoot)) { return; } await validateConfigFile(document.uri, diagnostics); provider.refresh(); }), vscode.workspace.onDidChangeWorkspaceFolders(async () => { provider.refresh(); await validateAllConfigs(diagnostics); }) ); void validateAllConfigs(diagnostics); } /** * Deactivate the extension. */ function deactivate() { } /** * Tree provider for the GFramework config explorer view. */ class ConfigTreeDataProvider { constructor() { this._emitter = new vscode.EventEmitter(); this.onDidChangeTreeData = this._emitter.event; } /** * Refresh the tree view. */ refresh() { this._emitter.fire(undefined); } /** * Resolve a tree item. * * @param {ConfigTreeItem} element Tree element. * @returns {vscode.TreeItem} Tree item. */ getTreeItem(element) { return element; } /** * Resolve child elements. * * @param {ConfigTreeItem | undefined} element Parent element. * @returns {Thenable} Child items. */ async getChildren(element) { const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { return []; } if (!element) { return this.getRootItems(workspaceRoot); } if (element.kind !== "domain" || !element.resourceUri) { return []; } return this.getFileItems(workspaceRoot, element.resourceUri); } /** * Build root domain items from the config directory. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {Promise} Root items. */ async getRootItems(workspaceRoot) { const configRoot = getConfigRoot(workspaceRoot); if (!configRoot || !fs.existsSync(configRoot.fsPath)) { return [ new ConfigTreeItem( localizer.t("tree.noConfigDirectory.label"), "info", vscode.TreeItemCollapsibleState.None, undefined, localizer.t("tree.noConfigDirectory.description")) ]; } const entries = fs.readdirSync(configRoot.fsPath, {withFileTypes: true}) .filter((entry) => entry.isDirectory()) .sort((left, right) => left.name.localeCompare(right.name)); return entries.map((entry) => { const domainUri = vscode.Uri.joinPath(configRoot, entry.name); return new ConfigTreeItem( entry.name, "domain", vscode.TreeItemCollapsibleState.Collapsed, domainUri, undefined); }); } /** * Build file items for a config domain directory. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @param {vscode.Uri} domainUri Domain directory URI. * @returns {Promise} File items. */ async getFileItems(workspaceRoot, domainUri) { const entries = fs.readdirSync(domainUri.fsPath, {withFileTypes: true}) .filter((entry) => entry.isFile() && isYamlPath(entry.name)) .sort((left, right) => left.name.localeCompare(right.name)); return entries.map((entry) => { const fileUri = vscode.Uri.joinPath(domainUri, entry.name); const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot); const description = schemaUri && fs.existsSync(schemaUri.fsPath) ? localizer.t("tree.fileDescription.schema") : localizer.t("tree.fileDescription.schemaMissing"); const item = new ConfigTreeItem( entry.name, "file", vscode.TreeItemCollapsibleState.None, fileUri, description); item.contextValue = "gframeworkConfigFile"; item.command = { command: "gframeworkConfig.openRaw", title: localizer.t("command.openRaw.title"), arguments: [item] }; return item; }); } } /** * Tree item used by the config explorer. */ class ConfigTreeItem extends vscode.TreeItem { /** * @param {string} label Display label. * @param {"domain" | "file" | "info"} kind Item kind. * @param {vscode.TreeItemCollapsibleState} collapsibleState Collapsible state. * @param {vscode.Uri | undefined} resourceUri Resource URI. * @param {string | undefined} description Description. */ constructor(label, kind, collapsibleState, resourceUri, description) { super(label, collapsibleState); this.kind = kind; this.resourceUri = resourceUri; this.description = description; this.contextValue = kind === "file" ? "gframeworkConfigFile" : kind; } } /** * Open the selected raw config file. * * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @returns {Promise} Async task. */ async function openRawFile(item) { const uri = item && item.resourceUri; if (!uri) { return; } const document = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(document, {preview: false}); } /** * Open the matching schema file for a selected config item. * * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @returns {Promise} Async task. */ async function openSchemaFile(item) { const workspaceRoot = getWorkspaceRoot(); const configUri = item && item.resourceUri; if (!workspaceRoot || !configUri) { return; } const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound")); return; } const document = await vscode.workspace.openTextDocument(schemaUri); await vscode.window.showTextDocument(document, {preview: false}); } /** * Open the schema file for a referenced config table. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @param {string | undefined} refTable Referenced table name. * @returns {Promise} Async task. */ async function openReferenceSchemaFile(workspaceRoot, refTable) { if (!workspaceRoot || !refTable) { return; } const schemaUri = vscode.Uri.joinPath(getSchemasRoot(workspaceRoot), `${refTable}.schema.json`); if (!fs.existsSync(schemaUri.fsPath)) { void vscode.window.showWarningMessage(localizer.t("message.referenceSchemaMissing", {refTable})); return; } const document = await vscode.workspace.openTextDocument(schemaUri); await vscode.window.showTextDocument(document, {preview: false}); } /** * Reveal the referenced config domain directory in the Explorer. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @param {string | undefined} refTable Referenced table name. * @returns {Promise} Async task. */ async function revealReferenceDomain(workspaceRoot, refTable) { if (!workspaceRoot || !refTable) { return; } const domainUri = vscode.Uri.joinPath(getConfigRoot(workspaceRoot), refTable); if (!fs.existsSync(domainUri.fsPath)) { void vscode.window.showWarningMessage(localizer.t("message.referenceDomainMissing", {refTable})); return; } await vscode.commands.executeCommand("revealInExplorer", domainUri); } /** * Open the referenced config file when the current field already has a key * value. If the direct file cannot be found, fall back to revealing the whole * referenced domain. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @param {string | undefined} refTable Referenced table name. * @param {string | undefined} refValue Referenced config id or file stem. * @returns {Promise} Async task. */ async function openReferenceValueFile(workspaceRoot, refTable, refValue) { if (!workspaceRoot || !refTable || !refValue) { return; } const configRoot = getConfigRoot(workspaceRoot); const domainUri = vscode.Uri.joinPath(configRoot, refTable); const yamlCandidate = vscode.Uri.joinPath(domainUri, `${refValue}.yaml`); const ymlCandidate = vscode.Uri.joinPath(domainUri, `${refValue}.yml`); const targetUri = fs.existsSync(yamlCandidate.fsPath) ? yamlCandidate : fs.existsSync(ymlCandidate.fsPath) ? ymlCandidate : undefined; if (!targetUri) { await revealReferenceDomain(workspaceRoot, refTable); void vscode.window.showWarningMessage(localizer.t("message.referenceValueMissing", { refTable, refValue })); return; } const document = await vscode.workspace.openTextDocument(targetUri); await vscode.window.showTextDocument(document, {preview: false}); } /** * Open a lightweight form preview for schema-bound config fields. * The preview walks nested object structures recursively and now supports * object-array editing for the repository's supported schema subset. * * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @returns {Promise} Async task. */ async function openFormPreview(item, diagnostics) { const workspaceRoot = getWorkspaceRoot(); const configUri = item && item.resourceUri; if (!workspaceRoot || !configUri) { return; } let latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const parsedYaml = parseTopLevelYaml(latestYamlText); const commentLookup = extractYamlComments(latestYamlText); const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); const canInitializeFromSchema = schemaInfo.exists && latestYamlText.trim().length === 0; const panel = vscode.window.createWebviewPanel( "gframeworkConfigFormPreview", localizer.t("webview.panelTitle", {fileName: path.basename(configUri.fsPath)}), vscode.ViewColumn.Beside, {enableScripts: true}); panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, parsedYaml, { commentLookup, canInitializeFromSchema }); panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const updatedYaml = applyFormUpdates(latestYamlText, { scalars: message.scalars || {}, arrays: parseArrayFieldPayload(message.arrays || {}), objectArrays: message.objectArrays || {}, comments: message.comments || {} }); await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); const document = await vscode.workspace.openTextDocument(configUri); await document.save(); await validateConfigFile(configUri, diagnostics); void vscode.window.showInformationMessage(localizer.t("message.formSaved")); return; } if (message.type === "openRaw") { await openRawFile({resourceUri: configUri}); return; } if (message.type === "initializeFromSchema") { if (!schemaInfo.exists) { void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound")); return; } const confirmLabel = localizer.t("button.initializeFromSchemaConfirm"); const cancelLabel = localizer.t("button.cancel"); const userChoice = await vscode.window.showWarningMessage( localizer.t("message.initializeFromSchemaConfirm"), {modal: true}, confirmLabel, cancelLabel); if (userChoice !== confirmLabel) { return; } const sampleYaml = createSampleConfigYaml(schemaInfo); await fs.promises.writeFile(configUri.fsPath, sampleYaml, "utf8"); const document = await vscode.workspace.openTextDocument(configUri); await document.save(); latestYamlText = sampleYaml; await validateConfigFile(configUri, diagnostics); panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, parseTopLevelYaml(latestYamlText), { commentLookup: extractYamlComments(latestYamlText), canInitializeFromSchema: false }); void vscode.window.showInformationMessage(localizer.t("message.formInitialized")); return; } if (message.type === "openReferenceSchema") { await openReferenceSchemaFile(workspaceRoot, message.refTable); return; } if (message.type === "openReferenceDomain") { await revealReferenceDomain(workspaceRoot, message.refTable); return; } if (message.type === "openReferenceValue") { await openReferenceValueFile(workspaceRoot, message.refTable, message.refValue); } }); } /** * Validate all config files in the configured config directory. * * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @returns {Promise} Async task. */ async function validateAllConfigs(diagnostics) { diagnostics.clear(); const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { return; } const configRoot = getConfigRoot(workspaceRoot); if (!configRoot || !fs.existsSync(configRoot.fsPath)) { return; } const files = enumerateYamlFiles(configRoot.fsPath); for (const filePath of files) { await validateConfigFile(vscode.Uri.file(filePath), diagnostics); } } /** * Validate a single config file against its matching schema. * * @param {vscode.Uri} configUri Config file URI. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @returns {Promise} Async task. */ async function validateConfigFile(configUri, diagnostics) { const workspaceRoot = getWorkspaceRoot(); if (!workspaceRoot) { return; } if (!isConfigFile(configUri, workspaceRoot)) { return; } const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const parsedYaml = parseTopLevelYaml(yamlText); const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); const fileDiagnostics = []; if (!schemaInfo.exists) { fileDiagnostics.push(new vscode.Diagnostic( new vscode.Range(0, 0, 0, 1), localizer.t("diagnostic.schemaMissing", {schemaPath: schemaInfo.schemaPath}), vscode.DiagnosticSeverity.Warning)); diagnostics.set(configUri, fileDiagnostics); return; } for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml, localizer)) { fileDiagnostics.push(new vscode.Diagnostic( new vscode.Range(0, 0, 0, 1), diagnostic.message, diagnostic.severity === "error" ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning)); } diagnostics.set(configUri, fileDiagnostics); } /** * Open a minimal batch editor for one config domain. * The workflow intentionally focuses on one schema-bound directory at a time * so designers can apply the same top-level scalar or scalar-array values * across multiple files without dropping down to repetitive raw-YAML edits. * * @param {ConfigTreeItem | { kind?: string, resourceUri?: vscode.Uri }} item Tree item. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. * @param {ConfigTreeDataProvider} provider Tree provider. * @returns {Promise} Async task. */ async function openBatchEdit(item, diagnostics, provider) { const workspaceRoot = getWorkspaceRoot(); const domainUri = item && item.resourceUri; if (!workspaceRoot || !domainUri || item.kind !== "domain") { return; } const fileItems = fs.readdirSync(domainUri.fsPath, {withFileTypes: true}) .filter((entry) => entry.isFile() && isYamlPath(entry.name)) .sort((left, right) => left.name.localeCompare(right.name)) .map((entry) => { const fileUri = vscode.Uri.joinPath(domainUri, entry.name); return { label: entry.name, description: path.relative(workspaceRoot.uri.fsPath, fileUri.fsPath), fileUri, picked: true }; }); if (fileItems.length === 0) { void vscode.window.showWarningMessage(localizer.t("message.noYamlFilesInDomain")); return; } const selectedFiles = await vscode.window.showQuickPick(fileItems, { canPickMany: true, title: localizer.t("quickPick.batchEdit.title", {domain: path.basename(domainUri.fsPath)}), placeHolder: localizer.t("quickPick.batchEdit.placeholder") }); if (!selectedFiles || selectedFiles.length === 0) { return; } const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot); if (!schemaInfo.exists) { void vscode.window.showWarningMessage(localizer.t("message.batchEditNeedsSchema")); return; } const editableFields = getEditableSchemaFields(schemaInfo); if (editableFields.length === 0) { void vscode.window.showWarningMessage(localizer.t("message.batchEditNoEditableFields")); return; } const selectedFields = await vscode.window.showQuickPick( editableFields.map((field) => ({ label: field.title || field.key, description: field.inputKind === "array" ? localizer.t("detail.arrayType", {itemType: field.itemType}) : field.type, detail: [ field.required ? localizer.t("detail.required") : "", field.description || "", field.refTable ? localizer.t("detail.refTable", {refTable: field.refTable}) : "" ].filter((part) => part.length > 0).join(" · ") || undefined, field })), { canPickMany: true, title: localizer.t("quickPick.batchEditFields.title", {domain: path.basename(domainUri.fsPath)}), placeHolder: localizer.t("quickPick.batchEditFields.placeholder") }); if (!selectedFields || selectedFields.length === 0) { return; } const updates = { scalars: {}, arrays: {} }; for (const selectedField of selectedFields) { const field = selectedField.field; const rawValue = await promptBatchFieldValue(field); if (rawValue === undefined) { return; } if (field.inputKind === "array") { updates.arrays[field.key] = parseBatchArrayValue(rawValue); continue; } updates.scalars[field.key] = rawValue; } const edit = new vscode.WorkspaceEdit(); const touchedDocuments = []; let changedFileCount = 0; for (const fileItem of selectedFiles) { const document = await vscode.workspace.openTextDocument(fileItem.fileUri); const originalYaml = document.getText(); const updatedYaml = applyFormUpdates(originalYaml, updates); if (updatedYaml === originalYaml) { continue; } const fullRange = new vscode.Range( document.positionAt(0), document.positionAt(originalYaml.length)); edit.replace(fileItem.fileUri, fullRange, updatedYaml); touchedDocuments.push(document); changedFileCount += 1; } if (changedFileCount === 0) { void vscode.window.showInformationMessage(localizer.t("message.batchEditNoChanges")); return; } const applied = await vscode.workspace.applyEdit(edit); if (!applied) { throw new Error(localizer.isChinese ? "VS Code 拒绝了这次批量编辑工作区更新。" : "VS Code rejected the batch edit workspace update."); } for (const document of touchedDocuments) { await document.save(); await validateConfigFile(document.uri, diagnostics); } provider.refresh(); void vscode.window.showInformationMessage(localizer.t("message.batchEditUpdated", { count: changedFileCount, domain: path.basename(domainUri.fsPath) })); } /** * Load schema info for a config file. * * @param {vscode.Uri} configUri Config file URI. * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record}>} Schema info. */ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); const schemaPath = schemaUri ? schemaUri.fsPath : ""; if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { return { exists: false, schemaPath, required: [], properties: {} }; } const content = await fs.promises.readFile(schemaUri.fsPath, "utf8"); try { const parsed = parseSchemaContent(content); return { exists: true, schemaPath, type: parsed.type, required: parsed.required, properties: parsed.properties }; } catch (error) { return { exists: false, schemaPath, required: [], properties: {} }; } } /** * Render the form-preview webview HTML. * * @param {string} fileName File name. * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record, type?: string}} schemaInfo Schema info. * @param {unknown} parsedYaml Parsed YAML data. * @param {{commentLookup?: Record, canInitializeFromSchema?: boolean} | undefined} options Render options. * @returns {string} HTML string. */ function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { const renderOptions = options || {}; const formModel = buildFormModel(schemaInfo, parsedYaml, renderOptions.commentLookup || {}); const saveButtonLabel = escapeHtml(localizer.t("webview.button.save")); const openRawButtonLabel = escapeHtml(localizer.t("webview.button.openRaw")); const objectArrayItemLabel = localizer.t("webview.objectArray.item"); const initializeAction = renderOptions.canInitializeFromSchema ? `` : ""; const renderedFields = formModel.fields .map((field) => renderFormField(field)) .join("\n"); const unsupportedFields = formModel.unsupported .map((field) => `
${escapeHtml(field.path)}: ${escapeHtml(field.message)}
`) .join("\n"); const schemaStatus = schemaInfo.exists ? escapeHtml(localizer.t("webview.meta.schema", {schemaPath: schemaInfo.schemaPath})) : escapeHtml(localizer.t("webview.meta.schemaMissing", {schemaPath: schemaInfo.schemaPath})); const editableContent = renderedFields; const unsupportedSection = unsupportedFields.length > 0 ? `
${unsupportedFields}
` : ""; const emptyState = editableContent.length > 0 ? `${editableContent}${unsupportedSection}` : `

${escapeHtml(localizer.t("webview.emptyState"))}

`; return `
${initializeAction}
${escapeHtml(localizer.t("webview.help.summary"))}
${escapeHtml(localizer.t("webview.meta.file", {fileName}))}
${schemaStatus}
${emptyState}
`; } /** * Render one form field. * * @param {Record} field Form field descriptor. * @returns {string} HTML fragment. */ function renderFormField(field) { if (field.kind === "section") { return `
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)} ${field.description ? `${escapeHtml(field.description)}` : ""} ${renderCommentEditor(field)}
`; } if (field.kind === "objectArray") { const renderedItems = field.items .map((item) => renderObjectArrayItem(item)) .join("\n"); const renderedTemplate = renderObjectArrayItem({ title: localizer.t("webview.objectArray.item"), fields: field.templateFields }); return `
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
${renderYamlCommentBlock(field)} ${escapeHtml(localizer.t("webview.objectArray.hint"))} ${renderFieldHint(field.schema, true)} ${renderReferenceActions(field)} ${renderCommentEditor(field)}
${renderedItems}
`; } if (field.kind === "array") { const itemType = field.itemType ? `array<${field.itemType}>` : "array"; const dataAttribute = field.itemMode ? `data-item-array-path="${escapeHtml(field.path)}"` : `data-array-path="${escapeHtml(field.path)}"`; return ` `; } const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : []; const dataAttribute = field.itemMode ? `data-item-local-path="${escapeHtml(field.path)}"` : `data-path="${escapeHtml(field.path)}"`; const inputControl = enumValues.length > 0 ? ` ` : ``; return ` `; } /** * Render one existing YAML comment block for a field. * * @param {{comment?: string}} field Form field descriptor. * @returns {string} HTML fragment. */ function renderYamlCommentBlock(field) { if (!field.comment) { return ""; } return `${escapeHtml(field.comment)}`; } /** * Render one comment editor so users can add or update YAML comments directly * from the structured form without dropping down to raw YAML first. * * @param {{displayPath?: string, path: string, comment?: string}} field Form field descriptor. * @returns {string} HTML fragment. */ function renderCommentEditor(field) { const commentPath = field.displayPath || field.path; if (isTemplatePath(commentPath)) { return ""; } return `
${escapeHtml(localizer.t("webview.comment.label"))}
`; } /** * Render lightweight reference-navigation actions for fields that point to * another config table. * * @param {{schema?: {refTable?: string}, value?: string, kind?: string, displayPath?: string}} field Form field descriptor. * @returns {string} HTML fragment. */ function renderReferenceActions(field) { if (!field.schema || !field.schema.refTable) { return ""; } const refTable = escapeHtml(field.schema.refTable); const actions = [ ``, `` ]; if (field.kind === "scalar" && field.value) { actions.push( ``); } return `
${actions.join("")}
`; } /** * Render one object-array item editor block. * * @param {{title: string, fields: Array>}} item Item model. * @returns {string} HTML fragment. */ function renderObjectArrayItem(item) { return `
${escapeHtml(item.title)}
${item.fields.map((field) => renderFormField(field)).join("\n")}
`; } /** * Build a recursive form model from schema and parsed YAML. * * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record, type?: string}} schemaInfo Schema info. * @param {unknown} parsedYaml Parsed YAML data. * @param {Record} commentLookup YAML comment lookup. * @returns {{fields: Array>, unsupported: Array<{path: string, message: string}>}} Form model. */ function buildFormModel(schemaInfo, parsedYaml, commentLookup) { if (!schemaInfo || schemaInfo.type !== "object") { return {fields: [], unsupported: []}; } const fields = []; const unsupported = []; collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported, commentLookup || {}); return {fields, unsupported}; } /** * Recursively collect top-level form-editable fields. * * @param {{type: string, required?: string[], properties?: Record, title?: string, description?: string}} schemaNode Schema node. * @param {unknown} yamlNode YAML node. * @param {string} currentPath Current logical path. * @param {number} depth Current depth. * @param {Array>} fields Field sink. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. * @param {Record} commentLookup YAML comment lookup. */ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } const yamlMap = getYamlObjectMap(yamlNode); const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { const propertyPath = joinPropertyPath(currentPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); if (propertySchema.type === "object") { fields.push({ kind: "section", path: propertyPath, label, description: propertySchema.description, comment: commentLookup[propertyPath] || "", required: requiredSet.has(key), depth }); collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported, commentLookup); continue; } if (propertySchema.type === "array" && propertySchema.items && ["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) { fields.push({ kind: "array", path: propertyPath, displayPath: propertyPath, label, required: requiredSet.has(key), depth, itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), schema: propertySchema, comment: commentLookup[propertyPath] || "" }); continue; } if (propertySchema.type === "array" && propertySchema.items && propertySchema.items.type === "object") { const itemFieldsTemplate = []; collectObjectArrayItemFields( propertySchema.items, undefined, "", joinArrayTemplatePath(propertyPath), depth + 1, itemFieldsTemplate, unsupported, commentLookup); fields.push({ kind: "objectArray", path: propertyPath, displayPath: propertyPath, label, required: requiredSet.has(key), depth, schema: propertySchema, comment: commentLookup[propertyPath] || "", items: buildObjectArrayItemModels( propertySchema.items, propertyValue, propertyPath, depth + 1, unsupported, commentLookup), templateFields: itemFieldsTemplate }); continue; } if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) { fields.push({ kind: "scalar", path: propertyPath, displayPath: propertyPath, label, required: requiredSet.has(key), depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), schema: propertySchema, comment: commentLookup[propertyPath] || "" }); continue; } unsupported.push({ path: propertyPath, message: propertySchema.type === "array" ? localizer.t("webview.unsupported.array") : localizer.t("webview.unsupported.type", {type: propertySchema.type}) }); } } /** * Build object-array item models from the current YAML array value. * * @param {{type: string, required?: string[], properties?: Record}} itemSchema Array item schema. * @param {unknown} yamlNode YAML node. * @param {string} propertyPath Top-level object-array path. * @param {number} depth Current depth. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. * @param {Record} commentLookup YAML comment lookup. * @returns {Array<{title: string, fields: Array>}>} Item models. */ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported, commentLookup) { if (!yamlNode || yamlNode.kind !== "array") { return []; } const items = []; for (let index = 0; index < yamlNode.items.length; index += 1) { const itemNode = yamlNode.items[index]; const itemPath = joinArrayIndexPath(propertyPath, index); if (!itemNode || itemNode.kind !== "object") { unsupported.push({ path: itemPath, message: localizer.t("webview.unsupported.objectArrayMixed") }); continue; } const fields = []; collectObjectArrayItemFields( itemSchema, itemNode, "", itemPath, depth, fields, unsupported, commentLookup); items.push({ title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}), fields }); } return items; } /** * Recursively collect editable fields inside one object-array item. * Nested objects remain editable, while nested object arrays still fall back * to raw YAML until a deeper editor model is added. * * @param {{type: string, required?: string[], properties?: Record, title?: string, description?: string}} schemaNode Schema node. * @param {unknown} yamlNode YAML node. * @param {string} localPath Path inside the current array item. * @param {string} displayPath Full logical path for UI display. * @param {number} depth Current depth. * @param {Array>} fields Field sink. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. * @param {Record} commentLookup YAML comment lookup. */ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } const yamlMap = getYamlObjectMap(yamlNode); const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { const itemLocalPath = joinPropertyPath(localPath, key); const itemDisplayPath = joinPropertyPath(displayPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); if (propertySchema.type === "object") { fields.push({ kind: "section", path: itemLocalPath, displayPath: itemDisplayPath, label, description: propertySchema.description, comment: commentLookup[itemDisplayPath] || "", required: requiredSet.has(key), depth }); collectObjectArrayItemFields( propertySchema, propertyValue, itemLocalPath, itemDisplayPath, depth + 1, fields, unsupported, commentLookup); continue; } if (propertySchema.type === "array" && propertySchema.items && ["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) { fields.push({ kind: "array", path: itemLocalPath, displayPath: itemDisplayPath, label, required: requiredSet.has(key), depth, itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), schema: propertySchema, itemMode: true, comment: commentLookup[itemDisplayPath] || "" }); continue; } if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) { fields.push({ kind: "scalar", path: itemLocalPath, displayPath: itemDisplayPath, label, required: requiredSet.has(key), depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), schema: propertySchema, itemMode: true, comment: commentLookup[itemDisplayPath] || "" }); continue; } unsupported.push({ path: itemDisplayPath, message: propertySchema.type === "array" ? localizer.t("webview.unsupported.nestedObjectArray") : localizer.t("webview.unsupported.type", {type: propertySchema.type}) }); } } /** * Get the mapping lookup for one parsed YAML object node. * * @param {unknown} yamlNode YAML node. * @returns {Map} Mapping lookup. */ function getYamlObjectMap(yamlNode) { return yamlNode && yamlNode.kind === "object" && yamlNode.map instanceof Map ? yamlNode.map : new Map(); } /** * Extract a scalar field value from a parsed YAML node. * * @param {unknown} yamlNode YAML node. * @param {string | undefined} defaultValue Default value from schema metadata. * @returns {string} Scalar display value. */ function getScalarFieldValue(yamlNode, defaultValue) { if (yamlNode && yamlNode.kind === "scalar") { return unquoteScalar(yamlNode.value || ""); } return defaultValue || ""; } /** * Extract a scalar-array value list from a parsed YAML node. * * @param {unknown} yamlNode YAML node. * @returns {string[]} Scalar array value list. */ function getScalarArrayValue(yamlNode) { if (!yamlNode || yamlNode.kind !== "array") { return []; } return yamlNode.items .filter((item) => item && item.kind === "scalar") .map((item) => unquoteScalar(item.value || "")); } /** * Render human-facing metadata hints for one schema field. * * @param {{description?: string, defaultValue?: string, enumValues?: string[], items?: {enumValues?: string[]}, refTable?: string}} propertySchema Property schema metadata. * @param {boolean} isArrayField Whether the field is an array. * @returns {string} HTML fragment. */ function renderFieldHint(propertySchema, isArrayField) { const hints = []; if (propertySchema.description) { hints.push(escapeHtml(propertySchema.description)); } if (propertySchema.defaultValue) { hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue}))); } const enumValues = isArrayField ? propertySchema.items && Array.isArray(propertySchema.items.enumValues) ? propertySchema.items.enumValues : [] : propertySchema.enumValues; if (Array.isArray(enumValues) && enumValues.length > 0) { hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")}))); } if (propertySchema.refTable) { hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable}))); } if (hints.length === 0) { return ""; } return `${hints.join(" · ")}`; } /** * Prompt for one batch-edit field value. * * @param {{key: string, type: string, itemType?: string, title?: string, description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor. * @returns {Promise} User input, or undefined when cancelled. */ async function promptBatchFieldValue(field) { if (field.inputKind === "array") { const hintParts = []; if (field.itemEnumValues && field.itemEnumValues.length > 0) { hintParts.push(localizer.t("input.batchArray.placeholder.allowedItems", { values: field.itemEnumValues.join(", ") })); } if (field.defaultValue) { hintParts.push(localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue})); } return vscode.window.showInputBox({ title: localizer.t("input.batchArray.title", {field: field.title || field.key}), prompt: localizer.t("input.batchArray.prompt", { fieldKey: field.key, itemType: field.itemType }), placeHolder: hintParts.join(" | "), ignoreFocusOut: true }); } if (field.enumValues && field.enumValues.length > 0) { const picked = await vscode.window.showQuickPick( field.enumValues.map((value) => ({ label: value, description: value === field.defaultValue ? localizer.t("detail.default") : undefined })), { title: localizer.t("quickPick.batchField.title", {field: field.title || field.key}), placeHolder: localizer.t("quickPick.batchField.placeholder", {fieldKey: field.key}) }); return picked ? picked.label : undefined; } return vscode.window.showInputBox({ title: localizer.t("input.batchField.title", {field: field.title || field.key}), prompt: localizer.t("input.batchField.prompt", { fieldKey: field.key, type: field.type }), placeHolder: [ field.description || "", field.defaultValue ? localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}) : "", field.refTable ? localizer.t("input.batchField.placeholder.refTable", {refTable: field.refTable}) : "" ].filter((part) => part.length > 0).join(" | ") || undefined, ignoreFocusOut: true }); } /** * Enumerate all YAML files recursively. * * @param {string} rootPath Root path. * @returns {string[]} YAML file paths. */ function enumerateYamlFiles(rootPath) { const results = []; for (const entry of fs.readdirSync(rootPath, {withFileTypes: true})) { const fullPath = path.join(rootPath, entry.name); if (entry.isDirectory()) { results.push(...enumerateYamlFiles(fullPath)); continue; } if (entry.isFile() && isYamlPath(entry.name)) { results.push(fullPath); } } return results; } /** * Check whether a path is a YAML file. * * @param {string} filePath File path. * @returns {boolean} True for YAML files. */ function isYamlPath(filePath) { return filePath.endsWith(".yaml") || filePath.endsWith(".yml"); } /** * Resolve the first workspace root. * * @returns {vscode.WorkspaceFolder | undefined} Workspace root. */ function getWorkspaceRoot() { const folders = vscode.workspace.workspaceFolders; return folders && folders.length > 0 ? folders[0] : undefined; } /** * Resolve the configured config root. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {vscode.Uri | undefined} Config root URI. */ function getConfigRoot(workspaceRoot) { const relativePath = vscode.workspace.getConfiguration("gframeworkConfig") .get("configPath", "config"); return vscode.Uri.joinPath(workspaceRoot.uri, relativePath); } /** * Resolve the configured schemas root. * * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {vscode.Uri | undefined} Schema root URI. */ function getSchemasRoot(workspaceRoot) { const relativePath = vscode.workspace.getConfiguration("gframeworkConfig") .get("schemasPath", "schemas"); return vscode.Uri.joinPath(workspaceRoot.uri, relativePath); } /** * Resolve the matching schema URI for a config file. * * @param {vscode.Uri} configUri Config file URI. * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {vscode.Uri | undefined} Schema URI. */ function getSchemaUriForConfigFile(configUri, workspaceRoot) { const configRoot = getConfigRoot(workspaceRoot); const schemaRoot = getSchemasRoot(workspaceRoot); if (!configRoot || !schemaRoot) { return undefined; } const relativePath = path.relative(configRoot.fsPath, configUri.fsPath); const segments = relativePath.split(path.sep); if (segments.length === 0 || !segments[0]) { return undefined; } return vscode.Uri.joinPath(schemaRoot, `${segments[0]}.schema.json`); } /** * Check whether a URI is inside the configured config root. * * @param {vscode.Uri} uri File URI. * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. * @returns {boolean} True when the file belongs to the config tree. */ function isConfigFile(uri, workspaceRoot) { const configRoot = getConfigRoot(workspaceRoot); if (!configRoot) { return false; } const relativePath = path.relative(configRoot.fsPath, uri.fsPath); return !relativePath.startsWith("..") && !path.isAbsolute(relativePath) && isYamlPath(uri.fsPath); } /** * Escape HTML text. * * @param {string} value Raw string. * @returns {string} Escaped string. */ function escapeHtml(value) { return String(value) .replace(/&/gu, "&") .replace(//gu, ">") .replace(/"/gu, """) .replace(/'/gu, "'"); } /** * Convert raw textarea payloads into scalar-array items. * * @param {Record} arrays Raw array editor payload. * @returns {Record} Parsed array updates. */ function parseArrayFieldPayload(arrays) { const parsed = {}; for (const [key, value] of Object.entries(arrays)) { parsed[key] = String(value) .split(/\r?\n/u) .map((item) => item.trim()) .filter((item) => item.length > 0); } return parsed; } module.exports = { activate, deactivate };