@@ -155,6 +155,68 @@ impl History {
155155 }
156156
157157 fn push_undo ( & mut self , op : Op ) {
158+ // Try to merge if the incoming op is a pipeline render operation
159+ if let Some ( prev) = self . undo_stack . back_mut ( ) {
160+ let should_merge = if let Op :: Batch { label, .. } = & op
161+ && ( label. contains ( "renderer: page " ) || label. contains ( "koharu-renderer: page " ) )
162+ {
163+ match prev {
164+ Op :: Batch {
165+ label : prev_label, ..
166+ } => !prev_label. starts_with ( "undo:" ) && !prev_label. contains ( ": page " ) ,
167+ _ => true ,
168+ }
169+ } else {
170+ false
171+ } ;
172+
173+ if should_merge
174+ && let Op :: Batch {
175+ label : incoming_label,
176+ ops : incoming_ops,
177+ } = op
178+ {
179+ match prev {
180+ Op :: Batch { ops : prev_ops, .. } => {
181+ prev_ops. extend ( incoming_ops) ;
182+ }
183+ single_op => {
184+ let prev_op = std:: mem:: replace (
185+ single_op,
186+ Op :: Batch {
187+ ops : vec ! [ ] ,
188+ label : String :: new ( ) ,
189+ } ,
190+ ) ;
191+ if let Op :: Batch { ops, label } = single_op {
192+ * ops = vec ! [
193+ prev_op,
194+ Op :: Batch {
195+ ops: incoming_ops,
196+ label: incoming_label,
197+ } ,
198+ ] ;
199+ * label = match & ops[ 0 ] {
200+ Op :: RemoveNode { .. } => "Delete block" . to_string ( ) ,
201+ Op :: UpdateNode { .. } => "Update block" . to_string ( ) ,
202+ Op :: AddNode { .. } => "Add block" . to_string ( ) ,
203+ Op :: RemovePage { .. } => "Delete page" . to_string ( ) ,
204+ Op :: AddPage { .. } => "Add page" . to_string ( ) ,
205+ Op :: UpdatePage { .. } => "Update page" . to_string ( ) ,
206+ Op :: ReorderPages { .. } => "Reorder pages" . to_string ( ) ,
207+ Op :: ReorderNodes { .. } => "Reorder blocks" . to_string ( ) ,
208+ Op :: UpdateProjectMeta { .. } => {
209+ "Update project metadata" . to_string ( )
210+ }
211+ Op :: Batch { label : l, .. } => l. clone ( ) ,
212+ } ;
213+ }
214+ }
215+ }
216+ return ;
217+ }
218+ }
219+
158220 self . undo_stack . push_back ( op) ;
159221 while self . undo_stack . len ( ) > self . limit {
160222 self . undo_stack . pop_front ( ) ;
@@ -219,3 +281,139 @@ pub fn replay(log_path: &Path, start_epoch: u64, scene: &mut Scene) -> Result<u6
219281 let _ = reader. seek ( SeekFrom :: End ( 0 ) ) ;
220282 Ok ( epoch)
221283}
284+
285+ #[ cfg( test) ]
286+ mod tests {
287+ use super :: * ;
288+ use std:: time:: { SystemTime , UNIX_EPOCH } ;
289+
290+ fn make_temp_history ( ) -> ( History , std:: path:: PathBuf ) {
291+ let mut temp_path = std:: env:: temp_dir ( ) ;
292+ let unique_id = SystemTime :: now ( )
293+ . duration_since ( UNIX_EPOCH )
294+ . unwrap ( )
295+ . as_nanos ( ) ;
296+ temp_path. push ( format ! ( "test_history_{}.log" , unique_id) ) ;
297+ let history = History :: open ( & temp_path, 0 ) . unwrap ( ) ;
298+ ( history, temp_path)
299+ }
300+
301+ #[ test]
302+ fn test_push_undo_merge_user_edit ( ) {
303+ let ( mut history, path) = make_temp_history ( ) ;
304+
305+ // 1. User action: "Update block"
306+ let user_op = Op :: Batch {
307+ ops : vec ! [ ] ,
308+ label : "Update block" . to_string ( ) ,
309+ } ;
310+ history. push_undo ( user_op) ;
311+ assert_eq ! ( history. undo_stack. len( ) , 1 ) ;
312+
313+ // 2. Renderer operation
314+ let render_op = Op :: Batch {
315+ ops : vec ! [ ] ,
316+ label : "koharu-renderer: page 1" . to_string ( ) ,
317+ } ;
318+ history. push_undo ( render_op) ;
319+
320+ // Should merge: undo stack size remains 1
321+ assert_eq ! ( history. undo_stack. len( ) , 1 ) ;
322+ if let Some ( Op :: Batch { label, .. } ) = history. undo_stack . back ( ) {
323+ assert_eq ! ( label, "Update block" ) ;
324+ } else {
325+ panic ! ( "Expected Batch" ) ;
326+ }
327+
328+ drop ( history) ;
329+ let _ = std:: fs:: remove_file ( path) ;
330+ }
331+
332+ #[ test]
333+ fn test_push_undo_no_merge_pipeline_step ( ) {
334+ let ( mut history, path) = make_temp_history ( ) ;
335+
336+ // 1. Pipeline step: "translation-gpt-4o: page 1"
337+ let pipeline_op = Op :: Batch {
338+ ops : vec ! [ ] ,
339+ label : "translation-gpt-4o: page 1" . to_string ( ) ,
340+ } ;
341+ history. push_undo ( pipeline_op) ;
342+ assert_eq ! ( history. undo_stack. len( ) , 1 ) ;
343+
344+ // 2. Renderer operation
345+ let render_op = Op :: Batch {
346+ ops : vec ! [ ] ,
347+ label : "koharu-renderer: page 1" . to_string ( ) ,
348+ } ;
349+ history. push_undo ( render_op) ;
350+
351+ // Should NOT merge: undo stack size becomes 2
352+ assert_eq ! ( history. undo_stack. len( ) , 2 ) ;
353+
354+ let first = & history. undo_stack [ 0 ] ;
355+ let second = & history. undo_stack [ 1 ] ;
356+
357+ if let Op :: Batch { label, .. } = first {
358+ assert_eq ! ( label, "translation-gpt-4o: page 1" ) ;
359+ } else {
360+ panic ! ( "Expected Batch" ) ;
361+ }
362+
363+ if let Op :: Batch { label, .. } = second {
364+ assert_eq ! ( label, "koharu-renderer: page 1" ) ;
365+ } else {
366+ panic ! ( "Expected Batch" ) ;
367+ }
368+
369+ drop ( history) ;
370+ let _ = std:: fs:: remove_file ( path) ;
371+ }
372+
373+ #[ test]
374+ fn test_push_undo_merge_only_immediate_preceding_user_edit ( ) {
375+ let ( mut history, path) = make_temp_history ( ) ;
376+
377+ // 1. Pipeline step (first in history)
378+ let pipeline_op = Op :: Batch {
379+ ops : vec ! [ ] ,
380+ label : "translation-gpt-4o: page 1" . to_string ( ) ,
381+ } ;
382+ history. push_undo ( pipeline_op) ;
383+
384+ // 2. User edit (second in history, immediately preceding)
385+ let user_op = Op :: Batch {
386+ ops : vec ! [ ] ,
387+ label : "Update block" . to_string ( ) ,
388+ } ;
389+ history. push_undo ( user_op) ;
390+
391+ // 3. Renderer operation
392+ let render_op = Op :: Batch {
393+ ops : vec ! [ ] ,
394+ label : "koharu-renderer: page 1" . to_string ( ) ,
395+ } ;
396+ history. push_undo ( render_op) ;
397+
398+ // Undo stack should have 2 entries (pipeline step + merged user edit & render)
399+ assert_eq ! ( history. undo_stack. len( ) , 2 ) ;
400+
401+ let first = & history. undo_stack [ 0 ] ;
402+ let second = & history. undo_stack [ 1 ] ;
403+
404+ if let Op :: Batch { label, .. } = first {
405+ assert_eq ! ( label, "translation-gpt-4o: page 1" ) ;
406+ } else {
407+ panic ! ( "Expected Batch" ) ;
408+ }
409+
410+ if let Op :: Batch { label, .. } = second {
411+ assert_eq ! ( label, "Update block" ) ;
412+ } else {
413+ panic ! ( "Expected Batch" ) ;
414+ }
415+
416+ drop ( history) ;
417+ let _ = std:: fs:: remove_file ( path) ;
418+ }
419+ }
0 commit comments