mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
/**
|
|
* 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) => `
|
|
<div class="ps-skel" style="
|
|
height:10px;width:${w}%;background:${c.muted};border-radius:2px;
|
|
margin-bottom:8px;animation-delay:${i * 0.1}s
|
|
"></div>`).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 = `
|
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:50%;gap:14px">
|
|
<pre style="font-size:0.75rem;color:${c.muted};opacity:0.5;line-height:1.6;text-align:center">~/.projects/
|
|
└── (none selected)</pre>
|
|
<div style="font-size:0.72rem;color:${c.muted};letter-spacing:0.1em;text-transform:uppercase">select a project</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
if (!stats) {
|
|
root.innerHTML = `
|
|
<div style="margin-bottom:24px">
|
|
<div style="font-size:1.3rem;font-weight:700">${ctx.project.name}<span style="color:${c.accent}">▌</span></div>
|
|
<div style="font-size:0.7rem;color:${c.muted};margin-top:4px">${ctx.project.path}</div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
|
|
${[0, 1, 2].map(i => `
|
|
<div style="background:${c.surface};border:1px solid ${c.border};border-radius:3px;padding:18px">
|
|
${skeletonRows(c, [65])}
|
|
${skeletonRows(c, [40])}
|
|
</div>`).join('')}
|
|
</div>
|
|
<div style="background:${c.surface};border:1px solid ${c.border};border-radius:3px;padding:18px;margin-bottom:12px">
|
|
${[75, 55, 38, 22, 14].map((w, i) => `
|
|
<div style="display:flex;gap:10px;align-items:center;margin-bottom:7px">
|
|
<div class="ps-skel" style="width:44px;height:8px;background:${c.muted};border-radius:2px;animation-delay:${i*0.08}s"></div>
|
|
<div class="ps-skel" style="width:${w}%;height:4px;background:${c.muted};border-radius:1px;animation-delay:${i*0.08}s"></div>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const maxCount = stats.byExtension[0]?.[1] || 1;
|
|
|
|
root.innerHTML = `
|
|
<div class="ps-up" style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px">
|
|
<div style="min-width:0;flex:1">
|
|
<div style="font-size:1.3rem;font-weight:700;letter-spacing:-0.02em;word-break:break-all">
|
|
${ctx.project.name}<span style="color:${c.accent}">▌</span>
|
|
</div>
|
|
<div style="font-size:0.7rem;color:${c.muted};margin-top:4px;word-break:break-all">${ctx.project.path}</div>
|
|
</div>
|
|
<button id="ps-refresh" style="
|
|
flex-shrink:0;margin-left:16px;padding:5px 12px;
|
|
background:transparent;border:1px solid ${c.border};
|
|
color:${c.muted};font-family:${MONO};font-size:0.7rem;
|
|
border-radius:3px;cursor:pointer;letter-spacing:0.05em;
|
|
transition:all 0.15s;
|
|
" onmouseover="this.style.borderColor='${c.accent}';this.style.color='${c.accent}'"
|
|
onmouseout="this.style.borderColor='${c.border}';this.style.color='${c.muted}'">
|
|
↻ refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
|
|
${[
|
|
['files', stats.totalFiles, n => n.toLocaleString()],
|
|
['lines', stats.totalLines, n => n.toLocaleString()],
|
|
['total size', stats.totalSize, fmt],
|
|
].map(([label, val, fmt], i) => `
|
|
<div class="ps-up" style="
|
|
background:${c.surface};border:1px solid ${c.border};
|
|
border-radius:3px;padding:18px;animation-delay:${i*0.06}s
|
|
">
|
|
<div id="ps-m-${i}" style="
|
|
font-size:1.9rem;font-weight:700;letter-spacing:-0.04em;
|
|
line-height:1;color:${c.text};
|
|
">0</div>
|
|
<div style="font-size:0.65rem;color:${c.muted};margin-top:6px;letter-spacing:0.1em;text-transform:uppercase">${label}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
|
|
<div class="ps-up" style="
|
|
background:${c.surface};border:1px solid ${c.border};
|
|
border-radius:3px;padding:18px;margin-bottom:12px;animation-delay:0.12s
|
|
">
|
|
<div style="font-size:0.62rem;color:${c.muted};letter-spacing:0.12em;text-transform:uppercase;margin-bottom:14px">file types</div>
|
|
${stats.byExtension.map(([ext, count], i) => `
|
|
<div class="ps-up" style="display:flex;align-items:center;gap:10px;margin-bottom:7px;animation-delay:${0.15+i*0.035}s">
|
|
<div style="width:50px;font-size:0.68rem;text-align:right;color:${c.muted};flex-shrink:0">${ext}</div>
|
|
<div style="flex:1;height:4px;background:${c.border};border-radius:1px;overflow:hidden">
|
|
<div class="ps-bar" style="
|
|
height:100%;width:${Math.round((count/maxCount)*100)}%;
|
|
background:${PALETTE[i % PALETTE.length]};
|
|
animation-delay:${0.18+i*0.035}s;border-radius:1px;
|
|
"></div>
|
|
</div>
|
|
<div style="width:30px;font-size:0.68rem;color:${c.muted};text-align:right;flex-shrink:0">${count}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
|
${[
|
|
['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) => `
|
|
<div class="ps-up" style="
|
|
background:${c.surface};border:1px solid ${c.border};
|
|
border-radius:3px;padding:18px;animation-delay:${0.18+ci*0.05}s
|
|
">
|
|
<div style="font-size:0.62rem;color:${c.muted};letter-spacing:0.12em;text-transform:uppercase;margin-bottom:12px">${title}</div>
|
|
${rows.map(([name, val], ri) => `
|
|
<div class="ps-up" style="
|
|
display:flex;justify-content:space-between;align-items:baseline;
|
|
padding:4px 0;border-bottom:1px solid ${c.border};font-size:0.7rem;
|
|
animation-delay:${0.2+ci*0.05+ri*0.03}s;gap:8px;
|
|
">
|
|
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;opacity:0.75" title="${name}">${name}</div>
|
|
<div style="color:${c.accent};flex-shrink:0;opacity:0.9">${val}</div>
|
|
</div>`).join('')}
|
|
</div>`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div style="padding:24px;font-size:0.78rem;color:${c.accent};opacity:0.8;font-family:${MONO}">
|
|
✗ ${err.message}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
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 = '';
|
|
}
|