Compare commits

..

No commits in common. "5e2d578583224be926c27997a4932bf59ff52625" and "6f4fb8058e27fa46db00ee5731ec08055bccffa3" have entirely different histories.

6 changed files with 131 additions and 219 deletions

View file

@ -171,26 +171,19 @@ compile error.
wipe redundant. It previously did both on every sync, for all three wipe redundant. It previously did both on every sync, for all three
viewports, on every frame of a drag. viewports, on every frame of a drag.
**Part ids are shared, not copied.** All three viewports hold clones Each viewport also owns a `next_part_id` allocator. A sync must
of one `PartIdAllocator` (`Rc<Cell<u64>>`), wired up by reconcile it with `scene_holder::reconcile_next_part_id`, never assign
`CadWorkspace::share_part_id_allocator`. Allocate with it: only one viewport wins a frame, so the loser's allocations are
`part_ids.allocate()`; never keep a private counter. absent from the winner's snapshot and a direct assignment moves the
allocator *backwards*, reissuing live ids.
This was three independent `u64`s synced by copy. Only one viewport This is still a copy, not shared ownership, and the id allocator is
wins a sync frame, so the loser's allocations were absent from the the clearest evidence that copying is the wrong model — one counter
winner's snapshot and the assignment moved its counter *backwards* duplicated three ways cannot be made correct, only bounded. The
two parts drawn in different views between syncs got the same remaining Phase 5.2 work is to hand all three viewports one
`NodeId`, which keys `MeshCache`, `part_geoms` and every command. `Rc<RefCell<CadDocument>>` and delete the snapshot pair; until then
Reconciling on each sync bounded that; sharing removes it. the generation check bounds the cost and the reconcile bounds the
damage.
Parts adopted from outside the allocator — script rebuild, file load,
undo restore — carry ids it never issued, so `reserve_past_nodes` runs
on every `replace_parts_snapshot`.
The parts list itself is still copied. That is the rest of Phase 5.2:
one `Rc<RefCell<CadDocument>>` and no snapshot pair. The id allocator
is the proof of the approach — one counter duplicated three ways could
only ever be bounded, never made correct.
5. **`CadTransform::rotation_euler_xyz` is in DEGREES.** This is the 5. **`CadTransform::rotation_euler_xyz` is in DEGREES.** This is the
crate-wide contract: `math::rot_*_mat`, `makepad_csg::Solid::rotate_*`, crate-wide contract: `math::rot_*_mat`, `makepad_csg::Solid::rotate_*`,

View file

@ -89,7 +89,7 @@ pub use cad_scene::{
NodeMetadata, PartKind, SceneBuilder, SceneMeta, SceneUnits, SceneVisitor, NodeMetadata, PartKind, SceneBuilder, SceneMeta, SceneUnits, SceneVisitor,
SheetId, walk_scene, SheetId, walk_scene,
}; };
pub use scene_holder::{PartIdAllocator, PartsStore, SceneCache}; pub use scene_holder::{PartsStore, SceneCache};
// Re-exports so call sites need not name the sub-module. // Re-exports so call sites need not name the sub-module.
pub(crate) use constants::{DEFAULT_CAD_SCRIPT, LIVE_UPDATE_INTERVAL, local_openai_url, local_openai_model, GENERATED_DIR, GENERATED_SCRIPT_FILE, GENERATED_OBJ_FILE, DEMO_MAX_CURVE_SEGMENTS, DEMO_MAX_SPHERE_RINGS, DEMO_MAX_TORUS_MINOR_SEGMENTS, PART_SELECT_COLOR, PART_PICK_RADIUS, MAX_UNDO_LEVELS, HOVER_PICK_MIN_MOVE_PX, CAD_SCRIPT_TIME_BUDGET, CAD_SCRIPT_BUDGET_SAMPLE_INSTRUCTIONS}; pub(crate) use constants::{DEFAULT_CAD_SCRIPT, LIVE_UPDATE_INTERVAL, local_openai_url, local_openai_model, GENERATED_DIR, GENERATED_SCRIPT_FILE, GENERATED_OBJ_FILE, DEMO_MAX_CURVE_SEGMENTS, DEMO_MAX_SPHERE_RINGS, DEMO_MAX_TORUS_MINOR_SEGMENTS, PART_SELECT_COLOR, PART_PICK_RADIUS, MAX_UNDO_LEVELS, HOVER_PICK_MIN_MOVE_PX, CAD_SCRIPT_TIME_BUDGET, CAD_SCRIPT_BUDGET_SAMPLE_INSTRUCTIONS};
pub(crate) use persistence::{PENDING_ATTACHED_IMAGE, CAD_SCRIPT_OUTPUT, cad_manifest_path, cad_generated_dir_path, cad_generated_script_path, cad_generated_obj_path, load_saved_cad_script, save_cad_state, set_cad_script_output, clear_cad_script_output, take_cad_script_output}; pub(crate) use persistence::{PENDING_ATTACHED_IMAGE, CAD_SCRIPT_OUTPUT, cad_manifest_path, cad_generated_dir_path, cad_generated_script_path, cad_generated_obj_path, load_saved_cad_script, save_cad_state, set_cad_script_output, clear_cad_script_output, take_cad_script_output};
@ -1659,11 +1659,8 @@ pub struct CadViewport {
/// `mark_dirty()` + `invalidate_node(id)`. /// `mark_dirty()` + `invalidate_node(id)`.
#[rust] #[rust]
scene_cache: SceneCache, scene_cache: SceneCache,
/// Shared with the other two viewports (see #[rust(1u64)]
/// `CadWorkspace::share_part_id_allocator`). Not a plain `u64`: next_part_id: u64,
/// three independent counters synced by copy reissued live ids.
#[rust]
part_ids: PartIdAllocator,
#[rust] #[rust]
selection: Vec<u64>, selection: Vec<u64>,
#[rust(ViewMode::ThreeD)] #[rust(ViewMode::ThreeD)]

View file

@ -464,84 +464,35 @@ mod tests {
/// This is the first increment of Phase 5.1. `CadViewport` still owns the /// This is the first increment of Phase 5.1. `CadViewport` still owns the
/// store directly; sharing one document across the three viewports (5.2) /// store directly; sharing one document across the three viewports (5.2)
/// is a separate change. /// is a separate change.
/// Part-id allocator shared by every `CadViewport`. /// Reconcile a destination viewport's id allocator with an incoming
/// snapshot.
/// ///
/// The three viewports used to hold one `u64` each, kept in step by /// The three `CadViewport`s each own a `next_part_id` counter and are
/// copying whole snapshots. That cannot be made correct: only one /// kept in step by copying whole snapshots between them. Assigning the
/// viewport wins a sync frame, so the loser's allocations are absent /// source's counter directly -- which is what the sync used to do --
/// from the winner's snapshot and a direct assignment moves the /// can move a destination's counter *backwards*, because only one
/// allocator backwards, reissuing ids that are still live. Two parts /// viewport wins per frame and the loser's allocations are not in the
/// drawn in different views between two syncs got the same `NodeId` -- /// winner's snapshot.
/// which keys `MeshCache`, `part_geoms` and every command.
/// ///
/// Reconciling the counters on every sync bounded that damage. Sharing /// Two parts drawn in different viewports between two syncs therefore
/// one counter removes it: an id handed out anywhere is never handed /// both got the same id, and the loser's part was silently dropped.
/// out again, with no sync step involved.
/// ///
/// `Rc<Cell<u64>>` rather than `Arc`: the viewports live on the UI /// The counter must never retreat, and must never collide with an id
/// thread. `Cell` because a `u64` is `Copy` and no borrow can outlive /// already present in the incoming parts, so take the maximum of:
/// the call, so there is no `RefCell` panic to reason about. /// what the destination has already handed out, what the source has
/// /// handed out, and one past the highest id actually in the list.
/// This is the first piece of Phase 5.2 to become genuinely shared pub fn reconcile_next_part_id(
/// rather than copied. The parts list itself is next. destination_next: u64,
#[derive(Clone, Debug)] source_next: u64,
pub struct PartIdAllocator { incoming: &[CadNode],
next: std::rc::Rc<std::cell::Cell<u64>>, ) -> u64 {
} let highest_in_use = incoming
.iter()
impl PartIdAllocator { .map(|n| n.id.raw())
/// A fresh allocator starting at `first`. .max()
pub fn new(first: u64) -> Self { .map(|m| m.saturating_add(1))
Self { next: std::rc::Rc::new(std::cell::Cell::new(first)) } .unwrap_or(0);
} destination_next.max(source_next).max(highest_in_use)
/// Hand out the next id.
///
/// Saturates rather than wrapping: reissuing id 0 after 2^64 parts
/// would alias a live node, which is worse than refusing to
/// allocate.
pub fn allocate(&self) -> u64 {
let id = self.next.get();
self.next.set(id.saturating_add(1));
id
}
/// The id that `allocate` will return next.
pub fn peek(&self) -> u64 {
self.next.get()
}
/// Ensure no id below `bound` is ever handed out.
///
/// Used when adopting parts from outside the allocator -- a script
/// rebuild, a file load, an undo restore -- whose ids were not
/// issued by it. Never lowers the counter.
pub fn reserve_up_to(&self, bound: u64) {
if bound > self.next.get() {
self.next.set(bound);
}
}
/// Reserve past every id present in `nodes`.
pub fn reserve_past_nodes(&self, nodes: &[CadNode]) {
if let Some(max) = nodes.iter().map(|n| n.id.raw()).max() {
self.reserve_up_to(max.saturating_add(1));
}
}
/// True when both handles refer to the same counter.
///
/// Only meaningful in tests: production code shares one allocator by
/// construction, and this is what proves it.
pub fn shares_with(&self, other: &Self) -> bool {
std::rc::Rc::ptr_eq(&self.next, &other.next)
}
}
impl Default for PartIdAllocator {
fn default() -> Self {
Self::new(1)
}
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@ -822,65 +773,51 @@ mod scene_cache_generation_tests {
); );
} }
// ---- shared id allocator ---- // ---- id allocator reconciliation ----
/// The defect that motivated sharing: two viewports allocating /// The sync used to assign the source's `next_part_id` directly,
/// between syncs used to produce the same id. A shared allocator /// which can move a destination's allocator BACKWARDS.
/// cannot, because there is one counter. ///
/// Only one viewport wins per sync frame. If the user draws in the
/// 2D view and the 3D view between two syncs, both allocate the
/// same id independently, and the loser's counter is then reset to
/// the winner's -- so the very next part reuses an id that is
/// already live.
#[test] #[test]
fn two_handles_never_hand_out_the_same_id() { fn reconciling_never_moves_the_allocator_backwards() {
let view_2d = PartIdAllocator::new(1); // Destination has handed out up to 41; source only up to 20.
let view_3d = view_2d.clone(); let next = reconcile_next_part_id(42, 20, &[]);
assert_eq!(
let a = view_2d.allocate(); next, 42,
let b = view_3d.allocate(); "the destination's allocator must not retreat to the source's"
let c = view_2d.allocate();
assert_eq!((a, b, c), (1, 2, 3), "ids must be globally unique");
assert!(
view_2d.shares_with(&view_3d),
"clone must alias the counter, not copy it"
); );
assert_eq!(view_3d.peek(), 4, "both handles observe every allocation");
} }
/// A separately constructed allocator must NOT share -- otherwise /// It must also clear every id actually present in the incoming
/// the test above would pass for the wrong reason. /// list, even when both counters are stale -- a snapshot restored
/// from disk or from undo can carry ids above both.
#[test] #[test]
fn separately_constructed_allocators_do_not_share() { fn reconciling_clears_the_highest_incoming_id() {
let a = PartIdAllocator::new(1); let parts = vec![node(3), node(99), node(7)];
let b = PartIdAllocator::new(1); let next = reconcile_next_part_id(5, 5, &parts);
assert!(!a.shares_with(&b)); assert_eq!(
a.allocate(); next, 100,
assert_eq!(b.peek(), 1, "an unrelated allocator is unaffected"); "an id already in the list must never be handed out again"
);
} }
/// Parts arriving from outside the allocator -- a script rebuild, a /// The ordinary case: nothing stale, so the counters agree.
/// file load, an undo restore -- carry ids it never issued. It must
/// step past them or the next allocation aliases a live node.
#[test] #[test]
fn reserving_past_adopted_nodes_prevents_reuse() { fn reconciling_is_a_no_op_when_the_viewports_agree() {
let alloc = PartIdAllocator::new(1); let parts = vec![node(1), node(2)];
alloc.reserve_past_nodes(&[node(3), node(99), node(7)]); assert_eq!(reconcile_next_part_id(3, 3, &parts), 3);
assert_eq!(alloc.allocate(), 100);
} }
/// Reserving must never lower the counter: ids already handed out /// u64::MAX must not wrap into 0 and start reissuing live ids.
/// are live even if the incoming list happens to be shorter.
#[test] #[test]
fn reserving_never_moves_the_counter_backwards() { fn reconciling_saturates_instead_of_wrapping() {
let alloc = PartIdAllocator::new(50); let parts = vec![node(u64::MAX)];
alloc.reserve_up_to(10); assert_eq!(reconcile_next_part_id(0, 0, &parts), u64::MAX);
alloc.reserve_past_nodes(&[node(2)]);
assert_eq!(alloc.peek(), 50);
}
/// Saturate rather than wrap. Wrapping to 0 would reissue live ids.
#[test]
fn allocation_saturates_at_the_top_of_the_range() {
let alloc = PartIdAllocator::new(u64::MAX);
assert_eq!(alloc.allocate(), u64::MAX);
assert_eq!(alloc.peek(), u64::MAX, "must not wrap to 0");
} }
#[test] #[test]

View file

@ -131,24 +131,12 @@ impl CadViewport {
cx.redraw_all(); cx.redraw_all();
} }
pub(crate) fn parts_snapshot(&self) -> (Vec<CadNode>, Vec<u64>) { pub(crate) fn parts_snapshot(&self) -> (Vec<CadNode>, Vec<u64>, u64) {
(self.parts.as_slice().to_vec(), self.selection.clone()) (
} self.parts.as_slice().to_vec(),
self.selection.clone(),
/// Hand this viewport the allocator the others use. self.next_part_id,
/// )
/// Called once per viewport at startup. Until every viewport shares
/// one, ids are only unique per view.
pub(crate) fn adopt_part_id_allocator(&mut self, shared: PartIdAllocator) {
// Anything this viewport already handed out must stay reserved.
shared.reserve_up_to(self.part_ids.peek());
shared.reserve_past_nodes(self.parts.as_slice());
self.part_ids = shared;
}
/// The allocator handle, for sharing with the other viewports.
pub(crate) fn part_id_allocator(&self) -> PartIdAllocator {
self.part_ids.clone()
} }
/// The `PartsStore` generation this viewport is currently showing. /// The `PartsStore` generation this viewport is currently showing.
@ -166,6 +154,7 @@ impl CadViewport {
cx: &mut Cx, cx: &mut Cx,
parts: Vec<CadNode>, parts: Vec<CadNode>,
selection: Vec<u64>, selection: Vec<u64>,
next_part_id: u64,
) { ) {
// Which node ids survive this replacement. GPU geometry for a // Which node ids survive this replacement. GPU geometry for a
// surviving id is still valid -- the mesh is rebuilt from the // surviving id is still valid -- the mesh is rebuilt from the
@ -180,11 +169,15 @@ impl CadViewport {
parts.iter().map(|p| p.id.raw()).collect(); parts.iter().map(|p| p.id.raw()).collect();
self.part_geoms.retain(|id, _| incoming.contains(id)); self.part_geoms.retain(|id, _| incoming.contains(id));
// No id reconciliation here any more: the allocator is shared, // Never let the id allocator retreat. Only one viewport wins a
// so an id handed out in any viewport is already accounted for // sync frame, so the loser's allocations are absent from this
// in all of them. Parts adopted from outside the allocator // snapshot -- assigning the source's counter directly would
// (script rebuild, file load) still need reserving. // reissue ids that are already live in the other view.
self.part_ids.reserve_past_nodes(&parts); self.next_part_id = super::scene_holder::reconcile_next_part_id(
self.next_part_id,
next_part_id,
&parts,
);
self.parts.replace_all(parts); self.parts.replace_all(parts);
self.selection = selection; self.selection = selection;
// The scene snapshot is rebuilt on demand: `scene_for` compares // The scene snapshot is rebuilt on demand: `scene_for` compares
@ -461,7 +454,8 @@ impl CadViewport {
} }
pub(crate) fn add_part(&mut self, cx: &mut Cx, kind: PartKind) -> u64 { pub(crate) fn add_part(&mut self, cx: &mut Cx, kind: PartKind) -> u64 {
let id = self.part_ids.allocate(); let id = self.next_part_id;
self.next_part_id += 1;
let (size, color) = match kind { let (size, color) = match kind {
PartKind::Cube => (vec3(1.0, 1.0, 1.0), vec4(0.34, 0.74, 0.86, 1.0)), PartKind::Cube => (vec3(1.0, 1.0, 1.0), vec4(0.34, 0.74, 0.86, 1.0)),
PartKind::Cylinder => (vec3(1.0, 1.2, 1.0), vec4(0.55, 0.82, 0.55, 1.0)), PartKind::Cylinder => (vec3(1.0, 1.2, 1.0), vec4(0.55, 0.82, 0.55, 1.0)),
@ -1223,7 +1217,8 @@ impl CadViewport {
let seg_len = total_len / n as f32; let seg_len = total_len / n as f32;
let mut new_ids = Vec::new(); let mut new_ids = Vec::new();
for i in 0..n { for i in 0..n {
let new_id = self.part_ids.allocate(); let new_id = self.next_part_id;
self.next_part_id += 1;
let offset = (i as f32 + 0.5) * seg_len - total_len * 0.5; let offset = (i as f32 + 0.5) * seg_len - total_len * 0.5;
let mut pos = part.pos(); let mut pos = part.pos();
let mut size = part.size(); let mut size = part.size();
@ -1336,7 +1331,8 @@ impl CadViewport {
let clipboard = self.clipboard_parts.clone(); let clipboard = self.clipboard_parts.clone();
let mut new_ids = Vec::new(); let mut new_ids = Vec::new();
for part in &clipboard { for part in &clipboard {
let id = self.part_ids.allocate(); let id = self.next_part_id;
self.next_part_id += 1;
let mut new_part = part.clone(); let mut new_part = part.clone();
new_part.id = NodeId(id); new_part.id = NodeId(id);
let mut p = new_part.pos(); let mut p = new_part.pos();
@ -3267,7 +3263,8 @@ impl CadViewport {
} }
let start = self.drawing.start_world; let start = self.drawing.start_world;
let end = self.drawing.current_world; let end = self.drawing.current_world;
let id = self.part_ids.allocate(); let id = self.next_part_id;
self.next_part_id += 1;
match self.drawing.tool { match self.drawing.tool {
CadTool::Arc => { CadTool::Arc => {
if let Some(mid) = self.drawing.arc_mid_world { if let Some(mid) = self.drawing.arc_mid_world {
@ -3706,7 +3703,8 @@ impl CadViewport {
} else { } else {
(0.2f32, dist as f32) (0.2f32, dist as f32)
}; };
let id = self.part_ids.allocate(); let id = self.next_part_id;
self.next_part_id += 1;
self.parts.push(CadNode { self.parts.push(CadNode {
id: NodeId(id), id: NodeId(id),
name: format!("Wall-{}", id), name: format!("Wall-{}", id),

View file

@ -314,20 +314,20 @@ impl CadWorkspace {
.widget(cx, ids!(cad_viewport)) .widget(cx, ids!(cad_viewport))
.borrow_mut::<CadViewport>() .borrow_mut::<CadViewport>()
{ {
let (parts, selected) = vp.parts_snapshot(); let (parts, selected, next_id) = vp.parts_snapshot();
if let Some(mut vp2d) = self if let Some(mut vp2d) = self
.view .view
.widget(cx, ids!(cad_viewport_2d)) .widget(cx, ids!(cad_viewport_2d))
.borrow_mut::<CadViewport>() .borrow_mut::<CadViewport>()
{ {
vp2d.replace_parts_snapshot(cx, parts.clone(), selected.clone()); vp2d.replace_parts_snapshot(cx, parts.clone(), selected.clone(), next_id);
} }
if let Some(mut vp3d) = self if let Some(mut vp3d) = self
.view .view
.widget(cx, ids!(cad_viewport_3d)) .widget(cx, ids!(cad_viewport_3d))
.borrow_mut::<CadViewport>() .borrow_mut::<CadViewport>()
{ {
vp3d.replace_parts_snapshot(cx, parts, selected); vp3d.replace_parts_snapshot(cx, parts, selected, next_id);
} }
} }
} }
@ -617,8 +617,8 @@ impl CadWorkspace {
return None; return None;
} }
let script = vp.generate_parts_script(); let script = vp.generate_parts_script();
let (parts, selected) = vp.parts_snapshot(); let (parts, selected, next_id) = vp.parts_snapshot();
Some((script, parts, selected, vp.parts_generation())) Some((script, parts, selected, next_id, vp.parts_generation()))
}); });
if let Some(Some(taken)) = taken { if let Some(Some(taken)) = taken {
if snapshot.is_none() { if snapshot.is_none() {
@ -627,7 +627,7 @@ impl CadWorkspace {
} }
} }
} }
let (Some((script, parts, selected, source_generation)), Some(source)) = let (Some((script, parts, selected, next_id, source_generation)), Some(source)) =
(snapshot, source) (snapshot, source)
else { else {
return None; return None;
@ -651,7 +651,7 @@ impl CadWorkspace {
continue; continue;
} }
self.with_viewport(cx, which, |vp, cx| { self.with_viewport(cx, which, |vp, cx| {
vp.replace_parts_snapshot(cx, parts.clone(), selected.clone()); vp.replace_parts_snapshot(cx, parts.clone(), selected.clone(), next_id);
}); });
} }
if script.is_empty() { if script.is_empty() {
@ -803,27 +803,7 @@ impl CadWorkspace {
self.set_viewport_layout_mode(cx, next); self.set_viewport_layout_mode(cx, next);
} }
/// Give all three viewports the same `PartIdAllocator`.
///
/// Idempotent and cheap, so it runs on the same path as worker
/// creation rather than needing its own init hook. Until this has
/// run, each viewport allocates from its own counter and ids are
/// only unique per view -- which is the bug this exists to remove.
fn share_part_id_allocator(&mut self, cx: &mut Cx) {
let Some(shared) = self.with_viewport(cx, 0, |vp, _cx| vp.part_id_allocator()) else {
return;
};
for which in [1usize, 2] {
self.with_viewport(cx, which, |vp, _cx| {
if !vp.part_id_allocator().shares_with(&shared) {
vp.adopt_part_id_allocator(shared.clone());
}
});
}
}
fn request_rebuild(&mut self, cx: &mut Cx, force: bool, save_output: bool) { fn request_rebuild(&mut self, cx: &mut Cx, force: bool, save_output: bool) {
self.share_part_id_allocator(cx);
if self.cad_worker.is_none() { if self.cad_worker.is_none() {
self.cad_worker = Some(CadRebuildWorker::new(cx)); self.cad_worker = Some(CadRebuildWorker::new(cx));
} }

View file

@ -813,17 +813,18 @@ impl Widget for BuildProjectsPage {
if let Some(mut ws) = self.view.widget(cx, ids!(spreadsheet_workspace)).borrow_mut::<spreadsheet_ui::SpreadsheetWorkspace>() { if let Some(mut ws) = self.view.widget(cx, ids!(spreadsheet_workspace)).borrow_mut::<spreadsheet_ui::SpreadsheetWorkspace>() {
ws.save_to_disk(cx); ws.save_to_disk(cx);
} }
// 2) Point the mobile grid at the saved workbook. // 2) Load the active sheet's data into the mobile grid
//
// The grid no longer owns a SpreadsheetData; it reads
// through a SharedWorkspaceModel. `load_saved` handles
// both the multi-sheet and the legacy single-sheet
// formats, which this used to open-code.
if let Some(mut grid) = self.view.widget(cx, ids!(m_ss_grid)).borrow_mut::<spreadsheet_ui::SpreadsheetGrid>() { if let Some(mut grid) = self.view.widget(cx, ids!(m_ss_grid)).borrow_mut::<spreadsheet_ui::SpreadsheetGrid>() {
if let Some(model) = spreadsheet_ui::model::WorkspaceModel::load_saved() { if let Some(saved) = spreadsheet_ui::persistence::load_saved_spreadsheet_state() {
grid.attach_workspace_model(std::rc::Rc::new( if let Some((sheets, active_idx)) = spreadsheet_ui::workbook::deserialize_workbook(&saved) {
std::cell::RefCell::new(model), if let Some(sheet) = sheets.get(active_idx) {
)); grid.data = sheet.data.clone();
}
} else {
// Legacy single-sheet format
grid.data = spreadsheet_ui::SpreadsheetData::default();
grid.data.deserialize(&saved);
}
} }
grid.selection_anchor = Some((0, 0)); grid.selection_anchor = Some((0, 0));
grid.selection_head = Some((0, 0)); grid.selection_head = Some((0, 0));
@ -835,13 +836,19 @@ impl Widget for BuildProjectsPage {
// Was mobile → going to desktop: // Was mobile → going to desktop:
// 1) Update the saved workbook with mobile grid's active sheet data // 1) Update the saved workbook with mobile grid's active sheet data
// (preserves the other sheets that were saved from desktop) // (preserves the other sheets that were saved from desktop)
// The grid edits the shared model in place, so saving it
// preserves every sheet -- no read-modify-write of the
// active sheet against the on-disk workbook.
if let Some(grid) = self.view.widget(cx, ids!(m_ss_grid)).borrow::<spreadsheet_ui::SpreadsheetGrid>() { if let Some(grid) = self.view.widget(cx, ids!(m_ss_grid)).borrow::<spreadsheet_ui::SpreadsheetGrid>() {
if let Some(model) = &grid.shared_model { let active_data = grid.data.serialize();
if let Err(e) = model.borrow().save() { if let Some(saved) = spreadsheet_ui::persistence::load_saved_spreadsheet_state() {
error!("spreadsheet: mobile->desktop save failed: {e}"); if let Some((mut sheets, active_idx)) = spreadsheet_ui::workbook::deserialize_workbook(&saved) {
if let Some(sheet) = sheets.get_mut(active_idx) {
sheet.data = spreadsheet_ui::SpreadsheetData::default();
sheet.data.deserialize(&active_data);
}
let updated = spreadsheet_ui::workbook::serialize_workbook(&sheets, active_idx);
let _ = spreadsheet_ui::persistence::save_spreadsheet_state(&updated);
} else {
// Legacy format — just save the grid data as-is
let _ = spreadsheet_ui::persistence::save_spreadsheet_state(&active_data);
} }
} }
} }