3 Commits

Author SHA1 Message Date
forust afb089bee6 fix(search): make stop interrupt all phases
ci-release / verify (push) Successful in 33s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 22:22:03 +02:00
forust 79001370b8 feat(ui): warn when search results are stale
ci-release / verify (push) Successful in 34s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 22:11:30 +02:00
forust 07fc419f1e fix(duplicates): correct result visibility
ci-release / verify (push) Successful in 37s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 21:51:48 +02:00
4 changed files with 168 additions and 35 deletions
+99 -23
View File
@@ -255,6 +255,12 @@ public slots:
void search(const SearchOptions &o)
{
cancelled_.store(false, std::memory_order_relaxed);
const auto finishIfCancelled = [this] {
if (!cancelled_.load(std::memory_order_relaxed))
return false;
emit finished({}, tr("Search stopped"));
return true;
};
cancelledByLimit_ = false;
cache_.setEnabled(o.useCache);
cacheHits_.store(0, std::memory_order_relaxed);
@@ -284,7 +290,8 @@ public slots:
dirOptions |= fs::directory_options::follow_directory_symlink;
if (!o.recursive) {
for (fs::directory_iterator it(root, dirOptions, ec), end;
it != end && !ec; it.increment(ec)) {
it != end && !ec && !cancelled_.load(std::memory_order_relaxed);
it.increment(ec)) {
++totalEntries;
if (totalEntries % 2048 == 0)
emit phaseProgress(tr("Counting files"), totalEntries, 0);
@@ -397,7 +404,9 @@ public slots:
if (!o.recursive) {
for (fs::directory_iterator it(root, dirOptions, ec), end;
it != end && !ec && !cancelledByLimit_; it.increment(ec)) {
it != end && !ec && !cancelled_.load(std::memory_order_relaxed)
&& !cancelledByLimit_;
it.increment(ec)) {
process(*it, root);
if (++scanned % 512 == 0) {
if (totalEntries)
@@ -438,10 +447,8 @@ public slots:
if (totalEntries)
emit phaseProgress(tr("Scanning files"), std::min(scanned, totalEntries), totalEntries);
if (cancelled_.load(std::memory_order_relaxed)) {
emit finished({}, tr("Search stopped"));
if (finishIfCancelled())
return;
}
if (!o.contains.isEmpty() && !o.findFolders) {
emit phaseProgress(tr("Searching file contents"), 0, candidates.size());
@@ -449,6 +456,8 @@ public slots:
const qsizetype total = candidates.size();
QFuture<FileRecord> future = QtConcurrent::filtered(
candidates, [this, o, &checked, total](const FileRecord &r) {
if (cancelled_.load(std::memory_order_relaxed))
return false;
const bool matched = cachedFileContains(r, o);
const qsizetype done = checked.fetch_add(1, std::memory_order_relaxed) + 1;
if (done == total || done % 32 == 0)
@@ -456,6 +465,8 @@ public slots:
return matched;
});
future.waitForFinished();
if (finishIfCancelled())
return;
candidates = future.results();
if (o.maxResults > 0 && candidates.size() > o.maxResults)
candidates.resize(o.maxResults);
@@ -480,6 +491,8 @@ public slots:
int group = 1;
QSet<QString> duplicatedPaths;
for (auto &[size, sameSize] : bySize) {
if (cancelled_.load(std::memory_order_relaxed))
break;
Q_UNUSED(size);
if (sameSize.size() < 2)
continue;
@@ -490,18 +503,24 @@ public slots:
emit phaseProgress(tr("Checking duplicates"), done, hashTotal);
return result;
});
if (cancelled_.load(std::memory_order_relaxed))
break;
QHash<QByteArray, QVector<FileRecord>> sameHash;
for (auto &[hash, record] : hashes)
if (!hash.isEmpty())
sameHash[hash].push_back(record);
for (const auto &hashCandidates : sameHash) {
if (cancelled_.load(std::memory_order_relaxed))
break;
if (hashCandidates.size() < 2)
continue;
QVector<QVector<FileRecord>> exactGroups;
for (const auto &record : hashCandidates) {
if (cancelled_.load(std::memory_order_relaxed))
break;
bool placed = false;
for (auto &exact : exactGroups) {
if (filesEqual(exact.front().path, record.path)) {
if (filesEqual(exact.front().path, record.path, &cancelled_)) {
exact.push_back(record);
placed = true;
break;
@@ -511,6 +530,8 @@ public slots:
exactGroups.push_back({record});
}
for (auto &exact : exactGroups) {
if (cancelled_.load(std::memory_order_relaxed))
break;
if (exact.size() < 2)
continue;
int duplicateCopy = 1;
@@ -518,21 +539,22 @@ public slots:
record.group = group;
record.duplicateCopy = duplicateCopy++;
duplicatedPaths.insert(record.path);
if (o.mode == QStringLiteral("Duplicates search"))
if (o.mode == QStringLiteral("Duplicates search")
&& shouldShowDuplicateResult(
o.showDuplicateCopiesOnly, record.duplicateCopy)) {
results.push_back(record);
}
}
++group;
}
}
}
if (finishIfCancelled())
return;
if (o.mode == QStringLiteral("Non-Duplicates search")) {
for (const auto &record : candidates)
if (record.type == QStringLiteral("File") && !duplicatedPaths.contains(record.path))
results.push_back(record);
} else if (!o.showOnlyDuplicateFiles) {
for (const auto &record : candidates)
if (record.type == QStringLiteral("File") && !duplicatedPaths.contains(record.path))
results.push_back(record);
}
} else if (o.mode == QStringLiteral("Duplicate names search")) {
QHash<QString, QVector<FileRecord>> byName;
@@ -546,6 +568,8 @@ public slots:
quint64 namesDone = 0;
emit phaseProgress(tr("Comparing duplicate names"), 0, byName.size());
for (auto sameName : byName) {
if (cancelled_.load(std::memory_order_relaxed))
break;
++namesDone;
if (namesDone % 32 == 0 || namesDone == quint64(byName.size()))
emit phaseProgress(tr("Comparing duplicate names"), namesDone, byName.size());
@@ -559,7 +583,8 @@ public slots:
bool identical = !firstHash.isEmpty();
for (qsizetype i = 1; identical && i < sameName.size(); ++i) {
identical = cachedSha256(sameName[i]) == firstHash
&& filesEqual(sameName.front().path, sameName[i].path);
&& filesEqual(
sameName.front().path, sameName[i].path, &cancelled_);
}
const bool wantsNonIdentical = o.duplicateNameMode.startsWith(QStringLiteral("Only non"));
if (identical == wantsNonIdentical)
@@ -573,9 +598,13 @@ public slots:
}
++group;
}
if (finishIfCancelled())
return;
} else if (o.mode == QStringLiteral("Summary")) {
QHash<QString, QVector<FileRecord>> byFolder;
for (const auto &record : candidates) {
if (cancelled_.load(std::memory_order_relaxed))
break;
byFolder[record.folder].push_back(record);
if (o.includeSubfoldersInSummary) {
QDir parent(record.folder);
@@ -595,6 +624,8 @@ public slots:
}
}
for (auto it = byFolder.cbegin(); it != byFolder.cend(); ++it) {
if (cancelled_.load(std::memory_order_relaxed))
break;
qint64 total = 0;
qint64 totalOnDisk = 0;
qint64 newest = 0;
@@ -608,10 +639,14 @@ public slots:
newest, 0, 0, 0, tr("%1 files").arg(it.value().size()),
{}, {}, 0, 0});
}
if (finishIfCancelled())
return;
} else {
results = std::move(candidates);
}
if (finishIfCancelled())
return;
const double seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started).count();
cache_.save();
@@ -635,8 +670,9 @@ private:
return digest;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
digest = sha256(record.path);
cache_.storeHash(record, digest);
digest = sha256(record.path, &cancelled_);
if (!cancelled_.load(std::memory_order_relaxed))
cache_.storeHash(record, digest);
return digest;
}
@@ -651,8 +687,9 @@ private:
return *cached;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
const bool matched = fileContains(record.path, o);
cache_.storeContent(record, queryKey, matched);
const bool matched = fileContains(record.path, o, &cancelled_);
if (!cancelled_.load(std::memory_order_relaxed))
cache_.storeContent(record, queryKey, matched);
return matched;
}
@@ -1221,14 +1258,14 @@ public:
action->setCheckable(true);
duplicateOptionsGroup->addAction(action);
}
showOnlyDuplicates->setChecked(options_.showOnlyDuplicateFiles);
showAllDuplicates->setChecked(!options_.showOnlyDuplicateFiles);
showOnlyDuplicates->setChecked(options_.showDuplicateCopiesOnly);
showAllDuplicates->setChecked(!options_.showDuplicateCopiesOnly);
connect(showOnlyDuplicates, &QAction::triggered, this, [this] {
options_.showOnlyDuplicateFiles = true;
options_.showDuplicateCopiesOnly = true;
saveOptions();
});
connect(showAllDuplicates, &QAction::triggered, this, [this] {
options_.showOnlyDuplicateFiles = false;
options_.showDuplicateCopiesOnly = false;
saveOptions();
});
@@ -1288,6 +1325,15 @@ public:
.arg(QStringLiteral(FOLDERSCOPE_VERSION)));
});
staleSearchWarning_ = new QLabel(
tr("⚠ Search settings changed — press F5 to rescan"));
staleSearchWarning_->setStyleSheet(
QStringLiteral("QLabel { color: #f0b429; font-weight: 600; padding: 0 6px; }"));
staleSearchWarning_->setToolTip(
tr("Displayed results were produced with different search settings."));
staleSearchWarning_->hide();
statusBar()->addPermanentWidget(staleSearchWarning_);
progress_ = new QProgressBar;
progress_->setRange(0, 0);
progress_->setMinimumWidth(280);
@@ -1307,12 +1353,16 @@ public:
statusBar()->showMessage(tr("Search cache cleared"), 5000);
});
connect(engine_, &SearchEngine::progress, this, [this](const QString &path, quint64 count) {
if (isStopping_)
return;
progress_->setRange(0, 0);
progress_->setFormat(tr("Scanning… %1 entries").arg(count));
statusBar()->showMessage(tr("Scanning %1 (%2 entries)").arg(path).arg(count));
});
connect(engine_, &SearchEngine::phaseProgress, this,
[this](const QString &phase, quint64 completed, quint64 total) {
if (isStopping_)
return;
if (total == 0) {
progress_->setRange(0, 0);
progress_->setFormat(completed
@@ -1334,7 +1384,14 @@ public:
workerThread_.start();
connect(searchAction_, &QAction::triggered, this, &MainWindow::showOptions);
connect(stopAction_, &QAction::triggered, this, [this] { emit stopRequested(); });
connect(stopAction_, &QAction::triggered, this, [this] {
isStopping_ = true;
stopAction_->setEnabled(false);
progress_->setRange(0, 0);
progress_->setFormat(tr("Stopping…"));
statusBar()->showMessage(tr("Stopping search…"));
emit stopRequested();
});
connect(refreshAction_, &QAction::triggered, this, &MainWindow::refreshSearch);
connect(exportAction, &QAction::triggered, this, &MainWindow::exportResults);
connect(openAction, &QAction::triggered, this, &MainWindow::openSelected);
@@ -1516,6 +1573,9 @@ private slots:
if (isSearching_ || options_.roots.trimmed().isEmpty())
return;
isSearching_ = true;
isStopping_ = false;
lastSearchOptions_ = options_;
updateStaleSearchWarning();
applyDisplayOptions();
model_->setRows({});
searchAction_->setEnabled(false);
@@ -1534,12 +1594,14 @@ private slots:
void searchFinished(const QVector<FileRecord> &results, const QString &message)
{
isSearching_ = false;
isStopping_ = false;
model_->setRows(results);
searchAction_->setEnabled(true);
stopAction_->setEnabled(false);
refreshAction_->setEnabled(true);
progress_->hide();
statusBar()->showMessage(message);
updateStaleSearchWarning();
if (autoSizeOnSearchEnd_) {
table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
table_->resizeColumnsToContents();
@@ -1991,11 +2053,20 @@ private:
settings.setValue(QStringLiteral("mode"), options_.mode);
settings.setValue(QStringLiteral("duplicateNameMode"), options_.duplicateNameMode);
settings.setValue(QStringLiteral("duplicateNameWithoutExtension"), options_.duplicateNameWithoutExtension);
settings.setValue(QStringLiteral("showOnlyDuplicateFiles"), options_.showOnlyDuplicateFiles);
settings.setValue(QStringLiteral("showDuplicateCopiesOnly"), options_.showDuplicateCopiesOnly);
settings.setValue(QStringLiteral("includeSubfoldersInSummary"), options_.includeSubfoldersInSummary);
settings.setValue(QStringLiteral("hidden"), options_.hidden);
settings.setValue(QStringLiteral("readonly"), options_.readonly);
settings.setValue(QStringLiteral("executable"), options_.executable);
updateStaleSearchWarning();
}
void updateStaleSearchWarning()
{
if (!staleSearchWarning_)
return;
staleSearchWarning_->setVisible(
lastSearchOptions_.has_value() && options_ != *lastSearchOptions_);
}
void loadOptions()
@@ -2028,7 +2099,9 @@ private:
options_.mode = s.value(QStringLiteral("mode"), options_.mode).toString();
options_.duplicateNameMode = s.value(QStringLiteral("duplicateNameMode"), options_.duplicateNameMode).toString();
options_.duplicateNameWithoutExtension = s.value(QStringLiteral("duplicateNameWithoutExtension"), false).toBool();
options_.showOnlyDuplicateFiles = s.value(QStringLiteral("showOnlyDuplicateFiles"), true).toBool();
options_.showDuplicateCopiesOnly =
s.value(QStringLiteral("showDuplicateCopiesOnly"),
s.value(QStringLiteral("showOnlyDuplicateFiles"), true)).toBool();
options_.includeSubfoldersInSummary = s.value(QStringLiteral("includeSubfoldersInSummary"), false).toBool();
options_.hidden = s.value(QStringLiteral("hidden"), options_.hidden).toString();
options_.readonly = s.value(QStringLiteral("readonly"), options_.readonly).toString();
@@ -2057,9 +2130,11 @@ private:
QAction *searchAction_{};
QAction *stopAction_{};
QAction *refreshAction_{};
QLabel *staleSearchWarning_{};
QProgressBar *progress_{};
QThread workerThread_;
SearchEngine *engine_{};
std::optional<SearchOptions> lastSearchOptions_;
QString findQuery_;
QString summarySizeUnitName_ = QStringLiteral("Automatic");
QString doubleClickAction_ = QStringLiteral("Open Properties Window");
@@ -2075,6 +2150,7 @@ private:
bool showGrid_ = true;
bool showTooltips_ = true;
bool isSearching_ = false;
bool isStopping_ = false;
};
int main(int argc, char **argv)
+30 -8
View File
@@ -54,6 +54,13 @@ bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensi
return false;
}
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority)
{
if (keeperPriority < 1)
return false;
return !copiesOnly || keeperPriority > 1;
}
QString humanSize(qint64 bytes)
{
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
@@ -87,8 +94,18 @@ std::optional<QByteArray> hexNeedle(QString text)
return QByteArray::fromHex(text.toLatin1());
}
bool fileContains(const QString &path, const SearchOptions &options)
namespace {
bool isCancelled(const std::atomic_bool *cancelled)
{
return cancelled && cancelled->load(std::memory_order_relaxed);
}
}
bool fileContains(const QString &path, const SearchOptions &options,
const std::atomic_bool *cancelled)
{
if (isCancelled(cancelled))
return false;
if (options.contains.isEmpty())
return true;
@@ -117,7 +134,7 @@ bool fileContains(const QString &path, const SearchOptions &options)
for (const QByteArray &needle : needles)
overlap = std::max(overlap, needle.size());
QByteArray tail;
while (!file.atEnd()) {
while (!file.atEnd() && !isCancelled(cancelled)) {
QByteArray data = tail + file.read(1024 * 1024);
if (!options.caseSensitive && !options.binary)
data = data.toLower();
@@ -138,31 +155,36 @@ bool fileContains(const QString &path, const SearchOptions &options)
return false;
}
bool filesEqual(const QString &leftPath, const QString &rightPath)
bool filesEqual(const QString &leftPath, const QString &rightPath,
const std::atomic_bool *cancelled)
{
if (isCancelled(cancelled))
return false;
QFile left(leftPath);
QFile right(rightPath);
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|| left.size() != right.size())
return false;
while (!left.atEnd()) {
while (!left.atEnd() && !isCancelled(cancelled)) {
const QByteArray leftData = left.read(1024 * 1024);
const QByteArray rightData = right.read(leftData.size());
if (leftData != rightData)
return false;
}
return right.atEnd();
return !isCancelled(cancelled) && right.atEnd();
}
QByteArray sha256(const QString &path)
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled)
{
if (isCancelled(cancelled))
return {};
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd())
while (!file.atEnd() && !isCancelled(cancelled))
hash.addData(file.read(1024 * 1024));
return hash.result();
return isCancelled(cancelled) ? QByteArray{} : hash.result();
}
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
+10 -4
View File
@@ -7,6 +7,7 @@
#include <QStringList>
#include <QVector>
#include <atomic>
#include <optional>
#include <sys/types.h>
@@ -40,11 +41,13 @@ struct SearchOptions {
QString mode = QStringLiteral("Standard search");
QString duplicateNameMode = QStringLiteral("All files and folders");
bool duplicateNameWithoutExtension = false;
bool showOnlyDuplicateFiles = true;
bool showDuplicateCopiesOnly = true;
bool includeSubfoldersInSummary = false;
QString hidden = QStringLiteral("Any");
QString readonly = QStringLiteral("Any");
QString executable = QStringLiteral("Any");
bool operator==(const SearchOptions &) const = default;
};
struct FileRecord {
@@ -74,7 +77,10 @@ bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensi
QString humanSize(qint64 bytes);
QString ownerName(uid_t uid);
std::optional<QByteArray> hexNeedle(QString text);
bool fileContains(const QString &path, const SearchOptions &options);
bool filesEqual(const QString &leftPath, const QString &rightPath);
QByteArray sha256(const QString &path);
bool fileContains(const QString &path, const SearchOptions &options,
const std::atomic_bool *cancelled = nullptr);
bool filesEqual(const QString &leftPath, const QString &rightPath,
const std::atomic_bool *cancelled = nullptr);
QByteArray sha256(const QString &path, const std::atomic_bool *cancelled = nullptr);
bool parseOptionalIsoDate(const QString &text, qint64 &seconds);
bool shouldShowDuplicateResult(bool copiesOnly, int keeperPriority);
+29
View File
@@ -33,6 +33,17 @@ private slots:
QVERIFY(!parseOptionalIsoDate(QStringLiteral("2026-not-a-date"), seconds));
}
void selectsDuplicateRowsForDisplay()
{
QVERIFY(!shouldShowDuplicateResult(true, 0));
QVERIFY(!shouldShowDuplicateResult(true, 1));
QVERIFY(shouldShowDuplicateResult(true, 2));
QVERIFY(!shouldShowDuplicateResult(false, 0));
QVERIFY(shouldShowDuplicateResult(false, 1));
QVERIFY(shouldShowDuplicateResult(false, 2));
}
void searchesContentsAndHashes()
{
QTemporaryDir directory;
@@ -79,6 +90,24 @@ private slots:
QVERIFY(!filesEqual(firstPath, differentPath));
QCOMPARE(sha256(firstPath), QCryptographicHash::hash(content, QCryptographicHash::Sha256));
}
void cancelsFileOperations()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString path = directory.filePath(QStringLiteral("file.bin"));
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
QCOMPARE(file.write("needle"), qint64(6));
file.close();
std::atomic_bool cancelled = true;
SearchOptions options;
options.contains = QStringLiteral("needle");
QVERIFY(!fileContains(path, options, &cancelled));
QVERIFY(!filesEqual(path, path, &cancelled));
QVERIFY(sha256(path, &cancelled).isEmpty());
}
};
QTEST_GUILESS_MAIN(SearchCoreTest)