diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 2f25fcb4..64eb7726 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -203,6 +203,9 @@ var hotReload = loader.EnableHotReload( - 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 - 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 +- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释 +- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 +- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 - 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 diff --git a/tools/gframework-config-tool/README.md b/tools/gframework-config-tool/README.md index 9a316fee..780518f4 100644 --- a/tools/gframework-config-tool/README.md +++ b/tools/gframework-config-tool/README.md @@ -11,6 +11,10 @@ VS Code extension for the GFramework AI-First config workflow. - Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and arrays of objects - Open a lightweight form preview for nested object fields, object arrays, top-level scalar fields, and scalar arrays +- Render existing YAML comments in the form preview and edit per-field YAML comments directly from the form +- Jump from reference fields to the referenced schema, config domain, or direct config file when a reference value is + present +- Initialize empty config files from schema-derived example YAML - Batch edit one config domain across multiple files for top-level scalar and scalar-array fields - Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the lightweight editors diff --git a/tools/gframework-config-tool/package.json b/tools/gframework-config-tool/package.json index 688c9601..cf9231ae 100644 --- a/tools/gframework-config-tool/package.json +++ b/tools/gframework-config-tool/package.json @@ -2,7 +2,7 @@ "name": "gframework-config-tool", "displayName": "%extension.displayName%", "description": "%extension.description%", - "version": "0.0.2", + "version": "0.0.3", "publisher": "GeWuYou", "license": "Apache-2.0", "repository": { diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 17ee2816..c233eabb 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -95,6 +95,125 @@ function parseTopLevelYaml(text) { return parseBlock(tokens, state, tokens[0].indent); } +/** + * Extract comment text from a YAML document and map it to logical field paths. + * The extractor focuses on comment lines that appear immediately above one key + * or array item so the form preview can surface author intent near the field. + * + * @param {string} text YAML text. + * @returns {Record} Comment lookup keyed by logical path. + */ +function extractYamlComments(text) { + const lines = String(text).split(/\r?\n/u); + const comments = {}; + const stack = [{indent: -1, type: "object", path: "", nextIndex: 0}]; + let pendingComments = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + + if (trimmed.length === 0) { + pendingComments = []; + continue; + } + + const indent = countLeadingSpaces(line); + if (trimmed.startsWith("#")) { + pendingComments.push(trimmed.replace(/^#\s?/u, "")); + continue; + } + + while (stack.length > 1 && indent < stack[stack.length - 1].indent) { + stack.pop(); + } + + const currentContext = stack[stack.length - 1]; + if (trimmed.startsWith("-")) { + if (currentContext.type !== "array") { + pendingComments = []; + continue; + } + + const itemIndex = currentContext.nextIndex || 0; + currentContext.nextIndex = itemIndex + 1; + const itemPath = `${currentContext.path}[${itemIndex}]`; + assignPendingComments(comments, itemPath, pendingComments); + pendingComments = []; + + const rest = trimmed.slice(1).trim(); + if (rest.length === 0) { + const nextLine = findNextMeaningfulLine(lines, index + 1); + if (nextLine && nextLine.indent > indent) { + stack.push(createContextForChild(itemPath, nextLine)); + } + continue; + } + + const inlineObjectMatch = /^([A-Za-z0-9_]+):(.*)$/u.exec(rest); + if (!inlineObjectMatch) { + continue; + } + + const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0}; + stack.push(itemObjectContext); + + const key = inlineObjectMatch[1]; + const parsedValue = splitYamlValueAndInlineComment(inlineObjectMatch[2].trim()); + if (parsedValue.comment) { + comments[`${itemPath}.${key}`] = parsedValue.comment; + } + + const nextLine = findNextMeaningfulLine(lines, index + 1); + if (parsedValue.value.length === 0 && nextLine && nextLine.indent > indent) { + stack.push(createContextForChild(`${itemPath}.${key}`, nextLine)); + } + + continue; + } + + const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(trimmed); + if (!match) { + pendingComments = []; + continue; + } + + const key = match[1]; + const valueInfo = splitYamlValueAndInlineComment(match[2].trim()); + const currentPath = currentContext.path ? `${currentContext.path}.${key}` : key; + assignPendingComments(comments, currentPath, pendingComments); + pendingComments = []; + + if (valueInfo.comment) { + comments[currentPath] = comments[currentPath] + ? `${comments[currentPath]}\n${valueInfo.comment}` + : valueInfo.comment; + } + + const nextLine = findNextMeaningfulLine(lines, index + 1); + if (valueInfo.value.length === 0 && nextLine && nextLine.indent > indent) { + stack.push(createContextForChild(currentPath, nextLine)); + } + } + + return comments; +} + +/** + * Create one example YAML config from a parsed schema tree. + * The sample includes schema descriptions as YAML comments so empty files can + * be bootstrapped into a readable starting point from the form preview. + * + * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. + * @returns {string} Example YAML text. + */ +function createSampleConfigYaml(schemaInfo) { + const sampleRoot = createSampleNodeFromSchema(schemaInfo); + const schemaComments = {}; + collectSchemaComments(schemaInfo, "", schemaComments); + return renderYaml(sampleRoot, 0, "", schemaComments).join("\n"); +} + /** * Produce extension-facing validation diagnostics from schema and parsed YAML. * @@ -151,14 +270,16 @@ function isScalarCompatible(expectedType, scalarValue) { * from the parsed structure so nested object edits can be saved safely. * * @param {string} originalYaml Original YAML content. - * @param {{scalars?: Record, arrays?: Record, objectArrays?: Record>>}} updates Updated form values. + * @param {{scalars?: Record, arrays?: Record, objectArrays?: Record>>, comments?: Record}} updates Updated form values. * @returns {string} Updated YAML content. */ function applyFormUpdates(originalYaml, updates) { const root = normalizeRootNode(parseTopLevelYaml(originalYaml)); + const preservedComments = extractYamlComments(originalYaml); const scalarUpdates = updates.scalars || {}; const arrayUpdates = updates.arrays || {}; const objectArrayUpdates = updates.objectArrays || {}; + const commentUpdates = updates.comments || {}; for (const [path, value] of Object.entries(scalarUpdates)) { setNodeAtPath(root, path.split("."), createScalarNode(String(value))); @@ -174,7 +295,17 @@ function applyFormUpdates(originalYaml, updates) { (items || []).map((item) => createNodeFromFormValue(item)))); } - return renderYaml(root).join("\n"); + for (const [path, comment] of Object.entries(commentUpdates)) { + const normalizedComment = String(comment || "").trim(); + if (normalizedComment.length === 0) { + delete preservedComments[path]; + continue; + } + + preservedComments[path] = normalizedComment; + } + + return renderYaml(root, 0, "", preservedComments).join("\n"); } /** @@ -741,13 +872,13 @@ function setObjectEntry(objectNode, key, valueNode) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderYaml(node, indent = 0) { +function renderYaml(node, indent = 0, currentPath = "", commentMap = {}) { if (node.kind === "object") { - return renderObjectNode(node, indent); + return renderObjectNode(node, indent, currentPath, commentMap); } if (node.kind === "array") { - return renderArrayNode(node, indent); + return renderArrayNode(node, indent, currentPath, commentMap); } return [`${" ".repeat(indent)}${formatYamlScalar(node.value)}`]; @@ -760,9 +891,14 @@ function renderYaml(node, indent = 0) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderObjectNode(node, indent) { +function renderObjectNode(node, indent, currentPath, commentMap) { const lines = []; for (const entry of node.entries) { + const entryPath = currentPath ? `${currentPath}.${entry.key}` : entry.key; + if (commentMap[entryPath]) { + lines.push(...renderYamlComments(commentMap[entryPath], indent)); + } + if (entry.node.kind === "scalar") { lines.push(`${" ".repeat(indent)}${entry.key}: ${formatYamlScalar(entry.node.value)}`); continue; @@ -774,7 +910,7 @@ function renderObjectNode(node, indent) { } lines.push(`${" ".repeat(indent)}${entry.key}:`); - lines.push(...renderYaml(entry.node, indent + 2)); + lines.push(...renderYaml(entry.node, indent + 2, entryPath, commentMap)); } return lines; @@ -787,16 +923,22 @@ function renderObjectNode(node, indent) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderArrayNode(node, indent) { +function renderArrayNode(node, indent, currentPath, commentMap) { const lines = []; - for (const item of node.items) { + for (let index = 0; index < node.items.length; index += 1) { + const item = node.items[index]; + const itemPath = `${currentPath}[${index}]`; + if (commentMap[itemPath]) { + lines.push(...renderYamlComments(commentMap[itemPath], indent)); + } + if (item.kind === "scalar") { lines.push(`${" ".repeat(indent)}- ${formatYamlScalar(item.value)}`); continue; } lines.push(`${" ".repeat(indent)}-`); - lines.push(...renderYaml(item, indent + 2)); + lines.push(...renderYaml(item, indent + 2, itemPath, commentMap)); } return lines; @@ -857,6 +999,207 @@ function createObjectNode() { return {kind: "object", entries: [], map: new Map()}; } +/** + * Build one example node recursively from schema metadata. + * + * @param {SchemaNode} schemaNode Schema node. + * @returns {YamlNode} Example YAML node. + */ +function createSampleNodeFromSchema(schemaNode) { + if (!schemaNode || schemaNode.type === "object") { + const objectNode = createObjectNode(); + for (const [key, propertySchema] of Object.entries(schemaNode && schemaNode.properties ? schemaNode.properties : {})) { + const childNode = createSampleNodeFromSchema(propertySchema); + setObjectEntry(objectNode, key, childNode); + } + + return objectNode; + } + + if (schemaNode.type === "array") { + if (schemaNode.items.type === "object") { + return createArrayNode([createSampleNodeFromSchema(schemaNode.items)]); + } + + return createArrayNode([createScalarNode(getSampleScalarValue(schemaNode.items))]); + } + + return createScalarNode(getSampleScalarValue(schemaNode)); +} + +/** + * Collect schema descriptions into a YAML comment lookup so sample configs can + * start with human-readable guidance right above generated fields. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {string} currentPath Current logical path. + * @param {Record} commentMap Comment lookup. + */ +function collectSchemaComments(schemaNode, currentPath, commentMap) { + if (!schemaNode || schemaNode.type !== "object") { + return; + } + + for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { + const propertyPath = currentPath ? `${currentPath}.${key}` : key; + if (propertySchema.description) { + commentMap[propertyPath] = propertySchema.description; + } + + if (propertySchema.type === "object") { + collectSchemaComments(propertySchema, propertyPath, commentMap); + continue; + } + + if (propertySchema.type === "array" && propertySchema.items.type === "object") { + collectSchemaComments(propertySchema.items, `${propertyPath}[0]`, commentMap); + } + } +} + +/** + * Resolve one sample scalar value from schema metadata. + * + * @param {Extract} schemaNode Scalar schema node. + * @returns {string} Sample scalar value. + */ +function getSampleScalarValue(schemaNode) { + if (schemaNode.defaultValue !== undefined) { + return schemaNode.defaultValue; + } + + if (Array.isArray(schemaNode.enumValues) && schemaNode.enumValues.length > 0) { + return schemaNode.enumValues[0]; + } + + switch (schemaNode.type) { + case "integer": + return "0"; + case "number": + return "0"; + case "boolean": + return "false"; + case "string": + default: + return schemaNode.refTable + ? "example_id" + : "example"; + } +} + +/** + * Render one comment block to YAML lines. + * + * @param {string} commentText Comment text. + * @param {number} indent Current indentation. + * @returns {string[]} YAML comment lines. + */ +function renderYamlComments(commentText, indent) { + return String(commentText) + .split(/\r?\n/u) + .filter((line) => line.length > 0) + .map((line) => `${" ".repeat(indent)}# ${line}`); +} + +/** + * Assign pending comment lines to one logical path. + * + * @param {Record} commentMap Comment lookup. + * @param {string} path Logical path. + * @param {string[]} pendingComments Pending comment lines. + */ +function assignPendingComments(commentMap, path, pendingComments) { + if (!path || !Array.isArray(pendingComments) || pendingComments.length === 0) { + return; + } + + commentMap[path] = pendingComments.join("\n"); +} + +/** + * Count leading spaces in one source line. + * + * @param {string} line Source line. + * @returns {number} Leading-space count. + */ +function countLeadingSpaces(line) { + const indentMatch = /^(\s*)/u.exec(line); + return indentMatch ? indentMatch[1].length : 0; +} + +/** + * Find the next non-empty, non-comment source line. + * + * @param {string[]} lines Source lines. + * @param {number} startIndex Starting index. + * @returns {{indent: number, trimmed: string} | undefined} Next significant line. + */ +function findNextMeaningfulLine(lines, startIndex) { + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + return { + indent: countLeadingSpaces(line), + trimmed + }; + } + + return undefined; +} + +/** + * Create one container context from the next meaningful line. + * + * @param {string} path Logical parent path. + * @param {{indent: number, trimmed: string}} nextLine Next meaningful line. + * @returns {{indent: number, type: "object" | "array", path: string, nextIndex: number}} Context model. + */ +function createContextForChild(path, nextLine) { + return { + indent: nextLine.indent, + type: nextLine.trimmed.startsWith("-") ? "array" : "object", + path, + nextIndex: 0 + }; +} + +/** + * Split a YAML value from one inline trailing comment. + * + * @param {string} rawValue Raw value segment after `key:`. + * @returns {{value: string, comment?: string}} Parsed value and optional comment. + */ +function splitYamlValueAndInlineComment(rawValue) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < rawValue.length; index += 1) { + const character = rawValue[index]; + if (character === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (character === "\"" && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (character === "#" && !inSingleQuote && !inDoubleQuote && (index === 0 || /\s/u.test(rawValue[index - 1]))) { + return { + value: rawValue.slice(0, index).trimEnd(), + comment: rawValue.slice(index + 1).trim() + }; + } + } + + return {value: rawValue}; +} + /** * Combine a parent path with one child segment. * @@ -871,6 +1214,8 @@ function combinePath(parentPath, key) { module.exports = { applyFormUpdates, applyScalarUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, isEditableScalarType, isScalarCompatible, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 62eb2b7f..56a3b99b 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -3,6 +3,8 @@ const path = require("path"); const vscode = require("vscode"); const { applyFormUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, parseBatchArrayValue, parseSchemaContent, @@ -254,6 +256,87 @@ async function openSchemaFile(item) { 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 @@ -272,7 +355,9 @@ async function openFormPreview(item, diagnostics) { const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const parsedYaml = parseTopLevelYaml(yamlText); + const commentLookup = extractYamlComments(yamlText); const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); + const canInitializeFromSchema = schemaInfo.exists && yamlText.trim().length === 0; const panel = vscode.window.createWebviewPanel( "gframeworkConfigFormPreview", @@ -283,7 +368,11 @@ async function openFormPreview(item, diagnostics) { panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, - parsedYaml); + parsedYaml, + { + commentLookup, + canInitializeFromSchema + }); panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { @@ -291,17 +380,57 @@ async function openFormPreview(item, diagnostics) { const updatedYaml = applyFormUpdates(latestYamlText, { scalars: message.scalars || {}, arrays: parseArrayFieldPayload(message.arrays || {}), - objectArrays: message.objectArrays || {} + 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 sampleYaml = createSampleConfigYaml(schemaInfo); + await fs.promises.writeFile(configUri.fsPath, sampleYaml, "utf8"); + const document = await vscode.workspace.openTextDocument(configUri); + await document.save(); + await validateConfigFile(configUri, diagnostics); + panel.webview.html = renderFormHtml( + path.basename(configUri.fsPath), + schemaInfo, + parseTopLevelYaml(sampleYaml), + { + commentLookup: extractYamlComments(sampleYaml), + 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); } }); } @@ -572,13 +701,18 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { * @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) { - const formModel = buildFormModel(schemaInfo, parsedYaml); +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"); @@ -635,6 +769,12 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { margin-bottom: 16px; color: var(--vscode-descriptionForeground); } + .hint-banner { + padding: 10px 12px; + border: 1px solid var(--vscode-panel-border, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, var(--vscode-panel-border, transparent)); + } .field { display: block; margin-bottom: 12px; @@ -701,6 +841,30 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { margin-bottom: 10px; color: var(--vscode-descriptionForeground); } + .field-actions { + display: flex; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; + } + .link-button { + padding: 4px 8px; + font-size: 12px; + } + .yaml-comment { + display: block; + margin-bottom: 6px; + padding: 8px 10px; + border-left: 3px solid var(--vscode-textBlockQuote-border); + background: color-mix(in srgb, var(--vscode-editor-background) 90%, var(--vscode-textBlockQuote-border)); + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); + font-size: 12px; + } + .comment-editor { + margin-top: 8px; + } .object-array { margin-bottom: 18px; padding: 12px; @@ -750,7 +914,9 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
+ ${initializeAction}
+
${escapeHtml(localizer.t("webview.help.summary"))}
${escapeHtml(localizer.t("webview.meta.file", {fileName}))}
${schemaStatus}
@@ -796,6 +962,34 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { }); } document.addEventListener("click", (event) => { + const schemaButton = event.target.closest("[data-open-ref-schema]"); + if (schemaButton) { + vscode.postMessage({ + type: "openReferenceSchema", + refTable: schemaButton.dataset.openRefSchema + }); + return; + } + + const domainButton = event.target.closest("[data-open-ref-domain]"); + if (domainButton) { + vscode.postMessage({ + type: "openReferenceDomain", + refTable: domainButton.dataset.openRefDomain + }); + return; + } + + const valueButton = event.target.closest("[data-open-ref-value]"); + if (valueButton) { + vscode.postMessage({ + type: "openReferenceValue", + refTable: valueButton.dataset.refTable, + refValue: valueButton.dataset.refValue + }); + return; + } + const addButton = event.target.closest("[data-add-object-array-item]"); if (addButton) { const editor = addButton.closest("[data-object-array-editor]"); @@ -824,12 +1018,16 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { const scalars = {}; const arrays = {}; const objectArrays = {}; + const comments = {}; for (const control of document.querySelectorAll("[data-path]")) { scalars[control.dataset.path] = control.value; } for (const textarea of document.querySelectorAll("textarea[data-array-path]")) { arrays[textarea.dataset.arrayPath] = textarea.value; } + for (const textarea of document.querySelectorAll("textarea[data-comment-path]")) { + comments[textarea.dataset.commentPath] = textarea.value; + } for (const editor of document.querySelectorAll("[data-object-array-editor]")) { const path = editor.dataset.objectArrayPath; const items = []; @@ -848,11 +1046,17 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { } objectArrays[path] = items; } - vscode.postMessage({ type: "save", scalars, arrays, objectArrays }); + vscode.postMessage({ type: "save", scalars, arrays, objectArrays, comments }); }); document.getElementById("openRaw").addEventListener("click", () => { vscode.postMessage({ type: "openRaw" }); }); + const initializeButton = document.getElementById("initializeFromSchema"); + if (initializeButton) { + initializeButton.addEventListener("click", () => { + vscode.postMessage({ type: "initializeFromSchema" }); + }); + } `; @@ -870,7 +1074,9 @@ function renderFormField(field) {
${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)}
`; } @@ -887,8 +1093,11 @@ function renderFormField(field) {
${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}
@@ -907,9 +1116,12 @@ function renderFormField(field) { `; } @@ -934,12 +1146,76 @@ function renderFormField(field) { `; } +/** + * 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 (commentPath.includes("[]")) { + 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. * @@ -963,16 +1239,17 @@ function renderObjectArrayItem(item) { * * @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) { +function buildFormModel(schemaInfo, parsedYaml, commentLookup) { if (!schemaInfo || schemaInfo.type !== "object") { return {fields: [], unsupported: []}; } const fields = []; const unsupported = []; - collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported); + collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported, commentLookup || {}); return {fields, unsupported}; } @@ -985,8 +1262,9 @@ function buildFormModel(schemaInfo, parsedYaml) { * @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) { +function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } @@ -1005,10 +1283,11 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns path: propertyPath, label, description: propertySchema.description, + comment: commentLookup[propertyPath] || "", required: requiredSet.has(key), depth }); - collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported); + collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported, commentLookup); continue; } @@ -1024,7 +1303,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns depth, itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), - schema: propertySchema + schema: propertySchema, + comment: commentLookup[propertyPath] || "" }); continue; } @@ -1040,7 +1320,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns `${propertyPath}[]`, depth + 1, itemFieldsTemplate, - unsupported); + unsupported, + commentLookup); fields.push({ kind: "objectArray", path: propertyPath, @@ -1049,7 +1330,14 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns required: requiredSet.has(key), depth, schema: propertySchema, - items: buildObjectArrayItemModels(propertySchema.items, propertyValue, propertyPath, depth + 1, unsupported), + comment: commentLookup[propertyPath] || "", + items: buildObjectArrayItemModels( + propertySchema.items, + propertyValue, + propertyPath, + depth + 1, + unsupported, + commentLookup), templateFields: itemFieldsTemplate }); continue; @@ -1064,7 +1352,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns required: requiredSet.has(key), depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), - schema: propertySchema + schema: propertySchema, + comment: commentLookup[propertyPath] || "" }); continue; } @@ -1086,9 +1375,10 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns * @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) { +function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported, commentLookup) { if (!yamlNode || yamlNode.kind !== "array") { return []; } @@ -1113,7 +1403,8 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u itemPath, depth, fields, - unsupported); + unsupported, + commentLookup); items.push({ title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}), fields @@ -1135,8 +1426,9 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u * @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) { +function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } @@ -1157,6 +1449,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa displayPath: itemDisplayPath, label, description: propertySchema.description, + comment: commentLookup[itemDisplayPath] || "", required: requiredSet.has(key), depth }); @@ -1167,7 +1460,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemDisplayPath, depth + 1, fields, - unsupported); + unsupported, + commentLookup); continue; } @@ -1184,7 +1478,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } @@ -1199,7 +1494,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 0fd1297f..4cb43ad5 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -37,11 +37,15 @@ const enMessages = { "command.openRaw.title": "Open Raw", "message.schemaNotFound": "Matching schema file was not found.", "message.formSaved": "Config file saved from form preview.", + "message.formInitialized": "Example config initialized from the schema.", "message.noYamlFilesInDomain": "No YAML config files were found in the selected domain.", "message.batchEditNeedsSchema": "Batch edit requires a matching schema file for the selected domain.", "message.batchEditNoEditableFields": "No top-level scalar or scalar-array fields were found in the matching schema.", "message.batchEditNoChanges": "Batch edit did not change any selected config files.", "message.batchEditUpdated": "Batch updated {count} config file(s) in '{domain}'.", + "message.referenceSchemaMissing": "The referenced schema '{refTable}.schema.json' was not found.", + "message.referenceDomainMissing": "The referenced config domain '{refTable}' was not found.", + "message.referenceValueMissing": "The referenced config '{refValue}' was not found in '{refTable}'.", "diagnostic.schemaMissing": "Matching schema file not found: {schemaPath}", "quickPick.batchEdit.title": "Batch Edit: {domain}", "quickPick.batchEdit.placeholder": "Select the config files to update.", @@ -67,7 +71,13 @@ const enMessages = { "webview.emptyState": "No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.", "webview.button.save": "Save Form", "webview.button.openRaw": "Open Raw YAML", + "webview.button.initialize": "Initialize Example", "webview.badge.required": "required", + "webview.help.summary": "Edit values, comments, and references here. Use raw YAML when you need unsupported structures or exact formatting control.", + "webview.comment.label": "YAML comment", + "webview.ref.openSchema": "Open Ref Schema", + "webview.ref.openDomain": "Open Ref Domain", + "webview.ref.openValue": "Open Ref File", "webview.objectArray.item": "Item", "webview.objectArray.itemNumber": "Item {index}", "webview.objectArray.hint": "Each item uses the object schema below.", @@ -91,11 +101,15 @@ const zhCnMessages = { "command.openRaw.title": "打开原始文件", "message.schemaNotFound": "未找到匹配的 schema 文件。", "message.formSaved": "已从表单预览保存配置文件。", + "message.formInitialized": "已根据 schema 初始化示例配置。", "message.noYamlFilesInDomain": "所选配置域中没有找到 YAML 配置文件。", "message.batchEditNeedsSchema": "批量编辑要求该配置域存在匹配的 schema 文件。", "message.batchEditNoEditableFields": "匹配的 schema 中没有可批量编辑的顶层标量字段或标量数组字段。", "message.batchEditNoChanges": "批量编辑未修改任何已选配置文件。", "message.batchEditUpdated": "已在“{domain}”中批量更新 {count} 个配置文件。", + "message.referenceSchemaMissing": "未找到引用的 schema 文件“{refTable}.schema.json”。", + "message.referenceDomainMissing": "未找到引用的配置域“{refTable}”。", + "message.referenceValueMissing": "在“{refTable}”中未找到引用配置“{refValue}”。", "diagnostic.schemaMissing": "未找到匹配的 schema 文件:{schemaPath}", "quickPick.batchEdit.title": "批量编辑:{domain}", "quickPick.batchEdit.placeholder": "选择要更新的配置文件。", @@ -121,7 +135,13 @@ const zhCnMessages = { "webview.emptyState": "当前没有可编辑的 schema 绑定字段。对于暂不支持的结构,请回退到原始 YAML 编辑。", "webview.button.save": "保存表单", "webview.button.openRaw": "打开原始 YAML", + "webview.button.initialize": "初始化示例", "webview.badge.required": "必填", + "webview.help.summary": "你可以在这里直接编辑字段值、YAML 注释和关联跳转。遇到暂不支持的复杂结构或需要精确保留排版时,请回退到原始 YAML。", + "webview.comment.label": "YAML 注释", + "webview.ref.openSchema": "打开引用 Schema", + "webview.ref.openDomain": "打开引用配置域", + "webview.ref.openValue": "打开引用文件", "webview.objectArray.item": "对象项", "webview.objectArray.itemNumber": "对象项 {index}", "webview.objectArray.hint": "每一项都按下面的对象 schema 编辑。", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index bde5079f..e62415dc 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -3,6 +3,8 @@ const assert = require("node:assert/strict"); const { applyFormUpdates, applyScalarUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, parseBatchArrayValue, parseSchemaContent, @@ -290,6 +292,95 @@ test("applyFormUpdates should clear object arrays when the form removes all item ].join("\n")); }); +test("extractYamlComments should map nested comments to logical paths", () => { + const comments = extractYamlComments(` +# Monster display name +name: Slime +stats: + # Current hp value + hp: 10 +skills: + # First skill entry + - + # Skill id note + id: jump +`); + + assert.equal(comments.name, "Monster display name"); + assert.equal(comments["stats.hp"], "Current hp value"); + assert.equal(comments["skills[0]"], "First skill entry"); + assert.equal(comments["skills[0].id"], "Skill id note"); +}); + +test("applyFormUpdates should preserve and update YAML comments", () => { + const updated = applyFormUpdates( + [ + "# Monster display name", + "name: Slime", + "stats:", + " # Current hp value", + " hp: 10" + ].join("\n"), + { + scalars: { + name: "Slime King" + }, + comments: { + name: "Localized display name", + "stats.hp": "Health points after rebalance" + } + }); + + assert.match(updated, /^# Localized display name$/mu); + assert.match(updated, /^name: Slime King$/mu); + assert.match(updated, /^ # Health points after rebalance$/mu); + assert.match(updated, /^ hp: 10$/mu); +}); + +test("createSampleConfigYaml should bootstrap comments and placeholder values from schema", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Monster display name." + }, + "rarity": { + "type": "string", + "description": "Monster rarity.", + "enum": ["common", "rare"] + }, + "skills": { + "type": "array", + "description": "Skill entries.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Skill id." + } + } + } + } + } + } + `); + + const sample = createSampleConfigYaml(schema); + + assert.match(sample, /^# Monster display name\.$/mu); + assert.match(sample, /^name: example$/mu); + assert.match(sample, /^# Monster rarity\.$/mu); + assert.match(sample, /^rarity: common$/mu); + assert.match(sample, /^# Skill entries\.$/mu); + assert.match(sample, /^skills:$/mu); + assert.match(sample, /^ -$/mu); + assert.match(sample, /^ # Skill id\.$/mu); + assert.match(sample, /^ id: example$/mu); +}); + test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => { const updated = applyScalarUpdates( [