Compare commits

...

12 commits

Author SHA1 Message Date
raf
c8b307341a
Merge pull request #20 from NotAShelf/notashelf/push-oszosmuyzyoo
allow sharing analysis and comparison pages independently
2026-04-16 09:49:11 +03:00
NotAShelf
735c4c7442 meta: auto-update dependencies 2026-04-16 06:49:00 +00:00
6484ded3d7
docs: improve 'usage instructions' for the web component
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib70b7d5101bb0e2b103e96770965570f6a6a6964
2026-04-16 09:40:28 +03:00
55f6a43dc2
packages/core: revise readme; elaborate on supported Nix impls
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibff4509c97cc205854ecab05320618f06a6a6964
2026-04-16 09:40:27 +03:00
5482cd9df1
packages: move sharing logic from web to core; reuse in web
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I26cb6848615a5788f2196fc1675bc22d6a6a6964
2026-04-16 09:40:26 +03:00
8d7bd7bb05
packages/web: allow sharing analysis and comparison views independently
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I16408e124ebcb36e8452d9c261f6d42f6a6a6964
2026-04-16 09:40:25 +03:00
raf
06b78c6b0e
Merge pull request #18 from NotAShelf/notashelf/push-swyypvuqxmyv
packages/web: add paste-from-clipboard in compare mode; fix overlaps
2026-04-16 08:31:55 +03:00
d26a70dacd
various: eliminate floating-point noise before displaying
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic14f4c0a9e0bcbe3460bbecc670f713d6a6a6964
2026-04-14 15:20:53 +03:00
6102e75a9e
packages/web: allow editing invalid JSON paste; show snapshots list with 1+ entries
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I627907cd02575fb6e84c7837cf1712746a6a6964
2026-04-14 14:52:31 +03:00
1541046669
jj commit -m "packages/web: add paste/upload snapshot flows; unify inputs"
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7cb4e2ee6962e5d343cd52da6524b3596a6a6964
2026-04-14 14:52:23 +03:00
697f1f1c73
packages/web: add paste-from-clipboard in compare mode; fix overlaps
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7311a4592d148ebbe8ab72b5091b82a46a6a6964
2026-04-14 11:00:15 +03:00
f7457fb9a4
meta: detect react version and fine-grain files for eslint
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iad7f3ee0e5e826f00c4c95874371bfcd6a6a6964
2026-04-14 11:00:14 +03:00
14 changed files with 1234 additions and 1188 deletions

View file

@ -1,4 +1,4 @@
<!-- markdownlint-disable MD033 -->
<!-- markdownlint-disable MD033 MD041 -->
<div align="center">
<h1 id="header">nix-evaluator-stats</h1>
@ -20,21 +20,29 @@
![Demo](./assets/ns-demo.png)
`nix-evaluator-stats`, or "ns" for short, is a pretty visualiser for the Nix
evaluator stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations.
It takes the resulting JSON data from your Nix invocation with the relevant
variables, and provides a ✨ pretty ✨ dashboard-like visual with the ability to
compare your "snapshots" of benchmarks. Besides looking nice, it is helpful in
`nix-evaluator-stats`, or "`ns`", is a pretty visualiser for the Nix evaluator
stats export from `NIX_SHOW_STATS` and `NIX_COUNT_CALLS` invocations. It takes
the resulting JSON data from your Nix invocation with the relevant variables,
and provides a ✨ pretty ✨ dashboard-like visual with the ability to compare
your "snapshots" of benchmarks. Besides looking nice, it is helpful in
collecting statistics about your Nix commands and tracking performance
regressions in subsequent exports.
regressions in subsequent exports with the comparison feature with snapshot of
your analyses.
## Usage
NS provides both a web application for pretty visuals, and a terminal client (a
TUI) for rendering the statistics from your terminal.
NS is primarily a web application for pretty visuals, with a terminal client
(both a CLI and a TUI) planned for rendering statistics or sharing your analyses
with others.
### Web
<!--markdownlint-disable MD059-->
You can find the site [here](https://notashelf.github.io/nix-evaluator-stats/).
<!--markdownlint-enable MD059-->
Usage instructions are provided in the initial page. Simply navigate to the site
and provide the JSON export (or a file) to render the statistics. The number of
rendered fields might differ based on your Nix version or implementation (Lix,
@ -61,19 +69,15 @@ Once you hit "Load", the JSON will be parsed and you'll be looking at a dash
board of your export. By using the snapshot feature, i.e., saving a particular
analysis you may compare two _named_ analyses at a time.
> [!NOTE]
> `nix-evaluator-stats` was created in a very short duration, and there might be
> UI bugs or areas where UI polish is very clearly missing. Please crate an
> issue if the generated graph or the site UI looks off. Thanks :)
#### Snapshots
Snapshots are an "experimental" (just means they're new and unpolished) feature
that lets you save an analysis in your browser storage with a name to be used
later on in the comparison view. At least two **named** analyses (i.e.,
snapshots) are required for an analysis.
Snapshots are a new feature that lets you save an analysis in your browser
storage with a name to be used later on in the comparison view. At least two
**named** analyses (i.e., snapshots) are required for an analysis.
You can save an analysis as a snapshot from the save button on the bottom right.
You can save can analysis as a snapshot from the save button on the bottom
right. Alternatively, you can visit the "compare" page and paste your JSON
directly.
## Hacking

View file

@ -5,6 +5,12 @@ export default [
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: ['**/*.{ts,tsx}'],
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
settings: {
react: {
version: 'detect',
},
},
},
];

View file

@ -20,7 +20,7 @@ stdenv.mkDerivation (finalAttrs: {
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname src;
hash = "sha256-zhdgC+sjIUAUsStG3H8RfVSrbhQG62a3zXeIPTUoGbI=";
hash = "sha256-KLOhMupWtsD4GO7bndVQ152e1c9t3yu7iCebJrf5dzg=";
fetcherVersion = 3; # https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
};

View file

@ -1,7 +1,11 @@
# @ns/core
Core types and parsing logic for Nix evaluator statistics. This package is
framework-agnostic and can be used in any JavaScript/TypeScript environment.
Core types, parsing logic for Nix evaluator statistics, and various bits from
the web component that the CLI may interact with in the future. The `@ns/core`
package contains the truly "generic" logic that can be used around the
workspace, or even outside it. This package is framework-agnostic and can be
used in any JavaScript/TypeScript environment. Which is to say that you can use
it to make your own statistics viewer without doing any heavy lifting.
## Usage
@ -20,8 +24,14 @@ const change = calculateChange(stats.nrThunks, previousStats.nrThunks);
console.log(`Thunks changed by ${change.percent.toFixed(2)}%`);
```
## Version Compatibility
## Nix Version/Implementation Compatibility
The parser handles different Nix implementations (Nix, Lix, Snix, etc.) by
checking for field existence in the raw JSON, since not all implementations
expose the same statistics.
There are currently 4 competing implementations of Nix, and only two of those
are considered first-class. The parser handles different implementations the
best it can by checking the field existence in the raw JSON, but it is not able
to handle new fields. It is unfortunate that not all implementations expose the
same statistics.
Worth noting that besides Lix and Nix, there are and will likely be more
implementations. If you want _yours_ to be supported as a first class citizen,
please submit a pull request.

View file

@ -16,5 +16,8 @@
},
"devDependencies": {
"typescript": "^6.0.2"
},
"dependencies": {
"lzutf8": "^0.6.3"
}
}

View file

@ -1,2 +1,3 @@
export * from './types';
export * from './parser';
export * from './share';

View file

@ -0,0 +1,47 @@
import LZUTF8 from 'lzutf8';
export type ShareState =
| { type: 'analysis'; data: Record<string, unknown>; name: string }
| {
type: 'compare';
left: Record<string, unknown>;
right: Record<string, unknown>;
leftName: string;
rightName: string;
};
export function encodeShareUrl(state: ShareState): string {
const json = JSON.stringify(state);
const encoded = LZUTF8.compress(json, { outputEncoding: 'Base64' }) as string;
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
export function decodeShareUrl(encoded: string): ShareState | null {
try {
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (base64.length % 4)) % 4;
base64 += '='.repeat(padLength);
const json = LZUTF8.decompress(base64, {
inputEncoding: 'Base64',
outputEncoding: 'String',
}) as string | null;
if (!json) {
return null;
}
const state = JSON.parse(json) as ShareState;
if (state.type === 'analysis') {
if (!state.data || !state.name) {
return null;
}
} else if (state.type === 'compare') {
if (!state.left || !state.right || !state.leftName || !state.rightName) {
return null;
}
} else {
return null;
}
return state;
} catch {
return null;
}
}

View file

@ -6,10 +6,11 @@ export function formatBytes(bytes: number, precision = 2): string {
}
export function formatNumber(num: number, precision = 2): string {
if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K';
return num.toString();
if (num >= 1e9) return parseFloat((num / 1e9).toFixed(precision)).toString() + 'B';
if (num >= 1e6) return parseFloat((num / 1e6).toFixed(precision)).toString() + 'M';
if (num >= 1e3) return parseFloat((num / 1e3).toFixed(precision)).toString() + 'K';
const factor = Math.pow(10, precision);
return (Math.round(num * factor) / factor).toString();
}
export function formatTime(seconds: number, precision = 2): string {

View file

@ -1,22 +1,96 @@
import { Component, For, createSignal, createMemo, Show } from 'solid-js';
import { ComparisonEntry, calculateChange } from '@ns/core';
import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import { ComparisonEntry, calculateChange, StatsData } from '@ns/core';
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
import XIcon from 'lucide-solid/icons/x';
import ShareIcon from 'lucide-solid/icons/share';
import FileUpload from './FileUpload';
interface ComparisonViewProps {
entries: ComparisonEntry[];
onSelect: (entry: ComparisonEntry) => void;
onDelete: (id: number) => void;
precision?: number;
pasteMode: 'advance' | 'replace';
onPasteModeChange: (mode: 'advance' | 'replace') => void;
onPasteStats: (text: string, name: string) => ComparisonEntry | null;
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void;
onGenerateShareUrl: (left: ComparisonEntry, right: ComparisonEntry) => void;
initialLeftId?: number | null;
initialRightId?: number | null;
onInitialSelectionUsed?: () => void;
}
const ComparisonView: Component<ComparisonViewProps> = props => {
const prec = () => props.precision ?? 2;
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
const [showPasteModal, setShowPasteModal] = createSignal(false);
const [pasteError, setPasteError] = createSignal('');
const [pasteName, setPasteName] = createSignal('');
const [pendingPasteText, setPendingPasteText] = createSignal('');
const handlePaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
setPendingPasteText(text);
setPasteName(`Snapshot ${props.entries.length + 1}`);
setShowPasteModal(true);
} catch {
// Silently ignore invalid JSON on paste
}
};
onMount(() => {
document.addEventListener('paste', handlePaste);
if (props.initialLeftId !== null && props.initialLeftId !== undefined) {
const left = props.entries.find(e => e.id === props.initialLeftId);
if (left) {
setLeftEntry(left);
}
}
if (props.initialRightId !== null && props.initialRightId !== undefined) {
const right = props.entries.find(e => e.id === props.initialRightId);
if (right) {
setRightEntry(right);
}
}
if (props.initialLeftId != null || props.initialRightId != null) {
props.onInitialSelectionUsed?.();
}
});
onCleanup(() => {
document.removeEventListener('paste', handlePaste);
});
const confirmPaste = () => {
const entry = props.onPasteStats(pendingPasteText(), pasteName());
if (!entry) {
setPasteError('Failed to process pasted statistics');
return;
}
if (props.pasteMode === 'advance') {
if (rightEntry()) {
setLeftEntry(rightEntry());
}
setRightEntry(entry);
} else {
setRightEntry(entry);
}
setShowPasteModal(false);
setPasteError('');
};
const comparison = createMemo(() => {
const left = leftEntry();
@ -100,6 +174,7 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
return (
<div class="comparison-view">
<Show when={props.entries.length >= 2}>
<div class="comparison-controls">
<div class="compare-selector">
<label>Baseline</label>
@ -122,7 +197,34 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</For>
</select>
</div>
<div class="compare-paste-toggle">
<button
class={props.pasteMode === 'advance' ? 'active' : ''}
onClick={() => props.onPasteModeChange('advance')}
title="Paste shifts current to baseline"
>
Auto
</button>
<button
class={props.pasteMode === 'replace' ? 'active' : ''}
onClick={() => props.onPasteModeChange('replace')}
title="Paste replaces current only"
>
Replace
</button>
</div>
<Show when={leftEntry() && rightEntry()}>
<button
class="share-btn"
onClick={() => props.onGenerateShareUrl(leftEntry()!, rightEntry()!)}
title="Copy share URL to clipboard"
>
<ShareIcon size={16} />
Share
</button>
</Show>
</div>
</Show>
<Show when={props.entries.length > 0}>
<div class="snapshots-list">
@ -140,9 +242,36 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</div>
</Show>
<Show
when={props.entries.length >= 2}
fallback={
<div class="compare-upload-section">
<FileUpload
onFileLoad={props.onFileLoad}
onTextLoad={props.onTextLoad}
snapshots={props.entries}
onLoadSnapshot={props.onSelect}
/>
<Show when={props.entries.length === 1}>
<div class="compare-more-needed">
<div class="compare-placeholder-hint">
Upload or paste one more snapshot to start comparing
</div>
</div>
</Show>
</div>
}
>
<Show
when={leftEntry() && rightEntry()}
fallback={<div class="compare-placeholder">Select two snapshots to compare metrics</div>}
fallback={
<div class="compare-placeholder">
<div>Select two snapshots to compare metrics</div>
<div class="compare-placeholder-hint">
Paste JSON stats here (Ctrl+V) while in compare mode
</div>
</div>
}
>
<div class="comparison-table">
<div class="compare-header">
@ -164,12 +293,12 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.leftValue)
? formatBytes(row.leftValue, prec())
: row.format === 'time'
? formatTime(row.leftValue)
? formatTime(row.leftValue, prec())
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)
? formatPercent(row.leftValue, prec())
: formatNumber(row.leftValue, prec())
}
>
<span class="missing-value">N/A</span>
@ -180,12 +309,12 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.rightValue)
? formatBytes(row.rightValue, prec())
: row.format === 'time'
? formatTime(row.rightValue)
? formatTime(row.rightValue, prec())
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)
? formatPercent(row.rightValue, prec())
: formatNumber(row.rightValue, prec())
}
>
<span class="missing-value">N/A</span>
@ -205,7 +334,13 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
<Show when={!row.isReduction}>
<ArrowUpIcon size={14} />
</Show>
{Math.abs(row.change).toFixed(prec())}%
{parseFloat(
(
Math.round(Math.abs(row.change) * Math.pow(10, prec())) /
Math.pow(10, prec())
).toFixed(prec()),
)}
%
</span>
</Show>
}
@ -238,6 +373,58 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</Show>
</div>
</Show>
</Show>
<Show when={showPasteModal()}>
<div
class="modal-overlay"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
<div class="modal" onClick={e => e.stopPropagation()}>
<div class="modal-header">
<h3>Paste Statistics</h3>
<button
class="close-btn"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
<XIcon size={20} />
</button>
</div>
<input
type="text"
class="snapshot-name-input"
placeholder="Enter snapshot name..."
value={pasteName()}
onInput={e => setPasteName(e.currentTarget.value)}
onKeyDown={e => e.key === 'Enter' && confirmPaste()}
autofocus
/>
<Show when={pasteError()}>
<div class="error">{pasteError()}</div>
</Show>
<div class="modal-actions">
<button
class="cancel-btn"
onClick={() => {
setShowPasteModal(false);
setPasteError('');
}}
>
Cancel
</button>
<button class="confirm-btn" onClick={confirmPaste}>
{props.entries.length >= 2 ? 'Save & Compare' : 'Save Snapshot'}
</button>
</div>
</div>
</div>
</Show>
</div>
);
};

View file

@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock';
interface FileUploadProps {
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void;
showHelp: boolean;
onToggleHelp: () => void;
snapshots?: ComparisonEntry[];
onLoadSnapshot?: (entry: ComparisonEntry) => void;
}
export default function FileUpload(props: FileUploadProps) {
const [textInput, setTextInput] = createSignal('');
const [isTextMode, setIsTextMode] = createSignal(false);
const [isTextMode, setIsTextMode] = createSignal(true);
const [error, setError] = createSignal('');
const [showHelp, setShowHelp] = createSignal(false);
const handleFile = async (e: Event) => {
const target = e.target as HTMLInputElement;
@ -49,11 +48,11 @@ export default function FileUpload(props: FileUploadProps) {
</div>
<h2>Load Statistics</h2>
<div class="help-link" onClick={props.onToggleHelp}>
<div class="help-link" onClick={() => setShowHelp(!showHelp())}>
How do I use this?
</div>
<Show when={props.showHelp}>
<Show when={showHelp()}>
<div class="help-panel">
<h4>Generating Stats</h4>
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix &gt; stats.json</code>
@ -85,6 +84,18 @@ export default function FileUpload(props: FileUploadProps) {
class="json-input"
value={textInput()}
onInput={e => setTextInput(e.currentTarget.value)}
onPaste={e => {
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
e.preventDefault();
props.onTextLoad(text);
} catch {
setTextInput(text);
setError('Invalid JSON in clipboard');
}
}}
/>
<button class="load-btn" onClick={handleTextLoad}>
Load

View file

@ -49,7 +49,10 @@ const ThunkChart: Component<ThunkChartProps> = props => {
<div class="ratio-bar">
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
</div>
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}%</span>
<span class="ratio-label">
Avoidance rate:{' '}
{Math.round(avoidedRatio() * 100 * Math.pow(10, prec())) / Math.pow(10, prec())}%
</span>
</div>
</div>
);

View file

@ -4,8 +4,16 @@ import SaveIcon from 'lucide-solid/icons/save';
import UploadIcon from 'lucide-solid/icons/upload';
import Trash2Icon from 'lucide-solid/icons/trash-2';
import XIcon from 'lucide-solid/icons/x';
import ShareIcon from 'lucide-solid/icons/link-2';
import FileUpload from './components/FileUpload';
import { StatsData, ComparisonEntry, parseStats } from '@ns/core';
import {
StatsData,
ComparisonEntry,
parseStats,
encodeShareUrl,
decodeShareUrl,
ShareState,
} from '@ns/core';
import './styles.css';
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
@ -29,10 +37,15 @@ function App() {
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
const [snapshotName, setSnapshotName] = createSignal('');
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
const [showHelp, setShowHelp] = createSignal(false);
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
const [precision, setPrecision] = createSignal(2);
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
const [isLoading, setIsLoading] = createSignal(true);
const [showOverrideModal, setShowOverrideModal] = createSignal(false);
const [pendingOverrideText, setPendingOverrideText] = createSignal('');
const [showShareToast, setShowShareToast] = createSignal(false);
const [initialLeftId, setInitialLeftId] = createSignal<number | null>(null);
const [initialRightId, setInitialRightId] = createSignal<number | null>(null);
const STORAGE_KEY = 'ns-data';
@ -62,10 +75,58 @@ function App() {
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
setPrecision(parsed.precision);
}
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
setPasteMode(parsed.pasteMode);
}
}
} catch (e) {
console.warn('Failed to load saved data:', e);
}
const params = new URLSearchParams(window.location.search);
const shareParam = params.get('share');
if (shareParam) {
const shareState = decodeShareUrl(shareParam);
if (shareState) {
try {
if (shareState.type === 'analysis') {
const data = parseStats(shareState.data);
setCurrentStats(data);
setCurrentRaw(shareState.data);
setSnapshotName(shareState.name);
setView('analysis');
window.history.replaceState({}, '', window.location.pathname);
} else if (shareState.type === 'compare') {
const leftData = parseStats(shareState.left);
const rightData = parseStats(shareState.right);
const leftId = Date.now();
const rightId = Date.now() + 1;
const leftEntry: ComparisonEntry = {
id: leftId,
name: shareState.leftName,
data: leftData,
raw: shareState.left,
timestamp: new Date(),
};
const rightEntry: ComparisonEntry = {
id: rightId,
name: shareState.rightName,
data: rightData,
raw: shareState.right,
timestamp: new Date(),
};
setSnapshots([leftEntry, rightEntry]);
setInitialLeftId(leftId);
setInitialRightId(rightId);
setView('compare');
window.history.replaceState({}, '', window.location.pathname);
}
} catch (e) {
console.warn('Failed to load shared data:', e);
}
}
}
setIsLoading(false);
const handleKeyDown = (e: KeyboardEvent) => {
@ -76,8 +137,28 @@ function App() {
};
window.addEventListener('keydown', handleKeyDown);
const handleAnalysisPaste = (e: ClipboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const text = e.clipboardData?.getData('text');
if (!text) return;
try {
JSON.parse(text);
} catch {
return;
}
if (view() === 'analysis' && currentStats()) {
setPendingOverrideText(text);
setShowOverrideModal(true);
}
};
document.addEventListener('paste', handleAnalysisPaste);
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('paste', handleAnalysisPaste);
};
});
@ -89,6 +170,7 @@ function App() {
snaps: ComparisonEntry[],
v: 'analysis' | 'compare',
prec: number,
pm: 'advance' | 'replace',
) => {
try {
localStorage.setItem(
@ -99,6 +181,7 @@ function App() {
currentRaw: raw,
view: v,
precision: prec,
pasteMode: pm,
}),
);
} catch (e) {
@ -115,10 +198,11 @@ function App() {
const snaps = snapshots();
const v = view();
const prec = precision();
const pm = pasteMode();
if (isLoading()) return;
saveToStorage(stats, raw, snaps, v, prec);
saveToStorage(stats, raw, snaps, v, prec, pm);
});
const saveSnapshot = () => {
@ -138,6 +222,25 @@ function App() {
setShowSaveDialog(false);
};
const shareAnalysis = () => {
const stats = currentStats();
const raw = currentRaw();
if (!stats || !raw) return;
const name = snapshotName().trim() || `Analysis ${Date.now()}`;
const state: ShareState = { type: 'analysis', data: raw, name };
const encoded = encodeShareUrl(state);
const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`;
navigator.clipboard
.writeText(url)
.then(() => {
setShowShareToast(true);
setTimeout(() => setShowShareToast(false), 2000);
})
.catch(err => {
console.error('Failed to copy share URL:', err);
});
};
const deleteSnapshot = (id: number) => {
setSnapshots(prev => prev.filter(e => e.id !== id));
};
@ -171,6 +274,76 @@ function App() {
}
};
const handlePasteStats = (text: string, name: string): ComparisonEntry | null => {
let raw: Record<string, unknown>;
let data: StatsData;
try {
raw = JSON.parse(text);
data = parseStats(raw);
} catch (e) {
console.error('Failed to parse pasted stats:', e);
return null;
}
const entry: ComparisonEntry = {
id: Date.now(),
name: name.trim() || `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
return entry;
};
const handleCompareFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
const entry: ComparisonEntry = {
id: Date.now(),
name: `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
};
const handleCompareTextLoad = (text: string) => {
try {
const raw = JSON.parse(text);
const data = parseStats(raw);
const entry: ComparisonEntry = {
id: Date.now(),
name: `Snapshot ${snapshots().length + 1}`,
data,
raw,
timestamp: new Date(),
};
setSnapshots(prev => [...prev, entry]);
} catch (e) {
console.error('Failed to parse stats:', e);
}
};
const handleGenerateShareUrl = (left: ComparisonEntry, right: ComparisonEntry) => {
const state: ShareState = {
type: 'compare',
left: left.raw,
right: right.raw,
leftName: left.name,
rightName: right.name,
};
const encoded = encodeShareUrl(state);
const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`;
navigator.clipboard
.writeText(url)
.then(() => {
setShowShareToast(true);
setTimeout(() => setShowShareToast(false), 2000);
})
.catch(err => {
console.error('Failed to copy share URL:', err);
});
};
return (
<div class="app">
<header class="header">
@ -181,11 +354,7 @@ function App() {
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
Analysis
</button>
<button
class={view() === 'compare' ? 'active' : ''}
onClick={() => setView('compare')}
disabled={snapshots().length < 2}
>
<button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
Compare ({snapshots().length})
</button>
<Show when={snapshots().length > 0}>
@ -223,8 +392,6 @@ function App() {
<FileUpload
onFileLoad={handleFileLoad}
onTextLoad={loadFromText}
showHelp={showHelp()}
onToggleHelp={() => setShowHelp(!showHelp())}
snapshots={snapshots()}
onLoadSnapshot={loadSnapshot}
/>
@ -255,6 +422,30 @@ function App() {
</div>
</div>
</Show>
<Show when={showOverrideModal()}>
<div class="modal-overlay" onClick={() => setShowOverrideModal(false)}>
<div class="modal" onClick={e => e.stopPropagation()}>
<h3>Override Analysis?</h3>
<p style={{ color: 'var(--text-secondary)', 'margin-bottom': '1rem' }}>
This will replace the current analysis with pasted data.
</p>
<div class="modal-actions">
<button class="cancel-btn" onClick={() => setShowOverrideModal(false)}>
Cancel
</button>
<button
class="confirm-btn"
onClick={() => {
loadFromText(pendingOverrideText());
setShowOverrideModal(false);
}}
>
Override
</button>
</div>
</div>
</div>
</Show>
</Show>
</Show>
@ -264,6 +455,18 @@ function App() {
onSelect={entry => setCurrentStats(entry.data)}
onDelete={deleteSnapshot}
precision={precision()}
pasteMode={pasteMode()}
onPasteModeChange={setPasteMode}
onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad}
onGenerateShareUrl={handleGenerateShareUrl}
initialLeftId={initialLeftId()}
initialRightId={initialRightId()}
onInitialSelectionUsed={() => {
setInitialLeftId(null);
setInitialRightId(null);
}}
/>
</Show>
</Show>
@ -329,11 +532,18 @@ function App() {
>
<SaveIcon size={20} />
</button>
<button class="action-btn share" onClick={shareAnalysis} title="Share Analysis">
<ShareIcon size={20} />
</button>
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
<UploadIcon size={20} />
</button>
</div>
</Show>
<Show when={showShareToast()}>
<div class="toast">Share URL copied to clipboard!</div>
</Show>
</div>
);
}

View file

@ -183,6 +183,7 @@ body {
align-items: center;
gap: 2rem;
padding-top: 4rem;
width: 100%;
}
.upload-card {
@ -443,6 +444,16 @@ body {
transform: scale(1.05);
}
.action-btn.share {
background: var(--accent);
color: white;
}
.action-btn.share:hover {
background: var(--accent-hover);
transform: scale(1.05);
}
.action-btn.clear {
background: var(--bg-secondary);
color: var(--text-secondary);
@ -824,6 +835,9 @@ body {
color: var(--text-primary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
line-height: 1.2;
}
.metric-label {
@ -983,6 +997,8 @@ body {
color: var(--text-primary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
}
.chart-legend {
@ -1029,6 +1045,8 @@ body {
color: var(--text-primary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
}
.legend-percent {
@ -1036,6 +1054,8 @@ body {
font-size: 0.75rem;
text-align: right;
min-width: 40px;
word-break: break-word;
overflow-wrap: break-word;
}
.donut-chart svg {
@ -1130,6 +1150,8 @@ body {
color: var(--text-secondary);
min-width: 80px;
text-align: right;
word-break: break-word;
overflow-wrap: break-word;
}
.gc-stats {
@ -1157,6 +1179,8 @@ body {
color: var(--text-primary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
}
/* Operations Chart */
@ -1213,6 +1237,8 @@ body {
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
text-align: right;
word-break: break-word;
overflow-wrap: break-word;
}
/* Thunk Chart */
@ -1267,6 +1293,8 @@ body {
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
text-align: right;
word-break: break-word;
overflow-wrap: break-word;
}
.thunk-ratio {
@ -1294,6 +1322,8 @@ body {
.ratio-label {
font-size: 0.75rem;
color: var(--text-muted);
word-break: break-word;
overflow-wrap: break-word;
}
.dashboard-grid {
@ -1341,6 +1371,8 @@ body {
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
text-align: right;
word-break: break-word;
overflow-wrap: break-word;
}
/* Top Lists */
@ -1384,6 +1416,9 @@ body {
color: var(--text-secondary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
text-align: right;
}
.top-item .location {
@ -1441,6 +1476,56 @@ body {
justify-content: center;
}
.compare-paste-toggle {
display: flex;
gap: 0.25rem;
align-items: flex-end;
padding: 0 0.5rem;
}
.compare-paste-toggle button {
height: calc(0.75rem * 2 + 1rem + 2px);
padding: 0 0.625rem;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 0.375rem;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.compare-paste-toggle button:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.compare-paste-toggle button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.share-btn {
display: flex;
align-items: center;
gap: 0.375rem;
height: calc(0.75rem * 2 + 1rem + 2px);
padding: 0 0.875rem;
background: var(--accent);
border: none;
color: white;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.share-btn:hover {
background: var(--accent-hover);
}
.compare-placeholder {
text-align: center;
padding: 4rem;
@ -1450,6 +1535,25 @@ body {
border-radius: 0.75rem;
}
.compare-placeholder-hint {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.compare-upload-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
.compare-more-needed {
text-align: center;
padding: 1rem;
}
.comparison-table {
background: var(--card-bg);
border: 1px solid var(--border-color);
@ -1510,10 +1614,14 @@ body {
color: var(--text-secondary);
font-family: var(--font-nums);
font-variant-numeric: tabular-nums;
word-break: break-word;
overflow-wrap: break-word;
}
.compare-row .col-change {
text-align: right;
word-break: break-word;
overflow-wrap: break-word;
}
.change-value {
@ -1690,3 +1798,30 @@ body {
font-size: 0.75rem;
color: var(--text-muted);
}
.toast {
position: fixed;
bottom: 6rem;
left: 50%;
transform: translateX(-50%);
padding: 0.875rem 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
box-shadow: var(--shadow);
z-index: 1000;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(0.5rem);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

1172
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff