From cd869bcbfa9b79eb3b6517a780754b5f4150ae52 Mon Sep 17 00:00:00 2001 From: Maaxion Date: Fri, 13 Nov 2026 10:44:59 +0200 Subject: [PATCH] Reload projects changed on disk Check the open project file about once a second while the app is idle. A change is a different modification stamp and a different content hash, so touching a file or rewriting identical bytes does nothing. Clean projects reload and keep the current camera. Projects with unsaved changes show a prompt to keep them or reload from disk. Reloads wait until viewport gestures, modeling, text and rename edits finish, and a reload does not reorder Recent Projects. --- assets/i18n/en.json | 5 + assets/i18n/pt_BR.json | 5 + assets/i18n/zh_CN.json | 5 + crates/benchup-app/src/app.rs | 2 + crates/benchup-app/src/project_tests.rs | 178 ++++++++++++++++++++++++ crates/benchup-app/src/projects.rs | 150 +++++++++++++++++++- 6 files changed, 342 insertions(+), 3 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 9647d8a..98ef735 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1156,6 +1156,7 @@ "projects.import_sketchup": "Import SketchUp", "projects.importing_geometry": "Importing geometry…", "projects.joint_error_separator": " · ", + "projects.keep_my_changes": "Keep my changes", "projects.layouts_use_assigned_stock_dimensions_and_saw_kerf": "Layouts use assigned stock dimensions and saw kerf. Confirm grain, joinery and irregular shapes against the model; this is a practical layout, not a guaranteed minimum.", "projects.leave_off_when_the_length_must_follow_the": "Leave off when the length must follow the stock grain.", "projects.location_and_share_link": "{location}\n{url}", @@ -1167,12 +1168,16 @@ "projects.preparing_estimates_pdf": "Preparing time and cost estimates PDF…", "projects.preparing_cutting_plan_pdf": "Preparing cutting plan PDF…", "projects.preparing_workshop_labels_pdf": "Preparing workshop labels PDF…", + "projects.project_changed_on_disk": "{name} was changed by another program. Reload it and discard your unsaved changes, or keep your changes.", + "projects.project_changed_on_disk_title": "Project changed on disk", "projects.project_download_started": "Project download started", "projects.project_file": "Project file", "projects.project_opened": "Project opened", + "projects.project_reloaded_from_disk": "Project reloaded from disk", "projects.project_saved": "Project saved", "projects.recent_project_detail": "\n{detail}", "projects.recent_projects": "Recent Projects", + "projects.reload_from_disk": "Reload from disk", "projects.remove_from_recent_projects": "Remove from recent projects", "projects.reselect_to_reopen_this_browser_file": "Reselect {} to reopen this browser file", "projects.restart_with_local_recovery": "Restart with local recovery", diff --git a/assets/i18n/pt_BR.json b/assets/i18n/pt_BR.json index 0cf32b7..825cde9 100644 --- a/assets/i18n/pt_BR.json +++ b/assets/i18n/pt_BR.json @@ -1154,6 +1154,7 @@ "projects.import_sketchup": "Importar SketchUp", "projects.importing_geometry": "Importando geometria…", "projects.joint_error_separator": " · ", + "projects.keep_my_changes": "Manter minhas alterações", "projects.layouts_use_assigned_stock_dimensions_and_saw_kerf": "Os planos usam as medidas da matéria-prima atribuída e a espessura da serra. Confira o veio, os encaixes e as formas irregulares no modelo; este é um plano prático, não um mínimo garantido.", "projects.leave_off_when_the_length_must_follow_the": "Deixe desmarcado quando o comprimento precisar seguir o veio da matéria-prima.", "projects.location_and_share_link": "{location}\n{url}", @@ -1165,12 +1166,16 @@ "projects.preparing_estimates_pdf": "Preparando PDF das estimativas de tempo e custo…", "projects.preparing_cutting_plan_pdf": "Preparando PDF do plano de corte…", "projects.preparing_workshop_labels_pdf": "Preparando PDF das etiquetas de oficina…", + "projects.project_changed_on_disk": "{name} foi alterado por outro programa. Recarregue-o e descarte suas alterações não salvas, ou mantenha suas alterações.", + "projects.project_changed_on_disk_title": "Projeto alterado no disco", "projects.project_download_started": "Download do projeto iniciado", "projects.project_file": "Arquivo de projeto", "projects.project_opened": "Projeto aberto", + "projects.project_reloaded_from_disk": "Projeto recarregado do disco", "projects.project_saved": "Projeto salvo", "projects.recent_project_detail": "\n{detail}", "projects.recent_projects": "Projetos recentes", + "projects.reload_from_disk": "Recarregar do disco", "projects.remove_from_recent_projects": "Remover dos projetos recentes", "projects.reselect_to_reopen_this_browser_file": "Selecione {} de novo para reabrir este arquivo do navegador", "projects.restart_with_local_recovery": "Reiniciar com recuperação local", diff --git a/assets/i18n/zh_CN.json b/assets/i18n/zh_CN.json index ed6af6f..00b5801 100644 --- a/assets/i18n/zh_CN.json +++ b/assets/i18n/zh_CN.json @@ -1156,6 +1156,7 @@ "projects.import_sketchup": "导入 SketchUp", "projects.importing_geometry": "正在导入几何体…", "projects.joint_error_separator": " · ", + "projects.keep_my_changes": "保留我的更改", "projects.layouts_use_assigned_stock_dimensions_and_saw_kerf": "排版使用已指定的原料尺寸和锯缝。请对照模型确认木纹、接合和不规则形状;这只是实用排版,并不保证是最小用料。", "projects.leave_off_when_the_length_must_follow_the": "当长度必须顺着原料木纹时,请保持关闭。", "projects.location_and_share_link": "{location}\n{url}", @@ -1167,12 +1168,16 @@ "projects.preparing_estimates_pdf": "准备时间和成本估算 PDF…", "projects.preparing_cutting_plan_pdf": "正在准备下料方案 PDF…", "projects.preparing_workshop_labels_pdf": "正在准备车间标签 PDF…", + "projects.project_changed_on_disk": "{name} 已被其他程序更改。重新加载将放弃未保存的更改,或者保留您的更改。", + "projects.project_changed_on_disk_title": "项目在磁盘上已更改", "projects.project_download_started": "项目下载已开始", "projects.project_file": "项目文件", "projects.project_opened": "项目已打开", + "projects.project_reloaded_from_disk": "已从磁盘重新加载项目", "projects.project_saved": "项目已保存", "projects.recent_project_detail": "\n{detail}", "projects.recent_projects": "最近的项目", + "projects.reload_from_disk": "从磁盘重新加载", "projects.remove_from_recent_projects": "从最近项目中移除", "projects.reselect_to_reopen_this_browser_file": "请重新选择 {} 以重新打开该浏览器文件", "projects.restart_with_local_recovery": "使用本地恢复重启", diff --git a/crates/benchup-app/src/app.rs b/crates/benchup-app/src/app.rs index ed9ae5f..2e81d02 100644 --- a/crates/benchup-app/src/app.rs +++ b/crates/benchup-app/src/app.rs @@ -7091,6 +7091,8 @@ impl eframe::App for BenchUpApp { self.history_preview = None; self.account.poll(&ctx); self.poll_project(&ctx); + #[cfg(not(target_arch = "wasm32"))] + self.poll_external_file(&ctx); #[cfg(target_os = "macos")] ctx.request_repaint_after(Duration::from_millis(500)); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/benchup-app/src/project_tests.rs b/crates/benchup-app/src/project_tests.rs index f2fee08..9a4240a 100644 --- a/crates/benchup-app/src/project_tests.rs +++ b/crates/benchup-app/src/project_tests.rs @@ -10,6 +10,184 @@ fn deliver(app: &mut BenchUpApp, result: Result, ctx: &egui::Co app.poll_project(ctx); } +#[cfg(not(target_arch = "wasm32"))] +fn write_project_file(path: &std::path::Path, doc: &Document) { + let json = serde_json::to_string(doc).unwrap(); + crate::project_files::write(path, &crate::project_files::encode(&json).unwrap()).unwrap(); +} + +#[cfg(not(target_arch = "wasm32"))] +fn settle(app: &mut BenchUpApp, ctx: &egui::Context) { + for _ in 0..100 { + app.poll_project(ctx); + if app.project.pending.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn external_edit_reloads_a_clean_project_and_keeps_the_view() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.doc.camera.yaw = 0.7; + let mut external = Document::default(); + external.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + write_project_file(&path, &external); + app.poll_external_file(&ctx); + settle(&mut app, &ctx); + assert_eq!(app.doc.model, external.model); + assert_eq!(app.doc.camera.yaw, 0.7); + assert!(!app.project.modified); + assert!(!app.project.external_change); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn external_edit_with_unsaved_changes_asks_instead_of_replacing_the_model() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.doc.model.add_edge(DVec3::ZERO, DVec3::Y).unwrap(); + app.project.modified = true; + let local = app.doc.model.clone(); + let mut external = Document::default(); + external.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + write_project_file(&path, &external); + app.poll_external_file(&ctx); + assert!(app.project.external_change); + assert!(app.project.pending.is_none()); + assert_eq!(app.doc.model, local); + app.poll_external_file(&ctx); + assert!(app.project.pending.is_none()); + assert_eq!(app.doc.model, local); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn own_save_does_not_reload_the_project() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.doc.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + app.project.modified = true; + write_project_file(&path, &app.doc); + deliver(&mut app, Ok(Outcome::Saved(Some(path))), &ctx); + app.poll_external_file(&ctx); + assert!(app.project.pending.is_none()); + assert!(!app.project.external_change); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn unreadable_external_write_keeps_the_document_and_retries_on_the_next_change() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + std::fs::write(&path, b"half written").unwrap(); + app.poll_external_file(&ctx); + settle(&mut app, &ctx); + assert!(app.project.error.is_none()); + assert!(app.doc.model.objects.is_empty()); + let mut external = Document::default(); + external.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + write_project_file(&path, &external); + app.project.external_checked = None; + app.poll_external_file(&ctx); + settle(&mut app, &ctx); + assert_eq!(app.doc.model, external.model); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn external_file_is_checked_at_most_once_a_second() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.poll_external_file(&ctx); + let mut external = Document::default(); + external.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + write_project_file(&path, &external); + app.poll_external_file(&ctx); + assert!(app.project.pending.is_none()); + assert!(app.doc.model.objects.is_empty()); + app.project.external_checked = None; + app.poll_external_file(&ctx); + settle(&mut app, &ctx); + assert_eq!(app.doc.model, external.model); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn same_content_rewrite_does_not_reload_or_prompt() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.doc.model.add_edge(DVec3::ZERO, DVec3::Y).unwrap(); + app.project.modified = true; + let local = app.doc.model.clone(); + write_project_file(&path, &Document::default()); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)) + .unwrap(); + app.poll_external_file(&ctx); + assert!(!app.project.external_change); + assert!(app.project.pending.is_none()); + assert_eq!(app.doc.model, local); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn external_edit_waits_until_a_viewport_drag_ends() { + let ctx = egui::Context::default(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("house.bup"); + write_project_file(&path, &Document::default()); + let mut app = BenchUpApp::with_document(Document::default(), None, true); + app.replace_project(Document::default(), Some(path.clone()), &ctx); + app.gesture = Some(Gesture { + button: egui::PointerButton::Primary, + start: Pos2::ZERO, + last: Pos2::ZERO, + kind: GestureKind::Select { additive: false }, + }); + let mut external = Document::default(); + external.model.add_edge(DVec3::ZERO, DVec3::X).unwrap(); + write_project_file(&path, &external); + app.poll_external_file(&ctx); + assert!(app.project.pending.is_none()); + assert!(app.doc.model.objects.is_empty()); + app.gesture = None; + app.project.external_checked = None; + app.poll_external_file(&ctx); + settle(&mut app, &ctx); + assert_eq!(app.doc.model, external.model); +} + #[test] fn selection_restores_for_its_project_and_ignores_missing_or_hidden_entities() { let ctx = egui::Context::default(); diff --git a/crates/benchup-app/src/projects.rs b/crates/benchup-app/src/projects.rs index b509db9..d676cb7 100644 --- a/crates/benchup-app/src/projects.rs +++ b/crates/benchup-app/src/projects.rs @@ -3,6 +3,24 @@ use super::*; use crate::project_files::{self, Outcome}; use std::{path::PathBuf, sync::mpsc::TryRecvError}; +#[cfg(not(target_arch = "wasm32"))] +const EXTERNAL_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + +/// Modification time and size flag a possible external write without reading the file. +fn file_stamp(path: &std::path::Path) -> Option<(std::time::SystemTime, u64)> { + let metadata = std::fs::metadata(path).ok()?; + Some((metadata.modified().ok()?, metadata.len())) +} + +/// A content hash tells a real external edit from a rewrite with the same bytes. +fn file_hash(path: &std::path::Path) -> Option { + use std::hash::Hasher; + let bytes = std::fs::read(path).ok()?; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + hasher.write(&bytes); + Some(hasher.finish()) +} + #[derive(Clone)] pub(super) enum Action { New, @@ -19,6 +37,8 @@ pub(super) enum Action { pub(super) enum Operation { #[default] Open, + #[cfg(not(target_arch = "wasm32"))] + Reload, Save, Import, Export, @@ -34,6 +54,14 @@ pub(super) struct Project { pub export_stl: bool, pub export_scale_open: bool, pub modified: bool, + // Only the desktop poll reads these; the web build never has a project path. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub disk_stamp: Option<(std::time::SystemTime, u64)>, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub disk_hash: Option, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub external_checked: Option, + pub external_change: bool, pub pending: Option, pub preview_save: Option<(super::screenshot_dialog::Capture, Option, String)>, pub operation: Operation, @@ -55,6 +83,10 @@ impl Default for Project { export_stl: false, export_scale_open: false, modified: false, + disk_stamp: None, + disk_hash: None, + external_checked: None, + external_change: false, pending: None, preview_save: None, operation: Operation::default(), @@ -105,6 +137,7 @@ impl Project { || self.confirm.is_some() || self.error.is_some() || self.cut_report.is_some() + || self.external_change } } @@ -217,6 +250,7 @@ impl BenchUpApp { self.paste_face_cache = renderer::FaceCache::default(); self.changed(ctx); self.project.path = path; + self.remember_project_file(); self.sync_stock_materials(ctx); self.project.modified = false; self.project.confirm = None; @@ -459,6 +493,63 @@ impl BenchUpApp { self.project.pending = Some(project_files::save(ctx, path, self.doc.name.clone(), contents)); } + fn remember_project_file(&mut self) { + self.project.disk_stamp = self.project.path.as_deref().and_then(file_stamp); + self.project.disk_hash = self.project.path.as_deref().and_then(file_hash); + } + + #[cfg(not(target_arch = "wasm32"))] + fn viewport_interaction_active(&self, ctx: &egui::Context) -> bool { + self.gesture.is_some() + || self.drawing.is_some() + || self.modeling.is_some() + || self.paste_preview.is_some() + || self.section_drag.is_some() + || self.text_editor.is_some() + || self.object_creation.is_some() + || self.object_rename.is_some() + || ctx.egui_wants_keyboard_input() + || ctx.input(|input| input.pointer.any_down()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn poll_external_file(&mut self, ctx: &egui::Context) { + let Some(path) = self.project.path.clone() else { + return; + }; + ctx.request_repaint_after(EXTERNAL_CHECK_INTERVAL); + if self.project.external_checked.is_some_and(|checked| checked.elapsed() < EXTERNAL_CHECK_INTERVAL) { + return; + } + self.project.external_checked = Some(std::time::Instant::now()); + if self.project.blocked() || self.viewport_interaction_active(ctx) { + return; + } + let stamp = file_stamp(&path); + if stamp.is_none() || stamp == self.project.disk_stamp { + return; + } + self.project.disk_stamp = stamp; + // Touching a file or saving identical bytes must not count as a change. An + // unreadable write keeps the document; the next write to the file retries. + let hash = file_hash(&path); + if hash.is_none() || hash == self.project.disk_hash { + return; + } + self.project.disk_hash = hash; + if self.project.modified { + self.project.external_change = true; + } else { + self.reload_project(ctx, path); + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn reload_project(&mut self, ctx: &egui::Context, path: PathBuf) { + self.project.operation = Operation::Reload; + self.project.pending = Some(project_files::open_path(ctx, path)); + } + pub(super) fn poll_project(&mut self, ctx: &egui::Context) { if let Some((mut capture, path, contents)) = self.project.preview_save.take() { match capture.poll(ctx) { @@ -503,10 +594,28 @@ impl BenchUpApp { other => (other, None), }; match result { - Ok(Outcome::Opened(doc, path)) => { + Ok(Outcome::Opened(mut doc, path)) => { + #[cfg(not(target_arch = "wasm32"))] + let reloading = matches!(operation, Operation::Reload); + #[cfg(target_arch = "wasm32")] + let reloading = false; + if reloading { + self.sync_viewport_cameras(); + doc.camera = self.doc.camera.clone(); + doc.viewport_cameras = self.doc.viewport_cameras.clone(); + } self.replace_project(*doc, path, ctx); - self.remember_recent_project(browser_file); - self.say(ctx, tr!("projects.project_opened")); + if !reloading { + self.remember_recent_project(browser_file); + } + self.say( + ctx, + if reloading { + tr!("projects.project_reloaded_from_disk") + } else { + tr!("projects.project_opened") + }, + ); } Ok(Outcome::Saved(path)) => { let downloaded = cfg!(target_arch = "wasm32") && path.is_none(); @@ -518,6 +627,7 @@ impl BenchUpApp { self.changed(ctx); } self.project.path = path; + self.remember_project_file(); self.project.modified = modified; self.say( ctx, @@ -579,6 +689,10 @@ impl BenchUpApp { #[cfg(target_arch = "wasm32")] Ok(Outcome::BrowserOpened(..)) => unreachable!(), Ok(Outcome::Cancelled) => self.project.after_save = None, + // A reload can read a file another program is still writing. Keep the + // current document; the next change to the file retries. + #[cfg(not(target_arch = "wasm32"))] + Err(_) if matches!(operation, Operation::Reload) => {} Err(error) => { self.project.after_save = None; self.project.error = Some(tr!("projects.could_not_complete_file_operation", error = error)); @@ -662,6 +776,8 @@ impl BenchUpApp { ui.spinner(); ui.text_label(match self.project.operation { Operation::Open => tr!("projects.opening_project"), + #[cfg(not(target_arch = "wasm32"))] + Operation::Reload => tr!("projects.opening_project"), Operation::Save => tr!("projects.saving_project"), Operation::Import => tr!("projects.importing_geometry"), Operation::Export => tr!("projects.exporting_geometry"), @@ -721,6 +837,34 @@ impl BenchUpApp { self.project.confirm = None; } } + #[cfg(not(target_arch = "wasm32"))] + if self.project.external_change { + let response = Modal::new("project_external_change", 480.).show(ctx, |modal| { + modal.header(|ui| { + ui.text_heading(tr!("projects.project_changed_on_disk_title")); + }); + modal.body(|ui| { + ui.text_label(tr!("projects.project_changed_on_disk", name = self.doc.name)); + }); + modal.footer(|ui| { + if primary_button(ui, tr!("projects.keep_my_changes"), true).clicked() { + Some(false) + } else if ui.button(tr!("projects.reload_from_disk")).clicked() { + Some(true) + } else { + None + } + }) + }); + if let Some(reload) = response.inner { + self.project.external_change = false; + if let (true, Some(path)) = (reload, self.project.path.clone()) { + self.reload_project(ctx, path); + } + } else if response.should_close() { + self.project.external_change = false; + } + } if let Some(error) = &self.project.error { let response = Modal::new("project_file_error", 480.).show(ctx, |modal| { modal.header(|ui| {