mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-05-18 21:17:36 +00:00
packages/web: add paste-from-clipboard in compare mode; fix overlaps
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I7311a4592d148ebbe8ab72b5091b82a46a6a6964
This commit is contained in:
parent
f7457fb9a4
commit
697f1f1c73
3 changed files with 216 additions and 10 deletions
|
|
@ -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 { ComparisonEntry, calculateChange } from '@ns/core';
|
||||||
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
|
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
|
||||||
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
|
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
|
||||||
|
|
@ -11,12 +11,62 @@ interface ComparisonViewProps {
|
||||||
onSelect: (entry: ComparisonEntry) => void;
|
onSelect: (entry: ComparisonEntry) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
precision?: number;
|
precision?: number;
|
||||||
|
pasteMode: 'advance' | 'replace';
|
||||||
|
onPasteModeChange: (mode: 'advance' | 'replace') => void;
|
||||||
|
onPasteStats: (text: string, name: string) => ComparisonEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComparisonView: Component<ComparisonViewProps> = props => {
|
const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
const prec = () => props.precision ?? 2;
|
const prec = () => props.precision ?? 2;
|
||||||
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
|
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
|
||||||
const [rightEntry, setRightEntry] = 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 comparison = createMemo(() => {
|
||||||
const left = leftEntry();
|
const left = leftEntry();
|
||||||
|
|
@ -122,6 +172,22 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
</For>
|
</For>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.entries.length > 0}>
|
<Show when={props.entries.length > 0}>
|
||||||
|
|
@ -164,12 +230,12 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
when={row.isMissing}
|
when={row.isMissing}
|
||||||
fallback={
|
fallback={
|
||||||
row.format === 'bytes'
|
row.format === 'bytes'
|
||||||
? formatBytes(row.leftValue)
|
? formatBytes(row.leftValue, prec())
|
||||||
: row.format === 'time'
|
: row.format === 'time'
|
||||||
? formatTime(row.leftValue)
|
? formatTime(row.leftValue, prec())
|
||||||
: row.format === 'percent'
|
: row.format === 'percent'
|
||||||
? formatPercent(row.leftValue)
|
? formatPercent(row.leftValue, prec())
|
||||||
: formatNumber(row.leftValue)
|
: formatNumber(row.leftValue, prec())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="missing-value">N/A</span>
|
<span class="missing-value">N/A</span>
|
||||||
|
|
@ -180,12 +246,12 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
when={row.isMissing}
|
when={row.isMissing}
|
||||||
fallback={
|
fallback={
|
||||||
row.format === 'bytes'
|
row.format === 'bytes'
|
||||||
? formatBytes(row.rightValue)
|
? formatBytes(row.rightValue, prec())
|
||||||
: row.format === 'time'
|
: row.format === 'time'
|
||||||
? formatTime(row.rightValue)
|
? formatTime(row.rightValue, prec())
|
||||||
: row.format === 'percent'
|
: row.format === 'percent'
|
||||||
? formatPercent(row.rightValue)
|
? formatPercent(row.rightValue, prec())
|
||||||
: formatNumber(row.rightValue)
|
: formatNumber(row.rightValue, prec())
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="missing-value">N/A</span>
|
<span class="missing-value">N/A</span>
|
||||||
|
|
@ -238,6 +304,57 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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}>
|
||||||
|
Save & Compare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ function App() {
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
||||||
const [precision, setPrecision] = createSignal(2);
|
const [precision, setPrecision] = createSignal(2);
|
||||||
|
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
|
||||||
const [isLoading, setIsLoading] = createSignal(true);
|
const [isLoading, setIsLoading] = createSignal(true);
|
||||||
|
|
||||||
const STORAGE_KEY = 'ns-data';
|
const STORAGE_KEY = 'ns-data';
|
||||||
|
|
@ -62,6 +63,9 @@ function App() {
|
||||||
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
||||||
setPrecision(parsed.precision);
|
setPrecision(parsed.precision);
|
||||||
}
|
}
|
||||||
|
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
|
||||||
|
setPasteMode(parsed.pasteMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load saved data:', e);
|
console.warn('Failed to load saved data:', e);
|
||||||
|
|
@ -89,6 +93,7 @@ function App() {
|
||||||
snaps: ComparisonEntry[],
|
snaps: ComparisonEntry[],
|
||||||
v: 'analysis' | 'compare',
|
v: 'analysis' | 'compare',
|
||||||
prec: number,
|
prec: number,
|
||||||
|
pm: 'advance' | 'replace',
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|
@ -99,6 +104,7 @@ function App() {
|
||||||
currentRaw: raw,
|
currentRaw: raw,
|
||||||
view: v,
|
view: v,
|
||||||
precision: prec,
|
precision: prec,
|
||||||
|
pasteMode: pm,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -115,10 +121,11 @@ function App() {
|
||||||
const snaps = snapshots();
|
const snaps = snapshots();
|
||||||
const v = view();
|
const v = view();
|
||||||
const prec = precision();
|
const prec = precision();
|
||||||
|
const pm = pasteMode();
|
||||||
|
|
||||||
if (isLoading()) return;
|
if (isLoading()) return;
|
||||||
|
|
||||||
saveToStorage(stats, raw, snaps, v, prec);
|
saveToStorage(stats, raw, snaps, v, prec, pm);
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveSnapshot = () => {
|
const saveSnapshot = () => {
|
||||||
|
|
@ -171,6 +178,27 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
|
|
@ -264,6 +292,9 @@ function App() {
|
||||||
onSelect={entry => setCurrentStats(entry.data)}
|
onSelect={entry => setCurrentStats(entry.data)}
|
||||||
onDelete={deleteSnapshot}
|
onDelete={deleteSnapshot}
|
||||||
precision={precision()}
|
precision={precision()}
|
||||||
|
pasteMode={pasteMode()}
|
||||||
|
onPasteModeChange={setPasteMode}
|
||||||
|
onPasteStats={handlePasteStats}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -824,6 +824,9 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
|
|
@ -983,6 +986,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
|
|
@ -1029,6 +1034,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-percent {
|
.legend-percent {
|
||||||
|
|
@ -1036,6 +1043,8 @@ body {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.donut-chart svg {
|
.donut-chart svg {
|
||||||
|
|
@ -1130,6 +1139,8 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gc-stats {
|
.gc-stats {
|
||||||
|
|
@ -1157,6 +1168,8 @@ body {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Operations Chart */
|
/* Operations Chart */
|
||||||
|
|
@ -1213,6 +1226,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thunk Chart */
|
/* Thunk Chart */
|
||||||
|
|
@ -1267,6 +1282,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thunk-ratio {
|
.thunk-ratio {
|
||||||
|
|
@ -1294,6 +1311,8 @@ body {
|
||||||
.ratio-label {
|
.ratio-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
|
|
@ -1341,6 +1360,8 @@ body {
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Top Lists */
|
/* Top Lists */
|
||||||
|
|
@ -1384,6 +1405,9 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-item .location {
|
.top-item .location {
|
||||||
|
|
@ -1441,6 +1465,36 @@ body {
|
||||||
justify-content: center;
|
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 {
|
.compare-placeholder {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem;
|
padding: 4rem;
|
||||||
|
|
@ -1510,10 +1564,14 @@ body {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-nums);
|
font-family: var(--font-nums);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compare-row .col-change {
|
.compare-row .col-change {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-value {
|
.change-value {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue