9 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
forust 168c5ad3ac chore: release v0.2.2
ci-release / publish-linux-amd64 (push) Successful in 23s
ci-release / verify (push) Successful in 28s
2026-07-25 19:52:39 +02:00
forust 5726ff3f62 docs: prepare v0.2.2 release notes 2026-07-25 19:52:39 +02:00
forust 0b93c8c124 fix(ci): lint workflow sources only
ci-release / verify (push) Successful in 30s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 19:51:00 +02:00
forust 4cb6fdf540 ci: combine verification and release workflow
ci-release / verify (push) Failing after 34s
ci-release / publish-linux-amd64 (push) Has been skipped
2026-07-25 19:50:29 +02:00
16 changed files with 1023 additions and 291 deletions
@@ -1,9 +1,12 @@
name: release
name: ci-release
"on":
push:
branches:
- main
tags:
- "v[0-9]*.[0-9]*.[0-9]*"
pull_request:
workflow_dispatch:
permissions:
@@ -16,7 +19,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify tag matches VERSION
- name: Verify release tag matches VERSION
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
tag="${GITHUB_REF##*/}"
@@ -30,6 +34,10 @@ jobs:
shell: bash
run: cmake --build build --parallel
- name: Run tests
shell: bash
run: ctest --test-dir build --output-on-failure
- name: Smoke test GUI
shell: bash
run: |
@@ -38,6 +46,10 @@ jobs:
status="$?"
test "$status" = 0 || test "$status" = 124
- name: Verify Linux binary
shell: bash
run: test -x build/searchmyfiles
- name: Lint YAML
shell: bash
run: |
@@ -45,9 +57,10 @@ jobs:
-v "$PWD:/work" \
-w /work \
cytopia/yamllint:latest \
-c .yamllint .
-c .yamllint .gitea
linux-amd64:
publish-linux-amd64:
if: startsWith(github.ref, 'refs/tags/v')
needs: verify
runs-on: [self-hosted, linux, arch, homelab]
env:
-50
View File
@@ -1,50 +0,0 @@
name: ci
"on":
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
build-linux-amd64:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure release build
shell: bash
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
- name: Build
shell: bash
run: cmake --build build --parallel
- name: Smoke test GUI
shell: bash
run: |
set +e
QT_QPA_PLATFORM=offscreen timeout 3s ./build/searchmyfiles
status="$?"
test "$status" = 0 || test "$status" = 124
- name: Verify Linux binary
shell: bash
run: test -x build/searchmyfiles
lint-yaml:
runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Lint workflow files
shell: bash
run: |
docker run --rm \
-v "$PWD:/work" \
-w /work \
cytopia/yamllint:latest \
-c .yamllint .
+6
View File
@@ -1 +1,7 @@
/build/
/cmake-build-*/
/out/
/.idea/
/.vscode/
/CMakeLists.txt.user
/CMakeLists.txt.user.*
+41
View File
@@ -5,6 +5,44 @@ All notable changes to FolderScope are documented here.
The project uses [Semantic Versioning](https://semver.org/). Release tags are
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
### Fixed
- Scoped workflow YAML linting to the `.gitea` source directory.
- Combined CI and tag-gated release publishing into one workflow.
## [0.2.1] - 2026-07-25
### Fixed
@@ -25,3 +63,6 @@ formatted as `vMAJOR.MINOR.PATCH`.
[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.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)
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
src/long_long_spin_box.cpp
src/long_long_spin_box.h
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_definitions(searchmyfiles PRIVATE FOLDERSCOPE_VERSION="${PROJECT_VERSION}")
install(TARGETS searchmyfiles RUNTIME DESTINATION bin)
install(FILES packaging/searchmyfiles.desktop
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;
- inspect duplicate files and files with matching names;
- 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;
- 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;
- open files and folders, copy paths, and use context actions from the results
table;
@@ -34,6 +36,12 @@ cmake --build build -j
./build/searchmyfiles
```
Run the automated tests:
```bash
ctest --test-dir build --output-on-failure
```
Install into the current system:
```bash
+1 -1
View File
@@ -1 +1 @@
0.2.1
0.2.4
+1 -1
View File
@@ -9,7 +9,7 @@ fi
version="$1"
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
exit 1
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 <QCryptographicHash>
#include <QDateTime>
#include <QDataStream>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusMessage>
@@ -33,14 +34,18 @@
#include <QMenuBar>
#include <QMessageBox>
#include <QMimeData>
#include <QMutex>
#include <QMutexLocker>
#include <QProgressBar>
#include <QProcess>
#include <QPushButton>
#include <QRegularExpression>
#include <QSaveFile>
#include <QSettings>
#include <QSortFilterProxyModel>
#include <QSpinBox>
#include <QStatusBar>
#include <QStandardPaths>
#include <QTabWidget>
#include <QTableView>
#include <QTextEdit>
@@ -50,13 +55,15 @@
#include <QUrl>
#include <QtConcurrent>
#include "long_long_spin_box.h"
#include "search_core.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <functional>
#include <optional>
#include <pwd.h>
#include <sys/stat.h>
#include <unordered_map>
#include <utility>
@@ -64,218 +71,173 @@
namespace fs = std::filesystem;
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;
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 SearchCacheEntry {
qint64 size = -1;
qint64 modified = -1;
qint64 lastUsed = 0;
QByteArray sha256;
QHash<QByteArray, bool> contentMatches;
};
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>)
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;
}
class SearchCache {
public:
SearchCache()
{
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
QDir().mkpath(cacheRoot);
path_ = QDir(cacheRoot).filePath(QStringLiteral("search-cache-v1.dat"));
load();
}
if (!item.trimmed().isEmpty())
out.push_back(item.trimmed());
return out;
}
static 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;
}
void setEnabled(bool enabled) { enabled_ = enabled; }
bool enabled() const { return enabled_; }
static 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;
}
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)
bool findHash(const FileRecord &record, QByteArray &digest)
{
if (!enabled_)
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)
{
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();
}
void storeHash(const FileRecord &record, const QByteArray &digest)
{
if (!enabled_ || digest.isEmpty())
return;
QMutexLocker lock(&mutex_);
SearchCacheEntry &entry = currentEntry(record);
entry.sha256 = digest;
}
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 {
Q_OBJECT
@@ -284,11 +246,19 @@ public:
public slots:
void stop() { cancelled_.store(true, std::memory_order_relaxed); }
void clearCache()
{
cache_.clear();
emit cacheCleared();
}
void search(const SearchOptions &o)
{
cancelled_.store(false, std::memory_order_relaxed);
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();
QVector<FileRecord> candidates;
const Qt::CaseSensitivity cs = o.caseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive;
@@ -479,7 +449,7 @@ public slots:
const qsizetype total = candidates.size();
QFuture<FileRecord> future = QtConcurrent::filtered(
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;
if (done == total || done % 32 == 0)
emit phaseProgress(tr("Searching file contents"), done, total);
@@ -514,7 +484,7 @@ public slots:
if (sameSize.size() < 2)
continue;
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;
if (done == hashTotal || done % 16 == 0)
emit phaseProgress(tr("Checking duplicates"), done, hashTotal);
@@ -585,10 +555,10 @@ public slots:
if (sameName.size() < 2)
continue;
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();
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);
}
const bool wantsNonIdentical = o.duplicateNameMode.startsWith(QStringLiteral("Only non"));
@@ -644,16 +614,51 @@ public slots:
const double seconds = std::chrono::duration<double>(
std::chrono::steady_clock::now() - started).count();
emit finished(results, tr("%1 results, %2 entries scanned in %3 seconds")
.arg(results.size()).arg(scanned).arg(seconds, 0, 'f', 2));
cache_.save();
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:
void progress(const QString &path, quint64 scanned);
void phaseProgress(const QString &phase, quint64 completed, quint64 total);
void cacheCleared();
void finished(const QVector<FileRecord> &results, const QString &message);
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;
bool cancelledByLimit_ = false;
};
@@ -671,12 +676,21 @@ public:
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 {};
static const QStringList names{
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("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")
};
return names.value(section);
@@ -829,6 +843,8 @@ public:
retrieveOwner_ = new QCheckBox(tr("Retrieve file owner (slower)")); retrieveOwner_->setChecked(o.retrieveOwner);
accurateProgress_ = new QCheckBox(tr("Count files first for accurate progress"));
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_->setSpecialValueText(tr("Unlimited"));
maxResults_ = new QSpinBox; maxResults_->setRange(0, 100'000'000); maxResults_->setValue(o.maxResults);
@@ -840,6 +856,7 @@ public:
filesForm->addRow(followLinks_);
filesForm->addRow(retrieveOwner_);
filesForm->addRow(accurateProgress_);
filesForm->addRow(useCache_);
filesForm->addRow(tr("Stop after finding:"), maxResults_);
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);
multiple_ = new QCheckBox(tr("Search multiple values (comma separated)")); multiple_->setChecked(o.multipleValues);
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);
contentForm->addRow(binary_);
contentForm->addRow(multiple_);
@@ -890,7 +907,7 @@ public:
tabs->addTab(filterPage, tr("Size, time & attributes"));
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(browse, &QPushButton::clicked, this, [this] {
const QStringList existingFolders = patterns(roots_->text());
@@ -922,20 +939,21 @@ public:
o.contains = contains_->toPlainText();
o.binary = binary_->isChecked();
o.multipleValues = multiple_->isChecked();
o.multipleAnd = multipleMode_->currentText() == tr("And");
o.multipleAnd = multipleMode_->currentIndex() == 1;
o.caseSensitive = caseSensitive_->isChecked();
o.minSize = minSize_->value();
o.maxSize = maxSize_->value();
o.timeField = timeField_->currentText();
o.lastMinutes = lastMinutes_->value();
o.minTime = QDateTime::fromString(minTime_->text(), Qt::ISODate).toSecsSinceEpoch();
o.maxTime = QDateTime::fromString(maxTime_->text(), Qt::ISODate).toSecsSinceEpoch();
o.minTime = dateValue(minTime_);
o.maxTime = dateValue(maxTime_);
o.recursive = recursive_->isChecked();
o.findFiles = findFiles_->isChecked();
o.findFolders = findFolders_->isChecked();
o.followLinks = followLinks_->isChecked();
o.retrieveOwner = retrieveOwner_->isChecked();
o.accurateProgress = accurateProgress_->isChecked();
o.useCache = useCache_->isChecked();
o.maxDepth = maxDepth_->value();
o.maxResults = maxResults_->value();
o.mode = mode_->currentText();
@@ -947,6 +965,15 @@ public:
return o;
}
protected:
void accept() override
{
if (!validateDate(minTime_, tr("From time"))
|| !validateDate(maxTime_, tr("To time")))
return;
QDialog::accept();
}
private:
static QLineEdit *addLine(QFormLayout *layout, const QString &label, const QString &value)
{
@@ -954,14 +981,33 @@ private:
layout->addRow(label, edit);
return edit;
}
static QSpinBox *sizeSpin(qint64 value)
static LongLongSpinBox *sizeSpin(qint64 value)
{
auto *spin = new QSpinBox;
spin->setRange(0, 2'000'000'000);
auto *spin = new LongLongSpinBox;
spin->setRange(0, std::numeric_limits<qint64>::max());
spin->setSuffix(QObject::tr(" bytes"));
spin->setValue(static_cast<int>(std::min<qint64>(value, spin->maximum())));
spin->setValue(value);
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)
{
auto *combo = new QComboBox;
@@ -984,8 +1030,8 @@ private:
QCheckBox *multiple_{};
QComboBox *multipleMode_{};
QCheckBox *caseSensitive_{};
QSpinBox *minSize_{};
QSpinBox *maxSize_{};
LongLongSpinBox *minSize_{};
LongLongSpinBox *maxSize_{};
QComboBox *timeField_{};
QSpinBox *lastMinutes_{};
QLineEdit *minTime_{};
@@ -996,6 +1042,7 @@ private:
QCheckBox *followLinks_{};
QCheckBox *retrieveOwner_{};
QCheckBox *accurateProgress_{};
QCheckBox *useCache_{};
QSpinBox *maxDepth_{};
QSpinBox *maxResults_{};
QComboBox *hidden_{};
@@ -1023,6 +1070,7 @@ public:
table_->setSortingEnabled(true);
table_->setAlternatingRowColors(true);
table_->setContextMenuPolicy(Qt::CustomContextMenu);
table_->horizontalHeader()->setContextMenuPolicy(Qt::CustomContextMenu);
table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Name, QHeaderView::ResizeToContents);
table_->horizontalHeader()->setSectionResizeMode(ResultsModel::Folder, QHeaderView::Stretch);
setCentralWidget(table_);
@@ -1222,6 +1270,11 @@ public:
[this](bool value) { options_.retrieveOwner = value; saveOptions(); });
addToggle(tr("Accurate Progress (Count Files First)"), options_.accurateProgress,
[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_,
[this](bool value) { focusOnSearchStart_ = value; });
addToggle(tr("Set Focus On Search End"), focusOnSearchEnd_,
@@ -1249,6 +1302,10 @@ public:
connect(&workerThread_, &QThread::finished, engine_, &QObject::deleteLater);
connect(this, &MainWindow::startRequested, engine_, &SearchEngine::search);
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) {
progress_->setRange(0, 0);
progress_->setFormat(tr("Scanning… %1 entries").arg(count));
@@ -1309,6 +1366,8 @@ public:
executeConfiguredAction(doubleClickAction_);
});
connect(table_, &QWidget::customContextMenuRequested, this, &MainWindow::contextMenu);
connect(table_->horizontalHeader(), &QHeaderView::customContextMenuRequested,
this, &MainWindow::headerContextMenu);
}
~MainWindow() override
@@ -1339,6 +1398,7 @@ protected:
signals:
void startRequested(const SearchOptions &options);
void stopRequested();
void clearCacheRequested();
private slots:
void showOptions()
@@ -1621,8 +1681,8 @@ private slots:
addValue(tr("Entry Modified Time:"), date(record->changed));
addValue(tr("Attributes:"), record->attributes);
addValue(tr("Extension:"), QFileInfo(record->name).suffix());
addValue(tr("Duplicate Number:"), record->group ? QString::number(record->group) : QString{});
addValue(tr("Duplicate Group:"),
addValue(tr("Duplicate Set:"), record->group ? QString::number(record->group) : QString{});
addValue(tr("Keeper Priority:"),
record->duplicateCopy ? QString::number(record->duplicateCopy) : QString{});
addValue(tr("File Owner:"), record->owner);
layout->addLayout(form);
@@ -1681,7 +1741,45 @@ private slots:
this, &MainWindow::showProperties);
menu.addSeparator();
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()
@@ -1722,7 +1820,7 @@ private slots:
const QStringList headers{
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("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")
};
QTextStream out(&file);
@@ -1850,11 +1948,16 @@ private:
QStringList selectedPaths() const
{
QStringList paths;
QSet<int> sourceRows;
for (const QModelIndex &index : table_->selectionModel()->selectedRows())
sourceRows.insert(proxy_->mapToSource(index).row());
for (int row : sourceRows)
paths.push_back(model_->data(model_->index(row, 0), Qt::UserRole).toString());
QModelIndexList selected = table_->selectionModel()->selectedRows();
std::sort(selected.begin(), selected.end(),
[](const QModelIndex &left, const QModelIndex &right) {
return left.row() < right.row();
});
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;
}
@@ -1882,6 +1985,7 @@ private:
settings.setValue(QStringLiteral("followLinks"), options_.followLinks);
settings.setValue(QStringLiteral("retrieveOwner"), options_.retrieveOwner);
settings.setValue(QStringLiteral("accurateProgress"), options_.accurateProgress);
settings.setValue(QStringLiteral("useCache"), options_.useCache);
settings.setValue(QStringLiteral("maxDepth"), options_.maxDepth);
settings.setValue(QStringLiteral("maxResults"), options_.maxResults);
settings.setValue(QStringLiteral("mode"), options_.mode);
@@ -1918,6 +2022,7 @@ private:
options_.followLinks = s.value(QStringLiteral("followLinks"), false).toBool();
options_.retrieveOwner = s.value(QStringLiteral("retrieveOwner"), false).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_.maxResults = s.value(QStringLiteral("maxResults"), 0).toInt();
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"