Tell a second person who has a project open, and open it read-only

Two people opening the same project on a shared drive could both edit it,
and the last save silently wiped the other's work. A project now has a
hidden lock file beside it (.~plant.qetz.lock, small JSON: user, host,
process, version, since, heartbeat). Opening a project another live session
holds shows who has it, since when and how long ago they were last active,
and offers Open read-only (default), Open anyway, or Cancel.

- The lock is made with an exclusive create, refreshed every minute and
  deleted when the project closes. It is not a QLockFile: that treats any
  lock from another machine as stale after 30 s. Here only a lock made on
  this machine by a process that no longer exists is taken silently.
- A folder the lock cannot be made in, or the setting turned off
  (Settings > General > Projects), opens as before.
- Save As and .qet saved as .qetz move the lock to the new file, and refuse
  a file someone else has open. A project opened read-only for another's
  lock does not save over that file.
- --resave and --set-titleblock refuse a file someone has open unless given
  --ignore-lock; the read-only verbs never look at locks and headless runs
  never take one. A script's qet.save() refuses the same way.

Checked: 17 new unit tests (5 go red when stale detection and the exclusive
create are broken), full ctest 107/107, the dialog and both choices in the
GUI under Xvfb, and --resave refused/allowed from the command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-16 13:01:54 +13:00
parent c4b5cf2f11
commit 3521c64ba3
17 changed files with 1012 additions and 0 deletions
+90
View File
@@ -1888,6 +1888,51 @@ bool QETDiagramEditor::closeProject(QETProject *project)
return(true);
}
/**
@brief QETDiagramEditor::askAboutLock
Tell the user who has the project @p filepath open and let them choose.
Read-only is the default: opening anyway can lose the other person's
work, and a lock left by a crash on another machine cannot be told from
a live one, which is why the age of the last sign of life is shown.
*/
QETDiagramEditor::LockChoice QETDiagramEditor::askAboutLock(
const QString &filepath,
const ProjectLock::Holder &holder)
{
const QString name = QFileInfo(filepath).fileName();
QString text;
if (holder.session == QLatin1String("?")) {
text = tr("%1 is being opened by another session of QElectroTech.").arg(name);
} else {
const QString since = QLocale::system().toString(holder.since.toLocalTime(), QLocale::ShortFormat);
text = tr("%1 is open by %2 on %3 since %4").arg(name, holder.user, holder.host, since);
const qint64 minutes = holder.heartbeat.isValid()
? holder.heartbeat.secsTo(QDateTime::currentDateTimeUtc()) / 60
: -1;
if (minutes >= 0) {
text += minutes < 1
? tr(" (active just now).")
: tr(" (active %n minute(s) ago).", nullptr, static_cast<int>(minutes));
} else {
text += QLatin1Char('.');
}
}
QMessageBox box(QMessageBox::Warning, tr("Project in use", "message box title"), text, QMessageBox::NoButton, this);
box.setInformativeText(tr("Open it read-only to look at it without changing it. "
"Open it anyway only if you know that session is not "
"using it any more: both of you could then save over each other."));
QPushButton *read_only = box.addButton(tr("Open read-only"), QMessageBox::AcceptRole);
QPushButton *anyway = box.addButton(tr("Open anyway"), QMessageBox::DestructiveRole);
box.addButton(QMessageBox::Cancel);
box.setDefaultButton(read_only);
box.setEscapeButton(box.button(QMessageBox::Cancel));
box.exec();
if (box.clickedButton() == read_only) return LockChoice::ReadOnly;
if (box.clickedButton() == anyway) return LockChoice::OpenAnyway;
return LockChoice::Cancel;
}
/**
Ouvre un projet depuis un fichier et l'ajoute a cet editeur
@param filepath Chemin du projet a ouvrir
@@ -1966,6 +2011,40 @@ bool QETDiagramEditor::openAndAddProject(
}
}
//Is someone else working on this file? Taken before the project is
//read, so a cancel costs nothing. Never blocks opening: a folder
//the lock cannot be made in, or the setting off, opens as before.
std::unique_ptr<ProjectLock> lock;
bool opened_read_only_by_lock = false;
if (ProjectLock::mayTake() && filepath_info.isWritable())
{
lock = std::make_unique<ProjectLock>();
if (lock->acquire(filepath) == ProjectLock::Result::Held)
{
const ProjectLock::Holder holder = lock->blockingHolder();
bool take_over = false;
if (interactive) {
switch (askAboutLock(filepath, holder)) {
case LockChoice::Cancel:
return(false);
case LockChoice::OpenAnyway:
take_over = true;
break;
case LockChoice::ReadOnly:
break;
}
}
//Not interactive (an assistant opening it): read-only, which
//is the safe answer to a question nobody is there to ask
if (take_over && lock->acquire(filepath, true) == ProjectLock::Result::Acquired) {
// ours now
} else if (!take_over) {
lock.reset();
opened_read_only_by_lock = true;
}
}
}
//Create the project
DialogWaiting::instance(this);
@@ -1998,6 +2077,17 @@ bool QETDiagramEditor::openAndAddProject(
return(false);
}
if (opened_read_only_by_lock) project->setOpenedReadOnlyByLock(true);
if (lock && lock->isHeld()) {
connect(lock.get(), &ProjectLock::lockTaken, this, [this, project](const ProjectLock::Holder &by) {
statusBar()->showMessage(
tr("%1 on %2 has taken over %3. Save your changes with Save As to keep them.")
.arg(by.user, by.host, QFileInfo(project->filePath()).fileName()),
30000);
});
project->setLock(std::move(lock));
}
QETApp::projectsRecentFiles() -> fileWasOpened(filepath);
addProject(project);
DialogWaiting::dropInstance();