From 0f62285857ea6d1b60224d9a601423511eb195aa Mon Sep 17 00:00:00 2001 From: TREFOU Felix Date: Fri, 1 May 2026 12:29:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20history=20tracking=20=E2=80=94=20ma?= =?UTF-8?q?nual=20snapshots,=20fingerprint=20diff,=20trends=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage: append-only NDJSON at .vscode/codeclimate-visualiser.history.ndjson Fingerprint resolution (HistoryManager): - native: issue.fingerprint used directly - derived: sha1(check_name:path:line) when fingerprint absent - volatile: sha1(check_name:description) as last resort derived/volatile shown with ⚠ warning in diff Sidebar (SourcesViewProvider): - New "History" collapsible section showing snapshots newest-first - Each entry: label, date, total count, +new/-fixed diff vs previous, ⚠ if derived - Save icon button in History header → runs saveSnapshot command - Delete button per snapshot Command: CodeClimate: Save History Snapshot - Prompts for optional label (e.g. v1.2.3, sprint-42) - Saves current loaded issues as snapshot Trends view (buildTrendsView): - 0 snapshots → save-first prompt - 1 snapshot → severity KPI cards - 2+ snapshots → SVG line chart (total + per-severity overlays), new/fixed/persisting diff KPIs, full snapshot table with inline label editing, delta indicators, delete per row Co-Authored-By: Claude Sonnet 4.6 --- media/webview.css | 52 ++++++++ media/webview.js | 251 +++++++++++++++++++++++++------------ package.json | 5 + src/extension.ts | 36 +++++- src/historyManager.ts | 93 ++++++++++++++ src/sourcesViewProvider.ts | 136 +++++++++++++++++++- src/types.ts | 13 ++ src/webviewPanel.ts | 19 ++- 8 files changed, 519 insertions(+), 86 deletions(-) create mode 100644 src/historyManager.ts diff --git a/media/webview.css b/media/webview.css index 538e540..0a1e274 100644 --- a/media/webview.css +++ b/media/webview.css @@ -848,6 +848,58 @@ 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; +} + +/* ── 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; } diff --git a/media/webview.js b/media/webview.js index 89aff76..eb1e6ba 100644 --- a/media/webview.js +++ b/media/webview.js @@ -29,6 +29,8 @@ let allIssues = []; let allFiles = []; /** @type {Array<{name:string, index:number}>} */ let customColumnDefs = []; +/** @type {any[]} */ +let historySnapshots = []; /** @type {{severities:Set, categories:Set|null, quickTerms:Set, sourceFiles:Set, search:string, custom:Record|null>}} */ let filters = { @@ -174,8 +176,9 @@ function getIssueCustomValue(issue, colDef) { window.addEventListener('message', (event) => { const msg = event.data; if (msg.type === 'updateIssues') { - allIssues = msg.issues ?? []; - allFiles = msg.files ?? []; + allIssues = msg.issues ?? []; + allFiles = msg.files ?? []; + historySnapshots = msg.history ?? []; if (msg.config) config = { ...config, ...msg.config }; customColumnDefs = config.customColumns ?? []; filters.sourceFiles = new Set(allFiles.map(/** @param {any} f */ f => f.uri)); @@ -893,101 +896,187 @@ function buildTrendsView(container) { const view = document.createElement('div'); view.className = 'view'; - const note = document.createElement('div'); - note.className = 'trends-note'; - note.textContent = 'Historical trends require multiple reports loaded over time. Currently showing a snapshot breakdown of the loaded reports.'; - view.appendChild(note); + const snaps = [...historySnapshots].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - // Per-source-file breakdown - /** @type {Record>} */ - const bySrc = {}; - for (const i of allIssues) { - const src = i.sourceFile ?? '(unknown)'; - if (!bySrc[src]) bySrc[src] = { blocker: 0, critical: 0, major: 0, minor: 0, info: 0, total: 0 }; - bySrc[src][(i.severity ?? 'info')]++; - bySrc[src].total++; + if (snaps.length === 0) { + const empty = document.createElement('div'); + empty.className = 'trends-note'; + empty.innerHTML = 'No history yet.

Load a CodeClimate report, then click Save Snapshot in the sidebar to start tracking trends over time.'; + view.appendChild(empty); + container.appendChild(view); + return; } - const sources = Object.entries(bySrc).sort((a, b) => b[1].total - a[1].total); - - // KPI cards per severity - const counts = { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 }; - for (const i of allIssues) counts[(i.severity ?? 'info')]++; - - const sevRow = document.createElement('div'); - sevRow.className = 'row row-3col'; - for (const sev of SEVERITY_ORDER.slice(0, 3)) { + // ── Line chart ────────────────────────────────────────────────────────── + if (snaps.length >= 2) { + const chartRow = document.createElement('div'); + chartRow.className = 'row row-full'; const card = document.createElement('div'); card.className = 'card'; - const badge = document.createElement('span'); - badge.className = `sev-badge ${sev}`; - badge.textContent = sev; - const hdr = document.createElement('div'); - hdr.className = 'card-header'; - hdr.appendChild(badge); - const num = document.createElement('div'); - num.style.cssText = `font-family:var(--font-mono);font-size:36px;font-weight:600;color:${SEVERITY_COLORS[sev]};font-variant-numeric:tabular-nums;margin-top:8px;`; - num.textContent = counts[sev].toLocaleString(); - card.appendChild(hdr); - card.appendChild(num); - sevRow.appendChild(card); + const hdr = document.createElement('div'); hdr.className = 'card-header'; + const t = document.createElement('div'); t.className = 'card-title'; t.textContent = 'Total Issues Over Time'; + hdr.appendChild(t); card.appendChild(hdr); + card.insertAdjacentHTML('beforeend', buildTrendSvg(snaps)); + chartRow.appendChild(card); view.appendChild(chartRow); } - view.appendChild(sevRow); - // Source breakdown - if (sources.length > 1) { - const row = document.createElement('div'); - row.className = 'row row-full'; - const card = document.createElement('div'); - card.className = 'card'; - const hdr = document.createElement('div'); - hdr.className = 'card-header'; - const title = document.createElement('div'); - title.className = 'card-title'; - title.textContent = 'Issues by Source Report'; - hdr.appendChild(title); - card.appendChild(hdr); - - for (const [src, c] of sources) { - const srcWrap = document.createElement('div'); - srcWrap.style.cssText = 'margin-bottom:14px;'; - const srcLbl = document.createElement('div'); - srcLbl.style.cssText = 'font-family:var(--font-mono);font-size:11.5px;color:var(--fg-muted);margin-bottom:5px;display:flex;justify-content:space-between;'; - srcLbl.innerHTML = `${src}${c.total}`; - const bar = document.createElement('div'); - bar.className = 'file-bar'; - bar.style.height = '14px'; - for (const sev of SEVERITY_ORDER) { - if (!c[sev]) continue; - const seg = document.createElement('div'); - seg.className = 'file-bar-seg'; - seg.style.width = `${(c[sev] / c.total) * 100}%`; - seg.style.background = SEVERITY_COLORS[sev]; - seg.title = `${sev}: ${c[sev]}`; - bar.appendChild(seg); - } - srcWrap.appendChild(srcLbl); - srcWrap.appendChild(bar); - card.appendChild(srcWrap); + // ── Latest diff KPIs ──────────────────────────────────────────────────── + if (snaps.length >= 2) { + const prev = snaps[snaps.length - 2]; + const curr = snaps[snaps.length - 1]; + const prevSet = new Set(prev.fingerprints ?? []); + const currSet = new Set(curr.fingerprints ?? []); + const newCount = (curr.fingerprints ?? []).filter(fp => !prevSet.has(fp)).length; + const fixedCount = (prev.fingerprints ?? []).filter(fp => !currSet.has(fp)).length; + const persistCount = (curr.fingerprints ?? []).filter(fp => prevSet.has(fp)).length; + const delta = curr.total - prev.total; + + const diffRow = document.createElement('div'); + diffRow.className = 'row row-3col'; + + /** @param {string} label @param {number} val @param {string} color @param {string} [sub] */ + function diffCard(label, val, color, sub) { + const c = document.createElement('div'); c.className = 'card'; + const h = document.createElement('div'); h.className = 'card-header'; + const tl = document.createElement('div'); tl.className = 'card-title'; tl.textContent = label; + h.appendChild(tl); c.appendChild(h); + const n = document.createElement('div'); + n.style.cssText = `font-size:32px;font-weight:600;font-variant-numeric:tabular-nums;color:${color};margin-top:6px;`; + n.textContent = (val > 0 && color === 'var(--sev-critical)' ? '+' : '') + val.toLocaleString(); + c.appendChild(n); + if (sub) { const s = document.createElement('div'); s.className = 'trends-sub'; s.textContent = sub; c.appendChild(s); } + return c; + } + const hasDerived = (curr.derivedCount ?? 0) > 0 || (prev.derivedCount ?? 0) > 0; + const fpWarn = hasDerived ? ' ⚠' : ''; + diffRow.appendChild(diffCard('New Issues', newCount, 'var(--sev-critical)', 'vs previous snapshot' + fpWarn)); + diffRow.appendChild(diffCard('Fixed Issues', fixedCount, 'var(--sev-info)', 'vs previous snapshot' + fpWarn)); + diffRow.appendChild(diffCard('Net Change', delta, delta > 0 ? 'var(--sev-major)' : delta < 0 ? '#4ade80' : 'var(--fg-muted)', `${persistCount} persisting`)); + if (hasDerived) { + const warn = document.createElement('div'); warn.className = 'trends-note trends-warn'; + warn.textContent = '⚠ Some issues use derived fingerprints (no native fingerprint in report). New/Fixed counts may be inaccurate if code moved between snapshots.'; + view.appendChild(diffRow); view.appendChild(warn); + } else { + view.appendChild(diffRow); } - row.appendChild(card); - view.appendChild(row); + } else { + // Single snapshot — show severity KPIs + const curr = snaps[0]; + const sevRow = document.createElement('div'); sevRow.className = 'row row-3col'; + for (const sev of SEVERITY_ORDER.slice(0, 3)) { + const c = document.createElement('div'); c.className = 'card'; + const h = document.createElement('div'); h.className = 'card-header'; + const badge = document.createElement('span'); badge.className = `sev-badge ${sev}`; badge.textContent = sev; + h.appendChild(badge); c.appendChild(h); + const n = document.createElement('div'); + n.style.cssText = `font-size:36px;font-weight:600;font-variant-numeric:tabular-nums;color:${SEVERITY_COLORS[sev]};margin-top:8px;`; + n.textContent = (curr.counts?.[sev] ?? 0).toLocaleString(); + c.appendChild(n); sevRow.appendChild(c); + } + view.appendChild(sevRow); } - // Minor/info breakdown - const row2 = document.createElement('div'); - row2.className = 'row row-2col'; + // ── Snapshot table ────────────────────────────────────────────────────── + const tableRow = document.createElement('div'); tableRow.className = 'row row-full'; + const tableCard = document.createElement('div'); tableCard.className = 'card'; + const tableHdr = document.createElement('div'); tableHdr.className = 'card-header'; + const tableTitle = document.createElement('div'); tableTitle.className = 'card-title'; tableTitle.textContent = 'Snapshot History'; + tableHdr.appendChild(tableTitle); tableCard.appendChild(tableHdr); + + const tbl = document.createElement('table'); tbl.className = 'snap-table'; + tbl.innerHTML = 'DateLabelTotalBCMajMinI'; + const tbody = document.createElement('tbody'); - const minorEntries = computeTopN(allIssues, i => i.check_name ?? '—', 10); - row2.appendChild(buildBarCard('Top Check Names', minorEntries, '#7c5cff')); + [...snaps].reverse().forEach((snap, idx) => { + const prev = snaps[snaps.length - 1 - idx - 1]; + const tr = document.createElement('tr'); + const d = new Date(snap.timestamp); + const dateTd = document.createElement('td'); dateTd.className = 'snap-ts'; dateTd.title = d.toISOString(); dateTd.textContent = d.toLocaleDateString(undefined, { month:'short', day:'numeric', year:'2-digit' }) + ' ' + d.toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' }); + + const labelTd = document.createElement('td'); labelTd.className = 'snap-lbl-cell'; + const labelEl = document.createElement('span'); labelEl.className = 'snap-lbl-edit'; labelEl.textContent = snap.label ?? '—'; labelEl.title = 'Click to edit label'; + labelEl.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'text'; input.value = snap.label ?? ''; input.className = 'snap-lbl-input'; + labelTd.replaceChild(input, labelEl); input.focus(); input.select(); + const commit = () => { + vscode.postMessage({ type: 'editSnapshotLabel', id: snap.id, label: input.value }); + labelTd.replaceChild(labelEl, input); + labelEl.textContent = input.value || '—'; + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') labelTd.replaceChild(labelEl, input); }); + }); + labelTd.appendChild(labelEl); + + const totalTd = document.createElement('td'); totalTd.style.cssText = 'text-align:right;font-variant-numeric:tabular-nums;font-weight:600;'; + if (prev) { + const delta = snap.total - prev.total; + totalTd.textContent = snap.total.toLocaleString(); + if (delta !== 0) { + const d2 = document.createElement('span'); d2.className = delta > 0 ? 'delta-pos' : 'delta-neg'; + d2.textContent = ' ' + (delta > 0 ? '+' : '') + delta; totalTd.appendChild(d2); + } + } else { + totalTd.textContent = snap.total.toLocaleString(); + } - const catEntries = computeTopN(allIssues, i => (i.categories ?? [])[0] ?? '—', 10); - row2.appendChild(buildBarCard('By Category', catEntries, '#22d3ee')); + const delTd = document.createElement('td'); + const delBtn = document.createElement('button'); delBtn.className = 'snap-del-btn'; delBtn.title = 'Delete'; + delBtn.innerHTML = ''; + delBtn.addEventListener('click', () => vscode.postMessage({ type: 'deleteSnapshot', id: snap.id })); + delTd.appendChild(delBtn); + + tr.appendChild(dateTd); tr.appendChild(labelTd); tr.appendChild(totalTd); + for (const sev of SEVERITY_ORDER) { + const td = document.createElement('td'); + td.style.cssText = `text-align:right;font-variant-numeric:tabular-nums;color:${SEVERITY_COLORS[sev]};`; + td.textContent = (snap.counts?.[sev] ?? 0) || ''; + tr.appendChild(td); + } + tr.appendChild(delTd); + tbody.appendChild(tr); + }); + tbl.appendChild(tbody); tableCard.appendChild(tbl); + tableRow.appendChild(tableCard); view.appendChild(tableRow); - view.appendChild(row2); container.appendChild(view); } +function buildTrendSvg(snaps) { + const W = 560, H = 140, PL = 44, PR = 12, PT = 10, PB = 28; + const cW = W - PL - PR, cH = H - PT - PB; + const n = snaps.length; + const maxVal = Math.max(...snaps.map(s => s.total), 1); + + /** @param {number} i */ const xOf = i => PL + (n < 2 ? cW / 2 : (i / (n - 1)) * cW); + /** @param {number} v */ const yOf = v => PT + cH - (v / maxVal) * cH; + + const gridLines = [0, 0.25, 0.5, 0.75, 1].map(f => { + const y = yOf(f * maxVal); const lbl = Math.round(f * maxVal); + return ` + ${lbl}`; + }).join(''); + + const totalPts = snaps.map((s, i) => `${xOf(i).toFixed(1)},${yOf(s.total).toFixed(1)}`).join(' '); + + const sevLines = SEVERITY_ORDER.map(sev => + `` + ).join(''); + + const dots = snaps.map((s, i) => + `${s.label ?? new Date(s.timestamp).toLocaleDateString()}: ${s.total}` + ).join(''); + + const xLabels = [0, Math.floor(n / 2), n - 1].filter((v, i, a) => a.indexOf(v) === i && v < n).map(i => { + const x = xOf(i); const lbl = new Date(snaps[i].timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + const anchor = i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle'; + return `${lbl}`; + }).join(''); + + return `${gridLines}${sevLines}${dots}${xLabels}`; +} + // ── Filtering ───────────────────────────────────────────────────────────────── function parseTerms() { diff --git a/package.json b/package.json index fd4c46a..7c32dde 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,11 @@ "command": "codeclimateVisualiser.reloadConfig", "title": "CodeClimate: Reload Config", "icon": "$(refresh)" + }, + { + "command": "codeclimateVisualiser.saveSnapshot", + "title": "CodeClimate: Save History Snapshot", + "icon": "$(history)" } ], "menus": { diff --git a/src/extension.ts b/src/extension.ts index 4c6d72f..7d2dd18 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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'); @@ -117,7 +118,9 @@ async function findConfiguredFiles(config: ProjectConfig | null): Promise { let loaded = 0; @@ -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)) { @@ -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(); diff --git a/src/historyManager.ts b/src/historyManager.ts new file mode 100644 index 0000000..f595be4 --- /dev/null +++ b/src/historyManager.ts @@ -0,0 +1,93 @@ +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 = { 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)); + } +} diff --git a/src/sourcesViewProvider.ts b/src/sourcesViewProvider.ts index 111e4fe..7e49324 100644 --- a/src/sourcesViewProvider.ts +++ b/src/sourcesViewProvider.ts @@ -1,20 +1,28 @@ import * as vscode from 'vscode'; import { IssueManager } from './issueManager'; +import { HistorySnapshot } from './types'; export class SourcesViewProvider implements vscode.WebviewViewProvider { static readonly viewId = 'codeclimateVisualiser.sourcesView'; private view?: vscode.WebviewView; + private historySnapshots: HistorySnapshot[] = []; constructor( private readonly issueManager: IssueManager, private readonly onInit: () => Promise, private readonly onFocusIssue: (id: string) => void, + private readonly onDeleteSnapshot: (id: string) => void, private readonly onOpenFile: (filePath: string, line: number) => Promise, ) { issueManager.onChange(() => this.update()); } + setHistory(snapshots: HistorySnapshot[]): void { + this.historySnapshots = snapshots; + this.update(); + } + resolveWebviewView(webviewView: vscode.WebviewView): void { this.view = webviewView; webviewView.webview.options = { enableScripts: true }; @@ -37,6 +45,8 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { this.onFocusIssue(msg.id); } else if (msg.type === 'openFile' && msg.path) { await this.onOpenFile(msg.path, msg.line ?? 1); + } else if (msg.type === 'deleteSnapshot' && msg.id) { + this.onDeleteSnapshot(msg.id); } }); @@ -56,7 +66,7 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { customColumns: i.customColumns, })); const customColumns = this.issueManager.getCustomColumns(); - this.view.webview.postMessage({ type: 'update', files, issues, customColumns }); + this.view.webview.postMessage({ type: 'update', files, issues, customColumns, history: this.historySnapshots }); } private buildHtml(): string { @@ -244,6 +254,24 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { /* ── Empty state ────────────────────────── */ #empty-msg { padding: 8px; opacity: 0.5; font-style: italic; } + + /* ── History section ────────────────────── */ + .snap-item { + display: flex; align-items: flex-start; padding: 4px 8px 4px 22px; gap: 4px; + cursor: default; flex-direction: column; + } + .snap-item:hover { background: var(--vscode-list-hoverBackground); } + .snap-row1 { display: flex; align-items: center; width: 100%; gap: 4px; } + .snap-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } + .snap-date { opacity: 0.5; font-size: 0.82em; flex-shrink: 0; } + .snap-total { font-variant-numeric: tabular-nums; flex-shrink: 0; } + .snap-delete { opacity: 0; transition: opacity 0.1s; flex-shrink: 0; } + .snap-item:hover .snap-delete { opacity: 0.45; } + .snap-item:hover .snap-delete:hover { opacity: 1; } + .snap-diff { display: flex; gap: 6px; font-size: 0.82em; opacity: 0.75; margin-top: 1px; } + .snap-diff .new { color: #f87171; } + .snap-diff .fixed { color: #4ade80; } + .snap-empty { padding: 6px 8px 6px 22px; opacity: 0.5; font-style: italic; font-size: 0.9em; } @@ -312,6 +340,18 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider {
No reports loaded.
+ +