5 Commits

Author SHA1 Message Date
forust 70d728f9c3 chore: release v0.2.4
ci-release / publish-linux-amd64 (push) Successful in 27s
ci-release / verify (push) Successful in 32s
2026-07-25 21:41:32 +02:00
forust e0d7cbb29a fix: harden search and release safeguards
ci-release / verify (push) Successful in 34s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 21:04:54 +02:00
forust 918e081834 chore: release v0.2.3
ci-release / publish-linux-amd64 (push) Successful in 23s
ci-release / verify (push) Successful in 28s
2026-07-25 20:50:53 +02:00
forust d2863997e8 fix(ui): clarify duplicate columns
ci-release / verify (push) Successful in 32s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 20:45:00 +02:00
forust 7e6875ee1c feat: cache searches and add header controls
ci-release / verify (push) Successful in 31s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 20:39:16 +02:00
15 changed files with 1002 additions and 237 deletions
+4
View File
@@ -34,6 +34,10 @@ jobs:
shell: bash shell: bash
run: cmake --build build --parallel run: cmake --build build --parallel
- name: Run tests
shell: bash
run: ctest --test-dir build --output-on-failure
- name: Smoke test GUI - name: Smoke test GUI
shell: bash shell: bash
run: | run: |
+6
View File
@@ -1 +1,7 @@
/build/ /build/
/cmake-build-*/
/out/
/.idea/
/.vscode/
/CMakeLists.txt.user
/CMakeLists.txt.user.*
+33
View File
@@ -5,6 +5,37 @@ All notable changes to FolderScope are documented here.
The project uses [Semantic Versioning](https://semver.org/). Release tags are The project uses [Semantic Versioning](https://semver.org/). Release tags are
formatted as `vMAJOR.MINOR.PATCH`. formatted as `vMAJOR.MINOR.PATCH`.
## [0.2.4] - 2026-07-25
### Fixed
- Corrected result-table context menu positioning.
- Added validation for ISO date filters instead of silently accepting invalid
input.
- Replaced the 2 GB size-filter limit with precise 64-bit inputs.
- Preserved visible table order when acting on multiple selected files.
- Made release preparation reject untracked files.
### Added
- Added automated tests for search primitives, large size inputs, and release
safeguards.
- Added the test suite to the CI verification gate.
## [0.2.3] - 2026-07-25
### Added
- Added a persistent cache for duplicate hashes and file-content matches with
automatic invalidation when files change.
- Added cache controls and a context menu on column headers for per-column and
global automatic width.
### Changed
- Renamed duplicate result columns to `Duplicate Set` and `Keeper Priority`,
with tooltips explaining which copy is preferred.
## [0.2.2] - 2026-07-25 ## [0.2.2] - 2026-07-25
### Fixed ### Fixed
@@ -33,3 +64,5 @@ formatted as `vMAJOR.MINOR.PATCH`.
[0.2.0]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.0 [0.2.0]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.0
[0.2.1]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.1 [0.2.1]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.1
[0.2.2]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.2 [0.2.2]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.2
[0.2.3]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.3
[0.2.4]: https://gitea.forust.xyz/forust/folderscope/releases/tag/v0.2.4
+38 -1
View File
@@ -14,14 +14,51 @@ set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus) find_package(Qt6 REQUIRED COMPONENTS Widgets Concurrent DBus)
add_library(folderscope_core STATIC
src/search_core.cpp
src/search_core.h
)
target_link_libraries(folderscope_core PUBLIC Qt6::Core)
target_compile_options(folderscope_core PRIVATE -Wall -Wextra -Wpedantic)
target_include_directories(folderscope_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src")
qt_add_executable(searchmyfiles qt_add_executable(searchmyfiles
src/long_long_spin_box.cpp
src/long_long_spin_box.h
src/main.cpp src/main.cpp
) )
target_link_libraries(searchmyfiles PRIVATE Qt6::Widgets Qt6::Concurrent Qt6::DBus) target_link_libraries(searchmyfiles PRIVATE folderscope_core Qt6::Widgets Qt6::Concurrent Qt6::DBus)
target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(searchmyfiles PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}") target_compile_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
install(TARGETS searchmyfiles RUNTIME DESTINATION bin) install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
install(FILES packaging/searchmyfiles.desktop install(FILES packaging/searchmyfiles.desktop
DESTINATION share/applications) DESTINATION share/applications)
include(CTest)
if (BUILD_TESTING)
find_package(Qt6 REQUIRED COMPONENTS Test)
qt_add_executable(search_core_tests
tests/search_core_test.cpp
)
target_link_libraries(search_core_tests PRIVATE folderscope_core Qt6::Test)
target_compile_options(search_core_tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME search-core COMMAND search_core_tests)
qt_add_executable(long_long_spin_box_tests
src/long_long_spin_box.cpp
src/long_long_spin_box.h
tests/long_long_spin_box_test.cpp
)
target_link_libraries(long_long_spin_box_tests PRIVATE Qt6::Widgets Qt6::Test)
target_compile_options(long_long_spin_box_tests PRIVATE -Wall -Wextra -Wpedantic)
target_include_directories(long_long_spin_box_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
add_test(NAME long-long-spin-box COMMAND long_long_spin_box_tests)
set_tests_properties(long-long-spin-box PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
add_test(NAME release-rejects-untracked
COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/tests/release_dirty_test.sh"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/release.sh")
endif()
+9 -1
View File
@@ -13,8 +13,10 @@ single window.
- filter by name patterns, size, timestamps, and file attributes; - filter by name patterns, size, timestamps, and file attributes;
- inspect duplicate files and files with matching names; - inspect duplicate files and files with matching names;
- search inside file contents, including text and hex; - search inside file contents, including text and hex;
- reuse a persistent, automatically invalidated cache for content matches and
duplicate hashes;
- show folder summaries and duplicate groups; - show folder summaries and duplicate groups;
- sort, resize, hide, and restore columns; - sort, resize, hide, and restore columns, including per-column header actions;
- export results to CSV, TSV/TXT, HTML, XML, and JSON; - export results to CSV, TSV/TXT, HTML, XML, and JSON;
- open files and folders, copy paths, and use context actions from the results - open files and folders, copy paths, and use context actions from the results
table; table;
@@ -34,6 +36,12 @@ cmake --build build -j
./build/searchmyfiles ./build/searchmyfiles
``` ```
Run the automated tests:
```bash
ctest --test-dir build --output-on-failure
```
Install into the current system: Install into the current system:
```bash ```bash
+1 -1
View File
@@ -1 +1 @@
0.2.2 0.2.4
+1 -1
View File
@@ -9,7 +9,7 @@ fi
version="$1" version="$1"
tag="v${version}" tag="v${version}"
if ! git diff --quiet || ! git diff --cached --quiet; then if [[ -n "$(git status --porcelain)" ]]; then
echo "Refusing to release with uncommitted changes." >&2 echo "Refusing to release with uncommitted changes." >&2
exit 1 exit 1
fi fi
+130
View File
@@ -0,0 +1,130 @@
#include "long_long_spin_box.h"
#include <QLineEdit>
#include <algorithm>
LongLongSpinBox::LongLongSpinBox(QWidget *parent) : QAbstractSpinBox(parent)
{
setValue(0);
connect(this, &QAbstractSpinBox::editingFinished, this, &LongLongSpinBox::commitText);
}
qint64 LongLongSpinBox::value() const
{
qint64 parsed = value_;
return valueFromText(lineEdit()->text(), parsed) ? parsed : value_;
}
void LongLongSpinBox::setValue(qint64 value)
{
value_ = std::clamp(value, minimum_, maximum_);
lineEdit()->setText(textFromValue(value_));
}
void LongLongSpinBox::setRange(qint64 minimum, qint64 maximum)
{
minimum_ = minimum;
maximum_ = std::max(minimum, maximum);
setValue(value_);
}
void LongLongSpinBox::setSingleStep(qint64 step)
{
singleStep_ = std::max<qint64>(1, step);
}
void LongLongSpinBox::setSuffix(const QString &suffix)
{
suffix_ = suffix;
setValue(value());
}
void LongLongSpinBox::setSpecialValueText(const QString &text)
{
specialValueText_ = text;
setValue(value());
}
QValidator::State LongLongSpinBox::validate(QString &input, int &position) const
{
Q_UNUSED(position);
if (!specialValueText_.isEmpty() && input == specialValueText_)
return QValidator::Acceptable;
QString number = input;
if (!suffix_.isEmpty() && number.endsWith(suffix_))
number.chop(suffix_.size());
number = number.trimmed();
if (number.isEmpty() || number == QStringLiteral("-"))
return QValidator::Intermediate;
qint64 parsed = 0;
if (!valueFromText(input, parsed))
return QValidator::Invalid;
return parsed >= minimum_ && parsed <= maximum_
? QValidator::Acceptable : QValidator::Invalid;
}
void LongLongSpinBox::fixup(QString &input) const
{
qint64 parsed = value_;
input = valueFromText(input, parsed)
? textFromValue(std::clamp(parsed, minimum_, maximum_))
: textFromValue(value_);
}
void LongLongSpinBox::stepBy(int steps)
{
commitText();
qint64 next = value_;
if (steps > 0) {
const qint64 count = steps;
next = singleStep_ > (maximum_ - value_) / count
? maximum_ : value_ + singleStep_ * count;
} else if (steps < 0) {
const qint64 count = -qint64(steps);
next = singleStep_ > (value_ - minimum_) / count
? minimum_ : value_ - singleStep_ * count;
}
setValue(next);
}
QAbstractSpinBox::StepEnabled LongLongSpinBox::stepEnabled() const
{
StepEnabled enabled = StepNone;
const qint64 current = value();
if (current > minimum_)
enabled |= StepDownEnabled;
if (current < maximum_)
enabled |= StepUpEnabled;
return enabled;
}
QString LongLongSpinBox::textFromValue(qint64 value) const
{
if (value == minimum_ && !specialValueText_.isEmpty())
return specialValueText_;
return QString::number(value) + suffix_;
}
bool LongLongSpinBox::valueFromText(const QString &text, qint64 &value) const
{
if (!specialValueText_.isEmpty() && text == specialValueText_) {
value = minimum_;
return true;
}
QString number = text;
if (!suffix_.isEmpty() && number.endsWith(suffix_))
number.chop(suffix_.size());
bool ok = false;
const qint64 parsed = number.trimmed().toLongLong(&ok);
if (ok)
value = parsed;
return ok;
}
void LongLongSpinBox::commitText()
{
setValue(value());
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <QAbstractSpinBox>
#include <QValidator>
#include <limits>
class LongLongSpinBox final : public QAbstractSpinBox {
public:
explicit LongLongSpinBox(QWidget *parent = nullptr);
qint64 value() const;
void setValue(qint64 value);
void setRange(qint64 minimum, qint64 maximum);
void setSingleStep(qint64 step);
void setSuffix(const QString &suffix);
void setSpecialValueText(const QString &text);
protected:
QValidator::State validate(QString &input, int &position) const override;
void fixup(QString &input) const override;
void stepBy(int steps) override;
StepEnabled stepEnabled() const override;
private:
QString textFromValue(qint64 value) const;
bool valueFromText(const QString &text, qint64 &value) const;
void commitText();
qint64 minimum_ = 0;
qint64 maximum_ = std::numeric_limits<qint64>::max();
qint64 value_ = 0;
qint64 singleStep_ = 1;
QString suffix_;
QString specialValueText_;
};
+338 -233
View File
@@ -7,6 +7,7 @@
#include <QComboBox> #include <QComboBox>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDataStream>
#include <QDBusConnection> #include <QDBusConnection>
#include <QDBusInterface> #include <QDBusInterface>
#include <QDBusMessage> #include <QDBusMessage>
@@ -33,14 +34,18 @@
#include <QMenuBar> #include <QMenuBar>
#include <QMessageBox> #include <QMessageBox>
#include <QMimeData> #include <QMimeData>
#include <QMutex>
#include <QMutexLocker>
#include <QProgressBar> #include <QProgressBar>
#include <QProcess> #include <QProcess>
#include <QPushButton> #include <QPushButton>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSaveFile>
#include <QSettings> #include <QSettings>
#include <QSortFilterProxyModel> #include <QSortFilterProxyModel>
#include <QSpinBox> #include <QSpinBox>
#include <QStatusBar> #include <QStatusBar>
#include <QStandardPaths>
#include <QTabWidget> #include <QTabWidget>
#include <QTableView> #include <QTableView>
#include <QTextEdit> #include <QTextEdit>
@@ -50,13 +55,15 @@
#include <QUrl> #include <QUrl>
#include <QtConcurrent> #include <QtConcurrent>
#include "long_long_spin_box.h"
#include "search_core.h"
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <functional> #include <functional>
#include <optional> #include <optional>
#include <pwd.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unordered_map> #include <unordered_map>
#include <utility> #include <utility>
@@ -64,218 +71,173 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
struct SearchOptions { struct SearchCacheEntry {
QString roots = QDir::homePath(); qint64 size = -1;
QString fileWildcards = QStringLiteral("*"); qint64 modified = -1;
QString subfolderWildcards = QStringLiteral("*"); qint64 lastUsed = 0;
QString excludeFiles; QByteArray sha256;
QString excludeFolders; QHash<QByteArray, bool> contentMatches;
QString includeFolders;
QString contains;
bool binary = false;
bool multipleValues = false;
bool multipleAnd = false;
bool caseSensitive = false;
qint64 minSize = 0;
qint64 maxSize = 0;
qint64 minTime = 0;
qint64 maxTime = 0;
QString timeField = QStringLiteral("Modified time");
int lastMinutes = 0;
bool recursive = true;
bool findFiles = true;
bool findFolders = false;
bool followLinks = false;
bool retrieveOwner = false;
bool accurateProgress = true;
int maxDepth = 0;
int maxResults = 0;
QString mode = QStringLiteral("Standard search");
QString duplicateNameMode = QStringLiteral("All files and folders");
bool duplicateNameWithoutExtension = false;
bool showOnlyDuplicateFiles = true;
bool includeSubfoldersInSummary = false;
QString hidden = QStringLiteral("Any");
QString readonly = QStringLiteral("Any");
QString executable = QStringLiteral("Any");
}; };
struct FileRecord { class SearchCache {
QString path; public:
QString name; SearchCache()
QString folder; {
qint64 size = 0; const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
qint64 sizeOnDisk = 0; QDir().mkpath(cacheRoot);
qint64 modified = 0; path_ = QDir(cacheRoot).filePath(QStringLiteral("search-cache-v1.dat"));
qint64 created = 0; load();
qint64 accessed = 0;
qint64 changed = 0;
QString type;
QString owner;
QString attributes;
int group = 0;
int duplicateCopy = 0;
};
Q_DECLARE_METATYPE(SearchOptions)
Q_DECLARE_METATYPE(FileRecord)
Q_DECLARE_METATYPE(QVector<FileRecord>)
static QStringList patterns(const QString &text)
{
QStringList out;
QString item;
bool quoted = false;
for (QChar ch : text) {
if (ch == u'"') {
quoted = !quoted;
} else if (!quoted && (ch == u';' || ch == u',')) {
if (!item.trimmed().isEmpty())
out.push_back(item.trimmed());
item.clear();
} else {
item += ch;
}
} }
if (!item.trimmed().isEmpty())
out.push_back(item.trimmed());
return out;
}
static QStringList exclusionPatterns(const QString &text) void setEnabled(bool enabled) { enabled_ = enabled; }
{ bool enabled() const { return enabled_; }
QString normalized = text;
normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";"));
QStringList result = patterns(normalized);
for (QString &pattern : result) {
if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/'))
pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\.")));
}
return result;
}
static bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs) bool findHash(const FileRecord &record, QByteArray &digest)
{ {
for (const QString &pattern : items) { if (!enabled_)
const auto regex = QRegularExpression(
QRegularExpression::wildcardToRegularExpression(pattern),
cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption
: QRegularExpression::NoPatternOption);
if (regex.match(value).hasMatch())
return true;
}
return false;
}
static QString humanSize(qint64 bytes)
{
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
QStringLiteral("MB"), QStringLiteral("GB"),
QStringLiteral("TB")};
double value = static_cast<double>(bytes);
int unit = 0;
while (value >= 1024.0 && unit < units.size() - 1) {
value /= 1024.0;
++unit;
}
return unit == 0 ? QStringLiteral("%1 B").arg(bytes)
: QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]);
}
static QString ownerName(uid_t uid)
{
struct passwd pwd {};
struct passwd *result = nullptr;
QByteArray buffer(16384, Qt::Uninitialized);
if (getpwuid_r(uid, &pwd, buffer.data(), static_cast<size_t>(buffer.size()), &result) == 0 && result)
return QString::fromLocal8Bit(result->pw_name);
return QString::number(uid);
}
static std::optional<QByteArray> hexNeedle(QString text)
{
text.remove(QRegularExpression(QStringLiteral("\\s+")));
if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch())
return std::nullopt;
return QByteArray::fromHex(text.toLatin1());
}
static bool fileContains(const QString &path, const SearchOptions &o)
{
if (o.contains.isEmpty())
return true;
QStringList terms = o.multipleValues ? patterns(o.contains) : QStringList{o.contains};
QList<QByteArray> needles;
for (const QString &term : terms) {
if (o.binary) {
const auto bytes = hexNeedle(term);
if (!bytes)
return false;
needles.push_back(*bytes);
} else {
needles.push_back(term.toUtf8());
}
}
if (needles.isEmpty())
return true;
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return false;
QVector<bool> found(needles.size(), false);
qsizetype overlap = 1;
for (const QByteArray &needle : needles)
overlap = std::max(overlap, needle.size());
QByteArray tail;
while (!file.atEnd()) {
QByteArray data = tail + file.read(1024 * 1024);
if (!o.caseSensitive && !o.binary)
data = data.toLower();
for (qsizetype i = 0; i < needles.size(); ++i) {
QByteArray needle = needles[i];
if (!o.caseSensitive && !o.binary)
needle = needle.toLower();
if (!found[i] && data.contains(needle))
found[i] = true;
}
const bool matched = o.multipleAnd
? std::all_of(found.cbegin(), found.cend(), [](bool x) { return x; })
: std::any_of(found.cbegin(), found.cend(), [](bool x) { return x; });
if (matched)
return true;
tail = data.right(overlap - 1);
}
return false;
}
static bool filesEqual(const QString &leftPath, const QString &rightPath)
{
QFile left(leftPath);
QFile right(rightPath);
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|| left.size() != right.size())
return false;
while (!left.atEnd()) {
const QByteArray a = left.read(1024 * 1024);
const QByteArray b = right.read(a.size());
if (a != b)
return false; return false;
QMutexLocker lock(&mutex_);
auto it = entries_.find(record.path);
if (it == entries_.end() || !signatureMatches(*it, record) || it->sha256.isEmpty())
return false;
it->lastUsed = QDateTime::currentSecsSinceEpoch();
digest = it->sha256;
return true;
} }
return right.atEnd();
}
static QByteArray sha256(const QString &path) void storeHash(const FileRecord &record, const QByteArray &digest)
{ {
QFile file(path); if (!enabled_ || digest.isEmpty())
if (!file.open(QIODevice::ReadOnly)) return;
return {}; QMutexLocker lock(&mutex_);
QCryptographicHash hash(QCryptographicHash::Sha256); SearchCacheEntry &entry = currentEntry(record);
while (!file.atEnd()) entry.sha256 = digest;
hash.addData(file.read(1024 * 1024)); }
return hash.result();
} std::optional<bool> findContent(const FileRecord &record, const QByteArray &queryKey)
{
if (!enabled_)
return std::nullopt;
QMutexLocker lock(&mutex_);
auto it = entries_.find(record.path);
if (it == entries_.end() || !signatureMatches(*it, record))
return std::nullopt;
const auto match = it->contentMatches.constFind(queryKey);
if (match == it->contentMatches.cend())
return std::nullopt;
it->lastUsed = QDateTime::currentSecsSinceEpoch();
return *match;
}
void storeContent(const FileRecord &record, const QByteArray &queryKey, bool matched)
{
if (!enabled_)
return;
QMutexLocker lock(&mutex_);
SearchCacheEntry &entry = currentEntry(record);
entry.contentMatches.insert(queryKey, matched);
}
void save()
{
if (!enabled_)
return;
QMutexLocker lock(&mutex_);
prune();
QSaveFile file(path_);
if (!file.open(QIODevice::WriteOnly))
return;
QDataStream stream(&file);
stream.setVersion(QDataStream::Qt_6_0);
stream << quint32(0x46534348) << quint32(1) << quint32(entries_.size());
for (auto it = entries_.cbegin(); it != entries_.cend(); ++it) {
stream << it.key() << it->size << it->modified << it->lastUsed << it->sha256;
stream << quint32(it->contentMatches.size());
for (auto match = it->contentMatches.cbegin(); match != it->contentMatches.cend(); ++match)
stream << match.key() << match.value();
}
if (stream.status() == QDataStream::Ok)
file.commit();
}
void clear()
{
QMutexLocker lock(&mutex_);
entries_.clear();
QFile::remove(path_);
}
private:
static bool signatureMatches(const SearchCacheEntry &entry, const FileRecord &record)
{
return entry.size == record.size && entry.modified == record.modified;
}
SearchCacheEntry &currentEntry(const FileRecord &record)
{
SearchCacheEntry &entry = entries_[record.path];
if (!signatureMatches(entry, record)) {
entry = {};
entry.size = record.size;
entry.modified = record.modified;
}
entry.lastUsed = QDateTime::currentSecsSinceEpoch();
return entry;
}
void load()
{
QFile file(path_);
if (!file.open(QIODevice::ReadOnly))
return;
QDataStream stream(&file);
stream.setVersion(QDataStream::Qt_6_0);
quint32 magic = 0;
quint32 version = 0;
quint32 count = 0;
stream >> magic >> version >> count;
if (magic != 0x46534348 || version != 1 || count > 200'000)
return;
for (quint32 index = 0; index < count && stream.status() == QDataStream::Ok; ++index) {
QString path;
SearchCacheEntry entry;
quint32 contentCount = 0;
stream >> path >> entry.size >> entry.modified >> entry.lastUsed >> entry.sha256;
stream >> contentCount;
if (contentCount > 256)
return;
for (quint32 contentIndex = 0; contentIndex < contentCount; ++contentIndex) {
QByteArray key;
bool matched = false;
stream >> key >> matched;
entry.contentMatches.insert(key, matched);
}
entries_.insert(path, std::move(entry));
}
if (stream.status() != QDataStream::Ok)
entries_.clear();
}
void prune()
{
constexpr qsizetype maxEntries = 100'000;
if (entries_.size() <= maxEntries)
return;
QVector<QPair<qint64, QString>> ages;
ages.reserve(entries_.size());
for (auto it = entries_.cbegin(); it != entries_.cend(); ++it)
ages.push_back({it->lastUsed, it.key()});
std::sort(ages.begin(), ages.end(),
[](const auto &left, const auto &right) { return left.first > right.first; });
for (qsizetype index = maxEntries; index < ages.size(); ++index)
entries_.remove(ages[index].second);
}
QHash<QString, SearchCacheEntry> entries_;
QMutex mutex_;
QString path_;
bool enabled_ = true;
};
class SearchEngine final : public QObject { class SearchEngine final : public QObject {
Q_OBJECT Q_OBJECT
@@ -284,11 +246,19 @@ public:
public slots: public slots:
void stop() { cancelled_.store(true, std::memory_order_relaxed); } void stop() { cancelled_.store(true, std::memory_order_relaxed); }
void clearCache()
{
cache_.clear();
emit cacheCleared();
}
void search(const SearchOptions &o) void search(const SearchOptions &o)
{ {
cancelled_.store(false, std::memory_order_relaxed); cancelled_.store(false, std::memory_order_relaxed);
cancelledByLimit_ = false; cancelledByLimit_ = false;
cache_.setEnabled(o.useCache);
cacheHits_.store(0, std::memory_order_relaxed);
cacheMisses_.store(0, std::memory_order_relaxed);
const auto started = std::chrono::steady_clock::now(); const auto started = std::chrono::steady_clock::now();
QVector<FileRecord> candidates; QVector<FileRecord> candidates;
const Qt::CaseSensitivity cs = o.caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive; const Qt::CaseSensitivity cs = o.caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive;
@@ -479,7 +449,7 @@ public slots:
const qsizetype total = candidates.size(); const qsizetype total = candidates.size();
QFuture<FileRecord> future = QtConcurrent::filtered( QFuture<FileRecord> future = QtConcurrent::filtered(
candidates, [this, o, &checked, total](const FileRecord &r) { candidates, [this, o, &checked, total](const FileRecord &r) {
const bool matched = fileContains(r.path, o); const bool matched = cachedFileContains(r, o);
const qsizetype done = checked.fetch_add(1, std::memory_order_relaxed) + 1; const qsizetype done = checked.fetch_add(1, std::memory_order_relaxed) + 1;
if (done == total || done % 32 == 0) if (done == total || done % 32 == 0)
emit phaseProgress(tr("Searching file contents"), done, total); emit phaseProgress(tr("Searching file contents"), done, total);
@@ -514,7 +484,7 @@ public slots:
if (sameSize.size() < 2) if (sameSize.size() < 2)
continue; continue;
auto hashes = QtConcurrent::blockingMapped(sameSize, [this, &hashed, hashTotal](const FileRecord &r) { auto hashes = QtConcurrent::blockingMapped(sameSize, [this, &hashed, hashTotal](const FileRecord &r) {
auto result = std::pair<QByteArray, FileRecord>{sha256(r.path), r}; auto result = std::pair<QByteArray, FileRecord>{cachedSha256(r), r};
const quint64 done = hashed.fetch_add(1, std::memory_order_relaxed) + 1; const quint64 done = hashed.fetch_add(1, std::memory_order_relaxed) + 1;
if (done == hashTotal || done % 16 == 0) if (done == hashTotal || done % 16 == 0)
emit phaseProgress(tr("Checking duplicates"), done, hashTotal); emit phaseProgress(tr("Checking duplicates"), done, hashTotal);
@@ -585,10 +555,10 @@ public slots:
if (sameName.size() < 2) if (sameName.size() < 2)
continue; continue;
if (o.duplicateNameMode.contains(QStringLiteral("identical"), Qt::CaseInsensitive)) { if (o.duplicateNameMode.contains(QStringLiteral("identical"), Qt::CaseInsensitive)) {
const QByteArray firstHash = sha256(sameName.front().path); const QByteArray firstHash = cachedSha256(sameName.front());
bool identical = !firstHash.isEmpty(); bool identical = !firstHash.isEmpty();
for (qsizetype i = 1; identical && i < sameName.size(); ++i) { for (qsizetype i = 1; identical && i < sameName.size(); ++i) {
identical = sha256(sameName[i].path) == firstHash identical = cachedSha256(sameName[i]) == firstHash
&& filesEqual(sameName.front().path, sameName[i].path); && filesEqual(sameName.front().path, sameName[i].path);
} }
const bool wantsNonIdentical = o.duplicateNameMode.startsWith(QStringLiteral("Only non")); const bool wantsNonIdentical = o.duplicateNameMode.startsWith(QStringLiteral("Only non"));
@@ -644,16 +614,51 @@ public slots:
const double seconds = std::chrono::duration<double>( const double seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started).count(); std::chrono::steady_clock::now() - started).count();
emit finished(results, tr("%1 results, %2 entries scanned in %3 seconds") cache_.save();
.arg(results.size()).arg(scanned).arg(seconds, 0, 'f', 2)); emit finished(results, tr("%1 results, %2 entries scanned in %3 seconds; cache: %4 hits")
.arg(results.size()).arg(scanned).arg(seconds, 0, 'f', 2)
.arg(cacheHits_.load(std::memory_order_relaxed)));
} }
signals: signals:
void progress(const QString &path, quint64 scanned); void progress(const QString &path, quint64 scanned);
void phaseProgress(const QString &phase, quint64 completed, quint64 total); void phaseProgress(const QString &phase, quint64 completed, quint64 total);
void cacheCleared();
void finished(const QVector<FileRecord> &results, const QString &message); void finished(const QVector<FileRecord> &results, const QString &message);
private: private:
QByteArray cachedSha256(const FileRecord &record)
{
QByteArray digest;
if (cache_.findHash(record, digest)) {
cacheHits_.fetch_add(1, std::memory_order_relaxed);
return digest;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
digest = sha256(record.path);
cache_.storeHash(record, digest);
return digest;
}
bool cachedFileContains(const FileRecord &record, const SearchOptions &o)
{
QByteArray queryData;
QDataStream stream(&queryData, QIODevice::WriteOnly);
stream << o.contains << o.binary << o.multipleValues << o.multipleAnd << o.caseSensitive;
const QByteArray queryKey = QCryptographicHash::hash(queryData, QCryptographicHash::Sha256);
if (const auto cached = cache_.findContent(record, queryKey)) {
cacheHits_.fetch_add(1, std::memory_order_relaxed);
return *cached;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
const bool matched = fileContains(record.path, o);
cache_.storeContent(record, queryKey, matched);
return matched;
}
SearchCache cache_;
std::atomic<quint64> cacheHits_ = 0;
std::atomic<quint64> cacheMisses_ = 0;
std::atomic_bool cancelled_ = false; std::atomic_bool cancelled_ = false;
bool cancelledByLimit_ = false; bool cancelledByLimit_ = false;
}; };
@@ -671,12 +676,21 @@ public:
QVariant headerData(int section, Qt::Orientation orientation, int role) const override QVariant headerData(int section, Qt::Orientation orientation, int role) const override
{ {
if (orientation != Qt::Horizontal || role != Qt::DisplayRole) if (orientation != Qt::Horizontal)
return {};
if (role == Qt::ToolTipRole) {
if (section == DuplicateNumber)
return tr("Identifies one set of identical files");
if (section == DuplicateGroup)
return tr("Order based on the base-folder list; 1 = preferred copy to keep");
return {};
}
if (role != Qt::DisplayRole)
return {}; return {};
static const QStringList names{ static const QStringList names{
tr("Filename"), tr("Folder"), tr("Size"), tr("Size On Disk"), tr("Modified Time"), tr("Filename"), tr("Folder"), tr("Size"), tr("Size On Disk"), tr("Modified Time"),
tr("Created Time"), tr("Last Accessed Time"), tr("Entry Modified Time"), tr("Created Time"), tr("Last Accessed Time"), tr("Entry Modified Time"),
tr("Attributes"), tr("Extension"), tr("Duplicate Number"), tr("Duplicate Group"), tr("Attributes"), tr("Extension"), tr("Duplicate Set"), tr("Keeper Priority"),
tr("Type"), tr("File Owner"), tr("Full Path") tr("Type"), tr("File Owner"), tr("Full Path")
}; };
return names.value(section); return names.value(section);
@@ -829,6 +843,8 @@ public:
retrieveOwner_ = new QCheckBox(tr("Retrieve file owner (slower)")); retrieveOwner_->setChecked(o.retrieveOwner); retrieveOwner_ = new QCheckBox(tr("Retrieve file owner (slower)")); retrieveOwner_->setChecked(o.retrieveOwner);
accurateProgress_ = new QCheckBox(tr("Count files first for accurate progress")); accurateProgress_ = new QCheckBox(tr("Count files first for accurate progress"));
accurateProgress_->setChecked(o.accurateProgress); accurateProgress_->setChecked(o.accurateProgress);
useCache_ = new QCheckBox(tr("Use persistent cache for hashes and content searches"));
useCache_->setChecked(o.useCache);
maxDepth_ = new QSpinBox; maxDepth_->setRange(0, 1024); maxDepth_->setValue(o.maxDepth); maxDepth_ = new QSpinBox; maxDepth_->setRange(0, 1024); maxDepth_->setValue(o.maxDepth);
maxDepth_->setSpecialValueText(tr("Unlimited")); maxDepth_->setSpecialValueText(tr("Unlimited"));
maxResults_ = new QSpinBox; maxResults_->setRange(0, 100'000'000); maxResults_->setValue(o.maxResults); maxResults_ = new QSpinBox; maxResults_->setRange(0, 100'000'000); maxResults_->setValue(o.maxResults);
@@ -840,6 +856,7 @@ public:
filesForm->addRow(followLinks_); filesForm->addRow(followLinks_);
filesForm->addRow(retrieveOwner_); filesForm->addRow(retrieveOwner_);
filesForm->addRow(accurateProgress_); filesForm->addRow(accurateProgress_);
filesForm->addRow(useCache_);
filesForm->addRow(tr("Stop after finding:"), maxResults_); filesForm->addRow(tr("Stop after finding:"), maxResults_);
tabs->addTab(filesPage, tr("Files & folders")); tabs->addTab(filesPage, tr("Files & folders"));
@@ -851,7 +868,7 @@ public:
binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary); binary_ = new QCheckBox(tr("Binary search (hex bytes, e.g. A2 C5 2F)")); binary_->setChecked(o.binary);
multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues); multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues);
multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")}); multipleMode_ = new QComboBox; multipleMode_->addItems({tr("Or"), tr("And")});
multipleMode_->setCurrentText(o.multipleAnd ? tr("And") : tr("Or")); multipleMode_->setCurrentIndex(o.multipleAnd ? 1 : 0);
caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive); caseSensitive_ = new QCheckBox(tr("Case sensitive")); caseSensitive_->setChecked(o.caseSensitive);
contentForm->addRow(binary_); contentForm->addRow(binary_);
contentForm->addRow(multiple_); contentForm->addRow(multiple_);
@@ -890,7 +907,7 @@ public:
tabs->addTab(filterPage, tr("Size, time & attributes")); tabs->addTab(filterPage, tr("Size, time & attributes"));
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &OptionsDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(browse, &QPushButton::clicked, this, [this] { connect(browse, &QPushButton::clicked, this, [this] {
const QStringList existingFolders = patterns(roots_->text()); const QStringList existingFolders = patterns(roots_->text());
@@ -922,20 +939,21 @@ public:
o.contains = contains_->toPlainText(); o.contains = contains_->toPlainText();
o.binary = binary_->isChecked(); o.binary = binary_->isChecked();
o.multipleValues = multiple_->isChecked(); o.multipleValues = multiple_->isChecked();
o.multipleAnd = multipleMode_->currentText() == tr("And"); o.multipleAnd = multipleMode_->currentIndex() == 1;
o.caseSensitive = caseSensitive_->isChecked(); o.caseSensitive = caseSensitive_->isChecked();
o.minSize = minSize_->value(); o.minSize = minSize_->value();
o.maxSize = maxSize_->value(); o.maxSize = maxSize_->value();
o.timeField = timeField_->currentText(); o.timeField = timeField_->currentText();
o.lastMinutes = lastMinutes_->value(); o.lastMinutes = lastMinutes_->value();
o.minTime = QDateTime::fromString(minTime_->text(), Qt::ISODate).toSecsSinceEpoch(); o.minTime = dateValue(minTime_);
o.maxTime = QDateTime::fromString(maxTime_->text(), Qt::ISODate).toSecsSinceEpoch(); o.maxTime = dateValue(maxTime_);
o.recursive = recursive_->isChecked(); o.recursive = recursive_->isChecked();
o.findFiles = findFiles_->isChecked(); o.findFiles = findFiles_->isChecked();
o.findFolders = findFolders_->isChecked(); o.findFolders = findFolders_->isChecked();
o.followLinks = followLinks_->isChecked(); o.followLinks = followLinks_->isChecked();
o.retrieveOwner = retrieveOwner_->isChecked(); o.retrieveOwner = retrieveOwner_->isChecked();
o.accurateProgress = accurateProgress_->isChecked(); o.accurateProgress = accurateProgress_->isChecked();
o.useCache = useCache_->isChecked();
o.maxDepth = maxDepth_->value(); o.maxDepth = maxDepth_->value();
o.maxResults = maxResults_->value(); o.maxResults = maxResults_->value();
o.mode = mode_->currentText(); o.mode = mode_->currentText();
@@ -947,6 +965,15 @@ public:
return o; return o;
} }
protected:
void accept() override
{
if (!validateDate(minTime_, tr("From time"))
|| !validateDate(maxTime_, tr("To time")))
return;
QDialog::accept();
}
private: private:
static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value) static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value)
{ {
@@ -954,14 +981,33 @@ private:
layout->addRow(label, edit); layout->addRow(label, edit);
return edit; return edit;
} }
static QSpinBox *sizeSpin(qint64 value) static LongLongSpinBox *sizeSpin(qint64 value)
{ {
auto *spin = new QSpinBox; auto *spin = new LongLongSpinBox;
spin->setRange(0, 2'000'000'000); spin->setRange(0, std::numeric_limits<qint64>::max());
spin->setSuffix(QObject::tr(" bytes")); spin->setSuffix(QObject::tr(" bytes"));
spin->setValue(static_cast<int>(std::min<qint64>(value, spin->maximum()))); spin->setValue(value);
return spin; return spin;
} }
static qint64 dateValue(const QLineEdit *edit)
{
qint64 seconds = 0;
parseOptionalIsoDate(edit->text(), seconds);
return seconds;
}
bool validateDate(QLineEdit *edit, const QString &label)
{
qint64 seconds = 0;
if (parseOptionalIsoDate(edit->text(), seconds))
return true;
QMessageBox::warning(
this, tr("Invalid date"),
tr("%1 must be a valid ISO date and time, for example:\n2026-07-25T20:30:00")
.arg(label));
edit->setFocus();
edit->selectAll();
return false;
}
static QComboBox *attrCombo(const QString &value) static QComboBox *attrCombo(const QString &value)
{ {
auto *combo = new QComboBox; auto *combo = new QComboBox;
@@ -984,8 +1030,8 @@ private:
QCheckBox *multiple_{}; QCheckBox *multiple_{};
QComboBox *multipleMode_{}; QComboBox *multipleMode_{};
QCheckBox *caseSensitive_{}; QCheckBox *caseSensitive_{};
QSpinBox *minSize_{}; LongLongSpinBox *minSize_{};
QSpinBox *maxSize_{}; LongLongSpinBox *maxSize_{};
QComboBox *timeField_{}; QComboBox *timeField_{};
QSpinBox *lastMinutes_{}; QSpinBox *lastMinutes_{};
QLineEdit *minTime_{}; QLineEdit *minTime_{};
@@ -996,6 +1042,7 @@ private:
QCheckBox *followLinks_{}; QCheckBox *followLinks_{};
QCheckBox *retrieveOwner_{}; QCheckBox *retrieveOwner_{};
QCheckBox *accurateProgress_{}; QCheckBox *accurateProgress_{};
QCheckBox *useCache_{};
QSpinBox *maxDepth_{}; QSpinBox *maxDepth_{};
QSpinBox *maxResults_{}; QSpinBox *maxResults_{};
QComboBox *hidden_{}; QComboBox *hidden_{};
@@ -1023,6 +1070,7 @@ public:
table_->setSortingEnabled(true); table_->setSortingEnabled(true);
table_->setAlternatingRowColors(true); table_->setAlternatingRowColors(true);
table_->setContextMenuPolicy(Qt::CustomContextMenu); table_->setContextMenuPolicy(Qt::CustomContextMenu);
table_->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Name, QHeaderView::ResizeToContents); table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Name, QHeaderView::ResizeToContents);
table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Folder, QHeaderView::Stretch); table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Folder, QHeaderView::Stretch);
setCentralWidget(table_); setCentralWidget(table_);
@@ -1222,6 +1270,11 @@ public:
[this](bool value) { options_.retrieveOwner = value; saveOptions(); }); [this](bool value) { options_.retrieveOwner = value; saveOptions(); });
addToggle(tr("Accurate Progress (Count Files First)"), options_.accurateProgress, addToggle(tr("Accurate Progress (Count Files First)"), options_.accurateProgress,
[this](bool value) { options_.accurateProgress = value; saveOptions(); }); [this](bool value) { options_.accurateProgress = value; saveOptions(); });
addToggle(tr("Use Persistent Search Cache"), options_.useCache,
[this](bool value) { options_.useCache = value; saveOptions(); });
optionsMenu->addAction(tr("Clear Search Cache"), this, [this] {
emit clearCacheRequested();
});
addToggle(tr("Set Focus On Search Start"), focusOnSearchStart_, addToggle(tr("Set Focus On Search Start"), focusOnSearchStart_,
[this](bool value) { focusOnSearchStart_ = value; }); [this](bool value) { focusOnSearchStart_ = value; });
addToggle(tr("Set Focus On Search End"), focusOnSearchEnd_, addToggle(tr("Set Focus On Search End"), focusOnSearchEnd_,
@@ -1249,6 +1302,10 @@ public:
connect(&workerThread_, &QThread::finished, engine_, &QObject::deleteLater); connect(&workerThread_, &QThread::finished, engine_, &QObject::deleteLater);
connect(this, &MainWindow::startRequested, engine_, &SearchEngine::search); connect(this, &MainWindow::startRequested, engine_, &SearchEngine::search);
connect(this, &MainWindow::stopRequested, engine_, &SearchEngine::stop, Qt::DirectConnection); connect(this, &MainWindow::stopRequested, engine_, &SearchEngine::stop, Qt::DirectConnection);
connect(this, &MainWindow::clearCacheRequested, engine_, &SearchEngine::clearCache);
connect(engine_, &SearchEngine::cacheCleared, this, [this] {
statusBar()->showMessage(tr("Search cache cleared"), 5000);
});
connect(engine_, &SearchEngine::progress, this, [this](const QString &path, quint64 count) { connect(engine_, &SearchEngine::progress, this, [this](const QString &path, quint64 count) {
progress_->setRange(0, 0); progress_->setRange(0, 0);
progress_->setFormat(tr("Scanning… %1 entries").arg(count)); progress_->setFormat(tr("Scanning… %1 entries").arg(count));
@@ -1309,6 +1366,8 @@ public:
executeConfiguredAction(doubleClickAction_); executeConfiguredAction(doubleClickAction_);
}); });
connect(table_, &QWidget::customContextMenuRequested, this, &MainWindow::contextMenu); connect(table_, &QWidget::customContextMenuRequested, this, &MainWindow::contextMenu);
connect(table_->horizontalHeader(), &QHeaderView::customContextMenuRequested,
this, &MainWindow::headerContextMenu);
} }
~MainWindow() override ~MainWindow() override
@@ -1339,6 +1398,7 @@ protected:
signals: signals:
void startRequested(const SearchOptions &options); void startRequested(const SearchOptions &options);
void stopRequested(); void stopRequested();
void clearCacheRequested();
private slots: private slots:
void showOptions() void showOptions()
@@ -1621,8 +1681,8 @@ private slots:
addValue(tr("Entry Modified Time:"), date(record->changed)); addValue(tr("Entry Modified Time:"), date(record->changed));
addValue(tr("Attributes:"), record->attributes); addValue(tr("Attributes:"), record->attributes);
addValue(tr("Extension:"), QFileInfo(record->name).suffix()); addValue(tr("Extension:"), QFileInfo(record->name).suffix());
addValue(tr("Duplicate Number:"), record->group ? QString::number(record->group) : QString{}); addValue(tr("Duplicate Set:"), record->group ? QString::number(record->group) : QString{});
addValue(tr("Duplicate Group:"), addValue(tr("Keeper Priority:"),
record->duplicateCopy ? QString::number(record->duplicateCopy) : QString{}); record->duplicateCopy ? QString::number(record->duplicateCopy) : QString{});
addValue(tr("File Owner:"), record->owner); addValue(tr("File Owner:"), record->owner);
layout->addLayout(form); layout->addLayout(form);
@@ -1681,7 +1741,45 @@ private slots:
this, &MainWindow::showProperties); this, &MainWindow::showProperties);
menu.addSeparator(); menu.addSeparator();
menu.addAction(tr("Refresh"), QKeySequence(Qt::Key_F5), this, &MainWindow::refreshSearch); menu.addAction(tr("Refresh"), QKeySequence(Qt::Key_F5), this, &MainWindow::refreshSearch);
menu.exec(table_->viewport()->mapToGlobal(point)); menu.exec(table_->mapToGlobal(point));
}
void headerContextMenu(const QPoint &point)
{
QHeaderView *header = table_->horizontalHeader();
const int clickedColumn = header->logicalIndexAt(point);
QMenu menu(this);
auto *autoWidthColumn = menu.addAction(tr("Auto Width This Column"));
autoWidthColumn->setEnabled(clickedColumn >= 0);
connect(autoWidthColumn, &QAction::triggered, this, [this, clickedColumn] {
table_->horizontalHeader()->setSectionResizeMode(clickedColumn, QHeaderView::Interactive);
table_->resizeColumnToContents(clickedColumn);
saveUiSettings();
});
menu.addAction(tr("Auto Width All Columns"), this, [this] {
table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
table_->resizeColumnsToContents();
saveUiSettings();
});
menu.addSeparator();
auto *hideColumn = menu.addAction(tr("Hide This Column"));
hideColumn->setEnabled(clickedColumn >= 0);
connect(hideColumn, &QAction::triggered, this, [this, clickedColumn] {
table_->setColumnHidden(clickedColumn, true);
saveUiSettings();
});
auto *columns = menu.addMenu(tr("Choose Columns"));
for (int column = 0; column < ResultsModel::ColumnCount; ++column) {
auto *action = columns->addAction(
model_->headerData(column, Qt::Horizontal, Qt::DisplayRole).toString());
action->setCheckable(true);
action->setChecked(!table_->isColumnHidden(column));
connect(action, &QAction::toggled, this, [this, column](bool visible) {
table_->setColumnHidden(column, !visible);
saveUiSettings();
});
}
menu.exec(header->mapToGlobal(point));
} }
void exportResults() void exportResults()
@@ -1722,7 +1820,7 @@ private slots:
const QStringList headers{ const QStringList headers{
tr("Filename"), tr("Folder"), tr("Size"), tr("Size On Disk"), tr("Modified Time"), tr("Filename"), tr("Folder"), tr("Size"), tr("Size On Disk"), tr("Modified Time"),
tr("Created Time"), tr("Last Accessed Time"), tr("Entry Modified Time"), tr("Created Time"), tr("Last Accessed Time"), tr("Entry Modified Time"),
tr("Attributes"), tr("Extension"), tr("Duplicate Number"), tr("Duplicate Group"), tr("Attributes"), tr("Extension"), tr("Duplicate Set"), tr("Keeper Priority"),
tr("Type"), tr("File Owner"), tr("Full Path") tr("Type"), tr("File Owner"), tr("Full Path")
}; };
QTextStream out(&file); QTextStream out(&file);
@@ -1850,11 +1948,16 @@ private:
QStringList selectedPaths() const QStringList selectedPaths() const
{ {
QStringList paths; QStringList paths;
QSet<int> sourceRows; QModelIndexList selected = table_->selectionModel()->selectedRows();
for (const QModelIndex &index : table_->selectionModel()->selectedRows()) std::sort(selected.begin(), selected.end(),
sourceRows.insert(proxy_->mapToSource(index).row()); [](const QModelIndex &left, const QModelIndex &right) {
for (int row : sourceRows) return left.row() < right.row();
paths.push_back(model_->data(model_->index(row, 0), Qt::UserRole).toString()); });
for (const QModelIndex &index : selected) {
const int sourceRow = proxy_->mapToSource(index).row();
paths.push_back(model_->data(
model_->index(sourceRow, ResultsModel::Name), Qt::UserRole).toString());
}
return paths; return paths;
} }
@@ -1882,6 +1985,7 @@ private:
settings.setValue(QStringLiteral("followLinks"), options_.followLinks); settings.setValue(QStringLiteral("followLinks"), options_.followLinks);
settings.setValue(QStringLiteral("retrieveOwner"), options_.retrieveOwner); settings.setValue(QStringLiteral("retrieveOwner"), options_.retrieveOwner);
settings.setValue(QStringLiteral("accurateProgress"), options_.accurateProgress); settings.setValue(QStringLiteral("accurateProgress"), options_.accurateProgress);
settings.setValue(QStringLiteral("useCache"), options_.useCache);
settings.setValue(QStringLiteral("maxDepth"), options_.maxDepth); settings.setValue(QStringLiteral("maxDepth"), options_.maxDepth);
settings.setValue(QStringLiteral("maxResults"), options_.maxResults); settings.setValue(QStringLiteral("maxResults"), options_.maxResults);
settings.setValue(QStringLiteral("mode"), options_.mode); settings.setValue(QStringLiteral("mode"), options_.mode);
@@ -1918,6 +2022,7 @@ private:
options_.followLinks = s.value(QStringLiteral("followLinks"), false).toBool(); options_.followLinks = s.value(QStringLiteral("followLinks"), false).toBool();
options_.retrieveOwner = s.value(QStringLiteral("retrieveOwner"), false).toBool(); options_.retrieveOwner = s.value(QStringLiteral("retrieveOwner"), false).toBool();
options_.accurateProgress = s.value(QStringLiteral("accurateProgress"), true).toBool(); options_.accurateProgress = s.value(QStringLiteral("accurateProgress"), true).toBool();
options_.useCache = s.value(QStringLiteral("useCache"), true).toBool();
options_.maxDepth = s.value(QStringLiteral("maxDepth"), 0).toInt(); options_.maxDepth = s.value(QStringLiteral("maxDepth"), 0).toInt();
options_.maxResults = s.value(QStringLiteral("maxResults"), 0).toInt(); options_.maxResults = s.value(QStringLiteral("maxResults"), 0).toInt();
options_.mode = s.value(QStringLiteral("mode"), options_.mode).toString(); options_.mode = s.value(QStringLiteral("mode"), options_.mode).toString();
+180
View File
@@ -0,0 +1,180 @@
#include "search_core.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QFile>
#include <QRegularExpression>
#include <algorithm>
#include <pwd.h>
QStringList patterns(const QString &text)
{
QStringList out;
QString item;
bool quoted = false;
for (QChar ch : text) {
if (ch == u'"') {
quoted = !quoted;
} else if (!quoted && (ch == u';' || ch == u',')) {
if (!item.trimmed().isEmpty())
out.push_back(item.trimmed());
item.clear();
} else {
item += ch;
}
}
if (!item.trimmed().isEmpty())
out.push_back(item.trimmed());
return out;
}
QStringList exclusionPatterns(const QString &text)
{
QString normalized = text;
normalized.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(";"));
QStringList result = patterns(normalized);
for (QString &pattern : result) {
if (!pattern.contains(u'*') && !pattern.contains(u'?') && !pattern.contains(u'/'))
pattern = QStringLiteral("*.") + pattern.remove(QRegularExpression(QStringLiteral("^\\.")));
}
return result;
}
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs)
{
for (const QString &pattern : items) {
const auto regex = QRegularExpression(
QRegularExpression::wildcardToRegularExpression(pattern),
cs == Qt::CaseInsensitive ? QRegularExpression::CaseInsensitiveOption
: QRegularExpression::NoPatternOption);
if (regex.match(value).hasMatch())
return true;
}
return false;
}
QString humanSize(qint64 bytes)
{
static const QStringList units{QStringLiteral("B"), QStringLiteral("KB"),
QStringLiteral("MB"), QStringLiteral("GB"),
QStringLiteral("TB")};
double value = static_cast<double>(bytes);
int unit = 0;
while (value >= 1024.0 && unit < units.size() - 1) {
value /= 1024.0;
++unit;
}
return unit == 0 ? QStringLiteral("%1 B").arg(bytes)
: QStringLiteral("%1 %2").arg(value, 0, 'f', 2).arg(units[unit]);
}
QString ownerName(uid_t uid)
{
struct passwd pwd {};
struct passwd *result = nullptr;
QByteArray buffer(16384, Qt::Uninitialized);
if (getpwuid_r(uid, &pwd, buffer.data(), static_cast<size_t>(buffer.size()), &result) == 0 && result)
return QString::fromLocal8Bit(result->pw_name);
return QString::number(uid);
}
std::optional<QByteArray> hexNeedle(QString text)
{
text.remove(QRegularExpression(QStringLiteral("\\s+")));
if (text.size() % 2 != 0 || !QRegularExpression(QStringLiteral("^[0-9a-fA-F]*$")).match(text).hasMatch())
return std::nullopt;
return QByteArray::fromHex(text.toLatin1());
}
bool fileContains(const QString &path, const SearchOptions &options)
{
if (options.contains.isEmpty())
return true;
const QStringList terms = options.multipleValues
? patterns(options.contains) : QStringList{options.contains};
QList<QByteArray> needles;
for (const QString &term : terms) {
if (options.binary) {
const auto bytes = hexNeedle(term);
if (!bytes)
return false;
needles.push_back(*bytes);
} else {
needles.push_back(term.toUtf8());
}
}
if (needles.isEmpty())
return true;
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return false;
QVector<bool> found(needles.size(), false);
qsizetype overlap = 1;
for (const QByteArray &needle : needles)
overlap = std::max(overlap, needle.size());
QByteArray tail;
while (!file.atEnd()) {
QByteArray data = tail + file.read(1024 * 1024);
if (!options.caseSensitive && !options.binary)
data = data.toLower();
for (qsizetype i = 0; i < needles.size(); ++i) {
QByteArray needle = needles[i];
if (!options.caseSensitive && !options.binary)
needle = needle.toLower();
if (!found[i] && data.contains(needle))
found[i] = true;
}
const bool matched = options.multipleAnd
? std::all_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; })
: std::any_of(found.cbegin(), found.cend(), [](bool foundTerm) { return foundTerm; });
if (matched)
return true;
tail = data.right(overlap - 1);
}
return false;
}
bool filesEqual(const QString &leftPath, const QString &rightPath)
{
QFile left(leftPath);
QFile right(rightPath);
if (!left.open(QIODevice::ReadOnly) || !right.open(QIODevice::ReadOnly)
|| left.size() != right.size())
return false;
while (!left.atEnd()) {
const QByteArray leftData = left.read(1024 * 1024);
const QByteArray rightData = right.read(leftData.size());
if (leftData != rightData)
return false;
}
return right.atEnd();
}
QByteArray sha256(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd())
hash.addData(file.read(1024 * 1024));
return hash.result();
}
bool parseOptionalIsoDate(const QString &text, qint64 &seconds)
{
const QString trimmed = text.trimmed();
if (trimmed.isEmpty()) {
seconds = 0;
return true;
}
const QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODate);
if (!parsed.isValid())
return false;
seconds = parsed.toSecsSinceEpoch();
return true;
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <QByteArray>
#include <QDir>
#include <QMetaType>
#include <QString>
#include <QStringList>
#include <QVector>
#include <optional>
#include <sys/types.h>
struct SearchOptions {
QString roots = QDir::homePath();
QString fileWildcards = QStringLiteral("*");
QString subfolderWildcards = QStringLiteral("*");
QString excludeFiles;
QString excludeFolders;
QString includeFolders;
QString contains;
bool binary = false;
bool multipleValues = false;
bool multipleAnd = false;
bool caseSensitive = false;
qint64 minSize = 0;
qint64 maxSize = 0;
qint64 minTime = 0;
qint64 maxTime = 0;
QString timeField = QStringLiteral("Modified time");
int lastMinutes = 0;
bool recursive = true;
bool findFiles = true;
bool findFolders = false;
bool followLinks = false;
bool retrieveOwner = false;
bool accurateProgress = true;
bool useCache = true;
int maxDepth = 0;
int maxResults = 0;
QString mode = QStringLiteral("Standard search");
QString duplicateNameMode = QStringLiteral("All files and folders");
bool duplicateNameWithoutExtension = false;
bool showOnlyDuplicateFiles = true;
bool includeSubfoldersInSummary = false;
QString hidden = QStringLiteral("Any");
QString readonly = QStringLiteral("Any");
QString executable = QStringLiteral("Any");
};
struct FileRecord {
QString path;
QString name;
QString folder;
qint64 size = 0;
qint64 sizeOnDisk = 0;
qint64 modified = 0;
qint64 created = 0;
qint64 accessed = 0;
qint64 changed = 0;
QString type;
QString owner;
QString attributes;
int group = 0;
int duplicateCopy = 0;
};
Q_DECLARE_METATYPE(SearchOptions)
Q_DECLARE_METATYPE(FileRecord)
Q_DECLARE_METATYPE(QVector<FileRecord>)
QStringList patterns(const QString &text);
QStringList exclusionPatterns(const QString &text);
bool wildcardMatch(const QString &value, const QStringList &items, Qt::CaseSensitivity cs);
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 parseOptionalIsoDate(const QString &text, qint64 &seconds);
+40
View File
@@ -0,0 +1,40 @@
#include "long_long_spin_box.h"
#include <QLineEdit>
#include <QtTest>
class LongLongSpinBoxTest final : public QObject {
Q_OBJECT
private slots:
void preservesValuesAboveTwoGigabytes()
{
LongLongSpinBox spin;
spin.setRange(0, std::numeric_limits<qint64>::max());
spin.setSuffix(QStringLiteral(" bytes"));
spin.setValue(5'000'000'000);
QCOMPARE(spin.value(), qint64(5'000'000'000));
QCOMPARE(spin.findChild<QLineEdit *>()->text(), QStringLiteral("5000000000 bytes"));
spin.stepUp();
QCOMPARE(spin.value(), qint64(5'000'000'001));
spin.findChild<QLineEdit *>()->setText(QStringLiteral("6000000000 bytes"));
QVERIFY(QMetaObject::invokeMethod(&spin, "editingFinished", Qt::DirectConnection));
QCOMPARE(spin.value(), qint64(6'000'000'000));
}
void clampsAtRangeEdges()
{
LongLongSpinBox spin;
spin.setRange(0, 10);
spin.setValue(10);
spin.stepUp();
QCOMPARE(spin.value(), qint64(10));
spin.setValue(-50);
QCOMPARE(spin.value(), qint64(0));
}
};
QTEST_MAIN(LongLongSpinBoxTest)
#include "long_long_spin_box_test.moc"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
release_script="$(realpath "$1")"
test_directory="$(mktemp -d)"
trap 'rm -rf "$test_directory"' EXIT
git -C "$test_directory" init -q
git -C "$test_directory" config user.name "FolderScope Tests"
git -C "$test_directory" config user.email "tests@folderscope.invalid"
printf '9.9.9\n' > "$test_directory/VERSION"
printf '# Changelog\n\n## [9.9.9]\n' > "$test_directory/CHANGELOG.md"
git -C "$test_directory" add VERSION CHANGELOG.md
git -C "$test_directory" commit -q -m "test fixture"
touch "$test_directory/untracked"
if (cd "$test_directory" && "$release_script" 9.9.9 >/dev/null 2>&1); then
echo "release.sh accepted a repository with untracked files" >&2
exit 1
fi
+86
View File
@@ -0,0 +1,86 @@
#include "search_core.h"
#include <QCryptographicHash>
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
class SearchCoreTest final : public QObject {
Q_OBJECT
private slots:
void parsesPatternsAndExclusions()
{
QCOMPARE(patterns(QStringLiteral("/a; \"/b,with-comma\" , /c")),
QStringList({QStringLiteral("/a"), QStringLiteral("/b,with-comma"),
QStringLiteral("/c")}));
QCOMPARE(exclusionPatterns(QStringLiteral("tmp log *.bak")),
QStringList({QStringLiteral("*.tmp"), QStringLiteral("*.log"),
QStringLiteral("*.bak")}));
QVERIFY(wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
Qt::CaseInsensitive));
QVERIFY(!wildcardMatch(QStringLiteral("Report.TXT"), {QStringLiteral("*.txt")},
Qt::CaseSensitive));
}
void parsesOptionalIsoDates()
{
qint64 seconds = -1;
QVERIFY(parseOptionalIsoDate(QString{}, seconds));
QCOMPARE(seconds, 0);
QVERIFY(parseOptionalIsoDate(QStringLiteral("2026-07-25T20:30:00"), seconds));
QVERIFY(seconds > 0);
QVERIFY(!parseOptionalIsoDate(QStringLiteral("2026-not-a-date"), seconds));
}
void searchesContentsAndHashes()
{
QTemporaryDir directory;
QVERIFY(directory.isValid());
const QString firstPath = directory.filePath(QStringLiteral("first.bin"));
const QString secondPath = directory.filePath(QStringLiteral("second.bin"));
const QString differentPath = directory.filePath(QStringLiteral("different.bin"));
const QByteArray content = QByteArray(1024 * 1024 - 2, 'x')
+ QByteArray("Alpha needle BETA");
QFile first(firstPath);
QVERIFY(first.open(QIODevice::WriteOnly));
QCOMPARE(first.write(content), content.size());
first.close();
QFile second(secondPath);
QVERIFY(second.open(QIODevice::WriteOnly));
QCOMPARE(second.write(content), content.size());
second.close();
QFile different(differentPath);
QVERIFY(different.open(QIODevice::WriteOnly));
QCOMPARE(different.write("different"), qint64(9));
different.close();
SearchOptions options;
options.contains = QStringLiteral("needle");
QVERIFY(fileContains(firstPath, options));
options.contains = QStringLiteral("alpha,beta");
options.multipleValues = true;
options.multipleAnd = true;
QVERIFY(fileContains(firstPath, options));
options.caseSensitive = true;
QVERIFY(!fileContains(firstPath, options));
options.binary = true;
options.multipleValues = false;
options.multipleAnd = false;
options.contains = QStringLiteral("41 6c 70 68 61");
QVERIFY(fileContains(firstPath, options));
options.contains = QStringLiteral("ABC");
QVERIFY(!fileContains(firstPath, options));
QVERIFY(filesEqual(firstPath, secondPath));
QVERIFY(!filesEqual(firstPath, differentPath));
QCOMPARE(sha256(firstPath), QCryptographicHash::hash(content, QCryptographicHash::Sha256));
}
};
QTEST_GUILESS_MAIN(SearchCoreTest)
#include "search_core_test.moc"