feat: 方法级代码审查 + 模板导入/预览增强 + SQLFluff 方言 + AI 空响应报错修复

- 方法级审查:CodeLens 触发 + 单次 AI 调用(规则匹配 + 6 维度深度审查),新增 method-extractor / status-cache / codeLensProvider
- 模板导入:severity 保留原始值 + 占位 id、去重对照统一 known-rules、重复提示条双语翻译、箭头展开/折叠 UI、520 条静态规则补 zh/ja 翻译
- SQL:sql-lint 重命名 sqlfluff + sqlfluff.dialect 方言可配置 + 默认方言调整
- ESLint:v9 flat config 接线修复(overrideConfigFile)+ legacy 迁移提示
- AI:空响应 EmptyContentError + 重试一次 + max_tokens 截断专用报错
- JSP:整文件检查走 PMD JSP 规则集 + scriptlet 包装解析 + 行号映射
- 诊断按 severity + 行号排序
This commit is contained in:
范智鹏
2026-08-03 22:53:20 +08:00
parent 5848eaa82a
commit 247f19fd44
46 changed files with 6143 additions and 937 deletions
+636 -140
View File
@@ -1,9 +1,13 @@
import * as vscode from 'vscode';
import type { ConversionResult, PreviewDecision, ImportableRule } from './import-types';
import { t } from '../i18n/messages';
import type { ConversionResult, PreviewDecision, ImportableRule, ValidationIssue } from './import-types';
import { dedupSingleRule } from './import-service';
import { loadActiveRules } from './yaml-parser';
import staticRules from './static-rules.json';
import { t, getLanguage } from '../i18n/messages';
export async function showImportPreview(
result: ConversionResult,
context: vscode.ExtensionContext,
): Promise<PreviewDecision | null> {
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
@@ -22,9 +26,11 @@ export async function showImportPreview(
panel.webview.html = renderPreviewHtml(result, keepRule);
panel.webview.onDidReceiveMessage((msg) => {
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'toggleRule') {
keepRule[msg.ruleId] = msg.keep;
} else if (msg.type === 'addErrorRule') {
await handleAddErrorRule(msg, result, keepRule, context, panel);
} else if (msg.type === 'confirm') {
resolve({
keepRule,
@@ -42,6 +48,108 @@ export async function showImportPreview(
});
}
interface RuleValidationError {
field: 'id' | 'severity' | 'description' | 'message';
message: string;
}
function validateRule(rule: ImportableRule): RuleValidationError | null {
if (!rule.id || !rule.id.trim()) {
return { field: 'id', message: t('import.validationIdEmpty') };
}
if (!['error', 'warning', 'info'].includes(rule.severity)) {
return { field: 'severity', message: t('import.validationSeverityInvalid') };
}
if (!rule.description || !rule.description.trim()) {
return { field: 'description', message: t('import.validationDescEmpty', { 0: rule.id }) };
}
if (!rule.message || !rule.message.trim()) {
return { field: 'message', message: t('import.validationMsgEmpty', { 0: rule.id }) };
}
return null;
}
async function handleAddErrorRule(
msg: {
ruleId: string;
rule: ImportableRule;
},
result: ConversionResult,
keepRule: Record<string, boolean>,
context: vscode.ExtensionContext,
panel: vscode.WebviewPanel,
): Promise<void> {
const rule = msg.rule;
const validationError = validateRule(rule);
if (validationError) {
panel.webview.postMessage({
type: 'addError',
ruleId: msg.ruleId,
field: validationError.field,
message: validationError.message,
});
return;
}
const original = result.rules.find(r => r.id === msg.ruleId);
if (original?.idPlaceholder && rule.id === msg.ruleId) {
panel.webview.postMessage({
type: 'addError',
ruleId: msg.ruleId,
field: 'id',
message: t('import.idMissing'),
});
return;
}
const conflict = result.rules.some(r =>
!r.validationIssues?.length && r.id.toLowerCase() === rule.id.toLowerCase()
);
if (conflict) {
panel.webview.postMessage({
type: 'addError',
ruleId: msg.ruleId,
field: 'id',
message: t('import.idConflict', { 0: rule.id }),
});
return;
}
const dedup = await dedupSingleRule(rule, context);
const level = dedup?.duplicateLevel ?? 'none';
const dedupFailed = !dedup;
const idx = result.rules.findIndex(r => r.id === msg.ruleId);
if (idx >= 0) {
result.rules[idx] = {
...result.rules[idx],
id: rule.id,
severity: rule.severity,
description: rule.description,
message: rule.message,
languages: rule.languages,
excludeLanguages: rule.excludeLanguages,
duplicateLevel: level,
duplicateOf: dedup?.duplicateOf,
duplicateReason: dedup?.duplicateReason,
validationIssues: undefined,
};
}
keepRule[rule.id] = level !== 'exact';
panel.webview.postMessage({
type: 'ruleAdded',
ruleId: msg.ruleId,
id: rule.id,
duplicateLevel: level,
duplicateOf: dedup?.duplicateOf,
duplicateReason: dedup?.duplicateReason,
dedupFailed,
});
}
const SEVERITY_OPTIONS = ['error', 'warning', 'info'];
const SEVERITY_COLORS: Record<string, string> = {
error: '#f48771',
@@ -69,92 +177,151 @@ function renderPreviewHtml(
? `<div class="summary-bar" style="border-color:rgba(88,166,255,0.3);color:#58a6ff;">${t('import.template.skipped', { 0: String(result.skippedCount) })}</div>`
: '';
const hasValidRules = cleanRules.length > 0;
const emptyValidHint = !hasValidRules
? `<div class="validation-error" style="display:block;">${t('import.emptyValidRules')}</div>`
: '';
function dupLabel(dupOf: string | undefined): string {
return dupOf?.startsWith('custom/')
? `${t('import.customRulePrefix')} ${dupOf.slice(7)}`
: (dupOf ?? 'unknown');
}
const confirmBtnAttrs = hasValidRules
? 'onclick="doConfirm()"'
: 'disabled style="opacity:0.5;cursor:not-allowed;"';
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const customRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
function renderRuleCard(rule: ImportableRule): string {
function resolveDupDescription(dupOf: string | undefined): string | undefined {
if (!dupOf) { return undefined; }
if (dupOf.startsWith('custom/')) {
const id = dupOf.slice(7);
return customRules.find(r => r.id === id)?.description;
}
const slash = dupOf.indexOf('/');
const linter = slash > 0 ? dupOf.slice(0, slash) : '';
const linterRules = (staticRules.rules as Record<string, Array<{ id: string; description: string; descriptionZh?: string; descriptionJa?: string }>>)[linter];
const rule = linterRules?.find(r => r.id === dupOf);
if (!rule) { return undefined; }
const lang = getLanguage();
if (lang === 'zh-CN' && rule.descriptionZh) {
return `${rule.description} (${rule.descriptionZh})`;
}
if (lang === 'ja' && rule.descriptionJa) {
return `${rule.description} (${rule.descriptionJa})`;
}
return rule.description;
}
function renderRuleCard(rule: ImportableRule, isError = false): string {
const kept = keepRule[rule.id];
const color = SEVERITY_COLORS[rule.severity] || '#8b949e';
const editRule = rule;
const sevIssue = !!rule.validationIssues?.some(i => i.field === 'severity');
const color = sevIssue ? '#f48771' : (SEVERITY_COLORS[rule.severity] || '#8b949e');
let duplicateInfo = '';
if (rule.duplicateLevel === 'exact') {
const prefix = rule.duplicateOf?.startsWith('custom/') ? `${t('import.customRulePrefix')} ${rule.duplicateOf.slice(7)}` : (rule.duplicateOf ?? 'unknown');
duplicateInfo = `<div style="color:#8b949e;font-size:12px;margin-top:4px;">${t('import.duplicateOf', { 0: prefix })}</div>`;
} else if (rule.duplicateLevel === 'overlap') {
const prefix = rule.duplicateOf?.startsWith('custom/') ? `${t('import.customRulePrefix')} ${rule.duplicateOf.slice(7)}` : (rule.duplicateOf ?? 'unknown');
duplicateInfo = `
<div style="color:#d29922;font-size:12px;margin-top:4px;">${t('import.overlapWith', { 0: prefix })}</div>
${rule.duplicateReason ? `<div style="color:#8b949e;font-size:12px;margin-top:2px;">${t('import.overlapReason', { 0: rule.duplicateReason })}</div>` : ''}
`;
let statusBadge = '';
if (!isError) {
if (rule.duplicateLevel === 'exact') {
const dupDesc = resolveDupDescription(rule.duplicateOf);
duplicateInfo = `
<div class="dup-banner dup-exact">
<div class="dup-banner-title">${t('import.dupExactTitle')}</div>
<div class="dup-banner-text">${t('import.dupExactText', { 0: dupLabel(rule.duplicateOf) })}</div>
${dupDesc ? `<div class="dup-banner-desc">${t('import.dupDescriptionLabel', { 0: dupDesc })}</div>` : ''}
<div class="dup-banner-hint">${t('import.dupExactHint')}</div>
</div>
`;
statusBadge = `<span class="badge badge-exact">${kept ? t('import.badgeRestored') : t('import.badgeWillComment')}</span>`;
} else if (rule.duplicateLevel === 'overlap') {
const dupDesc = resolveDupDescription(rule.duplicateOf);
duplicateInfo = `
<div class="dup-banner dup-overlap">
<div class="dup-banner-title">${t('import.dupOverlapTitle')}</div>
<div class="dup-banner-text">${t('import.dupOverlapText', { 0: dupLabel(rule.duplicateOf) })}</div>
${dupDesc ? `<div class="dup-banner-desc">${t('import.dupDescriptionLabel', { 0: dupDesc })}</div>` : ''}
${rule.duplicateReason ? `<div class="dup-banner-reason">${t('import.overlapReason', { 0: rule.duplicateReason })}</div>` : ''}
</div>
`;
statusBadge = `<span class="badge badge-overlap">${kept ? t('importPreview.keep') : t('importPreview.comment')}</span>`;
} else {
statusBadge = `<span class="badge badge-none">${t('importPreview.keep')}</span>`;
}
} else {
statusBadge = `<span class="badge" style="background:rgba(248,81,73,0.15);color:#f48771;">${t('import.cannotImport')}</span>`;
}
const statusBadge = rule.duplicateLevel === 'exact'
? `<span class="badge badge-exact">${kept ? t('import.badgeRestored') : t('import.badgeWillComment')}</span>`
: rule.duplicateLevel === 'overlap'
? `<span class="badge badge-overlap">${kept ? t('importPreview.keep') : t('importPreview.comment')}</span>`
: `<span class="badge badge-none">${t('importPreview.keep')}</span>`;
const tagDisplay = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.map(tag => `<span class="tag" data-value="${tag}">${tag}<span class="tag-remove" data-tag="${tag}">×</span></span>`).join('') : '';
const tagValue = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.join(',') : '';
const tagDisplay = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.map(t => `<span class="tag" data-value="${t}">${t}<span class="tag-remove" data-tag="${t}">×</span></span>`).join('') : '';
const issueByField = new Map<string, ValidationIssue>();
if (isError) {
for (const i of rule.validationIssues || []) {
if (!issueByField.has(i.field)) {
issueByField.set(i.field, i);
}
}
}
const issueCls = (field: string) => issueByField.has(field) ? ' field-error' : '';
const inputCls = (field: string) => issueByField.has(field) ? 'field-error-input ' : '';
const issueMsg = (field: string) => issueByField.has(field)
? `<div class="field-error-msg">${t('import.issuePrefix')} ${issueByField.get(field)!.message}</div>`
: '';
const actionArea = isError
? `<button class="add-btn" data-addbtn="${rule.id}" onclick="event.stopPropagation();addErrorRule(this)">${t('import.add')}</button>`
: `<div class="keep-toggle">
<button class="toggle-btn ${kept ? 'active' : ''}" data-action="keep" onclick="event.stopPropagation();toggleKeep(this, true)">${t('importPreview.keep')}</button>
<button class="toggle-btn ${!kept ? 'active' : ''}" data-action="comment" onclick="event.stopPropagation();toggleKeep(this, false)">${t('importPreview.comment')}</button>
</div>`;
const bodyDisplay = isError ? 'block' : 'none';
const expandIcon = '▼';
return `
<div class="rule-card" data-ruleid="${rule.id}">
<div class="rule-card-header" onclick="toggleCard('${rule.id}')">
<div class="rule-card${isError ? ' expanded' : ''}" data-ruleid="${rule.id}"${isError ? ' data-error="true"' : ''}>
<div class="rule-card-header" onclick="toggleCard(this)">
<div class="rule-card-summary">
<input class="rule-id-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId('${rule.id}', this.value)" onclick="event.stopPropagation()">
<span class="rule-severity-tag" style="background:${color}20;color:${color};border:1px solid ${color}40;">${editRule.severity}</span>
<span class="rule-desc-preview">${editRule.description}</span>
<input class="rule-id-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId(this)" onclick="event.stopPropagation()">
<span class="rule-severity-tag" style="background:${color}20;color:${color};border:1px solid ${color}40;">${sevIssue ? (rule.originalSeverity || t('import.severityMissing')) : rule.severity}</span>
<span class="rule-desc-preview">${rule.description}</span>
</div>
<div class="rule-card-meta">
${statusBadge}
<span class="expand-icon"></span>
<span class="expand-icon">${expandIcon}</span>
</div>
</div>
<div class="rule-card-body" id="body-${rule.id}" style="display:none;">
<div class="rule-card-body" id="body-${rule.id}" style="display:${bodyDisplay};">
<div class="edit-header">
<div class="id-row">
<span class="id-display-label">${t('import.idLabel')}</span>
<input class="id-display-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId('${rule.id}', this.value)">
<input class="id-display-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId(this)">
${/^rule-\d+$/.test(rule.id) ? `<span class="placeholder-hint">${t('import.placeholderIdHint')}</span>` : ''}
</div>
<div class="keep-toggle">
<button class="toggle-btn ${kept ? 'active' : ''}" data-action="keep" onclick="event.stopPropagation();toggleKeep('${rule.id}', true)">${t('importPreview.keep')}</button>
<button class="toggle-btn ${!kept ? 'active' : ''}" data-action="comment" onclick="event.stopPropagation();toggleKeep('${rule.id}', false)">${t('importPreview.comment')}</button>
</div>
${actionArea}
</div>
${duplicateInfo}
<div class="edit-field">
<div class="edit-field${issueCls('severity')}">
<label>${t('import.severityLabel')}</label>
<select onchange="updateRule('${rule.id}','severity',this.value)">
${SEVERITY_OPTIONS.map(s => `<option value="${s}" ${s === editRule.severity ? 'selected' : ''}>${s}</option>`).join('')}
<select class="${inputCls('severity')}" onchange="updateRule(this,'severity',this.value)">
${sevIssue ? `<option value="" disabled selected>${t('import.severitySelectHint')}</option>` : ''}
${SEVERITY_OPTIONS.map(s => `<option value="${s}" ${!sevIssue && s === rule.severity ? 'selected' : ''}>${s}</option>`).join('')}
</select>
${issueMsg('severity')}
</div>
<div class="edit-field">
<div class="edit-field${issueCls('description')}">
<label>${t('import.descriptionLabel')}</label>
<textarea rows="2" onchange="updateRule('${rule.id}','description',this.value)">${editRule.description}</textarea>
<textarea rows="2" class="${inputCls('description')}" onchange="updateRule(this,'description',this.value)">${rule.description}</textarea>
${issueMsg('description')}
</div>
<div class="edit-field">
<div class="edit-field${issueCls('message')}">
<label>${t('import.messageLabel')}</label>
<textarea rows="2" onchange="updateRule('${rule.id}','message',this.value)">${editRule.message}</textarea>
<textarea rows="2" class="${inputCls('message')}" onchange="updateRule(this,'message',this.value)">${rule.message}</textarea>
${issueMsg('message')}
</div>
<div class="edit-field">
<label>${t('import.languagesLabel')}</label>
<div class="tag-input-wrapper">
<div class="tag-list" data-ruleid="${rule.id}" data-field="languages">
${tagDisplay(editRule.languages)}
${tagDisplay(rule.languages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="languages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
@@ -164,7 +331,7 @@ function renderPreviewHtml(
<label>${t('import.excludeLanguagesLabel')}</label>
<div class="tag-input-wrapper">
<div class="tag-list" data-ruleid="${rule.id}" data-field="excludeLanguages">
${tagDisplay(editRule.excludeLanguages)}
${tagDisplay(rule.excludeLanguages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="excludeLanguages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
@@ -174,59 +341,35 @@ function renderPreviewHtml(
`;
}
function renderErrorCard(rule: ImportableRule): string {
const issues = (rule.validationIssues || []).map(i =>
`<div style="color:#f48771;font-size:12px;margin-bottom:4px;">${t('import.issuePrefix')} ${i.message}</div>`
).join('');
return `
<div class="rule-card" data-error="true" style="opacity:0.7;border-color:rgba(248,81,73,0.3);">
<div class="rule-card-header" style="cursor:default;">
<div class="rule-card-summary">
<span style="font-family:monospace;font-size:13px;font-weight:600;">${rule.id}</span>
<span style="color:#f48771;font-size:11px;font-weight:600;">${t('import.cannotImport')}</span>
</div>
</div>
<div class="rule-card-body" style="border-top:1px solid rgba(248,81,73,0.15);padding-top:8px;">
${issues}
<div style="color:#8b949e;font-size:11px;margin-top:6px;">
severity: ${rule.severity} | description: ${rule.description} | message: ${rule.message}
</div>
</div>
</div>
`;
}
function renderErrorSection(rules: ImportableRule[]): string {
if (rules.length === 0) { return ''; }
const sectionId = 'section-error';
return `
<div style="margin-bottom:12px;">
<div class="section-wrapper expanded" data-section-wrap="error" style="margin-bottom:12px;${rules.length === 0 ? 'display:none;' : ''}">
<div class="section-header" onclick="toggleSection('${sectionId}')">
<span style="font-size:14px;">🚫</span>
<span class="section-title">${t('import.sectionInvalid')}${rules.length}</span>
<span class="section-title" data-section-title="error"></span>
<span class="section-arrow">▼</span>
</div>
<div id="${sectionId}">
${rules.map(renderErrorCard).join('')}
<div id="${sectionId}" data-section="error" style="display:block;">
${rules.map(r => renderRuleCard(r, true)).join('')}
</div>
</div>
`;
}
function renderSection(title: string, icon: string, rules: ImportableRule[], _defaultExpanded: boolean): string {
if (rules.length === 0) { return ''; }
const sectionId = `section-${title.replace(/\s/g, '')}`;
function renderSection(title: string, icon: string, key: string, rules: ImportableRule[]): string {
const sectionId = `section-${key}`;
const count = rules.length;
const show = rules.some(r => keepRule[r.id] !== undefined);
return `
<div style="margin-bottom:12px;">
<div class="section-wrapper${show ? ' expanded' : ''}" data-section-wrap="${key}" style="margin-bottom:12px;${count === 0 ? 'display:none;' : ''}">
<div class="section-header" onclick="toggleSection('${sectionId}')">
<span style="font-size:14px;">${icon}</span>
<span class="section-title">${title}${t('import.ruleCount', { 0: rules.length })}</span>
<span class="section-arrow"></span>
<span class="section-title" data-section-title="${key}"></span>
<span class="section-arrow"></span>
</div>
<div id="${sectionId}" style="display:${show ? 'block' : 'none'};">
${rules.map(renderRuleCard).join('')}
<div id="${sectionId}" data-section="${key}" style="display:${show ? 'block' : 'none'};">
${rules.map(r => renderRuleCard(r)).join('')}
</div>
</div>
`;
@@ -265,6 +408,10 @@ body {
font-size: 12px; background: rgba(139,92,246,0.1);
border: 1px solid rgba(139,92,246,0.3); color: #a78bfa;
}
.summary-bar.warn {
border-color: rgba(210,153,34,0.3); color: #d29922;
background: rgba(210,153,34,0.1);
}
.actions {
display: flex; gap: 8px; padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border);
@@ -284,7 +431,13 @@ body {
cursor: pointer; padding: 4px 0;
}
.section-title { font-weight: 600; font-size: 13px; }
.section-arrow { font-size: 10px; color: var(--vscode-descriptionForeground); }
.section-arrow {
display: inline-block;
font-size: 10px;
color: var(--vscode-descriptionForeground);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
.rule-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px; margin-bottom: 8px; overflow: hidden;
@@ -324,7 +477,17 @@ body {
.rule-card-meta {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.expand-icon { font-size: 10px; color: var(--vscode-descriptionForeground); }
.expand-icon {
display: inline-block;
font-size: 10px;
color: var(--vscode-descriptionForeground);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
.rule-card.expanded .expand-icon,
.section-wrapper.expanded .section-arrow {
transform: rotate(0deg);
}
.badge {
padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
}
@@ -339,7 +502,10 @@ body {
padding: 10px 0 8px;
}
.id-row {
display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.id-row .field-error-msg {
flex-basis: 100%; margin-left: 0;
}
.keep-toggle { display: flex; gap: 4px; }
.toggle-btn {
@@ -353,6 +519,20 @@ body {
.toggle-btn.active[data-action="comment"] {
background: rgba(248,81,73,0.15); color: #f48771; border-color: rgba(248,81,73,0.3);
}
.add-btn {
padding: 4px 14px; border-radius: 4px; cursor: pointer; font-size: 11px;
border: 1px solid rgba(35,134,54,0.4);
background: rgba(35,134,54,0.15); color: #3fb950;
}
.add-btn:hover { background: rgba(35,134,54,0.25); }
.add-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.field-error-input {
border-color: rgba(248,81,73,0.7) !important;
box-shadow: 0 0 0 1px rgba(248,81,73,0.25);
}
.field-error-msg {
color: #f48771; font-size: 11px; margin-top: 4px;
}
.edit-field { margin-top: 10px; }
.edit-field label {
display: block; font-size: 11px; font-weight: 600;
@@ -420,6 +600,26 @@ body {
background: rgba(248,81,73,0.15); color: #f48771;
border: 1px solid rgba(248,81,73,0.3); font-size: 12px;
}
.dup-banner {
border-radius: 6px; padding: 8px 12px; margin-top: 10px;
font-size: 12px; line-height: 1.6;
}
.dup-exact {
background: rgba(248,81,73,0.1);
border: 1px solid rgba(248,81,73,0.3);
border-left: 3px solid #f48771;
}
.dup-overlap {
background: rgba(210,153,34,0.1);
border: 1px solid rgba(210,153,34,0.3);
border-left: 3px solid #d29922;
}
.dup-banner-title { font-weight: 600; }
.dup-exact .dup-banner-title { color: #f48771; }
.dup-overlap .dup-banner-title { color: #d29922; }
.dup-banner-desc { color: var(--vscode-foreground); }
.dup-banner-reason { color: #d29922; }
.dup-banner-hint { color: var(--vscode-descriptionForeground); }
</style>
</head>
<body>
@@ -428,40 +628,73 @@ body {
<div class="header-sub">${t('importPreview.source', { 0: result.sourceFileName, 1: String(result.rules.length) })}</div>
</div>
<div class="summary">
<div class="summary-item" style="border-color:rgba(248,81,73,0.3);color:#f48771;">${t('import.exactDuplicate', { 0: exactRules.length })}</div>
<div class="summary-item" style="border-color:rgba(210,153,34,0.3);color:#d29922;">${t('import.overlapDuplicate', { 0: overlapRules.length })}</div>
<div class="summary-item" style="border-color:rgba(35,134,54,0.3);color:#3fb950;">${t('import.noDuplicate', { 0: noneRules.length })}</div>
<div class="summary-item" style="border-color:rgba(248,81,73,0.3);color:#f48771;">${t('import.exactDuplicate', { 0: `<span id="count-exact">${exactRules.length}</span>` })}</div>
<div class="summary-item" style="border-color:rgba(210,153,34,0.3);color:#d29922;">${t('import.overlapDuplicate', { 0: `<span id="count-overlap">${overlapRules.length}</span>` })}</div>
<div class="summary-item" style="border-color:rgba(35,134,54,0.3);color:#3fb950;">${t('import.noDuplicate', { 0: `<span id="count-none">${noneRules.length}</span>` })}</div>
</div>
<div class="summary-bar" id="statusBar">
${t('import.statusBar', { 0: `<b id="keepCount">${totalKept}</b>`, 1: `<b id="commentCount">${totalCommented}</b>` })}
<span id="editHint" style="display:none;">${t('import.editedHint', { 0: '<b id="editCount">0</b>' })}</span>
</div>
<div id="addHint" class="summary-bar warn" style="display:none;"></div>
<div id="validationError" class="validation-error" style="display:none;"></div>
${skippedHint}
${renderErrorSection(errorRules)}
${renderSection(t('import.sectionExact'), '⛔', exactRules, false)}
${renderSection(t('import.sectionOverlap'), '⚠️', overlapRules, true)}
${renderSection(t('import.sectionNone'), '✅', noneRules, false)}
${renderSection(t('import.sectionExact'), '⛔', 'exact', exactRules)}
${renderSection(t('import.sectionOverlap'), '⚠️', 'overlap', overlapRules)}
${renderSection(t('import.sectionNone'), '✅', 'none', noneRules)}
${emptyValidHint}
<div id="emptyValidHint" class="validation-error" style="display:none;">${t('import.emptyValidRules')}</div>
<div class="actions">
<button class="btn" onclick="cancel()">${t('importPreview.cancel')}</button>
<button class="btn btn-primary" ${confirmBtnAttrs}>${t('importPreview.confirm')}</button>
<button class="btn btn-primary" id="confirmBtn" onclick="doConfirm()">${t('importPreview.confirm')}</button>
</div>
<script>
const vscode = acquireVsCodeApi();
const editedRules = {};
let addedRules = 0;
let addingRuleId = null;
const VALIDATION_DESC_EMPTY = ${JSON.stringify(t('import.validationDescEmpty'))};
const VALIDATION_MSG_EMPTY = ${JSON.stringify(t('import.validationMsgEmpty'))};
const VALIDATION_ID_EMPTY = ${JSON.stringify(t('import.validationIdEmpty'))};
const ADD_TEXT = ${JSON.stringify(t('import.add'))};
const ADDING_TEXT = ${JSON.stringify(t('import.adding'))};
const KEEP_TEXT = ${JSON.stringify(t('importPreview.keep'))};
const COMMENT_TEXT = ${JSON.stringify(t('importPreview.comment'))};
const WILL_COMMENT_TEXT = ${JSON.stringify(t('import.badgeWillComment'))};
const DEDUP_FALLBACK_TEXT = ${JSON.stringify(t('import.addDedupFallback'))};
const CUSTOM_PREFIX = ${JSON.stringify(t('import.customRulePrefix'))};
const DUPLICATE_OF_TEXT = ${JSON.stringify(t('import.duplicateOf'))};
const OVERLAP_WITH_TEXT = ${JSON.stringify(t('import.overlapWith'))};
const OVERLAP_REASON_TEXT = ${JSON.stringify(t('import.overlapReason'))};
const RULE_COUNT_TEMPLATE = ${JSON.stringify(t('import.ruleCount'))};
const SECTION_TITLES = {
error: ${JSON.stringify(t('import.sectionInvalid'))},
exact: ${JSON.stringify(t('import.sectionExact'))},
overlap: ${JSON.stringify(t('import.sectionOverlap'))},
none: ${JSON.stringify(t('import.sectionNone'))},
};
function syncId(originalId, newValue) {
const card = document.querySelector('.rule-card[data-ruleid="' + originalId + '"]');
function setSectionTitle(key, count) {
const el = document.querySelector('[data-section-title="' + key + '"]');
if (el) {
el.textContent = SECTION_TITLES[key] + '' + RULE_COUNT_TEMPLATE.replace('{0}', count) + '';
}
}
function fmt(tpl, v) {
return tpl.replace('{0}', v);
}
function syncId(el) {
const card = el.closest('.rule-card');
if (!card) return;
const originalId = card.dataset.ruleid;
const newValue = el.value;
const headerInput = card.querySelector('.rule-id-input');
const panelInput = card.querySelector('.id-display-input');
if (headerInput) headerInput.value = newValue;
@@ -472,48 +705,52 @@ function syncId(originalId, newValue) {
updateEditHint();
const isPlaceholder = /^rule-\\d+$/.test(newValue);
[headerInput, panelInput].forEach(el => {
if (el) {
el.classList.toggle('placeholder-id', isPlaceholder);
[headerInput, panelInput].forEach(input => {
if (input) {
input.classList.toggle('placeholder-id', isPlaceholder);
}
});
}
function toggleCard(ruleId) {
const body = document.getElementById('body-' + ruleId);
const card = body.closest('.rule-card');
const icon = card.querySelector('.expand-icon');
function toggleCard(el) {
const card = el.closest('.rule-card');
if (!card) return;
const body = card.querySelector('.rule-card-body');
if (body.style.display === 'none') {
body.style.display = 'block';
icon.textContent = '▲';
card.classList.add('expanded');
} else {
body.style.display = 'none';
icon.textContent = '▼';
card.classList.remove('expanded');
}
}
function toggleSection(id) {
const el = document.getElementById(id);
const arrow = el.previousElementSibling.querySelector('.section-arrow');
const wrap = el.closest('.section-wrapper');
if (el.style.display === 'none') {
el.style.display = 'block';
arrow.textContent = '▼';
wrap.classList.add('expanded');
} else {
el.style.display = 'none';
arrow.textContent = '▶';
wrap.classList.remove('expanded');
}
}
function toggleKeep(ruleId, keep) {
vscode.postMessage({ type: 'toggleRule', ruleId, keep });
const card = document.querySelector('.rule-card[data-ruleid="' + ruleId + '"]');
function toggleKeep(el, keep) {
const card = el.closest('.rule-card');
if (!card) return;
const ruleId = card.dataset.ruleid;
vscode.postMessage({ type: 'toggleRule', ruleId, keep });
const btns = card.querySelectorAll('.toggle-btn');
btns.forEach(b => b.classList.toggle('active', (keep && b.dataset.action === 'keep') || (!keep && b.dataset.action === 'comment')));
updateSummary();
}
function updateRule(ruleId, field, value) {
function updateRule(el, field, value) {
const card = el.closest('.rule-card');
if (!card) return;
const ruleId = card.dataset.ruleid;
if (!editedRules[ruleId]) {
editedRules[ruleId] = {};
}
@@ -521,34 +758,288 @@ function updateRule(ruleId, field, value) {
updateEditHint();
}
function collectCardRule(card) {
if (!card) return null;
const idInput = card.querySelector('.id-display-input');
const severityEl = card.querySelector('.edit-field select');
const textareas = card.querySelectorAll('.edit-field textarea');
const descEl = textareas[0];
const msgEl = textareas[1];
const langList = card.querySelector('.tag-list[data-field="languages"]');
const exclList = card.querySelector('.tag-list[data-field="excludeLanguages"]');
return {
id: idInput ? idInput.value.trim() : card.dataset.ruleid,
severity: severityEl ? severityEl.value : 'warning',
description: descEl ? descEl.value : '',
message: msgEl ? msgEl.value : '',
languages: langList ? Array.from(langList.querySelectorAll('.tag')).map(tag => tag.dataset.value) : [],
excludeLanguages: exclList ? Array.from(exclList.querySelectorAll('.tag')).map(tag => tag.dataset.value) : [],
};
}
function collectEditedRules() {
const result = [];
document.querySelectorAll('.rule-card').forEach(card => {
if (card.hasAttribute('data-error')) { return; }
const originalId = card.dataset.ruleid;
const idInput = card.querySelector('.id-display-input');
const ruleId = idInput ? idInput.value.trim() || originalId : originalId;
const severityEl = card.querySelector('.edit-field select');
const textareas = card.querySelectorAll('.edit-field textarea');
const descEl = textareas[0];
const msgEl = textareas[1];
const langList = card.querySelector('.tag-list[data-field="languages"]');
const exclList = card.querySelector('.tag-list[data-field="excludeLanguages"]');
const rule = {
id: ruleId,
severity: severityEl ? severityEl.value : 'warning',
description: descEl ? descEl.value : '',
message: msgEl ? msgEl.value : '',
languages: langList ? Array.from(langList.querySelectorAll('.tag')).map(t => t.dataset.value) : [],
excludeLanguages: exclList ? Array.from(exclList.querySelectorAll('.tag')).map(t => t.dataset.value) : [],
};
result.push(rule);
const rule = collectCardRule(card);
if (rule) { result.push(rule); }
});
return result;
}
function addErrorRule(el) {
if (addingRuleId) { return; }
const card = el.closest('.rule-card');
if (!card) return;
const btn = el;
if (btn.disabled) { return; }
btn.disabled = true;
btn.textContent = ADDING_TEXT;
const ruleId = card.dataset.ruleid;
addingRuleId = ruleId;
const rule = collectCardRule(card);
if (!rule) {
addingRuleId = null;
btn.disabled = false;
btn.textContent = ADD_TEXT;
return;
}
vscode.postMessage({ type: 'addErrorRule', ruleId, rule });
}
function fieldElement(card, field) {
if (field === 'id') return card.querySelector('.id-display-input');
if (field === 'severity') return card.querySelector('.edit-field select');
const tas = card.querySelectorAll('.edit-field textarea');
return field === 'description' ? (tas[0] || null) : (tas[1] || null);
}
function setFieldError(card, field, message) {
const el = fieldElement(card, field);
if (!el) return;
el.classList.add('field-error-input');
const wrap = el.closest('.edit-field, .id-row');
if (!wrap) return;
wrap.classList.add('field-error');
let msg = wrap.querySelector('.field-error-msg');
if (!msg) {
msg = document.createElement('div');
msg.className = 'field-error-msg';
wrap.appendChild(msg);
}
msg.textContent = '⚠ ' + message;
}
function clearFieldError(card, field) {
const el = fieldElement(card, field);
if (!el) return;
el.classList.remove('field-error-input');
const wrap = el.closest('.edit-field, .id-row');
if (wrap) {
wrap.classList.remove('field-error');
const msg = wrap.querySelector('.field-error-msg');
if (msg) { msg.remove(); }
}
}
function clearCardFieldErrors(card) {
card.querySelectorAll('.field-error-input').forEach(function (el) {
el.classList.remove('field-error-input');
});
card.querySelectorAll('.field-error').forEach(function (wrap) {
wrap.classList.remove('field-error');
const msg = wrap.querySelector('.field-error-msg');
if (msg) { msg.remove(); }
});
}
function liveClear(event) {
const card = event.target.closest('.rule-card');
if (!card || !card.hasAttribute('data-error')) return;
const target = event.target;
if (target.classList.contains('id-display-input') || target.classList.contains('rule-id-input')) {
if (target.value.trim()) { clearFieldError(card, 'id'); }
} else if (target.tagName === 'SELECT') {
clearFieldError(card, 'severity');
} else if (target.tagName === 'TEXTAREA') {
const tas = card.querySelectorAll('.edit-field textarea');
const field = tas[0] === target ? 'description' : (tas[1] === target ? 'message' : null);
if (field && target.value.trim()) { clearFieldError(card, field); }
}
}
function showCardError(ruleId, field, message) {
const card = document.querySelector('.rule-card[data-ruleid="' + ruleId + '"]');
if (card && field) {
setFieldError(card, field, message);
}
const btn = document.querySelector('[data-addbtn="' + ruleId + '"]');
if (btn) {
btn.disabled = false;
btn.textContent = ADD_TEXT;
}
addingRuleId = null;
}
function dupInfoHtml(level, dupOf, reason) {
const prefix = dupOf && dupOf.startsWith('custom/')
? CUSTOM_PREFIX + ' ' + dupOf.slice(7)
: (dupOf || 'unknown');
if (level === 'exact') {
return '<div style="color:#8b949e;font-size:12px;margin-top:4px;">' + fmt(DUPLICATE_OF_TEXT, prefix) + '</div>';
}
if (level === 'overlap') {
let html = '<div style="color:#d29922;font-size:12px;margin-top:4px;">' + fmt(OVERLAP_WITH_TEXT, prefix) + '</div>';
if (reason) {
html += '<div style="color:#8b949e;font-size:12px;margin-top:2px;">' + fmt(OVERLAP_REASON_TEXT, reason) + '</div>';
}
return html;
}
return '';
}
function moveCardToSection(msg) {
const card = document.querySelector('.rule-card[data-ruleid="' + msg.ruleId + '"]');
if (!card) return;
card.dataset.ruleid = msg.id;
const headerInput = card.querySelector('.rule-id-input');
const panelInput = card.querySelector('.id-display-input');
if (headerInput) headerInput.value = msg.id;
if (panelInput) panelInput.value = msg.id;
const isPlaceholder = /^rule-\\d+$/.test(msg.id);
[headerInput, panelInput].forEach(el => {
if (el) el.classList.toggle('placeholder-id', isPlaceholder);
});
card.removeAttribute('data-error');
clearCardFieldErrors(card);
const addBtn = card.querySelector('.add-btn');
if (addBtn) { addBtn.remove(); }
const kept = msg.duplicateLevel !== 'exact';
const toggle = document.createElement('div');
toggle.className = 'keep-toggle';
function makeToggleBtn(action, active, label) {
const btn = document.createElement('button');
btn.className = 'toggle-btn' + (active ? ' active' : '');
btn.dataset.action = action;
btn.textContent = label;
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
toggleKeep(this, action === 'keep');
});
return btn;
}
toggle.appendChild(makeToggleBtn('keep', kept, KEEP_TEXT));
toggle.appendChild(makeToggleBtn('comment', !kept, COMMENT_TEXT));
card.querySelector('.edit-header').appendChild(toggle);
const meta = card.querySelector('.rule-card-meta');
const oldBadge = meta.querySelector('.badge');
if (oldBadge) { oldBadge.remove(); }
const badge = document.createElement('span');
badge.className = 'badge';
if (msg.duplicateLevel === 'exact') {
badge.classList.add('badge-exact');
badge.textContent = WILL_COMMENT_TEXT;
} else if (msg.duplicateLevel === 'overlap') {
badge.classList.add('badge-overlap');
badge.textContent = KEEP_TEXT;
} else {
badge.classList.add('badge-none');
badge.textContent = KEEP_TEXT;
}
const icon = meta.querySelector('.expand-icon');
meta.insertBefore(badge, icon);
const body = card.querySelector('.rule-card-body');
const infoHtml = dupInfoHtml(msg.duplicateLevel, msg.duplicateOf, msg.duplicateReason);
if (infoHtml) {
const infoDiv = document.createElement('div');
infoDiv.innerHTML = infoHtml;
const firstField = body.querySelector('.edit-field');
body.insertBefore(infoDiv, firstField);
}
const section = msg.duplicateLevel === 'exact'
? 'exact'
: (msg.duplicateLevel === 'overlap' ? 'overlap' : 'none');
const target = document.querySelector('[data-section="' + section + '"]');
if (target) {
const wrap = target.closest('[data-section-wrap]');
if (wrap) {
wrap.style.display = '';
wrap.classList.add('expanded');
}
target.style.display = 'block';
target.appendChild(card);
}
addedRules++;
if (msg.dedupFailed) {
showAddHint(DEDUP_FALLBACK_TEXT);
}
updateSectionCounts();
updateSummary();
updateEditHint();
}
function showAddHint(text) {
const el = document.getElementById('addHint');
el.textContent = text;
el.style.display = 'block';
setTimeout(function () { el.style.display = 'none'; }, 5000);
}
function updateSectionCounts() {
const sections = ['error', 'exact', 'overlap', 'none'];
const counts = {};
let totalValid = 0;
for (const key of sections) {
const container = document.querySelector('[data-section="' + key + '"]');
const count = container ? container.querySelectorAll('.rule-card').length : 0;
counts[key] = count;
if (key !== 'error') { totalValid += count; }
setSectionTitle(key, count);
if (container) {
const wrap = container.closest('[data-section-wrap]');
if (wrap) { wrap.style.display = count > 0 ? '' : 'none'; }
}
}
document.getElementById('count-exact').textContent = counts.exact;
document.getElementById('count-overlap').textContent = counts.overlap;
document.getElementById('count-none').textContent = counts.none;
const btn = document.getElementById('confirmBtn');
const hint = document.getElementById('emptyValidHint');
if (totalValid === 0) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
hint.style.display = 'block';
} else {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
hint.style.display = 'none';
}
}
window.addEventListener('message', function (e) {
const msg = e.data;
if (!msg) { return; }
if (msg.type === 'addError') {
showCardError(msg.ruleId, msg.field, msg.message);
} else if (msg.type === 'ruleAdded') {
moveCardToSection(msg);
}
});
function validate() {
const rules = collectEditedRules();
for (const rule of rules) {
@@ -575,7 +1066,8 @@ function doConfirm() {
}
const edited = collectEditedRules();
const hasEdits = Object.keys(editedRules).length > 0;
vscode.postMessage({ type: 'confirm', editedRules: hasEdits ? edited : undefined });
const withData = (hasEdits || addedRules > 0) ? edited : undefined;
vscode.postMessage({ type: 'confirm', editedRules: withData });
}
function cancel() {
@@ -586,7 +1078,6 @@ function updateSummary() {
let keepCount = 0, commentCount = 0;
document.querySelectorAll('.rule-card').forEach(card => {
if (card.hasAttribute('data-error')) { return; }
const ruleId = card.dataset.ruleid;
const keepBtns = card.querySelectorAll('.toggle-btn');
let isKept = true;
keepBtns.forEach(b => {
@@ -621,7 +1112,7 @@ document.addEventListener('keydown', function(e) {
const list = input.parentElement.querySelector('.tag-list');
const existing = list.querySelectorAll('.tag');
const exists = Array.from(existing).some(t => t.dataset.value === val);
const exists = Array.from(existing).some(tag => tag.dataset.value === val);
if (exists) { input.value = ''; return; }
const tag = document.createElement('span');
@@ -631,7 +1122,7 @@ document.addEventListener('keydown', function(e) {
list.appendChild(tag);
input.value = '';
const tags = Array.from(list.querySelectorAll('.tag')).map(t => t.dataset.value);
const tags = Array.from(list.querySelectorAll('.tag')).map(tag => tag.dataset.value);
if (!editedRules[ruleId]) editedRules[ruleId] = {};
editedRules[ruleId][field] = tags;
updateEditHint();
@@ -645,12 +1136,17 @@ document.addEventListener('click', function(e) {
const ruleId = list.dataset.ruleid;
const field = list.dataset.field;
tag.remove();
const remaining = Array.from(list.querySelectorAll('.tag')).map(t => t.dataset.value);
const remaining = Array.from(list.querySelectorAll('.tag')).map(tag => tag.dataset.value);
if (!editedRules[ruleId]) editedRules[ruleId] = {};
editedRules[ruleId][field] = remaining;
updateEditHint();
}
});
document.addEventListener('input', liveClear);
document.addEventListener('change', liveClear);
updateSectionCounts();
</script>
</body>
</html>`;