GeWuYou 0e538738df feat(game): 添加游戏内容配置系统和YAML配置校验器
- 实现面向静态游戏内容的AI-First配置方案,支持怪物、物品、技能、任务等数据管理
- 集成YAML作为配置源文件格式,JSON Schema作为结构描述标准
- 提供一对象一文件的目录组织结构和运行时只读查询功能
- 实现Source Generator生成配置类型、表包装和注册/访问辅助代码
- 添加VS Code插件支持配置浏览、raw编辑、schema打开和递归校验功能
- 创建YamlConfigSchemaValidator类提供YAML与JSON Schema的运行时校验能力
- 支持嵌套对象、对象数组、标量数组的递归校验和深层约束检查
- 实现跨表引用验证和配置热重载功能
- 提供详细的错误诊断信息和开发期工具链支持
2026-04-03 16:32:14 +08:00

1850 lines
66 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const vscode = require("vscode");
const {
applyFormUpdates,
createSampleConfigYaml,
extractYamlComments,
getEditableSchemaFields,
parseBatchArrayValue,
parseSchemaContent,
parseTopLevelYaml,
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.
* The current tool focuses on workspace file navigation, lightweight
* validation, and a schema-aware form preview for common editing workflows.
*
* @param {vscode.ExtensionContext} context Extension context.
*/
function activate(context) {
const diagnostics = vscode.languages.createDiagnosticCollection("gframeworkConfig");
const provider = new ConfigTreeDataProvider();
context.subscriptions.push(diagnostics);
context.subscriptions.push(
vscode.window.registerTreeDataProvider("gframeworkConfigExplorer", provider),
vscode.commands.registerCommand("gframeworkConfig.refresh", async () => {
provider.refresh();
await validateAllConfigs(diagnostics);
}),
vscode.commands.registerCommand("gframeworkConfig.openRaw", async (item) => {
await openRawFile(item);
}),
vscode.commands.registerCommand("gframeworkConfig.openSchema", async (item) => {
await openSchemaFile(item);
}),
vscode.commands.registerCommand("gframeworkConfig.openFormPreview", async (item) => {
await openFormPreview(item, diagnostics);
}),
vscode.commands.registerCommand("gframeworkConfig.batchEditDomain", async (item) => {
await openBatchEdit(item, diagnostics, provider);
}),
vscode.commands.registerCommand("gframeworkConfig.validateAll", async () => {
await validateAllConfigs(diagnostics);
}),
vscode.workspace.onDidSaveTextDocument(async (document) => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
if (!isConfigFile(document.uri, workspaceRoot)) {
return;
}
await validateConfigFile(document.uri, diagnostics);
provider.refresh();
}),
vscode.workspace.onDidChangeWorkspaceFolders(async () => {
provider.refresh();
await validateAllConfigs(diagnostics);
})
);
void validateAllConfigs(diagnostics);
}
/**
* Deactivate the extension.
*/
function deactivate() {
}
/**
* Tree provider for the GFramework config explorer view.
*/
class ConfigTreeDataProvider {
constructor() {
this._emitter = new vscode.EventEmitter();
this.onDidChangeTreeData = this._emitter.event;
}
/**
* Refresh the tree view.
*/
refresh() {
this._emitter.fire(undefined);
}
/**
* Resolve a tree item.
*
* @param {ConfigTreeItem} element Tree element.
* @returns {vscode.TreeItem} Tree item.
*/
getTreeItem(element) {
return element;
}
/**
* Resolve child elements.
*
* @param {ConfigTreeItem | undefined} element Parent element.
* @returns {Thenable<ConfigTreeItem[]>} Child items.
*/
async getChildren(element) {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return [];
}
if (!element) {
return this.getRootItems(workspaceRoot);
}
if (element.kind !== "domain" || !element.resourceUri) {
return [];
}
return this.getFileItems(workspaceRoot, element.resourceUri);
}
/**
* Build root domain items from the config directory.
*
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {Promise<ConfigTreeItem[]>} Root items.
*/
async getRootItems(workspaceRoot) {
const configRoot = getConfigRoot(workspaceRoot);
if (!configRoot || !fs.existsSync(configRoot.fsPath)) {
return [
new ConfigTreeItem(
localizer.t("tree.noConfigDirectory.label"),
"info",
vscode.TreeItemCollapsibleState.None,
undefined,
localizer.t("tree.noConfigDirectory.description"))
];
}
const entries = fs.readdirSync(configRoot.fsPath, {withFileTypes: true})
.filter((entry) => entry.isDirectory())
.sort((left, right) => left.name.localeCompare(right.name));
return entries.map((entry) => {
const domainUri = vscode.Uri.joinPath(configRoot, entry.name);
return new ConfigTreeItem(
entry.name,
"domain",
vscode.TreeItemCollapsibleState.Collapsed,
domainUri,
undefined);
});
}
/**
* Build file items for a config domain directory.
*
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @param {vscode.Uri} domainUri Domain directory URI.
* @returns {Promise<ConfigTreeItem[]>} File items.
*/
async getFileItems(workspaceRoot, domainUri) {
const entries = fs.readdirSync(domainUri.fsPath, {withFileTypes: true})
.filter((entry) => entry.isFile() && isYamlPath(entry.name))
.sort((left, right) => left.name.localeCompare(right.name));
return entries.map((entry) => {
const fileUri = vscode.Uri.joinPath(domainUri, entry.name);
const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot);
const description = schemaUri && fs.existsSync(schemaUri.fsPath)
? localizer.t("tree.fileDescription.schema")
: localizer.t("tree.fileDescription.schemaMissing");
const item = new ConfigTreeItem(
entry.name,
"file",
vscode.TreeItemCollapsibleState.None,
fileUri,
description);
item.contextValue = "gframeworkConfigFile";
item.command = {
command: "gframeworkConfig.openRaw",
title: localizer.t("command.openRaw.title"),
arguments: [item]
};
return item;
});
}
}
/**
* Tree item used by the config explorer.
*/
class ConfigTreeItem extends vscode.TreeItem {
/**
* @param {string} label Display label.
* @param {"domain" | "file" | "info"} kind Item kind.
* @param {vscode.TreeItemCollapsibleState} collapsibleState Collapsible state.
* @param {vscode.Uri | undefined} resourceUri Resource URI.
* @param {string | undefined} description Description.
*/
constructor(label, kind, collapsibleState, resourceUri, description) {
super(label, collapsibleState);
this.kind = kind;
this.resourceUri = resourceUri;
this.description = description;
this.contextValue = kind === "file" ? "gframeworkConfigFile" : kind;
}
}
/**
* Open the selected raw config file.
*
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @returns {Promise<void>} Async task.
*/
async function openRawFile(item) {
const uri = item && item.resourceUri;
if (!uri) {
return;
}
const document = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(document, {preview: false});
}
/**
* Open the matching schema file for a selected config item.
*
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @returns {Promise<void>} Async task.
*/
async function openSchemaFile(item) {
const workspaceRoot = getWorkspaceRoot();
const configUri = item && item.resourceUri;
if (!workspaceRoot || !configUri) {
return;
}
const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot);
if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) {
void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound"));
return;
}
const document = await vscode.workspace.openTextDocument(schemaUri);
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
* object-array editing for the repository's supported schema subset.
*
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
* @returns {Promise<void>} Async task.
*/
async function openFormPreview(item, diagnostics) {
const workspaceRoot = getWorkspaceRoot();
const configUri = item && item.resourceUri;
if (!workspaceRoot || !configUri) {
return;
}
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",
localizer.t("webview.panelTitle", {fileName: path.basename(configUri.fsPath)}),
vscode.ViewColumn.Beside,
{enableScripts: true});
panel.webview.html = renderFormHtml(
path.basename(configUri.fsPath),
schemaInfo,
parsedYaml,
{
commentLookup,
canInitializeFromSchema
});
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === "save") {
latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const updatedYaml = applyFormUpdates(latestYamlText, {
scalars: message.scalars || {},
arrays: parseArrayFieldPayload(message.arrays || {}),
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 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);
}
});
}
/**
* Validate all config files in the configured config directory.
*
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
* @returns {Promise<void>} Async task.
*/
async function validateAllConfigs(diagnostics) {
diagnostics.clear();
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
const configRoot = getConfigRoot(workspaceRoot);
if (!configRoot || !fs.existsSync(configRoot.fsPath)) {
return;
}
const files = enumerateYamlFiles(configRoot.fsPath);
for (const filePath of files) {
await validateConfigFile(vscode.Uri.file(filePath), diagnostics);
}
}
/**
* Validate a single config file against its matching schema.
*
* @param {vscode.Uri} configUri Config file URI.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
* @returns {Promise<void>} Async task.
*/
async function validateConfigFile(configUri, diagnostics) {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
if (!isConfigFile(configUri, workspaceRoot)) {
return;
}
const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
const parsedYaml = parseTopLevelYaml(yamlText);
const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot);
const fileDiagnostics = [];
if (!schemaInfo.exists) {
fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1),
localizer.t("diagnostic.schemaMissing", {schemaPath: schemaInfo.schemaPath}),
vscode.DiagnosticSeverity.Warning));
diagnostics.set(configUri, fileDiagnostics);
return;
}
for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml, localizer)) {
fileDiagnostics.push(new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 1),
diagnostic.message,
diagnostic.severity === "error"
? vscode.DiagnosticSeverity.Error
: vscode.DiagnosticSeverity.Warning));
}
diagnostics.set(configUri, fileDiagnostics);
}
/**
* Open a minimal batch editor for one config domain.
* The workflow intentionally focuses on one schema-bound directory at a time
* so designers can apply the same top-level scalar or scalar-array values
* across multiple files without dropping down to repetitive raw-YAML edits.
*
* @param {ConfigTreeItem | { kind?: string, resourceUri?: vscode.Uri }} item Tree item.
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
* @param {ConfigTreeDataProvider} provider Tree provider.
* @returns {Promise<void>} Async task.
*/
async function openBatchEdit(item, diagnostics, provider) {
const workspaceRoot = getWorkspaceRoot();
const domainUri = item && item.resourceUri;
if (!workspaceRoot || !domainUri || item.kind !== "domain") {
return;
}
const fileItems = fs.readdirSync(domainUri.fsPath, {withFileTypes: true})
.filter((entry) => entry.isFile() && isYamlPath(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => {
const fileUri = vscode.Uri.joinPath(domainUri, entry.name);
return {
label: entry.name,
description: path.relative(workspaceRoot.uri.fsPath, fileUri.fsPath),
fileUri,
picked: true
};
});
if (fileItems.length === 0) {
void vscode.window.showWarningMessage(localizer.t("message.noYamlFilesInDomain"));
return;
}
const selectedFiles = await vscode.window.showQuickPick(fileItems, {
canPickMany: true,
title: localizer.t("quickPick.batchEdit.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: localizer.t("quickPick.batchEdit.placeholder")
});
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot);
if (!schemaInfo.exists) {
void vscode.window.showWarningMessage(localizer.t("message.batchEditNeedsSchema"));
return;
}
const editableFields = getEditableSchemaFields(schemaInfo);
if (editableFields.length === 0) {
void vscode.window.showWarningMessage(localizer.t("message.batchEditNoEditableFields"));
return;
}
const selectedFields = await vscode.window.showQuickPick(
editableFields.map((field) => ({
label: field.title || field.key,
description: field.inputKind === "array"
? localizer.t("detail.arrayType", {itemType: field.itemType})
: field.type,
detail: [
field.required ? localizer.t("detail.required") : "",
field.description || "",
field.refTable ? localizer.t("detail.refTable", {refTable: field.refTable}) : ""
].filter((part) => part.length > 0).join(" · ") || undefined,
field
})),
{
canPickMany: true,
title: localizer.t("quickPick.batchEditFields.title", {domain: path.basename(domainUri.fsPath)}),
placeHolder: localizer.t("quickPick.batchEditFields.placeholder")
});
if (!selectedFields || selectedFields.length === 0) {
return;
}
const updates = {
scalars: {},
arrays: {}
};
for (const selectedField of selectedFields) {
const field = selectedField.field;
const rawValue = await promptBatchFieldValue(field);
if (rawValue === undefined) {
return;
}
if (field.inputKind === "array") {
updates.arrays[field.key] = parseBatchArrayValue(rawValue);
continue;
}
updates.scalars[field.key] = rawValue;
}
const edit = new vscode.WorkspaceEdit();
const touchedDocuments = [];
let changedFileCount = 0;
for (const fileItem of selectedFiles) {
const document = await vscode.workspace.openTextDocument(fileItem.fileUri);
const originalYaml = document.getText();
const updatedYaml = applyFormUpdates(originalYaml, updates);
if (updatedYaml === originalYaml) {
continue;
}
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(originalYaml.length));
edit.replace(fileItem.fileUri, fullRange, updatedYaml);
touchedDocuments.push(document);
changedFileCount += 1;
}
if (changedFileCount === 0) {
void vscode.window.showInformationMessage(localizer.t("message.batchEditNoChanges"));
return;
}
const applied = await vscode.workspace.applyEdit(edit);
if (!applied) {
throw new Error(localizer.isChinese
? "VS Code 拒绝了这次批量编辑工作区更新。"
: "VS Code rejected the batch edit workspace update.");
}
for (const document of touchedDocuments) {
await document.save();
await validateConfigFile(document.uri, diagnostics);
}
provider.refresh();
void vscode.window.showInformationMessage(localizer.t("message.batchEditUpdated", {
count: changedFileCount,
domain: path.basename(domainUri.fsPath)
}));
}
/**
* Load schema info for a config file.
*
* @param {vscode.Uri} configUri Config file URI.
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {Promise<{exists: boolean, schemaPath: string, required: string[], properties: Record<string, {
* type: string,
* itemType?: string,
* title?: string,
* description?: string,
* defaultValue?: string,
* enumValues?: string[],
* itemEnumValues?: string[],
* refTable?: string
* }>}>} Schema info.
*/
async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot);
const schemaPath = schemaUri ? schemaUri.fsPath : "";
if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) {
return {
exists: false,
schemaPath,
required: [],
properties: {}
};
}
const content = await fs.promises.readFile(schemaUri.fsPath, "utf8");
try {
const parsed = parseSchemaContent(content);
return {
exists: true,
schemaPath,
type: parsed.type,
required: parsed.required,
properties: parsed.properties
};
} catch (error) {
return {
exists: false,
schemaPath,
required: [],
properties: {}
};
}
}
/**
* Render the form-preview webview HTML.
*
* @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, 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");
const unsupportedFields = formModel.unsupported
.map((field) => `
<div class="unsupported">
<strong>${escapeHtml(field.path)}</strong>: ${escapeHtml(field.message)}
</div>
`)
.join("\n");
const schemaStatus = schemaInfo.exists
? 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
? `<div class="unsupported-list">${unsupportedFields}</div>`
: "";
const emptyState = editableContent.length > 0
? `${editableContent}${unsupportedSection}`
: `<p>${escapeHtml(localizer.t("webview.emptyState"))}</p>`;
return `<!DOCTYPE html>
<html lang="${escapeHtml(localizer.languageTag)}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
font-family: var(--vscode-font-family);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 16px;
}
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
button {
border: 1px solid var(--vscode-button-border, transparent);
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 8px 12px;
cursor: pointer;
}
.secondary-button {
background: transparent;
color: var(--vscode-button-foreground);
}
.meta {
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;
}
.section {
margin: 18px 0 8px;
padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border, transparent);
}
.section-title {
font-weight: 700;
margin-bottom: 4px;
}
.meta-key {
display: inline-block;
margin-bottom: 6px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.label {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
input, select {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
}
textarea {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid var(--vscode-input-border, transparent);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: var(--vscode-editor-font-family, var(--vscode-font-family));
resize: vertical;
}
.hint {
display: block;
margin-bottom: 6px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
font-size: 11px;
}
.unsupported-list {
margin-top: 20px;
border-top: 1px solid var(--vscode-panel-border, transparent);
padding-top: 16px;
}
.unsupported {
margin-bottom: 10px;
color: var(--vscode-descriptionForeground);
}
.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;
border: 1px solid var(--vscode-panel-border, transparent);
border-radius: 6px;
}
.object-array-items {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 12px;
}
.object-array-item {
padding: 12px;
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--vscode-editor-background) 88%, var(--vscode-panel-border, transparent));
}
.object-array-item-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.object-array-item-title {
font-weight: 700;
}
.depth-1 {
margin-left: 12px;
}
.depth-2 {
margin-left: 24px;
}
.depth-3 {
margin-left: 36px;
}
.depth-4 {
margin-left: 48px;
}
.depth-5 {
margin-left: 60px;
}
</style>
</head>
<body>
<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>
</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)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function setNestedObjectValue(target, path, value) {
const segments = path.split(".").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return;
}
let current = target;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
if (index === segments.length - 1) {
current[segment] = value;
return;
}
if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
current[segment] = {};
}
current = current[segment];
}
}
function renumberObjectArrayItems(editor) {
const items = editor.querySelectorAll("[data-object-array-item]");
items.forEach((item, index) => {
const title = item.querySelector(".object-array-item-title");
if (title) {
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]");
const itemsHost = editor.querySelector("[data-object-array-items]");
const template = editor.querySelector("template[data-object-array-template]");
if (itemsHost && template) {
itemsHost.appendChild(template.content.cloneNode(true));
renumberObjectArrayItems(editor);
}
return;
}
const removeButton = event.target.closest("[data-remove-object-array-item]");
if (removeButton) {
const item = removeButton.closest("[data-object-array-item]");
const editor = removeButton.closest("[data-object-array-editor]");
if (item) {
item.remove();
}
if (editor) {
renumberObjectArrayItems(editor);
}
}
});
document.getElementById("save").addEventListener("click", () => {
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 = [];
for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) {
const itemValue = {};
for (const control of item.querySelectorAll("[data-item-local-path]")) {
setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value);
}
for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) {
setNestedObjectValue(
itemValue,
textarea.dataset.itemArrayPath,
parseArrayEditorValue(textarea.value));
}
items.push(itemValue);
}
objectArrays[path] = items;
}
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>`;
}
/**
* Render one form field.
*
* @param {Record<string, unknown>} field Form field descriptor.
* @returns {string} HTML fragment.
*/
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">${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>
`;
}
if (field.kind === "objectArray") {
const renderedItems = field.items
.map((item) => renderObjectArrayItem(item))
.join("\n");
const renderedTemplate = renderObjectArrayItem({
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">${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>
</div>
`;
}
if (field.kind === "array") {
const itemType = 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">${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>
`;
}
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
const dataAttribute = field.itemMode
? `data-item-local-path="${escapeHtml(field.path)}"`
: `data-path="${escapeHtml(field.path)}"`;
const inputControl = enumValues.length > 0
? `
<select ${dataAttribute}>
${enumValues.map((value) => {
const escapedOption = escapeHtml(value);
const selected = value === field.value ? " selected" : "";
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
}).join("\n")}
</select>
`
: `<input ${dataAttribute} value="${escapeHtml(field.value)}" />`;
return `
<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 (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.
*
* @param {{title: string, fields: Array<Record<string, unknown>>}} item Item model.
* @returns {string} HTML fragment.
*/
function renderObjectArrayItem(item) {
return `
<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>${escapeHtml(localizer.t("webview.objectArray.remove"))}</button>
</div>
${item.fields.map((field) => renderFormField(field)).join("\n")}
</div>
`;
}
/**
* Build a recursive form model from schema and parsed YAML.
*
* @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, commentLookup) {
if (!schemaInfo || schemaInfo.type !== "object") {
return {fields: [], unsupported: []};
}
const fields = [];
const unsupported = [];
collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported, commentLookup || {});
return {fields, unsupported};
}
/**
* Recursively collect top-level form-editable fields.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
* @param {string} currentPath Current logical path.
* @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, commentLookup) {
if (!schemaNode || schemaNode.type !== "object") {
return;
}
const yamlMap = getYamlObjectMap(yamlNode);
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const propertyPath = joinPropertyPath(currentPath, key);
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
if (propertySchema.type === "object") {
fields.push({
kind: "section",
path: propertyPath,
label,
description: propertySchema.description,
comment: commentLookup[propertyPath] || "",
required: requiredSet.has(key),
depth
});
collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported, commentLookup);
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
fields.push({
kind: "array",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema,
comment: commentLookup[propertyPath] || ""
});
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
propertySchema.items.type === "object") {
const itemFieldsTemplate = [];
collectObjectArrayItemFields(
propertySchema.items,
undefined,
"",
joinArrayTemplatePath(propertyPath),
depth + 1,
itemFieldsTemplate,
unsupported,
commentLookup);
fields.push({
kind: "objectArray",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
schema: propertySchema,
comment: commentLookup[propertyPath] || "",
items: buildObjectArrayItemModels(
propertySchema.items,
propertyValue,
propertyPath,
depth + 1,
unsupported,
commentLookup),
templateFields: itemFieldsTemplate
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: propertyPath,
displayPath: propertyPath,
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema,
comment: commentLookup[propertyPath] || ""
});
continue;
}
unsupported.push({
path: propertyPath,
message: propertySchema.type === "array"
? localizer.t("webview.unsupported.array")
: localizer.t("webview.unsupported.type", {type: propertySchema.type})
});
}
}
/**
* Build object-array item models from the current YAML array value.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>}} itemSchema Array item schema.
* @param {unknown} yamlNode YAML node.
* @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, commentLookup) {
if (!yamlNode || yamlNode.kind !== "array") {
return [];
}
const items = [];
for (let index = 0; index < yamlNode.items.length; index += 1) {
const itemNode = yamlNode.items[index];
const itemPath = joinArrayIndexPath(propertyPath, index);
if (!itemNode || itemNode.kind !== "object") {
unsupported.push({
path: itemPath,
message: localizer.t("webview.unsupported.objectArrayMixed")
});
continue;
}
const fields = [];
collectObjectArrayItemFields(
itemSchema,
itemNode,
"",
itemPath,
depth,
fields,
unsupported,
commentLookup);
items.push({
title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}),
fields
});
}
return items;
}
/**
* Recursively collect editable fields inside one object-array item.
* Nested objects remain editable, while nested object arrays still fall back
* to raw YAML until a deeper editor model is added.
*
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
* @param {unknown} yamlNode YAML node.
* @param {string} localPath Path inside the current array item.
* @param {string} displayPath Full logical path for UI display.
* @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, commentLookup) {
if (!schemaNode || schemaNode.type !== "object") {
return;
}
const yamlMap = getYamlObjectMap(yamlNode);
const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []);
for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) {
const itemLocalPath = joinPropertyPath(localPath, key);
const itemDisplayPath = joinPropertyPath(displayPath, key);
const label = propertySchema.title || key;
const propertyValue = yamlMap.get(key);
if (propertySchema.type === "object") {
fields.push({
kind: "section",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
description: propertySchema.description,
comment: commentLookup[itemDisplayPath] || "",
required: requiredSet.has(key),
depth
});
collectObjectArrayItemFields(
propertySchema,
propertyValue,
itemLocalPath,
itemDisplayPath,
depth + 1,
fields,
unsupported,
commentLookup);
continue;
}
if (propertySchema.type === "array" &&
propertySchema.items &&
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
fields.push({
kind: "array",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
required: requiredSet.has(key),
depth,
itemType: propertySchema.items.type,
value: getScalarArrayValue(propertyValue),
schema: propertySchema,
itemMode: true,
comment: commentLookup[itemDisplayPath] || ""
});
continue;
}
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
fields.push({
kind: "scalar",
path: itemLocalPath,
displayPath: itemDisplayPath,
label,
required: requiredSet.has(key),
depth,
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
schema: propertySchema,
itemMode: true,
comment: commentLookup[itemDisplayPath] || ""
});
continue;
}
unsupported.push({
path: itemDisplayPath,
message: propertySchema.type === "array"
? localizer.t("webview.unsupported.nestedObjectArray")
: localizer.t("webview.unsupported.type", {type: propertySchema.type})
});
}
}
/**
* Get the mapping lookup for one parsed YAML object node.
*
* @param {unknown} yamlNode YAML node.
* @returns {Map<string, unknown>} Mapping lookup.
*/
function getYamlObjectMap(yamlNode) {
return yamlNode && yamlNode.kind === "object" && yamlNode.map instanceof Map
? yamlNode.map
: new Map();
}
/**
* Extract a scalar field value from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @param {string | undefined} defaultValue Default value from schema metadata.
* @returns {string} Scalar display value.
*/
function getScalarFieldValue(yamlNode, defaultValue) {
if (yamlNode && yamlNode.kind === "scalar") {
return unquoteScalar(yamlNode.value || "");
}
return defaultValue || "";
}
/**
* Extract a scalar-array value list from a parsed YAML node.
*
* @param {unknown} yamlNode YAML node.
* @returns {string[]} Scalar array value list.
*/
function getScalarArrayValue(yamlNode) {
if (!yamlNode || yamlNode.kind !== "array") {
return [];
}
return yamlNode.items
.filter((item) => item && item.kind === "scalar")
.map((item) => unquoteScalar(item.value || ""));
}
/**
* Render human-facing metadata hints for one schema field.
*
* @param {{description?: string, defaultValue?: string, minimum?: number, maximum?: number, minLength?: number, maxLength?: number, enumValues?: string[], items?: {enumValues?: string[], minimum?: number, maximum?: number, minLength?: number, maxLength?: number}, refTable?: string}} propertySchema Property schema metadata.
* @param {boolean} isArrayField Whether the field is an array.
* @returns {string} HTML fragment.
*/
function renderFieldHint(propertySchema, isArrayField) {
const hints = [];
if (propertySchema.description) {
hints.push(escapeHtml(propertySchema.description));
}
if (propertySchema.defaultValue) {
hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue})));
}
const enumValues = isArrayField
? propertySchema.items && Array.isArray(propertySchema.items.enumValues)
? propertySchema.items.enumValues
: []
: propertySchema.enumValues;
if (Array.isArray(enumValues) && enumValues.length > 0) {
hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")})));
}
if (!isArrayField && typeof propertySchema.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minimum", {value: propertySchema.minimum})));
}
if (!isArrayField && typeof propertySchema.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.maximum", {value: propertySchema.maximum})));
}
if (!isArrayField && typeof propertySchema.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.minLength", {value: propertySchema.minLength})));
}
if (!isArrayField && typeof propertySchema.maxLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.maxLength", {value: propertySchema.maxLength})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.minimum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinimum", {value: propertySchema.items.minimum})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.maximum === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaximum", {value: propertySchema.items.maximum})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.minLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMinLength", {value: propertySchema.items.minLength})));
}
if (isArrayField && propertySchema.items && typeof propertySchema.items.maxLength === "number") {
hints.push(escapeHtml(localizer.t("webview.hint.itemMaxLength", {value: propertySchema.items.maxLength})));
}
if (propertySchema.refTable) {
hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable})));
}
if (hints.length === 0) {
return "";
}
return `<span class="hint">${hints.join(" · ")}</span>`;
}
/**
* Prompt for one batch-edit field value.
*
* @param {{key: string, type: string, itemType?: string, title?: string, description?: string, defaultValue?: string, enumValues?: string[], itemEnumValues?: string[], refTable?: string, inputKind: "scalar" | "array", required: boolean}} field Editable field descriptor.
* @returns {Promise<string | undefined>} User input, or undefined when cancelled.
*/
async function promptBatchFieldValue(field) {
if (field.inputKind === "array") {
const hintParts = [];
if (field.itemEnumValues && field.itemEnumValues.length > 0) {
hintParts.push(localizer.t("input.batchArray.placeholder.allowedItems", {
values: field.itemEnumValues.join(", ")
}));
}
if (field.defaultValue) {
hintParts.push(localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}));
}
return vscode.window.showInputBox({
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
});
}
if (field.enumValues && field.enumValues.length > 0) {
const picked = await vscode.window.showQuickPick(
field.enumValues.map((value) => ({
label: value,
description: value === field.defaultValue
? localizer.t("detail.default")
: undefined
})),
{
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: 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 ? 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
});
}
/**
* Enumerate all YAML files recursively.
*
* @param {string} rootPath Root path.
* @returns {string[]} YAML file paths.
*/
function enumerateYamlFiles(rootPath) {
const results = [];
for (const entry of fs.readdirSync(rootPath, {withFileTypes: true})) {
const fullPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
results.push(...enumerateYamlFiles(fullPath));
continue;
}
if (entry.isFile() && isYamlPath(entry.name)) {
results.push(fullPath);
}
}
return results;
}
/**
* Check whether a path is a YAML file.
*
* @param {string} filePath File path.
* @returns {boolean} True for YAML files.
*/
function isYamlPath(filePath) {
return filePath.endsWith(".yaml") || filePath.endsWith(".yml");
}
/**
* Resolve the first workspace root.
*
* @returns {vscode.WorkspaceFolder | undefined} Workspace root.
*/
function getWorkspaceRoot() {
const folders = vscode.workspace.workspaceFolders;
return folders && folders.length > 0 ? folders[0] : undefined;
}
/**
* Resolve the configured config root.
*
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {vscode.Uri | undefined} Config root URI.
*/
function getConfigRoot(workspaceRoot) {
const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
.get("configPath", "config");
return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
}
/**
* Resolve the configured schemas root.
*
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {vscode.Uri | undefined} Schema root URI.
*/
function getSchemasRoot(workspaceRoot) {
const relativePath = vscode.workspace.getConfiguration("gframeworkConfig")
.get("schemasPath", "schemas");
return vscode.Uri.joinPath(workspaceRoot.uri, relativePath);
}
/**
* Resolve the matching schema URI for a config file.
*
* @param {vscode.Uri} configUri Config file URI.
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {vscode.Uri | undefined} Schema URI.
*/
function getSchemaUriForConfigFile(configUri, workspaceRoot) {
const configRoot = getConfigRoot(workspaceRoot);
const schemaRoot = getSchemasRoot(workspaceRoot);
if (!configRoot || !schemaRoot) {
return undefined;
}
const relativePath = path.relative(configRoot.fsPath, configUri.fsPath);
const segments = relativePath.split(path.sep);
if (segments.length === 0 || !segments[0]) {
return undefined;
}
return vscode.Uri.joinPath(schemaRoot, `${segments[0]}.schema.json`);
}
/**
* Check whether a URI is inside the configured config root.
*
* @param {vscode.Uri} uri File URI.
* @param {vscode.WorkspaceFolder} workspaceRoot Workspace root.
* @returns {boolean} True when the file belongs to the config tree.
*/
function isConfigFile(uri, workspaceRoot) {
const configRoot = getConfigRoot(workspaceRoot);
if (!configRoot) {
return false;
}
const relativePath = path.relative(configRoot.fsPath, uri.fsPath);
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath) && isYamlPath(uri.fsPath);
}
/**
* Escape HTML text.
*
* @param {string} value Raw string.
* @returns {string} Escaped string.
*/
function escapeHtml(value) {
return String(value)
.replace(/&/gu, "&amp;")
.replace(/</gu, "&lt;")
.replace(/>/gu, "&gt;")
.replace(/"/gu, "&quot;")
.replace(/'/gu, "&#39;");
}
/**
* Convert raw textarea payloads into scalar-array items.
*
* @param {Record<string, string>} arrays Raw array editor payload.
* @returns {Record<string, string[]>} Parsed array updates.
*/
function parseArrayFieldPayload(arrays) {
const parsed = {};
for (const [key, value] of Object.entries(arrays)) {
parsed[key] = String(value)
.split(/\r?\n/u)
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
return parsed;
}
module.exports = {
activate,
deactivate
};