mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 20:09:00 +08:00
feat(vscode): 添加 GFramework 配置工具扩展
- 实现配置文件浏览器功能,支持工作区 config 目录下的 YAML 文件浏览 - 添加配置文件验证功能,支持基于 JSON Schema 的轻量级验证 - 提供表单预览界面,支持顶层标量字段的编辑功能 - 实现配置文件与匹配模式文件的快速打开功能 - 添加工作区设置选项,可自定义配置和模式目录路径 - 支持实时保存和验证反馈,集成 VSCode 诊断集合显示错误警告
This commit is contained in:
parent
c9d2306295
commit
9972788c32
23
tools/vscode-config-extension/README.md
Normal file
23
tools/vscode-config-extension/README.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# GFramework Config Tools
|
||||||
|
|
||||||
|
Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
|
||||||
|
|
||||||
|
## Current MVP
|
||||||
|
|
||||||
|
- Browse config files from the workspace `config/` directory
|
||||||
|
- Open raw YAML files
|
||||||
|
- Open matching schema files from `schemas/`
|
||||||
|
- Run lightweight schema validation for required fields and simple scalar types
|
||||||
|
- Open a lightweight form preview for top-level scalar fields
|
||||||
|
|
||||||
|
## Current Constraints
|
||||||
|
|
||||||
|
- Multi-root workspaces use the first workspace folder
|
||||||
|
- Validation only covers a minimal subset of JSON Schema
|
||||||
|
- Form editing currently supports top-level scalar fields only
|
||||||
|
- Arrays and nested objects should still be edited in raw YAML
|
||||||
|
|
||||||
|
## Workspace Settings
|
||||||
|
|
||||||
|
- `gframeworkConfig.configPath`
|
||||||
|
- `gframeworkConfig.schemasPath`
|
||||||
101
tools/vscode-config-extension/package.json
Normal file
101
tools/vscode-config-extension/package.json
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "gframework-config-extension",
|
||||||
|
"displayName": "GFramework Config Tools",
|
||||||
|
"description": "Workspace tools for browsing, validating, and editing AI-First config files in GFramework projects.",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"publisher": "gewuyou",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.90.0"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"activationEvents": [
|
||||||
|
"onView:gframeworkConfigExplorer",
|
||||||
|
"onCommand:gframeworkConfig.refresh",
|
||||||
|
"onCommand:gframeworkConfig.openRaw",
|
||||||
|
"onCommand:gframeworkConfig.openSchema",
|
||||||
|
"onCommand:gframeworkConfig.openFormPreview",
|
||||||
|
"onCommand:gframeworkConfig.validateAll"
|
||||||
|
],
|
||||||
|
"main": "./src/extension.js",
|
||||||
|
"contributes": {
|
||||||
|
"views": {
|
||||||
|
"explorer": [
|
||||||
|
{
|
||||||
|
"id": "gframeworkConfigExplorer",
|
||||||
|
"name": "GFramework Config"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.refresh",
|
||||||
|
"title": "GFramework Config: Refresh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openRaw",
|
||||||
|
"title": "GFramework Config: Open Raw File"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openSchema",
|
||||||
|
"title": "GFramework Config: Open Schema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openFormPreview",
|
||||||
|
"title": "GFramework Config: Open Form Preview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.validateAll",
|
||||||
|
"title": "GFramework Config: Validate All"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"menus": {
|
||||||
|
"view/title": [
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.refresh",
|
||||||
|
"when": "view == gframeworkConfigExplorer",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.validateAll",
|
||||||
|
"when": "view == gframeworkConfigExplorer",
|
||||||
|
"group": "navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view/item/context": [
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openRaw",
|
||||||
|
"when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile",
|
||||||
|
"group": "inline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openSchema",
|
||||||
|
"when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "gframeworkConfig.openFormPreview",
|
||||||
|
"when": "view == gframeworkConfigExplorer && viewItem == gframeworkConfigFile",
|
||||||
|
"group": "navigation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"title": "GFramework Config",
|
||||||
|
"properties": {
|
||||||
|
"gframeworkConfig.configPath": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "config",
|
||||||
|
"description": "Relative path from the workspace root to the config directory."
|
||||||
|
},
|
||||||
|
"gframeworkConfig.schemasPath": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "schemas",
|
||||||
|
"description": "Relative path from the workspace root to the schema directory."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
807
tools/vscode-config-extension/src/extension.js
Normal file
807
tools/vscode-config-extension/src/extension.js
Normal file
@ -0,0 +1,807 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const vscode = require("vscode");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the GFramework config extension.
|
||||||
|
* The initial MVP focuses on workspace file navigation, lightweight validation,
|
||||||
|
* and a small form-preview entry for top-level scalar values.
|
||||||
|
*
|
||||||
|
* @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.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(
|
||||||
|
"No config directory",
|
||||||
|
"info",
|
||||||
|
vscode.TreeItemCollapsibleState.None,
|
||||||
|
undefined,
|
||||||
|
"Set gframeworkConfig.configPath or create the directory.")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
? "schema"
|
||||||
|
: "schema missing";
|
||||||
|
const item = new ConfigTreeItem(
|
||||||
|
entry.name,
|
||||||
|
"file",
|
||||||
|
vscode.TreeItemCollapsibleState.None,
|
||||||
|
fileUri,
|
||||||
|
description);
|
||||||
|
|
||||||
|
item.contextValue = "gframeworkConfigFile";
|
||||||
|
item.command = {
|
||||||
|
command: "gframeworkConfig.openRaw",
|
||||||
|
title: "Open Raw",
|
||||||
|
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("Matching schema file was not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await vscode.workspace.openTextDocument(schemaUri);
|
||||||
|
await vscode.window.showTextDocument(document, {preview: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a lightweight form preview for top-level scalar fields.
|
||||||
|
* The editor intentionally edits only simple scalar keys and keeps raw YAML as
|
||||||
|
* the escape hatch for arrays, nested objects, and advanced changes.
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
|
||||||
|
const parsedYaml = parseTopLevelYaml(yamlText);
|
||||||
|
const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot);
|
||||||
|
|
||||||
|
const panel = vscode.window.createWebviewPanel(
|
||||||
|
"gframeworkConfigFormPreview",
|
||||||
|
`Config Form: ${path.basename(configUri.fsPath)}`,
|
||||||
|
vscode.ViewColumn.Beside,
|
||||||
|
{enableScripts: true});
|
||||||
|
|
||||||
|
panel.webview.html = renderFormHtml(
|
||||||
|
path.basename(configUri.fsPath),
|
||||||
|
schemaInfo,
|
||||||
|
parsedYaml);
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(async (message) => {
|
||||||
|
if (message.type === "save") {
|
||||||
|
const updatedYaml = applyScalarUpdates(yamlText, message.values || {});
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "openRaw") {
|
||||||
|
await openRawFile({resourceUri: configUri});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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),
|
||||||
|
`Matching schema file not found: ${schemaInfo.schemaPath}`,
|
||||||
|
vscode.DiagnosticSeverity.Warning));
|
||||||
|
diagnostics.set(configUri, fileDiagnostics);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredProperty of schemaInfo.required) {
|
||||||
|
if (!parsedYaml.keys.has(requiredProperty)) {
|
||||||
|
fileDiagnostics.push(new vscode.Diagnostic(
|
||||||
|
new vscode.Range(0, 0, 0, 1),
|
||||||
|
`Required property '${requiredProperty}' is missing.`,
|
||||||
|
vscode.DiagnosticSeverity.Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [propertyName, expectedType] of Object.entries(schemaInfo.propertyTypes)) {
|
||||||
|
if (!parsedYaml.scalars.has(propertyName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scalarValue = parsedYaml.scalars.get(propertyName);
|
||||||
|
if (!isScalarCompatible(expectedType, scalarValue)) {
|
||||||
|
fileDiagnostics.push(new vscode.Diagnostic(
|
||||||
|
new vscode.Range(0, 0, 0, 1),
|
||||||
|
`Property '${propertyName}' is expected to be '${expectedType}', but the current scalar value is incompatible.`,
|
||||||
|
vscode.DiagnosticSeverity.Warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.set(configUri, fileDiagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[], propertyTypes: Record<string, 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: [],
|
||||||
|
propertyTypes: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.promises.readFile(schemaUri.fsPath, "utf8");
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
const required = Array.isArray(parsed.required)
|
||||||
|
? parsed.required.filter((value) => typeof value === "string")
|
||||||
|
: [];
|
||||||
|
const propertyTypes = {};
|
||||||
|
const properties = parsed.properties || {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value.type === "string") {
|
||||||
|
propertyTypes[key] = value.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
schemaPath,
|
||||||
|
required,
|
||||||
|
propertyTypes
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
schemaPath,
|
||||||
|
required: [],
|
||||||
|
propertyTypes: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse top-level YAML keys and scalar values.
|
||||||
|
* This intentionally supports only the MVP subset needed for lightweight form
|
||||||
|
* preview and validation.
|
||||||
|
*
|
||||||
|
* @param {string} text YAML text.
|
||||||
|
* @returns {{keys: Set<string>, scalars: Map<string, string>}} Parsed shape.
|
||||||
|
*/
|
||||||
|
function parseTopLevelYaml(text) {
|
||||||
|
const keys = new Set();
|
||||||
|
const scalars = new Map();
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line || line.trim().length === 0 || line.trim().startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\s/u.test(line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = match[1];
|
||||||
|
const rawValue = match[2] || "";
|
||||||
|
keys.add(key);
|
||||||
|
|
||||||
|
if (rawValue.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawValue.startsWith("|") || rawValue.startsWith(">")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scalars.set(key, rawValue.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {keys, scalars};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply scalar field updates back into the original YAML text.
|
||||||
|
*
|
||||||
|
* @param {string} originalYaml Original YAML content.
|
||||||
|
* @param {Record<string, string>} updates Updated scalar values.
|
||||||
|
* @returns {string} Updated YAML content.
|
||||||
|
*/
|
||||||
|
function applyScalarUpdates(originalYaml, updates) {
|
||||||
|
const lines = originalYaml.split(/\r?\n/u);
|
||||||
|
const touched = new Set();
|
||||||
|
|
||||||
|
const updatedLines = lines.map((line) => {
|
||||||
|
if (/^\s/u.test(line)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = /^([A-Za-z0-9_]+):(?:\s*(.*))?$/u.exec(line);
|
||||||
|
if (!match) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = match[1];
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(updates, key)) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
touched.add(key);
|
||||||
|
return `${key}: ${formatYamlScalar(updates[key])}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (touched.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedLines.push(`${key}: ${formatYamlScalar(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedLines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the form-preview webview HTML.
|
||||||
|
*
|
||||||
|
* @param {string} fileName File name.
|
||||||
|
* @param {{exists: boolean, schemaPath: string, required: string[], propertyTypes: Record<string, string>}} schemaInfo Schema info.
|
||||||
|
* @param {{keys: Set<string>, scalars: Map<string, string>}} parsedYaml Parsed YAML data.
|
||||||
|
* @returns {string} HTML string.
|
||||||
|
*/
|
||||||
|
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||||
|
const fields = Array.from(parsedYaml.scalars.entries())
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const escapedKey = escapeHtml(key);
|
||||||
|
const escapedValue = escapeHtml(unquoteScalar(value));
|
||||||
|
const required = schemaInfo.required.includes(key) ? "<span class=\"badge\">required</span>" : "";
|
||||||
|
return `
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">${escapedKey} ${required}</span>
|
||||||
|
<input data-key="${escapedKey}" value="${escapedValue}" />
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const schemaStatus = schemaInfo.exists
|
||||||
|
? `Schema: ${escapeHtml(schemaInfo.schemaPath)}`
|
||||||
|
: `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`;
|
||||||
|
|
||||||
|
const emptyState = fields.length > 0
|
||||||
|
? fields
|
||||||
|
: "<p>No editable top-level scalar fields were detected. Use raw YAML for nested objects or arrays.</p>";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="save">Save Scalars</button>
|
||||||
|
<button id="openRaw">Open Raw YAML</button>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div>File: ${escapeHtml(fileName)}</div>
|
||||||
|
<div>${schemaStatus}</div>
|
||||||
|
</div>
|
||||||
|
<div id="fields">${emptyState}</div>
|
||||||
|
<script>
|
||||||
|
const vscode = acquireVsCodeApi();
|
||||||
|
document.getElementById("save").addEventListener("click", () => {
|
||||||
|
const values = {};
|
||||||
|
for (const input of document.querySelectorAll("input[data-key]")) {
|
||||||
|
values[input.dataset.key] = input.value;
|
||||||
|
}
|
||||||
|
vscode.postMessage({ type: "save", values });
|
||||||
|
});
|
||||||
|
document.getElementById("openRaw").addEventListener("click", () => {
|
||||||
|
vscode.postMessage({ type: "openRaw" });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a scalar value matches a minimal schema type.
|
||||||
|
*
|
||||||
|
* @param {string} expectedType Schema type.
|
||||||
|
* @param {string} scalarValue YAML scalar value.
|
||||||
|
* @returns {boolean} True when compatible.
|
||||||
|
*/
|
||||||
|
function isScalarCompatible(expectedType, scalarValue) {
|
||||||
|
const value = unquoteScalar(scalarValue);
|
||||||
|
switch (expectedType) {
|
||||||
|
case "integer":
|
||||||
|
return /^-?\d+$/u.test(value);
|
||||||
|
case "number":
|
||||||
|
return /^-?\d+(?:\.\d+)?$/u.test(value);
|
||||||
|
case "boolean":
|
||||||
|
return /^(true|false)$/iu.test(value);
|
||||||
|
case "string":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a scalar value for YAML output.
|
||||||
|
*
|
||||||
|
* @param {string} value Scalar value.
|
||||||
|
* @returns {string} YAML-ready scalar.
|
||||||
|
*/
|
||||||
|
function formatYamlScalar(value) {
|
||||||
|
if (/^-?\d+(?:\.\d+)?$/u.test(value) || /^(true|false)$/iu.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length === 0 || /[:#\[\]\{\},]|^\s|\s$/u.test(value)) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a simple YAML string quote wrapper.
|
||||||
|
*
|
||||||
|
* @param {string} value Scalar value.
|
||||||
|
* @returns {string} Unquoted value.
|
||||||
|
*/
|
||||||
|
function unquoteScalar(value) {
|
||||||
|
if ((value.startsWith("\"") && value.endsWith("\"")) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
return value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, "&")
|
||||||
|
.replace(/</gu, "<")
|
||||||
|
.replace(/>/gu, ">")
|
||||||
|
.replace(/"/gu, """)
|
||||||
|
.replace(/'/gu, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
activate,
|
||||||
|
deactivate
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user