feat(config): 添加游戏内容配置系统和VS Code工具

- 实现YAML配置源文件和JSON Schema结构描述
- 提供一对象一文件的目录组织方式
- 集成Source Generator生成配置类型和表包装
- 开发VS Code插件支持配置浏览和编辑功能
- 实现运行时只读查询和热重载机制
- 添加跨表引用校验和批量编辑入口
This commit is contained in:
GeWuYou 2026-04-02 21:29:07 +08:00
parent 6df348fb4e
commit 94f0f536ea
7 changed files with 789 additions and 30 deletions

View File

@ -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` 元数据

View File

@ -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

View File

@ -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": {

View File

@ -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<string, string>} 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<string, SchemaNode>}} 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<string, string>, arrays?: Record<string, string[]>, objectArrays?: Record<string, Array<Record<string, unknown>>>}} updates Updated form values.
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>, objectArrays?: Record<string, Array<Record<string, unknown>>>, comments?: Record<string, string>}} 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<string, string>} 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, {type: "string" | "integer" | "number" | "boolean"}>} 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<string, string>} 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,

View File

@ -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<void>} 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<void>} 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<void>} 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<string, unknown>, type?: string}} schemaInfo Schema info.
* @param {unknown} parsedYaml Parsed YAML data.
* @param {{commentLookup?: Record<string, string>, 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
? `<button id="initializeFromSchema" class="secondary-button">${escapeHtml(localizer.t("webview.button.initialize"))}</button>`
: "";
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) {
<div class="toolbar">
<button id="save">${saveButtonLabel}</button>
<button id="openRaw">${openRawButtonLabel}</button>
${initializeAction}
</div>
<div class="meta hint-banner">${escapeHtml(localizer.t("webview.help.summary"))}</div>
<div class="meta">
<div>${escapeHtml(localizer.t("webview.meta.file", {fileName}))}</div>
<div>${schemaStatus}</div>
@ -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" });
});
}
</script>
</body>
</html>`;
@ -870,7 +1074,9 @@ function renderFormField(field) {
<div class="section depth-${field.depth}">
<div class="section-title">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
${renderYamlCommentBlock(field)}
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
${renderCommentEditor(field)}
</div>
`;
}
@ -887,8 +1093,11 @@ function renderFormField(field) {
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
<div class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</div>
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
${renderYamlCommentBlock(field)}
<span class="hint">${escapeHtml(localizer.t("webview.objectArray.hint"))}</span>
${renderFieldHint(field.schema, true)}
${renderReferenceActions(field)}
${renderCommentEditor(field)}
<div class="object-array-items" data-object-array-items>${renderedItems}</div>
<template data-object-array-template>${renderedTemplate}</template>
<button type="button" class="secondary-button" data-add-object-array-item>${escapeHtml(localizer.t("webview.objectArray.add"))}</button>
@ -907,9 +1116,12 @@ function renderFormField(field) {
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</span>
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
${renderYamlCommentBlock(field)}
<span class="hint">${escapeHtml(localizer.t("webview.array.hint", {itemType}))}</span>
${renderFieldHint(field.schema, true)}
${renderReferenceActions(field)}
<textarea ${dataAttribute} rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
${renderCommentEditor(field)}
</label>
`;
}
@ -934,12 +1146,76 @@ function renderFormField(field) {
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? `<span class="badge">${escapeHtml(localizer.t("webview.badge.required"))}</span>` : ""}</span>
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
${renderYamlCommentBlock(field)}
${renderFieldHint(field.schema, false)}
${renderReferenceActions(field)}
${inputControl}
${renderCommentEditor(field)}
</label>
`;
}
/**
* 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 `<span class="yaml-comment">${escapeHtml(field.comment)}</span>`;
}
/**
* 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 `
<div class="comment-editor">
<span class="hint">${escapeHtml(localizer.t("webview.comment.label"))}</span>
<textarea data-comment-path="${escapeHtml(commentPath)}" rows="2">${escapeHtml(field.comment || "")}</textarea>
</div>
`;
}
/**
* 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 = [
`<button type="button" class="secondary-button link-button" data-open-ref-schema="${refTable}">${escapeHtml(localizer.t("webview.ref.openSchema"))}</button>`,
`<button type="button" class="secondary-button link-button" data-open-ref-domain="${refTable}">${escapeHtml(localizer.t("webview.ref.openDomain"))}</button>`
];
if (field.kind === "scalar" && field.value) {
actions.push(
`<button type="button" class="secondary-button link-button" data-open-ref-value="true" data-ref-table="${refTable}" data-ref-value="${escapeHtml(field.value)}">${escapeHtml(localizer.t("webview.ref.openValue"))}</button>`);
}
return `<div class="field-actions">${actions.join("")}</div>`;
}
/**
* Render one object-array item editor block.
*
@ -963,16 +1239,17 @@ function renderObjectArrayItem(item) {
*
* @param {{exists: boolean, schemaPath: string, required: string[], properties: Record<string, unknown>, type?: string}} schemaInfo Schema info.
* @param {unknown} parsedYaml Parsed YAML data.
* @param {Record<string, string>} commentLookup YAML comment lookup.
* @returns {{fields: Array<Record<string, unknown>>, 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<Record<string, unknown>>} fields Field sink.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
* @param {Record<string, string>} 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<string, string>} commentLookup YAML comment lookup.
* @returns {Array<{title: string, fields: Array<Record<string, unknown>>}>} 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<Record<string, unknown>>} fields Field sink.
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
* @param {Record<string, string>} 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;
}

View File

@ -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 编辑。",

View File

@ -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(
[