jj commit -m "packages/web: add paste/upload snapshot flows; unify inputs"

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7cb4e2ee6962e5d343cd52da6524b3596a6a6964
This commit is contained in:
raf 2026-04-14 11:26:48 +03:00
commit 1541046669
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 288 additions and 156 deletions

View file

@ -1,10 +1,11 @@
import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js'; import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import { ComparisonEntry, calculateChange } from '@ns/core'; import { ComparisonEntry, calculateChange, StatsData } 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';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
import XIcon from 'lucide-solid/icons/x'; import XIcon from 'lucide-solid/icons/x';
import FileUpload from './FileUpload';
interface ComparisonViewProps { interface ComparisonViewProps {
entries: ComparisonEntry[]; entries: ComparisonEntry[];
@ -14,6 +15,8 @@ interface ComparisonViewProps {
pasteMode: 'advance' | 'replace'; pasteMode: 'advance' | 'replace';
onPasteModeChange: (mode: 'advance' | 'replace') => void; onPasteModeChange: (mode: 'advance' | 'replace') => void;
onPasteStats: (text: string, name: string) => ComparisonEntry | null; onPasteStats: (text: string, name: string) => ComparisonEntry | null;
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
onTextLoad: (text: string) => void;
} }
const ComparisonView: Component<ComparisonViewProps> = props => { const ComparisonView: Component<ComparisonViewProps> = props => {
@ -150,6 +153,7 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
return ( return (
<div class="comparison-view"> <div class="comparison-view">
<Show when={props.entries.length >= 2}>
<div class="comparison-controls"> <div class="comparison-controls">
<div class="compare-selector"> <div class="compare-selector">
<label>Baseline</label> <label>Baseline</label>
@ -189,7 +193,28 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</button> </button>
</div> </div>
</div> </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={props.entries.length > 0}> <Show when={props.entries.length > 0}>
<div class="snapshots-list"> <div class="snapshots-list">
<h4>Saved Snapshots</h4> <h4>Saved Snapshots</h4>
@ -208,7 +233,14 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
<Show <Show
when={leftEntry() && rightEntry()} 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="comparison-table">
<div class="compare-header"> <div class="compare-header">
@ -304,6 +336,7 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
</Show> </Show>
</div> </div>
</Show> </Show>
</Show>
<Show when={showPasteModal()}> <Show when={showPasteModal()}>
<div <div
@ -349,7 +382,7 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
Cancel Cancel
</button> </button>
<button class="confirm-btn" onClick={confirmPaste}> <button class="confirm-btn" onClick={confirmPaste}>
Save &amp; Compare {props.entries.length >= 2 ? 'Save & Compare' : 'Save Snapshot'}
</button> </button>
</div> </div>
</div> </div>

View file

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

View file

@ -29,11 +29,12 @@ function App() {
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis'); const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
const [snapshotName, setSnapshotName] = createSignal(''); const [snapshotName, setSnapshotName] = createSignal('');
const [showSaveDialog, setShowSaveDialog] = createSignal(false); const [showSaveDialog, setShowSaveDialog] = 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 [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
const [isLoading, setIsLoading] = createSignal(true); const [isLoading, setIsLoading] = createSignal(true);
const [showOverrideModal, setShowOverrideModal] = createSignal(false);
const [pendingOverrideText, setPendingOverrideText] = createSignal('');
const STORAGE_KEY = 'ns-data'; const STORAGE_KEY = 'ns-data';
@ -80,8 +81,28 @@ function App() {
}; };
window.addEventListener('keydown', handleKeyDown); 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 () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('paste', handleAnalysisPaste);
}; };
}); });
@ -199,6 +220,34 @@ function App() {
return 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);
}
};
return ( return (
<div class="app"> <div class="app">
<header class="header"> <header class="header">
@ -209,11 +258,7 @@ function App() {
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}> <button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
Analysis Analysis
</button> </button>
<button <button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
class={view() === 'compare' ? 'active' : ''}
onClick={() => setView('compare')}
disabled={snapshots().length < 2}
>
Compare ({snapshots().length}) Compare ({snapshots().length})
</button> </button>
<Show when={snapshots().length > 0}> <Show when={snapshots().length > 0}>
@ -251,8 +296,6 @@ function App() {
<FileUpload <FileUpload
onFileLoad={handleFileLoad} onFileLoad={handleFileLoad}
onTextLoad={loadFromText} onTextLoad={loadFromText}
showHelp={showHelp()}
onToggleHelp={() => setShowHelp(!showHelp())}
snapshots={snapshots()} snapshots={snapshots()}
onLoadSnapshot={loadSnapshot} onLoadSnapshot={loadSnapshot}
/> />
@ -283,6 +326,30 @@ function App() {
</div> </div>
</div> </div>
</Show> </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>
</Show> </Show>
@ -295,6 +362,8 @@ function App() {
pasteMode={pasteMode()} pasteMode={pasteMode()}
onPasteModeChange={setPasteMode} onPasteModeChange={setPasteMode}
onPasteStats={handlePasteStats} onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad}
/> />
</Show> </Show>
</Show> </Show>

View file

@ -183,6 +183,7 @@ body {
align-items: center; align-items: center;
gap: 2rem; gap: 2rem;
padding-top: 4rem; padding-top: 4rem;
width: 100%;
} }
.upload-card { .upload-card {
@ -1504,6 +1505,25 @@ body {
border-radius: 0.75rem; 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 { .comparison-table {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);