From 3c3ee7934b1b6e2b05ebccf116a6fc0e4c98dd7f Mon Sep 17 00:00:00 2001 From: subbajeu Date: Sun, 5 Jul 2026 21:08:19 +0800 Subject: [PATCH 1/5] feat: snapshots - in-editor checkpoints of the full edit state Adds named snapshots stored inside the sidecar, so you can checkpoint an edit, keep working, and flip between looks without leaving the editor. Complements library-level Virtual Copies (Duplicate Image > Virtual Copy): virtual copies are parallel library entries for deliverables; snapshots are in-place checkpoints while working on one entry. - New "Snapshots" section at the top of the Presets panel: save the current edit with one click, click a snapshot to apply it, context menu for apply / overwrite-with-current / rename / delete - Schema: adjustments.snapshots[] = {id, name, createdAt, state}, where state is a full adjustments snapshot (masks, AI edits, crop included). The sidecar stores adjustments as raw JSON, so this round-trips with no backend schema change; snapshots are excluded from copy/paste and multi-select sync via the existing copyable-key whitelist - Applying/renaming/deleting go through setAdjustments, so every snapshot operation is undoable and auto-saved like any other edit - Reset Adjustments preserves snapshots, so wiping the working state cannot destroy checkpoints Co-Authored-By: Claude Fable 5 --- src-tauri/src/file_management.rs | 9 + src/components/panel/right/PresetsPanel.tsx | 2 + .../panel/right/SnapshotsSection.tsx | 163 ++++++++++++++++++ src/i18n/locales/en.json | 10 ++ src/utils/adjustments.ts | 12 ++ 5 files changed, 196 insertions(+) create mode 100644 src/components/panel/right/SnapshotsSection.tsx diff --git a/src-tauri/src/file_management.rs b/src-tauri/src/file_management.rs index 43d8fac765..082672dce9 100644 --- a/src-tauri/src/file_management.rs +++ b/src-tauri/src/file_management.rs @@ -2260,7 +2260,16 @@ pub async fn reset_adjustments_for_paths( let mut existing_metadata = crate::exif_processing::load_sidecar(&sidecar_path); + // Saved snapshots survive a reset of the working state. + let preserved_snapshots = existing_metadata + .adjustments + .get("snapshots") + .filter(|v| v.as_array().is_some_and(|a| !a.is_empty())) + .cloned(); existing_metadata.adjustments = serde_json::json!({}); + if let Some(snapshots) = preserved_snapshots { + existing_metadata.adjustments["snapshots"] = snapshots; + } if let Ok(json_string) = serde_json::to_string_pretty(&existing_metadata) { let _ = std::fs::write(&sidecar_path, json_string); diff --git a/src/components/panel/right/PresetsPanel.tsx b/src/components/panel/right/PresetsPanel.tsx index 1c4613866e..abeeb804f2 100644 --- a/src/components/panel/right/PresetsPanel.tsx +++ b/src/components/panel/right/PresetsPanel.tsx @@ -39,6 +39,7 @@ import CreateFolderModal from '../../modals/CreateFolderModal'; import RenameFolderModal from '../../modals/RenameFolderModal'; import Button from '../../ui/Button'; import Text from '../../ui/Text'; +import SnapshotsSection from './SnapshotsSection'; import Slider from '../../ui/Slider'; import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; import { Adjustments, INITIAL_ADJUSTMENTS, ADJUSTMENT_GROUPS } from '../../../utils/adjustments'; @@ -1179,6 +1180,7 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp onContextMenu={handleBackgroundContextMenu} ref={setRootNodeRef} > + {isLoading && presets.length === 0 && ( s.adjustments); + const selectedImage = useEditorStore((s) => s.selectedImage); + const { setAdjustments } = useEditorActions(); + const { showContextMenu } = useContextMenu(); + const { t } = useTranslation(); + const [renamingId, setRenamingId] = useState(null); + const [tempName, setTempName] = useState(''); + + if (!selectedImage) { + return null; + } + const snapshots: AdjustmentSnapshot[] = adjustments.snapshots || []; + + const snapshotState = (adj: Adjustments): Partial => { + const { snapshots: _snapshots, ...state } = adj; + return structuredClone(state); + }; + + const handleSave = () => { + setAdjustments((prev: Adjustments) => ({ + ...prev, + snapshots: [ + ...(prev.snapshots || []), + { + id: uuidv4(), + name: t('editor.presets.snapshots.defaultName', { count: (prev.snapshots?.length || 0) + 1 }), + createdAt: Date.now(), + state: snapshotState(prev), + }, + ], + })); + }; + + const handleApply = (version: AdjustmentSnapshot) => { + setAdjustments((prev: Adjustments) => ({ + ...INITIAL_ADJUSTMENTS, + ...version.state, + snapshots: prev.snapshots, + })); + }; + + const handleOverwrite = (version: AdjustmentSnapshot) => { + setAdjustments((prev: Adjustments) => ({ + ...prev, + snapshots: (prev.snapshots || []).map((v) => + v.id === version.id ? { ...v, state: snapshotState(prev), createdAt: Date.now() } : v, + ), + })); + }; + + const handleDelete = (version: AdjustmentSnapshot) => { + setAdjustments((prev: Adjustments) => ({ + ...prev, + snapshots: (prev.snapshots || []).filter((v) => v.id !== version.id), + })); + }; + + const commitRename = () => { + const name = tempName.trim(); + if (name && renamingId) { + setAdjustments((prev: Adjustments) => ({ + ...prev, + snapshots: (prev.snapshots || []).map((v) => (v.id === renamingId ? { ...v, name } : v)), + })); + } + setRenamingId(null); + }; + + const handleRowContextMenu = (event: React.MouseEvent, version: AdjustmentSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + showContextMenu(event.clientX, event.clientY, [ + { label: t('editor.presets.snapshots.apply'), icon: Check, onClick: () => handleApply(version) }, + { label: t('editor.presets.snapshots.overwrite'), icon: RefreshCw, onClick: () => handleOverwrite(version) }, + { + label: t('editor.presets.snapshots.rename'), + icon: FileEdit, + onClick: () => { + setRenamingId(version.id); + setTempName(version.name); + }, + }, + { type: OPTION_SEPARATOR }, + { label: t('editor.presets.snapshots.delete'), icon: Trash2, onClick: () => handleDelete(version) }, + ]); + }; + + return ( +
+
+ + + {t('editor.presets.snapshots.title')} + + +
+ {snapshots.length === 0 ? ( + + {t('editor.presets.snapshots.empty')} + + ) : ( +
+ {snapshots.map((version) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e9665b760e..ed9cd47e52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -642,6 +642,16 @@ }, "presets": { "amount": "Amount", + "snapshots": { + "title": "Snapshots", + "saveTooltip": "Save a snapshot of the current edit", + "empty": "Save checkpoints of your edit and switch between looks without leaving the editor.", + "defaultName": "Snapshot {{count}}", + "apply": "Apply Snapshot", + "overwrite": "Overwrite with Current Edit", + "rename": "Rename", + "delete": "Delete Snapshot" + }, "dialog": { "allPresetFiles": "All Preset Files", "exportAllTitle": "Export All Presets", diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index 174a755a4c..082fdd4d19 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -146,9 +146,20 @@ export interface ParametricCurve { red: ParametricCurveSettings; } +// An in-editor checkpoint of the edit state, stored inside the sidecar. `state` is +// a full Adjustments snapshot minus the snapshots list itself. Complements +// library-level Virtual Copies (Duplicate Image > Virtual Copy). +export interface AdjustmentSnapshot { + id: string; + name: string; + createdAt: number; + state: Partial; +} + export interface Adjustments { [index: string]: any; aiPatches: Array; + snapshots: Array; aspectRatio: number | null; blacks: number; brightness: number; @@ -477,6 +488,7 @@ export const INITIAL_MASK_CONTAINER: MaskContainer = { export const INITIAL_ADJUSTMENTS: Adjustments = { aiPatches: [], + snapshots: [], aspectRatio: null, blacks: 0, brightness: 0, From 33d8006adddc24fa640be9264b670703d73a8c77 Mon Sep 17 00:00:00 2001 From: subbajeu Date: Mon, 6 Jul 2026 22:40:36 +0800 Subject: [PATCH 2/5] snapshots: keep them out of the render payload + match preset styling Addresses review on PR #1335: - Perf: executeApplyAdjustments structuredClone'd the full adjustments, including adjustments.snapshots, and shipped it to apply_adjustments on every interactive tick. Each snapshot is a full edit-state copy (and can nest base64 mask/AI-patch data), and the existing mask/patch stripping only reached top-level masks, not the copies inside snapshots. Destructure snapshots out before cloning; the renderer never reads them and they still persist through the separate sidecar save path. - Styling: snapshot rows now use the same tokens as preset rows (rounded-lg, bg-surface, hover:bg-surface-hover, small secondary metadata) so they read as part of the panel rather than bolted on. Co-Authored-By: Claude Fable 5 (cherry picked from commit 5b0777bf139e4bc127470acb9e355066bf4c5da1) --- src/components/panel/right/SnapshotsSection.tsx | 17 +++++++++++++---- src/hooks/useImageProcessing.ts | 8 +++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/components/panel/right/SnapshotsSection.tsx b/src/components/panel/right/SnapshotsSection.tsx index c2f08ea9fb..1d88543a84 100644 --- a/src/components/panel/right/SnapshotsSection.tsx +++ b/src/components/panel/right/SnapshotsSection.tsx @@ -122,11 +122,11 @@ export default function SnapshotsSection() { {t('editor.presets.snapshots.empty')}
) : ( -
+
{snapshots.map((version) => ( - ))} + + + + {dateLabel(version.createdAt)} + + + } + nameSlot={ + isRenaming ? ( + setTempName(e.target.value)} + onBlur={commitRename} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') setRenamingId(null); + }} + /> + ) : undefined + } + /> + {!isRenaming && ( + + )} +
+ ); + })}
)} From 20f2f085dad0598cb79b6136c4af2c8ccb7e0958 Mon Sep 17 00:00:00 2001 From: subbajeu Date: Wed, 8 Jul 2026 23:48:14 +0800 Subject: [PATCH 4/5] chore: rustfmt android_integration.rs and lut_processing.rs Pre-existing formatting drift on main (from the #1339 Android LUT fix) that fails cargo fmt --check in CI; collapses three multi-line calls that fit on one line. Not part of the snapshots feature. Co-Authored-By: Claude Fable 5 --- src-tauri/src/android_integration.rs | 9 ++------- src-tauri/src/lut_processing.rs | 3 +-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/android_integration.rs b/src-tauri/src/android_integration.rs index 9a027660d9..f67da917e7 100644 --- a/src-tauri/src/android_integration.rs +++ b/src-tauri/src/android_integration.rs @@ -39,17 +39,12 @@ pub fn initialize_android(window: &tauri::WebviewWindow) { .with_env(|env_22| { let verifier_context = unsafe { VerifierJObject::from_raw(env_22, raw_context) }; - rustls_platform_verifier::android::init_with_env( - env_22, - verifier_context, - ) + rustls_platform_verifier::android::init_with_env(env_22, verifier_context) }) .into_outcome() { jni22::Outcome::Ok(()) => { - log::info!( - "Successfully initialized rustls-platform-verifier on Android." - ); + log::info!("Successfully initialized rustls-platform-verifier on Android."); } jni22::Outcome::Err(error) => { log::error!( diff --git a/src-tauri/src/lut_processing.rs b/src-tauri/src/lut_processing.rs index a6bcea6d85..54f4128a8c 100644 --- a/src-tauri/src/lut_processing.rs +++ b/src-tauri/src/lut_processing.rs @@ -330,8 +330,7 @@ pub fn parse_lut_file(path_str: &str) -> anyhow::Result { .and_then(|s| s.to_str()) .unwrap_or("cube") .to_lowercase(); - let uri_bytes = - read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; + let uri_bytes = read_android_content_uri(path_str).map_err(|e| anyhow!("{}", e))?; (ext, Some(uri_bytes)) } #[cfg(not(target_os = "android"))] From 09a63dcbf618d0914aaca8eabcad979157e85eb1 Mon Sep 17 00:00:00 2001 From: subbajeu Date: Thu, 9 Jul 2026 07:58:50 +0800 Subject: [PATCH 5/5] snapshots: dedupe mutators + trim comments (cleanup) - Collapse the four snapshot mutators onto one updateSnapshots(list, adj) helper. - Drop the redundant pre-filter in generateSnapshotPreviews (enqueuePreviews already skips already-generated ids). - Trim over-explanatory comments. Co-Authored-By: Claude Fable 5 --- .../panel/right/PresetItemDisplay.tsx | 4 +- src/components/panel/right/PresetsPanel.tsx | 15 ++--- .../panel/right/SnapshotsSection.tsx | 66 +++++++------------ 3 files changed, 29 insertions(+), 56 deletions(-) diff --git a/src/components/panel/right/PresetItemDisplay.tsx b/src/components/panel/right/PresetItemDisplay.tsx index e86420034e..2984232244 100644 --- a/src/components/panel/right/PresetItemDisplay.tsx +++ b/src/components/panel/right/PresetItemDisplay.tsx @@ -16,10 +16,8 @@ export interface PresetItemDisplayProps { intensity?: number; onIntensityChange?: (val: number) => void; onDragStateChange?: (isDragging: boolean) => void; - // When set (snapshots), this replaces the preset type row and hides the - // preset-only feature badges, so snapshots reuse the card without its preset chrome. + // Snapshot mode: replaces the type row and hides preset-only feature badges. subtitle?: ReactNode; - // When set, replaces the name with this node (used to edit the name in place). nameSlot?: ReactNode; } diff --git a/src/components/panel/right/PresetsPanel.tsx b/src/components/panel/right/PresetsPanel.tsx index f889069bcd..80b1ca2a91 100644 --- a/src/components/panel/right/PresetsPanel.tsx +++ b/src/components/panel/right/PresetsPanel.tsx @@ -418,8 +418,7 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp item.folder.children.forEach((p: Preset) => allPresetIds.add(p.id)); } }); - // Keep live snapshot previews too; the id embeds createdAt, so an overwritten - // snapshot's stale preview falls out of the set here and gets revoked. + // Keep live snapshot previews; a stale (overwritten) one falls out and gets revoked. snapshots.forEach((s: AdjustmentSnapshot) => allPresetIds.add(snapshotPreviewId(s))); const currentPreviews = previewsRef.current; @@ -655,18 +654,14 @@ export default function PresetsPanel({ onNavigateToCommunity }: PresetsPanelProp } }, [selectedImage?.isReady, presets, enqueuePreviews]); - // Snapshots reuse the preset preview pipeline: each is fed in as a preset-shaped - // item ({ id, adjustments: state }) so it gets the same rendered thumbnail. + // Feed snapshots through the preset preview pipeline as { id, adjustments } items. const generateSnapshotPreviews = useCallback(() => { if (!selectedImage?.isReady) { return; } - const toGenerate = snapshots - .map((s: AdjustmentSnapshot) => ({ id: snapshotPreviewId(s), name: s.name, adjustments: s.state })) - .filter((p: any) => !previewsRef.current[p.id]); - if (toGenerate.length > 0) { - enqueuePreviews(toGenerate as any); - } + enqueuePreviews( + snapshots.map((s: AdjustmentSnapshot) => ({ id: snapshotPreviewId(s), name: s.name, adjustments: s.state })) as any, + ); }, [selectedImage?.isReady, snapshots, enqueuePreviews]); useEffect(() => { diff --git a/src/components/panel/right/SnapshotsSection.tsx b/src/components/panel/right/SnapshotsSection.tsx index 689d11adff..0c44d7c5c7 100644 --- a/src/components/panel/right/SnapshotsSection.tsx +++ b/src/components/panel/right/SnapshotsSection.tsx @@ -11,8 +11,7 @@ import PresetItemDisplay from './PresetItemDisplay'; import Text from '../../ui/Text'; import { TextColors, TextVariants, TextWeights } from '../../../types/typography'; -// Preview id for a snapshot thumbnail. Embeds createdAt so an overwrite (which bumps -// createdAt) produces a fresh id, and the pipeline revokes the stale one. +// Keyed by createdAt so overwriting a snapshot invalidates its cached thumbnail. export const snapshotPreviewId = (s: AdjustmentSnapshot) => `snapshot-${s.id}-${s.createdAt}`; interface SnapshotsSectionProps { @@ -20,8 +19,6 @@ interface SnapshotsSectionProps { isGeneratingPreviews: boolean; } -// In-editor checkpoints of the full edit state, stored in the sidecar. Rendered as -// preset-style cards (reusing PresetItemDisplay) so they read as part of the panel. export default function SnapshotsSection({ previews, isGeneratingPreviews }: SnapshotsSectionProps) { const adjustments = useEditorStore((s) => s.adjustments); const selectedImage = useEditorStore((s) => s.selectedImage); @@ -41,44 +38,30 @@ export default function SnapshotsSection({ previews, isGeneratingPreviews }: Sna return structuredClone(state); }; - const handleSave = () => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - snapshots: [ - ...(prev.snapshots || []), - { - id: uuidv4(), - name: t('editor.presets.snapshots.defaultName', { count: (prev.snapshots?.length || 0) + 1 }), - createdAt: Date.now(), - state: snapshotState(prev), - }, - ], - })); - }; + const updateSnapshots = (fn: (list: AdjustmentSnapshot[], adj: Adjustments) => AdjustmentSnapshot[]) => + setAdjustments((prev: Adjustments) => ({ ...prev, snapshots: fn(prev.snapshots || [], prev) })); - const handleApply = (version: AdjustmentSnapshot) => { - setAdjustments((prev: Adjustments) => ({ - ...INITIAL_ADJUSTMENTS, - ...version.state, - snapshots: prev.snapshots, - })); - }; + const handleSave = () => + updateSnapshots((list, adj) => [ + ...list, + { + id: uuidv4(), + name: t('editor.presets.snapshots.defaultName', { count: list.length + 1 }), + createdAt: Date.now(), + state: snapshotState(adj), + }, + ]); - const handleOverwrite = (version: AdjustmentSnapshot) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - snapshots: (prev.snapshots || []).map((v) => - v.id === version.id ? { ...v, state: snapshotState(prev), createdAt: Date.now() } : v, - ), - })); - }; + const handleApply = (version: AdjustmentSnapshot) => + setAdjustments((prev: Adjustments) => ({ ...INITIAL_ADJUSTMENTS, ...version.state, snapshots: prev.snapshots })); - const handleDelete = (version: AdjustmentSnapshot) => { - setAdjustments((prev: Adjustments) => ({ - ...prev, - snapshots: (prev.snapshots || []).filter((v) => v.id !== version.id), - })); - }; + const handleOverwrite = (version: AdjustmentSnapshot) => + updateSnapshots((list, adj) => + list.map((v) => (v.id === version.id ? { ...v, state: snapshotState(adj), createdAt: Date.now() } : v)), + ); + + const handleDelete = (version: AdjustmentSnapshot) => + updateSnapshots((list) => list.filter((v) => v.id !== version.id)); const startRename = (version: AdjustmentSnapshot) => { setRenamingId(version.id); @@ -88,10 +71,7 @@ export default function SnapshotsSection({ previews, isGeneratingPreviews }: Sna const commitRename = () => { const name = tempName.trim(); if (name && renamingId) { - setAdjustments((prev: Adjustments) => ({ - ...prev, - snapshots: (prev.snapshots || []).map((v) => (v.id === renamingId ? { ...v, name } : v)), - })); + updateSnapshots((list) => list.map((v) => (v.id === renamingId ? { ...v, name } : v))); } setRenamingId(null); };