fix(editor): bump document revision on raw content mutations

The layer-panel row cache keys on document_revision, which made revision
correctness load-bearing: host paths that mutate content through raw
active_children_mut() without mark_document_changed() left the panel
painting stale rows (and is_dirty save-tracking wrong) — most visibly
the starter-frame clear before a design run. Adds the bump at the four
raw sites (starter clear, image-search apply, both pick-fill handlers);
all other raw-mutation sites audited as already bumping via history.
This commit is contained in:
Kayshen-X 2026-07-11 00:25:44 +08:00
parent 2216e61c0f
commit d6f6cfaae1
5 changed files with 86 additions and 9 deletions

View file

@ -484,6 +484,11 @@ pub(crate) fn clear_fresh_starter_frame_for_design(state: &mut EditorState) -> b
}
state.active_children_mut().clear();
state.clear_selection();
// Raw `active_children_mut()` bypasses the command/history path, so
// bump the document revision explicitly. Without it the layer-panel
// row cache (keyed on `document_revision()`) keeps painting the
// now-deleted starter "Frame" row, and save-dirty tracking stays wrong.
state.mark_document_changed();
true
}
@ -767,6 +772,45 @@ mod tests {
node
}
#[test]
fn clear_fresh_starter_frame_bumps_document_revision() {
let mut state = EditorState::new();
// Install the exact blank starter frame the design classifier
// recognizes (id "n10", name "Frame", 1200x800, white fill).
let starter: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({
"type": "frame",
"id": "n10",
"name": "Frame",
"x": 0,
"y": 0,
"width": 1200,
"height": 800,
"fill": [{ "type": "solid", "color": "#ffffff" }],
"children": []
}))
.expect("starter frame fixture");
state.active_children_mut().clear();
state.active_children_mut().push(starter);
let revision_before = state.document_revision();
assert!(
clear_fresh_starter_frame_for_design(&mut state),
"the blank starter frame must be recognized and cleared"
);
assert!(
state.active_children().is_empty(),
"the starter Frame row must be gone after the clear"
);
// Regression: the raw `active_children_mut().clear()` must bump the
// revision, or the layer-panel row cache (keyed on
// `document_revision()`) keeps painting the deleted "Frame" row.
assert_ne!(
state.document_revision(),
revision_before,
"clearing the starter frame must advance document_revision"
);
}
#[test]
fn builtin_design_keyword_with_existing_target_prefers_modify_route() {
let mut state = EditorState::new();

View file

@ -292,10 +292,13 @@ impl ImageSearchSession {
let job = self.jobs.swap_remove(i);
let id = job.node_id.as_str().to_string();
self.in_flight.remove(&id);
// `in_flight`/`completed` mutated outside `enqueue_missing`
// (and `apply_result` may edit the document without bumping
// its revision) — invalidate the scan gate so the next
// `enqueue_missing` re-walks once.
// `in_flight`/`completed` are mutated outside
// `enqueue_missing`, and a failed job updates them without
// any document-content change (so no revision bump) —
// invalidate the scan gate so the next `enqueue_missing`
// re-walks once. (A successful `apply_result` DOES bump the
// revision, but the gate invalidation still covers the
// failure path.)
self.last_scanned = None;
if let Some(url) = url {
if apply_result(state, &job.node_id, &url) {
@ -724,7 +727,7 @@ pub(crate) fn apply_result(state: &mut EditorState, node_id: &NodeId, url: &str)
};
let is_unfilled_placeholder_frame = is_frame_placeholder_still_unfilled(node);
let is_unfilled_placeholder_rectangle = is_image_area_rectangle_by_heuristic(node);
match node {
let changed = match node {
PenNode::Image(image) => {
if image.src == url {
return false;
@ -771,7 +774,16 @@ pub(crate) fn apply_result(state: &mut EditorState, node_id: &NodeId, url: &str)
true
}
_ => false,
};
if changed {
// This writes document content through raw `active_children_mut()`
// outside the command/history path, so bump the revision. The
// layer-panel row cache + save-dirty tracking key on
// `document_revision()`; the placeholder-frame/rectangle branches
// also clear `children`, which changes the visible layer rows.
state.mark_document_changed();
}
changed
}
fn fetch_first_image_url_blocking(

View file

@ -234,6 +234,7 @@ fn apply_result_sets_empty_image_src() {
state
.active_children_mut()
.push(image_node("img1", "", Some("burger fries")));
let revision_before = state.document_revision();
assert!(apply_result(
&mut state,
@ -244,6 +245,13 @@ fn apply_result_sets_empty_image_src() {
panic!("expected image");
};
assert_eq!(image.src, "https://example.com/photo.jpg");
// A content-mutating apply_result bumps the revision so the layer-panel
// row cache + save-dirty tracking (keyed on `document_revision()`) refresh.
assert_ne!(
state.document_revision(),
revision_before,
"apply_result that writes content must advance document_revision"
);
}
#[test]

View file

@ -143,7 +143,13 @@ pub fn handle_pick_fill_image(host: &mut WidgetHostNative) {
return;
}
};
let _ = host.editor_state_mut().set_selected_fill_image_url(&url);
if host.editor_state_mut().set_selected_fill_image_url(&url) {
// `set_selected_fill_image_url` writes fill content without touching
// the command/history path, so bump the revision (layer-panel cache +
// save-dirty tracking key on `document_revision()`). The sibling
// relink handler above bumps via `commit_history()`.
host.editor_state_mut().mark_document_changed();
}
host.mark_editor_state_dirty();
}

View file

@ -623,10 +623,17 @@ fn pick_fill_image<C: RepaintContext + 'static>(inner: &InnerRc<C>) {
return;
};
let mut b = inner2.borrow_mut();
let _ = b
.host_mut()
if b.host_mut()
.editor_state_mut()
.set_selected_fill_image_url(&url);
.set_selected_fill_image_url(&url)
{
// Fill content written outside the command/history
// path — bump the revision so the layer-panel cache +
// save-dirty tracking (keyed on `document_revision()`)
// see it. The relink handler below bumps via
// `commit_history()`.
b.host_mut().editor_state_mut().mark_document_changed();
}
b.host_mut().mark_editor_state_dirty();
let _ = b.repaint();
}),