feat(files): add save-as-new vs overwrite choice for library file edits (#564)

Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior.

The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged.

Closes #495
This commit is contained in:
SnapOtter
2026-07-18 11:36:08 +08:00
committed by GitHub
parent e113684ddb
commit a23158d968
63 changed files with 1721 additions and 19 deletions
@@ -0,0 +1,66 @@
import { LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
import { useTranslation } from "@/contexts/i18n-context";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { useFileStore } from "@/stores/file-store";
/**
* Per-edit choice for library-sourced files (#495): save the processed result
* to the file library as a new file (default, keeps the original) or
* overwrite the original. Renders nothing when the choice would not be
* honored: entry not linked to a library file, tool whose route or submitter
* ignores the saveMode field, or a multi-file batch run (batches never send
* fileId, so nothing is auto-saved).
*/
export function LibrarySaveModeSelector({ toolId }: { toolId: string }) {
const { t } = useTranslation();
const currentEntry = useFileStore((s) => s.currentEntry);
const entries = useFileStore((s) => s.entries);
const librarySaveMode = useFileStore((s) => s.librarySaveMode);
const setLibrarySaveMode = useFileStore((s) => s.setLibrarySaveMode);
const processing = useFileStore((s) => s.processing);
if (!currentEntry?.serverFileId) return null;
if (LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)) return null;
const isBatchRun = entries.length > 1 && !MULTI_FILE_TOOLS.has(toolId);
if (isBatchRun) return null;
return (
<fieldset className="space-y-2 rounded-lg border border-border p-3">
<legend className="px-1 text-xs font-medium text-muted-foreground">
{t.toolPage.librarySaveTitle}
</legend>
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="radio"
name="library-save-mode"
className="mt-1"
checked={librarySaveMode === "new"}
onChange={() => setLibrarySaveMode("new")}
disabled={processing}
/>
<span>
{t.toolPage.librarySaveAsNew}
<span className="block text-xs text-muted-foreground">
{t.toolPage.librarySaveAsNewHint}
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="radio"
name="library-save-mode"
className="mt-1"
checked={librarySaveMode === "overwrite"}
onChange={() => setLibrarySaveMode("overwrite")}
disabled={processing}
/>
<span>
{t.toolPage.libraryOverwrite}
<span className="block text-xs text-muted-foreground">
{t.toolPage.libraryOverwriteHint}
</span>
</span>
</label>
</fieldset>
);
}
@@ -48,6 +48,8 @@ interface ReviewPanelProps {
totalCount?: number;
successCount?: number;
failedCount?: number;
/** Library id of the auto-saved result (#495); replaces the manual save link. */
savedLibraryFileId?: string | null;
}
export function ReviewPanel({
@@ -62,6 +64,7 @@ export function ReviewPanel({
totalCount,
successCount,
failedCount,
savedLibraryFileId,
}: ReviewPanelProps) {
const { t } = useTranslation();
@@ -194,8 +197,20 @@ export function ReviewPanel({
</button>
)}
{/* Result already auto-saved to the library: show where it went
instead of the manual save link (avoids duplicate saves). */}
{!isDataOutput && savedLibraryFileId && (
<div className="flex items-center justify-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
{t.toolPage.savedToFiles}
<Link to="/files" className="underline underline-offset-2 hover:text-foreground">
{t.toolPage.viewInFiles}
</Link>
</div>
)}
{/* Save to Files -- subtle text link */}
{!isDataOutput && (
{!isDataOutput && !savedLibraryFileId && (
<div className="flex justify-center">
<button
type="button"
@@ -432,7 +432,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
navigate("/", { state: { fromLibrary: true } });
}
// Set serverFileId on each entry so tool processing creates new versions
// Set serverFileId on each entry so tool processing auto-saves the result
// to the library: an independent new file by default, or a superseding
// version when the user picks overwrite (#495)
setTimeout(() => {
const store = useFileStore.getState();
for (let i = 0; i < valid.length; i++) {
@@ -186,8 +186,12 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
form.append("placements", JSON.stringify(placements));
form.append("clientJobId", clientJobId);
// Forward the library file id (when the PDF came from the library) so the
// worker auto-saves the signed result as a new version.
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
// worker auto-saves the signed result, honoring the chosen save mode
// (new file by default, overwrite on request).
if (currentEntry?.serverFileId) {
form.append("fileId", currentEntry.serverFileId);
form.append("saveMode", useFileStore.getState().librarySaveMode);
}
pngs.forEach((png, i) => {
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
});
+28 -3
View File
@@ -91,6 +91,10 @@ export function useToolProcessor(toolId: string) {
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeJobIdRef = useRef<string | null>(null);
const asyncModeRef = useRef(false);
// Save mode captured at run start (#495). Only "overwrite" re-anchors
// serverFileId to the saved result, so "new" keeps deriving from the
// original library file on re-runs.
const saveModeRef = useRef<"new" | "overwrite">("new");
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
@@ -180,6 +184,9 @@ export function useToolProcessor(toolId: string) {
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
const idx = useFileStore.getState().selectedIndex;
useFileStore.getState().updateEntry(idx, {
processedUrl: result.downloadUrl,
@@ -188,7 +195,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -275,6 +284,7 @@ export function useToolProcessor(toolId: string) {
setError(null);
setWarning(null);
setResultPayload(null);
useFileStore.getState().setLastSavedLibraryFileId(null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -353,6 +363,9 @@ export function useToolProcessor(toolId: string) {
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
@@ -360,7 +373,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -421,8 +436,10 @@ export function useToolProcessor(toolId: string) {
formData.append("clientJobId", clientJobId);
const capturedEntry = useFileStore.getState().entries[capturedIndex];
saveModeRef.current = useFileStore.getState().librarySaveMode;
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
formData.append("saveMode", saveModeRef.current);
}
const xhr = new XMLHttpRequest();
@@ -469,6 +486,9 @@ export function useToolProcessor(toolId: string) {
const result: ProcessResult = JSON.parse(xhr.responseText);
setWarning(result.warning ?? null);
setResultPayload(result as unknown as Record<string, unknown>);
if (result.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
}
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
@@ -476,7 +496,9 @@ export function useToolProcessor(toolId: string) {
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
...(result.savedFileId && saveModeRef.current === "overwrite"
? { serverFileId: result.savedFileId }
: {}),
});
} catch {
setError("Invalid response from server");
@@ -580,6 +602,9 @@ export function useToolProcessor(toolId: string) {
const { updateEntry, setBatchZip } = useFileStore.getState();
setError(null);
// Batch runs never auto-save to the library (no fileId is sent), so a
// previous single run's saved indicator must not survive into this one.
useFileStore.getState().setLastSavedLibraryFileId(null);
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
+5
View File
@@ -27,6 +27,7 @@ import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { BottomSheet } from "@/components/common/bottom-sheet";
import { Dropzone } from "@/components/common/dropzone";
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
import { ReviewPanel } from "@/components/common/review-panel";
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
@@ -303,6 +304,7 @@ export function ToolPage() {
navigateNext,
navigatePrev,
currentEntry,
lastSavedLibraryFileId,
} = useFileStore();
const isMobile = useMobile();
const hasMultiple = entries.length > 1;
@@ -1115,6 +1117,8 @@ export function ToolPage() {
</div>
)}
<LibrarySaveModeSelector toolId={tool?.id ?? ""} />
<div className="border-t border-border" />
<div className="space-y-2">
@@ -1161,6 +1165,7 @@ export function ToolPage() {
totalCount={batchTotal}
successCount={batchSuccess}
failedCount={batchFailed}
savedLibraryFileId={lastSavedLibraryFileId}
/>
</div>
)}
+24 -1
View File
@@ -1,4 +1,9 @@
import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared";
import {
ANALYTICS_EVENTS,
detectModalityFromMime,
type LibrarySaveMode,
type Modality,
} from "@snapotter/shared";
import { create } from "zustand";
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
@@ -118,6 +123,10 @@ interface FileState {
error: string | null;
activeJobId: string | null;
cancelCurrentJob: (() => Promise<void>) | null;
/** How library-sourced results are saved (#495): "new" keeps the original. */
librarySaveMode: LibrarySaveMode;
/** Library file id of the last run's auto-saved result, for the review UI. */
lastSavedLibraryFileId: string | null;
// Derived from entries (selected entry fields)
readonly files: File[];
@@ -142,6 +151,8 @@ interface FileState {
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
setActiveJob: (id: string | null, cancelFn: (() => Promise<void>) | null) => void;
setLibrarySaveMode: (mode: LibrarySaveMode) => void;
setLastSavedLibraryFileId: (id: string | null) => void;
setJobId: (id: string) => void;
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
setSizes: (original: number, processed: number) => void;
@@ -158,6 +169,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
librarySaveMode: "new",
lastSavedLibraryFileId: null,
// Initial derived values (empty state)
files: [],
@@ -175,6 +188,9 @@ export const useFileStore = create<FileState>((set, get) => ({
entries,
selectedIndex: 0,
error: null,
// A fresh file set is a fresh edit: the save-mode choice made for a
// previous file must not carry over (#495 defaults to non-destructive).
librarySaveMode: "new",
files: deriveFiles(entries),
...deriveSelected(entries, 0),
});
@@ -347,6 +363,10 @@ export const useFileStore = create<FileState>((set, get) => ({
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
},
setLibrarySaveMode: (mode) => set({ librarySaveMode: mode }),
setLastSavedLibraryFileId: (id) => set({ lastSavedLibraryFileId: id }),
undoProcessing: () => {
const { entries, selectedIndex } = get();
for (const entry of entries) {
@@ -370,6 +390,7 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
lastSavedLibraryFileId: null,
files: deriveFiles(resetEntries),
...deriveSelected(resetEntries, selectedIndex),
});
@@ -387,6 +408,8 @@ export const useFileStore = create<FileState>((set, get) => ({
error: null,
activeJobId: null,
cancelCurrentJob: null,
librarySaveMode: "new",
lastSavedLibraryFileId: null,
files: [],
...deriveSelected([], 0),
});