Show and configure safe Editor autosave

This commit is contained in:
Emil
2026-09-24 02:25:55 +03:00
parent 77c145fa02
commit 45c5d2d188
6 changed files with 304 additions and 50 deletions
+68 -4
View File
@@ -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<std::string>();
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<bool>();
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)
+73 -35
View File
@@ -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<std::string>().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<std::string>();
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<bool>();
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
autosave_enabled_);
register_commands();
plugins_ = std::make_unique<PluginManager>(
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<std::string>().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<std::string>();
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<std::string>().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<std::string>().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<bool>();
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(