From d787ea396009582686ab05c4d69d1b8c1e16be92 Mon Sep 17 00:00:00 2001 From: anansutiawan <77756125+anansutiawan@users.noreply.github.com> Date: Wed, 13 May 2026 10:55:49 +0700 Subject: [PATCH 1/6] Show unsaved entry changes in entry list --- src/gui/entry/EntryModel.cpp | 41 ++++++++++++++++++++++++++++++++++ tests/TestEntryModel.cpp | 43 ++++++++++++++++++++++++++++++++++++ tests/TestEntryModel.h | 1 + 3 files changed, 85 insertions(+) diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp index 34723485f0..27d1f237fb 100644 --- a/src/gui/entry/EntryModel.cpp +++ b/src/gui/entry/EntryModel.cpp @@ -18,12 +18,14 @@ #include "EntryModel.h" +#include #include #include #include #include #include "core/Clock.h" +#include "core/Database.h" #include "core/Entry.h" #include "core/Group.h" #include "core/Metadata.h" @@ -34,6 +36,29 @@ #include "gui/osutils/macutils/MacUtils.h" #endif +namespace +{ + bool entryHasUnsavedChanges(const Entry* entry) + { + if (config()->get(Config::AutoSaveAfterEveryChange).toBool()) { + return false; + } + + auto database = entry->database(); + if (!database || !database->isModified() || database->filePath().isEmpty()) { + return false; + } + + const QFileInfo databaseFile(database->filePath()); + if (!databaseFile.exists()) { + return false; + } + + const auto saved = databaseFile.lastModified().toUTC(); + return saved.isValid() && entry->timeInfo().lastModificationTime() > saved; + } +} + EntryModel::EntryModel(QObject* parent) : QAbstractTableModel(parent) , m_group(nullptr) @@ -326,6 +351,9 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const if (entry->isExpired()) { font.setStrikeOut(true); } + if (entryHasUnsavedChanges(entry)) { + font.setItalic(true); + } return font; } else if (role == Qt::ForegroundRole) { @@ -589,6 +617,11 @@ void EntryModel::entryDataChanged(Entry* entry) void EntryModel::onConfigChanged(Config::ConfigKey key) { switch (key) { + case Config::AutoSaveAfterEveryChange: + if (rowCount() > 0) { + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), {Qt::FontRole}); + } + break; case Config::GUI_HideUsernames: emit dataChanged(index(0, Username), index(rowCount() - 1, Username), {Qt::DisplayRole}); break; @@ -613,6 +646,14 @@ void EntryModel::severConnections() void EntryModel::makeConnections(const Group* group) { + if (group->database()) { + connect(group->database(), &Database::databaseSaved, this, [this]() { + if (rowCount() > 0) { + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), {Qt::FontRole}); + } + }); + } + connect(group, SIGNAL(entryAboutToAdd(Entry*)), SLOT(entryAboutToAdd(Entry*))); connect(group, SIGNAL(entryAdded(Entry*)), SLOT(entryAdded(Entry*))); connect(group, SIGNAL(entryAboutToRemove(Entry*)), SLOT(entryAboutToRemove(Entry*))); diff --git a/tests/TestEntryModel.cpp b/tests/TestEntryModel.cpp index e4677324ce..a67aa613f3 100644 --- a/tests/TestEntryModel.cpp +++ b/tests/TestEntryModel.cpp @@ -18,9 +18,15 @@ #include "TestEntryModel.h" +#include +#include +#include #include +#include #include +#include "core/Config.h" +#include "core/Database.h" #include "core/Entry.h" #include "core/Group.h" #include "crypto/Crypto.h" @@ -132,6 +138,43 @@ void TestEntryModel::test() delete model; } +void TestEntryModel::testUnsavedEntryFont() +{ + const auto originalAutoSave = config()->get(Config::AutoSaveAfterEveryChange); + config()->set(Config::AutoSaveAfterEveryChange, false); + + QTemporaryFile dbFile; + QVERIFY(dbFile.open()); + QVERIFY(dbFile.setFileTime(QDateTime::currentDateTimeUtc().addSecs(-60), QFileDevice::FileModificationTime)); + + auto db = new Database(); + db->setFilePath(dbFile.fileName()); + + auto entry = new Entry(); + entry->setTitle("Unsaved"); + entry->setGroup(db->rootGroup()); + db->markAsModified(); + + auto model = new EntryModel(this); + model->setGroup(db->rootGroup()); + + auto font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value(); + QVERIFY(font.italic()); + + config()->set(Config::AutoSaveAfterEveryChange, true); + font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value(); + QVERIFY(!font.italic()); + + config()->set(Config::AutoSaveAfterEveryChange, false); + db->markAsClean(); + font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value(); + QVERIFY(!font.italic()); + + config()->set(Config::AutoSaveAfterEveryChange, originalAutoSave); + delete model; + delete db; +} + void TestEntryModel::testAttachmentsModel() { auto entryAttachments = new EntryAttachments(this); diff --git a/tests/TestEntryModel.h b/tests/TestEntryModel.h index df80331e86..09cdd317c6 100644 --- a/tests/TestEntryModel.h +++ b/tests/TestEntryModel.h @@ -27,6 +27,7 @@ class TestEntryModel : public QObject private slots: void initTestCase(); void test(); + void testUnsavedEntryFont(); void testAttachmentsModel(); void testAttributesModel(); void testDefaultIconModel(); From 000b7871b027ef902e05236a194fa79f892e6362 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Tue, 19 May 2026 19:07:48 -0500 Subject: [PATCH 2/6] Cleanup and add is:modified search Remove unnecessary code from EntryModel and place the hasUnsavedChanges into Entry itself. Add default search Add tooltip --- src/core/Entry.cpp | 19 ++++++++++++++++ src/core/Entry.h | 1 + src/core/EntrySearcher.cpp | 3 +++ src/gui/entry/EntryModel.cpp | 42 +++--------------------------------- src/gui/tag/TagModel.cpp | 9 +++++--- 5 files changed, 32 insertions(+), 42 deletions(-) diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 92404ab3dc..f4a26e7f20 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -585,6 +585,25 @@ bool Entry::hasValidTotp() const return error.isEmpty(); } +bool Entry::hasUnsavedChanges() const +{ + auto db = database(); + // Basic checks to avoid more expensive file checks later + if (!db || !db->isModified() || db->filePath().isEmpty()) { + return false; + } + + // If the database file doesn't exist, then we haven't done an initial save yet + const QFileInfo databaseFile(db->filePath()); + if (!databaseFile.exists()) { + return false; + } + + // Check to see if the last modified time of this entry is after the file modification time + const auto saved = databaseFile.lastModified().toUTC(); + return saved.isValid() && timeInfo().lastModificationTime() > saved; +} + bool Entry::hasPasskey() const { return m_attributes->hasPasskey(); diff --git a/src/core/Entry.h b/src/core/Entry.h index 4874f59376..e1a6556588 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -121,6 +121,7 @@ class Entry : public ModifiableObject const QSharedPointer passwordHealth() const; bool excludeFromReports() const; void setExcludeFromReports(bool state); + bool hasUnsavedChanges() const; bool hasPasskey() const; void removePasskey(); diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp index cb9e135fe3..329701bfc1 100644 --- a/src/core/EntrySearcher.cpp +++ b/src/core/EntrySearcher.cpp @@ -218,6 +218,9 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry) break; } } + } else if (term.word.compare("modified", Qt::CaseInsensitive) == 0) { + found = entry->hasUnsavedChanges(); + break; } found = false; break; diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp index 27d1f237fb..41f611da5a 100644 --- a/src/gui/entry/EntryModel.cpp +++ b/src/gui/entry/EntryModel.cpp @@ -18,14 +18,12 @@ #include "EntryModel.h" -#include #include #include #include #include #include "core/Clock.h" -#include "core/Database.h" #include "core/Entry.h" #include "core/Group.h" #include "core/Metadata.h" @@ -36,29 +34,6 @@ #include "gui/osutils/macutils/MacUtils.h" #endif -namespace -{ - bool entryHasUnsavedChanges(const Entry* entry) - { - if (config()->get(Config::AutoSaveAfterEveryChange).toBool()) { - return false; - } - - auto database = entry->database(); - if (!database || !database->isModified() || database->filePath().isEmpty()) { - return false; - } - - const QFileInfo databaseFile(database->filePath()); - if (!databaseFile.exists()) { - return false; - } - - const auto saved = databaseFile.lastModified().toUTC(); - return saved.isValid() && entry->timeInfo().lastModificationTime() > saved; - } -} - EntryModel::EntryModel(QObject* parent) : QAbstractTableModel(parent) , m_group(nullptr) @@ -351,7 +326,7 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const if (entry->isExpired()) { font.setStrikeOut(true); } - if (entryHasUnsavedChanges(entry)) { + if (entry->hasUnsavedChanges()) { font.setItalic(true); } return font; @@ -388,6 +363,8 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const } else if (role == Qt::ToolTipRole) { if (index.column() == PasswordStrength && !entry->password().isEmpty() && !entry->excludeFromReports()) { return entry->passwordHealth()->scoreReason(); + } else if (index.column() == Title && entry->hasUnsavedChanges()) { + return tr("This entry has unsaved changes"); } } @@ -617,11 +594,6 @@ void EntryModel::entryDataChanged(Entry* entry) void EntryModel::onConfigChanged(Config::ConfigKey key) { switch (key) { - case Config::AutoSaveAfterEveryChange: - if (rowCount() > 0) { - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), {Qt::FontRole}); - } - break; case Config::GUI_HideUsernames: emit dataChanged(index(0, Username), index(rowCount() - 1, Username), {Qt::DisplayRole}); break; @@ -646,14 +618,6 @@ void EntryModel::severConnections() void EntryModel::makeConnections(const Group* group) { - if (group->database()) { - connect(group->database(), &Database::databaseSaved, this, [this]() { - if (rowCount() > 0) { - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), {Qt::FontRole}); - } - }); - } - connect(group, SIGNAL(entryAboutToAdd(Entry*)), SLOT(entryAboutToAdd(Entry*))); connect(group, SIGNAL(entryAdded(Entry*)), SLOT(entryAdded(Entry*))); connect(group, SIGNAL(entryAboutToRemove(Entry*)), SLOT(entryAboutToRemove(Entry*))); diff --git a/src/gui/tag/TagModel.cpp b/src/gui/tag/TagModel.cpp index a19f263d84..67c0c61d08 100644 --- a/src/gui/tag/TagModel.cpp +++ b/src/gui/tag/TagModel.cpp @@ -20,7 +20,6 @@ #include "core/Database.h" #include "core/Metadata.h" #include "gui/Icons.h" -#include "gui/MessageBox.h" #include #include @@ -28,10 +27,14 @@ TagModel::TagModel(QObject* parent) : QAbstractListModel(parent) { - m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) << qMakePair(tr("All Entries"), QString("*")) - << qMakePair(tr("Expired"), QString("is:expired")) + // clang-format off + m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) + << qMakePair(tr("All Entries"), QString("*")) + << qMakePair(tr("Modified Entries"), QString("is:modified")) + << qMakePair(tr("Expired Entries"), QString("is:expired")) << qMakePair(tr("Weak Passwords"), QString("is:weak")) << qMakePair(tr("TOTP Entries"), QString("has:totp")); + // clang-format on } TagModel::~TagModel() = default; From 7b5f1a372e0053a7d218d0033ff9eb90c87fbb98 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Tue, 19 May 2026 19:08:21 -0500 Subject: [PATCH 3/6] Use a modified badge indicator Testing out using a modified visual indicator on the entry icon --- share/icons/badges/3_Modified.svg | 1 + share/icons/icons.qrc | 1 + src/gui/DatabaseIcons.h | 3 ++- src/gui/Icons.cpp | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 share/icons/badges/3_Modified.svg diff --git a/share/icons/badges/3_Modified.svg b/share/icons/badges/3_Modified.svg new file mode 100644 index 0000000000..1a477fac85 --- /dev/null +++ b/share/icons/badges/3_Modified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 3e82e172d5..5758a61e95 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -195,5 +195,6 @@ badges/0_ShareActive.svg badges/1_ShareInactive.svg badges/2_Expired.svg + badges/3_Modified.svg diff --git a/src/gui/DatabaseIcons.h b/src/gui/DatabaseIcons.h index ddcdc2568a..d7d7e95a8b 100644 --- a/src/gui/DatabaseIcons.h +++ b/src/gui/DatabaseIcons.h @@ -40,7 +40,8 @@ class DatabaseIcons { ShareActive = 0, ShareInactive, - Expired + Expired, + Modified }; QPixmap icon(int index, IconSize size = IconSize::Default); diff --git a/src/gui/Icons.cpp b/src/gui/Icons.cpp index cdb553fb24..f886a7c0c4 100644 --- a/src/gui/Icons.cpp +++ b/src/gui/Icons.cpp @@ -246,6 +246,8 @@ QPixmap Icons::entryIconPixmap(const Entry* entry, IconSize size) if (entry->isExpired()) { icon = databaseIcons()->applyBadge(icon, DatabaseIcons::Badges::Expired); + } else if (entry->hasUnsavedChanges()) { + icon = databaseIcons()->applyBadge(icon, DatabaseIcons::Badges::Modified); } return icon; From 78f692d1b9f6d70d4da30a2238cddcbf5186103d Mon Sep 17 00:00:00 2001 From: anansutiawan <77756125+anansutiawan@users.noreply.github.com> Date: Wed, 20 May 2026 11:00:00 +0700 Subject: [PATCH 4/6] Cache database file modified time --- src/core/Database.cpp | 18 ++++++++++++++++++ src/core/Database.h | 3 +++ src/core/Entry.cpp | 13 +++++-------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 5f57601411..cd66d578e0 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -712,6 +712,7 @@ void Database::setFilePath(const QString& filePath) if (filePath != m_data.filePath) { QString oldPath = m_data.filePath; m_data.filePath = filePath; + updateSavedFileModifiedTime(); // Don't watch for changes until the next open or save operation m_fileWatcher->stop(); m_ignoreFileChangesUntilSaved = false; @@ -719,6 +720,11 @@ void Database::setFilePath(const QString& filePath) } } +QDateTime Database::savedFileModifiedTime() const +{ + return m_savedFileModifiedTime; +} + const QByteArray& Database::fileBlockHash() const { return m_fileBlockHash; @@ -1057,11 +1063,23 @@ void Database::markAsClean() m_modified = false; stopModifiedTimer(); m_hasNonDataChange = false; + updateSavedFileModifiedTime(); if (emitSignal) { emit databaseSaved(); } } +void Database::updateSavedFileModifiedTime() +{ + if (m_data.filePath.isEmpty()) { + m_savedFileModifiedTime = {}; + return; + } + + const QFileInfo databaseFile(m_data.filePath); + m_savedFileModifiedTime = databaseFile.exists() ? databaseFile.lastModified().toUTC() : QDateTime{}; +} + void Database::markNonDataChange() { m_hasNonDataChange = true; diff --git a/src/core/Database.h b/src/core/Database.h index 0d183e778b..145d3b045c 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -110,6 +110,7 @@ class Database : public ModifiableObject QString filePath() const; QString canonicalFilePath() const; void setFilePath(const QString& filePath); + QDateTime savedFileModifiedTime() const; const QByteArray& fileBlockHash() const; void setIgnoreFileChangesUntilSaved(bool ignore); @@ -239,6 +240,7 @@ public slots: void startModifiedTimer(); void stopModifiedTimer(); + void updateSavedFileModifiedTime(); QByteArray m_fileBlockHash; bool m_ignoreFileChangesUntilSaved; @@ -249,6 +251,7 @@ public slots: QTimer m_modifiedTimer; QMutex m_saveMutex; QPointer m_fileWatcher; + QDateTime m_savedFileModifiedTime; bool m_modified = false; bool m_hasNonDataChange = false; QString m_keyError; diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index f4a26e7f20..74535d677a 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -587,20 +587,17 @@ bool Entry::hasValidTotp() const bool Entry::hasUnsavedChanges() const { - auto db = database(); - // Basic checks to avoid more expensive file checks later - if (!db || !db->isModified() || db->filePath().isEmpty()) { + if (config()->get(Config::AutoSaveAfterEveryChange).toBool()) { return false; } - // If the database file doesn't exist, then we haven't done an initial save yet - const QFileInfo databaseFile(db->filePath()); - if (!databaseFile.exists()) { + auto db = database(); + // Basic checks to avoid more expensive file checks later + if (!db || !db->isModified() || db->filePath().isEmpty()) { return false; } - // Check to see if the last modified time of this entry is after the file modification time - const auto saved = databaseFile.lastModified().toUTC(); + const auto saved = db->savedFileModifiedTime(); return saved.isValid() && timeInfo().lastModificationTime() > saved; } From d8ce8615aef63774ca149a8f18a00a25af6b11f0 Mon Sep 17 00:00:00 2001 From: anansutiawan <77756125+anansutiawan@users.noreply.github.com> Date: Fri, 22 May 2026 12:18:01 +0700 Subject: [PATCH 5/6] Respect autosave failure state for unsaved entries --- src/core/Entry.cpp | 4 ---- tests/TestEntryModel.cpp | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 74535d677a..2846100c22 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -587,10 +587,6 @@ bool Entry::hasValidTotp() const bool Entry::hasUnsavedChanges() const { - if (config()->get(Config::AutoSaveAfterEveryChange).toBool()) { - return false; - } - auto db = database(); // Basic checks to avoid more expensive file checks later if (!db || !db->isModified() || db->filePath().isEmpty()) { diff --git a/tests/TestEntryModel.cpp b/tests/TestEntryModel.cpp index a67aa613f3..fb67ae4675 100644 --- a/tests/TestEntryModel.cpp +++ b/tests/TestEntryModel.cpp @@ -141,7 +141,6 @@ void TestEntryModel::test() void TestEntryModel::testUnsavedEntryFont() { const auto originalAutoSave = config()->get(Config::AutoSaveAfterEveryChange); - config()->set(Config::AutoSaveAfterEveryChange, false); QTemporaryFile dbFile; QVERIFY(dbFile.open()); @@ -163,7 +162,7 @@ void TestEntryModel::testUnsavedEntryFont() config()->set(Config::AutoSaveAfterEveryChange, true); font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value(); - QVERIFY(!font.italic()); + QVERIFY(font.italic()); config()->set(Config::AutoSaveAfterEveryChange, false); db->markAsClean(); From 7ff8105c1caaf64bd67737ad4535b87c23081903 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Fri, 22 May 2026 09:46:47 -0400 Subject: [PATCH 6/6] Rename saved search to "Unsaved Changes" --- share/translations/keepassxc_en.ts | 14 +++++++++++--- src/gui/tag/TagModel.cpp | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 574dc39671..84f1cd6cd3 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -4362,6 +4362,10 @@ Would you like to overwrite the existing attachment? Group Path + + This entry has unsaved changes + + EntryPreviewWidget @@ -10334,15 +10338,19 @@ This option is deprecated, use --set-key-file instead. - Expired + Weak Passwords - Weak Passwords + TOTP Entries - TOTP Entries + Unsaved Changes + + + + Expired Entries diff --git a/src/gui/tag/TagModel.cpp b/src/gui/tag/TagModel.cpp index 67c0c61d08..323bb92316 100644 --- a/src/gui/tag/TagModel.cpp +++ b/src/gui/tag/TagModel.cpp @@ -30,7 +30,7 @@ TagModel::TagModel(QObject* parent) // clang-format off m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) << qMakePair(tr("All Entries"), QString("*")) - << qMakePair(tr("Modified Entries"), QString("is:modified")) + << qMakePair(tr("Unsaved Changes"), QString("is:modified")) << qMakePair(tr("Expired Entries"), QString("is:expired")) << qMakePair(tr("Weak Passwords"), QString("is:weak")) << qMakePair(tr("TOTP Entries"), QString("has:totp"));