Autosave named scenes with revision and disk conflict safety
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
#include <faset/editor/autosave.hpp>
|
||||
|
||||
#include <faset/core/error.hpp>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace faset::editor {
|
||||
|
||||
AutosaveController::AutosaveController(SaveFn save) : save_(std::move(save)) {
|
||||
if (!save_)
|
||||
throw std::invalid_argument("Autosave requires a save callback");
|
||||
}
|
||||
|
||||
void AutosaveController::observe(const Json& documents, Clock::time_point now, bool enabled) {
|
||||
std::lock_guard lock(mutex_);
|
||||
enabled_ = enabled;
|
||||
std::set<std::string> present;
|
||||
for (const auto& document : documents) {
|
||||
const auto id = document.at("id").get<std::string>();
|
||||
const auto revision = document.at("revision").get<std::uint64_t>();
|
||||
const auto path = document.value("path", std::string());
|
||||
const auto dirty = document.value("dirty", false);
|
||||
present.insert(id);
|
||||
auto [found, inserted] = entries_.try_emplace(id);
|
||||
auto& entry = found->second;
|
||||
const auto changed = inserted || entry.revision != revision || entry.path != path;
|
||||
const auto prior_state = entry.state;
|
||||
entry.revision = revision;
|
||||
entry.path = path;
|
||||
if (!dirty) {
|
||||
entry.state = "saved";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (path.empty()) {
|
||||
entry.state = "save_as_required";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (!enabled) {
|
||||
entry.state = "disabled";
|
||||
entry.error.clear();
|
||||
continue;
|
||||
}
|
||||
if (changed || prior_state == "disabled" || prior_state == "saved" ||
|
||||
prior_state == "save_as_required") {
|
||||
entry.state = "pending";
|
||||
entry.error.clear();
|
||||
entry.deadline = now + std::chrono::seconds(2);
|
||||
}
|
||||
if (entry.state != "pending" || now < entry.deadline)
|
||||
continue;
|
||||
entry.state = "saving";
|
||||
try {
|
||||
const auto saved = save_(id, revision);
|
||||
if (saved.at("revision").get<std::uint64_t>() != revision ||
|
||||
saved.at("dirty").get<bool>())
|
||||
throw std::runtime_error("Save returned an unexpected document revision");
|
||||
entry.state = "saved";
|
||||
entry.error.clear();
|
||||
} catch (const Error& error) {
|
||||
entry.state = error.code() == "save.disk_conflict" ||
|
||||
error.code() == "revision.conflict"
|
||||
? "conflict"
|
||||
: "failed";
|
||||
entry.error = error.what();
|
||||
} catch (const std::exception& error) {
|
||||
entry.state = "failed";
|
||||
entry.error = error.what();
|
||||
}
|
||||
}
|
||||
for (auto it = entries_.begin(); it != entries_.end();)
|
||||
it = present.contains(it->first) ? std::next(it) : entries_.erase(it);
|
||||
}
|
||||
|
||||
Json AutosaveController::status() const {
|
||||
std::lock_guard lock(mutex_);
|
||||
Json documents = Json::array();
|
||||
for (const auto& [id, entry] : entries_)
|
||||
documents.push_back({{"id", id},
|
||||
{"state", entry.state},
|
||||
{"revision", entry.revision},
|
||||
{"path", entry.path},
|
||||
{"error", entry.error}});
|
||||
return {{"enabled", enabled_}, {"documents", documents}};
|
||||
}
|
||||
|
||||
} // namespace faset::editor
|
||||
@@ -103,9 +103,13 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
[&](const Json& args) { return authoring_.query(args.at("document")); }, true);
|
||||
add("faset_document_save",
|
||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
||||
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
||||
object_schema({{"document", text}, {"path", text},
|
||||
{"expected_revision", integer}}, {"document"}), [&](const Json& args) {
|
||||
std::optional<std::uint64_t> expected;
|
||||
if (args.contains("expected_revision"))
|
||||
expected = args.at("expected_revision").get<std::uint64_t>();
|
||||
return authoring_.save(args.at("document"),
|
||||
path_from_utf8(args.value("path", std::string())));
|
||||
path_from_utf8(args.value("path", std::string())), expected);
|
||||
});
|
||||
add(
|
||||
"faset_schema",
|
||||
|
||||
+11
-1
@@ -52,7 +52,10 @@ struct Session::ImportTask {
|
||||
};
|
||||
Session::Session(SessionConfig config)
|
||||
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
|
||||
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
|
||||
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)),
|
||||
autosave_([this](const std::string& document, std::uint64_t revision) {
|
||||
return authoring_.save(document, {}, revision);
|
||||
}) {
|
||||
register_commands();
|
||||
plugins_ = std::make_unique<PluginManager>(
|
||||
commands_, [this](std::string message) { log(std::move(message)); });
|
||||
@@ -297,12 +300,19 @@ void Session::poll() {
|
||||
if (status == "failed" || status == "conflict")
|
||||
log("Asset import " + status + ": " + value.value("error", std::string()));
|
||||
}
|
||||
autosave_.observe(authoring_.documents(), AutosaveController::Clock::now(),
|
||||
autosave_enabled_);
|
||||
}
|
||||
void Session::register_commands() {
|
||||
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
|
||||
auto schema = [](Json properties, Json required = Json::array()) {
|
||||
return Commands::object_schema(std::move(properties), std::move(required));
|
||||
};
|
||||
commands_.add("faset_autosave_status",
|
||||
"Read revision-aware scene autosave state. Unnamed scenes remain in recovery "
|
||||
"until explicitly saved with a path.",
|
||||
schema(Json::object()),
|
||||
[this](const Json&) { return autosave_.status(); }, true);
|
||||
commands_.add(
|
||||
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
|
||||
schema(Json::object()),
|
||||
|
||||
Reference in New Issue
Block a user