From ab6223c2569624068b1d77eb19e18f92757a133a Mon Sep 17 00:00:00 2001 From: omigamedev Date: Thu, 4 Jun 2026 13:47:43 +0200 Subject: [PATCH] Centralize legacy document file saves --- docs/modernization/build-inventory.md | 12 ++- docs/modernization/debt.md | 1 + docs/modernization/roadmap.md | 15 ++++ src/app_core/document_session.h | 43 +++++++++++ src/app_dialogs.cpp | 39 ++-------- src/legacy_document_session_services.cpp | 85 ++++++++++++++++++++++ src/legacy_document_session_services.h | 10 +++ tests/app_core/document_session_tests.cpp | 89 +++++++++++++++++++++++ 8 files changed, 257 insertions(+), 37 deletions(-) diff --git a/docs/modernization/build-inventory.md b/docs/modernization/build-inventory.md index 943e563..70abc15 100644 --- a/docs/modernization/build-inventory.md +++ b/docs/modernization/build-inventory.md @@ -587,8 +587,9 @@ Known local toolchain state: close-request executor dispatch/no-op preservation, document-save executor dispatch/no-op preservation, save-before-workflow executor dispatch, new-document target/resolution/overwrite planning and executor dispatch, - document file target, combined save-file overwrite planning, and save-version - target decisions without requiring a window, canvas, or message box. + document file target, combined save-file overwrite planning and executor + dispatch, plus save-version target decisions and executor validation without + requiring a window, canvas, or message box. - `src/legacy_document_open_services.*` is the current app-shell bridge between `pp_app_core` document-open plans and live ABR/PPBR import prompts, unsaved-project discard prompts, project opening, layer UI refresh, title @@ -599,8 +600,11 @@ Known local toolchain state: save dialogs, save-version routing, existing-project saves, and save-before-workflow prompts. It also bridges accepted new-document plans to legacy canvas resize/layer setup, overwrite prompts, title updates, and - keyboard/dialog cleanup. Retained legacy UI/canvas execution remains tracked - by `DEBT-0040` and `DEBT-0041`. + keyboard/dialog cleanup. Accepted Save As and Save Version plans now also + route through this bridge before reaching legacy project-save execution, + overwrite prompts, document field updates, title updates, and keyboard/dialog + cleanup. Retained legacy UI/canvas execution remains tracked by `DEBT-0040`, + `DEBT-0041`, and `DEBT-0042`. - `src/legacy_history_services.*` is the current app-shell bridge between `pp_app_core` history plans and legacy `ActionManager`; toolbar and `NodeCanvas` hotkeys share it while document-history extraction remains diff --git a/docs/modernization/debt.md b/docs/modernization/debt.md index 97adf5c..140353b 100644 --- a/docs/modernization/debt.md +++ b/docs/modernization/debt.md @@ -59,6 +59,7 @@ agent or engineer to remove them without reconstructing context from chat. | DEBT-0039 | Open | Modernization | Document-open planning and execution dispatch now consume pure `pp_app_core` through `App::open_document`, `pano_cli plan-open-route`, `DocumentOpenServices`, and `src/legacy_document_open_services.*`, but the bridge still opens ABR/PPBR import prompts, launches legacy brush preset import threads, applies unsaved-project discard prompts, calls legacy project-open execution, refreshes layer UI, updates the app title, and clears legacy history directly | Preserve current file-open/import behavior while document loading and brush import move toward app/document/asset/UI services | `pp_app_core_document_route_tests`; `pp_app_core_document_session_tests`; `pano_cli plan-open-route --path D:/Paint/Scenes/demo.ppi --unsaved`; `pano_cli plan-open-route --path D:/Paint/Brushes/clouds.ABR --unsaved`; `ctest --preset desktop-fast --build-config Debug` | Brush import prompting/execution, project-open execution, unsaved-project discard prompting, layer refresh, title updates, and history clearing are owned by injected app/document/asset/UI services with `App::open_document` acting only as an adapter | | DEBT-0040 | Open | Modernization | Close request, document save, and save-before-workflow planning/execution dispatch now consume pure `pp_app_core` through `App::request_close`, `App::save_document`, `App::continue_document_workflow_after_optional_save`, `pano_cli simulate-app-session`, `DocumentSaveServices`, `CloseRequestServices`, `DocumentWorkflowServices`, and `src/legacy_document_session_services.*`, but the bridge still opens legacy message boxes/save dialogs, calls `Canvas::I->project_save`, mutates the unsaved flag on close confirmation, invokes native app close, and routes save-version through the retained legacy dialog | Preserve current close/save/dirty-workflow behavior while document session execution moves toward app/document/UI/platform services | `pp_app_core_document_session_tests`; `pano_cli simulate-app-session --unsaved --save-intent save-dirty-version`; `pano_cli simulate-app-session --no-canvas`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `ctest --preset desktop-fast --build-config Debug` | Close prompt execution, native close requests, dirty-workflow save prompts, existing-project saves, save dialogs, save-version execution, and unsaved-flag mutation are owned by injected app/document/UI/platform services with `App` methods acting only as adapters | | DEBT-0041 | Open | Modernization | Accepted new-document planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_newdoc`, `pano_cli plan-new-document`, `NewDocumentServices`, and `src/legacy_document_session_services.*`, but the bridge still mutates legacy app document fields, clears legacy layer UI, resizes legacy `Canvas`, clears legacy history, creates the default layer through legacy UI, mutates unsaved/new-document flags, updates the title, and handles keyboard/dialog cleanup directly | Preserve current New Document dialog behavior while document creation moves toward app/document/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3`; `pano_cli plan-new-document --work-dir D:/Paint --name demo --resolution-index 3 --target-exists`; `pano_cli simulate-app-session --save-intent save`; `ctest --preset desktop-fast --build-config Debug` | New document creation, overwrite confirmation, canvas/document allocation, default layer creation, history clearing, title updates, dirty/new-document state, and keyboard/dialog cleanup are owned by injected app/document/UI services with `App::dialog_newdoc` acting only as a UI adapter | +| DEBT-0042 | Open | Modernization | Accepted Save As and Save Version planning/execution dispatch now consumes pure `pp_app_core` through `App::dialog_save`, `App::dialog_save_ver`, `pano_cli plan-document-file`, `pano_cli plan-document-version`, `DocumentFileSaveServices`, `DocumentVersionSaveServices`, and `src/legacy_document_session_services.*`, but the bridge still opens legacy overwrite prompts, calls legacy `Canvas::project_save`, mutates app document name/path/directory fields, marks version saves dirty before saving, updates the title, and handles keyboard/dialog cleanup directly | Preserve current Save As and Save Version behavior while document persistence moves toward app/document/storage/UI services | `pp_app_core_document_session_tests`; `pano_cli plan-document-file --work-dir D:/Paint --name demo --target-exists`; `pano_cli plan-document-version --directory D:/Paint --doc-name demo.01 --existing-path D:/Paint/demo.02.ppi`; `pano_cli simulate-app-session --save-intent save-as`; `pano_cli simulate-app-session --save-intent save-version`; `ctest --preset desktop-fast --build-config Debug` | Save As overwrite prompting, project-save execution, app document metadata updates, title updates, version-save dirty-state handling, and keyboard/dialog cleanup are owned by injected app/document/storage/UI services with `App::dialog_save` and `App::dialog_save_ver` acting only as UI adapters | ## Closed Debt diff --git a/docs/modernization/roadmap.md b/docs/modernization/roadmap.md index db800c2..d803361 100644 --- a/docs/modernization/roadmap.md +++ b/docs/modernization/roadmap.md @@ -744,6 +744,12 @@ app-core new-document executor and `src/legacy_document_session_services.*`, preserving target overwrite prompts, legacy canvas resize/layer setup, history clearing, title updates, dirty/new-document flag mutation, and keyboard/dialog cleanup while retained execution remains tracked under `DEBT-0041`. +`App::dialog_save` and `App::dialog_save_ver` now route accepted Save As and +Save Version plans through app-core document file/version save executors and +`src/legacy_document_session_services.*`, preserving overwrite prompts, +legacy `Canvas::project_save`, app document field updates, title updates, and +keyboard/dialog cleanup while retained execution remains tracked under +`DEBT-0042`. Implementation tasks: @@ -1299,6 +1305,15 @@ Results: - Focused new-document/session CTest coverage passed for `pp_app_core_document_session_tests`, `pano_cli_plan_new_document_*`, and `pano_cli_simulate_app_session_*` smoke tests after the live bridge split. +- `PanoPainter`, `pp_app_core_document_session_tests`, and `pano_cli` built + after accepted Save As and Save Version execution moved behind document + file/version save services. A clean rebuild was required once because MSVC + reported the known Debug PDB `LNK1103` corruption, after which the build + passed. +- Focused Save As/Version/session CTest coverage passed for + `pp_app_core_document_session_tests`, `pano_cli_plan_document_file_*`, + `pano_cli_plan_document_version_*`, and `pano_cli_simulate_app_session_*` + smoke tests after the live bridge split. - `pp_app_core_document_recording_tests` passed, covering recording start/stop, clear, platform recorded-file cleanup, frame-count reset, export progress totals, and oversized progress-total clamping. diff --git a/src/app_core/document_session.h b/src/app_core/document_session.h index c7e3e94..5858b3b 100644 --- a/src/app_core/document_session.h +++ b/src/app_core/document_session.h @@ -106,6 +106,21 @@ struct DocumentFileSavePlan { DocumentFileWriteDecision write_decision = DocumentFileWriteDecision::save_now; }; +class DocumentFileSaveServices { +public: + virtual ~DocumentFileSaveServices() = default; + + virtual void save_document_file(const DocumentFileSavePlan& plan) = 0; + virtual void prompt_overwrite_document_file(const DocumentFileSavePlan& plan) = 0; +}; + +class DocumentVersionSaveServices { +public: + virtual ~DocumentVersionSaveServices() = default; + + virtual void save_document_version(const DocumentVersionTarget& target) = 0; +}; + struct NewDocumentPlan { DocumentFileTarget target; int resolution = 0; @@ -339,6 +354,22 @@ template return pp::foundation::Result::success(std::move(plan)); } +[[nodiscard]] inline pp::foundation::Status execute_document_file_save_plan( + const DocumentFileSavePlan& plan, + DocumentFileSaveServices& services) +{ + switch (plan.write_decision) { + case DocumentFileWriteDecision::save_now: + services.save_document_file(plan); + return pp::foundation::Status::success(); + case DocumentFileWriteDecision::prompt_overwrite: + services.prompt_overwrite_document_file(plan); + return pp::foundation::Status::success(); + } + + return pp::foundation::Status::invalid_argument("unknown document file save write decision"); +} + [[nodiscard]] constexpr pp::foundation::Result document_resolution_from_index(int index) noexcept { constexpr std::array resolutions{ 512, 1024, 1536, 2048, 4096, 8192 }; @@ -471,4 +502,16 @@ template pp::foundation::Status::out_of_range("no available document version target")); } +[[nodiscard]] inline pp::foundation::Status execute_document_version_save( + const DocumentVersionTarget& target, + DocumentVersionSaveServices& services) +{ + if (target.name.empty() || target.path.empty()) { + return pp::foundation::Status::invalid_argument("document version target requires a name and path"); + } + + services.save_document_version(target); + return pp::foundation::Status::success(); +} + } diff --git a/src/app_dialogs.cpp b/src/app_dialogs.cpp index cfa384b..d20ede2 100644 --- a/src/app_dialogs.cpp +++ b/src/app_dialogs.cpp @@ -280,11 +280,9 @@ void App::dialog_save_ver() return; } - doc_name = target.value().name; - doc_path = target.value().path; - canvas->m_canvas->m_unsaved = true; - title_update(); - canvas->m_canvas->project_save(doc_path); + const auto status = pp::panopainter::execute_legacy_document_version_save(*this, target.value()); + if (!status.ok()) + LOG("Document version save action failed: %s", status.message); } void App::save_document(pp::app::DocumentSaveIntent intent) @@ -332,34 +330,9 @@ void App::dialog_save() return; } - auto action = [this, dialog, plan = plan.value()] { - canvas->m_canvas->project_save(plan.target.path); - doc_name = plan.target.name; - doc_path = plan.target.path; - doc_dir = plan.target.directory; - title_update(); - dialog->destroy(); - App::I->hideKeyboard(); - }; - - if (plan.value().write_decision == pp::app::DocumentFileWriteDecision::prompt_overwrite) - { - // ask confirm is file already exist - auto msgbox = new NodeMessageBox(); - msgbox->set_manager(&layout); - msgbox->init(); - msgbox->m_title->set_text("Warning"); - msgbox->m_message->set_text(("Are you sure you want to overwrite " + plan.value().target.name + "?").c_str()); - msgbox->btn_ok->on_click = [this, msgbox, action](Node*) { - action(); - msgbox->destroy(); - }; - layout[main_id]->add_child(msgbox); - } - else - { - action(); - } + const auto status = pp::panopainter::execute_legacy_document_file_save_plan(*this, plan.value(), dialog); + if (!status.ok()) + LOG("Document file save action failed: %s", status.message); }; dialog->btn_cancel->on_click = [this, dialog](Node*) { diff --git a/src/legacy_document_session_services.cpp b/src/legacy_document_session_services.cpp index 6aed955..ce08465 100644 --- a/src/legacy_document_session_services.cpp +++ b/src/legacy_document_session_services.cpp @@ -71,6 +71,74 @@ private: std::shared_ptr dialog_; }; +void save_legacy_document_file( + App& app, + const pp::app::DocumentFileSavePlan& plan, + const std::shared_ptr& dialog) +{ + app.canvas->m_canvas->project_save(plan.target.path); + app.doc_name = plan.target.name; + app.doc_path = plan.target.path; + app.doc_dir = plan.target.directory; + app.title_update(); + dialog->destroy(); + App::I->hideKeyboard(); +} + +class LegacyDocumentFileSaveServices final : public pp::app::DocumentFileSaveServices { +public: + LegacyDocumentFileSaveServices(App& app, std::shared_ptr dialog) noexcept + : app_(app) + , dialog_(std::move(dialog)) + { + } + + void save_document_file(const pp::app::DocumentFileSavePlan& plan) override + { + save_legacy_document_file(app_, plan, dialog_); + } + + void prompt_overwrite_document_file(const pp::app::DocumentFileSavePlan& plan) override + { + auto msgbox = new NodeMessageBox(); + msgbox->set_manager(&app_.layout); + msgbox->init(); + msgbox->m_title->set_text("Warning"); + msgbox->m_message->set_text(("Are you sure you want to overwrite " + plan.target.name + "?").c_str()); + auto* app = &app_; + auto dialog = dialog_; + msgbox->btn_ok->on_click = [app, msgbox, dialog, plan](Node*) { + save_legacy_document_file(*app, plan, dialog); + msgbox->destroy(); + }; + app_.layout[app_.main_id]->add_child(msgbox); + } + +private: + App& app_; + std::shared_ptr dialog_; +}; + +class LegacyDocumentVersionSaveServices final : public pp::app::DocumentVersionSaveServices { +public: + explicit LegacyDocumentVersionSaveServices(App& app) noexcept + : app_(app) + { + } + + void save_document_version(const pp::app::DocumentVersionTarget& target) override + { + app_.doc_name = target.name; + app_.doc_path = target.path; + app_.canvas->m_canvas->m_unsaved = true; + app_.title_update(); + app_.canvas->m_canvas->project_save(app_.doc_path); + } + +private: + App& app_; +}; + class LegacyCloseRequestServices final : public pp::app::CloseRequestServices { public: LegacyCloseRequestServices(App& app, bool& dialog_already_opened) noexcept @@ -213,4 +281,21 @@ pp::foundation::Status execute_legacy_new_document_plan( return pp::app::execute_new_document_plan(plan, services); } +pp::foundation::Status execute_legacy_document_file_save_plan( + App& app, + const pp::app::DocumentFileSavePlan& plan, + std::shared_ptr dialog) +{ + LegacyDocumentFileSaveServices services(app, std::move(dialog)); + return pp::app::execute_document_file_save_plan(plan, services); +} + +pp::foundation::Status execute_legacy_document_version_save( + App& app, + const pp::app::DocumentVersionTarget& target) +{ + LegacyDocumentVersionSaveServices services(app); + return pp::app::execute_document_version_save(target, services); +} + } // namespace pp::panopainter diff --git a/src/legacy_document_session_services.h b/src/legacy_document_session_services.h index 0ad0f8e..0035d00 100644 --- a/src/legacy_document_session_services.h +++ b/src/legacy_document_session_services.h @@ -8,6 +8,7 @@ class App; class NodeDialogNewDoc; +class NodeDialogSave; namespace pp::panopainter { @@ -30,4 +31,13 @@ namespace pp::panopainter { const pp::app::NewDocumentPlan& plan, std::shared_ptr dialog); +[[nodiscard]] pp::foundation::Status execute_legacy_document_file_save_plan( + App& app, + const pp::app::DocumentFileSavePlan& plan, + std::shared_ptr dialog); + +[[nodiscard]] pp::foundation::Status execute_legacy_document_version_save( + App& app, + const pp::app::DocumentVersionTarget& target); + } // namespace pp::panopainter diff --git a/tests/app_core/document_session_tests.cpp b/tests/app_core/document_session_tests.cpp index b69ae11..3b28f28 100644 --- a/tests/app_core/document_session_tests.cpp +++ b/tests/app_core/document_session_tests.cpp @@ -132,6 +132,44 @@ public: std::string call_order; }; +class FakeDocumentFileSaveServices final : public pp::app::DocumentFileSaveServices { +public: + void save_document_file(const pp::app::DocumentFileSavePlan& plan) override + { + saves += 1; + last_path = plan.target.path; + call_order += "save-file;"; + } + + void prompt_overwrite_document_file(const pp::app::DocumentFileSavePlan& plan) override + { + overwrite_prompts += 1; + last_path = plan.target.path; + call_order += "overwrite-file;"; + } + + int saves = 0; + int overwrite_prompts = 0; + std::string last_path; + std::string call_order; +}; + +class FakeDocumentVersionSaveServices final : public pp::app::DocumentVersionSaveServices { +public: + void save_document_version(const pp::app::DocumentVersionTarget& target) override + { + saves += 1; + last_name = target.name; + last_path = target.path; + call_order += "save-version;"; + } + + int saves = 0; + std::string last_name; + std::string last_path; + std::string call_order; +}; + [[nodiscard]] pp::app::DocumentOpenRoute project_route() { return { @@ -506,6 +544,29 @@ void document_file_save_plan_rejects_empty_name(pp::tests::Harness& harness) PP_EXPECT(harness, plan.status().code == pp::foundation::StatusCode::invalid_argument); } +void document_file_save_executor_dispatches_save_and_overwrite_paths(pp::tests::Harness& harness) +{ + FakeDocumentFileSaveServices services; + auto save = pp::app::plan_document_file_save( + "D:/Paint", + "demo", + [](const std::string&) { return false; }); + auto overwrite = pp::app::plan_document_file_save( + "D:/Paint", + "demo", + [](const std::string& path) { return path == "D:/Paint/demo.ppi"; }); + + PP_EXPECT(harness, save); + PP_EXPECT(harness, overwrite); + PP_EXPECT(harness, pp::app::execute_document_file_save_plan(save.value(), services).ok()); + PP_EXPECT(harness, pp::app::execute_document_file_save_plan(overwrite.value(), services).ok()); + + PP_EXPECT(harness, services.saves == 1); + PP_EXPECT(harness, services.overwrite_prompts == 1); + PP_EXPECT(harness, services.last_path == "D:/Paint/demo.ppi"); + PP_EXPECT(harness, services.call_order == "save-file;overwrite-file;"); +} + void document_resolution_index_maps_legacy_choices(pp::tests::Harness& harness) { const auto first = pp::app::document_resolution_from_index(0); @@ -654,6 +715,32 @@ void document_version_target_reports_when_no_slots_remain(pp::tests::Harness& ha PP_EXPECT(harness, target.status().code == pp::foundation::StatusCode::out_of_range); } +void document_version_executor_dispatches_and_rejects_empty_targets(pp::tests::Harness& harness) +{ + FakeDocumentVersionSaveServices services; + const pp::app::DocumentVersionTarget valid { + .name = "demo.02", + .path = "D:/Paint/demo.02.ppi", + }; + const pp::app::DocumentVersionTarget missing_name { + .name = "", + .path = "D:/Paint/demo.02.ppi", + }; + const pp::app::DocumentVersionTarget missing_path { + .name = "demo.02", + .path = "", + }; + + PP_EXPECT(harness, pp::app::execute_document_version_save(valid, services).ok()); + PP_EXPECT(harness, !pp::app::execute_document_version_save(missing_name, services).ok()); + PP_EXPECT(harness, !pp::app::execute_document_version_save(missing_path, services).ok()); + + PP_EXPECT(harness, services.saves == 1); + PP_EXPECT(harness, services.last_name == "demo.02"); + PP_EXPECT(harness, services.last_path == "D:/Paint/demo.02.ppi"); + PP_EXPECT(harness, services.call_order == "save-version;"); +} + } int main() @@ -686,6 +773,7 @@ int main() "document file save plan combines target and write decision", document_file_save_plan_combines_target_and_write_decision); harness.run("document file save plan rejects empty name", document_file_save_plan_rejects_empty_name); + harness.run("document file save executor dispatches save and overwrite paths", document_file_save_executor_dispatches_save_and_overwrite_paths); harness.run("document resolution index maps legacy choices", document_resolution_index_maps_legacy_choices); harness.run("document resolution index rejects out of range", document_resolution_index_rejects_out_of_range); harness.run( @@ -701,5 +789,6 @@ int main() "document version target preserves legacy nonnumeric suffix handling", document_version_target_preserves_legacy_nonnumeric_suffix_handling); harness.run("document version target reports when no slots remain", document_version_target_reports_when_no_slots_remain); + harness.run("document version executor dispatches and rejects empty targets", document_version_executor_dispatches_and_rejects_empty_targets); return harness.finish(); }