diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 1c7941e..02e8fbc 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -1,4 +1,4 @@ -import { Component, For, createSignal, createMemo, Show } from 'solid-js'; +import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js'; import { ComparisonEntry, calculateChange } from '@ns/core'; import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils'; import ArrowRightIcon from 'lucide-solid/icons/arrow-right'; @@ -11,12 +11,62 @@ interface ComparisonViewProps { 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; } 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(); @@ -122,6 +172,22 @@ const ComparisonView: Component = props => { +
+ + +
0}> @@ -164,12 +230,12 @@ const ComparisonView: Component = 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()) } > N/A @@ -180,12 +246,12 @@ const ComparisonView: Component = 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()) } > N/A @@ -238,6 +304,57 @@ const ComparisonView: Component = props => { + + + + ); }; diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index 578bb8a..31290b8 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -32,6 +32,7 @@ function App() { 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 STORAGE_KEY = 'ns-data'; @@ -62,6 +63,9 @@ 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); @@ -89,6 +93,7 @@ function App() { snaps: ComparisonEntry[], v: 'analysis' | 'compare', prec: number, + pm: 'advance' | 'replace', ) => { try { localStorage.setItem( @@ -99,6 +104,7 @@ function App() { currentRaw: raw, view: v, precision: prec, + pasteMode: pm, }), ); } catch (e) { @@ -115,10 +121,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 = () => { @@ -171,6 +178,27 @@ function App() { } }; + const handlePasteStats = (text: string, name: string): ComparisonEntry | null => { + let raw: Record; + 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; + }; + return (
@@ -264,6 +292,9 @@ function App() { onSelect={entry => setCurrentStats(entry.data)} onDelete={deleteSnapshot} precision={precision()} + pasteMode={pasteMode()} + onPasteModeChange={setPasteMode} + onPasteStats={handlePasteStats} /> diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 95e9806..17b5920 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -824,6 +824,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 +986,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 +1034,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 +1043,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 +1139,8 @@ body { color: var(--text-secondary); min-width: 80px; text-align: right; + word-break: break-word; + overflow-wrap: break-word; } .gc-stats { @@ -1157,6 +1168,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 +1226,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 +1282,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 +1311,8 @@ body { .ratio-label { font-size: 0.75rem; color: var(--text-muted); + word-break: break-word; + overflow-wrap: break-word; } .dashboard-grid { @@ -1341,6 +1360,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 +1405,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 +1465,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; @@ -1510,10 +1564,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 {