From 45c5d2d188b20b97a86842e30a872de183bf3fa2 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:25:55 +0300 Subject: [PATCH] Show and configure safe Editor autosave --- docs/manual/editor/mcp.md | 14 ++++ docs/manual/editor/workspace.md | 28 +++++-- src/editor/editor_ui.cpp | 72 +++++++++++++++++- src/editor/session.cpp | 108 ++++++++++++++++++--------- tests/editor_session_tests.cpp | 59 ++++++++++++++- tests/editor_ui_project_settings.cpp | 73 +++++++++++++++++- 6 files changed, 304 insertions(+), 50 deletions(-) diff --git a/docs/manual/editor/mcp.md b/docs/manual/editor/mcp.md index fc02753..5ae42d9 100644 --- a/docs/manual/editor/mcp.md +++ b/docs/manual/editor/mcp.md @@ -67,6 +67,20 @@ resume and single-step process controls; it cannot read or modify game entities. scenes that have never been saved. Restoring an already open document requires its current revision. Recovery refuses to overwrite an externally changed scene file. +Long-lived headless and graphical Editor sessions also poll the same autosave +controller. With `editor.autosave` enabled (the default), a named dirty scene is +saved after two seconds idle. `faset_autosave_status {}` reports `enabled` and each +open document's `state`, `revision`, `path`, and `error`. States include `saved`, +`pending`, `saving`, `conflict`, `failed`, `save_as_required`, and `disabled`. +An unnamed scene remains in recovery until you give it an explicit path. An +external disk edit produces `conflict` without overwriting that edit. For scripted +workflows, use `faset_document_save` with `expected_revision` when you need a +definite save boundary instead of waiting for idle autosave. If a conflict occurs, +save to a new project-relative path and compare the two files. Change the +preference through `faset_project_settings_get` and `faset_project_settings_set` +with the returned project revision and `{"editor":{"autosave":false}}`; the +new value applies to the current session and persists for the next Editor launch. + ## Single-command CLI The CLI is useful for scripts that do not need an MCP session: diff --git a/docs/manual/editor/workspace.md b/docs/manual/editor/workspace.md index 4ba079d..ac65c77 100644 --- a/docs/manual/editor/workspace.md +++ b/docs/manual/editor/workspace.md @@ -50,6 +50,19 @@ outside the Editor, ordinary Save reports a conflict instead of overwriting it. Save your in-memory work to a new path and compare the two versions before replacing anything. +Autosave is enabled by default. After a named scene has been idle for two seconds, +the Editor saves its current revision to that scene file. The status bar shows +**Pending autosave**, **Saving**, or **Saved**. An unnamed scene shows **Save As +required** until you choose its path. The status bar's **Save As...** action opens +an editable, new project-relative path; it never overwrites the conflicting file. + +If the disk file changes outside Faset, autosave stops for that revision and shows +**Save conflict**. Use **Save As...** to keep both versions, then compare or reload +the original. **Save failed** preserves the unsaved scene in the recovery journal; +correct the write error or use Save As. Autosave does not clear Undo history or +replace the [recovery journal](#recover-unsaved-work). Recovery still protects +unnamed scenes and edits before the idle deadline. + ## Navigate and transform - **Right drag:** orbit the 3D view; pan in a 2D scene. @@ -119,13 +132,16 @@ for the C++ iteration loop. ## Project settings Open **File → Project settings** to change the project name, initial **2D / 3D** type, -and start-scene path. Save a scene first, then choose it from the saved-scene list or -enter its project-relative path. Choose **Save project** to update -`project.faset.json` explicitly. A missing start scene is rejected and the dialog -stays open. +start-scene path, or **Autosave named scenes** preference. Save a scene first, then +choose it from the saved-scene list or enter its project-relative path. Choose +**Save project** to update `project.faset.json` explicitly. A missing start scene is +rejected and the dialog stays open. The Autosave preference applies immediately; +when disabled, the status bar says **Autosave off** and you can still use Ctrl+S or +Save As. The project file stores it as `editor.autosave`; omitting it means enabled. -These settings are used when the project opens again; they do not convert the active -scene, switch its dimension, restart the Player, or add a scene Undo entry. +The project name, default dimension, and start scene are used when the project opens +again; they do not convert the active scene, switch its dimension, restart the Player, +or add a scene Undo entry. **Cancel** or **Escape** discards this form's draft. If another writer changes the project settings while the dialog is open, Save reports a revision conflict and keeps your draft. **Reload saved** deliberately discards it and reloads the latest diff --git a/src/editor/editor_ui.cpp b/src/editor/editor_ui.cpp index 54ec6c8..b07595e 100644 --- a/src/editor/editor_ui.cpp +++ b/src/editor/editor_ui.cpp @@ -186,6 +186,7 @@ struct EditorUI::Impl { bool project_settings_open = false; Json project_settings_state; int project_settings_dimension = 3; + bool project_settings_autosave = true; std::string project_settings_error; bool project_switch_enabled = true, project_switch_requested = false, project_switch_warning = false; @@ -601,6 +602,20 @@ struct EditorUI::Impl { schema_status.visible = false; schema_status.enabled = false; schema_status.tooltip = "Choose Build to refresh gameplay fields in Inspector"; + auto& autosave_status = statusrow.add(Kind::Label, "autosave-status", "Saved"); + autosave_status.layout.width = 160; + autosave_status.enabled = false; + auto& autosave_action = button(statusrow, "autosave-save-as", "Save As...", [this] { + const auto path = current.value("path", std::string()); + const auto directory = path.empty() ? std::string("Scenes/") + : path.substr(0, path.find_last_of('/') + 1); + ui.update_text("save-path", + directory + "Recovered-" + new_id().substr(0, 8) + ".scene.json", + true); + menu = "File"; + }, 100); + autosave_action.visible = false; + autosave_action.tooltip = "Save this scene to a new project-relative path"; label(statusrow, "renderer-status", "Vulkan", 225); statusrow.find("renderer-status")->enabled = false; build_overlays(); @@ -792,12 +807,20 @@ struct EditorUI::Impl { } label(project, "project-settings-start-label", "Start scene (saved project-relative path)"); project.add(Kind::TextField, "project-settings-start"); + auto& autosave = project.add(Kind::Checkbox, "project-settings-autosave", + "Autosave named scenes after 2 seconds idle"); + autosave.layout.height = 30; + autosave.checked = true; + autosave.on_commit = [this](Widget& widget) { + project_settings_autosave = widget.checked; + }; + autosave.tooltip = "Unsaved scenes remain in recovery until you choose Save As"; auto& scenes = project.add(Kind::Column, "project-settings-scenes"); scenes.layout.height = 104; scenes.layout.scroll = true; scenes.layout.gap = 1; label(project, "project-settings-note", - "Applies on next project open. Scene Undo is unchanged."); + "Autosave applies now; other settings on next open. Scene Undo is unchanged."); auto& project_actions = project.add(Kind::Row, "project-settings-actions"); project_actions.layout.height = 32; button( @@ -1258,6 +1281,42 @@ struct EditorUI::Impl { (current.value("dirty", false) ? " *" : ""); ui.find("project-title")->tooltip = project_name + " / " + scene_name; ui.find("status")->text = status; + const auto autosave = call("faset_autosave_status"); + std::string save_state = "saved", save_error; + if (!autosave.is_null()) { + if (!autosave.value("enabled", true)) + save_state = "disabled"; + for (const auto& entry : autosave.at("documents")) + if (entry.at("id") == document) { + save_state = entry.at("state").get(); + save_error = entry.value("error", std::string()); + break; + } + if (!autosave.value("enabled", true) && save_state == "saved") + save_state = "disabled"; + } + auto& save_label = *ui.find("autosave-status"); + save_label.text = save_state == "pending" ? "Pending autosave" + : save_state == "saving" ? "Saving" + : save_state == "conflict" ? "Save conflict" + : save_state == "failed" ? "Save failed" + : save_state == "save_as_required" ? "Save As required" + : save_state == "disabled" ? "Autosave off" + : "Saved"; + save_label.tooltip = save_state == "conflict" + ? "Scene file changed outside Faset. Use Save As to keep both " + "versions, then compare or reload. " + save_error + : save_state == "failed" + ? "Scene save failed. Your recovery journal remains available. " + "Use Save As or correct the error. " + save_error + : save_state == "save_as_required" + ? "Choose Save As; unnamed scenes remain in recovery only" + : save_state == "disabled" + ? "Autosave is off in Project settings; use Save manually" + : "Scene autosave status"; + ui.find("autosave-save-as")->visible = + save_state == "conflict" || save_state == "failed" || + save_state == "save_as_required"; ui.find("renderer-status")->text = "Vulkan 1.3 | " + std::to_string(resolved.at("entities").size()) + " objects"; } @@ -2179,7 +2238,7 @@ struct EditorUI::Impl { refresh_project_settings(); const bool modal = palette || !recovery.empty() || simulation_open || project_switch_warning || project_settings_open; - for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel"}) + for (const auto* id : {"menubar", "toolbar", "workspace", "bottom_panel", "statusbar"}) ui.find(id)->enabled = !modal; const bool file = menu == "File" || menu == "Faset", edit = menu == "Edit" || menu == "Scene", @@ -2234,6 +2293,8 @@ struct EditorUI::Impl { project_settings_state = result; const auto& settings = result.at("settings"); project_settings_dimension = settings.value("dimension", 3); + project_settings_autosave = settings.at("editor").at("autosave").get(); + ui.find("project-settings-autosave")->checked = project_settings_autosave; ui.update_text("project-settings-name", settings.value("name", std::string()), true); ui.update_text("project-settings-start", settings.value("start_scene", std::string()), true); @@ -2300,7 +2361,9 @@ struct EditorUI::Impl { project_settings_error = "Enter a project name."; return; } - Json changes = {{"name", name}, {"dimension", project_settings_dimension}}; + Json changes = {{"name", name}, + {"dimension", project_settings_dimension}, + {"editor", {{"autosave", project_settings_autosave}}}}; if (!start.empty() || !project_settings_state.at("settings").value("start_scene", std::string()).empty()) changes["start_scene"] = start; @@ -2314,7 +2377,7 @@ struct EditorUI::Impl { project_settings_state = result; project_settings_open = false; project_settings_error.clear(); - status = "Project settings saved for the next project open"; + status = "Project settings saved; autosave preference applies now"; } void refresh_project_settings() { auto* panel = ui.find("project-settings-panel"); @@ -2325,6 +2388,7 @@ struct EditorUI::Impl { panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f); ui.find("project-settings-2d")->selected = project_settings_dimension == 2; ui.find("project-settings-3d")->selected = project_settings_dimension == 3; + ui.find("project-settings-autosave")->checked = project_settings_autosave; ui.find("project-settings-error")->text = project_settings_error; for (auto& choice : ui.find("project-settings-scenes")->children) if (choice->kind == Kind::TreeRow) diff --git a/src/editor/session.cpp b/src/editor/session.cpp index 0c6d160..6ad0d0d 100644 --- a/src/editor/session.cpp +++ b/src/editor/session.cpp @@ -23,6 +23,52 @@ BuildConfig build_config(const SessionConfig& config) { result.cache_root = config.project_root / ".faset/cache"; return result; } +struct ProjectState { + Json value; + std::string revision; +}; +ProjectState read_project_state(const std::filesystem::path& project_root) { + const auto path = project_root / "project.faset.json"; + if (std::filesystem::exists(path)) { + auto value = read_json(path); + // The revision represents persisted content, including whether a + // default-valued setting was explicitly written by another client. + const auto revision = sha256(value.dump()); + require(value.is_object() && value.value("format", "") == "faset.project" && + value.value("version", 0) == 1, + "project.version", "Unsupported project format or version"); + require(value.contains("name") && value.at("name").is_string() && + !value.at("name").get().empty(), + "project.name", "Project name must be a nonempty string"); + const auto dimension = value.value("dimension", 3); + require(dimension == 2 || dimension == 3, "project.dimension", + "Project dimension must be 2 or 3"); + if (value.contains("start_scene")) { + require(value.at("start_scene").is_string(), "project.start_scene", + "Project start_scene must be a relative path"); + const auto scene = value.at("start_scene").get(); + if (!scene.empty()) + project_path(project_root, path_from_utf8(scene)); + } + if (value.contains("editor")) + require(value.at("editor").is_object(), "project.editor", + "Project editor settings must be an object"); + else + value["editor"] = Json::object(); + if (value["editor"].contains("autosave")) + require(value["editor"]["autosave"].is_boolean(), "project.autosave", + "Project autosave setting must be true or false"); + else + value["editor"]["autosave"] = true; + return {std::move(value), revision}; + } + Json value = {{"format", "faset.project"}, + {"version", 1}, + {"name", path_to_utf8(project_root.filename())}, + {"dimension", 3}, + {"editor", {{"autosave", true}}}}; + return {value, sha256(value.dump())}; +} Json resolved_or_throw(Commands& commands, const std::string& id) { const auto resolved = commands.resolved_scene(id); if (!resolved.at("conflicts").empty()) @@ -56,6 +102,9 @@ Session::Session(SessionConfig config) autosave_([this](const std::string& document, std::uint64_t revision) { return authoring_.save(document, {}, revision); }) { + autosave_enabled_ = project().at("editor").at("autosave").get(); + autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(), + autosave_enabled_); register_commands(); plugins_ = std::make_unique( commands_, [this](std::string message) { log(std::move(message)); }); @@ -86,31 +135,7 @@ void Session::log(std::string value) { logs_.erase(logs_.begin(), logs_.begin() + 100); } Json Session::project() const { - const auto path = config_.project_root / "project.faset.json"; - if (std::filesystem::exists(path)) { - const auto value = read_json(path); - require(value.is_object() && value.value("format", "") == "faset.project" && - value.value("version", 0) == 1, - "project.version", "Unsupported project format or version"); - require(value.contains("name") && value.at("name").is_string() && - !value.at("name").get().empty(), - "project.name", "Project name must be a nonempty string"); - const auto dimension = value.value("dimension", 3); - require(dimension == 2 || dimension == 3, "project.dimension", - "Project dimension must be 2 or 3"); - if (value.contains("start_scene")) { - require(value.at("start_scene").is_string(), "project.start_scene", - "Project start_scene must be a relative path"); - const auto scene = value.at("start_scene").get(); - if (!scene.empty()) - project_path(config_.project_root, path_from_utf8(scene)); - } - return value; - } - return {{"format", "faset.project"}, - {"version", 1}, - {"name", path_to_utf8(config_.project_root.filename())}, - {"dimension", 3}}; + return read_project_state(config_.project_root).value; } void Session::scaffold(const std::string& name, int dimension) { builds_.scaffold(name, dimension); @@ -346,23 +371,25 @@ void Session::register_commands() { "faset_project_settings_get", "Read project settings and their content revision.", schema(Json::object()), [&](const Json&) { - const auto value = project(); - return Json{{"settings", value}, {"revision", sha256(value.dump())}}; + const auto state = read_project_state(config_.project_root); + return Json{{"settings", state.value}, {"revision", state.revision}}; }, true); commands_.add( "faset_project_settings_set", - "Save project name, initial scene dimension or start scene with an expected content " - "revision. Applies on the next project open; does not change the active scene or its Undo " - "history.", + "Save project name, initial scene dimension, start scene or editor.autosave with an " + "expected content revision. Autosave applies immediately; other settings apply on the " + "next project open without changing scene Undo history.", schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}), [&](const Json& args) { - auto value = project(); - require(args.at("revision") == sha256(value.dump()), "revision.conflict", + auto state = read_project_state(config_.project_root); + auto value = std::move(state.value); + require(args.at("revision") == state.revision, "revision.conflict", "Project settings changed; reload them before saving"); const auto& changes = args.at("settings"); for (const auto& [key, field] : changes.items()) { - require(key == "name" || key == "dimension" || key == "start_scene", + require(key == "name" || key == "dimension" || key == "start_scene" || + key == "editor", "project.setting", "Unknown editable project setting: " + key); if (key == "name") require(field.is_string() && !field.get().empty(), "project.name", @@ -370,7 +397,15 @@ void Session::register_commands() { else if (key == "dimension") require(field.is_number_integer() && (field == 2 || field == 3), "project.dimension", "Initial scene dimension must be 2 or 3"); - else { + else if (key == "editor") { + require(field.is_object() && field.size() == 1 && + field.contains("autosave"), + "project.setting", "Only editor.autosave can be changed here"); + require(field.at("autosave").is_boolean(), "project.autosave", + "Autosave must be enabled or disabled"); + value["editor"]["autosave"] = field.at("autosave"); + continue; + } else { require(field.is_string() && !field.get().empty(), "project.start_scene", "Choose a saved scene inside the project"); const auto file = project_path(config_.project_root, @@ -388,7 +423,10 @@ void Session::register_commands() { if (!value.contains("id")) value["id"] = new_id(); atomic_write_json(config_.project_root / "project.faset.json", value); - log("Project settings saved; changes apply on next project open"); + autosave_enabled_ = value.at("editor").at("autosave").get(); + autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(), + autosave_enabled_); + log("Project settings saved; autosave preference applies immediately"); return Json{{"settings", value}, {"revision", sha256(value.dump())}}; }); commands_.add( diff --git a/tests/editor_session_tests.cpp b/tests/editor_session_tests.cpp index 8522101..8989bc0 100644 --- a/tests/editor_session_tests.cpp +++ b/tests/editor_session_tests.cpp @@ -4,7 +4,7 @@ #include #include -int main(int argc, char** argv) { +int test_main(int argc, char** argv) { using namespace faset; if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") { Json arguments = Json::array(); @@ -36,6 +36,8 @@ int main(int argc, char** argv) { require(!session.play_pending() && !session.playing(), "test", "Stop must cancel a pending Play build"); const auto initial = commands.call("faset_project_settings_get", Json::object()); + require(initial.at("settings").at("editor").at("autosave") == true, "test", + "Missing autosave setting must read as enabled"); auto changed = commands.call("faset_project_settings_set", {{"revision", initial.at("revision")}, {"settings", @@ -75,15 +77,57 @@ int main(int argc, char** argv) { atomic_write_json(root / "project.faset.json", external); rejects({{"revision", changed.at("revision")}, {"settings", {{"name", "Race"}}}}, "revision.conflict"); - const auto reloaded = commands.call("faset_project_settings_get", Json::object()); + auto reloaded = commands.call("faset_project_settings_get", Json::object()); + auto explicit_default = external; + explicit_default["editor"]["autosave"] = true; + atomic_write_json(root / "project.faset.json", explicit_default); + rejects({{"revision", reloaded.at("revision")}, + {"settings", {{"name", "Lost external write"}}}}, + "revision.conflict"); + reloaded = commands.call("faset_project_settings_get", Json::object()); changed = commands.call("faset_project_settings_set", {{"revision", reloaded.at("revision")}, {"settings", {{"name", "Preserved"}}}}); require(changed.at("settings").at("custom_tool").at("keep") == true, "test", "Saving settings erased unknown project metadata"); require(changed.at("settings").at("scripting") == external.at("scripting") && - changed.at("settings").at("editor") == external.at("editor"), + changed.at("settings").at("editor").at("script_editor") == + external.at("editor").at("script_editor"), "test", "Project settings erased Lua configuration or external editor command"); + const auto autosave_off = commands.call( + "faset_project_settings_set", + {{"revision", changed.at("revision")}, + {"settings", {{"editor", {{"autosave", false}}}}}}); + require(autosave_off.at("settings").at("editor").at("autosave") == false && + session.project().at("editor").at("script_editor") == + external.at("editor").at("script_editor"), + "test", "Autosave setting must merge without erasing the editor command"); + session.poll(); + require(commands.call("faset_autosave_status", Json::object()).at("enabled") == false, + "test", "Autosave disable must take effect in the current session"); + { + editor::Session reopened({root, path_from_utf8(FASET_TEST_ENGINE), {}}); + reopened.poll(); + require(reopened.commands() + .call("faset_autosave_status", Json::object()) + .at("enabled") == false, + "test", "Autosave setting must survive Editor reopen"); + } + rejects({{"revision", changed.at("revision")}, + {"settings", {{"editor", {{"autosave", true}}}}}}, + "revision.conflict"); + rejects({{"revision", autosave_off.at("revision")}, + {"settings", {{"editor", {{"autosave", "sometimes"}}}}}}, + "project.autosave"); + rejects({{"revision", autosave_off.at("revision")}, + {"settings", {{"editor", {{"script_editor", {"other"}}}}}}}, + "project.setting"); + changed = commands.call("faset_project_settings_set", + {{"revision", autosave_off.at("revision")}, + {"settings", {{"editor", {{"autosave", true}}}}}}); + session.poll(); + require(commands.call("faset_autosave_status", Json::object()).at("enabled") == true, + "test", "Autosave enable must take effect in the current session"); const auto setup = commands.call("faset_lua_setup", Json::object()); require(setup.at("configuration_created") == true && setup.at("scripts_configuration_created") == true && @@ -193,3 +237,12 @@ int main(int argc, char** argv) { return 1; } } +#ifdef _WIN32 +int wmain(int argc, wchar_t** argv) { + return run_utf8_main(argc, argv, test_main); +} +#else +int main(int argc, char** argv) { + return test_main(argc, argv); +} +#endif diff --git a/tests/editor_ui_project_settings.cpp b/tests/editor_ui_project_settings.cpp index 55c9335..3274078 100644 --- a/tests/editor_ui_project_settings.cpp +++ b/tests/editor_ui_project_settings.cpp @@ -1,6 +1,8 @@ #include #include +#include #include +#include using namespace faset; namespace { void check(bool value, const std::string& message) { @@ -50,6 +52,7 @@ int main() { {"id", new_id()}, {"name", "Original project"}, {"dimension", 3}, + {"editor", {{"script_editor", {"zed", "{file}"}}}}, {"custom_metadata", {{"preserve", true}}}}); editor::Session session({root, path_from_utf8(FASET_TEST_ENGINE), root}); render::Renderer renderer({1280, 800, "Project settings acceptance", true, true}); @@ -73,6 +76,13 @@ int main() { open(ui); check(ui.widgets().find("project-settings-name")->text == "Original project", "Opening settings reloads current saved values"); + check(ui.widgets().find("project-settings-autosave")->checked, + "Missing autosave setting appears enabled in Project settings"); + check(ui.widgets().focus("project-settings-autosave"), + "Keyboard focus reaches the Autosave toggle"); + ui.frame({key("Space")}); + check(!ui.widgets().find("project-settings-autosave")->checked, + "Space toggles Autosave from the keyboard"); text(ui, "project-settings-name", "Новый проект"); click(ui, "project-settings-2d"); click(ui, "project-scene-choice-1"); @@ -81,11 +91,25 @@ int main() { click(ui, "project-settings-save"); auto saved = read_json(project_file); check(saved["name"] == "Новый проект" && saved["dimension"] == 2 && - saved["start_scene"] == "Scenes/Другая.scene.json", + saved["start_scene"] == "Scenes/Другая.scene.json" && + saved["editor"]["autosave"] == false, "Explicit Save project persists typed settings"); check(saved["custom_metadata"] == project_before["custom_metadata"] && - saved["id"] == project_before["id"], + saved["id"] == project_before["id"] && + saved["editor"]["script_editor"] == + project_before["editor"]["script_editor"], "Project settings preserve unknown metadata and project identity"); + ui.frame({}); + check(ui.widgets().find("autosave-status")->text == "Autosave off", + "Disabling Autosave is visible in the Editor status bar"); + open(ui); + check(!ui.widgets().find("project-settings-autosave")->checked, + "Autosave preference is restored when reopening Project settings"); + click(ui, "project-settings-autosave"); + click(ui, "project-settings-save"); + saved = read_json(project_file); + check(saved["editor"]["autosave"] == true, + "Autosave can be enabled again through Project settings"); check(session.authoring().query(ui.current_document()) == document_before, "Project settings do not change current document, dimension, revision or Undo"); open(ui); @@ -118,6 +142,51 @@ int main() { "Save after explicit reload uses new content revision"); check(session.authoring().query(ui.current_document()) == document_before, "Project settings remain outside scene Undo throughout conflicts"); + const auto original_document = ui.current_document(); + const auto revision = + session.authoring().query(original_document).at("revision").get(); + session.authoring().transact( + original_document, revision, + Json::array({{{"op", "scene.rename"}, {"name", "Pending autosave"}}})); + ui.frame({}); + check(ui.widgets().find("autosave-status")->text == "Pending autosave", + "Status bar shows pending scene save after edit"); + session.authoring().save(original_document); + ui.frame({}); + check(ui.widgets().find("autosave-status")->text == "Saved", + "Status bar returns to Saved after the scene reaches disk"); + session.authoring().transact( + original_document, + session.authoring().query(original_document).at("revision"), + Json::array({{{"op", "scene.rename"}, {"name", "Conflicting autosave"}}})); + ui.frame({}); + auto disk_scene = read_json(root / "Scenes/Главная.scene.json"); + disk_scene["name"] = "External scene edit"; + atomic_write_json(root / "Scenes/Главная.scene.json", disk_scene); + std::this_thread::sleep_for(std::chrono::milliseconds(2100)); + ui.frame({}); + check(ui.widgets().find("autosave-status")->text == "Save conflict" && + ui.widgets().find("autosave-status")->tooltip.find("Save As") != + std::string::npos, + "Disk conflict remains visible with Save As guidance"); + renderer.render(ui.snapshot()); + renderer.capture(root / "autosave-conflict.ppm"); + check(ui.widgets().focus("autosave-save-as"), + "Keyboard focus reaches the recovery Save As action"); + ui.frame({key("Return")}); + check(ui.widgets().find("menu-popup")->visible && + ui.widgets().find("save-path")->visible, + "Recovery action opens an editable Save As path"); + ui.frame({key("Escape")}); + const auto unnamed = session.authoring().create("Unsaved", 2); + session.authoring().transact( + unnamed.at("id"), unnamed.at("revision"), + Json::array({{{"op", "scene.rename"}, {"name", "Needs a path"}}})); + ui.select_document(unnamed.at("id")); + ui.frame({}); + check(ui.widgets().find("autosave-status")->text == "Save As required" && + ui.widgets().find("autosave-save-as")->visible, + "Unnamed dirty scene is never assigned an implicit path"); open(ui); renderer.render(ui.snapshot()); renderer.capture(root / "project-settings.ppm");