diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index f840f0c..83a5567 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -144,7 +144,7 @@ var hotReload = loader.EnableHotReload( - 打开 raw YAML 文件 - 打开匹配的 schema 文件 - 对必填字段、未知顶层字段、基础标量类型和标量数组元素做轻量校验 -- 对顶层标量字段提供轻量表单入口 +- 对顶层标量字段和顶层标量数组提供轻量表单入口 当前仍建议把复杂数组、嵌套对象和批量修改放在 raw YAML 中完成。 @@ -154,6 +154,6 @@ var hotReload = loader.EnableHotReload( - 跨表引用校验 - 更完整的 JSON Schema 支持 -- 更强的 VS Code 表单编辑器 +- 更强的 VS Code 嵌套对象与复杂数组编辑器 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 diff --git a/tools/vscode-config-extension/README.md b/tools/vscode-config-extension/README.md index 6007c32..c1208e3 100644 --- a/tools/vscode-config-extension/README.md +++ b/tools/vscode-config-extension/README.md @@ -8,7 +8,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow. - Open raw YAML files - Open matching schema files from `schemas/` - Run lightweight schema validation for required fields, unknown top-level fields, scalar types, and scalar array items -- Open a lightweight form preview for top-level scalar fields +- Open a lightweight form preview for top-level scalar fields and top-level scalar arrays ## Validation Coverage @@ -32,8 +32,8 @@ node --test ./test/*.test.js - Multi-root workspaces use the first workspace folder - Validation only covers a minimal subset of JSON Schema -- Form editing currently supports top-level scalar fields only -- Arrays and nested objects should still be edited in raw YAML +- Form editing currently supports top-level scalar fields and top-level scalar arrays +- Nested objects and complex arrays should still be edited in raw YAML ## Workspace Settings diff --git a/tools/vscode-config-extension/src/configValidation.js b/tools/vscode-config-extension/src/configValidation.js index ef71331..6182210 100644 --- a/tools/vscode-config-extension/src/configValidation.js +++ b/tools/vscode-config-extension/src/configValidation.js @@ -227,44 +227,85 @@ function isScalarCompatible(expectedType, scalarValue) { } /** - * Apply scalar field updates back into the original YAML text. + * Apply form field updates back into the original YAML text. + * The current form editor supports top-level scalar fields and top-level scalar + * arrays, while nested objects and complex arrays remain raw-YAML-only. + * + * @param {string} originalYaml Original YAML content. + * @param {{scalars?: Record, arrays?: Record}} updates Updated form values. + * @returns {string} Updated YAML content. + */ +function applyFormUpdates(originalYaml, updates) { + const lines = originalYaml.split(/\r?\n/u); + const scalarUpdates = updates.scalars || {}; + const arrayUpdates = updates.arrays || {}; + const touchedScalarKeys = new Set(); + const touchedArrayKeys = new Set(); + const blocks = findTopLevelBlocks(lines); + const updatedLines = []; + let cursor = 0; + + for (const block of blocks) { + while (cursor < block.start) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + + if (Object.prototype.hasOwnProperty.call(scalarUpdates, block.key)) { + touchedScalarKeys.add(block.key); + updatedLines.push(renderScalarLine(block.key, scalarUpdates[block.key])); + cursor = block.end + 1; + continue; + } + + if (Object.prototype.hasOwnProperty.call(arrayUpdates, block.key)) { + touchedArrayKeys.add(block.key); + updatedLines.push(...renderArrayBlock(block.key, arrayUpdates[block.key])); + cursor = block.end + 1; + continue; + } + + while (cursor <= block.end) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + } + + while (cursor < lines.length) { + updatedLines.push(lines[cursor]); + cursor += 1; + } + + for (const [key, value] of Object.entries(scalarUpdates)) { + if (touchedScalarKeys.has(key)) { + continue; + } + + updatedLines.push(renderScalarLine(key, value)); + } + + for (const [key, value] of Object.entries(arrayUpdates)) { + if (touchedArrayKeys.has(key)) { + continue; + } + + updatedLines.push(...renderArrayBlock(key, value)); + } + + return updatedLines.join("\n"); +} + +/** + * Apply only scalar updates back into the original YAML text. + * This helper is preserved for compatibility with existing tests and callers + * that only edit top-level scalar fields. * * @param {string} originalYaml Original YAML content. * @param {Record} updates Updated scalar values. * @returns {string} Updated YAML content. */ function applyScalarUpdates(originalYaml, updates) { - const lines = originalYaml.split(/\r?\n/u); - const touched = new Set(); - - const updatedLines = lines.map((line) => { - if (/^\s/u.test(line)) { - return line; - } - - const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); - if (!match) { - return line; - } - - const key = match[1]; - if (!Object.prototype.hasOwnProperty.call(updates, key)) { - return line; - } - - touched.add(key); - return `${key}: ${formatYamlScalar(updates[key])}`; - }); - - for (const [key, value] of Object.entries(updates)) { - if (touched.has(key)) { - continue; - } - - updatedLines.push(`${key}: ${formatYamlScalar(value)}`); - } - - return updatedLines.join("\n"); + return applyFormUpdates(originalYaml, {scalars: updates}); } /** @@ -329,8 +370,90 @@ function parseTopLevelArray(childLines) { return items; } +/** + * Find top-level YAML blocks so form updates can replace whole entries without + * touching unrelated domains in the file. + * + * @param {string[]} lines YAML lines. + * @returns {Array<{key: string, start: number, end: number}>} Top-level blocks. + */ +function findTopLevelBlocks(lines) { + const blocks = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line || line.trim().length === 0 || line.trim().startsWith("#") || /^\s/u.test(line)) { + continue; + } + + const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line); + if (!match) { + continue; + } + + let cursor = index + 1; + while (cursor < lines.length) { + const nextLine = lines[cursor]; + if (nextLine.trim().length === 0 || nextLine.trim().startsWith("#")) { + cursor += 1; + continue; + } + + if (!/^\s/u.test(nextLine)) { + break; + } + + cursor += 1; + } + + blocks.push({ + key: match[1], + start: index, + end: cursor - 1 + }); + index = cursor - 1; + } + + return blocks; +} + +/** + * Render a top-level scalar line. + * + * @param {string} key Property name. + * @param {string} value Scalar value. + * @returns {string} Rendered YAML line. + */ +function renderScalarLine(key, value) { + return `${key}: ${formatYamlScalar(value)}`; +} + +/** + * Render a top-level scalar array block. + * + * @param {string} key Property name. + * @param {string[]} items Array items. + * @returns {string[]} Rendered YAML lines. + */ +function renderArrayBlock(key, items) { + const normalizedItems = Array.isArray(items) + ? items + .map((item) => String(item).trim()) + .filter((item) => item.length > 0) + : []; + + const lines = [`${key}:`]; + for (const item of normalizedItems) { + lines.push(` - ${formatYamlScalar(item)}`); + } + + return lines; +} + module.exports = { + applyFormUpdates, applyScalarUpdates, + findTopLevelBlocks, formatYamlScalar, isScalarCompatible, parseSchemaContent, diff --git a/tools/vscode-config-extension/src/extension.js b/tools/vscode-config-extension/src/extension.js index c73775c..49ad2c2 100644 --- a/tools/vscode-config-extension/src/extension.js +++ b/tools/vscode-config-extension/src/extension.js @@ -2,7 +2,7 @@ const fs = require("fs"); const path = require("path"); const vscode = require("vscode"); const { - applyScalarUpdates, + applyFormUpdates, parseSchemaContent, parseTopLevelYaml, unquoteScalar, @@ -247,9 +247,9 @@ async function openSchemaFile(item) { } /** - * Open a lightweight form preview for top-level scalar fields. - * The editor intentionally edits only simple scalar keys and keeps raw YAML as - * the escape hatch for arrays, nested objects, and advanced changes. + * Open a lightweight form preview for top-level scalar fields and scalar + * arrays. Nested objects and more complex array shapes still use raw YAML as + * the escape hatch. * * @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item. * @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection. @@ -279,7 +279,10 @@ async function openFormPreview(item, diagnostics) { panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { - const updatedYaml = applyScalarUpdates(yamlText, message.values || {}); + const updatedYaml = applyFormUpdates(yamlText, { + scalars: message.scalars || {}, + arrays: parseArrayFieldPayload(message.arrays || {}) + }); await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); const document = await vscode.workspace.openTextDocument(configUri); await document.save(); @@ -409,7 +412,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { * @returns {string} HTML string. */ function renderFormHtml(fileName, schemaInfo, parsedYaml) { - const fields = Array.from(parsedYaml.entries.entries()) + const scalarFields = Array.from(parsedYaml.entries.entries()) .filter(([, entry]) => entry.kind === "scalar") .map(([key, entry]) => { const escapedKey = escapeHtml(key); @@ -424,13 +427,48 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { }) .join("\n"); + const arrayFields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind === "array") + .map(([key, entry]) => { + const escapedKey = escapeHtml(key); + const escapedValue = escapeHtml((entry.items || []) + .map((item) => unquoteScalar(item.raw)) + .join("\n")); + const required = schemaInfo.required.includes(key) ? "required" : ""; + const itemType = schemaInfo.properties[key] && schemaInfo.properties[key].itemType + ? `array<${escapeHtml(schemaInfo.properties[key].itemType)}>` + : "array"; + + return ` + + `; + }) + .join("\n"); + + const unsupportedFields = Array.from(parsedYaml.entries.entries()) + .filter(([, entry]) => entry.kind !== "scalar" && entry.kind !== "array") + .map(([key, entry]) => ` +
+ ${escapeHtml(key)}: ${escapeHtml(entry.kind)} fields are currently raw-YAML-only. +
+ `) + .join("\n"); + const schemaStatus = schemaInfo.exists ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; - const emptyState = fields.length > 0 - ? fields - : "

No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.

"; + const editableContent = [scalarFields, arrayFields].filter((content) => content.length > 0).join("\n"); + const unsupportedSection = unsupportedFields.length > 0 + ? `
${unsupportedFields}
` + : ""; + const emptyState = editableContent.length > 0 + ? `${editableContent}${unsupportedSection}` + : "

No editable top-level scalar or scalar-array fields were detected. Use raw YAML for nested objects or complex arrays.

"; return ` @@ -477,6 +515,22 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { background: var(--vscode-input-background); color: var(--vscode-input-foreground); } + textarea { + width: 100%; + padding: 8px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, transparent); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); + resize: vertical; + } + .hint { + display: block; + margin-bottom: 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + } .badge { display: inline-block; margin-left: 6px; @@ -486,11 +540,20 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { color: var(--vscode-badge-foreground); font-size: 11px; } + .unsupported-list { + margin-top: 20px; + border-top: 1px solid var(--vscode-panel-border, transparent); + padding-top: 16px; + } + .unsupported { + margin-bottom: 10px; + color: var(--vscode-descriptionForeground); + }
- +
@@ -501,11 +564,15 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {