fix(app): context-menu actions, clip clipboard, A/V drop, add-track, default tracks, undo divergence

- Right-clicking an unselected clip selects it first (C++ parity) —
  this is what made Cut/Delete appear to do nothing.
- Cut/Copy/Paste clipboard: clipboard_copy/cut/paste on the engine,
  clipboard clips keep footage/range/speed/track kind and stay linked
  in the pasted group; paste lands at the playhead as one undo entry.
- Dropping a video-with-audio footage places the video clip plus a
  linked audio clip at the same range in ONE 'Add Clip' undo entry.
- Add Video/Audio Track buttons in the timeline toolbar and the track
  header context menu; new sequences start with 2 video + 2 audio
  tracks (not an undoable edit).
- oaknode Graph::add_entry now reclaims the slot from the free list —
  before, a detached-then-reattached node left its slot in the free
  list, so node_count undercounted and the next add_node silently
  clobbered the restored node. This was the user's 'undo, redo, undo,
  redo and the result changed' bug; regression covered by cycle tests
  (move/trim/delete/split/add-track/linked-placement all converge).
This commit is contained in:
2026-08-19 17:42:21 +08:00
parent dffa94127a
commit 29696479b5
8 changed files with 563 additions and 6 deletions
+6 -1
View File
@@ -111,7 +111,12 @@ impl Graph {
pub fn add_entry(&mut self, entry: NodeEntry, id: NodeId) -> NodeId {
let index = id.index();
if (index as usize) < self.entries.len() && self.entries[index as usize].vacant {
// Original slot free: reuse (index, generation) unchanged.
// Original slot free: reuse (index, generation) unchanged. The
// slot was pushed to the free list by `take_node` — reclaim it,
// or `node_count` keeps undercounting and, worse, the next
// `add_node` hands the same slot out again and silently
// clobbers the restored node (the undo/redo divergence).
self.free_list.retain(|&i| i != index);
let generation = entry.generation;
self.entries[index as usize] = entry;
return NodeId::new(index, generation);
+32
View File
@@ -222,3 +222,35 @@ fn adjacency_queries() {
assert_eq!(g.downstream(a), vec![b, c]);
assert_eq!(g.downstream(d), Vec::<NodeId>::new());
}
/// take_node + add_entry must restore the node count AND reclaim the
/// slot: before the fix, add_entry reused the slot without removing it
/// from the free list, so node_count kept undercounting and the next
/// add_node silently overwrote the restored node (the undo/redo
/// divergence the user hit: repeated undo/redo changed the result).
#[test]
fn add_entry_reclaims_the_free_slot() {
let (mut g, ids) = build(3);
let victim = ids[1];
let count_before = g.node_count();
// Detach and re-attach: the count must round-trip.
let entry = g.take_node(victim).expect("take the node");
assert_eq!(g.node_count(), count_before - 1, "detach drops the count");
let readded = g.add_entry(entry, victim);
assert_eq!(readded, victim, "identity is preserved");
assert_eq!(g.node_count(), count_before, "re-attach restores the count");
// A fresh add_node must NOT clobber the restored node (it must get a
// different slot).
let mut core = NodeCore::new();
core.label = "fresh".to_string();
let fresh = g.add_node(core, Box::new(TestNode { id: "fresh" }));
assert_ne!(fresh, victim, "the fresh node takes a different slot");
assert!(g.is_valid(victim), "the restored node survives add_node");
assert_eq!(
g.get(victim).map(|e| e.core.label.as_str()),
g.get(victim).map(|e| e.core.label.as_str()),
);
assert_eq!(g.node_count(), count_before + 1);
}