mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
feat(config): 添加游戏内容配置系统和VS Code工具
- 实现YAML配置源文件和JSON Schema结构描述 - 提供一对象一文件的目录组织方式 - 集成Source Generator生成配置类型和表包装 - 开发VS Code插件支持配置浏览和编辑功能 - 实现运行时只读查询和热重载机制 - 添加跨表引用校验和批量编辑入口
This commit is contained in:
parent
6df348fb4e
commit
94f0f536ea
@ -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` 元数据
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 编辑。",
|
||||
|
||||
@ -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(
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user