9 Commits

Author SHA1 Message Date
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
forust d2e3106632 chore: release v0.2.1
ci / build-linux-amd64 (push) Successful in 22s
release / linux-amd64 (push) Has been skipped
ci / lint-yaml (push) Successful in 7s
release / verify (push) Failing after 28s
2026-07-25 19:49:01 +02:00
forust 26c5af792e fix(ci): remove incompatible artifact upload 2026-07-25 19:48:54 +02:00
6 changed files with 344 additions and 75 deletions
@@ -1,9 +1,12 @@
name: release name: ci-release
"on": "on":
push: push:
branches:
- main
tags: tags:
- "v[0-9]*.[0-9]*.[0-9]*" - "v[0-9]*.[0-9]*.[0-9]*"
pull_request:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -16,7 +19,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Verify tag matches VERSION - name: Verify release tag matches VERSION
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
run: | run: |
tag="${GITHUB_REF##*/}" tag="${GITHUB_REF##*/}"
@@ -38,6 +42,10 @@ jobs:
status="$?" status="$?"
test "$status" = 0 || test "$status" = 124 test "$status" = 0 || test "$status" = 124
- name: Verify Linux binary
shell: bash
run: test -x build/searchmyfiles
- name: Lint YAML - name: Lint YAML
shell: bash shell: bash
run: | run: |
@@ -45,9 +53,10 @@ jobs:
-v "$PWD:/work" \ -v "$PWD:/work" \
-w /work \ -w /work \
cytopia/yamllint:latest \ cytopia/yamllint:latest \
-c .yamllint . -c .yamllint .gitea
linux-amd64: publish-linux-amd64:
if: startsWith(github.ref, 'refs/tags/v')
needs: verify needs: verify
runs-on: [self-hosted, linux, arch, homelab] runs-on: [self-hosted, linux, arch, homelab]
env: env:
@@ -131,9 +140,3 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset}" -F "attachment=@${asset}"
done done
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: folderscope-linux-amd64
path: dist/*
-52
View File
@@ -1,52 +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: Upload Linux binary
uses: actions/upload-artifact@v4
with:
name: folderscope-linux-amd64
path: 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 .
+32
View File
@@ -5,6 +5,35 @@ 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.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
- Removed the incompatible workflow-artifact upload step from Gitea Actions.
- Kept the release gate fail-closed: version validation, build, GUI smoke test,
and YAML lint must all pass before Gitea release assets are published.
## [0.2.0] - 2026-07-25 ## [0.2.0] - 2026-07-25
### Added ### Added
@@ -16,3 +45,6 @@ formatted as `vMAJOR.MINOR.PATCH`.
reporting. reporting.
[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.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
+3 -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;
+1 -1
View File
@@ -1 +1 @@
0.2.0 0.2.3
+295 -11
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>
@@ -88,6 +93,7 @@ struct SearchOptions {
bool followLinks = false; bool followLinks = false;
bool retrieveOwner = false; bool retrieveOwner = false;
bool accurateProgress = true; bool accurateProgress = true;
bool useCache = true;
int maxDepth = 0; int maxDepth = 0;
int maxResults = 0; int maxResults = 0;
QString mode = QStringLiteral("Standard search"); QString mode = QStringLiteral("Standard search");
@@ -277,6 +283,174 @@ static QByteArray sha256(const QString &path)
return hash.result(); return hash.result();
} }
struct SearchCacheEntry {
qint64 size = -1;
qint64 modified = -1;
qint64 lastUsed = 0;
QByteArray sha256;
QHash<QByteArray, bool> contentMatches;
};
class SearchCache {
public:
SearchCache()
{
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
QDir().mkpath(cacheRoot);
path_ = QDir(cacheRoot).filePath(QStringLiteral("search-cache-v1.dat"));
load();
}
void setEnabled(bool enabled) { enabled_ = enabled; }
bool enabled() const { return enabled_; }
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;
}
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 { class SearchEngine final : public QObject {
Q_OBJECT Q_OBJECT
public: public:
@@ -284,11 +458,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 +661,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 +696,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 +767,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 +826,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 +888,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 +1055,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 +1068,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"));
@@ -936,6 +1165,7 @@ public:
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();
@@ -996,6 +1226,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 +1254,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 +1454,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 +1486,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 +1550,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 +1582,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 +1865,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);
@@ -1684,6 +1928,44 @@ private slots:
menu.exec(table_->viewport()->mapToGlobal(point)); menu.exec(table_->viewport()->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()
{ {
QVector<FileRecord> rows; QVector<FileRecord> rows;
@@ -1722,7 +2004,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);
@@ -1882,6 +2164,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 +2201,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();