Reload a project changed on disk, fingerprinted by its manifest and image sizes (12.13a)
This commit is contained in:
1 parent
aa253d9715
commit
838d5b1459
20 files changed
+1388
-8
No files matched your search
@@ -46,6 +46,27 @@ void EditorSession::installProject(const ProjectSnapshot &snapshot, const QStrin
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::reloadProject(const ProjectSnapshot &snapshot)
|
||||
{
|
||||
if (!m_projectPath)
|
||||
return;
|
||||
const CanvasViewport kept = viewport;
|
||||
const QSet<QUuid> collapsed = m_collapsedGroupIDs;
|
||||
const std::optional<QUuid> active = m_activeLayerID;
|
||||
const QSet<QUuid> selected = m_selectedLayerIDs;
|
||||
installProject(snapshot, *m_projectPath);
|
||||
viewport = kept;
|
||||
QSet<QUuid> ids;
|
||||
for (const ProjectLayerRecord &record : snapshot.manifest.layers)
|
||||
ids.insert(record.id);
|
||||
m_collapsedGroupIDs = collapsed & ids;
|
||||
if (active && ids.contains(*active)) {
|
||||
setActiveLayerID(active);
|
||||
m_selectedLayerIDs = (selected & ids) | QSet<QUuid>{*active};
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
void EditorSession::setProjectPath(std::optional<QString> path)
|
||||
{
|
||||
m_projectPath = std::move(path);
|
||||
|
||||
@@ -3,5 +3,7 @@ public:
|
||||
std::optional<ProjectSnapshot> projectSnapshot() const;
|
||||
// Only a package that loaded and validated whole is installed.
|
||||
void installProject(const ProjectSnapshot &snapshot, const QString &path);
|
||||
// Swift's reloadProject: what the package holds now, the view kept.
|
||||
void reloadProject(const ProjectSnapshot &snapshot);
|
||||
void clearProject();
|
||||
void createNewProject(int width, int height);
|
||||
@@ -92,6 +92,7 @@ void ProjectWorkspace::select(QUuid id)
|
||||
current().session.commitTransform();
|
||||
m_selectedID = id;
|
||||
current().controller.window = window;
|
||||
current().controller.resumeExternalChangeCheck();
|
||||
emit changed();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "Document/ProjectWorkspace.h"
|
||||
#include "IO/ProjectController.h"
|
||||
#include "IO/ProjectDigest.h"
|
||||
#include "Logging.h"
|
||||
#include <QFileInfo>
|
||||
#include <QFutureWatcher>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QtConcurrent>
|
||||
|
||||
namespace {
|
||||
// Off the UI thread; a half-written package gives none.
|
||||
QFuture<std::optional<QByteArray>> digestOf(const QString &path)
|
||||
{
|
||||
return QtConcurrent::run([path]() -> std::optional<QByteArray> {
|
||||
try {
|
||||
return ProjectDigest::compute(path);
|
||||
} catch (const std::runtime_error &error) {
|
||||
qCDebug(lcIO) << "no digest for" << path << error.what();
|
||||
return std::nullopt;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void ProjectController::watchProject(const QString &path)
|
||||
{
|
||||
if (externalChanges.watcher && externalChanges.watcher->path == path)
|
||||
return;
|
||||
externalChanges.watcher = std::make_unique<ProjectWatcher>(path, [this] { noteExternalChange(); });
|
||||
}
|
||||
|
||||
void ProjectController::stopWatchingProject()
|
||||
{
|
||||
externalChanges.watcher.reset();
|
||||
externalChanges.knownDigest.reset();
|
||||
externalChanges.recheck->stop();
|
||||
externalChanges.pending = false;
|
||||
}
|
||||
|
||||
void ProjectController::rememberProjectDigest(const QString &path, std::function<void()> then)
|
||||
{
|
||||
auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this);
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, then] {
|
||||
watcher->deleteLater();
|
||||
externalChanges.knownDigest = watcher->result();
|
||||
then();
|
||||
});
|
||||
watcher->setFuture(digestOf(path));
|
||||
}
|
||||
|
||||
void ProjectController::resumeExternalChangeCheck()
|
||||
{
|
||||
if (externalChanges.pending)
|
||||
noteExternalChange();
|
||||
}
|
||||
|
||||
void ProjectController::noteExternalChange()
|
||||
{
|
||||
externalChanges.pending = true;
|
||||
if (externalChanges.checking)
|
||||
return;
|
||||
externalChanges.checking = true;
|
||||
checkExternalChange();
|
||||
}
|
||||
|
||||
// Swift's loop: each pass takes one pending change, else stops.
|
||||
void ProjectController::checkExternalChange()
|
||||
{
|
||||
if (!externalChanges.pending) {
|
||||
externalChanges.checking = false;
|
||||
return;
|
||||
}
|
||||
externalChanges.pending = false;
|
||||
const std::optional<QString> path = session.projectPath();
|
||||
if (!path || !session.document() || externalChanges.saving) {
|
||||
externalChanges.checking = false;
|
||||
return;
|
||||
}
|
||||
// Content, not dates: sync clients touch metadata alone.
|
||||
auto *watcher = new QFutureWatcher<std::optional<QByteArray>>(this);
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, path = *path] {
|
||||
watcher->deleteLater();
|
||||
const std::optional<QByteArray> digest = watcher->result();
|
||||
if (!digest || digest == externalChanges.knownDigest) {
|
||||
checkExternalChange();
|
||||
return;
|
||||
}
|
||||
// An edit in progress finishes before the document goes.
|
||||
if (!session.canStartProjectOperation() || session.transformEdit() || (workspace && workspace->isManaging())) {
|
||||
externalChanges.checking = false;
|
||||
scheduleRecheck();
|
||||
return;
|
||||
}
|
||||
if (!session.isModified()) {
|
||||
reloadFromDisk(path, [this] { checkExternalChange(); });
|
||||
return;
|
||||
}
|
||||
if (!window || !isFrontmost()) {
|
||||
externalChanges.pending = true;
|
||||
externalChanges.checking = false;
|
||||
return;
|
||||
}
|
||||
askToRevert([this, path, digest](bool revert) {
|
||||
if (!revert) {
|
||||
externalChanges.knownDigest = digest;
|
||||
checkExternalChange();
|
||||
return;
|
||||
}
|
||||
reloadFromDisk(path, [this] { checkExternalChange(); });
|
||||
});
|
||||
});
|
||||
watcher->setFuture(digestOf(*path));
|
||||
}
|
||||
|
||||
bool ProjectController::isFrontmost() const
|
||||
{
|
||||
return !workspace || &workspace->current().controller == this;
|
||||
}
|
||||
|
||||
// Backs off while busy: a long operation is not polled.
|
||||
void ProjectController::scheduleRecheck()
|
||||
{
|
||||
externalChanges.pending = true;
|
||||
const int attempt = externalChanges.recheckAttempt;
|
||||
externalChanges.recheckAttempt = std::min(attempt + 1, 7);
|
||||
externalChanges.recheck->start(250 * (1 << attempt));
|
||||
}
|
||||
|
||||
void ProjectController::reloadFromDisk(const QString &path, std::function<void()> then)
|
||||
{
|
||||
externalChanges.recheckAttempt = 0;
|
||||
session.setIsProjectBusy(true);
|
||||
// A package that fails to load leaves the document alone.
|
||||
load(path, [this, path, then](const Loaded &loaded) {
|
||||
if (!loaded.snapshot || session.projectPath() != path || !session.document()) {
|
||||
if (!loaded.snapshot)
|
||||
qCInfo(lcIO).noquote() << "the changed package did not load yet:" << loaded.failure;
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
return;
|
||||
}
|
||||
session.reloadProject(*loaded.snapshot);
|
||||
// As loaded, not first seen: it may have changed.
|
||||
rememberProjectDigest(path, [this, path, then] {
|
||||
externalChanges.reloadCount += 1;
|
||||
qCInfo(lcIO).noquote() << "reloaded" << path << "after it changed on disk";
|
||||
session.setIsProjectBusy(false);
|
||||
then();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ProjectController::askToRevert(std::function<void(bool)> then)
|
||||
{
|
||||
const QString name = session.projectPath() ? QFileInfo(*session.projectPath()).fileName() : QStringLiteral("Untitled");
|
||||
auto *alert = new QMessageBox(window);
|
||||
alert->setAttribute(Qt::WA_DeleteOnClose);
|
||||
alert->setIcon(QMessageBox::Warning);
|
||||
alert->setText(QStringLiteral("“%1” was changed on disk.").arg(name));
|
||||
alert->setInformativeText(QStringLiteral("Another app changed this project. You can revert to the version on disk, losing your unsaved changes, or keep what you have."));
|
||||
const QPushButton *revert = alert->addButton(QStringLiteral("Revert"), QMessageBox::AcceptRole);
|
||||
alert->addButton(QStringLiteral("Keep Mine"), QMessageBox::RejectRole);
|
||||
connect(alert, &QDialog::finished, this, [alert, revert, then] { then(alert->clickedButton() == revert); });
|
||||
alert->open();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include "IO/ProjectWatcher.h"
|
||||
#include <QByteArray>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
class QTimer;
|
||||
|
||||
// Swift's ExternalChangeState: the watch's bookkeeping, per controller.
|
||||
struct ExternalChangeState {
|
||||
std::unique_ptr<ProjectWatcher> watcher;
|
||||
// The package as last read or written.
|
||||
std::optional<QByteArray> knownDigest;
|
||||
bool checking = false;
|
||||
bool pending = false;
|
||||
// Our own save is writing: its events are ours.
|
||||
bool saving = false;
|
||||
QTimer *recheck = nullptr;
|
||||
int recheckAttempt = 0;
|
||||
// Reloads because the package changed on disk; tests read it.
|
||||
int reloadCount = 0;
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QFutureWatcher>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QtConcurrent>
|
||||
|
||||
bool ProjectController::isProject(const QUrl &url)
|
||||
@@ -24,7 +25,13 @@ bool ProjectController::samePlace(const std::optional<QString> &path, const QStr
|
||||
return path && resolved(*path) == resolved(other);
|
||||
}
|
||||
|
||||
ProjectController::ProjectController(EditorSession &session) : session(session) {}
|
||||
ProjectController::ProjectController(EditorSession &session) : session(session)
|
||||
{
|
||||
externalChanges.recheck = new QTimer(this);
|
||||
externalChanges.recheck->setSingleShot(true);
|
||||
externalChanges.recheck->setTimerType(Qt::PreciseTimer);
|
||||
connect(externalChanges.recheck, &QTimer::timeout, this, &ProjectController::noteExternalChange);
|
||||
}
|
||||
|
||||
bool ProjectController::canStart() const
|
||||
{
|
||||
@@ -113,17 +120,27 @@ void ProjectController::saveCurrent(bool asNew, std::function<void(bool)> then)
|
||||
|
||||
void ProjectController::write(const ProjectSnapshot &snapshot, const QString &destination, std::function<void(bool)> then)
|
||||
{
|
||||
// Our own save changes the package: its events are ours.
|
||||
externalChanges.saving = true;
|
||||
auto *watcher = new QFutureWatcher<std::optional<QString>>(this);
|
||||
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, destination, then] {
|
||||
watcher->deleteLater();
|
||||
if (const std::optional<QString> failure = watcher->result()) {
|
||||
showError(QStringLiteral("Couldn’t save the project"), *failure, [then] { then(false); });
|
||||
// Swift's defer: the guard lasts until the alert is answered.
|
||||
showError(QStringLiteral("Couldn’t save the project"), *failure, [this, then] {
|
||||
externalChanges.saving = false;
|
||||
then(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.setProjectPath(destination);
|
||||
session.history.markSaved();
|
||||
m_saveGeneration += 1;
|
||||
then(true);
|
||||
rememberProjectDigest(destination, [this, destination, then] {
|
||||
watchProject(destination);
|
||||
externalChanges.saving = false;
|
||||
then(true);
|
||||
});
|
||||
});
|
||||
// The store blocks on the disk: off the UI thread.
|
||||
watcher->setFuture(QtConcurrent::run([snapshot, destination]() -> std::optional<QString> {
|
||||
@@ -188,7 +205,10 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo
|
||||
// Saving in the confirmation can replace the opened file.
|
||||
if (m_saveGeneration == previousSave || !samePlace(session.projectPath(), source)) {
|
||||
session.installProject(*first.snapshot, source);
|
||||
end(true);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
load(source, [this, end, refuse, source](const Loaded &again) {
|
||||
@@ -197,7 +217,10 @@ void ProjectController::open(std::optional<QString> path, std::function<void(boo
|
||||
return;
|
||||
}
|
||||
session.installProject(*again.snapshot, source);
|
||||
end(true);
|
||||
rememberProjectDigest(source, [this, source, end] {
|
||||
watchProject(source);
|
||||
end(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -230,8 +253,10 @@ void ProjectController::newCanvas()
|
||||
return;
|
||||
confirmReplacement([this](bool proceed) {
|
||||
session.setIsProjectBusy(false);
|
||||
if (proceed)
|
||||
session.clearProject();
|
||||
if (!proceed)
|
||||
return;
|
||||
session.clearProject();
|
||||
stopWatchingProject();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +273,7 @@ void ProjectController::close(QWidget *closing)
|
||||
if (!proceed)
|
||||
return;
|
||||
session.clearProject();
|
||||
stopWatchingProject();
|
||||
if (closing)
|
||||
closing->close();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "Document/EditorSession.h"
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "IO/ProjectController+ExternalChanges.h"
|
||||
#include <QList>
|
||||
#include <QPointer>
|
||||
#include <QUrl>
|
||||
@@ -44,6 +45,15 @@ public:
|
||||
void exportPNG(std::function<void()> done = {});
|
||||
void exportJPEG(std::function<void()> done = {});
|
||||
|
||||
// Swift's ProjectController+ExternalChanges: the package kept in step.
|
||||
ExternalChangeState externalChanges;
|
||||
void watchProject(const QString &path);
|
||||
void stopWatchingProject();
|
||||
// Remembers the package as it is now, then calls `then`.
|
||||
void rememberProjectDigest(const QString &path, std::function<void()> then);
|
||||
// Its tab came to the front: ask a held question.
|
||||
void resumeExternalChangeCheck();
|
||||
|
||||
private:
|
||||
struct Incoming {
|
||||
QList<QUrl> urls;
|
||||
@@ -78,6 +88,12 @@ private:
|
||||
void drainIncoming();
|
||||
void handle(const Incoming &request);
|
||||
std::optional<QUuid> ownTab() const;
|
||||
void noteExternalChange();
|
||||
void checkExternalChange();
|
||||
bool isFrontmost() const;
|
||||
void scheduleRecheck();
|
||||
void reloadFromDisk(const QString &path, std::function<void()> then);
|
||||
void askToRevert(std::function<void(bool)> then);
|
||||
|
||||
int m_saveGeneration = 0;
|
||||
std::deque<Incoming> m_incoming;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#include "IO/ProjectDigest.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QtEndian>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
QByteArray ProjectDigest::compute(const QString &path)
|
||||
{
|
||||
QCryptographicHash hasher(QCryptographicHash::Sha256);
|
||||
QFile manifest(path + QStringLiteral("/manifest.json"));
|
||||
if (!manifest.open(QIODevice::ReadOnly))
|
||||
throw std::runtime_error(manifest.errorString().toStdString());
|
||||
// A failed read comes up short; Swift's Data(contentsOf:) throws.
|
||||
const QByteArray bytes = manifest.readAll();
|
||||
if (bytes.size() != manifest.size())
|
||||
throw std::runtime_error("the manifest could not be read whole");
|
||||
hasher.addData(bytes);
|
||||
// Assets are not read: a save would wait on each.
|
||||
const QDir images(path + QStringLiteral("/images"));
|
||||
QStringList names = images.entryList(QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot, QDir::Unsorted);
|
||||
std::sort(names.begin(), names.end());
|
||||
for (const QString &name : names) {
|
||||
const QFileInfo info(images.filePath(name));
|
||||
// Gone mid-listing: half written, as Swift's resourceValues throws.
|
||||
if (!info.exists() && !info.isSymLink())
|
||||
throw std::runtime_error("an image went while the package was read");
|
||||
if (info.isSymLink() || !info.isFile())
|
||||
continue;
|
||||
hasher.addData(name.toUtf8());
|
||||
const quint64 size = qToLittleEndian(quint64(info.size()));
|
||||
hasher.addData(QByteArrayView(reinterpret_cast<const char *>(&size), sizeof size));
|
||||
}
|
||||
return hasher.result();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
// Swift's ProjectDigest: the manifest, and each image's name and size.
|
||||
namespace ProjectDigest {
|
||||
// Throws std::runtime_error when the manifest cannot be read.
|
||||
QByteArray compute(const QString &path);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#include "IO/ProjectWatcher.h"
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <sys/stat.h>
|
||||
|
||||
ProjectWatcher::ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent)
|
||||
: QObject(parent), path(path), m_onChange(std::move(onChange)), m_delivery(new QTimer(this)), m_rearm(new QTimer(this))
|
||||
{
|
||||
m_delivery->setObjectName(QStringLiteral("delivery"));
|
||||
m_delivery->setSingleShot(true);
|
||||
// Swift's sleeps never end early; a coarse timer may.
|
||||
m_delivery->setTimerType(Qt::PreciseTimer);
|
||||
m_delivery->setInterval(coalescing);
|
||||
connect(m_delivery, &QTimer::timeout, this, [this] { m_onChange(); });
|
||||
// A swapped-in package retires the watched inodes: arm by path.
|
||||
m_rearm->setObjectName(QStringLiteral("rearm"));
|
||||
m_rearm->setInterval(100);
|
||||
m_rearm->setTimerType(Qt::PreciseTimer);
|
||||
connect(m_rearm, &QTimer::timeout, this, [this] {
|
||||
arm();
|
||||
m_rearmTries += 1;
|
||||
if (m_watcher.files().size() + m_watcher.directories().size() == watchedPaths().size() || m_rearmTries >= 20)
|
||||
m_rearm->stop();
|
||||
});
|
||||
connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, &ProjectWatcher::noteEvent);
|
||||
connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, &ProjectWatcher::noteEvent);
|
||||
// The parent's other entries are not ours: only another package.
|
||||
connect(&m_parent, &QFileSystemWatcher::directoryChanged, this, [this] {
|
||||
if (identity() != m_identity)
|
||||
noteEvent();
|
||||
});
|
||||
m_parent.addPath(QFileInfo(path).absolutePath());
|
||||
arm();
|
||||
}
|
||||
|
||||
QStringList ProjectWatcher::watchedPaths() const
|
||||
{
|
||||
return {path, path + QStringLiteral("/manifest.json"), path + QStringLiteral("/images")};
|
||||
}
|
||||
|
||||
std::optional<std::pair<dev_t, ino_t>> ProjectWatcher::identity() const
|
||||
{
|
||||
struct stat status;
|
||||
if (::stat(QFile::encodeName(path).constData(), &status) != 0)
|
||||
return std::nullopt;
|
||||
return std::pair(status.st_dev, status.st_ino);
|
||||
}
|
||||
|
||||
void ProjectWatcher::arm()
|
||||
{
|
||||
m_identity = identity();
|
||||
const QStringList watched = m_watcher.files() + m_watcher.directories();
|
||||
if (!watched.isEmpty())
|
||||
m_watcher.removePaths(watched);
|
||||
for (const QString &each : watchedPaths()) {
|
||||
if (QFileInfo::exists(each))
|
||||
m_watcher.addPath(each);
|
||||
}
|
||||
}
|
||||
|
||||
void ProjectWatcher::noteEvent()
|
||||
{
|
||||
m_rearmTries = 0;
|
||||
m_rearm->start();
|
||||
m_delivery->start();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <QFileSystemWatcher>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <sys/types.h>
|
||||
|
||||
// Swift's ProjectWatcher: tells its owner the package changed on disk.
|
||||
class ProjectWatcher : public QObject {
|
||||
public:
|
||||
ProjectWatcher(const QString &path, std::function<void()> onChange, QObject *parent = nullptr);
|
||||
|
||||
const QString path;
|
||||
// Quiet time before reporting, so one save reports once.
|
||||
static constexpr int coalescing = 300;
|
||||
|
||||
private:
|
||||
QStringList watchedPaths() const;
|
||||
void arm();
|
||||
void noteEvent();
|
||||
// The package folder's device and inode, when it exists.
|
||||
std::optional<std::pair<dev_t, ino_t>> identity() const;
|
||||
|
||||
const std::function<void()> m_onChange;
|
||||
QFileSystemWatcher m_watcher;
|
||||
// Qt watches no IN_MOVE_SELF: a renamed package shows above.
|
||||
QFileSystemWatcher m_parent;
|
||||
std::optional<std::pair<dev_t, ino_t>> m_identity;
|
||||
// Named children, so tests read whether they run.
|
||||
QTimer *const m_delivery;
|
||||
QTimer *const m_rearm;
|
||||
int m_rearmTries = 0;
|
||||
};
|
||||
Reference in new issue
Block a user