diff --git a/media/webview.css b/media/webview.css index 122d4fa..32d26e8 100644 --- a/media/webview.css +++ b/media/webview.css @@ -70,6 +70,7 @@ button { font-family: inherit; color: inherit; background: none; border: none; c #dashboard { padding: 24px 28px 40px; max-width: 1400px; + min-width: 500px; } /* ── Dashboard header ────────────────────────────────────────────────────────── */ @@ -212,6 +213,7 @@ button { font-family: inherit; color: inherit; background: none; border: none; c display: flex; align-items: center; gap: 10px; + min-width: 0; } .kpi-sev-bar { width: 3px; align-self: stretch; border-radius: 2px; flex-shrink: 0; } @@ -257,6 +259,7 @@ button { font-family: inherit; color: inherit; background: none; border: none; c border: 1px solid var(--border); border-radius: var(--r-md); padding: 18px 20px; + min-width: 200px; } .card-header { @@ -894,7 +897,7 @@ button { font-family: inherit; color: inherit; background: none; border: none; c .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%; } +.trend-chart-area { width:100%; overflow:hidden; min-width:0; } .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; } diff --git a/media/webview.js b/media/webview.js index afc9663..3c7229a 100644 --- a/media/webview.js +++ b/media/webview.js @@ -55,6 +55,7 @@ let config = { showCheckNameChart: true, showSourceChart: true, showFileChart: true, + maxChartSnapshots: 20, /** @type {Array<{name:string,index:number}>} */ customColumns: [], }; @@ -75,6 +76,8 @@ const snippetMeta = new Map(); let currentView = 'overview'; let navTabsReady = false; +/** @type {ResizeObserver|null} */ +let trendsResizeObserver = null; function setView(view) { currentView = view; @@ -1035,8 +1038,8 @@ function getChartTooltip() { return tip; } -/** @param {HTMLElement} wrapEl @param {number} n @param {number} svgW @param {number} PL @param {number} PR @param {(idx:number)=>string} getContent */ -function wireChartHover(wrapEl, n, svgW, PL, PR, getContent) { +/** @param {HTMLElement} wrapEl @param {number} n @param {number} _svgW @param {number} PL @param {number} PR @param {(idx:number)=>string} getContent */ +function wireChartHover(wrapEl, n, _svgW, PL, PR, getContent) { const tip = getChartTooltip(); wrapEl.style.position = 'relative'; const ch = document.createElement('div'); @@ -1044,12 +1047,16 @@ function wireChartHover(wrapEl, n, svgW, PL, PR, getContent) { wrapEl.appendChild(ch); wrapEl.addEventListener('mousemove', e => { - const rect = wrapEl.getBoundingClientRect(); - const scale = rect.width / svgW; - const relX = (e.clientX - rect.left) / scale; - const frac = Math.max(0, Math.min(1, (relX - PL) / (svgW - PL - PR))); + const svgEl = wrapEl.querySelector('svg'); + if (!svgEl) return; + const svgRect = svgEl.getBoundingClientRect(); + const wrapRect = wrapEl.getBoundingClientRect(); + const W = svgRect.width; + const relX = e.clientX - svgRect.left; + const frac = Math.max(0, Math.min(1, (relX - PL) / (W - PL - PR))); const idx = Math.max(0, Math.min(n - 1, Math.round(frac * (n - 1)))); - ch.style.left = ((PL + frac * (svgW - PL - PR)) * scale).toFixed(1) + 'px'; + const xInSvg = n < 2 ? PL + (W - PL - PR) / 2 : PL + (idx / (n - 1)) * (W - PL - PR); + ch.style.left = (svgRect.left - wrapRect.left + xInSvg).toFixed(1) + 'px'; ch.style.display = 'block'; tip.innerHTML = getContent(idx); tip.style.display = 'block'; @@ -1063,10 +1070,10 @@ function wireChartHover(wrapEl, n, svgW, PL, PR, getContent) { wrapEl.addEventListener('mouseleave', () => { tip.style.display = 'none'; ch.style.display = 'none'; }); } -function buildNewFixedSvg(pts) { +function buildNewFixedSvg(pts, W = 560) { // 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 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); @@ -1083,7 +1090,7 @@ function buildNewFixedSvg(pts) { ${lbl}`; }).join(''); - return ` + return ` ${gridY} @@ -1093,9 +1100,11 @@ function buildNewFixedSvg(pts) { } function buildTrendsView(container) { + if (trendsResizeObserver) { trendsResizeObserver.disconnect(); trendsResizeObserver = null; } container.innerHTML = ''; const view = document.createElement('div'); view.className = 'view'; + container.appendChild(view); const snaps = [...historySnapshots].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); @@ -1104,13 +1113,15 @@ function buildTrendsView(container) { 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; } // ── Current vs last snapshot diff ─────────────────────────────────────── const lastSnap = snaps[snaps.length - 1]; const cur = currentState; + const maxSnaps = config.maxChartSnapshots ?? 20; + /** @type {Array<{wrap: HTMLElement, build: (w:number)=>string}>} */ + const chartWrappers = []; if (cur) { const lastSet = new Set(lastSnap.fingerprints ?? []); @@ -1163,6 +1174,7 @@ function buildTrendsView(container) { // New vs Fixed area chart (snaps + current) if (snaps.length >= 1) { + const visSnaps = snaps.slice(-maxSnaps); 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'; @@ -1171,9 +1183,9 @@ function buildTrendsView(container) { // 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]; + for (let i = 0; i < visSnaps.length; i++) { + const prev = i === 0 ? null : visSnaps[i - 1]; + const s = visSnaps[i]; if (!prev) { nfPts.push({ new: 0, fixed: 0 }); continue; } const pSet = new Set(prev.fingerprints ?? []); const sSet = new Set(s.fingerprints ?? []); @@ -1186,33 +1198,38 @@ function buildTrendsView(container) { nfPts.push({ new: newCount, fixed: fixedCount }); const nfWrap = document.createElement('div'); nfWrap.className = 'trend-chart-area'; - nfWrap.innerHTML = buildNewFixedSvg(nfPts); - const nfLabels = [...snaps.map(s => s.label ? `${s.label} · ${fmtSnapDate(s)}` : fmtSnapDate(s)), 'Current']; - wireChartHover(nfWrap, nfPts.length, 560, 36, 12, idx => { + const leg = document.createElement('div'); leg.className = 'nf-legend'; + leg.innerHTML = `NewFixed`; + nfCard.appendChild(nfWrap); nfCard.appendChild(leg); + nfRow.appendChild(nfCard); view.appendChild(nfRow); + const nfW = Math.round(nfWrap.getBoundingClientRect().width) || 560; + nfWrap.insertAdjacentHTML('afterbegin', buildNewFixedSvg(nfPts, nfW)); + const nfLabels = [...visSnaps.map(s => s.label ? `${s.label} · ${fmtSnapDate(s)}` : fmtSnapDate(s)), 'Current']; + wireChartHover(nfWrap, nfPts.length, 0, 36, 12, idx => { const p = nfPts[idx]; return `
${nfLabels[idx] ?? ''}
` + `
▲ ${p.new} new
` + `
▼ ${p.fixed} fixed
`; }); - const leg = document.createElement('div'); leg.className = 'nf-legend'; - leg.innerHTML = `NewFixed`; - nfCard.appendChild(nfWrap); nfCard.appendChild(leg); - nfRow.appendChild(nfCard); view.appendChild(nfRow); + chartWrappers.push({ wrap: nfWrap, build: w => buildNewFixedSvg(nfPts, w) }); } } // ── 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 visSnaps = snaps.slice(-maxSnaps); + const chartSnaps = cur ? [...visSnaps, { timestamp: new Date().toISOString(), total: cur.total, counts: cur.counts }] : visSnaps; 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); const trendWrap = document.createElement('div'); trendWrap.className = 'trend-chart-area'; - trendWrap.innerHTML = buildTrendSvg(chartSnaps); card.appendChild(trendWrap); - wireChartHover(trendWrap, chartSnaps.length, 560, 44, 12, idx => { + chartRow.appendChild(card); view.appendChild(chartRow); + const trendW = Math.round(trendWrap.getBoundingClientRect().width) || 560; + trendWrap.insertAdjacentHTML('afterbegin', buildTrendSvg(chartSnaps, trendW)); + wireChartHover(trendWrap, chartSnaps.length, 0, 44, 12, idx => { const s = chartSnaps[idx]; const isLive = cur && idx === chartSnaps.length - 1; const lbl = isLive ? 'Current' : (s.label ? `${s.label} · ${fmtSnapDate(s)}` : fmtSnapDate(s)); @@ -1224,7 +1241,20 @@ function buildTrendsView(container) { `
Total: ${s.total}
` + (sevParts ? `
${sevParts}
` : ''); }); - chartRow.appendChild(card); view.appendChild(chartRow); + chartWrappers.push({ wrap: trendWrap, build: w => buildTrendSvg(chartSnaps, w) }); + } + + // ── Resize observer for trend charts ──────────────────────────────────── + if (chartWrappers.length > 0) { + trendsResizeObserver = new ResizeObserver(() => { + for (const { wrap, build } of chartWrappers) { + const w = Math.round(wrap.getBoundingClientRect().width); + if (w < 1) continue; + const old = wrap.querySelector('svg'); if (old) old.remove(); + wrap.insertAdjacentHTML('afterbegin', build(w)); + } + }); + trendsResizeObserver.observe(container); } // ── Per-severity sparkline mini cards ─────────────────────────────────── @@ -1315,12 +1345,10 @@ function buildTrendsView(container) { }); tbl.appendChild(tbody); tableCard.appendChild(tbl); tableRow.appendChild(tableCard); view.appendChild(tableRow); - - container.appendChild(view); } -function buildTrendSvg(snaps) { - const W = 560, H = 140, PL = 44, PR = 12, PT = 10, PB = 28; +function buildTrendSvg(snaps, W = 560) { + const 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); @@ -1350,7 +1378,7 @@ function buildTrendSvg(snaps) { return `${lbl}`; }).join(''); - return `${gridLines}${sevLines}${dots}${xLabels}`; + return `${gridLines}${sevLines}${dots}${xLabels}`; } // ── Filtering ───────────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index 18a2116..fc5c125 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,12 @@ "type": "boolean", "default": true, "description": "Show the 'Top Files' pie chart." + }, + "codeclimateVisualiser.maxChartSnapshots": { + "type": "number", + "default": 20, + "minimum": 1, + "description": "Maximum number of history snapshots displayed in trend charts." } } }, diff --git a/src/decorationProvider.ts b/src/decorationProvider.ts index c70c39d..92121cb 100644 --- a/src/decorationProvider.ts +++ b/src/decorationProvider.ts @@ -63,14 +63,12 @@ export class DecorationProvider implements vscode.Disposable { const sev: Severity = issue.severity ?? 'info'; const fullRange = new vscode.Range(beginLine, 0, Math.max(beginLine, endLine), Number.MAX_SAFE_INTEGER); - byS.get(sev)?.push({ - range: fullRange, - hoverMessage: new vscode.MarkdownString( - `**[${sev.toUpperCase()}]** \`${issue.check_name}\` \n` + - `${issue.description} \n\n` + - `*Lines ${beginLine + 1}–${endLine + 1} · ${issue.sourceFile}*`, - ), - }); + const dot: Record = { blocker: '🟣', critical: '🔴', major: '🟠', minor: '🟡', info: '🔵' }; + const md = new vscode.MarkdownString( + `${dot[sev]} **${issue.check_name}**` + + (issue.description ? `\n\n*${issue.description}*` : ''), + ); + byS.get(sev)?.push({ range: fullRange, hoverMessage: md }); } for (const [sev, opts] of byS.entries()) { diff --git a/src/webviewPanel.ts b/src/webviewPanel.ts index 58e534d..49a5ab7 100644 --- a/src/webviewPanel.ts +++ b/src/webviewPanel.ts @@ -100,6 +100,7 @@ export class CodeClimatePanel implements vscode.Disposable { showCheckNameChart: cfg.get('showCheckNameChart', true), showSourceChart: cfg.get('showSourceChart', true), showFileChart: cfg.get('showFileChart', true), + maxChartSnapshots: cfg.get('maxChartSnapshots', 20), customColumns: this.issueManager.getCustomColumns(), }, history,