Merge pull request #166 from GeWuYou/feat/config-content-system

Feat/config content system
This commit is contained in:
gewuyou 2026-04-02 22:29:59 +08:00 committed by GitHub
commit eaa1e5dff4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1557 additions and 239 deletions

View File

@ -1,94 +1,94 @@
#name: Publish VS Code Extension
#
#on:
# workflow_dispatch:
# inputs:
# version:
# description: Extension version to publish, for example 0.1.0. Leave empty to use package.json or the pushed tag.
# required: false
# type: string
# publish_to_marketplace:
# description: Publish to the Visual Studio Marketplace after packaging.
# required: true
# type: boolean
# default: true
# push:
# tags:
# - 'gframework-config-tool-v*'
#
#permissions:
# contents: read
#
#jobs:
# publish:
# name: Package And Publish Marketplace Extension
# runs-on: ubuntu-latest
#
# defaults:
# run:
# working-directory: tools/gframework-config-tool
#
# steps:
# - name: Checkout repository
# uses: actions/checkout@v6
# with:
# fetch-depth: 0
#
# - name: Setup Node.js 20
# uses: actions/setup-node@v5
# with:
# node-version: 20
#
# - name: Setup Bun
# uses: oven-sh/setup-bun@v2
# with:
# bun-version: 1.2.15
#
# - name: Determine extension version
# id: version
# shell: bash
# run: |
# set -euo pipefail
#
# PACKAGE_VERSION=$(node -p "require('./package.json').version")
# VERSION="${PACKAGE_VERSION}"
#
# if [[ "${GITHUB_REF:-}" == refs/tags/gframework-config-tool-v* ]]; then
# VERSION="${GITHUB_REF#refs/tags/gframework-config-tool-v}"
# elif [[ -n "${{ inputs.version || '' }}" ]]; then
# VERSION="${{ inputs.version }}"
# fi
#
# echo "Resolved extension version: ${VERSION}"
# echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
#
# - name: Install extension dependencies
# run: bun install
#
# - name: Synchronize package.json version
# shell: bash
# run: |
# set -euo pipefail
# node -e "const fs=require('fs'); const path='package.json'; const data=JSON.parse(fs.readFileSync(path,'utf8')); data.version='${{ steps.version.outputs.version }}'; fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n');"
#
# - name: Run extension tests
# run: bun run test
#
# - name: Package VSIX
# run: |
# set -euo pipefail
# mkdir -p ../../artifacts
# bun run package:vsix -- --out "../../artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix"
#
# - name: Upload VSIX artifact
# uses: actions/upload-artifact@v7
# with:
# name: gframework-config-tool-vsix
# path: artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix
# if-no-files-found: error
#
# - name: Publish to Visual Studio Marketplace
# if: github.event_name == 'push' || inputs.publish_to_marketplace
# env:
# VSCE_PAT: ${{ secrets.VSCE_PAT }}
# run: bun run publish:marketplace
name: Publish VS Code Extension
on:
workflow_dispatch:
inputs:
version:
description: Extension version to publish, for example 0.1.0. Leave empty to use package.json or the pushed tag.
required: false
type: string
publish_to_marketplace:
description: Publish to the Visual Studio Marketplace after packaging.
required: true
type: boolean
default: true
push:
tags:
- 'gframework-config-tool-v*'
permissions:
contents: read
jobs:
publish:
name: Package And Publish Marketplace Extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: tools/gframework-config-tool
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node.js 20
uses: actions/setup-node@v5
with:
node-version: 20
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.15
- name: Determine extension version
id: version
shell: bash
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
VERSION="${PACKAGE_VERSION}"
if [[ "${GITHUB_REF:-}" == refs/tags/gframework-config-tool-v* ]]; then
VERSION="${GITHUB_REF#refs/tags/gframework-config-tool-v}"
elif [[ -n "${{ inputs.version || '' }}" ]]; then
VERSION="${{ inputs.version }}"
fi
echo "Resolved extension version: ${VERSION}"
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
- name: Install extension dependencies
run: bun install
- name: Synchronize package.json version
shell: bash
run: |
set -euo pipefail
node -e "const fs=require('fs'); const path='package.json'; const data=JSON.parse(fs.readFileSync(path,'utf8')); data.version='${{ steps.version.outputs.version }}'; fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n');"
- name: Run extension tests
run: bun run test
- name: Package VSIX
run: |
set -euo pipefail
mkdir -p ../../artifacts
bun run package:vsix -- --out "../../artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix"
- name: Upload VSIX artifact
uses: actions/upload-artifact@v7
with:
name: gframework-config-tool-vsix
path: artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix
if-no-files-found: error
- name: Publish to Visual Studio Marketplace
if: github.event_name == 'push' || inputs.publish_to_marketplace
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
run: bun run publish:marketplace

View File

@ -200,8 +200,12 @@ var hotReload = loader.EnableHotReload(
- 浏览 `config/` 目录
- 打开 raw YAML 文件
- 打开匹配的 schema 文件
- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释
- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口
- 对空配置文件提供基于 schema 的示例 YAML 初始化入口
- 对同一配置域内的多份 YAML 文件执行批量字段更新
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据

View File

@ -7,9 +7,14 @@ VS Code extension for the GFramework AI-First config workflow.
- Browse config files from the workspace `config/` directory
- Open raw YAML files
- Open matching schema files from `schemas/`
- Localize extension UI text in English and Simplified Chinese, including the form preview, prompts, and notifications
- 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

@ -1,8 +1,8 @@
{
"name": "gframework-config-tool",
"displayName": "GFramework Config Tool",
"description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.",
"version": "0.0.1",
"displayName": "%extension.displayName%",
"description": "%extension.description%",
"version": "0.0.3",
"publisher": "GeWuYou",
"license": "Apache-2.0",
"repository": {
@ -54,34 +54,34 @@
"explorer": [
{
"id": "gframeworkConfigExplorer",
"name": "GFramework Config"
"name": "%view.gframeworkConfig.name%"
}
]
},
"commands": [
{
"command": "gframeworkConfig.refresh",
"title": "GFramework Config: Refresh"
"title": "%command.refresh.title%"
},
{
"command": "gframeworkConfig.openRaw",
"title": "GFramework Config: Open Raw File"
"title": "%command.openRaw.title%"
},
{
"command": "gframeworkConfig.openSchema",
"title": "GFramework Config: Open Schema"
"title": "%command.openSchema.title%"
},
{
"command": "gframeworkConfig.openFormPreview",
"title": "GFramework Config: Open Form Preview"
"title": "%command.openFormPreview.title%"
},
{
"command": "gframeworkConfig.batchEditDomain",
"title": "GFramework Config: Batch Edit Domain"
"title": "%command.batchEditDomain.title%"
},
{
"command": "gframeworkConfig.validateAll",
"title": "GFramework Config: Validate All"
"title": "%command.validateAll.title%"
}
],
"menus": {
@ -121,17 +121,17 @@
]
},
"configuration": {
"title": "GFramework Config",
"title": "%configuration.title%",
"properties": {
"gframeworkConfig.configPath": {
"type": "string",
"default": "config",
"description": "Relative path from the workspace root to the config directory."
"description": "%configuration.configPath.description%"
},
"gframeworkConfig.schemasPath": {
"type": "string",
"default": "schemas",
"description": "Relative path from the workspace root to the schema directory."
"description": "%configuration.schemasPath.description%"
}
}
}

View File

@ -0,0 +1,14 @@
{
"extension.displayName": "GFramework Config Tool",
"extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.",
"view.gframeworkConfig.name": "GFramework Config",
"command.refresh.title": "GFramework Config: Refresh",
"command.openRaw.title": "GFramework Config: Open Raw File",
"command.openSchema.title": "GFramework Config: Open Schema",
"command.openFormPreview.title": "GFramework Config: Open Form Preview",
"command.batchEditDomain.title": "GFramework Config: Batch Edit Domain",
"command.validateAll.title": "GFramework Config: Validate All",
"configuration.title": "GFramework Config",
"configuration.configPath.description": "Relative path from the workspace root to the config directory.",
"configuration.schemasPath.description": "Relative path from the workspace root to the schema directory."
}

View File

@ -0,0 +1,14 @@
{
"extension.displayName": "GFramework 配置工具",
"extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。",
"view.gframeworkConfig.name": "GFramework 配置",
"command.refresh.title": "GFramework 配置:刷新",
"command.openRaw.title": "GFramework 配置:打开原始文件",
"command.openSchema.title": "GFramework 配置:打开 Schema",
"command.openFormPreview.title": "GFramework 配置:打开表单预览",
"command.batchEditDomain.title": "GFramework 配置:批量编辑配置域",
"command.validateAll.title": "GFramework 配置:校验全部",
"configuration.title": "GFramework 配置",
"configuration.configPath.description": "从工作区根目录到配置目录的相对路径。",
"configuration.schemasPath.description": "从工作区根目录到 Schema 目录的相对路径。"
}

View File

@ -0,0 +1,64 @@
/**
* Join one object property onto a logical config path.
*
* @param {string} parentPath Parent logical path.
* @param {string} propertyName Property name.
* @returns {string} Combined logical path.
*/
function joinPropertyPath(parentPath, propertyName) {
return parentPath ? `${parentPath}.${propertyName}` : propertyName;
}
/**
* Join one indexed array item onto a logical config path.
*
* @param {string} arrayPath Array logical path.
* @param {number} itemIndex Zero-based item index.
* @returns {string} Indexed logical path.
*/
function joinArrayIndexPath(arrayPath, itemIndex) {
return `${arrayPath}[${itemIndex}]`;
}
/**
* Join one array-item template marker onto a logical config path.
*
* @param {string} arrayPath Array logical path.
* @returns {string} Template logical path.
*/
function joinArrayTemplatePath(arrayPath) {
return `${arrayPath}[]`;
}
/**
* Check whether a logical path still contains one template array marker.
*
* @param {string} path Logical path.
* @returns {boolean} True when the path contains a template array segment.
*/
function isTemplatePath(path) {
return String(path).includes("[]");
}
/**
* Split one logical object path into individual property segments.
* The current form model only supports dotted object paths here and keeps
* array indexing as part of other dedicated helpers.
*
* @param {string} path Logical path.
* @returns {string[]} Property segments.
*/
function splitObjectPath(path) {
return String(path)
.split(".")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
module.exports = {
isTemplatePath,
joinArrayIndexPath,
joinArrayTemplatePath,
joinPropertyPath,
splitObjectPath
};

View File

@ -1,3 +1,11 @@
const {
joinArrayIndexPath,
joinArrayTemplatePath,
joinPropertyPath,
splitObjectPath
} = require("./configPath");
const {ValidationMessageKeys} = require("./localizationKeys");
/**
* Parse the repository's minimal config-schema subset into a recursive tree.
* The parser intentionally mirrors the same high-level contract used by the
@ -95,16 +103,136 @@ 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 = joinArrayIndexPath(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 inlineObjectMapping = parseYamlMappingText(rest);
if (!inlineObjectMapping) {
continue;
}
const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0};
stack.push(itemObjectContext);
const key = inlineObjectMapping.key;
const parsedValue = splitYamlValueAndInlineComment(inlineObjectMapping.rawValue.trim());
if (parsedValue.comment) {
comments[joinPropertyPath(itemPath, key)] = parsedValue.comment;
}
const nextLine = findNextMeaningfulLine(lines, index + 1);
if (parsedValue.value.length === 0 && nextLine && nextLine.indent > indent) {
stack.push(createContextForChild(joinPropertyPath(itemPath, key), nextLine));
}
continue;
}
const mapping = parseYamlMappingText(trimmed);
if (!mapping) {
pendingComments = [];
continue;
}
const key = mapping.key;
const valueInfo = splitYamlValueAndInlineComment(mapping.rawValue.trim());
const currentPath = joinPropertyPath(currentContext.path, 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.
*
* @param {{type: "object", required: string[], properties: Record<string, SchemaNode>}} schemaInfo Parsed schema.
* @param {YamlNode} parsedYaml Parsed YAML tree.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
* @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics.
*/
function validateParsedConfig(schemaInfo, parsedYaml) {
function validateParsedConfig(schemaInfo, parsedYaml, localizer) {
const diagnostics = [];
validateNode(schemaInfo, parsedYaml, "", diagnostics);
validateNode(schemaInfo, parsedYaml, "", diagnostics, localizer);
return diagnostics;
}
@ -150,30 +278,42 @@ 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)));
setNodeAtPath(root, splitObjectPath(path), createScalarNode(String(value)));
}
for (const [path, values] of Object.entries(arrayUpdates)) {
setNodeAtPath(root, path.split("."), createArrayNode(
setNodeAtPath(root, splitObjectPath(path), createArrayNode(
(values || []).map((item) => createScalarNode(String(item)))));
}
for (const [path, items] of Object.entries(objectArrayUpdates)) {
setNodeAtPath(root, path.split("."), createArrayNode(
setNodeAtPath(root, splitObjectPath(path), createArrayNode(
(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");
}
/**
@ -308,7 +448,7 @@ function parseSchemaNode(rawNode, displayPath) {
: [];
const properties = {};
for (const [key, propertyNode] of Object.entries(value.properties || {})) {
properties[key] = parseSchemaNode(propertyNode, combinePath(displayPath, key));
properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key));
}
return {
@ -323,7 +463,7 @@ function parseSchemaNode(rawNode, displayPath) {
}
if (type === "array") {
const itemNode = parseSchemaNode(value.items || {}, `${displayPath}[]`);
const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath));
return {
type: "array",
displayPath,
@ -353,10 +493,11 @@ function parseSchemaNode(rawNode, displayPath) {
* @param {YamlNode} yamlNode YAML node.
* @param {string} displayPath Current logical path.
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
*/
function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (schemaNode.type === "object") {
validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics);
validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer);
return;
}
@ -364,13 +505,20 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode || yamlNode.kind !== "array") {
diagnostics.push({
severity: "error",
message: `Property '${displayPath}' is expected to be an array.`
message: localizeValidationMessage(ValidationMessageKeys.expectedArray, localizer, {
displayPath
})
});
return;
}
for (let index = 0; index < yamlNode.items.length; index += 1) {
validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics);
validateNode(
schemaNode.items,
yamlNode.items[index],
joinArrayIndexPath(displayPath, index),
diagnostics,
localizer);
}
return;
}
@ -378,7 +526,11 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode || yamlNode.kind !== "scalar") {
diagnostics.push({
severity: "error",
message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current YAML shape is '${yamlNode ? yamlNode.kind : "missing"}'.`
message: localizeValidationMessage(ValidationMessageKeys.expectedScalarShape, localizer, {
displayPath,
schemaType: schemaNode.type,
yamlKind: yamlNode ? yamlNode.kind : "missing"
})
});
return;
}
@ -386,7 +538,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!isScalarCompatible(schemaNode.type, yamlNode.value)) {
diagnostics.push({
severity: "error",
message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current scalar value is incompatible.`
message: localizeValidationMessage(ValidationMessageKeys.expectedScalarValue, localizer, {
displayPath,
schemaType: schemaNode.type
})
});
return;
}
@ -396,7 +551,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
!schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) {
diagnostics.push({
severity: "error",
message: `Property '${displayPath}' must be one of: ${schemaNode.enumValues.join(", ")}.`
message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, {
displayPath,
values: schemaNode.enumValues.join(", ")
})
});
}
}
@ -408,13 +566,23 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) {
* @param {YamlNode} yamlNode YAML node.
* @param {string} displayPath Current logical path.
* @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
*/
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) {
if (!yamlNode || yamlNode.kind !== "object") {
const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`;
const subject = displayPath.length === 0
? localizer && localizer.isChinese
? "根对象应为对象。"
: "Root object is expected to be an object."
: localizer && localizer.isChinese
? `属性“${displayPath}”应为对象。`
: `Property '${displayPath}' is expected to be an object.`;
diagnostics.push({
severity: "error",
message: `${subject} is expected to be an object.`
message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, {
subject,
displayPath
})
});
return;
}
@ -423,7 +591,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!yamlNode.map.has(requiredProperty)) {
diagnostics.push({
severity: "error",
message: `Required property '${combinePath(displayPath, requiredProperty)}' is missing.`
message: localizeValidationMessage(ValidationMessageKeys.missingRequired, localizer, {
displayPath: joinPropertyPath(displayPath, requiredProperty)
})
});
}
}
@ -432,7 +602,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) {
diagnostics.push({
severity: "error",
message: `Property '${combinePath(displayPath, entry.key)}' is not declared in the matching schema.`
message: localizeValidationMessage(ValidationMessageKeys.unknownProperty, localizer, {
displayPath: joinPropertyPath(displayPath, entry.key)
})
});
continue;
}
@ -440,8 +612,63 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) {
validateNode(
schemaNode.properties[entry.key],
entry.node,
combinePath(displayPath, entry.key),
diagnostics);
joinPropertyPath(displayPath, entry.key),
diagnostics,
localizer);
}
}
/**
* Format one validation message in either English or Simplified Chinese.
*
* @param {string} key Message key.
* @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer.
* @param {Record<string, string>} params Message parameters.
* @returns {string} Localized validation message.
*/
function localizeValidationMessage(key, localizer, params) {
if (localizer && typeof localizer.t === "function") {
return localizer.t(key, params);
}
if (localizer && localizer.isChinese) {
switch (key) {
case ValidationMessageKeys.expectedArray:
return `属性“${params.displayPath}”应为数组。`;
case ValidationMessageKeys.expectedScalarShape:
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`;
case ValidationMessageKeys.expectedScalarValue:
return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`;
case ValidationMessageKeys.enumMismatch:
return `属性“${params.displayPath}”必须是以下值之一:${params.values}`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
return `缺少必填属性“${params.displayPath}”。`;
case ValidationMessageKeys.unknownProperty:
return `属性“${params.displayPath}”未在匹配的 schema 中声明。`;
default:
return key;
}
}
switch (key) {
case ValidationMessageKeys.expectedArray:
return `Property '${params.displayPath}' is expected to be an array.`;
case ValidationMessageKeys.expectedScalarShape:
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`;
case ValidationMessageKeys.expectedScalarValue:
return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`;
case ValidationMessageKeys.enumMismatch:
return `Property '${params.displayPath}' must be one of: ${params.values}.`;
case ValidationMessageKeys.expectedObject:
return params.subject;
case ValidationMessageKeys.missingRequired:
return `Required property '${params.displayPath}' is missing.`;
case ValidationMessageKeys.unknownProperty:
return `Property '${params.displayPath}' is not declared in the matching schema.`;
default:
return key;
}
}
@ -513,14 +740,14 @@ function parseMapping(tokens, state, indent) {
continue;
}
const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(token.text);
if (!match) {
const mapping = parseYamlMappingText(token.text);
if (!mapping) {
state.index += 1;
continue;
}
const key = match[1];
const rawValue = match[2].trim();
const key = mapping.key;
const rawValue = mapping.rawValue.trim();
state.index += 1;
let node;
@ -568,7 +795,7 @@ function parseSequence(tokens, state, indent) {
continue;
}
if (/^[A-Za-z0-9_]+:/u.test(rest)) {
if (parseYamlMappingText(rest)) {
items.push(parseInlineObjectItem(tokens, state, indent, rest));
continue;
}
@ -666,13 +893,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)}`];
@ -685,9 +912,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 = joinPropertyPath(currentPath, 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;
@ -699,7 +931,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;
@ -712,16 +944,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 = joinArrayIndexPath(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;
@ -783,19 +1021,274 @@ function createObjectNode() {
}
/**
* Combine a parent path with one child segment.
* Build one example node recursively from schema metadata.
*
* @param {string} parentPath Parent path.
* @param {string} key Child key.
* @returns {string} Combined path.
* @param {SchemaNode} schemaNode Schema node.
* @returns {YamlNode} Example YAML node.
*/
function combinePath(parentPath, key) {
return parentPath && parentPath !== "<root>" ? `${parentPath}.${key}` : key;
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 = joinPropertyPath(currentPath, 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, joinArrayIndexPath(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};
}
/**
* Parse one YAML mapping entry such as `key: value` or `"complex key": value`.
*
* @param {string} text Raw YAML line text without leading indentation.
* @returns {{key: string, rawValue: string} | undefined} Parsed mapping entry.
*/
function parseYamlMappingText(text) {
const separatorIndex = findYamlKeyValueSeparator(text);
if (separatorIndex < 0) {
return undefined;
}
const rawKey = text.slice(0, separatorIndex).trim();
if (rawKey.length === 0) {
return undefined;
}
return {
key: normalizeYamlKey(rawKey),
rawValue: text.slice(separatorIndex + 1)
};
}
/**
* Find the first `:` that acts as a YAML key/value separator.
*
* @param {string} text Raw YAML line text without leading indentation.
* @returns {number} Separator index, or -1 when not found.
*/
function findYamlKeyValueSeparator(text) {
let inSingleQuote = false;
let inDoubleQuote = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}
if (character === "\"" && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (character === ":" && !inSingleQuote && !inDoubleQuote) {
return index;
}
}
return -1;
}
/**
* Normalize a YAML key token into the logical key name used in the form model.
*
* @param {string} rawKey Raw YAML key token.
* @returns {string} Normalized key name.
*/
function normalizeYamlKey(rawKey) {
return unquoteScalar(rawKey.trim());
}
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,
@ -10,6 +12,15 @@ const {
unquoteScalar,
validateParsedConfig
} = require("./configValidation");
const {
isTemplatePath,
joinArrayIndexPath,
joinArrayTemplatePath,
joinPropertyPath
} = require("./configPath");
const {createLocalizer} = require("./localization");
const localizer = createLocalizer(vscode.env.language);
/**
* Activate the GFramework config extension.
@ -132,11 +143,11 @@ class ConfigTreeDataProvider {
if (!configRoot || !fs.existsSync(configRoot.fsPath)) {
return [
new ConfigTreeItem(
"No config directory",
localizer.t("tree.noConfigDirectory.label"),
"info",
vscode.TreeItemCollapsibleState.None,
undefined,
"Set gframeworkConfig.configPath or create the directory.")
localizer.t("tree.noConfigDirectory.description"))
];
}
@ -171,8 +182,8 @@ class ConfigTreeDataProvider {
const fileUri = vscode.Uri.joinPath(domainUri, entry.name);
const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot);
const description = schemaUri && fs.existsSync(schemaUri.fsPath)
? "schema"
: "schema missing";
? localizer.t("tree.fileDescription.schema")
: localizer.t("tree.fileDescription.schemaMissing");
const item = new ConfigTreeItem(
entry.name,
"file",
@ -183,7 +194,7 @@ class ConfigTreeDataProvider {
item.contextValue = "gframeworkConfigFile";
item.command = {
command: "gframeworkConfig.openRaw",
title: "Open Raw",
title: localizer.t("command.openRaw.title"),
arguments: [item]
};
@ -243,7 +254,7 @@ async function openSchemaFile(item) {
const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot);
if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) {
void vscode.window.showWarningMessage("Matching schema file was not found.");
void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound"));
return;
}
@ -251,6 +262,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
@ -267,38 +359,97 @@ async function openFormPreview(item, diagnostics) {
return;
}
const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const parsedYaml = parseTopLevelYaml(yamlText);
let latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const parsedYaml = parseTopLevelYaml(latestYamlText);
const commentLookup = extractYamlComments(latestYamlText);
const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot);
const canInitializeFromSchema = schemaInfo.exists && latestYamlText.trim().length === 0;
const panel = vscode.window.createWebviewPanel(
"gframeworkConfigFormPreview",
`Config Form: ${path.basename(configUri.fsPath)}`,
localizer.t("webview.panelTitle", {fileName: path.basename(configUri.fsPath)}),
vscode.ViewColumn.Beside,
{enableScripts: true});
panel.webview.html = renderFormHtml(
path.basename(configUri.fsPath),
schemaInfo,
parsedYaml);
parsedYaml,
{
commentLookup,
canInitializeFromSchema
});
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === "save") {
const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
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("Config file saved from form preview.");
void vscode.window.showInformationMessage(localizer.t("message.formSaved"));
return;
}
if (message.type === "openRaw") {
await openRawFile({resourceUri: configUri});
return;
}
if (message.type === "initializeFromSchema") {
if (!schemaInfo.exists) {
void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound"));
return;
}
const confirmLabel = localizer.t("button.initializeFromSchemaConfirm");
const cancelLabel = localizer.t("button.cancel");
const userChoice = await vscode.window.showWarningMessage(
localizer.t("message.initializeFromSchemaConfirm"),
{modal: true},
confirmLabel,
cancelLabel);
if (userChoice !== confirmLabel) {
return;
}
const sampleYaml = createSampleConfigYaml(schemaInfo);
await fs.promises.writeFile(configUri.fsPath, sampleYaml, "utf8");
const document = await vscode.workspace.openTextDocument(configUri);
await document.save();
latestYamlText = sampleYaml;
await validateConfigFile(configUri, diagnostics);
panel.webview.html = renderFormHtml(
path.basename(configUri.fsPath),
schemaInfo,
parseTopLevelYaml(latestYamlText),
{
commentLookup: extractYamlComments(latestYamlText),
canInitializeFromSchema: false
});
void vscode.window.showInformationMessage(localizer.t("message.formInitialized"));
return;
}
if (message.type === "openReferenceSchema") {
await openReferenceSchemaFile(workspaceRoot, message.refTable);
return;
}
if (message.type === "openReferenceDomain") {
await revealReferenceDomain(workspaceRoot, message.refTable);
return;
}
if (message.type === "openReferenceValue") {
await openReferenceValueFile(workspaceRoot, message.refTable, message.refValue);
}
});
}
@ -353,13 +504,13 @@ async function validateConfigFile(configUri, diagnostics) {
if (!schemaInfo.exists) {
fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1),
`Matching schema file not found: ${schemaInfo.schemaPath}`,
localizer.t("diagnostic.schemaMissing", {schemaPath: schemaInfo.schemaPath}),
vscode.DiagnosticSeverity.Warning));
diagnostics.set(configUri, fileDiagnostics);
return;
}
for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml)) {
for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml, localizer)) {
fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1),
diagnostic.message,
@ -403,14 +554,14 @@ async function openBatchEdit(item, diagnostics, provider) {
});
if (fileItems.length === 0) {
void vscode.window.showWarningMessage("No YAML config files were found in the selected domain.");
void vscode.window.showWarningMessage(localizer.t("message.noYamlFilesInDomain"));
return;
}
const selectedFiles = await vscode.window.showQuickPick(fileItems, {
canPickMany: true,
title: `Batch Edit: ${path.basename(domainUri.fsPath)}`,
placeHolder: "Select the config files to update."
title: localizer.t("quickPick.batchEdit.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: localizer.t("quickPick.batchEdit.placeholder")
});
if (!selectedFiles || selectedFiles.length === 0) {
return;
@ -418,14 +569,13 @@ async function openBatchEdit(item, diagnostics, provider) {
const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot);
if (!schemaInfo.exists) {
void vscode.window.showWarningMessage("Batch edit requires a matching schema file for the selected domain.");
void vscode.window.showWarningMessage(localizer.t("message.batchEditNeedsSchema"));
return;
}
const editableFields = getEditableSchemaFields(schemaInfo);
if (editableFields.length === 0) {
void vscode.window.showWarningMessage(
"No top-level scalar or scalar-array fields were found in the matching schema.");
void vscode.window.showWarningMessage(localizer.t("message.batchEditNoEditableFields"));
return;
}
@ -433,19 +583,19 @@ async function openBatchEdit(item, diagnostics, provider) {
editableFields.map((field) => ({
label: field.title || field.key,
description: field.inputKind === "array"
? `array<${field.itemType}>`
? localizer.t("detail.arrayType", {itemType: field.itemType})
: field.type,
detail: [
field.required ? "required" : "",
field.required ? localizer.t("detail.required") : "",
field.description || "",
field.refTable ? `ref: ${field.refTable}` : ""
field.refTable ? localizer.t("detail.refTable", {refTable: field.refTable}) : ""
].filter((part) => part.length > 0).join(" · ") || undefined,
field
})),
{
canPickMany: true,
title: `Batch Edit Fields: ${path.basename(domainUri.fsPath)}`,
placeHolder: "Select the fields to apply across the chosen files."
title: localizer.t("quickPick.batchEditFields.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: localizer.t("quickPick.batchEditFields.placeholder")
});
if (!selectedFields || selectedFields.length === 0) {
return;
@ -492,13 +642,15 @@ async function openBatchEdit(item, diagnostics, provider) {
}
if (changedFileCount === 0) {
void vscode.window.showInformationMessage("Batch edit did not change any selected config files.");
void vscode.window.showInformationMessage(localizer.t("message.batchEditNoChanges"));
return;
}
const applied = await vscode.workspace.applyEdit(edit);
if (!applied) {
throw new Error("VS Code rejected the batch edit workspace update.");
throw new Error(localizer.isChinese
? "VS Code 拒绝了这次批量编辑工作区更新。"
: "VS Code rejected the batch edit workspace update.");
}
for (const document of touchedDocuments) {
@ -507,8 +659,10 @@ async function openBatchEdit(item, diagnostics, provider) {
}
provider.refresh();
void vscode.window.showInformationMessage(
`Batch updated ${changedFileCount} config file(s) in '${path.basename(domainUri.fsPath)}'.`);
void vscode.window.showInformationMessage(localizer.t("message.batchEditUpdated", {
count: changedFileCount,
domain: path.basename(domainUri.fsPath)
}));
}
/**
@ -566,10 +720,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");
@ -583,8 +745,8 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
.join("\n");
const schemaStatus = schemaInfo.exists
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
? escapeHtml(localizer.t("webview.meta.schema", {schemaPath: schemaInfo.schemaPath}))
: escapeHtml(localizer.t("webview.meta.schemaMissing", {schemaPath: schemaInfo.schemaPath}));
const editableContent = renderedFields;
const unsupportedSection = unsupportedFields.length > 0
@ -592,10 +754,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
: "";
const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}`
: "<p>No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.</p>";
: `<p>${escapeHtml(localizer.t("webview.emptyState"))}</p>`;
return `<!DOCTYPE html>
<html lang="en">
<html lang="${escapeHtml(localizer.languageTag)}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -626,6 +788,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;
@ -692,6 +860,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;
@ -739,16 +931,19 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
</head>
<body>
<div class="toolbar">
<button id="save">Save Form</button>
<button id="openRaw">Open Raw YAML</button>
<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>File: ${escapeHtml(fileName)}</div>
<div>${escapeHtml(localizer.t("webview.meta.file", {fileName}))}</div>
<div>${schemaStatus}</div>
</div>
<div id="fields">${emptyState}</div>
<script>
const vscode = acquireVsCodeApi();
const objectArrayItemLabel = ${JSON.stringify(objectArrayItemLabel)};
function parseArrayEditorValue(value) {
return String(value)
.split(/\\r?\\n/u)
@ -781,11 +976,39 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title");
if (title) {
title.textContent = "Item " + (index + 1);
title.textContent = objectArrayItemLabel + " " + (index + 1);
}
});
}
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]");
@ -814,12 +1037,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 = [];
@ -838,11 +1065,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>`;
@ -858,9 +1091,11 @@ function renderFormField(field) {
if (field.kind === "section") {
return `
<div class="section depth-${field.depth}">
<div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
<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>
`;
}
@ -870,36 +1105,42 @@ function renderFormField(field) {
.map((item) => renderObjectArrayItem(item))
.join("\n");
const renderedTemplate = renderObjectArrayItem({
title: "Item",
title: localizer.t("webview.objectArray.item"),
fields: field.templateFields
});
return `
<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\">required</span>" : ""}</div>
<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>
<span class="hint">Each item uses the object schema below.</span>
${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>Add Item</button>
<button type="button" class="secondary-button" data-add-object-array-item>${escapeHtml(localizer.t("webview.objectArray.add"))}</button>
</div>
`;
}
if (field.kind === "array") {
const itemType = field.itemType
? `array<${escapeHtml(field.itemType)}>`
? `array<${field.itemType}>`
: "array";
const dataAttribute = field.itemMode
? `data-item-array-path="${escapeHtml(field.path)}"`
: `data-array-path="${escapeHtml(field.path)}"`;
return `
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<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>
<span class="hint">One item per line. Expected type: ${itemType}</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>
`;
}
@ -922,14 +1163,78 @@ function renderFormField(field) {
return `
<label class="field depth-${field.depth}">
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
<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 (isTemplatePath(commentPath)) {
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.
*
@ -941,7 +1246,7 @@ function renderObjectArrayItem(item) {
<div class="object-array-item" data-object-array-item>
<div class="object-array-item-header">
<span class="object-array-item-title">${escapeHtml(item.title)}</span>
<button type="button" class="secondary-button" data-remove-object-array-item>Remove</button>
<button type="button" class="secondary-button" data-remove-object-array-item>${escapeHtml(localizer.t("webview.objectArray.remove"))}</button>
</div>
${item.fields.map((field) => renderFormField(field)).join("\n")}
</div>
@ -953,16 +1258,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};
}
@ -975,8 +1281,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;
}
@ -985,7 +1292,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const propertyPath = currentPath ? `${currentPath}.${key}` : key;
const propertyPath = joinPropertyPath(currentPath, key);
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
@ -995,10 +1302,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;
}
@ -1014,7 +1322,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;
}
@ -1027,10 +1336,11 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
propertySchema.items,
undefined,
"",
`${propertyPath}[]`,
joinArrayTemplatePath(propertyPath),
depth + 1,
itemFieldsTemplate,
unsupported);
unsupported,
commentLookup);
fields.push({
kind: "objectArray",
path: propertyPath,
@ -1039,7 +1349,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;
@ -1054,7 +1371,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;
}
@ -1062,8 +1380,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
unsupported.push({
path: propertyPath,
message: propertySchema.type === "array"
? "Unsupported array shapes are currently raw-YAML-only in the form preview."
: `${propertySchema.type} fields are currently raw-YAML-only.`
? localizer.t("webview.unsupported.array")
: localizer.t("webview.unsupported.type", {type: propertySchema.type})
});
}
}
@ -1076,9 +1394,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 [];
}
@ -1086,11 +1405,11 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u
const items = [];
for (let index = 0; index < yamlNode.items.length; index += 1) {
const itemNode = yamlNode.items[index];
const itemPath = `${propertyPath}[${index}]`;
const itemPath = joinArrayIndexPath(propertyPath, index);
if (!itemNode || itemNode.kind !== "object") {
unsupported.push({
path: itemPath,
message: "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items."
message: localizer.t("webview.unsupported.objectArrayMixed")
});
continue;
}
@ -1103,9 +1422,10 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u
itemPath,
depth,
fields,
unsupported);
unsupported,
commentLookup);
items.push({
title: `Item ${index + 1}`,
title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}),
fields
});
}
@ -1125,8 +1445,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;
}
@ -1135,8 +1456,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const itemLocalPath = localPath ? `${localPath}.${key}` : key;
const itemDisplayPath = `${displayPath}.${key}`;
const itemLocalPath = joinPropertyPath(localPath, key);
const itemDisplayPath = joinPropertyPath(displayPath, key);
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
@ -1147,6 +1468,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
displayPath: itemDisplayPath,
label,
description: propertySchema.description,
comment: commentLookup[itemDisplayPath] || "",
required: requiredSet.has(key),
depth
});
@ -1157,7 +1479,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
itemDisplayPath,
depth + 1,
fields,
unsupported);
unsupported,
commentLookup);
continue;
}
@ -1174,7 +1497,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema,
itemMode: true
itemMode: true,
comment: commentLookup[itemDisplayPath] || ""
});
continue;
}
@ -1189,7 +1513,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema,
itemMode: true
itemMode: true,
comment: commentLookup[itemDisplayPath] || ""
});
continue;
}
@ -1197,8 +1522,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa
unsupported.push({
path: itemDisplayPath,
message: propertySchema.type === "array"
? "Nested object-array fields are currently raw-YAML-only inside the object-array editor."
: `${propertySchema.type} fields are currently raw-YAML-only.`
? localizer.t("webview.unsupported.nestedObjectArray")
: localizer.t("webview.unsupported.type", {type: propertySchema.type})
});
}
}
@ -1261,7 +1586,7 @@ function renderFieldHint(propertySchema, isArrayField) {
}
if (propertySchema.defaultValue) {
hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`);
hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue})));
}
const enumValues = isArrayField
@ -1270,11 +1595,11 @@ function renderFieldHint(propertySchema, isArrayField) {
: []
: propertySchema.enumValues;
if (Array.isArray(enumValues) && enumValues.length > 0) {
hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`);
hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")})));
}
if (propertySchema.refTable) {
hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`);
hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable})));
}
if (hints.length === 0) {
@ -1294,16 +1619,21 @@ async function promptBatchFieldValue(field) {
if (field.inputKind === "array") {
const hintParts = [];
if (field.itemEnumValues && field.itemEnumValues.length > 0) {
hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`);
hintParts.push(localizer.t("input.batchArray.placeholder.allowedItems", {
values: field.itemEnumValues.join(", ")
}));
}
if (field.defaultValue) {
hintParts.push(`Default: ${field.defaultValue}`);
hintParts.push(localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}));
}
return vscode.window.showInputBox({
title: `Batch Edit Array: ${field.title || field.key}`,
prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`,
title: localizer.t("input.batchArray.title", {field: field.title || field.key}),
prompt: localizer.t("input.batchArray.prompt", {
fieldKey: field.key,
itemType: field.itemType
}),
placeHolder: hintParts.join(" | "),
ignoreFocusOut: true
});
@ -1313,22 +1643,27 @@ async function promptBatchFieldValue(field) {
const picked = await vscode.window.showQuickPick(
field.enumValues.map((value) => ({
label: value,
description: value === field.defaultValue ? "default" : undefined
description: value === field.defaultValue
? localizer.t("detail.default")
: undefined
})),
{
title: `Batch Edit Field: ${field.title || field.key}`,
placeHolder: `Select a value for '${field.key}'.`
title: localizer.t("quickPick.batchField.title", {field: field.title || field.key}),
placeHolder: localizer.t("quickPick.batchField.placeholder", {fieldKey: field.key})
});
return picked ? picked.label : undefined;
}
return vscode.window.showInputBox({
title: `Batch Edit Field: ${field.title || field.key}`,
prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`,
title: localizer.t("input.batchField.title", {field: field.title || field.key}),
prompt: localizer.t("input.batchField.prompt", {
fieldKey: field.key,
type: field.type
}),
placeHolder: [
field.description || "",
field.defaultValue ? `Default: ${field.defaultValue}` : "",
field.refTable ? `Ref table: ${field.refTable}` : ""
field.defaultValue ? localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}) : "",
field.refTable ? localizer.t("input.batchField.placeholder.refTable", {refTable: field.refTable}) : ""
].filter((part) => part.length > 0).join(" | ") || undefined,
ignoreFocusOut: true
});

View File

@ -0,0 +1,198 @@
const {ValidationMessageKeys} = require("./localizationKeys");
/**
* Create a tiny in-process localizer for the extension runtime and webview.
* VS Code contribution points use package.nls files, while runtime strings are
* resolved here so the preview panel and prompts stay readable for both
* Simplified Chinese and English users.
*
* @param {string | undefined} language VS Code UI language.
* @returns {{languageTag: string, isChinese: boolean, t: (key: string, params?: Record<string, string | number>) => string}} Localizer.
*/
function createLocalizer(language) {
const normalizedLanguage = String(language || "en").toLowerCase();
const isChinese = normalizedLanguage.startsWith("zh");
const isTraditionalChinese =
normalizedLanguage === "zh-tw" ||
normalizedLanguage === "zh-hk" ||
normalizedLanguage === "zh-mo" ||
normalizedLanguage.startsWith("zh-hant");
const isSimplifiedChinese = isChinese && !isTraditionalChinese;
const languageTag = isTraditionalChinese
? normalizedLanguage
: isSimplifiedChinese
? "zh-CN"
: "en";
const dictionary = isTraditionalChinese
? enMessages
: isSimplifiedChinese
? zhCnMessages
: enMessages;
return {
languageTag,
isChinese: isSimplifiedChinese,
t(key, params) {
const template = dictionary[key] || enMessages[key] || key;
return template.replace(/\{([A-Za-z0-9_]+)\}/gu, (match, token) => {
if (!params || !Object.prototype.hasOwnProperty.call(params, token)) {
return match;
}
return String(params[token]);
});
}
};
}
const enMessages = {
"tree.noConfigDirectory.label": "No config directory",
"tree.noConfigDirectory.description": "Set gframeworkConfig.configPath or create the directory.",
"tree.fileDescription.schema": "schema",
"tree.fileDescription.schemaMissing": "schema missing",
"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.initializeFromSchemaConfirm": "Initializing from the schema will replace the current configuration and may discard unsaved form changes. Do you want to continue?",
"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.",
"quickPick.batchEditFields.title": "Batch Edit Fields: {domain}",
"quickPick.batchEditFields.placeholder": "Select the fields to apply across the chosen files.",
"detail.required": "required",
"detail.refTable": "ref: {refTable}",
"detail.arrayType": "array<{itemType}>",
"detail.default": "default",
"button.cancel": "Cancel",
"button.initializeFromSchemaConfirm": "Initialize from schema",
"input.batchArray.title": "Batch Edit Array: {field}",
"input.batchArray.prompt": "Enter comma-separated items for '{fieldKey}' (expected array<{itemType}>). Leave empty to clear the array.",
"input.batchArray.placeholder.allowedItems": "Allowed items: {values}",
"input.batchArray.placeholder.default": "Default: {value}",
"quickPick.batchField.title": "Batch Edit Field: {field}",
"quickPick.batchField.placeholder": "Select a value for '{fieldKey}'.",
"input.batchField.title": "Batch Edit Field: {field}",
"input.batchField.prompt": "Enter the new value for '{fieldKey}' (expected {type}).",
"input.batchField.placeholder.refTable": "Ref table: {refTable}",
"webview.panelTitle": "Config Form: {fileName}",
"webview.meta.file": "File: {fileName}",
"webview.meta.schema": "Schema: {schemaPath}",
"webview.meta.schemaMissing": "Schema missing: {schemaPath}",
"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.",
"webview.objectArray.add": "Add Item",
"webview.objectArray.remove": "Remove",
"webview.array.hint": "One item per line. Expected type: {itemType}",
"webview.hint.default": "Default: {value}",
"webview.hint.allowed": "Allowed: {values}",
"webview.hint.refTable": "Ref table: {refTable}",
"webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.",
"webview.unsupported.type": "{type} fields are currently raw-YAML-only.",
"webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.",
"webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.",
[ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.",
[ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.",
[ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.",
[ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.",
[ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.",
[ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.",
[ValidationMessageKeys.unknownProperty]: "Property '{displayPath}' is not declared in the matching schema."
};
const zhCnMessages = {
"tree.noConfigDirectory.label": "未找到配置目录",
"tree.noConfigDirectory.description": "请设置 gframeworkConfig.configPath或先创建该目录。",
"tree.fileDescription.schema": "已匹配 schema",
"tree.fileDescription.schemaMissing": "缺少 schema",
"command.openRaw.title": "打开原始文件",
"message.schemaNotFound": "未找到匹配的 schema 文件。",
"message.formSaved": "已从表单预览保存配置文件。",
"message.formInitialized": "已根据 schema 初始化示例配置。",
"message.initializeFromSchemaConfirm": "从 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": "选择要更新的配置文件。",
"quickPick.batchEditFields.title": "批量编辑字段:{domain}",
"quickPick.batchEditFields.placeholder": "选择要应用到已选文件的字段。",
"detail.required": "必填",
"detail.refTable": "引用表:{refTable}",
"detail.arrayType": "数组<{itemType}>",
"detail.default": "默认值",
"button.cancel": "取消",
"button.initializeFromSchemaConfirm": "从 schema 初始化",
"input.batchArray.title": "批量编辑数组:{field}",
"input.batchArray.prompt": "请输入“{fieldKey}”的逗号分隔项(期望类型:数组<{itemType}>)。留空表示清空数组。",
"input.batchArray.placeholder.allowedItems": "允许项:{values}",
"input.batchArray.placeholder.default": "默认值:{value}",
"quickPick.batchField.title": "批量编辑字段:{field}",
"quickPick.batchField.placeholder": "为“{fieldKey}”选择一个值。",
"input.batchField.title": "批量编辑字段:{field}",
"input.batchField.prompt": "请输入“{fieldKey}”的新值(期望类型:{type})。",
"input.batchField.placeholder.refTable": "引用表:{refTable}",
"webview.panelTitle": "配置表单:{fileName}",
"webview.meta.file": "文件:{fileName}",
"webview.meta.schema": "Schema{schemaPath}",
"webview.meta.schemaMissing": "缺少 Schema{schemaPath}",
"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 编辑。",
"webview.objectArray.add": "新增对象项",
"webview.objectArray.remove": "删除",
"webview.array.hint": "每行一个元素。期望类型:{itemType}",
"webview.hint.default": "默认值:{value}",
"webview.hint.allowed": "允许值:{values}",
"webview.hint.refTable": "引用表:{refTable}",
"webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。",
"webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。",
"webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。",
"webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。",
[ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。",
[ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。",
[ValidationMessageKeys.expectedObject]: "{subject}",
[ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。",
[ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。",
[ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。",
[ValidationMessageKeys.unknownProperty]: "属性“{displayPath}”未在匹配的 schema 中声明。"
};
module.exports = {
createLocalizer
};

View File

@ -0,0 +1,13 @@
const ValidationMessageKeys = Object.freeze({
enumMismatch: "validation.enumMismatch",
expectedArray: "validation.expectedArray",
expectedObject: "validation.expectedObject",
expectedScalarShape: "validation.expectedScalarShape",
expectedScalarValue: "validation.expectedScalarValue",
missingRequired: "validation.missingRequired",
unknownProperty: "validation.unknownProperty"
});
module.exports = {
ValidationMessageKeys
};

View File

@ -3,6 +3,8 @@ const assert = require("node:assert/strict");
const {
applyFormUpdates,
applyScalarUpdates,
createSampleConfigYaml,
extractYamlComments,
getEditableSchemaFields,
parseBatchArrayValue,
parseSchemaContent,
@ -83,6 +85,20 @@ phases:
assert.equal(yaml.map.get("phases").items[0].map.get("wave").value, "1");
});
test("parseTopLevelYaml should keep complex mapping keys", () => {
const yaml = parseTopLevelYaml(`
my-key: slime
"complex key": value
root:
item.id: potion
`);
assert.equal(yaml.kind, "object");
assert.equal(yaml.map.get("my-key").value, "slime");
assert.equal(yaml.map.get("complex key").value, "value");
assert.equal(yaml.map.get("root").map.get("item.id").value, "potion");
});
test("validateParsedConfig should report missing and unknown nested properties", () => {
const schema = parseSchemaContent(`
{
@ -174,6 +190,27 @@ reward:
assert.match(diagnostics[0].message, /coin, gem/u);
});
test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string" }
}
}
`);
const yaml = parseTopLevelYaml(`
id: 1
`);
const diagnostics = validateParsedConfig(schema, yaml, {isChinese: true});
assert.equal(diagnostics.length, 2);
assert.match(diagnostics[0].message, /缺少必填属性/u);
assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u);
});
test("applyFormUpdates should update nested scalar and scalar-array paths", () => {
const updated = applyFormUpdates(
[
@ -269,6 +306,111 @@ 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("extractYamlComments should keep comments for complex YAML keys", () => {
const comments = extractYamlComments(`
# Dashed key comment
my-key: Slime
# Quoted key comment
"complex key": value
root:
# Dotted key comment
item.id: potion
`);
assert.equal(comments["my-key"], "Dashed key comment");
assert.equal(comments["complex key"], "Quoted key comment");
assert.equal(comments["root.item.id"], "Dotted key comment");
});
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(
[

View File

@ -0,0 +1,36 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {createLocalizer} = require("../src/localization");
test("createLocalizer should default to English strings", () => {
const localizer = createLocalizer("en");
assert.equal(localizer.languageTag, "en");
assert.equal(localizer.isChinese, false);
assert.equal(localizer.t("webview.button.save"), "Save Form");
assert.equal(
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"Batch updated 2 config file(s) in 'monster'.");
});
test("createLocalizer should switch to Simplified Chinese for zh languages", () => {
const localizer = createLocalizer("zh-cn");
assert.equal(localizer.languageTag, "zh-CN");
assert.equal(localizer.isChinese, true);
assert.equal(localizer.t("webview.button.save"), "保存表单");
assert.equal(
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"已在“monster”中批量更新 2 个配置文件。");
});
test("createLocalizer should fall back to English for Traditional Chinese locales", () => {
const localizer = createLocalizer("zh-TW");
assert.equal(localizer.languageTag, "zh-tw");
assert.equal(localizer.isChinese, false);
assert.equal(localizer.t("webview.button.save"), "Save Form");
assert.equal(
localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}),
"Batch updated 2 config file(s) in 'monster'.");
});