diff --git a/media/webview.css b/media/webview.css index 538e540..0d53ce9 100644 --- a/media/webview.css +++ b/media/webview.css @@ -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; @@ -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; } @@ -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; } diff --git a/media/webview.js b/media/webview.js index 89aff76..8909643 100644 --- a/media/webview.js +++ b/media/webview.js @@ -29,6 +29,10 @@ let allIssues = []; let allFiles = []; /** @type {Array<{name:string, index:number}>} */ let customColumnDefs = []; +/** @type {any[]} */ +let historySnapshots = []; +/** @type {any} */ +let currentState = null; /** @type {{severities:Set, categories:Set|null, quickTerms:Set, sourceFiles:Set, search:string, custom:Record|null>}} */ let filters = { @@ -174,8 +178,10 @@ 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 ?? []; + currentState = msg.currentState ?? null; if (msg.config) config = { ...config, ...msg.config }; customColumnDefs = config.customColumns ?? []; filters.sourceFiles = new Set(allFiles.map(/** @param {any} f */ f => f.uri)); @@ -254,22 +260,32 @@ function buildOverviewView(container) { const total = allIssues.length; const fileCount = new Set(allIssues.map(i => i.location?.path ?? '').filter(Boolean)).size; + // Compute deltas vs last snapshot + const snaps = [...historySnapshots].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + const lastSnap = snaps.length > 0 ? snaps[snaps.length - 1] : null; + const totalDelta = lastSnap !== null ? total - lastSnap.total : null; + const totalSpark = snaps.length >= 2 ? [...snaps.map(s => s.total), total] : null; + // KPI grid (3 large) const kpiGrid = document.createElement('div'); kpiGrid.className = 'kpi-grid'; - kpiGrid.appendChild(makeKPICard('Total Issues', total.toLocaleString(), null, null)); - kpiGrid.appendChild(makeKPICard('Blocker', counts.blocker, 'blocker', '--sev-blocker')); - kpiGrid.appendChild(makeKPICard('Critical', counts.critical, 'critical', '--sev-critical')); + kpiGrid.appendChild(makeKPICard('Total Issues', total.toLocaleString(), null, null, totalDelta, totalSpark)); + const blockerDelta = lastSnap !== null ? counts.blocker - (lastSnap.counts?.blocker ?? 0) : null; + const blockerSpark = snaps.length >= 2 ? [...snaps.map(s => s.counts?.blocker ?? 0), counts.blocker] : null; + kpiGrid.appendChild(makeKPICard('Blocker', counts.blocker, 'blocker', '--sev-blocker', blockerDelta, blockerSpark)); + const critDelta = lastSnap !== null ? counts.critical - (lastSnap.counts?.critical ?? 0) : null; + const critSpark = snaps.length >= 2 ? [...snaps.map(s => s.counts?.critical ?? 0), counts.critical] : null; + kpiGrid.appendChild(makeKPICard('Critical', counts.critical, 'critical', '--sev-critical', critDelta, critSpark)); view.appendChild(kpiGrid); // KPI sev row (4 small) const sevRow = document.createElement('div'); sevRow.className = 'kpi-sev-row'; const sevSmall = [ - { label: 'Major', color: SEVERITY_COLORS.major, val: counts.major }, - { label: 'Minor', color: SEVERITY_COLORS.minor, val: counts.minor }, - { label: 'Info', color: SEVERITY_COLORS.info, val: counts.info }, - { label: 'Files', color: 'var(--border-strong)', val: fileCount }, + { label: 'Major', color: SEVERITY_COLORS.major, key: 'major', val: counts.major }, + { label: 'Minor', color: SEVERITY_COLORS.minor, key: 'minor', val: counts.minor }, + { label: 'Info', color: SEVERITY_COLORS.info, key: 'info', val: counts.info }, + { label: 'Files', color: 'var(--border-strong)', key: null, val: fileCount }, ]; for (const s of sevSmall) { const card = document.createElement('div'); @@ -289,6 +305,14 @@ function buildOverviewView(container) { val.textContent = s.val.toLocaleString(); info.appendChild(lbl); info.appendChild(val); + if (s.key && lastSnap !== null) { + const d = s.val - (lastSnap.counts?.[s.key] ?? 0); + const deltaEl = document.createElement('div'); + const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; + deltaEl.className = `kpi-sev-delta ${cls}`; + deltaEl.textContent = (d > 0 ? '▲ +' : d < 0 ? '▼ ' : '— ') + d; + info.appendChild(deltaEl); + } card.appendChild(bar); card.appendChild(info); sevRow.appendChild(card); @@ -349,8 +373,10 @@ function buildOverviewView(container) { * @param {string|number} value * @param {string|null} sev * @param {string|null} colorVar + * @param {number|null} [delta] + * @param {number[]|null} [sparkData] */ -function makeKPICard(label, value, sev, colorVar) { +function makeKPICard(label, value, sev, colorVar, delta, sparkData) { const card = document.createElement('div'); card.className = 'kpi'; const lbl = document.createElement('div'); @@ -369,11 +395,44 @@ function makeKPICard(label, value, sev, colorVar) { if (colorVar) valEl.style.color = `var(${colorVar})`; valEl.textContent = String(value); body.appendChild(valEl); + + if (delta !== null && delta !== undefined) { + const deltaEl = document.createElement('div'); + const cls = delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'; + deltaEl.className = `kpi-delta ${cls}`; + const arrow = delta > 0 ? '▲' : delta < 0 ? '▼' : '—'; + deltaEl.textContent = `${arrow} ${delta > 0 ? '+' : ''}${delta} vs last snapshot`; + body.appendChild(deltaEl); + } + + if (sparkData && sparkData.length >= 2) { + const sparkColor = colorVar ? `var(${colorVar})` : 'var(--accent)'; + const sparkWrap = document.createElement('div'); + sparkWrap.className = 'kpi-spark'; + sparkWrap.innerHTML = buildSparklineSvg(sparkData, sparkColor, 72, 28); + body.appendChild(sparkWrap); + } + card.appendChild(lbl); card.appendChild(body); return card; } +/** + * @param {number[]} data + * @param {string} color + * @param {number} w + * @param {number} h + */ +function buildSparklineSvg(data, color, w, h) { + if (data.length < 2) return ''; + const max = Math.max(...data, 1); + const xStep = w / (data.length - 1); + const pts = data.map((v, i) => `${(i * xStep).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`).join(' '); + const areaClose = ` ${((data.length - 1) * xStep).toFixed(1)},${h} 0,${h}`; + return ``; +} + function buildDonutChart(counts, total) { const size = 160, thickness = 22; const r = size / 2 - thickness / 2 - 2; @@ -888,106 +947,276 @@ function layoutTreemap(items, width, height) { // ── Trends view ─────────────────────────────────────────────────────────────── +function fmtSnapDate(snap) { + const d = new Date(snap.timestamp); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: '2-digit' }) + + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +function buildNewFixedSvg(pts) { + // pts: [{new, fixed}] chronologically (snaps[0..n-1] + current) + if (pts.length < 2) return ''; + const W = 560, H = 120, PL = 36, PR = 12, PT = 10, PB = 20; + const cW = W - PL - PR, cH = H - PT - PB; + const maxVal = Math.max(...pts.flatMap(p => [p.new, p.fixed]), 1); + const xOf = /** @param {number} i */ i => PL + (pts.length < 2 ? cW / 2 : (i / (pts.length - 1)) * cW); + const yOf = /** @param {number} v */ v => PT + cH - (v / maxVal) * cH; + + const newPts = pts.map((p, i) => `${xOf(i).toFixed(1)},${yOf(p.new).toFixed(1)}`).join(' '); + const fixPts = pts.map((p, i) => `${xOf(i).toFixed(1)},${yOf(p.fixed).toFixed(1)}`).join(' '); + const newArea = newPts + ` ${xOf(pts.length-1).toFixed(1)},${(PT+cH).toFixed(1)} ${PL},${(PT+cH).toFixed(1)}`; + const fixArea = fixPts + ` ${xOf(pts.length-1).toFixed(1)},${(PT+cH).toFixed(1)} ${PL},${(PT+cH).toFixed(1)}`; + + const gridY = [0, 0.5, 1].map(f => { + const y = yOf(f * maxVal); const lbl = Math.round(f * maxVal); + return ` + ${lbl}`; + }).join(''); + + return ` + ${gridY} + + + + + `; +} + function buildTrendsView(container) { container.innerHTML = ''; 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); + // ── Current vs last snapshot diff ─────────────────────────────────────── + const lastSnap = snaps[snaps.length - 1]; + const cur = currentState; + + if (cur) { + const lastSet = new Set(lastSnap.fingerprints ?? []); + const curSet = new Set(cur.fingerprints ?? []); + const newCount = cur.fingerprints.filter(fp => !lastSet.has(fp)).length; + const fixedCount = (lastSnap.fingerprints ?? []).filter(fp => !curSet.has(fp)).length; + const persistCount = cur.fingerprints.filter(fp => lastSet.has(fp)).length; + const delta = cur.total - lastSnap.total; + const hasDerived = (cur.derivedCount ?? 0) > 0 || (lastSnap.derivedCount ?? 0) > 0; + + const diffRow = document.createElement('div'); + diffRow.className = 'row row-3col'; + + /** @param {string} lbl @param {number} val @param {string} color @param {string} [sub] @param {string} [stripe] */ + function trendCard(lbl, val, color, sub, stripe) { + const c = document.createElement('div'); c.className = 'card'; + const inner = document.createElement('div'); inner.className = 'trend-card-stripe'; + if (stripe) { + const bar = document.createElement('div'); bar.className = 'trend-card-stripe-bar'; bar.style.background = stripe; + inner.appendChild(bar); + } + const body = document.createElement('div'); body.style.flex = '1'; + const h = document.createElement('div'); h.className = 'card-header'; + const tl = document.createElement('div'); tl.className = 'card-title'; tl.textContent = lbl; + h.appendChild(tl); body.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 = String(val > 0 && stripe === 'var(--sev-critical)' ? '+' + val.toLocaleString() : val.toLocaleString()); + body.appendChild(n); + if (sub) { const s = document.createElement('div'); s.className = 'trends-sub'; s.textContent = sub; body.appendChild(s); } + inner.appendChild(body); + c.appendChild(inner); + return c; + } - // 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 fpWarn = hasDerived ? ' ⚠' : ''; + const netColor = delta > 0 ? 'var(--sev-major)' : delta < 0 ? '#4ade80' : 'var(--fg-muted)'; + diffRow.appendChild(trendCard('New Issues', newCount, 'var(--sev-critical)', 'vs last snapshot' + fpWarn, 'var(--sev-critical)')); + diffRow.appendChild(trendCard('Fixed Issues', fixedCount, '#4ade80', 'vs last snapshot' + fpWarn, '#4ade80')); + diffRow.appendChild(trendCard('Net Change', delta, netColor, `${persistCount} persisting`, netColor === 'var(--fg-muted)' ? 'var(--fg-dim)' : netColor)); + view.appendChild(diffRow); + + 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(warn); + } - const sevRow = document.createElement('div'); - sevRow.className = 'row row-3col'; - for (const sev of SEVERITY_ORDER.slice(0, 3)) { - 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); + // New vs Fixed area chart (snaps + current) + if (snaps.length >= 1) { + const nfRow = document.createElement('div'); nfRow.className = 'row row-full'; + const nfCard = document.createElement('div'); nfCard.className = 'card'; + const nfHdr = document.createElement('div'); nfHdr.className = 'card-header'; + const nfTitle = document.createElement('div'); nfTitle.className = 'card-title'; nfTitle.textContent = 'New vs Fixed per Snapshot'; + nfHdr.appendChild(nfTitle); nfCard.appendChild(nfHdr); + + // Build pts: for each consecutive pair (prev→snap), compute new/fixed + const nfPts = []; + for (let i = 0; i < snaps.length; i++) { + const prev = i === 0 ? null : snaps[i - 1]; + const s = snaps[i]; + if (!prev) { nfPts.push({ new: 0, fixed: 0 }); continue; } + const pSet = new Set(prev.fingerprints ?? []); + const sSet = new Set(s.fingerprints ?? []); + nfPts.push({ + new: (s.fingerprints ?? []).filter(fp => !pSet.has(fp)).length, + fixed: (prev.fingerprints ?? []).filter(fp => !sSet.has(fp)).length, + }); + } + // Last point: last snap → current + nfPts.push({ new: newCount, fixed: fixedCount }); + + const nfWrap = document.createElement('div'); nfWrap.className = 'trend-chart-area'; + nfWrap.innerHTML = buildNewFixedSvg(nfPts); + const leg = document.createElement('div'); leg.className = 'nf-legend'; + leg.innerHTML = `NewFixed`; + nfCard.appendChild(nfWrap); nfCard.appendChild(leg); + nfRow.appendChild(nfCard); view.appendChild(nfRow); + } } - 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); + // ── Line chart (total over time) ──────────────────────────────────────── + if (snaps.length >= 2) { + const chartSnaps = cur ? [...snaps, { timestamp: new Date().toISOString(), total: cur.total, counts: cur.counts }] : snaps; + const chartRow = document.createElement('div'); chartRow.className = 'row row-full'; + const card = document.createElement('div'); card.className = '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(chartSnaps)); + chartRow.appendChild(card); view.appendChild(chartRow); + } + + // ── Per-severity sparkline mini cards ─────────────────────────────────── + if (snaps.length >= 2) { + const sevSparkRow = document.createElement('div'); + sevSparkRow.className = 'kpi-sev-row'; + for (const sev of SEVERITY_ORDER) { + const sparkData = [...snaps.map(s => s.counts?.[sev] ?? 0)]; + if (cur) sparkData.push(cur.counts?.[sev] ?? 0); + const card = document.createElement('div'); card.className = 'kpi-sev'; + const bar = document.createElement('div'); bar.className = 'kpi-sev-bar'; bar.style.background = SEVERITY_COLORS[sev]; + const info = document.createElement('div'); info.className = 'kpi-sev-info'; + const lbl = document.createElement('div'); lbl.className = 'kpi-sev-label'; lbl.style.color = SEVERITY_COLORS[sev]; lbl.textContent = sev; + const valNum = cur ? (cur.counts?.[sev] ?? 0) : (lastSnap.counts?.[sev] ?? 0); + const valEl = document.createElement('div'); valEl.className = 'kpi-sev-val'; valEl.style.color = SEVERITY_COLORS[sev]; valEl.textContent = valNum.toLocaleString(); + info.appendChild(lbl); info.appendChild(valEl); + card.appendChild(bar); card.appendChild(info); + const sparkWrap = document.createElement('div'); sparkWrap.className = 'kpi-spark'; + sparkWrap.innerHTML = buildSparklineSvg(sparkData, SEVERITY_COLORS[sev], 56, 24); + card.appendChild(sparkWrap); + sevSparkRow.appendChild(card); } - row.appendChild(card); - view.appendChild(row); + view.appendChild(sevSparkRow); } - // 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 minorEntries = computeTopN(allIssues, i => i.check_name ?? '—', 10); - row2.appendChild(buildBarCard('Top Check Names', minorEntries, '#7c5cff')); + const tbl = document.createElement('table'); tbl.className = 'snap-table'; + tbl.innerHTML = 'DateLabelTotalBCMajMinI'; + const tbody = document.createElement('tbody'); + + [...snaps].reverse().forEach((snap, idx) => { + const prev = snaps[snaps.length - 1 - idx - 1]; + const tr = document.createElement('tr'); + const dateTd = document.createElement('td'); dateTd.className = 'snap-ts'; dateTd.title = snap.timestamp; dateTd.textContent = fmtSnapDate(snap); + + 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 d2 = snap.total - prev.total; + totalTd.textContent = snap.total.toLocaleString(); + if (d2 !== 0) { + const sp = document.createElement('span'); sp.className = d2 > 0 ? 'delta-pos' : 'delta-neg'; + sp.textContent = ' ' + (d2 > 0 ? '+' : '') + d2; totalTd.appendChild(sp); + } + } 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..1a2643b --- /dev/null +++ b/src/historyManager.ts @@ -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 = { 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; + total: number; + derivedCount: number; + volatileCount: number; + } { + const counts: Record = { 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 }; + } +} diff --git a/src/sourcesViewProvider.ts b/src/sourcesViewProvider.ts index 111e4fe..cd1189e 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,10 @@ 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); + } else if (msg.type === 'openSourceFile' && msg.uri) { + await vscode.commands.executeCommand('vscode.open', vscode.Uri.parse(msg.uri)); } }); @@ -56,7 +68,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 +256,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 +342,18 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider {
No reports loaded.
+ +