@@ -14,6 +14,19 @@ import {
1414 TestReview ,
1515} from "@/types/Review.ts" ;
1616
17+ type FileSystemApiHandle = {
18+ createWritable : ( ) => Promise < { write : ( data : string ) => Promise < void > ; close : ( ) => Promise < void > } > ;
19+ queryPermission ?: ( opts : { mode : 'read' | 'readwrite' } ) => Promise < 'granted' | 'denied' | 'prompt' > ;
20+ requestPermission ?: ( opts : { mode : 'read' | 'readwrite' } ) => Promise < 'granted' | 'denied' | 'prompt' > ;
21+ } ;
22+
23+ type WindowWithFileSystemApi = Window & {
24+ showSaveFilePicker ?: ( opts : {
25+ suggestedName ?: string ;
26+ types ?: Array < { description : string ; accept : Record < string , string [ ] > } > ;
27+ } ) => Promise < FileSystemApiHandle > ;
28+ } ;
29+
1730type AppContextType = {
1831 data : WebFuzzingCommonsReport | null ;
1932 loading : boolean ;
@@ -204,7 +217,9 @@ export const AppProvider = ({ children }: AppProviderProps) => {
204217 applyReviews ( { ...prev , [ testId ] : { ...existing , comment} } ) ;
205218 } , [ applyReviews ] ) ;
206219
207- const saveReviews = useCallback ( ( ) => {
220+ const fileHandleRef = useRef < FileSystemApiHandle | null > ( null ) ;
221+
222+ const saveReviews = useCallback ( async ( ) => {
208223 // Flush any pending textarea edits (local-state rows commit on blur)
209224 const active = document . activeElement ;
210225 if ( active instanceof HTMLElement && ( active . tagName === 'TEXTAREA' || active . tagName === 'INPUT' ) ) {
@@ -222,7 +237,54 @@ export const AppProvider = ({ children }: AppProviderProps) => {
222237 file . reviews [ id ] = { state : r . state , comment : r . comment ?? "" } ;
223238 }
224239 }
225- const blob = new Blob ( [ JSON . stringify ( file , null , 2 ) ] , { type : "application/json" } ) ;
240+ const json = JSON . stringify ( file , null , 2 ) ;
241+ const reviewCount = Object . keys ( file . reviews ) . length ;
242+
243+ const commitBaseline = ( ) => {
244+ baselineRef . current = { ...source } ;
245+ setIsDirty ( false ) ;
246+ } ;
247+
248+ // Preferred path: File System Access API (Chrome/Edge, secure context).
249+ // Lets subsequent saves overwrite the same file without the OS appending (1), (2)...
250+ const showSaveFilePicker = ( window as WindowWithFileSystemApi ) . showSaveFilePicker ;
251+ if ( typeof showSaveFilePicker === 'function' ) {
252+ try {
253+ let handle = fileHandleRef . current ;
254+ if ( handle ) {
255+ const perm = ( await handle . queryPermission ?.( { mode : 'readwrite' } ) ) ?? 'granted' ;
256+ if ( perm !== 'granted' ) {
257+ const req = ( await handle . requestPermission ?.( { mode : 'readwrite' } ) ) ?? 'denied' ;
258+ if ( req !== 'granted' ) handle = null ;
259+ }
260+ }
261+ if ( ! handle ) {
262+ handle = await showSaveFilePicker ( {
263+ suggestedName : REVIEW_FILE_NAME ,
264+ types : [ { description : 'JSON' , accept : { 'application/json' : [ '.json' ] } } ] ,
265+ } ) ;
266+ fileHandleRef . current = handle ;
267+ }
268+ const writable = await handle . createWritable ( ) ;
269+ await writable . write ( json ) ;
270+ await writable . close ( ) ;
271+ commitBaseline ( ) ;
272+ setReviewMessage ( {
273+ type : "info" ,
274+ text : `Saved ${ reviewCount } review(s). Subsequent saves will overwrite this file silently.` ,
275+ } ) ;
276+ return ;
277+ } catch ( e ) {
278+ // User cancelled the picker — bail without downloading.
279+ if ( e instanceof DOMException && e . name === 'AbortError' ) return ;
280+ console . warn ( 'File System Access API failed, falling back to download:' , e ) ;
281+ fileHandleRef . current = null ;
282+ }
283+ }
284+
285+ // Fallback: anchor download. On Windows this may auto-rename to report-review(1).json
286+ // when "Ask where to save each file" is disabled — unavoidable without the API above.
287+ const blob = new Blob ( [ json ] , { type : "application/json" } ) ;
226288 const url = URL . createObjectURL ( blob ) ;
227289 const a = document . createElement ( "a" ) ;
228290 a . href = url ;
@@ -231,11 +293,10 @@ export const AppProvider = ({ children }: AppProviderProps) => {
231293 a . click ( ) ;
232294 document . body . removeChild ( a ) ;
233295 URL . revokeObjectURL ( url ) ;
234- baselineRef . current = { ...source } ;
235- setIsDirty ( false ) ;
296+ commitBaseline ( ) ;
236297 setReviewMessage ( {
237298 type : "info" ,
238- text : `Saved ${ Object . keys ( file . reviews ) . length } review(s). Place the downloaded ${ REVIEW_FILE_NAME } next to report.json.` ,
299+ text : `Saved ${ reviewCount } review(s). Place the downloaded ${ REVIEW_FILE_NAME } next to report.json.` ,
239300 } ) ;
240301 } , [ ] ) ;
241302
0 commit comments