diff --git a/.vscode/launch.json b/.vscode/launch.json index 3d984bf..e1d1ca1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "args": ["--extensionDevelopmentPath=${workspaceFolder}", "${workspaceFolder}/testdata"], "outFiles": ["${workspaceFolder}/out/**/*.js"], - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "Build & seed history" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6c50e84..f1e7e17 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,16 +2,27 @@ "version": "2.0.0", "tasks": [ { + "label": "npm: compile", "type": "npm", "script": "compile", - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "silent" - }, + "group": { "kind": "build", "isDefault": true }, + "presentation": { "reveal": "silent" }, "problemMatcher": "$tsc" + }, + { + "label": "Seed test snapshots", + "type": "shell", + "command": "node scripts/gen-test-snapshots.js", + "presentation": { "reveal": "silent", "panel": "shared" }, + "problemMatcher": [] + }, + { + "label": "Build & seed history", + "dependsOrder": "sequence", + "dependsOn": ["npm: compile", "Seed test snapshots"], + "group": { "kind": "build", "isDefault": false }, + "presentation": { "reveal": "silent" }, + "problemMatcher": [] } ] } diff --git a/.vscodeignore b/.vscodeignore index e4d2a16..ffd19bd 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -4,6 +4,8 @@ src/** testdata/** out/**/*.map node_modules/** +!node_modules/@vscode/codicons/dist/codicon.css +!node_modules/@vscode/codicons/dist/codicon.ttf **/.gitignore **/.vscodeignore tsconfig.json diff --git a/media/webview.css b/media/webview.css index 0d53ce9..122d4fa 100644 --- a/media/webview.css +++ b/media/webview.css @@ -230,6 +230,19 @@ button { font-family: inherit; color: inherit; background: none; border: none; c .kpi-sev-delta.up { color: var(--sev-critical); } .kpi-sev-delta.down { color: #4ade80; } .kpi-sev-delta.flat { color: var(--fg-dim); } +.kpi-delta.up[style*="cursor"], +.kpi-sev-delta.up[style*="cursor"] { text-decoration: underline dotted; } +.kpi-delta.up[style*="cursor"]:hover, +.kpi-sev-delta.up[style*="cursor"]:hover { text-decoration: underline; } + +/* ── New issue badge ─────────────────────────────────────────────────────────── */ +.new-badge { + display: inline-flex; align-items: center; + padding: 1px 5px; border-radius: 4px; + font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + background: rgba(248,113,113,0.12); color: #f87171; border: 1px solid rgba(248,113,113,0.35); + margin-left: 4px; flex-shrink: 0; vertical-align: middle; +} /* ── Layout grid ─────────────────────────────────────────────────────────────── */ .row { display: grid; gap: var(--gap); margin-bottom: var(--gap); } @@ -305,7 +318,13 @@ button { font-family: inherit; color: inherit; background: none; border: none; c gap: 10px; align-items: center; font-size: 12px; + border-radius: 4px; + padding: 2px 4px; + margin: -2px -4px; } +.bar-row.clickable { cursor: pointer; } +.bar-row.clickable:hover { background: var(--surface-2); } +.kpi-sev[style*="cursor"]:hover { background: var(--surface-2); border-radius: var(--r-md); } .bar-label { font-family: var(--font-mono); @@ -455,6 +474,10 @@ button { font-family: inherit; color: inherit; background: none; border: none; c .qf-chip:hover { border-color: var(--border-strong); color: var(--fg); } .qf-chip.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } +.qf-chip.new-toggle { color: #f87171; border-color: rgba(248,113,113,0.3); } +.qf-chip.new-toggle:hover:not(:disabled) { border-color: #f87171; } +.qf-chip.new-toggle.active { background: rgba(248,113,113,0.15); color: #f87171; border-color: #f87171; font-weight: 600; } +.qf-chip.disabled, .qf-chip:disabled { opacity: 0.4; cursor: default; } /* Active filter chips (above table) */ #active-filters { diff --git a/media/webview.js b/media/webview.js index 8909643..e884231 100644 --- a/media/webview.js +++ b/media/webview.js @@ -34,7 +34,7 @@ let historySnapshots = []; /** @type {any} */ let currentState = null; -/** @type {{severities:Set, categories:Set|null, quickTerms:Set, sourceFiles:Set, search:string, custom:Record|null>}} */ +/** @type {{severities:Set, categories:Set|null, quickTerms:Set, sourceFiles:Set, search:string, custom:Record|null>, newOnly:boolean}} */ let filters = { severities: new Set(SEVERITY_ORDER), categories: null, @@ -42,6 +42,7 @@ let filters = { sourceFiles: new Set(), search: '', custom: {}, + newOnly: false, }; let config = { @@ -207,6 +208,7 @@ function handleFocusIssue(issueId) { filters.sourceFiles = new Set(allFiles.map(/** @param {any} f */ f => f.uri)); filters.search = ''; filters.custom = {}; + filters.newOnly = false; expandedIds.add(issueId); newlyExpandedIds.add(issueId); @@ -269,13 +271,16 @@ function buildOverviewView(container) { // KPI grid (3 large) const kpiGrid = document.createElement('div'); kpiGrid.className = 'kpi-grid'; - kpiGrid.appendChild(makeKPICard('Total Issues', total.toLocaleString(), null, null, totalDelta, totalSpark)); + kpiGrid.appendChild(makeKPICard('Total Issues', total.toLocaleString(), null, null, totalDelta, totalSpark, + () => goToIssues({}))); 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)); + kpiGrid.appendChild(makeKPICard('Blocker', counts.blocker, 'blocker', '--sev-blocker', blockerDelta, blockerSpark, + () => goToIssues({ severities: new Set(['blocker']) }))); 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)); + kpiGrid.appendChild(makeKPICard('Critical', counts.critical, 'critical', '--sev-critical', critDelta, critSpark, + () => goToIssues({ severities: new Set(['critical']) }))); view.appendChild(kpiGrid); // KPI sev row (4 small) @@ -290,6 +295,15 @@ function buildOverviewView(container) { for (const s of sevSmall) { const card = document.createElement('div'); card.className = 'kpi-sev'; + if (s.key) { + const sevKey = s.key; + card.style.cursor = 'pointer'; + card.title = `View ${s.label} issues`; + card.addEventListener('click', (e) => { + if (/** @type {HTMLElement} */(e.target).closest('.kpi-sev-delta')) return; + goToIssues({ severities: new Set([sevKey]) }); + }); + } const bar = document.createElement('div'); bar.className = 'kpi-sev-bar'; bar.style.background = s.color; @@ -311,6 +325,17 @@ function buildOverviewView(container) { const cls = d > 0 ? 'up' : d < 0 ? 'down' : 'flat'; deltaEl.className = `kpi-sev-delta ${cls}`; deltaEl.textContent = (d > 0 ? '▲ +' : d < 0 ? '▼ ' : '— ') + d; + if (d > 0 && s.key) { + const sevKey = s.key; + deltaEl.style.cursor = 'pointer'; + deltaEl.title = `Show ${d} new ${s.label} issue${d !== 1 ? 's' : ''}`; + deltaEl.addEventListener('click', () => { + filters.severities = new Set([sevKey]); + filters.newOnly = true; + renderSeverityFilter(); + setView('issues'); + }); + } info.appendChild(deltaEl); } card.appendChild(bar); @@ -344,11 +369,13 @@ function buildOverviewView(container) { // Category bar const catEntries = computeTopN(allIssues, i => (i.categories ?? [])[0] ?? '—', 8); - row1.appendChild(buildBarCard('By Category', catEntries, '#22d3ee')); + row1.appendChild(buildBarCard('By Category', catEntries, '#22d3ee', + cat => () => goToIssues({ categories: cat !== '—' ? new Set([cat]) : null }))); // Check name bar const checkEntries = computeTopN(allIssues, i => i.check_name ?? '—', 8); - row1.appendChild(buildBarCard('Top Check Names', checkEntries, '#7c5cff')); + row1.appendChild(buildBarCard('Top Check Names', checkEntries, '#7c5cff', + name => () => goToIssues({ quickTerms: new Set([name]) }))); view.appendChild(row1); @@ -357,12 +384,15 @@ function buildOverviewView(container) { row2.className = 'row row-2col'; const fileEntries = computeTopN(allIssues, i => basename(i.location?.path ?? '—'), 10); - const fileCard = buildBarCard('Top Files by Issue Count', fileEntries, 'var(--accent)'); - row2.appendChild(fileCard); + row2.appendChild(buildBarCard('Top Files by Issue Count', fileEntries, 'var(--accent)', + file => () => goToIssues({ quickTerms: new Set([file]) }))); const srcEntries = computeTopN(allIssues, i => i.sourceFile ?? '—', 8); - const srcCard = buildBarCard('By Source Report', srcEntries, '#fb923c'); - row2.appendChild(srcCard); + row2.appendChild(buildBarCard('By Source Report', srcEntries, '#fb923c', + src => () => { + const file = allFiles.find(/** @param {any} f */ f => f.filename === src); + goToIssues({ sourceFiles: file ? new Set([file.uri]) : undefined }); + })); view.appendChild(row2); container.appendChild(view); @@ -375,8 +405,9 @@ function buildOverviewView(container) { * @param {string|null} colorVar * @param {number|null} [delta] * @param {number[]|null} [sparkData] + * @param {(() => void)|null} [onClick] */ -function makeKPICard(label, value, sev, colorVar, delta, sparkData) { +function makeKPICard(label, value, sev, colorVar, delta, sparkData, onClick) { const card = document.createElement('div'); card.className = 'kpi'; const lbl = document.createElement('div'); @@ -394,6 +425,11 @@ function makeKPICard(label, value, sev, colorVar, delta, sparkData) { valEl.className = 'kpi-value'; if (colorVar) valEl.style.color = `var(${colorVar})`; valEl.textContent = String(value); + if (onClick) { + valEl.style.cursor = 'pointer'; + valEl.title = 'View in issues list'; + valEl.addEventListener('click', onClick); + } body.appendChild(valEl); if (delta !== null && delta !== undefined) { @@ -402,6 +438,16 @@ function makeKPICard(label, value, sev, colorVar, delta, sparkData) { deltaEl.className = `kpi-delta ${cls}`; const arrow = delta > 0 ? '▲' : delta < 0 ? '▼' : '—'; deltaEl.textContent = `${arrow} ${delta > 0 ? '+' : ''}${delta} vs last snapshot`; + if (delta > 0) { + deltaEl.style.cursor = 'pointer'; + deltaEl.title = `Show ${delta} new issue${delta !== 1 ? 's' : ''}`; + deltaEl.addEventListener('click', () => { + filters.severities = sev ? new Set([sev]) : new Set(SEVERITY_ORDER); + filters.newOnly = true; + renderSeverityFilter(); + setView('issues'); + }); + } body.appendChild(deltaEl); } @@ -564,8 +610,9 @@ function computeTopN(issues, keyFn, limit) { * @param {string} title * @param {Array<[string,number]>} entries * @param {string} color + * @param {((label:string) => () => void)|null} [getRowClick] */ -function buildBarCard(title, entries, color) { +function buildBarCard(title, entries, color, getRowClick) { const card = document.createElement('div'); card.className = 'card'; const hdr = document.createElement('div'); @@ -574,6 +621,12 @@ function buildBarCard(title, entries, color) { titleEl.className = 'card-title'; titleEl.textContent = title; hdr.appendChild(titleEl); + if (getRowClick) { + const action = document.createElement('div'); + action.className = 'card-action'; + action.textContent = 'Click row to filter →'; + hdr.appendChild(action); + } card.appendChild(hdr); if (entries.length === 0) { const empty = document.createElement('div'); @@ -587,7 +640,11 @@ function buildBarCard(title, entries, color) { const max = entries.reduce((m, [, v]) => Math.max(m, v), 1); for (const [label, value] of entries) { const row = document.createElement('div'); - row.className = 'bar-row'; + row.className = 'bar-row' + (getRowClick ? ' clickable' : ''); + if (getRowClick) { + row.addEventListener('click', getRowClick(label)); + row.title = label; + } const lbl = document.createElement('div'); lbl.className = 'bar-label'; lbl.textContent = label; @@ -637,6 +694,11 @@ function buildIssuesView(container) { wrap.appendChild(toolbar); } + // New issues qf-bar (always present when snapshots exist) + const newBar = document.createElement('div'); + newBar.id = 'filter-new'; + wrap.appendChild(newBar); + // Category qf-bar if (config.showCategoryFilter) { const catBar = document.createElement('div'); @@ -711,6 +773,7 @@ function buildIssuesView(container) { // Populate filters and table if (config.showSeverityFilter) renderSeverityFilter(); + renderNewFilter(); if (config.showCategoryFilter) renderCategoryFilter(); if (config.showCheckNameFilter) renderCheckNameFilter(); renderCustomColumnFilters(); @@ -1014,9 +1077,10 @@ function buildTrendsView(container) { 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) { + /** @param {string} lbl @param {number} val @param {string} color @param {string} [sub] @param {string} [stripe] @param {(() => void)|null} [onClick] */ + function trendCard(lbl, val, color, sub, stripe, onClick) { const c = document.createElement('div'); c.className = 'card'; + if (onClick) { c.style.cursor = 'pointer'; c.title = 'View in issues list'; c.addEventListener('click', onClick); } 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; @@ -1038,7 +1102,8 @@ function buildTrendsView(container) { 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('New Issues', newCount, 'var(--sev-critical)', 'vs last snapshot' + fpWarn, 'var(--sev-critical)', + newCount > 0 ? () => goToIssues({ newOnly: true }) : null)); 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); @@ -1102,6 +1167,9 @@ function buildTrendsView(container) { 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 sevKey = sev; + card.style.cursor = 'pointer'; card.title = `View ${sev} issues`; + card.addEventListener('click', () => goToIssues({ severities: new Set([sevKey]) })); 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; @@ -1226,6 +1294,7 @@ function parseTerms() { function getFiltered() { const typedTerms = parseTerms(); return allIssues.filter((issue) => { + if (filters.newOnly && !issue.isNew) return false; if (!filters.severities.has(issue.severity ?? 'info')) return false; if (!filters.sourceFiles.has(issue.sourceUri)) return false; if (filters.categories !== null) { @@ -1343,6 +1412,21 @@ function getSorted(issues) { }); } +// ── Navigation helper ───────────────────────────────────────────────────────── + +/** Navigate to Issues view with a clean, specific filter set. */ +function goToIssues(opts) { + filters.severities = opts.severities ?? new Set(SEVERITY_ORDER); + filters.categories = opts.categories ?? null; + filters.quickTerms = opts.quickTerms ?? new Set(); + filters.search = opts.search ?? ''; + filters.newOnly = opts.newOnly ?? false; + filters.sourceFiles = opts.sourceFiles !== undefined + ? opts.sourceFiles + : new Set(allFiles.map(/** @param {any} f */ f => f.uri)); + setView('issues'); +} + // ── Filter renderers ────────────────────────────────────────────────────────── function renderSeverityFilter() { @@ -1371,12 +1455,40 @@ function renderSeverityFilter() { filters.severities.add(sev); } renderSeverityFilter(); + renderActiveFilters(); renderTable(); }); container.appendChild(btn); } } +function renderNewFilter() { + const container = document.getElementById('filter-new'); + if (!container) return; + container.innerHTML = ''; + if (historySnapshots.length === 0) return; + const newCount = allIssues.filter(i => i.isNew).length; + container.className = 'qf-bar'; + const lbl = document.createElement('span'); + lbl.className = 'toolbar-label'; + lbl.textContent = 'New'; + container.appendChild(lbl); + const chip = document.createElement('button'); + chip.className = 'qf-chip new-toggle' + (filters.newOnly ? ' active' : '') + (newCount === 0 ? ' disabled' : ''); + chip.textContent = newCount > 0 ? `▲ ${newCount} new issue${newCount !== 1 ? 's' : ''}` : 'no new issues'; + if (newCount === 0) chip.disabled = true; + chip.title = newCount > 0 + ? 'Show only issues not in last snapshot' + : 'No new issues compared to last snapshot'; + chip.addEventListener('click', () => { + filters.newOnly = !filters.newOnly; + renderNewFilter(); + renderActiveFilters(); + renderTable(); + }); + container.appendChild(chip); +} + function renderCategoryFilter() { const container = document.getElementById('filter-categories'); if (!container) return; @@ -1463,6 +1575,25 @@ function renderActiveFilters() { const container = document.getElementById('active-filters'); if (!container) return; container.innerHTML = ''; + if (filters.newOnly) { + container.appendChild(makeChip('new issues', () => { + filters.newOnly = false; + renderNewFilter(); renderActiveFilters(); renderTable(); + })); + } + if (filters.severities.size < SEVERITY_ORDER.length) { + for (const sev of SEVERITY_ORDER) { + if (!filters.severities.has(sev)) continue; + container.appendChild(makeChip(`sev: ${sev}`, () => { + if (filters.severities.size > 1) { + filters.severities.delete(sev); + } else { + filters.severities = new Set(SEVERITY_ORDER); + } + renderSeverityFilter(); renderActiveFilters(); renderTable(); + })); + } + } if (filters.categories !== null) { for (const cat of filters.categories) { container.appendChild(makeChip('cat: ' + cat, () => toggleCategoryFilter(cat))); @@ -1560,9 +1691,15 @@ function renderTable() { e.stopPropagation(); const isIsolated = filters.severities.size === 1 && filters.severities.has(sev); filters.severities = isIsolated ? new Set(SEVERITY_ORDER) : new Set([sev]); - renderSeverityFilter(); renderTable(); + renderSeverityFilter(); renderActiveFilters(); renderTable(); }); td.appendChild(badge); + if (issue.isNew) { + const nb = document.createElement('span'); + nb.className = 'new-badge'; + nb.textContent = 'new'; + td.appendChild(nb); + } break; } case 'categories': { diff --git a/package-lock.json b/package-lock.json index f34ed0a..2f775e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "codeclimate-visualiser", "version": "0.1.0", + "dependencies": { + "@vscode/codicons": "^0.0.45" + }, "devDependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", @@ -1330,6 +1333,11 @@ "node": ">=20.0.0" } }, + "node_modules/@vscode/codicons": { + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz", + "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==" + }, "node_modules/@vscode/vsce": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.1.tgz", diff --git a/package.json b/package.json index 7c32dde..0023430 100644 --- a/package.json +++ b/package.json @@ -169,5 +169,8 @@ }, "overrides": { "undici": "5" + }, + "dependencies": { + "@vscode/codicons": "^0.0.45" } } diff --git a/scripts/gen-test-snapshots.js b/scripts/gen-test-snapshots.js new file mode 100644 index 0000000..fa3c719 --- /dev/null +++ b/scripts/gen-test-snapshots.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Generates 10 realistic test snapshots in testdata history file. +// Run before debug session to populate Trends view for manual testing. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const HISTORY_PATH = path.join(__dirname, '..', 'testdata', '.vscode', 'codeclimate-visualiser.history.ndjson'); + +const SEV_MAP = { + 'sg-sqli-001': 'blocker', 'sg-sqli-002': 'blocker', 'sg-secret-001': 'blocker', + 'sg-crypto-001': 'critical','sg-cred-001': 'critical', 'sg-token-001': 'critical', + 'sg-auth-001': 'critical', 'sg-bo-001': 'critical', 'sg-bo-002': 'critical', + 'sg-io-001': 'critical', 'sg-uaf-001': 'critical', 'sg-xss-001': 'critical', + 'sg-ssrf-001': 'critical', 'sg-path-001': 'major', + 'cp-bo-001': 'major', 'cp-null-001': 'major', 'cp-dbz-001': 'major', + 'cp-global-001': 'major', 'cp-bo-002': 'major', 'cp-uaf-001': 'major', + 'cp-ec-001': 'major', 'cp-global-002': 'major', 'cp-arr-001': 'major', + 'cp-fsb-001': 'major', 'cp-ptr-001': 'minor', 'cp-magic-001': 'info', + 'cp-io-001': 'minor', 'cp-race-001': 'major', 'cp-leak-001': 'major', + 'eslint-no-var-001': 'minor', 'eslint-no-any-001': 'minor', + 'eslint-eqeqeq-001': 'minor', 'eslint-no-any-002': 'minor', + 'eslint-returntype-001': 'minor','eslint-complexity-001': 'major', + 'eslint-console-001': 'info', 'eslint-fp-001': 'minor', + 'eslint-no-any-003': 'minor', 'eslint-unused-001': 'info', + 'eslint-sql-001': 'major', 'eslint-sql-002': 'major', + 'eslint-async-001': 'minor', +}; + +// Stable core fingerprints present in all snapshots +const CORE = [ + 'sg-sqli-001','sg-sqli-002','sg-secret-001','sg-crypto-001','sg-cred-001', + 'sg-token-001','sg-bo-002','sg-io-001', + 'cp-bo-001','cp-null-001','cp-dbz-001','cp-global-001','cp-uaf-001','cp-ec-001', + 'cp-global-002','cp-arr-001','cp-fsb-001','cp-magic-001','cp-io-001', + 'eslint-no-var-001','eslint-no-any-001','eslint-eqeqeq-001','eslint-complexity-001', + 'eslint-sql-001','eslint-sql-002', +]; + +// Each snapshot: weeks ago + label + extra fingerprints beyond core +const CONFIGS = [ + { w: 11, label: 'v0.8.0', extra: [] }, + { w: 9, label: '', extra: ['eslint-no-any-002','eslint-returntype-001','cp-bo-002'] }, + { w: 8, label: 'v0.9.0', extra: ['eslint-no-any-002','eslint-returntype-001','cp-bo-002','sg-uaf-001','cp-ptr-001'] }, + { w: 7, label: '', extra: ['eslint-no-any-002','cp-bo-002','sg-uaf-001'] }, + { w: 6, label: '', extra: ['eslint-no-any-002','cp-bo-002','sg-uaf-001','sg-path-001','cp-race-001'] }, + { w: 5, label: 'v1.0.0', extra: ['eslint-no-any-002','eslint-returntype-001','eslint-fp-001','cp-bo-002','sg-uaf-001','sg-auth-001','sg-bo-001','cp-ptr-001'] }, + { w: 4, label: '', extra: ['eslint-no-any-002','eslint-returntype-001','eslint-fp-001','cp-bo-002','sg-uaf-001','sg-auth-001','sg-bo-001','cp-ptr-001','eslint-async-001'] }, + { w: 3, label: '', extra: ['eslint-no-any-002','eslint-fp-001','cp-bo-002','sg-uaf-001','sg-auth-001','sg-bo-001'] }, + { w: 2, label: 'v1.1.0', extra: ['eslint-no-any-002','eslint-returntype-001','eslint-fp-001','eslint-no-any-003','cp-bo-002','sg-uaf-001','sg-auth-001','sg-bo-001','cp-ptr-001','cp-leak-001','eslint-unused-001'] }, + { w: 1, label: '', extra: ['eslint-no-any-002','eslint-returntype-001','eslint-fp-001','eslint-no-any-003','cp-bo-002','sg-uaf-001','sg-auth-001','sg-bo-001','cp-ptr-001'] }, +]; + +const now = Date.now(); +const WEEK = 7 * 24 * 60 * 60 * 1000; + +const lines = CONFIGS.map(cfg => { + const fps = [...new Set([...CORE, ...cfg.extra])]; + const counts = { blocker: 0, critical: 0, major: 0, minor: 0, info: 0 }; + for (const fp of fps) counts[SEV_MAP[fp] ?? 'info']++; + const snap = { + id: crypto.randomUUID(), + timestamp: new Date(now - cfg.w * WEEK).toISOString(), + ...(cfg.label ? { label: cfg.label } : {}), + sources: ['eslint-report.json', 'semgrep-report.json', 'codeparser-report.json'], + counts, + total: fps.length, + nativeCount: fps.length, + derivedCount: 0, + volatileCount: 0, + fingerprints: fps, + }; + return JSON.stringify(snap); +}); + +fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true }); +fs.writeFileSync(HISTORY_PATH, lines.join('\n') + '\n', 'utf-8'); +console.log(`✓ ${lines.length} test snapshots → ${path.relative(process.cwd(), HISTORY_PATH)}`); diff --git a/src/extension.ts b/src/extension.ts index 7d2dd18..922dffd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -146,6 +146,7 @@ export function activate(context: vscode.ExtensionContext): void { } const sourcesView = new SourcesViewProvider( + context.extensionUri, issueManager, autoLoadFromConfig, (issueId) => { panel.show(); panel.focusIssue(issueId); }, @@ -181,6 +182,7 @@ export function activate(context: vscode.ExtensionContext): void { editor.selection = new vscode.Selection(pos, pos); editor.revealRange(new vscode.Range(pos, pos), vscode.TextEditorRevealType.InCenter); }, + () => historyManager?.loadHistory() ?? [], ); context.subscriptions.push(decorationProvider, panel, logChannel, vscode.window.registerWebviewViewProvider(SourcesViewProvider.viewId, sourcesView)); diff --git a/src/historyManager.ts b/src/historyManager.ts index 1a2643b..e2cda16 100644 --- a/src/historyManager.ts +++ b/src/historyManager.ts @@ -91,6 +91,11 @@ export class HistoryManager { this.rewrite(this.loadHistory().map(s => s.id === id ? { ...s, label: label || undefined } : s)); } + resolveIssueFingerprint(issue: IssueWithSource): string | null { + const { fp, source } = resolveFingerprint(issue); + return source === 'volatile' ? null : fp; + } + computeCurrentState(issues: IssueWithSource[]): { fingerprints: string[]; counts: Record; diff --git a/src/sourcesViewProvider.ts b/src/sourcesViewProvider.ts index cd1189e..3c09664 100644 --- a/src/sourcesViewProvider.ts +++ b/src/sourcesViewProvider.ts @@ -9,11 +9,13 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { private historySnapshots: HistorySnapshot[] = []; constructor( + private readonly extensionUri: vscode.Uri, 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, + private readonly historyLoader?: () => HistorySnapshot[], ) { issueManager.onChange(() => this.update()); } @@ -25,11 +27,22 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { resolveWebviewView(webviewView: vscode.WebviewView): void { this.view = webviewView; - webviewView.webview.options = { enableScripts: true }; - webviewView.webview.html = this.buildHtml(); + const codiconsDistUri = vscode.Uri.joinPath(this.extensionUri, 'node_modules', '@vscode', 'codicons', 'dist'); + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [codiconsDistUri], + }; + webviewView.webview.html = this.buildHtml(webviewView.webview); + + if (this.historyLoader) { + this.historySnapshots = this.historyLoader(); + } webviewView.onDidChangeVisibility(() => { - if (webviewView.visible) this.update(); + if (webviewView.visible) { + if (this.historyLoader) this.historySnapshots = this.historyLoader(); + this.update(); + } }); webviewView.webview.onDidReceiveMessage(async (msg: { @@ -71,13 +84,17 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { this.view.webview.postMessage({ type: 'update', files, issues, customColumns, history: this.historySnapshots }); } - private buildHtml(): string { + private buildHtml(webview: vscode.Webview): string { const nonce = getNonce(); + const codiconsUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'node_modules', '@vscode', 'codicons', 'dist', 'codicon.css') + ); return ` - + + @@ -286,6 +305,7 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { Reports +
@@ -298,9 +318,10 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider { Issues - - - + + + +
@@ -343,11 +364,11 @@ export class SourcesViewProvider implements vscode.WebviewViewProvider {
No reports loaded.