Files
2026Technology-Competition/src/rules/import-preview.ts
T
范智鹏 247f19fd44 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 + 行号排序
2026-08-03 22:53:20 +08:00

1154 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
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(
'ruleImportPreview',
t('importPreview.title'),
vscode.ViewColumn.Active,
{ enableScripts: true },
);
const keepRule: Record<string, boolean> = {};
for (const rule of result.rules) {
if (!rule.validationIssues?.length) {
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
}
panel.webview.html = renderPreviewHtml(result, keepRule);
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,
confirmed: true,
editedRules: msg.editedRules as ImportableRule[] | undefined,
});
panel.dispose();
} else if (msg.type === 'cancel') {
resolve(null);
panel.dispose();
}
});
panel.onDidDispose(() => resolve(null));
});
}
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',
warning: '#d29922',
info: '#58a6ff',
};
function renderPreviewHtml(
result: ConversionResult,
keepRule: Record<string, boolean>,
): string {
const errorRules = result.rules.filter(r => r.validationIssues?.length);
const cleanRules = result.rules.filter(r => !r.validationIssues?.length);
const exactRules = cleanRules.filter(r => r.duplicateLevel === 'exact');
const overlapRules = cleanRules.filter(r => r.duplicateLevel === 'overlap');
const noneRules = cleanRules.filter(
r => r.duplicateLevel !== 'exact' && r.duplicateLevel !== 'overlap'
);
const totalKept = Object.values(keepRule).filter(Boolean).length;
const totalCommented = Object.values(keepRule).filter(v => !v).length;
const skippedHint = result.skippedCount
? `<div class="summary-bar" style="border-color:rgba(88,166,255,0.3);color:#58a6ff;">${t('import.template.skipped', { 0: String(result.skippedCount) })}</div>`
: '';
function dupLabel(dupOf: string | undefined): string {
return dupOf?.startsWith('custom/')
? `${t('import.customRulePrefix')} ${dupOf.slice(7)}`
: (dupOf ?? 'unknown');
}
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const customRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
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 sevIssue = !!rule.validationIssues?.some(i => i.field === 'severity');
const color = sevIssue ? '#f48771' : (SEVERITY_COLORS[rule.severity] || '#8b949e');
let duplicateInfo = '';
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 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 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${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(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">${expandIcon}</span>
</div>
</div>
<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(this)">
${/^rule-\d+$/.test(rule.id) ? `<span class="placeholder-hint">${t('import.placeholderIdHint')}</span>` : ''}
</div>
${actionArea}
</div>
${duplicateInfo}
<div class="edit-field${issueCls('severity')}">
<label>${t('import.severityLabel')}</label>
<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${issueCls('description')}">
<label>${t('import.descriptionLabel')}</label>
<textarea rows="2" class="${inputCls('description')}" onchange="updateRule(this,'description',this.value)">${rule.description}</textarea>
${issueMsg('description')}
</div>
<div class="edit-field${issueCls('message')}">
<label>${t('import.messageLabel')}</label>
<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(rule.languages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="languages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
</div>
<div class="edit-field">
<label>${t('import.excludeLanguagesLabel')}</label>
<div class="tag-input-wrapper">
<div class="tag-list" data-ruleid="${rule.id}" data-field="excludeLanguages">
${tagDisplay(rule.excludeLanguages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="excludeLanguages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
</div>
</div>
</div>
`;
}
function renderErrorSection(rules: ImportableRule[]): string {
const sectionId = 'section-error';
return `
<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" data-section-title="error"></span>
<span class="section-arrow">▼</span>
</div>
<div id="${sectionId}" data-section="error" style="display:block;">
${rules.map(r => renderRuleCard(r, true)).join('')}
</div>
</div>
`;
}
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 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" data-section-title="${key}"></span>
<span class="section-arrow">▼</span>
</div>
<div id="${sectionId}" data-section="${key}" style="display:${show ? 'block' : 'none'};">
${rules.map(r => renderRuleCard(r)).join('')}
</div>
</div>
`;
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 16px; line-height: 1.5;
}
.header {
border-bottom: 1px solid var(--vscode-panel-border);
padding-bottom: 12px; margin-bottom: 12px;
}
.header-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.header-sub { font-size: 12px; color: var(--vscode-descriptionForeground); }
.summary {
display: flex; gap: 12px; margin-bottom: 16px;
}
.summary-item {
padding: 8px 12px; border-radius: 6px; font-size: 12px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
}
.summary-bar {
margin-bottom: 16px; padding: 8px 12px; border-radius: 6px;
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);
justify-content: flex-end;
}
.btn {
padding: 6px 16px; border: 1px solid var(--vscode-panel-border);
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-radius: 6px; cursor: pointer; font-size: 12px;
}
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
.btn-primary { background: #7c3aed; color: #fff; border-color: #7c3aed; }
.btn-primary:hover { background: #8b5cf6; }
.section-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
cursor: pointer; padding: 4px 0;
}
.section-title { font-weight: 600; font-size: 13px; }
.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;
}
.rule-card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; cursor: pointer; gap: 8px;
}
.rule-card-header:hover { background: var(--vscode-list-hoverBackground); }
.rule-card-summary {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.rule-id-input {
font-family: monospace; font-size: 13px; font-weight: 600; white-space: nowrap;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--vscode-foreground);
padding: 1px 4px; outline: none;
width: auto; min-width: 120px;
}
.rule-id-input:focus {
border-color: var(--vscode-focusBorder, #7c3aed);
background: var(--vscode-input-background);
}
.rule-id-input.placeholder-id {
border-color: #f59e0b !important;
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
}
.rule-severity-tag {
padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap;
}
.rule-desc-preview {
font-size: 12px; color: var(--vscode-descriptionForeground);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rule-card-meta {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.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;
}
.badge-exact { background: rgba(248,81,73,0.15); color: #f48771; }
.badge-overlap { background: rgba(210,153,34,0.15); color: #d29922; }
.badge-none { background: rgba(35,134,54,0.15); color: #3fb950; }
.rule-card-body {
padding: 0 12px 12px; border-top: 1px solid var(--vscode-panel-border);
}
.edit-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0 8px;
}
.id-row {
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 {
padding: 3px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
border: 1px solid var(--vscode-panel-border);
background: transparent; color: var(--vscode-foreground);
}
.toggle-btn.active {
background: rgba(35,134,54,0.15); color: #3fb950; border-color: rgba(35,134,54,0.3);
}
.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;
color: var(--vscode-descriptionForeground); margin-bottom: 4px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.id-display-label {
font-size: 11px; font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase; letter-spacing: 0.5px;
}
.id-display-input {
font-family: monospace; font-size: 13px; font-weight: 600;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
padding: 4px 8px; outline: none;
width: auto; min-width: 200px;
}
.id-display-input:focus {
border-color: var(--vscode-focusBorder, #7c3aed);
}
.id-display-input.placeholder-id {
border-color: #f59e0b !important;
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
}
.placeholder-hint {
color: #d29922; font-size: 11px;
}
.edit-field select, .edit-field textarea {
width: 100%; padding: 6px 8px; border-radius: 4px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.edit-field textarea { resize: vertical; }
.tag-input-wrapper {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px; padding: 4px 6px;
background: var(--vscode-input-background);
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
}
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 3px;
padding: 1px 6px; border-radius: 3px; font-size: 11px;
background: rgba(88,166,255,0.15); color: #58a6ff;
border: 1px solid rgba(88,166,255,0.3);
}
.tag-remove {
cursor: pointer; font-size: 13px; line-height: 1; opacity: 0.7;
}
.tag-remove:hover { opacity: 1; }
.tag-input {
border: none; outline: none; flex: 1; min-width: 80px;
background: transparent; color: var(--vscode-input-foreground);
font-size: 12px; padding: 2px 0;
}
.tag-input::placeholder { color: var(--vscode-input-placeholderForeground); }
.validation-error {
padding: 8px 12px; margin-bottom: 12px; border-radius: 6px;
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>
<div class="header">
<div class="header-title">${t('importPreview.title')}</div>
<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: `<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'), '⛔', 'exact', exactRules)}
${renderSection(t('import.sectionOverlap'), '⚠️', 'overlap', overlapRules)}
${renderSection(t('import.sectionNone'), '✅', 'none', noneRules)}
<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" 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 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;
if (panelInput) panelInput.value = newValue;
if (!editedRules[originalId]) editedRules[originalId] = {};
editedRules[originalId].id = newValue;
updateEditHint();
const isPlaceholder = /^rule-\\d+$/.test(newValue);
[headerInput, panelInput].forEach(input => {
if (input) {
input.classList.toggle('placeholder-id', isPlaceholder);
}
});
}
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';
card.classList.add('expanded');
} else {
body.style.display = 'none';
card.classList.remove('expanded');
}
}
function toggleSection(id) {
const el = document.getElementById(id);
const wrap = el.closest('.section-wrapper');
if (el.style.display === 'none') {
el.style.display = 'block';
wrap.classList.add('expanded');
} else {
el.style.display = 'none';
wrap.classList.remove('expanded');
}
}
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(el, field, value) {
const card = el.closest('.rule-card');
if (!card) return;
const ruleId = card.dataset.ruleid;
if (!editedRules[ruleId]) {
editedRules[ruleId] = {};
}
editedRules[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 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) {
if (!rule.id || !rule.id.trim()) {
return VALIDATION_ID_EMPTY;
}
if (!rule.description || !rule.description.trim()) {
return VALIDATION_DESC_EMPTY.replace('{0}', rule.id);
}
if (!rule.message || !rule.message.trim()) {
return VALIDATION_MSG_EMPTY.replace('{0}', rule.id);
}
}
return null;
}
function doConfirm() {
const err = validate();
if (err) {
const errEl = document.getElementById('validationError');
errEl.textContent = err;
errEl.style.display = 'block';
return;
}
const edited = collectEditedRules();
const hasEdits = Object.keys(editedRules).length > 0;
const withData = (hasEdits || addedRules > 0) ? edited : undefined;
vscode.postMessage({ type: 'confirm', editedRules: withData });
}
function cancel() {
vscode.postMessage({ type: 'cancel' });
}
function updateSummary() {
let keepCount = 0, commentCount = 0;
document.querySelectorAll('.rule-card').forEach(card => {
if (card.hasAttribute('data-error')) { return; }
const keepBtns = card.querySelectorAll('.toggle-btn');
let isKept = true;
keepBtns.forEach(b => {
if (b.classList.contains('active') && b.dataset.action === 'comment') isKept = false;
});
if (isKept) keepCount++; else commentCount++;
});
document.getElementById('keepCount').textContent = keepCount;
document.getElementById('commentCount').textContent = commentCount;
}
function updateEditHint() {
const count = Object.keys(editedRules).length;
const hint = document.getElementById('editHint');
const countEl = document.getElementById('editCount');
if (count > 0) {
hint.style.display = 'inline';
countEl.textContent = count;
} else {
hint.style.display = 'none';
}
}
document.addEventListener('keydown', function(e) {
if (e.target.classList.contains('tag-input') && e.key === 'Enter') {
e.preventDefault();
const input = e.target;
const val = input.value.trim();
if (!val) return;
const ruleId = input.dataset.ruleid;
const field = input.dataset.field;
const list = input.parentElement.querySelector('.tag-list');
const existing = list.querySelectorAll('.tag');
const exists = Array.from(existing).some(tag => tag.dataset.value === val);
if (exists) { input.value = ''; return; }
const tag = document.createElement('span');
tag.className = 'tag';
tag.dataset.value = val;
tag.innerHTML = val + '<span class="tag-remove">×</span>';
list.appendChild(tag);
input.value = '';
const tags = Array.from(list.querySelectorAll('.tag')).map(tag => tag.dataset.value);
if (!editedRules[ruleId]) editedRules[ruleId] = {};
editedRules[ruleId][field] = tags;
updateEditHint();
}
});
document.addEventListener('click', function(e) {
if (e.target.classList.contains('tag-remove')) {
const tag = e.target.closest('.tag');
const list = tag.closest('.tag-list');
const ruleId = list.dataset.ruleid;
const field = list.dataset.field;
tag.remove();
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>`;
}