@@ -10,6 +10,8 @@ import { ThemeToggle } from './components/ThemeToggle';
1010import { Header } from './components/Header' ;
1111import { PanelLeftClose , PanelLeftOpen } from 'lucide-react' ;
1212import { mergeFilesWithReplacement } from './utils/mergeFiles.js' ;
13+ import { useToast } from './components/ToastContext.jsx' ;
14+ import { loadFiles as loadFilesFromStorage , saveFiles as saveFilesToStorage , clearFiles as clearFilesInStorage } from './utils/fileStorage.js' ;
1315
1416// Threshold for "large file" - files above this won't have content persisted
1517const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024 ; // 5MB of content
@@ -34,34 +36,58 @@ export const DEFAULT_GLOBAL_PARSING_CONFIG = {
3436 stepKeyword : 'step:'
3537} ;
3638
39+ export const DEFAULT_CHART_CONFIG = {
40+ downsampleEnabled : true ,
41+ downsampleThreshold : 2000
42+ } ;
43+
44+ function restoreFile ( file ) {
45+ return {
46+ ...file ,
47+ enabled : file . enabled ?? true ,
48+ isParsing : false ,
49+ metricsData : file . metricsData || { } ,
50+ needsReupload : file . isLargeFile && ! file . content
51+ } ;
52+ }
53+
3754function App ( ) {
3855 const { t } = useTranslation ( ) ;
56+ const toast = useToast ( ) ;
57+ const toastRef = useRef ( toast ) ;
58+ const tRef = useRef ( t ) ;
59+ useEffect ( ( ) => { toastRef . current = toast ; } , [ toast ] ) ;
60+ useEffect ( ( ) => { tRef . current = t ; } , [ t ] ) ;
61+ // Sync init from legacy localStorage so the first paint matches prior behavior;
62+ // an async IndexedDB load below will replace this state if richer data is present.
3963 const [ uploadedFiles , setUploadedFiles ] = useState ( ( ) => {
4064 const stored = localStorage . getItem ( 'uploadedFiles' ) ;
4165 if ( ! stored ) return [ ] ;
4266 try {
4367 const parsed = JSON . parse ( stored ) ;
44- // Restore files with proper defaults for large files that have metricsData
45- return parsed . map ( file => ( {
46- ...file ,
47- enabled : file . enabled ?? true ,
48- isParsing : false ,
49- // For large files, metricsData is already stored; for small files it will be re-parsed
50- metricsData : file . metricsData || { } ,
51- // Mark large files that need re-upload for re-parsing
52- needsReupload : file . isLargeFile && ! file . content
53- } ) ) ;
68+ return parsed . map ( restoreFile ) ;
5469 } catch {
5570 return [ ] ;
5671 }
5772 } ) ;
73+ const initialLoadDoneRef = useRef ( false ) ;
5874
5975 // Global parsing configuration state
6076 const [ globalParsingConfig , setGlobalParsingConfig ] = useState ( ( ) => {
6177 const stored = localStorage . getItem ( 'globalParsingConfig' ) ;
6278 return stored ? JSON . parse ( stored ) : JSON . parse ( JSON . stringify ( DEFAULT_GLOBAL_PARSING_CONFIG ) ) ;
6379 } ) ;
6480
81+ const [ chartConfig , setChartConfig ] = useState ( ( ) => {
82+ const stored = localStorage . getItem ( 'chartConfig' ) ;
83+ if ( ! stored ) return { ...DEFAULT_CHART_CONFIG } ;
84+ try {
85+ return { ...DEFAULT_CHART_CONFIG , ...JSON . parse ( stored ) } ;
86+ } catch {
87+ return { ...DEFAULT_CHART_CONFIG } ;
88+ }
89+ } ) ;
90+
6591 const [ compareMode , setCompareMode ] = useState ( 'normal' ) ;
6692 const [ multiFileMode , setMultiFileMode ] = useState ( 'baseline' ) ;
6793 const [ baselineFile , setBaselineFile ] = useState ( '' ) ;
@@ -85,29 +111,46 @@ function App() {
85111
86112 workerRef . current . onmessage = ( e ) => {
87113 const { type, payload } = e . data ;
88- if ( type === 'PARSE_COMPLETE ' ) {
114+ if ( type === 'PARSE_PROGRESS ' ) {
89115 setUploadedFiles ( prev => prev . map ( file => {
90116 if ( file . id === payload . fileId ) {
91- return {
92- ...file ,
93- metricsData : payload . metricsData ,
94- isParsing : false
95- } ;
117+ return { ...file , progress : payload . progress } ;
96118 }
97119 return file ;
98120 } ) ) ;
99- } else if ( type === 'PARSE_ERROR' ) {
100- console . error ( 'Worker parsing error:' , payload . error ) ;
121+ } else if ( type === 'PARSE_COMPLETE' ) {
101122 setUploadedFiles ( prev => prev . map ( file => {
102123 if ( file . id === payload . fileId ) {
103124 return {
104125 ...file ,
126+ metricsData : payload . metricsData ,
105127 isParsing : false ,
106- error : payload . error
128+ progress : 1
107129 } ;
108130 }
109131 return file ;
110132 } ) ) ;
133+ } else if ( type === 'PARSE_ERROR' ) {
134+ console . error ( 'Worker parsing error:' , payload . error ) ;
135+ setUploadedFiles ( prev => {
136+ const target = prev . find ( f => f . id === payload . fileId ) ;
137+ if ( target ) {
138+ toastRef . current . error (
139+ tRef . current ( 'toast.parseError' , { name : target . name , error : payload . error } )
140+ ) ;
141+ }
142+ return prev . map ( file => {
143+ if ( file . id === payload . fileId ) {
144+ return {
145+ ...file ,
146+ isParsing : false ,
147+ progress : undefined ,
148+ error : payload . error
149+ } ;
150+ }
151+ return file ;
152+ } ) ;
153+ } ) ;
111154 }
112155 } ;
113156
@@ -128,6 +171,26 @@ function App() {
128171 }
129172 } , [ enabledFiles , baselineFile ] ) ;
130173
174+ // Async load from IndexedDB on mount. Overrides the sync localStorage init
175+ // if richer data is present (e.g. after migration or in IDB-capable browsers).
176+ useEffect ( ( ) => {
177+ let cancelled = false ;
178+ loadFilesFromStorage ( ) . then ( files => {
179+ if ( cancelled ) return ;
180+ initialLoadDoneRef . current = true ;
181+ if ( Array . isArray ( files ) && files . length > 0 ) {
182+ setUploadedFiles ( prev => {
183+ // Avoid overwriting if user already added files during async load
184+ if ( prev . length > 0 ) return prev ;
185+ return files . map ( restoreFile ) ;
186+ } ) ;
187+ }
188+ } ) . catch ( ( ) => {
189+ initialLoadDoneRef . current = true ;
190+ } ) ;
191+ return ( ) => { cancelled = true ; } ;
192+ } , [ ] ) ;
193+
131194 // Persist configuration to localStorage
132195 useEffect ( ( ) => {
133196 if ( savingDisabledRef . current ) return ;
@@ -136,53 +199,43 @@ function App() {
136199
137200 useEffect ( ( ) => {
138201 if ( savingDisabledRef . current ) return ;
139- try {
140- // Smart serialization: for large files, only store metricsData (not raw content)
141- // This allows the app to still display charts after refresh, but re-parsing will need re-upload
142- const serialized = uploadedFiles . map ( ( { id, name, enabled, content, config, metricsData } ) => {
143- const isLargeFile = content && content . length > LARGE_FILE_THRESHOLD ;
144- return {
145- id,
146- name,
147- enabled,
148- // For large files, don't store content to save memory/storage
149- content : isLargeFile ? null : content ,
150- config,
151- // Store metricsData for large files so charts still work after refresh
152- metricsData : isLargeFile ? metricsData : undefined ,
153- // Flag to indicate this file needs re-upload for re-parsing
154- isLargeFile
155- } ;
156- } ) ;
157- if ( serialized . length > 0 ) {
158- const json = JSON . stringify ( serialized ) ;
159- // Avoid filling localStorage with very large data
160- if ( json . length > 5 * 1024 * 1024 ) {
161- savingDisabledRef . current = true ;
162- console . warn ( 'Uploaded files exceed storage limit; persistence disabled.' ) ;
163- return ;
164- }
165- localStorage . setItem ( 'uploadedFiles' , json ) ;
166- } else {
167- localStorage . removeItem ( 'uploadedFiles' ) ;
168- }
169- } catch ( err ) {
170- if ( err instanceof DOMException && err . name === 'QuotaExceededError' ) {
202+ localStorage . setItem ( 'chartConfig' , JSON . stringify ( chartConfig ) ) ;
203+ } , [ chartConfig ] ) ;
204+
205+ useEffect ( ( ) => {
206+ if ( savingDisabledRef . current ) return ;
207+ // Smart serialization: for large files, only store metricsData (not raw content).
208+ // Charts still render after refresh, but re-parsing requires re-upload.
209+ const serialized = uploadedFiles . map ( ( { id, name, enabled, content, config, metricsData } ) => {
210+ const isLargeFile = content && content . length > LARGE_FILE_THRESHOLD ;
211+ return {
212+ id,
213+ name,
214+ enabled,
215+ content : isLargeFile ? null : content ,
216+ config,
217+ metricsData : isLargeFile ? metricsData : undefined ,
218+ isLargeFile
219+ } ;
220+ } ) ;
221+ saveFilesToStorage ( serialized ) . catch ( err => {
222+ if ( err && ( err . name === 'QuotaExceededError' || err . code === 22 ) ) {
171223 savingDisabledRef . current = true ;
172- console . warn ( 'LocalStorage quota exceeded; uploaded files will not be persisted.' ) ;
173- localStorage . removeItem ( 'uploadedFiles' ) ;
224+ console . warn ( 'Storage quota exceeded; uploaded files will not be persisted.' ) ;
225+ toastRef . current . warning ( t ( 'toast.storageQuotaExceeded' ) ) ;
174226 } else {
175- throw err ;
227+ console . warn ( 'Failed to persist uploaded files' , err ) ;
176228 }
177- }
178- } , [ uploadedFiles ] ) ;
229+ } ) ;
230+ } , [ uploadedFiles , t ] ) ;
179231
180232 const handleFilesUploaded = useCallback ( ( files ) => {
181233 const filesWithDefaults = files . map ( file => ( {
182234 ...file ,
183235 enabled : true ,
184236 metricsData : { } , // Initialize empty
185237 isParsing : true , // Mark as parsing
238+ progress : 0 ,
186239 config : {
187240 // Use global parsing config as default values
188241 metrics : globalParsingConfig . metrics . map ( m => ( { ...m } ) ) ,
@@ -273,7 +326,7 @@ function App() {
273326 }
274327 } ) ;
275328 }
276- return { ...file , config, isParsing : true } ;
329+ return { ...file , config, isParsing : true , progress : 0 } ;
277330 }
278331 return file ;
279332 } ) ) ;
@@ -320,7 +373,8 @@ function App() {
320373 return {
321374 ...file ,
322375 config : newFileConfig ,
323- isParsing : true
376+ isParsing : true ,
377+ progress : 0
324378 } ;
325379 }
326380 return file ;
@@ -333,8 +387,10 @@ function App() {
333387 const handleResetConfig = useCallback ( ( ) => {
334388 savingDisabledRef . current = true ;
335389 localStorage . removeItem ( 'globalParsingConfig' ) ;
336- localStorage . removeItem ( 'uploadedFiles' ) ;
390+ localStorage . removeItem ( 'chartConfig' ) ;
391+ clearFilesInStorage ( ) ;
337392 setGlobalParsingConfig ( JSON . parse ( JSON . stringify ( DEFAULT_GLOBAL_PARSING_CONFIG ) ) ) ;
393+ setChartConfig ( { ...DEFAULT_CHART_CONFIG } ) ;
338394 setUploadedFiles ( [ ] ) ;
339395 setTimeout ( ( ) => {
340396 savingDisabledRef . current = false ;
@@ -573,6 +629,44 @@ function App() {
573629 < p className = "text-xs text-gray-500 dark:text-gray-400" > { t ( 'display.chartDesc' ) } </ p >
574630 </ div >
575631
632+ < div className = "border-t border-gray-200 dark:border-gray-700 pt-3" >
633+ < h4 className = "text-xs font-medium text-gray-700 dark:text-gray-300 mb-2" > { t ( 'display.performance' ) } </ h4 >
634+ < label className = "flex items-center text-xs text-gray-700 dark:text-gray-300 mb-2" >
635+ < input
636+ type = "checkbox"
637+ className = "mr-2 checkbox"
638+ checked = { chartConfig . downsampleEnabled }
639+ onChange = { ( e ) => setChartConfig ( prev => ( { ...prev , downsampleEnabled : e . target . checked } ) ) }
640+ />
641+ { t ( 'display.downsample' ) }
642+ </ label >
643+ { chartConfig . downsampleEnabled && (
644+ < div >
645+ < label
646+ htmlFor = "downsample-threshold"
647+ className = "block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
648+ >
649+ { t ( 'display.downsampleThreshold' ) }
650+ </ label >
651+ < input
652+ id = "downsample-threshold"
653+ type = "number"
654+ min = "100"
655+ step = "100"
656+ value = { chartConfig . downsampleThreshold }
657+ onChange = { ( e ) => {
658+ const v = parseInt ( e . target . value , 10 ) ;
659+ if ( Number . isFinite ( v ) && v >= 100 ) {
660+ setChartConfig ( prev => ( { ...prev , downsampleThreshold : v } ) ) ;
661+ }
662+ } }
663+ className = "input-field"
664+ />
665+ </ div >
666+ ) }
667+ < p className = "text-xs text-gray-500 dark:text-gray-400 mt-1" > { t ( 'display.downsampleDesc' ) } </ p >
668+ </ div >
669+
576670 < div className = "border-t border-gray-200 dark:border-gray-700 pt-3" >
577671 < h4 className = "text-xs font-medium text-gray-700 dark:text-gray-300 mb-2" > { t ( 'display.baseline' ) } </ h4 >
578672 < div className = "space-y-3" >
@@ -649,6 +743,8 @@ function App() {
649743 onXRangeChange = { setXRange }
650744 yRange = { yRange }
651745 onMaxStepChange = { setMaxStep }
746+ downsampleEnabled = { chartConfig . downsampleEnabled }
747+ downsampleThreshold = { chartConfig . downsampleThreshold }
652748 />
653749 </ section >
654750 </ main >
0 commit comments