Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions media/webview.css
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ button { font-family: inherit; color: inherit; background: none; border: none; c
color: var(--fg);
}

.kpi-delta { display:flex; align-items:center; gap:4px; font-size:11.5px; font-family:var(--font-mono); margin-top:4px; }
.kpi-delta.up { color: var(--sev-critical); }
.kpi-delta.down { color: #4ade80; }
.kpi-delta.flat { color: var(--fg-dim); }
.kpi-spark { flex-shrink:0; align-self:flex-end; }

/* ── KPI secondary row (4 small) ─────────────────────────────────────────────── */
.kpi-sev-row {
display: grid;
Expand Down Expand Up @@ -220,6 +226,11 @@ button { font-family: inherit; color: inherit; background: none; border: none; c
line-height: 1.2;
}

.kpi-sev-delta { font-size:10.5px; font-family:var(--font-mono); margin-top:2px; }
.kpi-sev-delta.up { color: var(--sev-critical); }
.kpi-sev-delta.down { color: #4ade80; }
.kpi-sev-delta.flat { color: var(--fg-dim); }

/* ── Layout grid ─────────────────────────────────────────────────────────────── */
.row { display: grid; gap: var(--gap); margin-bottom: var(--gap); }
.row-3col { grid-template-columns: 1.3fr 1fr 1fr; }
Expand Down Expand Up @@ -848,6 +859,64 @@ button { font-family: inherit; color: inherit; background: none; border: none; c
color: var(--fg-muted);
margin-bottom: var(--gap);
}
.trends-warn {
border-color: var(--sev-minor);
color: var(--sev-minor);
}
.trends-sub {
font-size: 11px;
color: var(--fg-muted);
margin-top: 4px;
}

.trend-card-stripe { display:flex; align-items:center; gap:16px; }
.trend-card-stripe-bar { width:3px; align-self:stretch; border-radius:2px; flex-shrink:0; }
.trend-chart-area { width:100%; }
.nf-legend { display:flex; gap:16px; margin-top:8px; font-size:11px; color:var(--fg-muted); }
.nf-legend-dot { width:12px; height:2px; border-radius:1px; display:inline-block; margin-right:4px; vertical-align:middle; }

/* ── Snapshot table ──────────────────────────────────────────────────────────── */
.snap-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.snap-table thead th {
text-align: left;
padding: 4px 8px;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-muted);
border-bottom: 1px solid var(--border);
}
.snap-table thead th:nth-child(n+3) { text-align: right; }
.snap-table tbody tr { border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.04)); }
.snap-table tbody tr:hover { background: var(--surface-2); }
.snap-table tbody td { padding: 5px 8px; vertical-align: middle; }
.snap-ts { white-space: nowrap; color: var(--fg-muted); font-size: 11px; }
.snap-lbl-cell { min-width: 80px; }
.snap-lbl-edit { cursor: pointer; border-bottom: 1px dashed transparent; }
.snap-lbl-edit:hover { border-bottom-color: var(--fg-muted); }
.snap-lbl-input {
background: var(--vscode-input-background, #2a2a2a);
color: var(--fg);
border: 1px solid var(--accent);
border-radius: 3px;
padding: 1px 4px;
font-size: 12px;
width: 100%;
outline: none;
}
.delta-pos { color: var(--sev-critical); font-size: 11px; }
.delta-neg { color: #4ade80; font-size: 11px; }
.snap-del-btn {
background: none; border: none; color: var(--fg-muted);
cursor: pointer; padding: 2px; display: inline-flex; align-items: center;
border-radius: 3px; opacity: 0; transition: opacity 0.1s;
}
.snap-table tbody tr:hover .snap-del-btn { opacity: 0.5; }
.snap-table tbody tr:hover .snap-del-btn:hover { opacity: 1; color: var(--sev-critical); }

/* ── Prism token colors ──────────────────────────────────────────────────────── */
.token.comment,.token.prolog,.token.doctype,.token.cdata { color: #6a9955; font-style: italic; }
Expand Down
407 changes: 318 additions & 89 deletions media/webview.js

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@
"command": "codeclimateVisualiser.reloadConfig",
"title": "CodeClimate: Reload Config",
"icon": "$(refresh)"
},
{
"command": "codeclimateVisualiser.saveSnapshot",
"title": "CodeClimate: Save History Snapshot",
"icon": "$(history)"
}
],
"menus": {
Expand Down
36 changes: 35 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DecorationProvider } from './decorationProvider';
import { CodeClimatePanel } from './webviewPanel';
import { PatternEntry, ProjectConfig } from './types';
import { SourcesViewProvider } from './sourcesViewProvider';
import { HistoryManager } from './historyManager';

const logChannel = vscode.window.createOutputChannel('CodeClimate Visualiser');

Expand Down Expand Up @@ -117,7 +118,9 @@ async function findConfiguredFiles(config: ProjectConfig | null): Promise<FindRe
export function activate(context: vscode.ExtensionContext): void {
const issueManager = new IssueManager();
const decorationProvider = new DecorationProvider(issueManager);
const panel = new CodeClimatePanel(context, issueManager);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const historyManager = workspaceRoot ? new HistoryManager(workspaceRoot) : null;
const panel = new CodeClimatePanel(context, issueManager, historyManager);

async function loadFromEntries(entries: ResolvedFile[]): Promise<number> {
let loaded = 0;
Expand Down Expand Up @@ -146,6 +149,11 @@ export function activate(context: vscode.ExtensionContext): void {
issueManager,
autoLoadFromConfig,
(issueId) => { panel.show(); panel.focusIssue(issueId); },
(id: string) => {
historyManager?.deleteSnapshot(id);
sourcesView.setHistory(historyManager?.loadHistory() ?? []);
panel.refreshHistory();
},
async (filePath, line) => {
let resolved: string | null = null;
if (path.isAbsolute(filePath) && fs.existsSync(filePath)) {
Expand Down Expand Up @@ -213,6 +221,32 @@ export function activate(context: vscode.ExtensionContext): void {
await autoLoadFromConfig();
}),

vscode.commands.registerCommand('codeclimateVisualiser.saveSnapshot', async () => {
if (!historyManager) {
vscode.window.showWarningMessage('Open a folder to save history snapshots.');
return;
}
const issues = issueManager.getAllIssues();
if (issues.length === 0) {
vscode.window.showWarningMessage('No issues loaded. Open a CodeClimate report first.');
return;
}
const label = await vscode.window.showInputBox({
prompt: 'Snapshot label (optional)',
placeHolder: 'e.g. v1.2.3, main@abc1234, sprint-42…',
});
if (label === undefined) return;
const sources = issueManager.getFileInfos().map(f => f.filename);
const snap = historyManager.saveSnapshot(issues, sources, label || undefined);
log(`Saved snapshot ${snap.id}: ${snap.total} issues`);
const snaps = historyManager.loadHistory();
sourcesView.setHistory(snaps);
panel.refreshHistory();
vscode.window.showInformationMessage(
`Snapshot saved: ${snap.total} issues${label ? ` (${label})` : ''}`
);
}),

vscode.commands.registerCommand('codeclimateVisualiser.reloadConfig', async () => {
issueManager.clearAll();
decorationProvider.clearDecorations();
Expand Down
112 changes: 112 additions & 0 deletions src/historyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { IssueWithSource, HistorySnapshot, Severity } from './types';

function beginLine(issue: IssueWithSource): number {
const b = issue.location?.lines?.begin;
if (typeof b === 'number') return b;
if (b && typeof b === 'object') return (b as { line?: number }).line ?? -1;
return issue.location?.positions?.begin?.line ?? -1;
}

function sha1(s: string): string {
return crypto.createHash('sha1').update(s).digest('hex');
}

type FpSource = 'native' | 'derived' | 'volatile';

function resolveFingerprint(issue: IssueWithSource): { fp: string; source: FpSource } {
if (issue.fingerprint) return { fp: issue.fingerprint, source: 'native' };
if (issue.check_name && issue.location?.path) {
return { fp: sha1(`${issue.check_name}:${issue.location.path}:${beginLine(issue)}`), source: 'derived' };
}
return { fp: sha1(`${issue.check_name ?? ''}:${issue.description ?? ''}`), source: 'volatile' };
}

export class HistoryManager {
private readonly historyPath: string;

constructor(workspaceRoot: string) {
this.historyPath = path.join(workspaceRoot, '.vscode', 'codeclimate-visualiser.history.ndjson');
}

saveSnapshot(issues: IssueWithSource[], sources: string[], label?: string): HistorySnapshot {
const counts: Record<Severity, number> = { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 };
let nativeCount = 0, derivedCount = 0, volatileCount = 0;
const fingerprints: string[] = [];

for (const issue of issues) {
counts[issue.severity ?? 'info']++;
const { fp, source } = resolveFingerprint(issue);
if (source === 'volatile') {
volatileCount++;
} else {
fingerprints.push(fp);
if (source === 'native') nativeCount++; else derivedCount++;
}
}

const snapshot: HistorySnapshot = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
label: label || undefined,
sources,
counts,
total: issues.length,
nativeCount,
derivedCount,
volatileCount,
fingerprints,
};

fs.appendFileSync(this.historyPath, JSON.stringify(snapshot) + '\n', 'utf-8');
return snapshot;
}

loadHistory(): HistorySnapshot[] {
try {
const raw = fs.readFileSync(this.historyPath, 'utf-8');
return raw
.trim()
.split('\n')
.filter(Boolean)
.map(line => { try { return JSON.parse(line) as HistorySnapshot; } catch { return null; } })
.filter((s): s is HistorySnapshot => s !== null);
} catch {
return [];
}
}

private rewrite(snapshots: HistorySnapshot[]): void {
const content = snapshots.map(s => JSON.stringify(s)).join('\n');
fs.writeFileSync(this.historyPath, content ? content + '\n' : '', 'utf-8');
}

deleteSnapshot(id: string): void {
this.rewrite(this.loadHistory().filter(s => s.id !== id));
}

updateLabel(id: string, label: string): void {
this.rewrite(this.loadHistory().map(s => s.id === id ? { ...s, label: label || undefined } : s));
}

computeCurrentState(issues: IssueWithSource[]): {
fingerprints: string[];
counts: Record<Severity, number>;
total: number;
derivedCount: number;
volatileCount: number;
} {
const counts: Record<Severity, number> = { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 };
let derivedCount = 0, volatileCount = 0;
const fingerprints: string[] = [];
for (const issue of issues) {
counts[issue.severity ?? 'info']++;
const { fp, source } = resolveFingerprint(issue);
if (source === 'volatile') { volatileCount++; }
else { fingerprints.push(fp); if (source === 'derived') derivedCount++; }
}
return { fingerprints, counts, total: issues.length, derivedCount, volatileCount };
}
}
Loading
Loading