Skip to content

Commit f58b097

Browse files
committed
file save
1 parent cf3a97b commit f58b097

1 file changed

Lines changed: 66 additions & 5 deletions

File tree

web-report/src/AppProvider.tsx

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1730
type 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

Comments
 (0)