diff --git a/eslint.config.mjs b/eslint.config.mjs index 7816ea6..bf54789 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,12 @@ export default [ ...tseslint.configs.recommended, eslintConfigPrettier, { + files: ['**/*.{ts,tsx}'], ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'], + settings: { + react: { + version: 'detect', + }, + }, }, ]; diff --git a/packages/ui-utils/src/formatters.ts b/packages/ui-utils/src/formatters.ts index f55309a..7764c4c 100644 --- a/packages/ui-utils/src/formatters.ts +++ b/packages/ui-utils/src/formatters.ts @@ -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 { diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 1c7941e..7bc516c 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -1,22 +1,75 @@ -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 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) => void; + onTextLoad: (text: string) => void; } const ComparisonView: Component = props => { const prec = () => props.precision ?? 2; const [leftEntry, setLeftEntry] = createSignal(null); const [rightEntry, setRightEntry] = createSignal(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); + }); + + 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,29 +153,47 @@ const ComparisonView: Component = props => { return (
-
-
- - + = 2}> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
-
- -
-
- - -
-
+ 0}>
@@ -141,101 +212,186 @@ const ComparisonView: Component = props => { Select two snapshots to compare metrics
} - > -
-
-
Metric
-
{leftEntry()?.name}
-
{rightEntry()?.name}
-
Change
-
- - {row => ( -
-
- {row.label} -
-
- - N/A - -
-
- - N/A - -
-
- —}> - - - - - - - - {Math.abs(row.change).toFixed(prec())}% - - - } - > - - N/A - - + when={props.entries.length >= 2} + fallback={ +
+ + +
+
+ Upload or paste one more snapshot to start comparing
- )} - -
+ +
+ } + > + +
Select two snapshots to compare metrics
+
+ Paste JSON stats here (Ctrl+V) while in compare mode +
+
+ } + > +
+
+
Metric
+
{leftEntry()?.name}
+
{rightEntry()?.name}
+
Change
+
+ + {row => ( +
+
+ {row.label} +
+
+ + N/A + +
+
+ + N/A + +
+
+ —}> + + + + + + + + {parseFloat( + ( + Math.round(Math.abs(row.change) * Math.pow(10, prec())) / + Math.pow(10, prec()) + ).toFixed(prec()), + )} + % + + + } + > + + N/A + + +
+
+ )} +
+
-
- r.isReduction)}> -
- {' '} - {comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved +
+ r.isReduction)}> +
+ {' '} + {comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved +
+
+ !r.isReduction && r.isDifferent)}> +
+ {' '} + {comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed +
+
+
+ + + + + diff --git a/packages/web/src/components/FileUpload.tsx b/packages/web/src/components/FileUpload.tsx index 95e5c20..5a2f34e 100644 --- a/packages/web/src/components/FileUpload.tsx +++ b/packages/web/src/components/FileUpload.tsx @@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock'; interface FileUploadProps { onFileLoad: (data: StatsData, raw: Record) => 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) {

Load Statistics

-
+ + + @@ -264,6 +359,11 @@ function App() { onSelect={entry => setCurrentStats(entry.data)} onDelete={deleteSnapshot} precision={precision()} + pasteMode={pasteMode()} + onPasteModeChange={setPasteMode} + onPasteStats={handlePasteStats} + onFileLoad={handleCompareFileLoad} + onTextLoad={handleCompareTextLoad} /> diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 95e9806..86e387d 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -183,6 +183,7 @@ body { align-items: center; gap: 2rem; padding-top: 4rem; + width: 100%; } .upload-card { @@ -824,6 +825,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 +987,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 +1035,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 +1044,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 +1140,8 @@ body { color: var(--text-secondary); min-width: 80px; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } .gc-stats { @@ -1157,6 +1169,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 +1227,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 +1283,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 +1312,8 @@ body { .ratio-label { font-size: 0.75rem; color: var(--text-muted); + word-break: break-word; + overflow-wrap: break-word; } .dashboard-grid { @@ -1341,6 +1361,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 +1406,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 +1466,36 @@ 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; +} + .compare-placeholder { text-align: center; padding: 4rem; @@ -1450,6 +1505,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 +1584,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 {