/** * Project Stats plugin — module entry point. * * api shape: * api.context — current PluginContext snapshot * api.onContextChange(cb) → unsubscribe — called on theme/project/session changes * api.rpc(method, path, body?) → Promise — proxied to this plugin's server subprocess */ const PALETTE = [ '#6366f1','#22d3ee','#f59e0b','#10b981', '#f43f5e','#a78bfa','#fb923c','#34d399', '#60a5fa','#e879f9','#facc15','#4ade80', ]; function ensureAssets() { if (document.getElementById('ps-font')) return; const link = document.createElement('link'); link.id = 'ps-font'; link.rel = 'stylesheet'; link.href = 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'; document.head.appendChild(link); const s = document.createElement('style'); s.id = 'ps-styles'; s.textContent = ` @keyframes ps-grow { from { width: 0 } } @keyframes ps-fadeup { from { opacity:0; transform:translateY(6px) } to { opacity:1; transform:translateY(0) } } @keyframes ps-pulse { 0%,100% { opacity:.3 } 50% { opacity:.6 } } .ps-bar { animation: ps-grow 0.75s cubic-bezier(.16,1,.3,1) both } .ps-up { animation: ps-fadeup 0.4s ease both } .ps-skel { animation: ps-pulse 1.6s ease infinite } `; document.head.appendChild(s); } const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace"; function fmt(b) { if (b < 1024) return `${b}B`; if (b < 1048576) return `${(b / 1024).toFixed(1)}KB`; return `${(b / 1048576).toFixed(1)}MB`; } function ago(ms) { const s = Math.floor((Date.now() - ms) / 1000); if (s < 60) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } function countUp(el, target, formatFn, duration = 900) { const start = performance.now(); function tick(now) { const p = Math.min((now - start) / duration, 1); const ease = 1 - (1 - p) ** 3; el.textContent = formatFn(Math.round(target * ease)); if (p < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); } function v(dark) { return dark ? { bg: '#08080f', surface: '#0e0e1a', border: '#1a1a2c', text: '#e2e0f0', muted: '#52507a', accent: '#fbbf24', dim: 'rgba(251,191,36,0.1)', } : { bg: '#fafaf9', surface: '#ffffff', border: '#e8e6f0', text: '#0f0e1a', muted: '#9490b0', accent: '#d97706', dim: 'rgba(217,119,6,0.08)', }; } function skeletonRows(c, widths) { return widths.map((w, i) => `
`).join(''); } export function mount(container, api) { ensureAssets(); let cache = null; const root = document.createElement('div'); Object.assign(root.style, { height: '100%', overflowY: 'auto', boxSizing: 'border-box', padding: '24px', fontFamily: MONO, }); container.appendChild(root); function render(ctx, stats) { const c = v(ctx.theme === 'dark'); root.style.background = c.bg; root.style.color = c.text; if (!ctx.project) { root.innerHTML = `
~/.projects/
└── (none selected)
select a project
`; return; } if (!stats) { root.innerHTML = `
${ctx.project.name}
${ctx.project.path}
${[0, 1, 2].map(i => `
${skeletonRows(c, [65])} ${skeletonRows(c, [40])}
`).join('')}
${[75, 55, 38, 22, 14].map((w, i) => `
`).join('')}
`; return; } const maxCount = stats.byExtension[0]?.[1] || 1; root.innerHTML = `
${ctx.project.name}
${ctx.project.path}
${[ ['files', stats.totalFiles, n => n.toLocaleString()], ['lines', stats.totalLines, n => n.toLocaleString()], ['total size', stats.totalSize, fmt], ].map(([label, val, fmt], i) => `
0
${label}
`).join('')}
file types
${stats.byExtension.map(([ext, count], i) => `
${ext}
${count}
`).join('')}
${[ ['largest files', stats.largest.map(f => [f.name, fmt(f.size)])], ['recently modified', stats.recent.map(f => [f.name, ago(f.mtime)])], ].map(([title, rows], ci) => `
${title}
${rows.map(([name, val], ri) => `
${name}
${val}
`).join('')}
`).join('')}
`; // Count-up animations for metric cards [ [0, stats.totalFiles, n => n.toLocaleString()], [1, stats.totalLines, n => n.toLocaleString()], [2, stats.totalSize, fmt], ].forEach(([i, val, formatFn]) => { const el = root.querySelector(`#ps-m-${i}`); if (el) countUp(el, val, formatFn); }); root.querySelector('#ps-refresh')?.addEventListener('click', () => { cache = null; load(api.context); }); } async function load(ctx) { if (!ctx.project) { cache = null; render(ctx, null); return; } render(ctx, null); try { const stats = await api.rpc('GET', `stats?path=${encodeURIComponent(ctx.project.path)}`); cache = { projectPath: ctx.project.path, stats }; render(ctx, stats); } catch (err) { const c = v(ctx.theme === 'dark'); root.style.background = c.bg; root.innerHTML = `
✗ ${err.message}
`; } } load(api.context); const unsubscribe = api.onContextChange(ctx => { if (ctx.project?.path === cache?.projectPath) render(ctx, cache?.stats ?? null); else load(ctx); }); container._psUnsubscribe = unsubscribe; } export function unmount(container) { if (typeof container._psUnsubscribe === 'function') { container._psUnsubscribe(); delete container._psUnsubscribe; } container.innerHTML = ''; }