1- import { createContext , useContext , useState , ReactNode , useEffect } from 'react' ;
1+ import { createContext , useContext , useState , ReactNode , useEffect , useRef , useCallback } from 'react' ;
22import { WebFuzzingCommonsReport } from "@/types/GeneratedTypes.tsx" ;
33import { ITestFiles } from "@/types/General.tsx" ;
44import { fetchFileContent , ITransformedReport , transformWebFuzzingReport } from "@/lib/utils.tsx" ;
55import { webFuzzingCommonsReportSchema } from "@/types/GeneratedTypesZod.ts" ;
66import { ZodIssue } from "zod" ;
7+ import {
8+ DEFAULT_REVIEW ,
9+ parseReviewFile ,
10+ REVIEW_FILE_NAME ,
11+ REVIEW_SCHEMA_VERSION ,
12+ ReviewFile ,
13+ ReviewState ,
14+ reviewsEqual ,
15+ TestReview ,
16+ } from "@/types/Review.ts" ;
717
818type AppContextType = {
919 data : WebFuzzingCommonsReport | null ;
@@ -16,6 +26,15 @@ type AppContextType = {
1626 invalidReportErrors : ZodIssue [ ] | null ;
1727 lowCodeMode : boolean ;
1828 setLowCodeMode : ( v : boolean ) => void ;
29+ reviews : Record < string , TestReview > ;
30+ getReview : ( testId : string ) => TestReview ;
31+ setReviewState : ( testId : string , state : ReviewState ) => void ;
32+ setReviewComment : ( testId : string , comment : string ) => void ;
33+ isDirty : boolean ;
34+ saveReviews : ( ) => void ;
35+ loadReviews : ( file : File ) => Promise < void > ;
36+ reviewMessage : { type : "info" | "error" ; text : string } | null ;
37+ clearReviewMessage : ( ) => void ;
1938} ;
2039
2140const AppContext = createContext < AppContextType | undefined > ( undefined ) ;
@@ -36,6 +55,15 @@ export const AppProvider = ({ children }: AppProviderProps) => {
3655 const [ lowCodeMode , setLowCodeMode ] = useState < boolean > ( initialLowCode ) ;
3756 const transformedReport = transformWebFuzzingReport ( data ) ;
3857
58+ const [ reviews , setReviews ] = useState < Record < string , TestReview > > ( { } ) ;
59+ const baselineRef = useRef < Record < string , TestReview > > ( { } ) ;
60+ const [ isDirty , setIsDirty ] = useState ( false ) ;
61+ const [ reviewMessage , setReviewMessage ] = useState < { type : "info" | "error" ; text : string } | null > ( null ) ;
62+
63+ useEffect ( ( ) => {
64+ setIsDirty ( ! reviewsEqual ( reviews , baselineRef . current ) ) ;
65+ } , [ reviews ] ) ;
66+
3967 useEffect ( ( ) => {
4068 const fetchData = async ( ) => {
4169 try {
@@ -106,6 +134,113 @@ export const AppProvider = ({ children }: AppProviderProps) => {
106134 }
107135 } , [ data ] ) ;
108136
137+ useEffect ( ( ) => {
138+ if ( ! data ) return ;
139+ if ( window . location . protocol === 'file:' ) {
140+ setReviewMessage ( {
141+ type : "info" ,
142+ text : `Auto-loading ${ REVIEW_FILE_NAME } is not possible when opening this page directly from disk. Either launch the report via webreport.py, or click "Load reviews" to import the file manually.` ,
143+ } ) ;
144+ return ;
145+ }
146+ let cancelled = false ;
147+ fetchFileContent ( './' + REVIEW_FILE_NAME )
148+ . then ( parsed => {
149+ if ( cancelled || ! parsed ) return ;
150+ try {
151+ const loaded = parseReviewFile ( parsed ) ;
152+ setReviews ( loaded ) ;
153+ baselineRef . current = loaded ;
154+ setIsDirty ( false ) ;
155+ setReviewMessage ( {
156+ type : "info" ,
157+ text : `Auto-loaded ${ Object . keys ( loaded ) . length } review(s) from ${ REVIEW_FILE_NAME } .` ,
158+ } ) ;
159+ } catch ( e ) {
160+ console . warn ( "Auto-load of reviews failed:" , e ) ;
161+ }
162+ } )
163+ . catch ( ( ) => { /* silent — expected when no review file is present or under file:// */ } ) ;
164+ return ( ) => { cancelled = true ; } ;
165+ } , [ data ] ) ;
166+
167+ useEffect ( ( ) => {
168+ if ( ! isDirty ) return ;
169+ const handler = ( e : BeforeUnloadEvent ) => {
170+ e . preventDefault ( ) ;
171+ e . returnValue = "" ;
172+ } ;
173+ window . addEventListener ( "beforeunload" , handler ) ;
174+ return ( ) => window . removeEventListener ( "beforeunload" , handler ) ;
175+ } , [ isDirty ] ) ;
176+
177+ const getReview = useCallback (
178+ ( testId : string ) : TestReview => reviews [ testId ] ?? DEFAULT_REVIEW ,
179+ [ reviews ] ,
180+ ) ;
181+
182+ const setReviewState = useCallback ( ( testId : string , state : ReviewState ) => {
183+ setReviews ( prev => {
184+ const existing = prev [ testId ] ?? DEFAULT_REVIEW ;
185+ return { ...prev , [ testId ] : { ...existing , state} } ;
186+ } ) ;
187+ } , [ ] ) ;
188+
189+ const setReviewComment = useCallback ( ( testId : string , comment : string ) => {
190+ setReviews ( prev => {
191+ const existing = prev [ testId ] ?? DEFAULT_REVIEW ;
192+ return { ...prev , [ testId ] : { ...existing , comment} } ;
193+ } ) ;
194+ } , [ ] ) ;
195+
196+ const saveReviews = useCallback ( ( ) => {
197+ const file : ReviewFile = {
198+ schemaVersion : REVIEW_SCHEMA_VERSION ,
199+ reviews : { } ,
200+ } ;
201+ for ( const [ id , r ] of Object . entries ( reviews ) ) {
202+ const comment = ( r . comment ?? "" ) . trim ( ) ;
203+ if ( r . state !== "NOT-REVIEWED" || comment !== "" ) {
204+ file . reviews [ id ] = { state : r . state , comment : r . comment ?? "" } ;
205+ }
206+ }
207+ const blob = new Blob ( [ JSON . stringify ( file , null , 2 ) ] , { type : "application/json" } ) ;
208+ const url = URL . createObjectURL ( blob ) ;
209+ const a = document . createElement ( "a" ) ;
210+ a . href = url ;
211+ a . download = REVIEW_FILE_NAME ;
212+ document . body . appendChild ( a ) ;
213+ a . click ( ) ;
214+ document . body . removeChild ( a ) ;
215+ URL . revokeObjectURL ( url ) ;
216+ baselineRef . current = { ...reviews } ;
217+ setIsDirty ( false ) ;
218+ setReviewMessage ( {
219+ type : "info" ,
220+ text : `Saved ${ Object . keys ( file . reviews ) . length } review(s). Place the downloaded ${ REVIEW_FILE_NAME } next to report.json.` ,
221+ } ) ;
222+ } , [ reviews ] ) ;
223+
224+ const loadReviews = useCallback ( async ( file : File ) => {
225+ try {
226+ const text = await file . text ( ) ;
227+ const parsed = JSON . parse ( text ) ;
228+ const loaded = parseReviewFile ( parsed ) ;
229+ setReviews ( loaded ) ;
230+ baselineRef . current = loaded ;
231+ setIsDirty ( false ) ;
232+ setReviewMessage ( {
233+ type : "info" ,
234+ text : `Loaded ${ Object . keys ( loaded ) . length } review(s) from ${ file . name } .` ,
235+ } ) ;
236+ } catch ( e ) {
237+ const msg = e instanceof Error ? e . message : String ( e ) ;
238+ setReviewMessage ( { type : "error" , text : `Failed to load reviews: ${ msg } ` } ) ;
239+ }
240+ } , [ ] ) ;
241+
242+ const clearReviewMessage = useCallback ( ( ) => setReviewMessage ( null ) , [ ] ) ;
243+
109244 const [ filteredEndpoints , setFilteredEndpoints ] = useState ( transformedReport ) ;
110245
111246 useEffect ( ( ) => {
@@ -175,7 +310,27 @@ export const AppProvider = ({ children }: AppProviderProps) => {
175310 return filtered ;
176311 }
177312
178- const value : AppContextType = { data, loading, error, testFiles, transformedReport, filterEndpoints, filteredEndpoints, invalidReportErrors, lowCodeMode, setLowCodeMode } ;
313+ const value : AppContextType = {
314+ data,
315+ loading,
316+ error,
317+ testFiles,
318+ transformedReport,
319+ filterEndpoints,
320+ filteredEndpoints,
321+ invalidReportErrors,
322+ lowCodeMode,
323+ setLowCodeMode,
324+ reviews,
325+ getReview,
326+ setReviewState,
327+ setReviewComment,
328+ isDirty,
329+ saveReviews,
330+ loadReviews,
331+ reviewMessage,
332+ clearReviewMessage,
333+ } ;
179334
180335 return (
181336 < AppContext . Provider value = { value } >
@@ -190,4 +345,4 @@ export const useAppContext = (): AppContextType => {
190345 throw new Error ( 'useAppContext must be used within AppProvider' ) ;
191346 }
192347 return context ;
193- } ;
348+ } ;
0 commit comments