From f1817723e323eb401f8c94626bcdb3ccdb8c45ca Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 01/38] Type a regular file from its name Building a listing asked the disk about every file a second time, because Qt stats a file again before it reads the name. A regular file is now typed from the name the listing already read; a link, a pipe or a device keeps the full lookup. --- src/foldermodel.cpp | 4 +++- tests/folio_tests.cpp | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..1fceb142 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -273,8 +273,10 @@ FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatab e.isDir = info.isDir(); e.size = e.isDir ? 0 : info.size(); e.modified = info.lastModified(); + // A regular file is typed by its name alone; handing Qt the QFileInfo stats it once more first. e.mime = e.isDir ? QStringLiteral("inode/directory") - : mimes.mimeTypeForFile(info, QMimeDatabase::MatchExtension).name(); + : info.isFile() ? mimes.mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name() + : mimes.mimeTypeForFile(info, QMimeDatabase::MatchExtension).name(); e.kind = kindForMime(e.mime); e.extension = e.isDir ? QString() : info.suffix().toUpper(); return e; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..09f2c177 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -543,6 +543,37 @@ private slots: QVERIFY(!error.isEmpty()); } + void folderTypesFilesByNameAndTheRestByWhatTheyAre() + { + QTemporaryDir dir; + write(dir.filePath("CMakeLists.txt"), "x"); + write(dir.filePath("notes.txt"), "x"); + QVERIFY(QFile::link("notes.txt", dir.filePath("link.md"))); + QVERIFY(QFile::link(dir.path(), dir.filePath("folder.png"))); + QVERIFY(QFile::link("/dev/null", dir.filePath("device.txt"))); + + const QMimeDatabase mimes; + const auto mime = [&](const char *name) { return FolderModel::entryFor(QFileInfo(dir.filePath(name)), mimes).mime; }; + QCOMPARE(mime("CMakeLists.txt"), QString("text/x-cmake")); + QCOMPARE(mime("notes.txt"), QString("text/plain")); + QCOMPARE(mime("link.md"), QString("text/markdown")); + QCOMPARE(mime("folder.png"), QString("inode/directory")); + // Not a regular file, whatever its name says. + QCOMPARE(mime("device.txt"), QString("inode/chardevice")); + } + + void folderTypesAFileByWhatItsListingRead() + { + // A file swapped for a pipe after its listing read it keeps the type it was read as: no second look. + QTemporaryDir dir; + write(dir.filePath("notes.txt"), "x"); + QFileInfo info(dir.filePath("notes.txt")); + QVERIFY(info.isFile()); + QVERIFY(QFile::remove(dir.filePath("notes.txt"))); + QCOMPARE(::mkfifo(QFile::encodeName(dir.filePath("notes.txt")).constData(), 0600), 0); + QCOMPARE(FolderModel::entryFor(info, QMimeDatabase()).mime, QString("text/plain")); + } + void folderSummaryCountsAndPeeks() { QTemporaryDir dir; From 96e5d109b39bdeb06b86233602a450f87f2ac6c6 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 02/38] Keep one copy of a listing A selection, a folder count or a second view of the same folder each made the model copy its whole listing. They now read it in place, so a large folder is held once however many views show it. --- src/foldermodel.cpp | 18 ++++++------- tests/folio_tests.cpp | 63 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 1fceb142..4dde9936 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -468,7 +468,7 @@ void FolderModel::reload() { setEntries(entries); if (!m_selected.isEmpty()) { QSet still; - for (const Entry &e : m_entries) + for (const Entry &e : std::as_const(m_entries)) if (m_selected.contains(e.path)) still.insert(e.path); if (still != m_selected) { @@ -498,8 +498,8 @@ void FolderModel::countFolders(quint64 generation) { // counting was overtaken by a re-read (and so remembered without them). QVector> folders; for (int row = 0; row < m_entries.size(); ++row) - if (m_entries[row].isDir && m_entries[row].itemCount < 0) - folders.push_back({row, m_entries[row]}); + if (m_entries.at(row).isDir && m_entries.at(row).itemCount < 0) + folders.push_back({row, m_entries.at(row)}); if (folders.isEmpty()) return; @@ -511,7 +511,7 @@ void FolderModel::countFolders(quint64 generation) { return; bool updated = false; for (const auto &[row, summary] : watcher->result()) { - if (row >= m_entries.size() || m_entries[row].path != summary.path) + if (row >= m_entries.size() || m_entries.at(row).path != summary.path) continue; m_entries[row].itemCount = summary.itemCount; const QModelIndex at = index(row); @@ -566,14 +566,14 @@ void FolderModel::setSelected(const QSet &paths) { } void FolderModel::selectOnly(int row) { - setSelected(row >= 0 && row < m_entries.size() ? QSet{m_entries[row].path} : QSet{}); + setSelected(row >= 0 && row < m_entries.size() ? QSet{m_entries.at(row).path} : QSet{}); } void FolderModel::toggleSelected(int row) { if (row < 0 || row >= m_entries.size()) return; QSet paths = m_selected; - const QString path = m_entries[row].path; + const QString path = m_entries.at(row).path; if (!paths.remove(path)) paths.insert(path); setSelected(paths); @@ -586,7 +586,7 @@ void FolderModel::selectRange(int from, int to, bool add) { to = qBound(0, to, int(m_entries.size()) - 1); QSet paths = add ? m_selected : QSet{}; for (int row = qMin(from, to); row <= qMax(from, to); ++row) - paths.insert(m_entries[row].path); + paths.insert(m_entries.at(row).path); setSelected(paths); } @@ -594,13 +594,13 @@ void FolderModel::selectRows(const QList &rows) { QSet paths; for (int row : rows) if (row >= 0 && row < m_entries.size()) - paths.insert(m_entries[row].path); + paths.insert(m_entries.at(row).path); setSelected(paths); } void FolderModel::selectAll() { QSet paths; - for (const Entry &e : m_entries) + for (const Entry &e : std::as_const(m_entries)) paths.insert(e.path); setSelected(paths); } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 09f2c177..62b82e01 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -1591,6 +1592,61 @@ private slots: QCOMPARE(model.selectedCount(), 0); } + void folderSelectsWithoutCopyingTheListing() + { + // Enough rows that a copy of the listing shows in the heap. + constexpr int files = 4000; + QTemporaryDir dir; + QDir(dir.path()).mkdir("sub"); + for (int i = 0; i < files; ++i) + write(dir.filePath(QString("f%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + QSignalSpy counted(&model, &QAbstractItemModel::dataChanged); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(!counted.isEmpty() || counted.wait(3000)); + QCOMPARE(model.get(0).value("itemCount").toInt(), 0); + + // Selecting, and re-reading with a selection, read the remembered listing and leave it shared. + const size_t before = heap(); + model.selectAll(); + QCOMPARE(model.selectedCount(), files + 1); + model.selectRows({1, 2}); + model.toggleSelected(2); + model.selectRange(3, 5, true); + model.selectOnly(4); + model.reload(); + QVERIFY(loaded.wait(3000)); + QTest::qWait(50); + QVERIFY2(heap() < before + files * sizeof(FolderModel::Entry) / 2, "selecting copied the listing"); + QVERIFY(model.isSelected(4) && model.selectedCount() == 1); + } + + void folderShowsARememberedListingWithoutCopyingIt() + { + constexpr int files = 4000; + QTemporaryDir dir; + for (int i = 0; i < files; ++i) + write(dir.filePath(QString("f%1.txt").arg(i)), "x"); + FolderModel first; + QSignalSpy firstLoaded(&first, &FolderModel::loaded); + first.setPath(dir.path()); + QVERIFY(firstLoaded.wait(3000)); + QTest::qWait(50); + + // A second view of the folder (a column, a split) shares the listing the first one left. + const size_t before = heap(); + FolderModel second; + QSignalSpy secondLoaded(&second, &FolderModel::loaded); + second.setPath(dir.path()); + QCOMPARE(second.count(), files); + QVERIFY(secondLoaded.wait(3000)); + QTest::qWait(50); + QVERIFY2(heap() < before + files * sizeof(FolderModel::Entry) / 2, "the second view copied the listing"); + QCOMPARE(second.count(), files); + } + void recentsSelectOneOfTwoSameNamedFiles() { // Recents can hold two files of the same name from different @@ -6111,6 +6167,13 @@ private slots: return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } + // What the process holds on the heap, in bytes. + static size_t heap() + { + const struct mallinfo2 m = mallinfo2(); + return m.uordblks + m.hblkhd; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path); From ef1b871711f3d5a42937fcdff50ac264b8f47910 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 03/38] Count a folder's items without sorting them The preview's folder summary sorted every name to count them and to pick two pictures. It now keeps the first two pictures in one pass, so summarizing a large folder no longer holds every name. --- src/foldermodel.cpp | 24 ++++++++++++------- tests/folio_tests.cpp | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 4dde9936..09d7d3d8 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -419,18 +419,26 @@ QString FolderModel::recentFile() { } void FolderModel::summarize(Entry &entry, bool showHidden) { - QDir dir(entry.path); - const QStringList names = dir.entryList(entryFilters(showHidden), QDir::Name); - entry.itemCount = names.size(); + entry.itemCount = 0; entry.peek.clear(); static const QStringList pictures{"jpg", "jpeg", "png", "webp", "gif", "heic", "avif", "jxl"}; - for (const QString &name : names) { - if (pictures.contains(QFileInfo(name).suffix().toLower())) - entry.peek << dir.filePath(name); - if (entry.peek.size() == 2) - break; + constexpr qsizetype peekSize = 2; // pictures the preview shows of a folder + // The first pictures in QDir::Name order (a case-sensitive compare), kept in one pass instead of sorting every name. + QStringList firsts; + for (QDirIterator it(entry.path, entryFilters(showHidden)); it.hasNext();) { + it.next(); + ++entry.itemCount; + const QString name = it.fileName(); + if ((firsts.size() == peekSize && name > firsts.last()) || !pictures.contains(QFileInfo(name).suffix().toLower())) + continue; + firsts.insert(std::lower_bound(firsts.cbegin(), firsts.cend(), name) - firsts.cbegin(), name); + if (firsts.size() > peekSize) + firsts.removeLast(); } + const QDir dir(entry.path); + for (const QString &name : std::as_const(firsts)) + entry.peek << dir.filePath(name); } int FolderModel::itemCount(const QString &folder, bool showHidden) { diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 62b82e01..3357281a 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -593,6 +593,48 @@ private slots: QCOMPARE(FolderModel::itemCount(dir.path(), true), 5); } + void folderSummaryPeeksWithoutSortingEveryName() + { + // Written in neither name order nor its reverse, so a listing in creation order, either way, gives the wrong two. + QTemporaryDir dir; + QDir(dir.path()).mkdir(".shots.png"); + for (int i = 20; i > 0; --i) + write(dir.filePath(QString("note%1.txt").arg(i)), "x"); + for (const char *name : {"photo10.jpg", "\xc3\x89lan.png", "Photo9.JPG", "photo9.jpg"}) + write(dir.filePath(QString::fromUtf8(name)), "x"); + QVERIFY(QFile::link(dir.filePath("missing"), dir.filePath("gone"))); + QCOMPARE(::mkfifo(QFile::encodeName(dir.filePath("pipe")).constData(), 0600), 0); + + // Name order is a case-sensitive compare: capitals first, photo10 before photo9, accents after z. + FolderModel::Entry entry; + entry.path = dir.path(); + FolderModel::summarize(entry, false); + QCOMPARE(entry.itemCount, 26); + QCOMPARE(entry.peek, QStringList({dir.filePath("Photo9.JPG"), dir.filePath("photo10.jpg")})); + // A folder named like a picture peeks like one. + FolderModel::summarize(entry, true); + QCOMPARE(entry.itemCount, 27); + QCOMPARE(entry.peek, QStringList({dir.filePath(".shots.png"), dir.filePath("Photo9.JPG")})); + + // A big folder is counted without holding every name at once. + constexpr int files = 20000; + constexpr long allowedKib = 4096; // about a fifth of what sorting 20,000 names holds + QTemporaryDir big; + for (int i = 0; i < files; ++i) + write(big.filePath(QString("f%1.txt").arg(i)), "x"); + entry.path = big.path(); + malloc_trim(0); + constexpr char resetPeak[] = "5"; // what /proc/self/clear_refs takes to set VmHWM back to the current VmRSS + QFile clear("/proc/self/clear_refs"); + QVERIFY(clear.open(QIODevice::WriteOnly)); + QVERIFY(clear.write(resetPeak) == 1); + clear.close(); + const long resident = residentKib("VmRSS:"); + FolderModel::summarize(entry, false); + QCOMPARE(entry.itemCount, files); + QVERIFY2(residentKib("VmHWM:") - resident < allowedKib, "summarizing held every name"); + } + void folderModelLoadsInTheBackground() { QTemporaryDir dir; @@ -6174,6 +6216,18 @@ private slots: return m.uordblks + m.hblkhd; } + // A line of /proc/self/status in KiB, such as "VmHWM: 12345 kB". + static long residentKib(const char *key) + { + QFile status("/proc/self/status"); + if (!status.open(QIODevice::ReadOnly)) + return -1; + for (const QByteArray &line : status.readAll().split('\n')) + if (line.startsWith(key)) + return line.mid(qstrlen(key)).trimmed().split(' ').first().toLong(); + return -1; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path); From c3364636022020327e865e5764517804f7031b99 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 04/38] Hold less for each entry of a listing Every entry kept its own copy of its type, kind and extension, and the listing kept spare room for entries that never came. The entries of a listing now share those strings, and the spare room is given back. --- src/foldermodel.cpp | 26 +++++++++++++++++--------- tests/folio_tests.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 09d7d3d8..4b732c14 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -207,23 +207,23 @@ void FolderModel::setPath(const QString &path) { } QString FolderModel::kindForMime(const QString &mime) { - if (mime == "inode/directory") return "folder"; - if (mime == "application/pdf") return "pdf"; - if (mime.startsWith("image/")) return "image"; - if (mime.startsWith("video/")) return "video"; - if (mime.startsWith("audio/")) return "audio"; + if (mime == "inode/directory") return QStringLiteral("folder"); + if (mime == "application/pdf") return QStringLiteral("pdf"); + if (mime.startsWith("image/")) return QStringLiteral("image"); + if (mime.startsWith("video/")) return QStringLiteral("video"); + if (mime.startsWith("audio/")) return QStringLiteral("audio"); // Anything readable as text (source code, config, JSON, Ruby, ...) is // text, whatever its own type is called. static const QMimeDatabase types; if (mime.startsWith("text/") || types.mimeTypeForName(mime).inherits("text/plain")) - return "text"; + return QStringLiteral("text"); static const QStringList archives{"application/zip", "application/x-tar", "application/gzip", "application/x-xz", "application/zstd", "application/x-7z-compressed", "application/x-compressed-tar", "application/x-zstd-compressed-tar", "application/vnd.rar", "application/x-iso9660-image"}; // Every compressed tar too: .tar.xz, .tar.bz2, .tar.lz4 and the rest. - if (archives.contains(mime) || mime.endsWith("-compressed-tar")) return "archive"; - return "other"; + if (archives.contains(mime) || mime.endsWith("-compressed-tar")) return QStringLiteral("archive"); + return QStringLiteral("other"); } // Everything in a folder (sockets and broken links too), the hidden only @@ -256,12 +256,20 @@ QVector FolderModel::list(const QString &path, bool showHidd // Extension-only matching: reading every file's header would make large // folders and network mounts crawl. QMimeDatabase mimes; + // One string per type and extension, shared by every entry that has it. + QSet shared; QDirIterator it(path, entryFilters(showHidden)); while (it.hasNext()) { if (stop && stop()) return {}; - entries.push_back(entryFor(it.nextFileInfo(), mimes)); + Entry e = entryFor(it.nextFileInfo(), mimes); + e.mime = *shared.insert(e.mime); + if (!e.extension.isEmpty()) + e.extension = *shared.insert(e.extension); + entries.push_back(std::move(e)); } + // Growing left room for many more entries than were read; the listing is kept, so give it back. + entries.squeeze(); sortEntries(entries, sort, locale); return entries; } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 3357281a..6740c930 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -537,6 +537,33 @@ private slots: QCOMPARE(names(QLocale("en_US")), QStringList({"åsa.txt", "zeta.txt"})); } + void folderSharesEachTypeAcrossAListing() + { + // A listing holds one string per type and extension, and no room for entries it never read. + QTemporaryDir dir; + for (const char *name : {"a.txt", "b.txt", "c.TXT", "d.png", "e.png", "README", "f"}) + write(dir.filePath(name), "x"); + QDir(dir.path()).mkdir("sub"); + QString error; + const auto entries = FolderModel::list(dir.path(), false, "name", &error); + QCOMPARE(entries.size(), 8); + QCOMPARE(entries.capacity(), entries.size()); + QHash first; + for (const auto &e : entries) { + const auto *seen = first.value(e.extension + '\n' + e.mime); + if (!seen) { + first.insert(e.extension + '\n' + e.mime, &e); + continue; + } + QVERIFY2(e.mime.constData() == seen->mime.constData(), qPrintable(e.name)); + QVERIFY2(e.kind.constData() == seen->kind.constData(), qPrintable(e.name)); + QVERIFY2(e.extension.isEmpty() || e.extension.constData() == seen->extension.constData(), qPrintable(e.name)); + } + QVERIFY(entries.at(1).mime.constData() == entries.at(3).mime.constData()); + QCOMPARE(entries.at(0).extension, QString()); + QCOMPARE(entries.at(3).extension, QString("TXT")); + } + void folderReportsMissingFolders() { QString error; From e37c4e3fbd7e988357da32e0197cf1ce6e1a9235 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 05/38] Read a file's type when a view asks for it Opening a folder typed every file before its first row showed. The listing now leaves a regular file's type, kind and extension unread until a role asks, which touches no disk; a sort by kind still types every entry in the worker. --- src/foldermodel.cpp | 50 ++++++++++++++++++++------- src/foldermodel.h | 8 +++-- tests/folio_tests.cpp | 79 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 4b732c14..66d817e1 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -4,9 +4,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -103,9 +105,9 @@ QVariant FolderModel::data(const QModelIndex &index, int role) const { case IsDirRole: return e.isDir; case SizeRole: return e.size; case ModifiedRole: return e.modified; - case KindRole: return e.kind; - case MimeRole: return e.mime; - case ExtensionRole: return e.extension; + case KindRole: return typed(e).kind; + case MimeRole: return typed(e).mime; + case ExtensionRole: return typed(e).extension; case ItemCountRole: return e.itemCount; case SelectedRole: return m_selected.contains(e.path); case IconRole: return e.icon; @@ -237,7 +239,7 @@ static QDir::Filters entryFilters(bool showHidden) { QVector FolderModel::list(const QString &path, bool showHidden, const QString &sort, QString *error, const std::function &stop, - const QLocale &locale) { + const QLocale &locale, bool typed) { if (path == recentPath()) { // Recents are always most recently used first; that's what they're for. return listRecent(recentFile(), showHidden, 200, stop); @@ -258,14 +260,26 @@ QVector FolderModel::list(const QString &path, bool showHidd QMimeDatabase mimes; // One string per type and extension, shared by every entry that has it. QSet shared; - QDirIterator it(path, entryFilters(showHidden)); - while (it.hasNext()) { + // Sorting by kind compares types, so they're read here. + const bool typing = typed || sort == "kind"; + // Links unresolved, so broken ones, sockets and fifos are listed too, as entryFilters() has them. + const auto flags = showHidden ? QDirListing::IteratorFlag::IncludeHidden : QDirListing::IteratorFlag::Default; + for (const QDirListing::DirEntry &item : QDirListing(path, flags)) { if (stop && stop()) return {}; - Entry e = entryFor(it.nextFileInfo(), mimes); - e.mime = *shared.insert(e.mime); - if (!e.extension.isEmpty()) - e.extension = *shared.insert(e.extension); + Entry e; + if (typing || !item.isFile()) { + e = entryFor(item.fileInfo(), mimes); + e.mime = *shared.insert(e.mime); + if (!e.extension.isEmpty()) + e.extension = *shared.insert(e.extension); + } else { + // A regular file's type comes from its name alone, so it can wait until it's asked for. + e.name = item.fileName(); + e.path = item.absoluteFilePath(); + e.size = item.size(); + e.modified = item.lastModified(QTimeZone::LocalTime); + } entries.push_back(std::move(e)); } // Growing left room for many more entries than were read; the listing is kept, so give it back. @@ -274,6 +288,17 @@ QVector FolderModel::list(const QString &path, bool showHidd return entries; } +FolderModel::Entry FolderModel::typed(const Entry &entry) { + // A plugin's item has a kind and no file type: its name says nothing about it. + if (!entry.mime.isNull() || !entry.kind.isNull()) + return entry; + Entry e = entry; + e.mime = QMimeDatabase().mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name(); + e.kind = kindForMime(e.mime); + e.extension = QFileInfo(e.name).suffix().toUpper(); + return e; +} + FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatabase &mimes) { Entry e; e.name = info.fileName(); @@ -503,7 +528,7 @@ void FolderModel::reload() { QString error; const auto overtaken = [&] { return *latest != generation; }; auto entries = isPluginView(path) ? listItems(items, query) - : query.isEmpty() || path == recentPath() ? list(path, showHidden, sort, &error, overtaken, locale) + : query.isEmpty() || path == recentPath() ? list(path, showHidden, sort, &error, overtaken, locale, false) : search(path, query, showHidden, sort, overtaken, 1000, locale); return qMakePair(entries, error); })); @@ -563,7 +588,8 @@ QVariantMap FolderModel::get(int row) const { return map; } -QVariantMap FolderModel::toMap(const Entry &entry) { +QVariantMap FolderModel::toMap(const Entry &listed) { + const Entry entry = typed(listed); return { {"name", entry.name}, {"path", entry.path}, {"isDir", entry.isDir}, {"size", entry.size}, {"modified", entry.modified}, {"kind", entry.kind}, {"mime", entry.mime}, diff --git a/src/foldermodel.h b/src/foldermodel.h index 681a2c7e..ffb32c8c 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -54,6 +54,7 @@ class FolderModel : public QAbstractListModel { bool isDir = false; qint64 size = 0; QDateTime modified; + // Null for a regular file the model listed: typed() reads them from its name when they're asked for. QString kind; QString mime; // A file's extension; a plugin item's subtitle. @@ -110,10 +111,13 @@ class FolderModel : public QAbstractListModel { // A listing stops, empty, as soon as `stop` returns true. Names sort // by `locale`'s alphabet; a background pass passes the one it was // started with, because the default locale may only be read on the - // thread that sets it. + // thread that sets it. Untyped, it leaves a regular file's type to typed(). static QVector list(const QString &path, bool showHidden, const QString &sort, QString *error, - const std::function &stop = {}, const QLocale &locale = QLocale()); + const std::function &stop = {}, const QLocale &locale = QLocale(), + bool typed = true); static Entry entryFor(const QFileInfo &info, const QMimeDatabase &mimes); + // The entry with its kind, MIME type and extension, read from its name when it was listed untyped. + static Entry typed(const Entry &entry); // An entry as QML reads it, by the names of its fields. static QVariantMap toMap(const Entry &entry); // A folder's item count and first pictures, for the preview window. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 6740c930..ec089876 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -564,6 +564,70 @@ private slots: QCOMPARE(entries.at(3).extension, QString("TXT")); } + void folderTypesOnlyWhatsAskedFor() + { + // Two screens and more of plain files, beside one of every other sort of entry. + constexpr int files = 2000; + QTemporaryDir dir; + for (int i = 0; i < files; ++i) + write(dir.filePath(QString("f%1.%2").arg(i).arg(i % 3 ? "txt" : "png")), QByteArray(i % 7, 'x')); + const auto named = {".bashrc", "a.tar.gz", "X.JpG", "noext", "CMakeLists.txt", "notes.md"}; + for (const char *name : named) + write(dir.filePath(name), "x"); + QDir(dir.path()).mkdir("folder.png"); + QVERIFY(QFile::link("notes.md", dir.filePath("link.txt"))); + constexpr int fileLinks = 1; // link.txt: a link to a regular file is listed as one + QVERIFY(QFile::link(dir.filePath("folder.png"), dir.filePath("linked"))); + QVERIFY(QFile::link(dir.filePath("nowhere"), dir.filePath("broken.jpg"))); + QCOMPARE(::mkfifo(QFile::encodeName(dir.filePath("pipe.txt")).constData(), 0600), 0); + constexpr int others = 4; // the folder, the link to it, the broken link and the pipe + const int all = files + int(named.size()) + fileLinks + others; + + // Listed untyped, only what isn't a regular file has a type yet; asked, each entry is as typed listing has it. + QString error; + for (const QString &sort : FolderModel::sortModes()) { + const auto untyped = FolderModel::list(dir.path(), true, sort, &error, {}, QLocale(), false); + const auto whole = FolderModel::list(dir.path(), true, sort, &error); + QCOMPARE(untyped.size(), all); + const auto typedCount = std::count_if(untyped.cbegin(), untyped.cend(), [](const auto &e) { return !e.mime.isNull(); }); + QCOMPARE(typedCount, sort == "kind" ? untyped.size() : others); + for (int i = 0; i < whole.size(); ++i) { + QCOMPARE(untyped.at(i).path, whole.at(i).path); + QCOMPARE(untyped.at(i).size, whole.at(i).size); + QCOMPARE(untyped.at(i).modified, whole.at(i).modified); + const auto asked = FolderModel::typed(untyped.at(i)); + QCOMPARE(asked.kind, whole.at(i).kind); + QCOMPARE(asked.mime, whole.at(i).mime); + QCOMPARE(asked.extension, whole.at(i).extension); + } + } + + // Through the model, a row's name, size, date and type roles, and the types get() gives, match a whole entry. + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setShowHidden(true); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), all); + const QMimeDatabase mimes; + for (int row = 0; row < model.count(); ++row) { + const QModelIndex at = model.index(row); + const auto whole = FolderModel::entryFor(QFileInfo(at.data(FolderModel::PathRole).toString()), mimes); + QCOMPARE(at.data(FolderModel::NameRole).toString(), whole.name); + QCOMPARE(at.data(FolderModel::IsDirRole).toBool(), whole.isDir); + QCOMPARE(at.data(FolderModel::SizeRole).toLongLong(), whole.size); + QCOMPARE(at.data(FolderModel::ModifiedRole).toDateTime(), whole.modified); + QCOMPARE(at.data(FolderModel::KindRole).toString(), whole.kind); + QCOMPARE(at.data(FolderModel::MimeRole).toString(), whole.mime); + QCOMPARE(at.data(FolderModel::ExtensionRole).toString(), whole.extension); + QCOMPARE(at.data(FolderModel::ExtensionRole).toString().isNull(), whole.extension.isNull()); + const QVariantMap map = model.get(row); + QCOMPARE(map.value("kind").toString(), whole.kind); + QCOMPARE(map.value("mime").toString(), whole.mime); + QCOMPARE(map.value("extension").toString(), whole.extension); + } + } + void folderReportsMissingFolders() { QString error; @@ -3599,6 +3663,21 @@ private slots: QCOMPARE(Backend::mountLabel("dav:host=cloud.example.com,ssl=true,prefix=%2Fremote.php"), QString("cloud.example.com")); } + void pluginItemsAreNotTypedFromTheirNames() + { + // A plugin's item is shown as listed: its kind and subtitle are not read again from its name. + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setItems({QVariantMap{{"name", "notes.txt"}, {"path", "plugin:servers/a"}, {"subtitle", "Windows"}}}); + model.setPath("plugin:servers"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.get(0).value("name").toString(), QString("notes.txt")); + QCOMPARE(model.get(0).value("kind").toString(), QString("item")); + QCOMPARE(model.get(0).value("extension").toString(), QString("Windows")); + QCOMPARE(model.data(model.index(0), FolderModel::KindRole).toString(), QString("item")); + QCOMPARE(model.data(model.index(0), FolderModel::ExtensionRole).toString(), QString("Windows")); + } + void typedServerAddressesAreMounted() { // What's typed is what's mounted, cleaned up for GIO: GVfs knows From 82daa69fb6ea5aa1e1d1306b5faa3f7e40f9f6a1 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 11:57:24 -0400 Subject: [PATCH 06/38] Rebuild a sidebar section only when its list changed Every refresh of Places, the 15 second poll included, told all four sidebar lists they had changed. Each list now has its own signal and is announced only when its contents differ. --- src/places.cpp | 30 +++++++++-------- src/places.h | 15 ++++++--- tests/folio_tests.cpp | 76 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/src/places.cpp b/src/places.cpp index 8e130c95..200924c5 100644 --- a/src/places.cpp +++ b/src/places.cpp @@ -134,7 +134,14 @@ void Places::refresh() { refreshPlaces(); refreshPinned(); refreshDevices(); - emit changed(); +} + +// The lists compare in order, so a reordered list is a changed one. +void Places::setList(QVariantList &list, const QVariantList &next, void (Places::*announce)()) { + if (next == list) + return; + list = next; + emit (this->*announce)(); } QString Places::userDirectory(int location, const QString &fallback) const { @@ -174,12 +181,14 @@ void Places::refreshPlaces() { add(displayName(name), path, icons.value(path, "folder")); } - m_places = places; + setList(m_places, places, &Places::placesChanged); // Views: lists that aren't a folder of their own. The sidebar names // them, so a language switch renames them at once. - m_views = {QVariantMap{{"path", FolderModel::recentPath()}, {"icon", "recent"}}, - QVariantMap{{"path", QDir::cleanPath(FileOps::homeTrash() + "/files")}, {"icon", "trash"}}}; + setList(m_views, + {QVariantMap{{"path", FolderModel::recentPath()}, {"icon", "recent"}}, + QVariantMap{{"path", QDir::cleanPath(FileOps::homeTrash() + "/files")}, {"icon", "trash"}}}, + &Places::viewsChanged); } QString Places::bookmarksPath() const { @@ -210,7 +219,7 @@ bool Places::writeBookmarkLines(const QStringList &lines) { } void Places::refreshPinned() { - m_pinned = parseBookmarks(readBookmarkLines(), m_home); + setList(m_pinned, parseBookmarks(readBookmarkLines(), m_home), &Places::pinnedChanged); } bool Places::isPinned(const QString &path) const { @@ -235,7 +244,6 @@ QString Places::pin(const QString &path) { if (!writeBookmarkLines(lines)) return tr("Couldn't save the pin to %1.").arg(bookmarksPath()); refreshPinned(); - emit changed(); return {}; } @@ -247,10 +255,8 @@ void Places::unpin(const QString &path) { const QUrl url(line.section(' ', 0, 0)); return url.isLocalFile() && QDir::cleanPath(url.toLocalFile()) == clean; }), lines.end()); - if (lines.size() != before && writeBookmarkLines(lines)) { + if (lines.size() != before && writeBookmarkLines(lines)) refreshPinned(); - emit changed(); - } } QVariantList Places::physicalDrives(const QList &mounts, const QString &systemDevice) { @@ -321,11 +327,7 @@ void Places::mountsRead() { mount.canUnmount = g_mount_can_unmount(gmount); g_object_unref(gmount); } - const QVariantList devices = physicalDrives(mounts, m_systemDevice); - if (devices != m_devices) { - m_devices = devices; - emit changed(); - } + setList(m_devices, physicalDrives(mounts, m_systemDevice), &Places::devicesChanged); if (std::exchange(m_rereadMounts, false)) refreshDevices(); } diff --git a/src/places.h b/src/places.h index 077d11ce..61cd4782 100644 --- a/src/places.h +++ b/src/places.h @@ -11,10 +11,10 @@ class Places : public QObject { Q_OBJECT Q_PROPERTY(QString home READ home CONSTANT) - Q_PROPERTY(QVariantList places READ places NOTIFY changed) - Q_PROPERTY(QVariantList pinned READ pinned NOTIFY changed) - Q_PROPERTY(QVariantList views READ views NOTIFY changed) - Q_PROPERTY(QVariantList devices READ devices NOTIFY changed) + Q_PROPERTY(QVariantList places READ places NOTIFY placesChanged) + Q_PROPERTY(QVariantList pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(QVariantList views READ views NOTIFY viewsChanged) + Q_PROPERTY(QVariantList devices READ devices NOTIFY devicesChanged) public: // `home` is overridable for tests; the real home also consults the XDG @@ -62,13 +62,18 @@ class Places : public QObject { static QVariantList physicalDrives(const QList &mounts, const QString &systemDevice); signals: - void changed(); + // Each list announces only a change from what it last held, so a poll that finds nothing new redraws nothing. + void placesChanged(); + void pinnedChanged(); + void viewsChanged(); + void devicesChanged(); // A drive is about to be let go of: whatever shows a folder on it // leaves, so Folio itself doesn't keep it busy. void releasing(const QString &root); void failed(const QString &message); private: + void setList(QVariantList &list, const QVariantList &next, void (Places::*announce)()); void refreshPlaces(); void refreshDevices(); // Every mount and its free space, read off the UI thread: reading them diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index ec089876..bf30d407 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -3637,6 +3638,81 @@ private slots: QCOMPARE(places.pinned().size(), 1); } + void placesAnnounceOnlyTheListThatChanged() + { + QTemporaryDir home; + QDir(home.path()).mkpath("Work"); + Places places(home.path()); + QSignalSpy placesSpy(&places, &Places::placesChanged); + QSignalSpy pinnedSpy(&places, &Places::pinnedChanged); + QSignalSpy viewsSpy(&places, &Places::viewsChanged); + // The drives are read off the UI thread and land later: these are the lists a poll changes at once. + auto counts = [&] { + return QList{int(placesSpy.count()), int(pinnedSpy.count()), int(viewsSpy.count())}; + }; + + // A poll that finds nothing new says nothing. + places.refresh(); + places.refresh(); + QCOMPARE(counts(), QList({0, 0, 0})); + + // A new folder in home changes Home and nothing else. + QDir(home.path()).mkpath("Music"); + places.refresh(); + QCOMPARE(counts(), QList({1, 0, 0})); + QCOMPARE(places.places().size(), 2); + } + + void placesListsEachHaveTheirOwnSignal() + { + // What the sidebar binds to: a list's own signal, so one list's change rebuilds one section. + const QMetaObject &meta = Places::staticMetaObject; + for (const char *list : {"places", "pinned", "views", "devices"}) + QCOMPARE(meta.property(meta.indexOfProperty(list)).notifySignal().name(), QByteArray(list) + "Changed"); + } + + void placesPinAnnouncesOnlyPinned() + { + QTemporaryDir home; + QDir(home.path()).mkpath("Work/folio"); + Places places(home.path()); + QSignalSpy placesSpy(&places, &Places::placesChanged); + QSignalSpy pinnedSpy(&places, &Places::pinnedChanged); + + QCOMPARE(places.pin(home.filePath("Work/folio")), QString()); + QCOMPARE(pinnedSpy.count(), 1); + // Pinning it again changes nothing, so it says nothing. + QCOMPARE(places.pin(home.filePath("Work/folio")), QString()); + QCOMPARE(pinnedSpy.count(), 1); + places.unpin(home.filePath("Work/folio")); + QCOMPARE(pinnedSpy.count(), 2); + places.refresh(); + QCOMPARE(pinnedSpy.count(), 2); + QCOMPARE(placesSpy.count(), 0); + } + + void placesReorderedPinsAreAChange() + { + QTemporaryDir home; + for (const char *dir : {"Work/folio", "Work/hype"}) + QDir(home.path()).mkpath(dir); + const QString bookmarks = home.filePath(".config/gtk-3.0/bookmarks"); + QDir().mkpath(QFileInfo(bookmarks).absolutePath()); + const QByteArray folio = QUrl::fromLocalFile(home.filePath("Work/folio")).toEncoded() + "\n"; + const QByteArray hype = QUrl::fromLocalFile(home.filePath("Work/hype")).toEncoded() + "\n"; + write(bookmarks, folio + hype); + Places places(home.path()); + QSignalSpy pinnedSpy(&places, &Places::pinnedChanged); + places.refresh(); + QCOMPARE(pinnedSpy.count(), 0); + + // Reordered in a GTK dialog: the same pins, a new order. + write(bookmarks, hype + folio); + places.refresh(); + QCOMPARE(pinnedSpy.count(), 1); + QCOMPARE(places.pinned().at(0).toMap().value("name").toString(), QString("hype")); + } + void typedViewsGoToTheirView() { // The path bar shows a view as its own name, so Return on it, or From d488df85550520fdc3d8f976abe009ee388023cb Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 07/38] Ask Hyprland for its rounding when it changes While active, Folio ran hyprctl once a second to read the corner rounding. It now reads it at start, on a palette change, on return to the window and when Hyprland's event socket reports a reloaded config. --- src/theme.cpp | 86 ++++++++++++++++++--- src/theme.h | 11 ++- tests/folio_tests.cpp | 170 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 252 insertions(+), 15 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index c3f44015..97403554 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -8,8 +8,15 @@ #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include namespace { @@ -89,6 +96,8 @@ Theme::Theme(const QString &directory, QObject *parent, const QString &hyprctl) connect(&m_roundingProcess, &QProcess::errorOccurred, this, [this] { m_roundingTimeout.stop(); }); connect(&m_roundingProcess, &QProcess::finished, this, [this](int code, QProcess::ExitStatus status) { m_roundingTimeout.stop(); + if (std::exchange(m_roundingPending, false)) + QMetaObject::invokeMethod(this, &Theme::refreshRounding, Qt::QueuedConnection); const auto value = QJsonDocument::fromJson(m_roundingProcess.readAllStandardOutput()).object().value("int"); if (code != 0 || status != QProcess::NormalExit || !value.isDouble() || value.toInt(-1) < 0) return; @@ -97,25 +106,20 @@ Theme::Theme(const QString &directory, QObject *parent, const QString &hyprctl) emit changed(); } }); - m_roundingPoll.setInterval(1000); - connect(&m_roundingPoll, &QTimer::timeout, this, &Theme::refreshRounding); if (!m_hyprctl.isEmpty()) { - m_roundingPoll.start(); - // The rounding only needs following while Folio is in use, and it's - // read again the moment Folio is back. + // Hyprland names each config reload; a value set any other way is read when Folio is back in use. connect(qGuiApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { - if (state == Qt::ApplicationActive) { + if (state == Qt::ApplicationActive) refreshRounding(); - m_roundingPoll.start(); - } else { - m_roundingPoll.stop(); - } }); + // Subscribed before the first ask, so a reload during that ask isn't missed. + watchHyprland(); } reload(); } Theme::~Theme() { + closeHyprlandEvents(); if (m_roundingProcess.state() != QProcess::NotRunning) { m_roundingProcess.kill(); m_roundingProcess.waitForFinished(1000); @@ -123,12 +127,72 @@ Theme::~Theme() { } void Theme::refreshRounding() { - if (m_hyprctl.isEmpty() || m_roundingProcess.state() != QProcess::NotRunning) + if (m_hyprctl.isEmpty()) + return; + // An answer already on its way may predate the change, so ask once more after it. + m_roundingPending = m_roundingProcess.state() != QProcess::NotRunning; + if (m_roundingPending) return; m_roundingProcess.start(m_hyprctl, {"-j", "getoption", "decoration:rounding"}); m_roundingTimeout.start(); } +// Hyprland's event socket, read as a plain Unix socket since Qt Network isn't linked. +void Theme::watchHyprland() { + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"), instance = qgetenv("HYPRLAND_INSTANCE_SIGNATURE"); + if (runtime.isEmpty() || instance.isEmpty()) + return; + const QByteArray path = runtime + "/hypr/" + instance + "/.socket2.sock"; + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (path.size() >= qsizetype(sizeof address.sun_path)) + return; + std::memcpy(address.sun_path, path.constData(), path.size()); + m_hyprlandEvents = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (m_hyprlandEvents < 0) + return; + if (::connect(m_hyprlandEvents, reinterpret_cast(&address), sizeof address) != 0) { + closeHyprlandEvents(); + return; + } + m_hyprlandNotifier = new QSocketNotifier(m_hyprlandEvents, QSocketNotifier::Read, this); + connect(m_hyprlandNotifier, &QSocketNotifier::activated, this, &Theme::readHyprlandEvents); +} + +void Theme::readHyprlandEvents() { + constexpr qsizetype longestLine = 64 * 1024; // window titles arrive here too; no line we want is this long + char chunk[4096]; + ssize_t got; + while ((got = ::read(m_hyprlandEvents, chunk, sizeof chunk)) > 0 || (got < 0 && errno == EINTR)) + if (got > 0) + m_hyprlandLine.append(chunk, got); + const bool closed = got == 0 || (errno != EAGAIN && errno != EWOULDBLOCK); + + // Sample input: "activewindow>>kitty,~\nconfigreloaded>>\n"; only a whole configreloaded line counts. + bool reloaded = false; + for (qsizetype end; (end = m_hyprlandLine.indexOf('\n')) >= 0; m_hyprlandLine.remove(0, end + 1)) + reloaded |= QByteArrayView(m_hyprlandLine).first(end) == "configreloaded>>"; + if (m_hyprlandLine.size() > longestLine) + m_hyprlandLine.clear(); + if (reloaded) + refreshRounding(); + // Hyprland is gone, and the corners keep their last rounding. + if (closed) + closeHyprlandEvents(); +} + +void Theme::closeHyprlandEvents() { + if (m_hyprlandNotifier) { + m_hyprlandNotifier->setEnabled(false); + m_hyprlandNotifier->deleteLater(); + m_hyprlandNotifier = nullptr; + } + if (m_hyprlandEvents >= 0) + ::close(m_hyprlandEvents); + m_hyprlandEvents = -1; + m_hyprlandLine.clear(); +} + QColor Theme::color(const QString &key) const { if (const auto it = m_colors.constFind(key); it != m_colors.constEnd()) return *it; diff --git a/src/theme.h b/src/theme.h index 221b9de4..b11ed825 100644 --- a/src/theme.h +++ b/src/theme.h @@ -7,6 +7,8 @@ #include #include +class QSocketNotifier; + // The Omarchy palette, followed live. A file manager draws far more surface // than a one-tool app, so it reads the whole colors.toml rather than only the // accent, and the Material style flips with the theme's light or dark mode. @@ -57,6 +59,9 @@ class Theme : public QObject { QColor color(const QString &key) const; void reload(); void refreshRounding(); + void watchHyprland(); + void readHyprlandEvents(); + void closeHyprlandEvents(); QString m_directory; QHash m_colors; @@ -66,5 +71,9 @@ class Theme : public QObject { QString m_hyprctl; int m_radius = 0; QProcess m_roundingProcess; - QTimer m_roundingPoll, m_roundingTimeout; + QTimer m_roundingTimeout; + bool m_roundingPending = false; + int m_hyprlandEvents = -1; + QSocketNotifier *m_hyprlandNotifier = nullptr; + QByteArray m_hyprlandLine; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index bf30d407..e00f08c6 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -32,9 +32,14 @@ #include #include #include +#include +#include #include +#include #include +#include #include +#include #include #include "backend.h" @@ -496,6 +501,8 @@ private slots: { // No test ever reaches the real udisks, which would change real disks. fakeDisks({}); + // No test follows the Hyprland of the session it's run from. + qunsetenv("HYPRLAND_INSTANCE_SIGNATURE"); } void folderListsDirectoriesFirstWithoutHidden() @@ -6145,16 +6152,114 @@ private slots: void themeFollowsHyprlandRounding() { - QTemporaryDir dir; - const QString response = dir.filePath("response.json"), command = dir.filePath("hyprctl"); - write(command, "#!/bin/sh\ncat '" + response.toUtf8() + "'\n"); + // The stand-in writes outside the theme folder, so its runs aren't theme changes. + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); + QTRY_COMPARE(theme.radius(), 2); + write(response, "{\"int\":6}"); + // Other events, and a reload name cut short, ask nothing. + send(events, "activewindow>>kitty,configreloaded>>\nconfigreloaded\n"); + QTest::qWait(300); + QCOMPARE(countRuns(runs), 1); + // A reload line that arrives in two pieces is one reload. + send(events, "configrel"); + QTest::qWait(100); + send(events, "oaded>>\n"); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(countRuns(runs), 2); + } + + void themeAsksAgainForAReloadDuringAnAsk() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = stage.filePath("hyprctl"), go = stage.filePath("go"); + // This hyprctl reads its answer, then holds it until `go` exists: a reload sent meanwhile makes it stale. + write(command, "#!/bin/sh\nanswer=$(cat '" + response.toUtf8() + "')\necho run >> '" + runs.toUtf8() + + "'\nuntil [ -e '" + go.toUtf8() + "' ]; do sleep 0.01; done\necho \"$answer\"\n"); QVERIFY(QFile::setPermissions(command, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); + QTRY_COMPARE(countRuns(runs), 1); + write(response, "{\"int\":6}"); + send(events, "configreloaded>>\n"); + // Theme reads the reload while the first ask is still held. + QVERIFY(unread(events) > 0); + QTRY_COMPARE(unread(events), 0); + write(go, ""); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(countRuns(runs), 2); + } + + void themeAsksHyprlandOnlyWhenSomethingChanges() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); write(response, "{\"int\":4}"); Theme theme(dir.path(), nullptr, command); QTRY_COMPARE(theme.radius(), 4); + // Longer than the one-second poll this replaced. + QTest::qWait(2500); + QCOMPARE(countRuns(runs), 1); + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), "accent = \"#112233\"\n"); + QTRY_COMPARE(countRuns(runs), 2); + } + + void themeKeepsRoundingWhenHyprctlFails() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); write(response, "{\"int\":6}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); QTRY_COMPARE(theme.radius(), 6); + + write(command, "#!/bin/sh\necho run >> '" + runs.toUtf8() + "'\necho '{\"int\":2}'\nexit 1\n"); + send(events, "configreloaded>>\n"); + QTRY_COMPARE(countRuns(runs), 2); + QTest::qWait(200); + QCOMPARE(theme.radius(), 6); + + // A hyprctl that's gone changes nothing, and the next one that answers is followed again. + QVERIFY(QFile::remove(command)); + send(events, "configreloaded>>\n"); + QTest::qWait(300); + QCOMPARE(theme.radius(), 6); + write(response, "{\"int\":2}"); + standInHyprctl(stage, response, runs); + send(events, "configreloaded>>\n"); + QTRY_COMPARE(theme.radius(), 2); + QCOMPARE(countRuns(runs), 3); } void themeAsksForRoundingOnlyWhileActive() @@ -6416,6 +6521,65 @@ private slots: QVERIFY(file.open(QIODevice::WriteOnly)); file.write(contents); } + // A hyprctl that notes each run and prints the response file. + static QString standInHyprctl(const QTemporaryDir &dir, const QString &response, const QString &runs) + { + const QString command = dir.filePath("hyprctl"); + write(command, "#!/bin/sh\necho run >> '" + runs.toUtf8() + "'\ncat '" + response.toUtf8() + "'\n"); + QFile::setPermissions(command, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner); + return command; + } + + static int countRuns(const QString &runs) + { + QFile file(runs); + return file.open(QIODevice::ReadOnly) ? int(file.readAll().count('\n')) : 0; + } + + // Hyprland's event socket, under a runtime folder of the test's own. + static int listenAsHyprland(const QString &runtime) + { + QDir(runtime).mkpath("hypr/test"); + const QByteArray path = QFile::encodeName(runtime + "/hypr/test/.socket2.sock"); + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (path.size() >= qsizetype(sizeof address.sun_path)) + return -1; + std::memcpy(address.sun_path, path.constData(), path.size()); + const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd < 0 || ::bind(fd, reinterpret_cast(&address), sizeof address) != 0 + || ::listen(fd, 1) != 0) { + if (fd >= 0) + ::close(fd); + return -1; + } + qputenv("XDG_RUNTIME_DIR", QFile::encodeName(runtime)); + qputenv("HYPRLAND_INSTANCE_SIGNATURE", "test"); + return fd; + } + + static void stopHyprland(int fd, const QByteArray &runtime) + { + ::close(fd); + if (runtime.isEmpty()) + qunsetenv("XDG_RUNTIME_DIR"); + else + qputenv("XDG_RUNTIME_DIR", runtime); + qunsetenv("HYPRLAND_INSTANCE_SIGNATURE"); + } + + // What a socket has sent that its other end hasn't read yet, as the kernel counts it: 0 once all of it is read. + static int unread(int fd) + { + int queued = -1; + ::ioctl(fd, SIOCOUTQ, &queued); + return queued; + } + + static void send(int fd, const QByteArray &events) + { + QCOMPARE(::write(fd, events.constData(), events.size()), ssize_t(events.size())); + } }; QTEST_MAIN(FolioTests) From 764bc6c555ebf867ab253a631073424b867578d4 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:36:40 -0400 Subject: [PATCH 08/38] Sort a large folder faster, in the same order Sorting 100,000 names collated both strings on every compare. A listing that arrives in a few ordered runs is now merged, and a name or kind sort makes one sort key an entry first. The comparator is the same, so every order and tie stays; the C locale compares as before. --- src/foldermodel.cpp | 69 ++++++++++++++++++++- src/foldermodel.h | 2 + tests/folio_tests.cpp | 135 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 3 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 66d817e1..ca5ddaed 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -13,8 +13,11 @@ #include #include #include +#include #include #include +#include +#include #include FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { @@ -361,11 +364,36 @@ QStringList FolderModel::sortModes() { return {"name", "kind", "modified", "oldest", "size"}; } +bool FolderModel::mergeRuns(QVector &entries, const std::function &less) { + constexpr qsizetype mostRuns = 64; // merging 64 runs takes 6 compares an entry, a third of sorting 100,000 + // Where each run starts, then where the last one ends. + QVarLengthArray::iterator, mostRuns + 1> bounds{entries.begin()}; + for (auto at = entries.begin(); at != entries.end();) { + if (bounds.size() > mostRuns) + return false; + at = std::is_sorted_until(at, entries.end(), less); + bounds.push_back(at); + } + // Neighbouring runs merge in pairs until one is left; a merge puts its left run's equals first, so ties keep their order. + while (bounds.size() > 2) { + qsizetype merged = 1; + for (qsizetype i = 0; i + 2 < bounds.size(); i += 2) { + std::inplace_merge(bounds[i], bounds[i + 1], bounds[i + 2], less); + bounds[merged++] = bounds[i + 2]; + } + if (bounds.size() % 2 == 0) // an odd run out keeps its end + bounds[merged++] = bounds.back(); + bounds.resize(merged); + } + return true; +} + void FolderModel::sortEntries(QVector &entries, const QString &sort, const QLocale &locale) { QCollator collator(locale); collator.setNumericMode(true); collator.setCaseSensitivity(Qt::CaseInsensitive); - std::stable_sort(entries.begin(), entries.end(), [&](const Entry &a, const Entry &b) { + // `collate` compares the two names, or with true the two extensions. + const auto before = [&sort](const Entry &a, const Entry &b, const auto &collate) { // Folders first in every order, as everywhere else on the desktop. if (a.isDir != b.isDir) return a.isDir; @@ -380,10 +408,45 @@ void FolderModel::sortEntries(QVector &entries, const QString &sort, cons if (a.kind != b.kind) return a.kind < b.kind; if (a.extension != b.extension) - return collator.compare(a.extension, b.extension) < 0; + return collate(true) < 0; } - return collator.compare(a.name, b.name) < 0; + return collate(false) < 0; + }; + const auto less = [&](const Entry &a, const Entry &b) { + return before(a, b, [&](bool extension) { + return extension ? collator.compare(a.extension, b.extension) : collator.compare(a.name, b.name); + }); + }; + // A folder listed in the order it was written, as btrfs gives one back, is in a few runs already. + if (mergeRuns(entries, less)) + return; + // Keys pay where names decide the order, and outside the C locale, whose keys keep the case its compare ignores. + if ((sort != "name" && sort != "kind") || collator.locale().language() == QLocale::C) { + std::stable_sort(entries.begin(), entries.end(), less); + return; + } + // Each name and extension made into a key once, so a compare reads bytes instead of collating both strings. + std::vector names, extensions; + names.reserve(entries.size()); + for (const Entry &e : std::as_const(entries)) + names.push_back(collator.sortKey(e.name)); + if (sort == "kind") { + extensions.reserve(entries.size()); + for (const Entry &e : std::as_const(entries)) + extensions.push_back(collator.sortKey(e.extension)); + } + std::vector order(entries.size()); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&](qsizetype i, qsizetype j) { + return before(entries.at(i), entries.at(j), [&](bool extension) { + return extension ? extensions[i].compare(extensions[j]) : names[i].compare(names[j]); + }); }); + QVector sorted; + sorted.reserve(entries.size()); + for (qsizetype i : order) + sorted.push_back(std::move(entries[i])); + entries = std::move(sorted); } // Recents are GTK's recently used files (~/.local/share/recently-used.xbel), diff --git a/src/foldermodel.h b/src/foldermodel.h index ffb32c8c..f6ae07e1 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -127,6 +127,8 @@ class FolderModel : public QAbstractListModel { static int itemCount(const QString &folder, bool showHidden); static QStringList sortModes(); static void sortEntries(QVector &entries, const QString &sort, const QLocale &locale = QLocale()); + // Sorts `entries` by merging the ascending runs they arrived in, as stable_sort would; false, untouched, when they're too many. + static bool mergeRuns(QVector &entries, const std::function &less); // The Recents view: a path that isn't a folder, listing recently used files. static QString recentPath() { return QStringLiteral("recent:"); } static QString recentFile(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index e00f08c6..75241770 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -1701,6 +1703,70 @@ private slots: QCOMPARE(order("size"), QString("Zeta a.png c.txt b.txt")); } + void folderSortsInThePlainSortsOrder() + { + // However they arrive, entries end in the order one stable sort by one collating compare gives, ties and all. + const auto entries = sortable(); + // A real locale sorts names by their keys, C by the plain compare: each is held to the same order. + for (const QLocale &locale : {QLocale(QLocale::English, QLocale::UnitedStates), QLocale::c()}) { + const SortLocale sorting(locale); + for (const QString &sort : FolderModel::sortModes()) { + auto ordered = entries; + plainSort(ordered, sort); + auto reversed = ordered; + std::reverse(reversed.begin(), reversed.end()); + auto shuffled = entries; + std::shuffle(shuffled.begin(), shuffled.end(), std::mt19937(2026)); + // A few runs, as a folder written in batches arrives. + auto runs = shuffled; + constexpr int batches = 5; + for (int i = 0; i < batches; ++i) { + QVector batch(runs.begin() + i * runs.size() / batches, runs.begin() + (i + 1) * runs.size() / batches); + plainSort(batch, sort); + std::move(batch.begin(), batch.end(), runs.begin() + i * runs.size() / batches); + } + for (const auto &arrived : {ordered, reversed, shuffled, runs}) { + auto sorted = arrived, expected = arrived; + FolderModel::sortEntries(sorted, sort); + plainSort(expected, sort); + QCOMPARE(paths(sorted), paths(expected)); + } + } + } + } + + void folderSortsAnOrderedListingInOneCompareAnEntry() + { + // A listing already in order is checked, not sorted again: about one compare an entry, where a sort takes a dozen. + constexpr qsizetype comparesAnEntry = 2; + for (const QString &sort : FolderModel::sortModes()) { + auto entries = sortable(); + plainSort(entries, sort); + const QStringList expected = paths(entries); + QCollator collator; + collator.setNumericMode(true); + collator.setCaseSensitivity(Qt::CaseInsensitive); + qsizetype compares = 0; + QVERIFY(FolderModel::mergeRuns(entries, [&](const FolderModel::Entry &a, const FolderModel::Entry &b) { + ++compares; + return plainBefore(a, b, sort, collator); + })); + QVERIFY2(compares <= comparesAnEntry * entries.size(), qPrintable(QString("%1 compares").arg(compares))); + QCOMPARE(paths(entries), expected); + } + } + + void folderLeavesAnOrderedListingWhereItIs() + { + // sortEntries merges what arrives in order; the keyed sort a real locale would fall to builds the listing again in another buffer. + const SortLocale sorting(QLocale(QLocale::English, QLocale::UnitedStates)); + auto entries = sortable(); + plainSort(entries, "name"); + const FolderModel::Entry *listed = entries.constData(); + FolderModel::sortEntries(entries, "name"); + QCOMPARE(entries.constData(), listed); + } + void folderKeepsASelection() { QTemporaryDir dir; @@ -6515,6 +6581,75 @@ private slots: return -1; } + // Entries with every kind of tie: names equal but for case, numbers written two ways, equal sizes and dates, folders among them. + static QVector sortable() + { + const QStringList names{"README", "readme", "ReadMe", "a9.txt", "a09.txt", "a10.txt", "photo9.jpg", "photo10.jpg", + "Photo9.jpg", "b.png", "B.PNG", "zeta", "Zeta", "é.md", "e.md", "notes", "x.tar.gz"}; + QVector entries; + constexpr int count = 2000; // shuffled, far more runs than mergeRuns takes, so the sorts behind it run too + for (int i = 0; i < count; ++i) { + FolderModel::Entry e; + e.name = i < 400 ? names.at(i % names.size()) : QString("file %1 %2").arg(i % 97).arg(names.at(i % names.size())); + e.path = QString("/sortable/%1").arg(i); + e = FolderModel::typed(e); + if (i % 13 == 0) { + e.isDir = true; + e.mime = "inode/directory"; + e.kind = FolderModel::kindForMime(e.mime); + e.extension = QString(); + } else { + e.size = (i % 5) * 10; + } + e.modified = QDateTime::fromSecsSinceEpoch(1700000000 + (i % 7) * 60); + entries.push_back(e); + } + return entries; + } + + // The default locale, which a QCollator made without one follows, for as long as this lives. + struct SortLocale { + explicit SortLocale(const QLocale &locale) { QLocale::setDefault(locale); } + ~SortLocale() { QLocale::setDefault(was); } + const QLocale was; + }; + + // The order every faster sort must keep: Folio's compare, one pair at a time, in a stable sort. + static bool plainBefore(const FolderModel::Entry &a, const FolderModel::Entry &b, const QString &sort, const QCollator &collator) + { + if (a.isDir != b.isDir) + return a.isDir; + if (sort == "modified" && a.modified != b.modified) + return a.modified > b.modified; + if (sort == "oldest" && a.modified != b.modified) + return a.modified < b.modified; + if (sort == "size" && !a.isDir && a.size != b.size) + return a.size > b.size; + if (sort == "kind" && !a.isDir) { + if (a.kind != b.kind) + return a.kind < b.kind; + if (a.extension != b.extension) + return collator.compare(a.extension, b.extension) < 0; + } + return collator.compare(a.name, b.name) < 0; + } + + static void plainSort(QVector &entries, const QString &sort) + { + QCollator collator; + collator.setNumericMode(true); + collator.setCaseSensitivity(Qt::CaseInsensitive); + std::stable_sort(entries.begin(), entries.end(), [&](const auto &a, const auto &b) { return plainBefore(a, b, sort, collator); }); + } + + static QStringList paths(const QVector &entries) + { + QStringList out; + for (const auto &e : entries) + out << e.path; + return out; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path); From 1f7cbff27e1e0b160b0aa9d8b8182793051ff76f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:36:40 -0400 Subject: [PATCH 09/38] Read sizes and dates for the rows a view shows A name or kind listing read every entry's size and date before its first row showed. It now reads the first 320 rows, and a later row when a view asks, one batch a turn, off the UI thread. A row not read yet shows no size and no date, never 0 B. --- src/Format.js | 9 +- src/PreviewWindow.qml | 2 +- src/foldermodel.cpp | 193 +++++++++++++++++++--- src/foldermodel.h | 19 ++- tests/folio_tests.cpp | 368 +++++++++++++++++++++++++++++++++++++++--- 5 files changed, 540 insertions(+), 51 deletions(-) diff --git a/src/Format.js b/src/Format.js index a359f60e..092c215c 100644 --- a/src/Format.js +++ b/src/Format.js @@ -8,8 +8,10 @@ // refreshed with the rest of the strings. // Each unit is whole with its number, so a language can place and space it -// its own way ("1,5 Mo"). +// its own way ("1,5 Mo"). Nothing for a size not read yet. function size(bytes) { + if (bytes === undefined) + return "" const units = [qsTr("%1 B"), qsTr("%1 KB"), qsTr("%1 MB"), qsTr("%1 GB"), qsTr("%1 TB")] let i = 0 let value = bytes @@ -47,13 +49,16 @@ function summary(item, type) { return item.extension if (item.isDir) return item.itemCount === 0 ? qsTr("Folder · No items") : item.itemCount > 0 ? qsTr("Folder · %Ln items", "", item.itemCount) : qsTr("Folder") - return qsTr("%1 · %2").arg(type || item.mime).arg(size(item.size)) + return item.size === undefined ? (type || item.mime) : qsTr("%1 · %2").arg(type || item.mime).arg(size(item.size)) } // The file's modified time is part of the URL: Qt keeps the images it has // shown by their URL, so a picture saved over would otherwise come back // with its old pixels. function thumb(path, mime, px, modified) { + // A time not read yet (undefined) gives no URL, or the picture would be asked for twice; null is a caller with no time. + if (modified === undefined) + return "" const changed = modified ? modified.getTime() : 0 return "image://thumb/" + px + "/" + changed + "/" + encodeURIComponent(mime) + "/" + encodeURIComponent(path) } diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e3..22b0335d 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -329,7 +329,7 @@ ApplicationWindow { height: 165 fillMode: Image.PreserveAspectCrop asynchronous: true - source: Format.thumb(modelData, "image/*", 256) + source: Format.thumb(modelData, "image/*", 256, null) } } } diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index ca5ddaed..61d0eae6 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -72,18 +71,52 @@ bool FolderModel::sameListing(const QVector &a, const QVector &b) // By path, not name: a folder copied with cp -a or rsync lists the same // names, sizes and times as the one it came from. for (int i = 0; i < a.size(); ++i) - // Name, subtitle, icon and details too: a plugin's item keeps its - // path (its id) when they change. - if (a[i].path != b[i].path || a[i].size != b[i].size || a[i].modified != b[i].modified - || a[i].isDir != b[i].isDir || a[i].name != b[i].name || a[i].extension != b[i].extension + // A link read again can be another type. Name, subtitle, icon and + // details too: a plugin's item keeps its path (its id) when they change. + if (a[i].path != b[i].path || a[i].isDir != b[i].isDir || a[i].mime != b[i].mime + || a[i].name != b[i].name || a[i].extension != b[i].extension || a[i].icon != b[i].icon || a[i].details != b[i].details) return false; return true; } +// The same rows read again: a size or date that changed, or was read for the first time, is taken and its row told. +void FolderModel::takeSizesAndDates(QVector entries) { + QList changed; + for (int row = 0; row < entries.size(); ++row) { + Entry &next = entries[row]; + const Entry &now = m_entries.at(row); + next.itemCount = now.itemCount; + if (next.unread && !now.unread) { + // Read since the re-read began. + next.unread = false; + next.size = now.size; + next.modified = now.modified; + } else if (!next.unread && (now.unread || m_reading.contains(row) || next.size != now.size + || next.modified != now.modified)) { + // A row this view asked for may have been read in place by another view of the listing. + changed << row; + } + } + // Nothing new keeps the listing as it is, shared with the remembered one. + if (changed.isEmpty()) + return; + m_entries = std::move(entries); + for (int row : std::as_const(changed)) { + m_reading.remove(row); + const QModelIndex at = index(row); + emit dataChanged(at, at, {SizeRole, ModifiedRole}); + } + // QML re-reads its get() snapshots on countChanged, as for the folder counts. + emit countChanged(); +} + void FolderModel::setEntries(const QVector &entries) { beginResetModel(); m_entries = entries; + // What was asked for was of the rows before; the views ask again for what they show. + m_asked.clear(); + m_reading.clear(); endResetModel(); emit countChanged(); } @@ -102,6 +135,11 @@ QVariant FolderModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() >= m_entries.size()) return {}; const Entry &e = m_entries.at(index.row()); + // Nothing until it's read, which asking starts: never a stat here, where a slow mount would freeze the window. + if (e.unread && (role == SizeRole || role == ModifiedRole)) { + ask(index.row()); + return {}; + } switch (role) { case NameRole: return e.name; case PathRole: return e.path; @@ -127,6 +165,77 @@ QHash FolderModel::roleNames() const { }; } +static bool sortsByDateOrSize(const QString &sort) { + return sort == "modified" || sort == "oldest" || sort == "size"; +} + +// A folder has no size of its own. +static void setSizeAndDate(FolderModel::Entry &entry, const QFileInfo &info) { + entry.unread = false; + entry.size = entry.isDir ? 0 : info.size(); + entry.modified = info.lastModified(); +} + +// A file gone since it was listed stays unread, with nothing wrong to show, until the folder's re-read drops it. +static void readSizeAndDate(FolderModel::Entry &entry) { + const QFileInfo info(entry.path); + if (info.exists()) + setSizeAndDate(entry, info); +} + +void FolderModel::ask(int row) const { + if (m_reading.contains(row)) + return; + m_reading.insert(row); + m_asked << row; + // Every row asked for in one turn of the event loop is read in one batch. + if (m_asked.size() == 1) + QMetaObject::invokeMethod(const_cast(this), &FolderModel::readAsked, Qt::QueuedConnection); +} + +void FolderModel::readAsked() { + QVector> rows; + for (int row : std::as_const(m_asked)) + rows.push_back({row, m_entries.at(row)}); + m_asked.clear(); + if (rows.isEmpty()) + return; + + const quint64 generation = m_generation; + auto *watcher = new QFutureWatcher>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + if (generation != m_generation) + return; // Another folder, or a re-read, which reads these rows itself. + bool updated = false; + for (const auto &[row, read] : watcher->result()) { + m_reading.remove(row); + if (read.unread || row >= m_entries.size() || m_entries.at(row).path != read.path) + continue; + // In place, without a copy: the remembered listing and every view sharing it keep what's read. + Entry &e = const_cast(m_entries.at(row)); + e.unread = false; + e.size = read.size; + e.modified = read.modified; + const QModelIndex at = index(row); + emit dataChanged(at, at, {SizeRole, ModifiedRole}); + updated = true; + } + // A batch that read nothing leaves the wait for the one that does. + if (updated && std::exchange(m_snapshotWaiting, false)) + emit countChanged(); + }); + const auto latest = m_latest; + watcher->setFuture(QtConcurrent::run([rows, latest, generation]() mutable { + for (auto &[row, entry] : rows) { + if (*latest != generation) + break; + readSizeAndDate(entry); + } + return rows; + })); +} + void FolderModel::setShowHidden(bool show) { if (show == m_showHidden) return; @@ -242,7 +351,7 @@ static QDir::Filters entryFilters(bool showHidden) { QVector FolderModel::list(const QString &path, bool showHidden, const QString &sort, QString *error, const std::function &stop, - const QLocale &locale, bool typed) { + const QLocale &locale, bool whole) { if (path == recentPath()) { // Recents are always most recently used first; that's what they're for. return listRecent(recentFile(), showHidden, 200, stop); @@ -263,31 +372,46 @@ QVector FolderModel::list(const QString &path, bool showHidd QMimeDatabase mimes; // One string per type and extension, shared by every entry that has it. QSet shared; - // Sorting by kind compares types, so they're read here. - const bool typing = typed || sort == "kind"; + // Sorting by kind compares types, and by date or size those, so they're read here. + const bool byKind = sort == "kind"; + const bool bySize = sortsByDateOrSize(sort); // Links unresolved, so broken ones, sockets and fifos are listed too, as entryFilters() has them. const auto flags = showHidden ? QDirListing::IteratorFlag::IncludeHidden : QDirListing::IteratorFlag::Default; for (const QDirListing::DirEntry &item : QDirListing(path, flags)) { if (stop && stop()) return {}; Entry e; - if (typing || !item.isFile()) { + // The folder's own listing tells a file or folder; a link, a fifo or an entry of no given type is looked up. + if (whole || item.isSymLink() || !(item.isFile() || item.isDir())) { e = entryFor(item.fileInfo(), mimes); - e.mime = *shared.insert(e.mime); - if (!e.extension.isEmpty()) - e.extension = *shared.insert(e.extension); } else { - // A regular file's type comes from its name alone, so it can wait until it's asked for. + // A type comes from the name alone, so it can wait until it's asked for. e.name = item.fileName(); e.path = item.absoluteFilePath(); - e.size = item.size(); - e.modified = item.lastModified(QTimeZone::LocalTime); + e.isDir = item.isDir(); + e.unread = true; + if (byKind) + e = typed(e); + if (bySize) + setSizeAndDate(e, item.fileInfo()); + } + if (!e.mime.isNull()) { + e.mime = *shared.insert(e.mime); + if (!e.extension.isEmpty()) + e.extension = *shared.insert(e.extension); } entries.push_back(std::move(e)); } // Growing left room for many more entries than were read; the listing is kept, so give it back. entries.squeeze(); sortEntries(entries, sort, locale); + // The first window shows whole; past it, a view asks for the rows it shows. + for (qsizetype row = 0; row < std::min(firstRows, entries.size()); ++row) { + if (stop && stop()) + return {}; + if (entries.at(row).unread) + readSizeAndDate(entries[row]); + } return entries; } @@ -296,9 +420,11 @@ FolderModel::Entry FolderModel::typed(const Entry &entry) { if (!entry.mime.isNull() || !entry.kind.isNull()) return entry; Entry e = entry; - e.mime = QMimeDatabase().mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name(); + e.mime = e.isDir ? QStringLiteral("inode/directory") + : QMimeDatabase().mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name(); e.kind = kindForMime(e.mime); - e.extension = QFileInfo(e.name).suffix().toUpper(); + if (!e.isDir) + e.extension = QFileInfo(e.name).suffix().toUpper(); return e; } @@ -307,8 +433,7 @@ FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatab e.name = info.fileName(); e.path = info.absoluteFilePath(); e.isDir = info.isDir(); - e.size = e.isDir ? 0 : info.size(); - e.modified = info.lastModified(); + setSizeAndDate(e, info); // A regular file is typed by its name alone; handing Qt the QFileInfo stats it once more first. e.mime = e.isDir ? QStringLiteral("inode/directory") : info.isFile() ? mimes.mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name() @@ -556,6 +681,13 @@ void FolderModel::reload() { const QString query = m_query; const QLocale locale; const QVariantList items = m_items; + // Rows read or asked for are read again, so a change shows and nothing asked is lost (a search, or by date or size, reads all). + QSet read; + if (query.isEmpty() && !sortsByDateOrSize(sort)) + for (int row = 0; row < m_entries.size(); ++row) + if (!m_entries.at(row).unread || m_reading.contains(row)) + read.insert(m_entries.at(row).path); + m_asked.clear(); *m_latest = generation; const auto latest = m_latest; auto *watcher = new QFutureWatcher, QString>>(this); @@ -564,12 +696,14 @@ void FolderModel::reload() { if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); - const auto [entries, error] = watcher->result(); + auto [entries, error] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); if (!same) setEntries(entries); + else + takeSizesAndDates(std::move(entries)); if (!m_selected.isEmpty()) { QSet still; for (const Entry &e : std::as_const(m_entries)) @@ -587,12 +721,19 @@ void FolderModel::reload() { remember(); countFolders(generation); }); - watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale] { + watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read] { QString error; const auto overtaken = [&] { return *latest != generation; }; auto entries = isPluginView(path) ? listItems(items, query) : query.isEmpty() || path == recentPath() ? list(path, showHidden, sort, &error, overtaken, locale, false) : search(path, query, showHidden, sort, overtaken, 1000, locale); + for (Entry &e : entries) { + if (!e.unread || !read.contains(e.path)) + continue; + if (overtaken()) + break; + readSizeAndDate(e); + } return qMakePair(entries, error); })); } @@ -646,6 +787,10 @@ QVariantMap FolderModel::get(int row) const { if (row < 0 || row >= m_entries.size()) return {}; const Entry &e = m_entries.at(row); + if (e.unread) { + ask(row); + m_snapshotWaiting = true; + } QVariantMap map = toMap(e); map.insert("selected", m_selected.contains(e.path)); return map; @@ -653,9 +798,11 @@ QVariantMap FolderModel::get(int row) const { QVariantMap FolderModel::toMap(const Entry &listed) { const Entry entry = typed(listed); + // Not read yet, the size and date are absent rather than 0 B and 1970. return { - {"name", entry.name}, {"path", entry.path}, {"isDir", entry.isDir}, {"size", entry.size}, - {"modified", entry.modified}, {"kind", entry.kind}, {"mime", entry.mime}, + {"name", entry.name}, {"path", entry.path}, {"isDir", entry.isDir}, + {"size", entry.unread ? QVariant() : entry.size}, {"modified", entry.unread ? QVariant() : entry.modified}, + {"kind", entry.kind}, {"mime", entry.mime}, {"extension", entry.extension}, {"itemCount", entry.itemCount}, {"icon", entry.icon}, {"details", entry.details}, }; diff --git a/src/foldermodel.h b/src/foldermodel.h index f6ae07e1..7ed6d8a5 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -52,9 +52,11 @@ class FolderModel : public QAbstractListModel { QString name; QString path; bool isDir = false; + // Listed by name, with the size and date left for when a view asks for them. + bool unread = false; qint64 size = 0; QDateTime modified; - // Null for a regular file the model listed: typed() reads them from its name when they're asked for. + // Null for a file or folder the model listed: typed() reads them from its name when they're asked for. QString kind; QString mime; // A file's extension; a plugin item's subtitle. @@ -107,14 +109,17 @@ class FolderModel : public QAbstractListModel { Q_INVOKABLE bool isSelected(int row) const; Q_INVOKABLE QStringList selectedPaths() const; + // Rows a listing reads sizes and dates for: 13 rows of 22 smallest tiles fill a 4K screen and what a view builds ahead. + static constexpr int firstRows = 320; + // Synchronous building blocks, used by the background passes and tests. // A listing stops, empty, as soon as `stop` returns true. Names sort // by `locale`'s alphabet; a background pass passes the one it was // started with, because the default locale may only be read on the - // thread that sets it. Untyped, it leaves a regular file's type to typed(). + // thread that sets it. Not whole, it leaves types, and sizes and dates past firstRows, unread. static QVector list(const QString &path, bool showHidden, const QString &sort, QString *error, const std::function &stop = {}, const QLocale &locale = QLocale(), - bool typed = true); + bool whole = true); static Entry entryFor(const QFileInfo &info, const QMimeDatabase &mimes); // The entry with its kind, MIME type and extension, read from its name when it was listed untyped. static Entry typed(const Entry &entry); @@ -168,6 +173,9 @@ class FolderModel : public QAbstractListModel { QString cacheKey() const; void remember(); static bool sameListing(const QVector &a, const QVector &b); + void takeSizesAndDates(QVector entries); + void ask(int row) const; + void readAsked(); void setError(const QString &error); void countFolders(quint64 generation); @@ -186,6 +194,11 @@ class FolderModel : public QAbstractListModel { // search and folder counts, so work that's been overtaken (more typing, // another folder, the model gone) stops early. std::shared_ptr> m_latest = std::make_shared>(0); + // Rows a view asked for a size or date this turn, read together, and every row asked for and not back yet. + mutable QList m_asked; + mutable QSet m_reading; + // A get() answered without a size or date, so QML takes its snapshots again (on countChanged) once they're in. + mutable bool m_snapshotWaiting = false; QFileSystemWatcher m_watcher; QTimer m_debounce; QTimer m_staleClear; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 75241770..da0574ea 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -576,43 +576,37 @@ private slots: void folderTypesOnlyWhatsAskedFor() { - // Two screens and more of plain files, beside one of every other sort of entry. - constexpr int files = 2000; QTemporaryDir dir; - for (int i = 0; i < files; ++i) - write(dir.filePath(QString("f%1.%2").arg(i).arg(i % 3 ? "txt" : "png")), QByteArray(i % 7, 'x')); - const auto named = {".bashrc", "a.tar.gz", "X.JpG", "noext", "CMakeLists.txt", "notes.md"}; - for (const char *name : named) - write(dir.filePath(name), "x"); - QDir(dir.path()).mkdir("folder.png"); - QVERIFY(QFile::link("notes.md", dir.filePath("link.txt"))); - constexpr int fileLinks = 1; // link.txt: a link to a regular file is listed as one - QVERIFY(QFile::link(dir.filePath("folder.png"), dir.filePath("linked"))); - QVERIFY(QFile::link(dir.filePath("nowhere"), dir.filePath("broken.jpg"))); - QCOMPARE(::mkfifo(QFile::encodeName(dir.filePath("pipe.txt")).constData(), 0600), 0); - constexpr int others = 4; // the folder, the link to it, the broken link and the pipe - const int all = files + int(named.size()) + fileLinks + others; + int all = 0; + oddEntries(dir, &all); + if (QTest::currentTestFailed()) + return; + constexpr int lookedUp = 4; // the link to a file, the link to the folder, the broken link and the pipe - // Listed untyped, only what isn't a regular file has a type yet; asked, each entry is as typed listing has it. + // Listed untyped, only what the folder's listing can't tell has a type yet; asked, each entry is as typed listing has it. QString error; for (const QString &sort : FolderModel::sortModes()) { const auto untyped = FolderModel::list(dir.path(), true, sort, &error, {}, QLocale(), false); const auto whole = FolderModel::list(dir.path(), true, sort, &error); QCOMPARE(untyped.size(), all); const auto typedCount = std::count_if(untyped.cbegin(), untyped.cend(), [](const auto &e) { return !e.mime.isNull(); }); - QCOMPARE(typedCount, sort == "kind" ? untyped.size() : others); + QCOMPARE(typedCount, sort == "kind" ? untyped.size() : lookedUp); for (int i = 0; i < whole.size(); ++i) { QCOMPARE(untyped.at(i).path, whole.at(i).path); - QCOMPARE(untyped.at(i).size, whole.at(i).size); - QCOMPARE(untyped.at(i).modified, whole.at(i).modified); + QCOMPARE(untyped.at(i).isDir, whole.at(i).isDir); + if (!untyped.at(i).unread) { + QCOMPARE(untyped.at(i).size, whole.at(i).size); + QCOMPARE(untyped.at(i).modified, whole.at(i).modified); + } const auto asked = FolderModel::typed(untyped.at(i)); QCOMPARE(asked.kind, whole.at(i).kind); QCOMPARE(asked.mime, whole.at(i).mime); QCOMPARE(asked.extension, whole.at(i).extension); + QCOMPARE(asked.extension.isNull(), whole.at(i).extension.isNull()); } } - // Through the model, a row's name, size, date and type roles, and the types get() gives, match a whole entry. + // Through the model, a row's type roles, and the types get() gives, match a whole entry. FolderModel model; QSignalSpy loaded(&model, &FolderModel::loaded); model.setShowHidden(true); @@ -625,8 +619,6 @@ private slots: const auto whole = FolderModel::entryFor(QFileInfo(at.data(FolderModel::PathRole).toString()), mimes); QCOMPARE(at.data(FolderModel::NameRole).toString(), whole.name); QCOMPARE(at.data(FolderModel::IsDirRole).toBool(), whole.isDir); - QCOMPARE(at.data(FolderModel::SizeRole).toLongLong(), whole.size); - QCOMPARE(at.data(FolderModel::ModifiedRole).toDateTime(), whole.modified); QCOMPARE(at.data(FolderModel::KindRole).toString(), whole.kind); QCOMPARE(at.data(FolderModel::MimeRole).toString(), whole.mime); QCOMPARE(at.data(FolderModel::ExtensionRole).toString(), whole.extension); @@ -638,6 +630,320 @@ private slots: } } + void folderReadsSizesAndDatesOfTheFirstRowsOnly() + { + // Listed for the model, a big folder reads the sizes and dates of its first window, by date or size every one. + QTemporaryDir big, small; + constexpr int files = 3000, folders = 5, all = files + folders; + for (int i = 0; i < files; ++i) + write(big.filePath(QString("f%1.txt").arg(i)), QByteArray(i % 7, 'x')); + for (int i = 0; i < folders; ++i) + QDir(big.path()).mkdir(QString("d%1").arg(i)); + for (int i = 0; i < 10; ++i) + write(small.filePath(QString("%1.txt").arg(i)), "x"); + QString error; + for (const QString &sort : FolderModel::sortModes()) { + const auto entries = FolderModel::list(big.path(), false, sort, &error, {}, QLocale(), false); + const bool bySize = sort == "modified" || sort == "oldest" || sort == "size"; + const auto read = std::count_if(entries.cbegin(), entries.cend(), [](const auto &e) { return !e.unread; }); + QCOMPARE(read, bySize ? all : FolderModel::firstRows); + // In sorted order: the first rows are the ones read. + for (int row = 0; row < entries.size(); ++row) + QCOMPARE(entries.at(row).unread, !bySize && row >= FolderModel::firstRows); + const auto few = FolderModel::list(small.path(), false, sort, &error, {}, QLocale(), false); + QCOMPARE(std::count_if(few.cbegin(), few.cend(), [](const auto &e) { return e.unread; }), 0); + } + } + + void folderReadsALaterRowWhenAViewAsks() + { + QTemporaryDir dir; + constexpr int later = 10; + for (int i = 0; i < FolderModel::firstRows + later; ++i) + write(dir.filePath(QString("f%1.txt").arg(i, 4, 10, QChar('0'))), QByteArray(i % 9, 'x')); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + + // Asked, a row not read yet answers nothing, never 0 B or 1970; every row asked in one turn is read in one batch. + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QSignalSpy counts(&model, &FolderModel::countChanged); + for (int row = FolderModel::firstRows; row < model.count(); ++row) { + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + QVERIFY(!model.index(row).data(FolderModel::ModifiedRole).isValid()); + } + QVERIFY(!model.get(FolderModel::firstRows).value("size").isValid()); + QVERIFY(!model.get(FolderModel::firstRows).value("modified").isValid()); + QCOMPARE(model.findChildren().size(), 0); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), 1); + + // One batch tells its rows in one step, never some of them first. + QTRY_VERIFY(changed.count() > 0); + QCOMPARE(changed.count(), later); + const QMimeDatabase mimes; + for (int i = 0; i < later; ++i) { + const int row = FolderModel::firstRows + i; + QCOMPARE(changed.at(i).at(0).toModelIndex().row(), row); + QCOMPARE(changed.at(i).at(1).toModelIndex().row(), row); + QCOMPARE(changed.at(i).at(2).value>(), QList({FolderModel::SizeRole, FolderModel::ModifiedRole})); + const QModelIndex at = model.index(row); + const auto whole = FolderModel::entryFor(QFileInfo(at.data(FolderModel::PathRole).toString()), mimes); + QCOMPARE(at.data(FolderModel::SizeRole).toLongLong(), whole.size); + QCOMPARE(at.data(FolderModel::ModifiedRole).toDateTime(), whole.modified); + QCOMPARE(model.get(row).value("size").toLongLong(), whole.size); + } + // The get() answered without a size is taken again. + QCOMPARE(counts.count(), 1); + // Read once, a row stays read: asking again reads nothing. + const auto batches = model.findChildren().size(); + QVERIFY(model.index(FolderModel::firstRows).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), batches); + } + + void folderShowsNothingForAFileGoneBeforeItsRead() + { + // Deleted between the listing and its row's read: no 0 B and no 1970 while the re-read that drops it comes. + QTemporaryDir dir; + for (int i = 0; i < FolderModel::firstRows + 10; ++i) + write(dir.filePath(QString("f%1.txt").arg(i, 4, 10, QChar('0'))), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + const int row = FolderModel::firstRows + 4; + const QString path = model.index(row).data(FolderModel::PathRole).toString(); + QVERIFY(QFile::remove(path)); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), 1); + QTRY_COMPARE(model.findChildren().size(), 0); + QCOMPARE(changed.count(), 0); + const int still = model.indexOfPath(path); + QVERIFY(still < 0 || !model.index(still).data(FolderModel::SizeRole).isValid()); + } + + void folderDropsWhatItReadForAFolderLeft() + { + QTemporaryDir a, b; + for (int i = 0; i < FolderModel::firstRows + 10; ++i) + write(a.filePath(QString("f%1.txt").arg(i, 4, 10, QChar('0'))), "x"); + write(b.filePath("one.txt"), "x"); + const int row = FolderModel::firstRows + 5; + { + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(a.path()); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + + // Asked, then left and come back to before the read is in: what it read is for a listing that's gone. + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), 1); + model.setPath(b.path()); + model.setPath(a.path()); + QTRY_COMPARE(model.findChildren().size(), 0); + QVERIFY(!model.loading()); + for (const auto &signal : std::as_const(changed)) + QVERIFY(!signal.at(2).value>().contains(FolderModel::SizeRole)); + } + + // A model gone with a read on its way leaves the read nothing to land on. + auto model = std::make_unique(); + model->setPath(a.path()); + QTRY_VERIFY(!model->loading()); + QTRY_COMPARE(model->findChildren().size(), 0); + QVERIFY(!model->index(row).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(model.get(), QEvent::MetaCall); + QCOMPARE(model->findChildren().size(), 1); + model.reset(); + QThreadPool::globalInstance()->waitForDone(); + QCoreApplication::processEvents(); + } + + void folderTellsASnapshotWaitingBehindABatchThatReadNothing() + { + // With the folder's parent renamed away a row reads nothing, and the folder itself, which is watched, never changes. + QTemporaryDir dir; + const QString parent = dir.filePath("parent"), away = dir.filePath("away"), folder = parent + "/folder"; + QVERIFY(QDir().mkpath(folder)); + const int nothing = FolderModel::firstRows + 1, snapshot = FolderModel::firstRows + 2, all = FolderModel::firstRows + 10; + for (int i = 0; i < all; ++i) + write(folder + QString("/f%1.txt").arg(i, 4, 10, QChar('0')), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(folder); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + + // The first batch reads nothing; a snapshot taken before it lands is read by the next. + QSignalSpy counts(&model, &FolderModel::countChanged); + QVERIFY(QFile::rename(parent, away)); + QVERIFY(!model.index(nothing).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), 1); + QThreadPool::globalInstance()->waitForDone(); + QVERIFY(QFile::rename(away, parent)); + QVERIFY(!model.get(snapshot).value("size").isValid()); + QTRY_VERIFY(model.index(snapshot).data(FolderModel::SizeRole).isValid()); + QCOMPARE(counts.count(), 1); + // No re-read stood in for it: the folder was listed once and holds every row. + QCOMPARE(loaded.count(), 1); + QCOMPARE(model.count(), all); + } + + void folderShowsAFileThatGrewAfterItsRowWasRead() + { + QTemporaryDir dir; + for (int i = 0; i < FolderModel::firstRows + 10; ++i) + write(dir.filePath(QString("f%1.txt").arg(i, 4, 10, QChar('0'))), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + // One row read with the listing, one read when asked, one never read. + const int first = 3, asked = FolderModel::firstRows + 2, never = FolderModel::firstRows + 7; + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QVERIFY(!model.index(asked).data(FolderModel::SizeRole).isValid()); + QTRY_COMPARE(changed.count(), 1); + QCOMPARE(model.index(asked).data(FolderModel::SizeRole).toLongLong(), 1); + + // Both grow, and get a date of their own; the re-read tells those two rows and resets nothing. + const QDateTime when = QDateTime::fromSecsSinceEpoch(1700000000); + for (int row : {first, asked, never}) { + QFile file(model.index(row).data(FolderModel::PathRole).toString()); + QVERIFY(file.open(QIODevice::Append)); + file.write("grown"); + file.flush(); + QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime)); + } + changed.clear(); + QSignalSpy resets(&model, &QAbstractItemModel::modelReset); + model.reload(); + QVERIFY(loaded.wait(3000)); + QCOMPARE(resets.count(), 0); + QSet told; + for (const auto &signal : std::as_const(changed)) { + QCOMPARE(signal.at(0).toModelIndex().row(), signal.at(1).toModelIndex().row()); + QCOMPARE(signal.at(2).value>(), QList({FolderModel::SizeRole, FolderModel::ModifiedRole})); + told << signal.at(0).toModelIndex().row(); + } + QCOMPARE(told, QSet({first, asked})); + for (int row : {first, asked}) { + QCOMPARE(model.index(row).data(FolderModel::SizeRole).toLongLong(), 6); + QCOMPARE(model.index(row).data(FolderModel::ModifiedRole).toDateTime(), when); + } + } + + void folderAsksEveryRowIntoAWholeListing() + { + // Every row asked for, the model holds what a whole listing does, in the same order, by name and by kind. + QTemporaryDir dir; + int all = 0; + oddEntries(dir, &all); + if (QTest::currentTestFailed()) + return; + const QMimeDatabase mimes; + for (const QString &sort : {QStringLiteral("name"), QStringLiteral("kind")}) { + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setShowHidden(true); + model.setSortMode(sort); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), all); + QTRY_COMPARE(model.findChildren().size(), 0); + int unread = 0; + for (int row = 0; row < model.count(); ++row) + unread += !model.index(row).data(FolderModel::ModifiedRole).isValid(); + QVERIFY(unread > 0); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QTRY_COMPARE(model.findChildren().size(), 0); + QString error; + const auto whole = FolderModel::list(dir.path(), true, sort, &error); + QCOMPARE(whole.size(), all); + for (int row = 0; row < model.count(); ++row) { + const QModelIndex at = model.index(row); + const auto &e = whole.at(row); + QCOMPARE(at.data(FolderModel::PathRole).toString(), e.path); + QCOMPARE(at.data(FolderModel::NameRole).toString(), e.name); + QCOMPARE(at.data(FolderModel::IsDirRole).toBool(), e.isDir); + QCOMPARE(at.data(FolderModel::SizeRole).toLongLong(), e.size); + QCOMPARE(at.data(FolderModel::ModifiedRole).toDateTime(), e.modified); + QCOMPARE(at.data(FolderModel::KindRole).toString(), e.kind); + QCOMPARE(at.data(FolderModel::MimeRole).toString(), e.mime); + QCOMPARE(at.data(FolderModel::ExtensionRole).toString(), e.extension); + QCOMPARE(at.data(FolderModel::ExtensionRole).toString().isNull(), e.extension.isNull()); + QCOMPARE(model.get(row).value("size").toLongLong(), e.size); + QCOMPARE(model.get(row).value("modified").toDateTime(), e.modified); + } + } + } + + void folderRowShowsNoSizeOrDateUntilRead() + { + // As a view draws it: a row past the first window shows no size, no date and asks for no thumbnail, then the real ones. + QTemporaryDir dir; + for (int i = 0; i < FolderModel::firstRows + 10; ++i) + write(dir.filePath(QString("p%1.png").arg(i, 4, 10, QChar('0'))), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + + QQmlEngine engine; + QQmlComponent rows(&engine); + rows.setData("import QtQml\nimport \"Format.js\" as Format\n" + "Instantiator { required property var folder; model: folder\n" + " delegate: QtObject { required property string path; required property string mime\n" + " required property var size; required property var modified\n" + " readonly property string line: Format.size(size) + \"|\" + Format.when(modified)\n" + " readonly property string thumb: Format.thumb(path, mime, 128, modified)\n" + " property var thumbs: []\n" + " Component.onCompleted: thumbs.push(thumb)\n" + " onThumbChanged: thumbs.push(thumb) } }", + QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); + std::unique_ptr view(rows.createWithInitialProperties({{"folder", QVariant::fromValue(&model)}})); + QVERIFY2(view, qPrintable(rows.errorString())); + QObject *read = nullptr, *later = nullptr; + QMetaObject::invokeMethod(view.get(), "objectAt", Q_RETURN_ARG(QObject *, read), Q_ARG(int, 0)); + QMetaObject::invokeMethod(view.get(), "objectAt", Q_RETURN_ARG(QObject *, later), Q_ARG(int, FolderModel::firstRows + 5)); + QVERIFY(read && later); + QVERIFY(read->property("line").toString().startsWith("1 B|")); + QCOMPARE(later->property("line").toString(), QString("|")); + QCOMPARE(later->property("thumb").toString(), QString()); + + QTRY_VERIFY(later->property("line").toString().startsWith("1 B|")); + const QDateTime modified = QFileInfo(dir.filePath(QString("p%1.png").arg(FolderModel::firstRows + 5, 4, 10, QChar('0')))).lastModified(); + QVERIFY(later->property("line").toString().size() > 4); + const QString thumb = later->property("thumb").toString(); + QVERIFY(thumb.startsWith(QString("image://thumb/128/%1/").arg(modified.toMSecsSinceEpoch()))); + // The thumbnail is asked for once, with its date. + QCOMPARE(later->property("thumbs").toStringList(), QStringList({QString(), thumb})); + + // Details without a size name the type alone. + QQmlComponent summary(&engine); + summary.setData("import QtQml\nimport \"Format.js\" as Format\n" + "QtObject { readonly property string unread: Format.summary({path: \"/p.png\", isDir: false, mime: \"image/png\"}, \"\")\n" + " readonly property string read: Format.summary({path: \"/p.png\", isDir: false, mime: \"image/png\", size: 0}, \"\")\n" + " readonly property string undated: Format.thumb(\"/p.png\", \"image/png\", 256, null) }", + QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); + std::unique_ptr details(summary.create()); + QVERIFY2(details, qPrintable(summary.errorString())); + QCOMPARE(details->property("unread").toString(), QString("image/png")); + QCOMPARE(details->property("read").toString(), QString("image/png · 0 B")); + // A caller with no time to give, as the preview's strip of a folder's pictures, still gets its picture. + QCOMPARE(details->property("undated").toString(), QString("image://thumb/256/0/image%2Fpng/%2Fp.png")); + } + void folderReportsMissingFolders() { QString error; @@ -6581,6 +6887,24 @@ private slots: return -1; } + // Two screens and more of plain files, beside one of every other sort of entry; `all` is how many are listed with hidden ones. + static void oddEntries(const QTemporaryDir &dir, int *all) + { + constexpr int files = 2000; + for (int i = 0; i < files; ++i) + write(dir.filePath(QString("f%1.%2").arg(i).arg(i % 3 ? "txt" : "png")), QByteArray(i % 7, 'x')); + const auto named = {".bashrc", "a.tar.gz", "X.JpG", "noext", "CMakeLists.txt", "notes.md"}; + for (const char *name : named) + write(dir.filePath(name), "x"); + QVERIFY(QDir(dir.path()).mkdir("folder.png")); + QVERIFY(QFile::link("notes.md", dir.filePath("link.txt"))); + QVERIFY(QFile::link(dir.filePath("folder.png"), dir.filePath("linked"))); + QVERIFY(QFile::link(dir.filePath("nowhere"), dir.filePath("broken.jpg"))); + QCOMPARE(::mkfifo(QFile::encodeName(dir.filePath("pipe.txt")).constData(), 0600), 0); + constexpr int others = 5; // the folder, the link to a file, the link to the folder, the broken link and the pipe + *all = files + int(named.size()) + others; + } + // Entries with every kind of tie: names equal but for case, numbers written two ways, equal sizes and dates, folders among them. static QVector sortable() { From f45515ff6f834bf54329270fb6e320e3310b5333 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:50:42 -0400 Subject: [PATCH 10/38] Count a folder again when its date moved A re-read that found the same names kept each folder's item count, even when the folder's own date had moved. Such a folder's count is now dropped and counted again, as it was before the listing learned to keep its rows through a re-read. --- src/foldermodel.cpp | 5 ++++- tests/folio_tests.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 61d0eae6..2bd24ae4 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -96,6 +96,9 @@ void FolderModel::takeSizesAndDates(QVector entries) { || next.modified != now.modified)) { // A row this view asked for may have been read in place by another view of the listing. changed << row; + // A folder whose size or date moved may hold other items now, so it is counted again. + if (next.isDir) + next.itemCount = -1; } } // Nothing new keeps the listing as it is, shared with the remembered one. @@ -105,7 +108,7 @@ void FolderModel::takeSizesAndDates(QVector entries) { for (int row : std::as_const(changed)) { m_reading.remove(row); const QModelIndex at = index(row); - emit dataChanged(at, at, {SizeRole, ModifiedRole}); + emit dataChanged(at, at, m_entries.at(row).isDir ? QList{SizeRole, ModifiedRole, ItemCountRole} : QList{SizeRole, ModifiedRole}); } // QML re-reads its get() snapshots on countChanged, as for the folder counts. emit countChanged(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index da0574ea..f2aacedb 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1161,6 +1161,33 @@ private slots: QTRY_COMPARE(model.get(1).value("itemCount").toInt(), 1); } + void folderModelCountsAFolderAgainWhenItChanged() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("Photos"); + write(dir.filePath("Photos/a.jpg"), "x"); + write(dir.filePath("notes.txt"), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.get(0).value("itemCount").toInt(), 1); + // The parent lists the same names; only the date of Photos says it holds one more. + write(dir.filePath("Photos/b.jpg"), "x"); + const timespec later[2] = {{0, UTIME_OMIT}, {QDateTime::currentSecsSinceEpoch() + 60, 0}}; + QCOMPARE(::utimensat(AT_FDCWD, QFile::encodeName(dir.filePath("Photos")).constData(), later, 0), 0); + QSignalSpy told(&model, &QAbstractItemModel::dataChanged); + model.reload(); + QVERIFY(loaded.wait(3000)); + // Its row is told that the count it showed no longer holds, before the new count comes in. + const QList uncounted{FolderModel::SizeRole, FolderModel::ModifiedRole, FolderModel::ItemCountRole}; + QVERIFY(std::any_of(told.cbegin(), told.cend(), [&](const auto &signal) { + return signal.at(0).toModelIndex().row() == 0 && signal.at(2).template value>() == uncounted; + })); + QTRY_COMPARE(model.get(0).value("itemCount").toInt(), 2); + QCOMPARE(model.get(0).value("name").toString(), QString("Photos")); + } + void folderModelForgetsAFolderLeftWhileLoading() { QTemporaryDir dir; From 3905ff91509896c995157b473d9474444fea6874 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:50:42 -0400 Subject: [PATCH 11/38] Watch Hyprland again after its events stop The event socket was opened once. Hyprland hangs up on a reader that fell behind, and after that a config reload no longer reached the corners. The watch is now taken up again when Folio is back in use. --- src/theme.cpp | 8 ++++++-- tests/folio_tests.cpp | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index 97403554..4a32ff84 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -109,8 +109,12 @@ Theme::Theme(const QString &directory, QObject *parent, const QString &hyprctl) if (!m_hyprctl.isEmpty()) { // Hyprland names each config reload; a value set any other way is read when Folio is back in use. connect(qGuiApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { - if (state == Qt::ApplicationActive) - refreshRounding(); + if (state != Qt::ApplicationActive) + return; + // Hyprland hangs up on a reader that fell behind, so a watch that ended is taken up again here. + if (m_hyprlandEvents < 0) + watchHyprland(); + refreshRounding(); }); // Subscribed before the first ask, so a reload during that ask isn't missed. watchHyprland(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index f2aacedb..661af4b9 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -6609,6 +6610,39 @@ private slots: QCOMPARE(countRuns(runs), 2); } + void themeWatchesHyprlandAgainOnceItsEventsStopped() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + QTRY_COMPARE(theme.radius(), 2); + QCOMPARE(theme.findChildren(Qt::FindDirectChildrenOnly).size(), 1); + // Hyprland hangs up, as it does on a reader that fell behind, and the theme lets go of the socket. + ::close(events); + QTRY_VERIFY(theme.findChildren(Qt::FindDirectChildrenOnly).isEmpty()); + write(response, "{\"int\":4}"); + emit qGuiApp->applicationStateChanged(Qt::ApplicationActive); + const int again = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(again >= 0); + const auto hangUp = qScopeGuard([again] { ::close(again); }); + // Coming back asked once, and that ask is over; the next change is told by the new watch alone. + QTRY_COMPARE(theme.radius(), 4); + QCOMPARE(countRuns(runs), 2); + write(response, "{\"int\":6}"); + send(again, "configreloaded>>\n"); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(countRuns(runs), 3); + } + void themeAsksHyprlandOnlyWhenSomethingChanges() { QTemporaryDir dir, stage; From 5e44c3644693234620badf2758967be2ff9e6e64 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 10:39:45 -0400 Subject: [PATCH 12/38] Ask for a text file's page once its date is read A text tile past the rows read at first asked for its page with no date, then again when the date arrived, and a page has no cache on disk, so the file was read and drawn twice. Like a thumbnail, a page is now asked for only once its date is known. --- src/Format.js | 3 +++ tests/folio_tests.cpp | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Format.js b/src/Format.js index 092c215c..59f11b15 100644 --- a/src/Format.js +++ b/src/Format.js @@ -65,6 +65,9 @@ function thumb(path, mime, px, modified) { // A text file's first lines on a page in the theme's colors. function page(path, px, modified, paper, ink) { + // As for a thumbnail: a time not read yet gives no URL, or the page would be read and drawn twice. + if (modified === undefined) + return "" const changed = modified ? modified.getTime() : 0 const hex = (color) => String(color).slice(1) return "image://thumb/page/" + px + "/" + changed + "/" + hex(paper) + "/" + hex(ink) + "/" + encodeURIComponent(path) diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 661af4b9..2290cbaf 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -908,9 +908,12 @@ private slots: " required property var size; required property var modified\n" " readonly property string line: Format.size(size) + \"|\" + Format.when(modified)\n" " readonly property string thumb: Format.thumb(path, mime, 128, modified)\n" + " readonly property string page: Format.page(path, 256, modified, \"#101010\", \"#f0f0f0\")\n" " property var thumbs: []\n" - " Component.onCompleted: thumbs.push(thumb)\n" - " onThumbChanged: thumbs.push(thumb) } }", + " property var pages: []\n" + " Component.onCompleted: { thumbs.push(thumb); pages.push(page) }\n" + " onThumbChanged: thumbs.push(thumb)\n" + " onPageChanged: pages.push(page) } }", QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); std::unique_ptr view(rows.createWithInitialProperties({{"folder", QVariant::fromValue(&model)}})); QVERIFY2(view, qPrintable(rows.errorString())); @@ -927,8 +930,11 @@ private slots: QVERIFY(later->property("line").toString().size() > 4); const QString thumb = later->property("thumb").toString(); QVERIFY(thumb.startsWith(QString("image://thumb/128/%1/").arg(modified.toMSecsSinceEpoch()))); - // The thumbnail is asked for once, with its date. + // The thumbnail is asked for once, with its date, and so is a text file's page. QCOMPARE(later->property("thumbs").toStringList(), QStringList({QString(), thumb})); + const QString page = later->property("page").toString(); + QVERIFY(page.startsWith(QString("image://thumb/page/256/%1/").arg(modified.toMSecsSinceEpoch()))); + QCOMPARE(later->property("pages").toStringList(), QStringList({QString(), page})); // Details without a size name the type alone. QQmlComponent summary(&engine); From b3c92e2db864fd8ee9e026c00b9c72f768ca67c2 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 21:37:40 -0400 Subject: [PATCH 13/38] Count a folder no view asked for again when it changed A folder past the rows read at first is counted without its date being read, so a re-read had nothing to compare and kept its old count. Its date is now read just before it is counted and kept with the count, and the re-read sees the folder changed since. --- src/foldermodel.cpp | 25 +++++++++--- tests/folio_tests.cpp | 95 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2bd24ae4..3ec9923e 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -217,9 +217,12 @@ void FolderModel::readAsked() { continue; // In place, without a copy: the remembered listing and every view sharing it keep what's read. Entry &e = const_cast(m_entries.at(row)); - e.unread = false; - e.size = read.size; - e.modified = read.modified; + // A folder counted since it was asked for keeps the date its count was made with. + if (e.unread) { + e.unread = false; + e.size = read.size; + e.modified = read.modified; + } const QModelIndex at = index(row); emit dataChanged(at, at, {SizeRole, ModifiedRole}); updated = true; @@ -761,9 +764,18 @@ void FolderModel::countFolders(quint64 generation) { for (const auto &[row, summary] : watcher->result()) { if (row >= m_entries.size() || m_entries.at(row).path != summary.path) continue; - m_entries[row].itemCount = summary.itemCount; + Entry &entry = m_entries[row]; + entry.itemCount = summary.itemCount; + QList roles{ItemCountRole}; + // A count goes with the date read before it: one a view read meanwhile may be newer, and would hide the change from a re-read. + if (!summary.unread && (entry.unread || entry.modified != summary.modified)) { + entry.unread = false; + entry.size = summary.size; + entry.modified = summary.modified; + roles = {SizeRole, ModifiedRole, ItemCountRole}; + } const QModelIndex at = index(row); - emit dataChanged(at, at, {ItemCountRole}); + emit dataChanged(at, at, roles); updated = true; } // QML re-reads its get() snapshots (the current item, which the @@ -780,6 +792,9 @@ void FolderModel::countFolders(quint64 generation) { for (auto &folder : folders) { if (*latest != generation) break; + // The date is read first: a change made while it's counted then shows as a newer date at the next re-read. + if (folder.second.unread) + readSizeAndDate(folder.second); folder.second.itemCount = itemCount(folder.second.path, showHidden); } return folders; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 2290cbaf..18262c27 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1195,6 +1195,41 @@ private slots: QCOMPARE(model.get(0).value("name").toString(), QString("Photos")); } + void folderModelCountsAFolderNoViewAskedForAgainWhenItChanged() + { + // Past the rows read at first and never asked for by a view, a folder is counted all the same: a re-read must see it changed. + QTemporaryDir dir; + const int last = FolderModel::firstRows + 9; + for (int i = 0; i <= last; ++i) + QVERIFY(QDir(dir.path()).mkpath(QString("d%1").arg(i, 4, 10, QChar('0')))); + const QString far = dir.filePath(QString("d%1").arg(last, 4, 10, QChar('0'))); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.index(last).data(FolderModel::NameRole).toString(), QFileInfo(far).fileName()); + // The count is read as a view's delegate reads it, which asks for nothing. + QTRY_COMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), 0); + write(far + "/a.jpg", "x"); + const timespec later[2] = {{0, UTIME_OMIT}, {QDateTime::currentSecsSinceEpoch() + 60, 0}}; + QCOMPARE(::utimensat(AT_FDCWD, QFile::encodeName(far).constData(), later, 0), 0); + model.reload(); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), 1); + } + + void folderModelKeepsTheDateAFolderWasCountedWith() + { + // A view's read of a folder's date lands after the folder was counted and then changed: the count's own date stays, so a re-read sees the change. + countedThenChangedAndReadForAView(false); + } + + void folderModelKeepsACountsDateOverAViewsReadThatLandedFirst() + { + // The same read lands ahead of the count made before it: the count brings its own, older date, and a re-read sees the change. + countedThenChangedAndReadForAView(true); + } + void folderModelForgetsAFolderLeftWhileLoading() { QTemporaryDir dir; @@ -7047,6 +7082,66 @@ private slots: QVERIFY(file.open(QIODevice::WriteOnly)); file.write(contents); } + // A folder past the first rows is counted, then changed, then read for a view; the two landings come in either order. + static void countedThenChangedAndReadForAView(bool readLandsFirst) + { + QTemporaryDir dir; + const int last = FolderModel::firstRows + 9; + for (int i = 0; i <= last; ++i) + QVERIFY(QDir(dir.path()).mkpath(QString("d%1").arg(i, 4, 10, QChar('0')))); + const QString far = dir.filePath(QString("d%1").arg(last, 4, 10, QChar('0'))); + QThreadPool *pool = QThreadPool::globalInstance(); + pool->waitForDone(); + const int threads = pool->maxThreadCount(); + pool->setMaxThreadCount(1); + QSemaphore counted, changed; + const auto restore = qScopeGuard([&] { + changed.release(); + pool->waitForDone(); + pool->setMaxThreadCount(threads); + }); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded), told(&model, &FolderModel::dataChanged); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + // One pool thread: the count is on it, this waits behind the count, and the view's read behind this. + pool->start([&] { + counted.release(); + changed.acquire(); + }); + QVERIFY(!model.index(last).data(FolderModel::ModifiedRole).isValid()); + QVERIFY(counted.tryAcquire(1, 3000)); + write(far + "/a.jpg", "x"); + const timespec later[2] = {{0, UTIME_OMIT}, {QDateTime::currentSecsSinceEpoch() + 60, 0}}; + QCOMPARE(::utimensat(AT_FDCWD, QFile::encodeName(far).constData(), later, 0), 0); + const auto tellings = [&] { + int rows = 0; + for (const auto &call : told) + rows += call.at(0).toModelIndex().row() == last; + return rows; + }; + if (readLandsFirst) { + // Each landing waits in its own watcher's events: the view's read is started and lands with the count's still waiting. + const auto watchers = model.findChildren().size(); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), watchers + 1); + changed.release(); + pool->waitForDone(); + QCoreApplication::sendPostedEvents(model.findChildren().constLast()); + QCOMPARE(tellings(), 1); + QCOMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), -1); + } else { + QCoreApplication::processEvents(); + changed.release(); + } + // The row is told twice: once counted, as it was before the change, and once read for the view, after it. + QTRY_COMPARE(tellings(), 2); + QCOMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), 0); + model.reload(); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), 1); + } + // A hyprctl that notes each run and prints the response file. static QString standInHyprctl(const QTemporaryDir &dir, const QString &response, const QString &runs) { From b0dee616dd32f8fe4bf74ec163906f01592a49f4 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 21:37:40 -0400 Subject: [PATCH 14/38] Read Hyprland's events a piece at a time A burst of events was read whole into memory before any of it was looked at, on the UI thread. One turn of the event loop now reads 64 KiB at most and looks at each piece as it comes, and a line too long to keep is dropped to its end, its last piece included. --- src/theme.cpp | 43 ++++++++++++++++++-------- src/theme.h | 5 ++++ tests/folio_tests.cpp | 70 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 13 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index 4a32ff84..659673c4 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -164,20 +164,36 @@ void Theme::watchHyprland() { } void Theme::readHyprlandEvents() { - constexpr qsizetype longestLine = 64 * 1024; // window titles arrive here too; no line we want is this long char chunk[4096]; - ssize_t got; - while ((got = ::read(m_hyprlandEvents, chunk, sizeof chunk)) > 0 || (got < 0 && errno == EINTR)) - if (got > 0) - m_hyprlandLine.append(chunk, got); - const bool closed = got == 0 || (errno != EAGAIN && errno != EWOULDBLOCK); - - // Sample input: "activewindow>>kitty,~\nconfigreloaded>>\n"; only a whole configreloaded line counts. - bool reloaded = false; - for (qsizetype end; (end = m_hyprlandLine.indexOf('\n')) >= 0; m_hyprlandLine.remove(0, end + 1)) - reloaded |= QByteArrayView(m_hyprlandLine).first(end) == "configreloaded>>"; - if (m_hyprlandLine.size() > longestLine) - m_hyprlandLine.clear(); + bool reloaded = false, closed = false; + for (qsizetype read = 0; read < eventsATurn; read += sizeof chunk) { + const ssize_t got = ::read(m_hyprlandEvents, chunk, sizeof chunk); + if (got < 0 && errno == EINTR) + continue; + if (got < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + break; + if (got <= 0) { + closed = true; + break; + } + QByteArrayView piece(chunk, got); + // The rest of a line too long to want is dropped up to its end, never read as a line of its own. + if (m_hyprlandLineTooLong) { + const qsizetype end = piece.indexOf('\n'); + if (end < 0) + continue; + piece = piece.sliced(end + 1); + m_hyprlandLineTooLong = false; + } + m_hyprlandLine.append(piece); + // Sample input: "activewindow>>kitty,~\nconfigreloaded>>\n"; only a whole configreloaded line counts. + for (qsizetype end; (end = m_hyprlandLine.indexOf('\n')) >= 0; m_hyprlandLine.remove(0, end + 1)) + reloaded |= QByteArrayView(m_hyprlandLine).first(end) == "configreloaded>>"; + if (m_hyprlandLine.size() > longestEventLine) { + m_hyprlandLine.clear(); + m_hyprlandLineTooLong = true; + } + } if (reloaded) refreshRounding(); // Hyprland is gone, and the corners keep their last rounding. @@ -195,6 +211,7 @@ void Theme::closeHyprlandEvents() { ::close(m_hyprlandEvents); m_hyprlandEvents = -1; m_hyprlandLine.clear(); + m_hyprlandLineTooLong = false; } QColor Theme::color(const QString &key) const { diff --git a/src/theme.h b/src/theme.h index b11ed825..a05cd91d 100644 --- a/src/theme.h +++ b/src/theme.h @@ -51,6 +51,10 @@ class Theme : public QObject { QColor orange() const { return color("orange"); } bool light() const { return m_light; } int radius() const { return m_radius; } + // The most of Hyprland's events read in one turn of the event loop: a burst is read over several, so the window stays live. + static constexpr qsizetype eventsATurn = 64 * 1024; + // The longest line of Hyprland's events kept: window titles arrive there too, and no line we want is this long. + static constexpr qsizetype longestEventLine = 64 * 1024; signals: void changed(); @@ -76,4 +80,5 @@ class Theme : public QObject { int m_hyprlandEvents = -1; QSocketNotifier *m_hyprlandNotifier = nullptr; QByteArray m_hyprlandLine; + bool m_hyprlandLineTooLong = false; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 18262c27..125ddd37 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6621,6 +6621,76 @@ private slots: QCOMPARE(countRuns(runs), 2); } + void themeReadsABurstOfHyprlandEventsOverSeveralTurns() + { + // More events than one turn reads are read a piece a turn, and the reload at their end still counts. + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); + QTRY_COMPARE(theme.radius(), 2); + write(response, "{\"int\":6}"); + QByteArray burst; + while (burst.size() < 2 * Theme::eventsATurn) + burst += "activewindow>>kitty,a title\n"; + burst += "configreloaded>>\n"; + // Room asked for the whole burst unread, and sent without waiting, so a socket too small fails here instead of hanging. + const int room = 2 * int(burst.size()); + QCOMPARE(::setsockopt(events, SOL_SOCKET, SO_SNDBUF, &room, sizeof room), 0); + QCOMPARE(::send(events, burst.constData(), burst.size(), MSG_DONTWAIT), ssize_t(burst.size())); + const int queued = unread(events); + // One turn at a time, up to the first that reads: it leaves the rest for the turns after it. + for (int turn = 0; turn < 100 && unread(events) == queued; ++turn) + QCoreApplication::processEvents(); + QVERIFY(unread(events) < queued); + QVERIFY(unread(events) > 0); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(unread(events), 0); + QCOMPARE(countRuns(runs), 2); + } + + void themeDropsTheRestOfAHyprlandLineTooLong() + { + // A line longer than any kept is dropped whole: its last piece, which reads like a reload, asks nothing. + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); + QTRY_COMPARE(theme.radius(), 2); + write(response, "{\"int\":6}"); + // All the theme keeps of one line, then one byte more: the line is given up there, with nothing of it left over. + send(events, QByteArray(Theme::longestEventLine, 'x')); + QTRY_COMPARE(unread(events), 0); + send(events, "x"); + QTRY_COMPARE(unread(events), 0); + // The line's end comes last, and reads like a reload standing alone. + send(events, "configreloaded>>\n"); + QTRY_COMPARE(unread(events), 0); + QTest::qWait(300); + QCOMPARE(countRuns(runs), 1); + send(events, "configreloaded>>\n"); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(countRuns(runs), 2); + } + void themeAsksAgainForAReloadDuringAnAsk() { QTemporaryDir dir, stage; From e6e7ae8059d271004712d4964fcfa95078751bab Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 22:25:26 -0400 Subject: [PATCH 15/38] Watch Hyprland again after a hang-up while in use Hung up on by Hyprland, the theme took its watch up again only when Folio was left and come back to, so a config reload in between left the corners as they were. While in use it now connects again after a pause, and reads the rounding for a reload that went by untold. --- src/theme.cpp | 20 +++++++++++++++++--- src/theme.h | 4 ++++ tests/folio_tests.cpp | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index 659673c4..ffc01ab3 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -109,13 +109,24 @@ Theme::Theme(const QString &directory, QObject *parent, const QString &hyprctl) if (!m_hyprctl.isEmpty()) { // Hyprland names each config reload; a value set any other way is read when Folio is back in use. connect(qGuiApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { - if (state != Qt::ApplicationActive) + m_inUse = state == Qt::ApplicationActive; + m_hyprlandAgain.stop(); + if (!m_inUse) return; // Hyprland hangs up on a reader that fell behind, so a watch that ended is taken up again here. if (m_hyprlandEvents < 0) watchHyprland(); refreshRounding(); }); + // Hung up on while Folio stays in use, it connects again after a pause and reads the rounding: a reload may have gone by untold. + m_hyprlandAgain.setSingleShot(true); + m_hyprlandAgain.setInterval(watchAgainAfter); + connect(&m_hyprlandAgain, &QTimer::timeout, this, [this] { + if (m_hyprlandEvents < 0) + watchHyprland(); + if (m_hyprlandEvents >= 0) + refreshRounding(); + }); // Subscribed before the first ask, so a reload during that ask isn't missed. watchHyprland(); } @@ -196,9 +207,12 @@ void Theme::readHyprlandEvents() { } if (reloaded) refreshRounding(); - // Hyprland is gone, and the corners keep their last rounding. - if (closed) + // Hung up on, or Hyprland is gone: a try after a pause while Folio is in use, and without an answer the corners keep their last rounding. + if (closed) { closeHyprlandEvents(); + if (m_inUse) + m_hyprlandAgain.start(); + } } void Theme::closeHyprlandEvents() { diff --git a/src/theme.h b/src/theme.h index a05cd91d..997c348b 100644 --- a/src/theme.h +++ b/src/theme.h @@ -55,6 +55,8 @@ class Theme : public QObject { static constexpr qsizetype eventsATurn = 64 * 1024; // The longest line of Hyprland's events kept: window titles arrive there too, and no line we want is this long. static constexpr qsizetype longestEventLine = 64 * 1024; + // How long after Hyprland hung up the watch is taken up again, in milliseconds. + static constexpr int watchAgainAfter = 1000; signals: void changed(); @@ -79,6 +81,8 @@ class Theme : public QObject { bool m_roundingPending = false; int m_hyprlandEvents = -1; QSocketNotifier *m_hyprlandNotifier = nullptr; + QTimer m_hyprlandAgain; + bool m_inUse = true; QByteArray m_hyprlandLine; bool m_hyprlandLineTooLong = false; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 125ddd37..a98ec20f 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -6737,10 +6738,15 @@ private slots: QVERIFY(events >= 0); QTRY_COMPARE(theme.radius(), 2); QCOMPARE(theme.findChildren(Qt::FindDirectChildrenOnly).size(), 1); - // Hyprland hangs up, as it does on a reader that fell behind, and the theme lets go of the socket. + // Away from Folio, Hyprland hangs up, as it does on a reader that fell behind, and the theme lets go of the socket. + emit qGuiApp->applicationStateChanged(Qt::ApplicationInactive); ::close(events); QTRY_VERIFY(theme.findChildren(Qt::FindDirectChildrenOnly).isEmpty()); write(response, "{\"int\":4}"); + // Nothing connects again while away, however long; coming back does. + QTest::qWait(2 * Theme::watchAgainAfter); + pollfd knock{hyprland, POLLIN, 0}; + QCOMPARE(::poll(&knock, 1, 0), 0); emit qGuiApp->applicationStateChanged(Qt::ApplicationActive); const int again = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); QVERIFY(again >= 0); @@ -6754,6 +6760,40 @@ private slots: QCOMPARE(countRuns(runs), 3); } + void themeWatchesHyprlandAgainAfterAHangUpWhileInUse() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + const int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([=] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + QTRY_COMPARE(theme.radius(), 2); + // Hyprland hangs up and then reloads its config: nobody is told of that reload. + ::close(events); + QTRY_VERIFY(theme.findChildren(Qt::FindDirectChildrenOnly).isEmpty()); + write(response, "{\"int\":4}"); + // Folio is never left and come back to: the theme connects again by itself after its pause. + pollfd knock{hyprland, POLLIN, 0}; + QTRY_COMPARE_WITH_TIMEOUT(::poll(&knock, 1, 0), 1, 5 * Theme::watchAgainAfter); + const int again = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(again >= 0); + const auto hangUp = qScopeGuard([again] { ::close(again); }); + // Connected again, it reads the rounding it wasn't told of, once; the next change is told by the new watch. + QTRY_COMPARE(theme.radius(), 4); + QCOMPARE(countRuns(runs), 2); + write(response, "{\"int\":6}"); + send(again, "configreloaded>>\n"); + QTRY_COMPARE(theme.radius(), 6); + QCOMPARE(countRuns(runs), 3); + } + void themeAsksHyprlandOnlyWhenSomethingChanges() { QTemporaryDir dir, stage; From 777a05a07cc81d6b983de17c6bf4f2a809ecdb40 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 23:27:26 -0400 Subject: [PATCH 16/38] Keep trying Hyprland while in use until it answers Hung up on while Folio stayed in use, the theme tried Hyprland's event socket once more after a pause. A try that found no socket was the last, and reloads went untold until Folio was left and come back to. Each failed try now starts the pause again. --- src/theme.cpp | 6 ++-- tests/folio_tests.cpp | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index ffc01ab3..8ac494e0 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -164,10 +164,10 @@ void Theme::watchHyprland() { return; std::memcpy(address.sun_path, path.constData(), path.size()); m_hyprlandEvents = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); - if (m_hyprlandEvents < 0) - return; - if (::connect(m_hyprlandEvents, reinterpret_cast(&address), sizeof address) != 0) { + if (m_hyprlandEvents < 0 || ::connect(m_hyprlandEvents, reinterpret_cast(&address), sizeof address) != 0) { closeHyprlandEvents(); + // No answer: tried again after each pause, as one try alone would leave the corners untold; leaving Folio stops the tries. + m_hyprlandAgain.start(); return; } m_hyprlandNotifier = new QSocketNotifier(m_hyprlandEvents, QSocketNotifier::Read, this); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index a98ec20f..df6d10be 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6794,6 +6794,77 @@ private slots: QCOMPARE(countRuns(runs), 3); } + void themeKeepsTryingHyprlandWhileInUseUntilItAnswers() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([&hyprland, runtime] { stopHyprland(hyprland, runtime); }); + + Theme theme(dir.path(), nullptr, command); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + QTRY_COMPARE(theme.radius(), 2); + // Hyprland hangs up and its socket is gone for longer than one pause: the first try after it finds nothing there. + ::close(std::exchange(hyprland, -1)); + QVERIFY(QFile::remove(stage.filePath("hypr/test/.socket2.sock"))); + ::close(events); + QTRY_VERIFY(theme.findChildren(Qt::FindDirectChildrenOnly).isEmpty()); + QSignalSpy states(qGuiApp, &QGuiApplication::applicationStateChanged); + // Two tries and more go by with nothing there, so no single try is the one that connects. + QTest::qWait(Theme::watchAgainAfter * 5 / 2); + // The socket is back, with a reload gone by untold; Folio was never left and come back to, and a later try finds it. + write(response, "{\"int\":4}"); + hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + pollfd knock{hyprland, POLLIN, 0}; + QTRY_COMPARE_WITH_TIMEOUT(::poll(&knock, 1, 0), 1, 5 * Theme::watchAgainAfter); + const int again = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(again >= 0); + const auto hangUp = qScopeGuard([again] { ::close(again); }); + QTRY_COMPARE(theme.radius(), 4); + QCOMPARE(countRuns(runs), 2); + // Coming back to Folio connects too, and didn't happen: a try after a pause did this. + QCOMPARE(states.size(), 0); + } + + void themeWatchesAHyprlandThatWasNotThereAtFirst() + { + QTemporaryDir dir, stage; + const QString response = stage.filePath("response.json"), runs = stage.filePath("runs"); + const QString command = standInHyprctl(stage, response, runs); + write(response, "{\"int\":2}"); + const QByteArray runtime = qgetenv("XDG_RUNTIME_DIR"); + int hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + const auto restore = qScopeGuard([&hyprland, runtime] { stopHyprland(hyprland, runtime); }); + // Hyprland is named and its socket isn't there: the theme's first try, as it's made, finds nothing. + ::close(std::exchange(hyprland, -1)); + QVERIFY(QFile::remove(stage.filePath("hypr/test/.socket2.sock"))); + + Theme theme(dir.path(), nullptr, command); + QSignalSpy states(qGuiApp, &QGuiApplication::applicationStateChanged); + QTRY_COMPARE(theme.radius(), 2); + QVERIFY(theme.findChildren(Qt::FindDirectChildrenOnly).isEmpty()); + // The socket appears, with a reload gone by untold; Folio's state never changes, and a later try finds it. + write(response, "{\"int\":4}"); + hyprland = listenAsHyprland(stage.path()); + QVERIFY(hyprland >= 0); + pollfd knock{hyprland, POLLIN, 0}; + QTRY_COMPARE_WITH_TIMEOUT(::poll(&knock, 1, 0), 1, 5 * Theme::watchAgainAfter); + const int events = ::accept4(hyprland, nullptr, nullptr, SOCK_CLOEXEC); + QVERIFY(events >= 0); + const auto hangUp = qScopeGuard([events] { ::close(events); }); + QTRY_COMPARE(theme.radius(), 4); + QCOMPARE(countRuns(runs), 2); + // Coming back to Folio connects too, and didn't happen: a try after a pause did this. + QCOMPARE(states.size(), 0); + } + void themeAsksHyprlandOnlyWhenSomethingChanges() { QTemporaryDir dir, stage; From a28e52e631edf60856cc8a30001568181c216771 Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 01:20:21 -0400 Subject: [PATCH 17/38] Let a row be asked again after a failed re-read A re-read drops the answers on their way and reads the rows asked for itself. A row it could not read either stayed marked as being read, so no view could ask for it again. Such a row is now owed: a view may ask again, the folder's next re-read tries it again, and a view is told when another view read it. --- src/foldermodel.cpp | 20 ++++++- src/foldermodel.h | 2 + tests/folio_tests.cpp | 129 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 3ec9923e..cebb5e73 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -92,7 +92,10 @@ void FolderModel::takeSizesAndDates(QVector entries) { next.unread = false; next.size = now.size; next.modified = now.modified; - } else if (!next.unread && (now.unread || m_reading.contains(row) || next.size != now.size + // Read by another view of the listing while this one is owed the row: this one is told too. + if (m_owed.contains(row)) + changed << row; + } else if (!next.unread && (now.unread || m_owed.contains(row) || next.size != now.size || next.modified != now.modified)) { // A row this view asked for may have been read in place by another view of the listing. changed << row; @@ -107,6 +110,7 @@ void FolderModel::takeSizesAndDates(QVector entries) { m_entries = std::move(entries); for (int row : std::as_const(changed)) { m_reading.remove(row); + m_owed.remove(row); const QModelIndex at = index(row); emit dataChanged(at, at, m_entries.at(row).isDir ? QList{SizeRole, ModifiedRole, ItemCountRole} : QList{SizeRole, ModifiedRole}); } @@ -120,6 +124,7 @@ void FolderModel::setEntries(const QVector &entries) { // What was asked for was of the rows before; the views ask again for what they show. m_asked.clear(); m_reading.clear(); + m_owed.clear(); endResetModel(); emit countChanged(); } @@ -213,8 +218,14 @@ void FolderModel::readAsked() { bool updated = false; for (const auto &[row, read] : watcher->result()) { m_reading.remove(row); - if (read.unread || row >= m_entries.size() || m_entries.at(row).path != read.path) + if (row >= m_entries.size() || m_entries.at(row).path != read.path) continue; + // Asked for and not read: the row is owed, so the folder's next re-read tries it again. + if (read.unread) { + m_owed.insert(row); + continue; + } + m_owed.remove(row); // In place, without a copy: the remembered listing and every view sharing it keep what's read. Entry &e = const_cast(m_entries.at(row)); // A folder counted since it was asked for keeps the date its count was made with. @@ -691,8 +702,11 @@ void FolderModel::reload() { QSet read; if (query.isEmpty() && !sortsByDateOrSize(sort)) for (int row = 0; row < m_entries.size(); ++row) - if (!m_entries.at(row).unread || m_reading.contains(row)) + if (!m_entries.at(row).unread || m_reading.contains(row) || m_owed.contains(row)) read.insert(m_entries.at(row).path); + // The answers on their way are dropped with the generation: this re-read owes their rows now, and a view may ask again. + m_owed.unite(m_reading); + m_reading.clear(); m_asked.clear(); *m_latest = generation; const auto latest = m_latest; diff --git a/src/foldermodel.h b/src/foldermodel.h index 7ed6d8a5..78f1ff19 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -197,6 +197,8 @@ class FolderModel : public QAbstractListModel { // Rows a view asked for a size or date this turn, read together, and every row asked for and not back yet. mutable QList m_asked; mutable QSet m_reading; + // Rows asked for and not read by the try that followed: a view may ask again, and the folder's next re-read reads them. + mutable QSet m_owed; // A get() answered without a size or date, so QML takes its snapshots again (on countChanged) once they're in. mutable bool m_snapshotWaiting = false; QFileSystemWatcher m_watcher; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index df6d10be..c1acee27 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -801,6 +801,135 @@ private slots: QCOMPARE(model.count(), all); } + void folderLetsARowBeAskedAgainAfterAReReadCouldNotReadIt() + { + if (geteuid() == 0) + QSKIP("Root can read every file."); + // A folder that may be listed and not searched gives its names and no sizes: a re-read lists the same rows and reads none. + QTemporaryDir dir; + const QString folder = dir.filePath("folder"); + QVERIFY(QDir().mkpath(folder)); + constexpr int files = 3, row = 1; + for (int i = 0; i < files; ++i) + write(folder + QString("/f%1.txt").arg(i), "x"); + const auto whole = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner; + QVERIFY(QFile::setPermissions(folder, QFile::ReadOwner)); + const auto restore = qScopeGuard([&] { QFile::setPermissions(folder, whole); }); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded), resets(&model, &FolderModel::modelReset); + model.setPath(folder); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), files); + QTRY_COMPARE(model.findChildren().size(), 0); + resets.clear(); + + // The row is asked for, and a re-read takes the asking over and can't read it either. + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + model.reload(); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + // The same rows were kept: a reset would have let every asking go by itself. + QCOMPARE(resets.count(), 0); + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + + // Asked for again, it is read: its answer lands with no turn of the event loop, where a re-read for the folder's own change could stand in. + QVERIFY(QFile::setPermissions(folder, whole)); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + const auto watchers = model.findChildren(); + QCOMPARE(watchers.size(), 1); + QThreadPool::globalInstance()->waitForDone(); + QCoreApplication::sendPostedEvents(watchers.constFirst()); + QVERIFY(model.index(row).data(FolderModel::SizeRole).isValid()); + } + + void folderTellsAViewOfARowAnotherViewReadBehindItsReRead() + { + if (geteuid() == 0) + QSKIP("Root can read every file."); + QTemporaryDir dir; + const QString folder = dir.filePath("folder"); + QVERIFY(QDir().mkpath(folder)); + constexpr int files = 3, row = 1; + for (int i = 0; i < files; ++i) + write(folder + QString("/f%1.txt").arg(i), "x"); + const auto whole = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner; + QVERIFY(QFile::setPermissions(folder, QFile::ReadOwner)); + const auto restore = qScopeGuard([&] { QFile::setPermissions(folder, whole); }); + QThreadPool *pool = QThreadPool::globalInstance(); + FolderModel first, second; + QSignalSpy loaded(&first, &FolderModel::loaded); + first.setPath(folder); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(first.findChildren().size(), 0); + // A second view shares the listing the first one left. + second.setPath(folder); + QCOMPARE(second.count(), files); + QTRY_COMPARE(second.findChildren().size(), 0); + + // The first view asks for a row, and its re-read owes the row and can't read it: the answer waits, not landed. + QSignalSpy told(&first, &FolderModel::dataChanged), resets(&first, &FolderModel::modelReset); + QVERIFY(!first.index(row).data(FolderModel::SizeRole).isValid()); + first.reload(); + pool->waitForDone(); + // Meanwhile the second view reads the row, in place in the listing both show. + QVERIFY(QFile::setPermissions(folder, whole)); + QVERIFY(!second.index(row).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&second, QEvent::MetaCall); + QCOMPARE(second.findChildren().size(), 1); + pool->waitForDone(); + QCoreApplication::sendPostedEvents(second.findChildren().constFirst()); + QVERIFY(second.index(row).data(FolderModel::SizeRole).isValid()); + QCOMPARE(told.count(), 0); + + // The re-read lands with the row unread: the first view is told of the row it is owed, once, and keeps its rows. + QCOMPARE(first.findChildren().size(), 1); + QCoreApplication::sendPostedEvents(first.findChildren().constFirst()); + QCOMPARE(resets.count(), 0); + QCOMPARE(told.count(), 1); + QCOMPARE(told.at(0).at(0).toModelIndex().row(), row); + QVERIFY(first.index(row).data(FolderModel::SizeRole).isValid()); + } + + void folderReadsARowAskedForAndNotReadAtItsNextReRead() + { + if (geteuid() == 0) + QSKIP("Root can read every file."); + // The row is past the ones every listing reads: only having been asked for gets it read again. + QTemporaryDir dir; + const QString folder = dir.filePath("folder"); + QVERIFY(QDir().mkpath(folder)); + const int files = FolderModel::firstRows + 10, row = FolderModel::firstRows + 5; + for (int i = 0; i < files; ++i) + write(folder + QString("/f%1.txt").arg(i, 4, 10, QChar('0')), "x"); + const auto whole = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner; + QVERIFY(QFile::setPermissions(folder, QFile::ReadOwner)); + const auto restore = qScopeGuard([&] { QFile::setPermissions(folder, whole); }); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded), told(&model, &FolderModel::dataChanged); + model.setPath(folder); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), files); + QTRY_COMPARE(model.findChildren().size(), 0); + + // Asked for while nothing in the folder can be looked up: the read finds nothing, and no row is told. + QVERIFY(!model.index(row).data(FolderModel::SizeRole).isValid()); + QCoreApplication::sendPostedEvents(&model, QEvent::MetaCall); + QCOMPARE(model.findChildren().size(), 1); + QTRY_COMPARE(model.findChildren().size(), 0); + QCOMPARE(told.count(), 0); + + // The folder is read again once it can be looked into: the row is read with it and told, with no view asking. + QVERIFY(QFile::setPermissions(folder, whole)); + model.reload(); + QVERIFY(loaded.wait(3000)); + QTRY_COMPARE(model.findChildren().size(), 0); + int tellings = 0; + for (const auto &call : told) + tellings += call.at(0).toModelIndex().row() == row; + QCOMPARE(tellings, 1); + QVERIFY(model.index(row).data(FolderModel::SizeRole).isValid()); + } + void folderShowsAFileThatGrewAfterItsRowWasRead() { QTemporaryDir dir; From e1e9b3f0e9d1d636143a07513a682b0de33d311e Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 08:25:18 -0400 Subject: [PATCH 18/38] Keep a row's type once it is read from its name A view asks for a row's kind, MIME type and extension each time it makes the row, and each ask read them from the name again: 48 us a row in /usr/bin. The model now keeps up to 4,096 names' types, until a folder is found changed. --- src/foldermodel.cpp | 28 ++++++++++++++--- src/foldermodel.h | 10 ++++++ tests/folio_tests.cpp | 72 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index cebb5e73..e2064bb5 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -154,9 +154,9 @@ QVariant FolderModel::data(const QModelIndex &index, int role) const { case IsDirRole: return e.isDir; case SizeRole: return e.size; case ModifiedRole: return e.modified; - case KindRole: return typed(e).kind; - case MimeRole: return typed(e).mime; - case ExtensionRole: return typed(e).extension; + case KindRole: return typeOf(e).kind; + case MimeRole: return typeOf(e).mime; + case ExtensionRole: return typeOf(e).extension; case ItemCountRole: return e.itemCount; case SelectedRole: return m_selected.contains(e.path); case IconRole: return e.icon; @@ -191,6 +191,21 @@ static void readSizeAndDate(FolderModel::Entry &entry) { setSizeAndDate(entry, info); } +FolderModel::Type FolderModel::typeOf(const Entry &entry) const { + // A folder's type takes no reading, and an entry that has its own keeps it whatever its name. + if (entry.isDir || !entry.mime.isNull() || !entry.kind.isNull()) { + const Entry e = typed(entry); + return {e.kind, e.mime, e.extension}; + } + const auto kept = m_types.constFind(entry.name); + if (kept != m_types.constEnd()) + return *kept; + if (m_types.size() >= typesKept) + m_types.clear(); + const Entry e = typed(entry); + return *m_types.insert(entry.name, {e.kind, e.mime, e.extension}); +} + void FolderModel::ask(int row) const { if (m_reading.contains(row)) return; @@ -720,10 +735,13 @@ void FolderModel::reload() { // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); - if (!same) + if (!same) { + // A folder found changed has its rows' types read again, as its sizes and dates are. + m_types.clear(); setEntries(entries); - else + } else { takeSizesAndDates(std::move(entries)); + } if (!m_selected.isEmpty()) { QSet still; for (const Entry &e : std::as_const(m_entries)) diff --git a/src/foldermodel.h b/src/foldermodel.h index 78f1ff19..45ebc160 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -111,6 +112,8 @@ class FolderModel : public QAbstractListModel { // Rows a listing reads sizes and dates for: 13 rows of 22 smallest tiles fill a 4K screen and what a view builds ahead. static constexpr int firstRows = 320; + // How many names' types a model keeps: the rows in sight and many folders back, never all of a large folder scrolled through. + static constexpr int typesKept = 4096; // Synchronous building blocks, used by the background passes and tests. // A listing stops, empty, as soon as `stop` returns true. Names sort @@ -174,6 +177,11 @@ class FolderModel : public QAbstractListModel { void remember(); static bool sameListing(const QVector &a, const QVector &b); void takeSizesAndDates(QVector entries); + // A row's kind, MIME type and extension as its roles give them. + struct Type { + QString kind, mime, extension; + }; + Type typeOf(const Entry &entry) const; void ask(int row) const; void readAsked(); void setError(const QString &error); @@ -194,6 +202,8 @@ class FolderModel : public QAbstractListModel { // search and folder counts, so work that's been overtaken (more typing, // another folder, the model gone) stops early. std::shared_ptr> m_latest = std::make_shared>(0); + // What typed() read from a file's name, kept by that name until a folder is found changed: a view asks for a row's type three times each time it makes the row. + mutable QHash m_types; // Rows a view asked for a size or date this turn, read together, and every row asked for and not back yet. mutable QList m_asked; mutable QSet m_reading; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index c1acee27..0b314168 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -632,6 +632,78 @@ private slots: } } + void aRowsTypeIsReadFromItsNameOnce() + { + // Asked again, a row's type roles give the type kept from the first ask: read from the name again, its extension would be another string. + QTemporaryDir files, folder, pipe; + write(files.filePath("photo.jpg"), "x"); + for (int i = 0; i < FolderModel::typesKept; ++i) + write(files.filePath(QString("z%1.txt").arg(i)), "x"); + QVERIFY(QDir(folder.path()).mkdir("photo.jpg")); + QCOMPARE(::mkfifo(QFile::encodeName(pipe.filePath("photo.jpg")).constData(), 0600), 0); + const QString plugin = "plugin:types"; + + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + const auto type = [&](int role, int row = 0) { return model.index(row).data(role).toString(); }; + // Seen once, a folder, a pipe and a plugin's item of the file's name are remembered, to be shown later while the file's type is kept. + model.setItems({QVariantMap{{"name", "photo.jpg"}, {"path", "plugin:types/a"}, {"subtitle", "Windows"}}}); + for (const QString &path : {folder.path(), pipe.path(), plugin}) { + model.setPath(path); + QVERIFY(loaded.wait(5000)); + QCOMPARE(model.count(), 1); + } + model.setPath(files.path()); + QVERIFY(loaded.wait(5000)); + QCOMPARE(model.count(), FolderModel::typesKept + 1); + QCOMPARE(type(FolderModel::NameRole), QString("photo.jpg")); + const QString first = type(FolderModel::ExtensionRole); + QCOMPARE(first, QString("JPG")); + QCOMPARE(type(FolderModel::MimeRole), QString("image/jpeg")); + QCOMPARE(type(FolderModel::KindRole), QString("image")); + QVERIFY(type(FolderModel::ExtensionRole).constData() == first.constData()); + + // With one name fewer than a model keeps asked after it, it is still the one kept; one more, and it is read again, the same. + for (int row = 1; row < FolderModel::typesKept; ++row) + QCOMPARE(type(FolderModel::ExtensionRole, row), QString("TXT")); + QVERIFY(type(FolderModel::ExtensionRole).constData() == first.constData()); + QCOMPARE(type(FolderModel::ExtensionRole, FolderModel::typesKept), QString("TXT")); + const QString again = type(FolderModel::ExtensionRole); + QVERIFY(again.constData() != first.constData()); + QCOMPARE(again, first); + QCOMPARE(type(FolderModel::MimeRole), QString("image/jpeg")); + + // The remembered rows of that name, shown before anything is read again, are not given the file's type. + model.setPath(folder.path()); + QCOMPARE(model.count(), 1); + QCOMPARE(type(FolderModel::NameRole), QString("photo.jpg")); + QCOMPARE(type(FolderModel::MimeRole), QString("inode/directory")); + QCOMPARE(type(FolderModel::KindRole), QString("folder")); + QCOMPARE(type(FolderModel::ExtensionRole), QString()); + model.setPath(pipe.path()); + QCOMPARE(model.count(), 1); + QCOMPARE(type(FolderModel::NameRole), QString("photo.jpg")); + QCOMPARE(type(FolderModel::MimeRole), QString("inode/fifo")); + QCOMPARE(type(FolderModel::ExtensionRole), QString("JPG")); + model.setPath(plugin); + QCOMPARE(model.count(), 1); + QCOMPARE(type(FolderModel::NameRole), QString("photo.jpg")); + QCOMPARE(type(FolderModel::KindRole), QString("item")); + QCOMPARE(type(FolderModel::ExtensionRole), QString("Windows")); + + // Back in the folder, and after it is read again and found as it was, the type is the one kept; found changed, it is read again. + model.setPath(files.path()); + QVERIFY(type(FolderModel::ExtensionRole).constData() == again.constData()); + QVERIFY(loaded.wait(5000)); + QVERIFY(type(FolderModel::ExtensionRole).constData() == again.constData()); + write(files.filePath("zz.txt"), "x"); + model.reload(); + QTRY_COMPARE_WITH_TIMEOUT(model.count(), FolderModel::typesKept + 2, 5000); + const QString anew = type(FolderModel::ExtensionRole); + QVERIFY(anew.constData() != again.constData()); + QCOMPARE(anew, again); + } + void folderReadsSizesAndDatesOfTheFirstRowsOnly() { // Listed for the model, a big folder reads the sizes and dates of its first window, by date or size every one. From c933b2798576a3530a44b85da26d1572bb3e9066 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 19/38] Search without a stat for every entry A search asked the disk about every file it passed. The walk now reads names and types from the directory alone and asks only about folders it enters and rows it shows. The matches and their order are as before. --- src/foldermodel.cpp | 38 ++-------- src/searchindex.cpp | 169 ++++++++++++++++++++++++++++++++++++++++++ src/searchindex.h | 61 +++++++++++++++ tests/folio_tests.cpp | 38 +++++++++- 4 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 src/searchindex.cpp create mode 100644 src/searchindex.h diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index e2064bb5..651b1c9d 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -1,4 +1,5 @@ #include "foldermodel.h" +#include "searchindex.h" #include #include @@ -478,41 +479,12 @@ FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatab QVector FolderModel::search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop, int limit, const QLocale &locale) { - QVector entries; - struct stat top; - if (::stat(QFile::encodeName(root).constData(), &top) != 0) - return entries; - const QDir::Filters filters = entryFilters(showHidden); - // Breadth first, so what's near comes before what's deep when there are // more matches than the limit. - QMimeDatabase mimes; - const QDir base(root); - QStringList folders{root}; - while (!folders.isEmpty() && entries.size() < limit) { - if (stop && stop()) - return {}; - QDirIterator it(folders.takeFirst(), filters); - while (it.hasNext()) { - if (stop && stop()) - return {}; - const QFileInfo info = it.nextFileInfo(); - if (info.fileName().contains(query, Qt::CaseInsensitive) && entries.size() < limit) { - Entry e = entryFor(info, mimes); - e.name = base.relativeFilePath(e.path); - entries.push_back(e); - } - // Other filesystems (a mounted drive, another machine, /proc - // from /) and symlinked folders aren't walked into. - struct stat st; - if (info.isDir() && !info.isSymLink() - && ::lstat(QFile::encodeName(info.absoluteFilePath()).constData(), &st) == 0 - && st.st_dev == top.st_dev) - folders << info.absoluteFilePath(); - } - } - sortEntries(entries, sort, locale); - return entries; + SearchIndex index; + if (!index.walk(root, showHidden, stop)) + return {}; + return index.find(query, sort, limit, stop, locale); } QStringList FolderModel::sortModes() { diff --git a/src/searchindex.cpp b/src/searchindex.cpp new file mode 100644 index 00000000..61da7967 --- /dev/null +++ b/src/searchindex.cpp @@ -0,0 +1,169 @@ +#include "searchindex.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool SearchIndex::walk(const QString &root, bool showHidden, const std::function &stop) { + ++counts.walks; + m_root = root; + const QByteArray top = QFile::encodeName(root); + dev_t drive = 0; + // The folders found are the queue: each is read in the order it was + // found, which is breadth first. -1 is the root. + for (qsizetype folder = -1; folder < m_folders.size(); ++folder) { + const quint32 parent = folder < 0 ? None : quint32(folder); + const QByteArray at = folder < 0 ? top : top + '/' + joined(m_folders[folder].parent, name(m_folders[folder].name)); + // Below the root, a folder that's become a link since it was listed isn't followed. + const int fd = ::open(at.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | (folder < 0 ? 0 : O_NOFOLLOW)); + if (fd < 0) + continue; + ++counts.opens; + ++counts.stats; + struct stat st; + // Other filesystems (a mounted drive, another machine, /proc from + // /) are listed but not read. + if (::fstat(fd, &st) != 0 || (folder >= 0 && st.st_dev != drive)) { + ::close(fd); + continue; + } + if (folder < 0) + drive = st.st_dev; + DIR *dir = ::fdopendir(fd); + if (!dir) { + ::close(fd); + continue; + } + while (const dirent *entry = ::readdir(dir)) { + if (stop && stop()) { + ::closedir(dir); + const Counts spent = counts; + *this = SearchIndex(); + counts = spent; + return false; + } + const char *text = entry->d_name; + if (text[0] == '.' && (!showHidden || text[1] == 0 || (text[1] == '.' && text[2] == 0))) + continue; + // The type comes with the name; only a filesystem that doesn't + // say needs asking. Symlinked folders aren't walked into. + bool isDir = entry->d_type == DT_DIR; + if (entry->d_type == DT_UNKNOWN) { + ++counts.stats; + struct stat child; + isDir = ::fstatat(::dirfd(dir), text, &child, AT_SYMLINK_NOFOLLOW) == 0 && S_ISDIR(child.st_mode); + } + const quint32 id = intern(QByteArrayView(text)); + m_rows.push_back({id, parent}); + if (isDir) + m_folders.push_back({id, parent}); + } + ::closedir(dir); + } + return true; +} + +quint32 SearchIndex::intern(QByteArrayView text) { + // Kept under three quarters full, so a probe ends soon. + if ((m_names.size() + 1) * 4 > m_lookup.size() * 3) { + QVector bigger(qMax(1024, m_lookup.size() * 2), None); + for (quint32 id = 0; id < quint32(m_names.size()); ++id) { + size_t at = qHash(name(id)) & (bigger.size() - 1); + while (bigger[at] != None) + at = (at + 1) & (bigger.size() - 1); + bigger[at] = id; + } + m_lookup.swap(bigger); + } + size_t at = qHash(text) & (m_lookup.size() - 1); + for (; m_lookup[at] != None; at = (at + 1) & (m_lookup.size() - 1)) + if (name(m_lookup[at]) == text) + return m_lookup[at]; + // A name never runs over the end of its slab. + if (m_slabs.empty() || m_slabs.back().size() + text.size() > SlabBytes) { + m_slabs.emplace_back(); + m_slabs.back().reserve(SlabBytes); + } + QByteArray &slab = m_slabs.back(); + const quint32 id = quint32(m_names.size()); + m_names.push_back({quint32((m_slabs.size() - 1) * SlabBytes + slab.size()), quint32(text.size())}); + slab.append(text); + m_lookup[at] = id; + return id; +} + +QByteArrayView SearchIndex::name(quint32 id) const { + const Name &n = m_names[id]; + return QByteArrayView(m_slabs[n.offset / SlabBytes].constData() + n.offset % SlabBytes, n.length); +} + +QByteArray SearchIndex::joined(quint32 folder, QByteArrayView last) const { + QByteArray out = last.toByteArray(); + for (; folder != None; folder = m_folders[folder].parent) + out.prepend('/').prepend(name(m_folders[folder].name)); + return out; +} + +QByteArray SearchIndex::path(quint32 row) const { + return joined(m_rows[row].parent, name(m_rows[row].name)); +} + +FolderModel::Entry SearchIndex::entry(quint32 row, const QMimeDatabase &mimes) { + const QString relative = QFile::decodeName(path(row)); + ++counts.stats; + FolderModel::Entry e = FolderModel::entryFor(QFileInfo(QDir(m_root).filePath(relative)), mimes); + e.name = relative; + return e; +} + +static char lower(char c) { + return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; +} + +// Whether `text` holds `needle`, ASCII letters of either case alike; the +// needle is in lower case. +static bool containsAscii(QByteArrayView text, QByteArrayView needle) { + for (qsizetype at = 0; at + needle.size() <= text.size(); ++at) { + qsizetype i = 0; + while (i < needle.size() && lower(text[at + i]) == needle[i]) + ++i; + if (i == needle.size()) + return true; + } + return false; +} + +static bool isAscii(QByteArrayView text) { + for (char c : text) + if (uchar(c) >= 0x80) + return false; + return true; +} + +QVector SearchIndex::find(const QString &query, const QString &sort, int limit, + const std::function &stop, const QLocale &locale) { + // Bytes on both sides when both are ASCII; otherwise Qt's own + // comparison, as for any name. + const QByteArray needle = query.toLower().toUtf8(); + const bool asciiQuery = isAscii(needle); + QVector entries; + QMimeDatabase mimes; + for (quint32 row = 0; row < quint32(m_rows.size()) && entries.size() < limit; ++row) { + const QByteArrayView text = name(m_rows[row].name); + const bool found = asciiQuery && isAscii(text) ? containsAscii(text, needle) + : QString::fromUtf8(text).contains(query, Qt::CaseInsensitive); + if (!found) + continue; + if (stop && stop()) + return {}; + entries.push_back(entry(row, mimes)); + } + FolderModel::sortEntries(entries, sort, locale); + return entries; +} diff --git a/src/searchindex.h b/src/searchindex.h new file mode 100644 index 00000000..2f454664 --- /dev/null +++ b/src/searchindex.h @@ -0,0 +1,61 @@ +#pragma once + +#include "foldermodel.h" + +#include +#include +#include +#include +#include + +class QMimeDatabase; + +// Everything below a folder by name, read once for a search. Names stay the +// bytes the folder gave, each kept once however many folders hold it, and a +// row's path is put together from its folders only when it's shown. +class SearchIndex { +public: + // What reading and answering cost, for the tests. + struct Counts { + qsizetype opens = 0; // folders opened + qsizetype stats = 0; // one for each folder opened, each row shown, and each entry of unknown type + qsizetype walks = 0; + }; + + // Reads `root` and every folder below it, breadth first, staying on its + // drive and out of symlinked folders. False, and empty, once `stop` + // returns true. + bool walk(const QString &root, bool showHidden, const std::function &stop = {}); + qsizetype size() const { return m_rows.size(); } + // The row's path from the root, in the folder's own bytes. + QByteArray path(quint32 row) const; + // The row as the views show it, named by its path from the root. + FolderModel::Entry entry(quint32 row, const QMimeDatabase &mimes); + // The first `limit` rows, in the walk's order, whose name contains + // `query` ignoring case, sorted by `sort` in `locale`'s alphabet. Empty once `stop` returns true. + QVector find(const QString &query, const QString &sort, int limit, + const std::function &stop = {}, const QLocale &locale = QLocale()); + + Counts counts; + +private: + static constexpr quint32 None = UINT32_MAX; + // Names are kept in slabs this big, which go back to the system with the search. + static constexpr qsizetype SlabBytes = 1 << 20; + struct Name { quint32 offset, length; }; + // A folder is its name and the folder holding it; a row likewise. + struct Folder { quint32 name, parent; }; + struct Row { quint32 name, parent; }; + + quint32 intern(QByteArrayView text); + QByteArrayView name(quint32 id) const; + QByteArray joined(quint32 folder, QByteArrayView last) const; + + QString m_root; + std::vector m_slabs; + QVector m_names; + // Open addressing over m_names, so a name seen before isn't kept again. + QVector m_lookup; + QVector m_folders; + QVector m_rows; +}; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 0b314168..87261361 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -54,6 +54,7 @@ #include "places.h" #include "plugins.h" #include "previewer.h" +#include "searchindex.h" #include "theme.h" #include "thumbnails.h" #include "translator.h" @@ -1318,6 +1319,33 @@ private slots: QCOMPARE(found.first().name, QString("real/match.txt")); } + void searchAsksAboutFoldersAndShownRowsOnly() + { + // A search reads names and types as each folder gives them: one stat + // for each folder it opens and one for each match it shows, none for + // every other file. + QTemporaryDir dir; + QDir(dir.path()).mkpath("a/b"); + QDir(dir.path()).mkpath("c"); + for (int i = 0; i < 50; ++i) { + write(dir.filePath(QString("a/b/file%1.txt").arg(i)), "x"); + write(dir.filePath(QString("c/note%1.md").arg(i)), "x"); + } + write(dir.filePath("beach.txt"), "x"); + write(dir.filePath("a/b/Beach.jpg"), "x"); + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QCOMPARE(index.size(), 105); + QCOMPARE(index.counts.opens, 4); + QCOMPARE(index.counts.stats, index.counts.opens); + QCOMPARE(index.find("BEACH", "name", 1000).size(), 2); + QCOMPARE(index.counts.stats, index.counts.opens + 2); + // Nearest first when there are more than the limit. + const auto nearest = index.find("beach", "name", 1); + QCOMPARE(nearest.size(), 1); + QCOMPARE(nearest.first().name, QString("beach.txt")); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; @@ -1454,16 +1482,20 @@ private slots: // next listing, the folder counts and file transfers all wait for. // With a pool of one thread, the next folder waits for the overtaken // read to stop, so it shows in a fraction of the time the read takes. - QTemporaryDir big, small, other; + QTemporaryDir big, small, other, tree; for (int i = 0; i < 20000; ++i) write(big.filePath(QString("%1.txt").arg(i)), "x"); + // A search asks nothing about a file, so what it takes time over is + // opening folders. + for (int i = 0; i < 5000; ++i) + QDir(tree.path()).mkdir(QString::number(i)); write(small.filePath("a.txt"), "x"); write(other.filePath("b.txt"), "x"); QElapsedTimer timer; timer.start(); FolderModel::list(big.path(), false, "name", nullptr); const qint64 listing = timer.restart(); - FolderModel::search(big.path(), "none", false, "name"); + FolderModel::search(tree.path(), "none", false, "name"); const qint64 searching = timer.elapsed(); QThreadPool *pool = QThreadPool::globalInstance(); @@ -1480,7 +1512,7 @@ private slots: QVERIFY(loaded.wait(5000)); QVERIFY(timer.elapsed() < listing / 2); - model.setPath(big.path()); + model.setPath(tree.path()); QVERIFY(loaded.wait(5000)); model.setQuery("none"); timer.restart(); From 263ca29625995427fe32f7d937f4d5cd2a6a42ad Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 20/38] Keep the walked names while a search is open Every key walked the whole tree again. The first key now walks it once and later keys are answered from the names in memory, until the search closes, the folder changes or it is read again. Escape frees them. --- src/foldermodel.cpp | 74 +++++++++++++++++++++++++++++++++++++++---- src/foldermodel.h | 10 ++++++ src/searchindex.cpp | 14 ++++++++ src/searchindex.h | 2 ++ tests/folio_tests.cpp | 41 ++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 651b1c9d..93971ef7 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { // running for this model. FolderModel::~FolderModel() { *m_latest = 0; + closeSearch(); } // Recent listings, shared by every model (the window's folder and each @@ -282,7 +284,7 @@ void FolderModel::setSortMode(const QString &sort) { return; m_sort = sort; emit sortModeChanged(); - reload(); + read(); } void FolderModel::setQuery(const QString &query) { @@ -291,12 +293,14 @@ void FolderModel::setQuery(const QString &query) { m_query = query; emit queryChanged(); setSelected({}); - // Back to the folder: its remembered listing shows at once. + // Back to the folder: its remembered listing shows at once, and what + // the search read is let go of. if (m_query.isEmpty()) { + closeSearch(); if (const auto *cached = recentListings().object(cacheKey())) setEntries(*cached); } - reload(); + read(); } void FolderModel::setItems(const QVariantList &items) { @@ -313,6 +317,7 @@ void FolderModel::setPath(const QString &path) { const QString clean = path.isEmpty() || isView(path) ? path : QDir::cleanPath(path); if (clean == m_path) return; + closeSearch(); if (!m_watcher.directories().isEmpty()) m_watcher.removePaths(m_watcher.directories()); if (!m_watcher.files().isEmpty()) @@ -673,7 +678,52 @@ int FolderModel::itemCount(const QString &folder, bool showHidden) { return count; } +// The open search: its index, read once in the background, and the lock +// its answers take turns at. +struct FolderModel::Search { + SearchIndex index; + QMutex lock; + std::atomic closed{false}; + // Set on the UI thread once the walk is done; nothing is answered before. + bool landed = false; +}; + +std::shared_ptr FolderModel::searchIndex() const { + return m_search ? std::shared_ptr(m_search, &m_search->index) : nullptr; +} + +void FolderModel::openSearch() { + m_search = std::make_shared(); + const auto search = m_search; + const QString path = m_path; + const bool showHidden = m_showHidden; + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, search] { + watcher->deleteLater(); + if (search != m_search) + return; // Ended, or another search started, while it was read. + search->landed = true; + read(); + }); + watcher->setFuture(QtConcurrent::run([search, path, showHidden] { + search->index.walk(path, showHidden, [&] { return search->closed.load(); }); + })); +} + +void FolderModel::closeSearch() { + if (!m_search) + return; + m_search->closed = true; + m_search.reset(); +} + +// What's on disk now: a search open reads its folders again. void FolderModel::reload() { + closeSearch(); + read(); +} + +void FolderModel::read() { if (m_path.isEmpty()) return; const quint64 generation = ++m_generation; @@ -697,6 +747,13 @@ void FolderModel::reload() { m_asked.clear(); *m_latest = generation; const auto latest = m_latest; + // A search's first letter starts reading the folders below; until + // they've been read, the landing asks again. + if (!query.isEmpty() && !isView(path) && !m_search) + openSearch(); + const auto search = query.isEmpty() || isView(path) ? nullptr : m_search; + if (search && !search->landed) + return; auto *watcher = new QFutureWatcher, QString>>(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { watcher->deleteLater(); @@ -731,12 +788,17 @@ void FolderModel::reload() { remember(); countFolders(generation); }); - watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read] { + watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search] { QString error; const auto overtaken = [&] { return *latest != generation; }; + if (search) { + // As many matches as a search has always shown. + constexpr int shown = 1000; + QMutexLocker hold(&search->lock); + return qMakePair(search->index.find(query, sort, shown, overtaken, locale), error); + } auto entries = isPluginView(path) ? listItems(items, query) - : query.isEmpty() || path == recentPath() ? list(path, showHidden, sort, &error, overtaken, locale, false) - : search(path, query, showHidden, sort, overtaken, 1000, locale); + : list(path, showHidden, sort, &error, overtaken, locale, false); for (Entry &e : entries) { if (!e.unread || !read.contains(e.path)) continue; diff --git a/src/foldermodel.h b/src/foldermodel.h index 45ebc160..a08799f8 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -19,6 +19,7 @@ // two passes: the entries first, then each subfolder's item count. class QFileInfo; class QMimeDatabase; +class SearchIndex; class FolderModel : public QAbstractListModel { Q_OBJECT @@ -156,6 +157,8 @@ class FolderModel : public QAbstractListModel { static QVector search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop = {}, int limit = 1000, const QLocale &locale = QLocale()); + // What the open search has read, for the tests; null with no search open. + std::shared_ptr searchIndex() const; signals: void pathChanged(); @@ -170,6 +173,10 @@ class FolderModel : public QAbstractListModel { void loaded(); private: + struct Search; + void read(); + void openSearch(); + void closeSearch(); void setLoading(bool loading); void setEntries(const QVector &entries); void clearEntries(); @@ -211,6 +218,9 @@ class FolderModel : public QAbstractListModel { mutable QSet m_owed; // A get() answered without a size or date, so QML takes its snapshots again (on countChanged) once they're in. mutable bool m_snapshotWaiting = false; + // The folders below, read on a search's first letter and kept until it + // ends, the folder changes or hidden files are shown or hidden. + std::shared_ptr m_search; QFileSystemWatcher m_watcher; QTimer m_debounce; QTimer m_staleClear; diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 61da7967..00c2ada0 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -66,9 +66,23 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function } ::closedir(dir); } + // Names are only looked up while folders are read, and what's left + // over from growing goes back. + m_lookup = {}; + m_names.squeeze(); + m_folders.squeeze(); + m_rows.squeeze(); return true; } +qsizetype SearchIndex::bytes() const { + qsizetype slabs = 0; + for (const QByteArray &slab : m_slabs) + slabs += slab.capacity(); + return slabs + m_names.capacity() * sizeof(Name) + m_lookup.capacity() * sizeof(quint32) + + m_folders.capacity() * sizeof(Folder) + m_rows.capacity() * sizeof(Row); +} + quint32 SearchIndex::intern(QByteArrayView text) { // Kept under three quarters full, so a probe ends soon. if ((m_names.size() + 1) * 4 > m_lookup.size() * 3) { diff --git a/src/searchindex.h b/src/searchindex.h index 2f454664..9a0871c1 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -27,6 +27,8 @@ class SearchIndex { // returns true. bool walk(const QString &root, bool showHidden, const std::function &stop = {}); qsizetype size() const { return m_rows.size(); } + // The memory it holds. + qsizetype bytes() const; // The row's path from the root, in the folder's own bytes. QByteArray path(quint32 row) const; // The row as the views show it, named by its path from the root. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 87261361..64254c50 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1346,6 +1346,47 @@ private slots: QCOMPARE(nearest.first().name, QString("beach.txt")); } + void searchReadsTheFoldersOnceWhileItsOpen() + { + // The first letter reads the folders below; every letter after is + // answered from what was read. Ending the search lets all of it go. + QTemporaryDir dir; + QDir(dir.path()).mkpath("Photos/2024"); + write(dir.filePath("Photos/2024/beach.jpg"), "x"); + write(dir.filePath("beacon.txt"), "x"); + write(dir.filePath(".beach"), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(!model.searchIndex()); + model.setQuery("b"); + QVERIFY(loaded.wait(3000)); + const std::weak_ptr held = model.searchIndex(); + for (const char *query : {"be", "bea"}) { + model.setQuery(query); + QVERIFY(loaded.wait(3000)); + } + QCOMPARE(model.count(), 2); + QVERIFY(model.searchIndex() == held.lock()); + QCOMPARE(held.lock()->counts.walks, 1); + QVERIFY(held.lock()->bytes() > 0); + model.setQuery(""); + QVERIFY(!model.searchIndex()); + QTRY_VERIFY(held.expired()); + + // The next search reads again, so it finds what's new; showing + // hidden files reads again too. + write(dir.filePath("Photos/beach2.jpg"), "x"); + model.setQuery("beach"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 2); + model.setShowHidden(true); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 3); + QCOMPARE(model.searchIndex()->counts.walks, 1); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; From 71eebe0079fb1c4d3eaccd33ff41aa0d4140879d Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 21/38] List the best matches first, a window at a time Results came by name and stopped at 1,000. They are now ranked: the exact name, then a name or a word that starts with the query, then its letters in order. Rows arrive 350 at a time as the view scrolls, and the field says how many matched. --- src/PathBar.qml | 11 +- src/foldermodel.cpp | 95 ++++++++++--- src/foldermodel.h | 14 +- src/i18n/en.json | 4 + src/searchindex.cpp | 302 ++++++++++++++++++++++++++++++++++++------ src/searchindex.h | 96 +++++++++++++- tests/folio_tests.cpp | 64 +++++++++ 7 files changed, 518 insertions(+), 68 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 47c25d28..bb2720f5 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -95,9 +95,9 @@ Rectangle { // Room at the end for where it searches; padding isn't mirrored. leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 - // Where it searches, at the end of the field. Unlike a - // placeholder, it stays while typing. Plugins' views have - // nothing below them. + // Where it searches, at the end of the field, and once it has + // answered, how many match. Unlike a placeholder, it stays while + // typing. Plugins' views have nothing below them. Text { id: scope // Recents, Trash and plugins' views are named in C++, which @@ -109,7 +109,10 @@ Rectangle { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : qsTr("Search %1 and below").arg(here) + text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) + : backend.folder.query.length > 0 && backend.folder.matches >= 0 + ? qsTr("%Ln matches in %1 and below", "", backend.folder.matches).arg(here) + : qsTr("Search %1 and below").arg(here) color: theme.secondaryForeground font.pixelSize: 12 } diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 93971ef7..37500c07 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -678,6 +678,21 @@ int FolderModel::itemCount(const QString &folder, bool showHidden) { return count; } +// A search shows its best matches this many at a time, more as the view +// scrolls to them. +static constexpr int SearchWindow = 350; +// Sorted another way than best first, it shows this many of the best, as +// many as a search ever showed. +static constexpr int SortedMatches = 1000; + +// What a read in the background brings back: the entries, what went wrong, +// and how many match when it's a search. +struct Listing { + QVector entries; + QString error; + int matches = -1; +}; + // The open search: its index, read once in the background, and the lock // its answers take turns at. struct FolderModel::Search { @@ -754,13 +769,13 @@ void FolderModel::read() { const auto search = query.isEmpty() || isView(path) ? nullptr : m_search; if (search && !search->landed) return; - auto *watcher = new QFutureWatcher, QString>>(this); + auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { watcher->deleteLater(); if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); - auto [entries, error] = watcher->future().takeResult(); + auto [entries, error, matches] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); @@ -782,6 +797,10 @@ void FolderModel::read() { } } setError(error); + if (matches != m_matches) { + m_matches = matches; + emit matchesChanged(); + } setLoading(false); emit loaded(); if (error.isEmpty()) @@ -789,24 +808,64 @@ void FolderModel::read() { countFolders(generation); }); watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search] { - QString error; + Listing listing; const auto overtaken = [&] { return *latest != generation; }; - if (search) { - // As many matches as a search has always shown. - constexpr int shown = 1000; - QMutexLocker hold(&search->lock); - return qMakePair(search->index.find(query, sort, shown, overtaken, locale), error); - } - auto entries = isPluginView(path) ? listItems(items, query) - : list(path, showHidden, sort, &error, overtaken, locale, false); - for (Entry &e : entries) { - if (!e.unread || !read.contains(e.path)) - continue; - if (overtaken()) - break; - readSizeAndDate(e); + if (!search) { + listing.entries = isPluginView(path) ? listItems(items, query) + : list(path, showHidden, sort, &listing.error, overtaken, locale, false); + for (Entry &e : listing.entries) { + if (!e.unread || !read.contains(e.path)) + continue; + if (overtaken()) + break; + readSizeAndDate(e); + } + return listing; } - return qMakePair(entries, error); + QMutexLocker hold(&search->lock); + if (overtaken()) + return listing; + listing.matches = int(search->index.answer(query)); + // Best first, a window at a time; another order puts the best + // matches in it instead. + listing.entries = search->index.entries(search->index.rows(0, sort == "name" ? SearchWindow : SortedMatches), overtaken); + if (sort != "name") + sortEntries(listing.entries, sort, locale); + return listing; + })); +} + +bool FolderModel::canFetchMore(const QModelIndex &parent) const { + return !parent.isValid() && m_search && m_search->landed && !m_loading && !m_fetching && m_sort == "name" + && m_entries.size() < m_matches; +} + +// The views ask as they scroll near the end of what's shown. +void FolderModel::fetchMore(const QModelIndex &parent) { + if (!canFetchMore(parent)) + return; + m_fetching = true; + const auto search = m_search; + const qsizetype from = m_entries.size(); + const quint64 generation = m_generation; + const auto latest = m_latest; + auto *watcher = new QFutureWatcher>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, from] { + watcher->deleteLater(); + m_fetching = false; + const QVector more = watcher->result(); + if (generation != m_generation || from != m_entries.size() || more.isEmpty()) + return; + beginInsertRows({}, from, from + more.size() - 1); + m_entries += more; + endInsertRows(); + emit countChanged(); + countFolders(generation); + }); + watcher->setFuture(QtConcurrent::run([search, from, latest, generation] { + const auto overtaken = [&] { return *latest != generation; }; + QMutexLocker hold(&search->lock); + return overtaken() ? QVector() : search->index.entries(search->index.rows(from, SearchWindow), overtaken); })); } diff --git a/src/foldermodel.h b/src/foldermodel.h index a08799f8..7f96be4c 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -34,6 +34,8 @@ class FolderModel : public QAbstractListModel { Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) // The items a plugin's view lists, as Plugins::views gives them. Q_PROPERTY(QVariantList items READ items WRITE setItems NOTIFY itemsChanged) + // How many match the query, shown or not; -1 until a search has answered. + Q_PROPERTY(int matches READ matches NOTIFY matchesChanged) public: enum Role { @@ -76,6 +78,8 @@ class FolderModel : public QAbstractListModel { int rowCount(const QModelIndex &parent = {}) const override; QVariant data(const QModelIndex &index, int role) const override; + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; QHash roleNames() const override; QString path() const { return m_path; } @@ -90,6 +94,7 @@ class FolderModel : public QAbstractListModel { void setQuery(const QString &query); QVariantList items() const { return m_items; } void setItems(const QVariantList &items); + int matches() const { return m_matches; } void setPath(const QString &path); Q_INVOKABLE void reload(); @@ -151,9 +156,9 @@ class FolderModel : public QAbstractListModel { // Recents and plugins' views: lists of their own, not folders. static bool isView(const QString &path) { return path == recentPath() || isPluginView(path); } static QString kindForMime(const QString &mime); - // Everything under `root` whose name contains `query` (ignoring case), - // named by its path from `root`. Stops at `limit` matches, at other - // filesystems and at symlinks, and as soon as `stop` returns true. + // The best `limit` matches for `query` under `root` (see SearchIndex), + // named by their path from `root`. Stops at other filesystems and at + // symlinks, and as soon as `stop` returns true. static QVector search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop = {}, int limit = 1000, const QLocale &locale = QLocale()); @@ -169,6 +174,7 @@ class FolderModel : public QAbstractListModel { void sortModeChanged(); void queryChanged(); void itemsChanged(); + void matchesChanged(); void selectionChanged(); void loaded(); @@ -204,6 +210,8 @@ class FolderModel : public QAbstractListModel { QString m_sort = "name"; QString m_query; QVariantList m_items; + int m_matches = -1; + bool m_fetching = false; quint64 m_generation = 0; // The newest listing's generation, shared with the background listing, // search and folder counts, so work that's been overtaken (more typing, diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..24544ad7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -40,6 +40,10 @@ "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." }, + "%Ln matches in %1 and below": { + "one": "%Ln match in %1 and below", + "other": "%Ln matches in %1 and below" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 00c2ada0..d988bd17 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -5,11 +5,110 @@ #include #include #include +#include +#include #include #include #include #include +static char lower(char c) { + return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; +} + +static bool isSeparator(char c) { + return c == '/' || c == '-' || c == '_' || c == '.' || c == ' '; +} + +// A word starts after a separator, and at a capital after a small letter +// or a digit ("renderFrame", "2024Report"). +static bool wordStart(QByteArrayView text, qsizetype at) { + return at == 0 || isSeparator(text[at - 1]) || (lower(text[at]) != text[at] && lower(text[at - 1]) == text[at - 1]); +} + +quint64 SearchMatcher::bitsOf(QByteArrayView text) { + // a to z, then 0 to 9, then / - _ . space +; anything else is Unknown. + static const char marks[] = "/-_. +"; + quint64 bits = 0; + for (char raw : text) { + const char c = lower(raw); + const char *mark = c ? std::strchr(marks, c) : nullptr; + if (c >= 'a' && c <= 'z') + bits |= 1ull << (c - 'a'); + else if (c >= '0' && c <= '9') + bits |= 1ull << (26 + c - '0'); + else if (mark) + bits |= 1ull << (36 + (mark - marks)); + else + bits |= Unknown; + } + return bits; +} + +SearchMatcher::SearchMatcher(const QString &query) + : m_needle(query.toCaseFolded().toUtf8()), m_bits(bitsOf(m_needle)) {} + +bool SearchMatcher::canNarrow(const QString &before, const QString &now) { + return !before.isEmpty() && now.startsWith(before); +} + +bool SearchMatcher::startsAt(QByteArrayView text, qsizetype at) const { + if (at + m_needle.size() > text.size()) + return false; + for (qsizetype i = 0; i < m_needle.size(); ++i) + if (lower(text[at + i]) != m_needle[i]) + return false; + return true; +} + +int SearchMatcher::name(QByteArrayView text) const { + if (m_needle.isEmpty()) + return NoMatch; + if (startsAt(text, 0)) + return text.size() == m_needle.size() ? Exact : Prefix; + qsizetype matched = 0; + for (qsizetype at = 0; at < text.size(); ++at) { + if (at > 0 && lower(text[at]) == m_needle[0] && wordStart(text, at) && startsAt(text, at)) + return Word; + if (matched < m_needle.size() && m_needle[matched] == lower(text[at])) + ++matched; + } + return matched == m_needle.size() ? Pending : NoMatch; +} + +void SearchMatcher::Alignment::reset(qsizetype length) { + best.assign(length + 1, Dead); + end.assign(length + 1, Dead); + best[0] = 0; + hit = length == 0 ? 0 : Dead; +} + +// best[k]: the best score with the query's first k letters placed so far; +// end[k]: the same with the k-th placed on the letter just read. +void SearchMatcher::Alignment::advance(QByteArrayView needle, QByteArrayView text, bool own) { + for (qsizetype i = 0; i < text.size(); ++i) { + const char c = lower(text[i]); + const int bonus = (wordStart(text, i) ? WordBonus : 0) + (own ? OwnNameBonus : 0); + for (qsizetype k = needle.size(); k >= 1; --k) { + const int matched = needle[k - 1] == c ? std::max(best[k - 1], end[k - 1] + RunBonus) + bonus : Dead; + best[k] = std::max(best[k] - 1, matched); + end[k] = matched; + } + hit = std::max(hit, end[needle.size()]); + } +} + +int SearchMatcher::Alignment::score(int tier) const { + return hit > Dead / 2 ? tier + std::clamp(hit, -Span, Span) : tier - Span; +} + +int SearchMatcher::align(QByteArrayView text) const { + Alignment alignment; + alignment.reset(m_needle.size()); + alignment.advance(m_needle, text, true); + return alignment.score(Named); +} + bool SearchIndex::walk(const QString &root, bool showHidden, const std::function &stop) { ++counts.walks; m_root = root; @@ -60,9 +159,14 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function isDir = ::fstatat(::dirfd(dir), text, &child, AT_SYMLINK_NOFOLLOW) == 0 && S_ISDIR(child.st_mode); } const quint32 id = intern(QByteArrayView(text)); + if (m_rows.size() % 8 == 0) + m_kinds.append('\0'); + if (isDir) { + m_kinds.back() = char(m_kinds.back() | (1 << (m_rows.size() % 8))); + const quint32 above = parent == None ? 0 : m_folders[parent].length + 1; + m_folders.push_back({id, parent, above + m_names[id].length}); + } m_rows.push_back({id, parent}); - if (isDir) - m_folders.push_back({id, parent}); } ::closedir(dir); } @@ -72,6 +176,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function m_names.squeeze(); m_folders.squeeze(); m_rows.squeeze(); + m_kinds.squeeze(); return true; } @@ -80,7 +185,8 @@ qsizetype SearchIndex::bytes() const { for (const QByteArray &slab : m_slabs) slabs += slab.capacity(); return slabs + m_names.capacity() * sizeof(Name) + m_lookup.capacity() * sizeof(quint32) - + m_folders.capacity() * sizeof(Folder) + m_rows.capacity() * sizeof(Row); + + m_folders.capacity() * sizeof(Folder) + m_rows.capacity() * sizeof(Row) + m_kinds.capacity() + + m_hits.capacity() * sizeof(Hit) + m_stamps.capacity() * sizeof(quint32) + m_scores.capacity() * sizeof(int); } quint32 SearchIndex::intern(QByteArrayView text) { @@ -106,7 +212,11 @@ quint32 SearchIndex::intern(QByteArrayView text) { } QByteArray &slab = m_slabs.back(); const quint32 id = quint32(m_names.size()); - m_names.push_back({quint32((m_slabs.size() - 1) * SlabBytes + slab.size()), quint32(text.size())}); + // Folded first when it isn't ASCII, so the letters are the ones it's matched by. + const bool ascii = std::all_of(text.begin(), text.end(), [](char c) { return uchar(c) < 0x80; }); + const quint64 bits = ascii ? SearchMatcher::bitsOf(text) + : SearchMatcher::bitsOf(QString::fromUtf8(text).toCaseFolded().toUtf8()) | SearchMatcher::NotAscii; + m_names.push_back({quint32((m_slabs.size() - 1) * SlabBytes + slab.size()), quint32(text.size()), bits}); slab.append(text); m_lookup[at] = id; return id; @@ -136,48 +246,164 @@ FolderModel::Entry SearchIndex::entry(quint32 row, const QMimeDatabase &mimes) { return e; } -static char lower(char c) { - return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; +QVector SearchIndex::entries(const QVector &rows, const std::function &stop) { + QVector out; + QMimeDatabase mimes; + for (quint32 row : rows) { + if (stop && stop()) + return {}; + out.push_back(entry(row, mimes)); + } + return out; +} + +QByteArrayView SearchIndex::text(quint32 id, QByteArray &folded) const { + if (!(m_names[id].bits & SearchMatcher::NotAscii)) + return name(id); + folded = QString::fromUtf8(name(id)).toCaseFolded().toUtf8(); + return folded; } -// Whether `text` holds `needle`, ASCII letters of either case alike; the -// needle is in lower case. -static bool containsAscii(QByteArrayView text, QByteArrayView needle) { - for (qsizetype at = 0; at + needle.size() <= text.size(); ++at) { - qsizetype i = 0; - while (i < needle.size() && lower(text[at + i]) == needle[i]) +qsizetype SearchIndex::pathLength(quint32 row) const { + const Row &r = m_rows[row]; + return (r.parent == None ? 0 : m_folders[r.parent].length + 1) + m_names[r.name].length; +} + +int SearchIndex::score(quint32 id, QByteArray &folded) const { + const Name &n = m_names[id]; + const quint64 wanted = m_matcher.bits(); + // A letter the query has and the name lacks rules it out unread. + if (!((wanted | n.bits) & SearchMatcher::Unknown) && (wanted & ~n.bits)) + return SearchMatcher::NoMatch; + return m_matcher.name(text(id, folded)); +} + +qsizetype SearchIndex::answer(const QString &query) { + // A longer query's matches are among the last one's, as long as + // nothing's been read since. + QVector within; + const bool narrow = m_answered == m_rows.size() && SearchMatcher::canNarrow(m_query, query); + if (narrow) { + within.reserve(m_hits.size()); + for (const Hit &hit : std::as_const(m_hits)) + within.push_back(hit.row); + } + m_query = query; + m_matcher = SearchMatcher(query); + m_answered = m_rows.size(); + m_hits.clear(); + m_ordered = 0; + // Each name is matched once, however many rows share it. + if (++m_stamp == 0) { + m_stamps.fill(0); + m_stamp = 1; + } + m_stamps.resize(m_names.size()); + m_scores.resize(m_names.size()); + QByteArray folded; + const qsizetype count = narrow ? within.size() : m_rows.size(); + for (qsizetype i = 0; i < count; ++i) { + const quint32 row = narrow ? within[i] : quint32(i); + const quint32 id = m_rows[row].name; + if (m_stamps[id] != m_stamp) { + m_stamps[id] = m_stamp; + m_scores[id] = score(id, folded); + ++counts.names; + } + if (m_scores[id] != SearchMatcher::NoMatch) + m_hits.push_back({m_scores[id], row}); + } + return m_hits.size(); +} + +// The classes, best first; a Pending score is in Named. +static int classOf(int score) { + static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word}; + if (score != SearchMatcher::Pending) + for (int i = 0; i < int(std::size(tiers)); ++i) + if (score >= tiers[i] - SearchMatcher::Span) + return i; + return int(std::size(tiers)); +} +static constexpr int Classes = 4; + +// Paths in the order people number things: "photo9" before "photo10". +static int natural(QByteArrayView a, QByteArrayView b) { + const auto digit = [](char c) { return c >= '0' && c <= '9'; }; + qsizetype i = 0, j = 0; + while (i < a.size() && j < b.size()) { + if (digit(a[i]) && digit(b[j])) { + qsizetype ie = i, je = j; + while (ie < a.size() && digit(a[ie])) + ++ie; + while (je < b.size() && digit(b[je])) + ++je; + while (i + 1 < ie && a[i] == '0') + ++i; + while (j + 1 < je && b[j] == '0') + ++j; + if (ie - i != je - j) + return ie - i < je - j ? -1 : 1; + if (const int order = a.sliced(i, ie - i).compare(b.sliced(j, je - j))) + return order; + i = ie; + j = je; + } else { + const char x = lower(a[i]), y = lower(b[j]); + if (x != y) + return uchar(x) < uchar(y) ? -1 : 1; ++i; - if (i == needle.size()) - return true; + ++j; + } } - return false; + if (i == a.size() && j == b.size()) + return a.compare(b); + return i == a.size() ? -1 : 1; } -static bool isAscii(QByteArrayView text) { - for (char c : text) - if (uchar(c) >= 0x80) - return false; - return true; +bool SearchIndex::before(const Hit &a, const Hit &b) const { + if (a.score != b.score) + return a.score > b.score; + if (isDir(a.row) != isDir(b.row)) + return isDir(a.row); + const qsizetype la = pathLength(a.row), lb = pathLength(b.row); + if (la != lb) + return la < lb; + return natural(path(a.row), path(b.row)) < 0; +} + +QVector SearchIndex::rows(qsizetype from, qsizetype count) { + const qsizetype end = qMin(from + count, m_hits.size()); + if (end > m_ordered) { + // Only the classes the rows reach are scored inside and ordered. + const qsizetype need = end - m_ordered; + qsizetype inClass[Classes] = {}; + for (qsizetype i = m_ordered; i < m_hits.size(); ++i) + ++inClass[classOf(m_hits[i].score)]; + int last = 0; + for (qsizetype reached = inClass[0]; reached < need; reached += inClass[++last]) {} + const auto rest = m_hits.begin() + m_ordered; + const auto reach = std::partition(rest, m_hits.end(), [&](const Hit &hit) { return classOf(hit.score) <= last; }); + QByteArray folded; + for (auto it = rest; it != reach; ++it) + if (it->score == SearchMatcher::Pending) + it->score = m_matcher.align(text(m_rows[it->row].name, folded)); + const auto order = [this](const Hit &a, const Hit &b) { return before(a, b); }; + if (rest + need < reach) + std::nth_element(rest, rest + need, reach, order); + std::sort(rest, rest + need, order); + m_ordered = end; + } + QVector out; + for (qsizetype i = from; i < end; ++i) + out.push_back(m_hits[i].row); + return out; } QVector SearchIndex::find(const QString &query, const QString &sort, int limit, const std::function &stop, const QLocale &locale) { - // Bytes on both sides when both are ASCII; otherwise Qt's own - // comparison, as for any name. - const QByteArray needle = query.toLower().toUtf8(); - const bool asciiQuery = isAscii(needle); - QVector entries; - QMimeDatabase mimes; - for (quint32 row = 0; row < quint32(m_rows.size()) && entries.size() < limit; ++row) { - const QByteArrayView text = name(m_rows[row].name); - const bool found = asciiQuery && isAscii(text) ? containsAscii(text, needle) - : QString::fromUtf8(text).contains(query, Qt::CaseInsensitive); - if (!found) - continue; - if (stop && stop()) - return {}; - entries.push_back(entry(row, mimes)); - } - FolderModel::sortEntries(entries, sort, locale); - return entries; + answer(query); + QVector found = entries(rows(0, limit), stop); + FolderModel::sortEntries(found, sort, locale); + return found; } diff --git a/src/searchindex.h b/src/searchindex.h index 9a0871c1..bdd8df20 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -5,11 +5,66 @@ #include #include #include +#include #include #include class QMimeDatabase; +// One query, matched against names a byte at a time: ASCII letters of either +// case alike, other text compared once Qt has folded its case. +class SearchMatcher { +public: + // The classes of a match, best first, far enough apart that the score + // inside a class never reaches the next one. + enum Tier : int { + Exact = 5000000, // the name is the query + Prefix = 4000000, // the name starts with it + Word = 3500000, // a word in the name starts with it + Named = 2000000, // its letters are in the name, in order + }; + static constexpr int Span = 100000; + // A match whose score inside its class is only worked out if it's shown. + static constexpr int Pending = -2; + static constexpr int NoMatch = INT_MIN; + + explicit SearchMatcher(const QString &query); + const QByteArray &needle() const { return m_needle; } + quint64 bits() const { return m_bits; } + // The class `text` matches in, Pending when that's Named, or NoMatch. + int name(QByteArrayView text) const; + // A Named match's score: runs, word starts and letters close together + // earn more. + int align(QByteArrayView text) const; + // Whether every match of `now` is among the matches of `before`. + static bool canNarrow(const QString &before, const QString &now); + // One bit for each letter, digit and common mark in `text`: a name that + // lacks one of the query's can't match it. + static quint64 bitsOf(QByteArrayView text); + static constexpr quint64 Unknown = 1ull << 63; + static constexpr quint64 NotAscii = 1ull << 62; + + // The score inside a class, read a piece of the path at a time: each of + // the query's letters placed, in order, earns more next to the last one, + // at the start of a word and in the row's own name, and a letter + // skipped costs one. + struct Alignment { + static constexpr int Dead = INT_MIN / 4; + static constexpr int RunBonus = 8, WordBonus = 6, OwnNameBonus = 4; + std::vector best, end; + int hit = Dead; + void reset(qsizetype length); + void advance(QByteArrayView needle, QByteArrayView text, bool own); + int score(int tier) const; + }; + +private: + bool startsAt(QByteArrayView text, qsizetype at) const; + + QByteArray m_needle; + quint64 m_bits = 0; +}; + // Everything below a folder by name, read once for a search. Names stay the // bytes the folder gave, each kept once however many folders hold it, and a // row's path is put together from its folders only when it's shown. @@ -20,6 +75,7 @@ class SearchIndex { qsizetype opens = 0; // folders opened qsizetype stats = 0; // one for each folder opened, each row shown, and each entry of unknown type qsizetype walks = 0; + qsizetype names = 0; // names matched, each once a query however many rows share it }; // Reads `root` and every folder below it, breadth first, staying on its @@ -33,8 +89,16 @@ class SearchIndex { QByteArray path(quint32 row) const; // The row as the views show it, named by its path from the root. FolderModel::Entry entry(quint32 row, const QMimeDatabase &mimes); - // The first `limit` rows, in the walk's order, whose name contains - // `query` ignoring case, sorted by `sort` in `locale`'s alphabet. Empty once `stop` returns true. + // The rows as entries; empty once `stop` returns true. + QVector entries(const QVector &rows, const std::function &stop = {}); + // Matches `query`, best first, and gives how many rows match. The rows + // themselves come from rows(), ordered only as far as they're asked for. + qsizetype answer(const QString &query); + // The answer's rows from `from`, at most `count` of them, best first: + // a better class first, then a better score, a folder before a file, + // the shorter path, and the paths in natural order. + QVector rows(qsizetype from, qsizetype count); + // The best `limit` rows that match `query`, sorted by `sort` in `locale`'s alphabet. Empty once `stop` returns true. QVector find(const QString &query, const QString &sort, int limit, const std::function &stop = {}, const QLocale &locale = QLocale()); @@ -44,14 +108,22 @@ class SearchIndex { static constexpr quint32 None = UINT32_MAX; // Names are kept in slabs this big, which go back to the system with the search. static constexpr qsizetype SlabBytes = 1 << 20; - struct Name { quint32 offset, length; }; - // A folder is its name and the folder holding it; a row likewise. - struct Folder { quint32 name, parent; }; + struct Name { quint32 offset, length; quint64 bits; }; + // A folder is its name, the folder holding it and the length of its + // path; a row is its name and its folder. + struct Folder { quint32 name, parent, length; }; struct Row { quint32 name, parent; }; + struct Hit { int score; quint32 row; }; quint32 intern(QByteArrayView text); QByteArrayView name(quint32 id) const; + // The name as the matcher reads it: folded first when it isn't ASCII. + QByteArrayView text(quint32 id, QByteArray &folded) const; QByteArray joined(quint32 folder, QByteArrayView last) const; + bool isDir(quint32 row) const { return m_kinds[row / 8] & (1 << (row % 8)); } + qsizetype pathLength(quint32 row) const; + int score(quint32 id, QByteArray &folded) const; + bool before(const Hit &a, const Hit &b) const; QString m_root; std::vector m_slabs; @@ -60,4 +132,18 @@ class SearchIndex { QVector m_lookup; QVector m_folders; QVector m_rows; + // A bit for each row, set for a folder. + QByteArray m_kinds; + + // The last answer: its query and matcher, its matches, how many of them + // are in their final order, and how many rows there were to match. + QString m_query; + SearchMatcher m_matcher{QString()}; + QVector m_hits; + qsizetype m_ordered = 0; + qsizetype m_answered = -1; + // Each name's score for the query the stamp is for. + quint32 m_stamp = 0; + QVector m_stamps; + QVector m_scores; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 64254c50..a2a6c41a 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1387,6 +1387,70 @@ private slots: QCOMPARE(model.searchIndex()->counts.walks, 1); } + void searchListsTheBestMatchesFirst() + { + // The name itself first, however deep; then names that start with + // it, then a word that does, then its letters in order. A folder + // goes before a file that matches as well, and a shorter path first. + QTemporaryDir dir; + QDir(dir.path()).mkpath("deep/down/frame"); + QDir(dir.path()).mkpath("src/frame_old"); + write(dir.filePath("for_a_meeting.txt"), "x"); + write(dir.filePath("src/render_frame.rs"), "x"); + write(dir.filePath("frame_new.txt"), "x"); + write(dir.filePath("Frame notes.txt"), "x"); + write(dir.filePath("frog.txt"), "x"); + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QCOMPARE(index.answer("frame"), 6); + QStringList order; + for (quint32 row : index.rows(0, 10)) + order << QString::fromUtf8(index.path(row)); + QCOMPARE(order, QStringList({"deep/down/frame", "src/frame_old", "frame_new.txt", "Frame notes.txt", + "src/render_frame.rs", "for_a_meeting.txt"})); + // Each name is matched once, and a longer query only among these. + const qsizetype names = index.counts.names; + QCOMPARE(index.answer("framen"), 3); + QCOMPARE(index.counts.names, names + 6); + } + + void searchShowsItsMatchesAWindowAtATime() + { + // The first matches show at once and the rest as the view scrolls, + // each looked at only when it's shown; nothing is cut off. + QTemporaryDir dir; + for (int i = 0; i < 1100; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.matches(), -1); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.matches(), 1100); + QCOMPARE(model.count(), 350); + QCOMPARE(model.get(0).value("name").toString(), QString("match0.txt")); + QCOMPARE(model.get(349).value("name").toString(), QString("match349.txt")); + const auto index = model.searchIndex(); + QCOMPARE(index->counts.stats, index->counts.opens + 350); + for (int shown : {700, 1050, 1100}) { + QVERIFY(model.canFetchMore({})); + model.fetchMore({}); + QTRY_COMPARE(model.count(), shown); + } + QVERIFY(!model.canFetchMore({})); + QCOMPARE(model.get(1099).value("name").toString(), QString("match1099.txt")); + QCOMPARE(index->counts.stats, index->counts.opens + 1100); + // Another order puts the best matches in it, as many as ever. + model.setSortMode("size"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 1000); + QVERIFY(!model.canFetchMore({})); + model.setSortMode("name"); + QVERIFY(loaded.wait(3000)); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; From 8e49454aad3d601a240a5a83846d022ec9899cbb Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 22/38] Match a search along the path, not only the name A query such as editor/src/render found nothing, since only names were compared. The folders above a row now take as much of the query as they can and the name takes the rest; a name that holds the query still comes first. --- src/searchindex.cpp | 128 ++++++++++++++++++++++++++++++++++++------ src/searchindex.h | 31 ++++++++-- tests/folio_tests.cpp | 31 ++++++++++ 3 files changed, 166 insertions(+), 24 deletions(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index d988bd17..34b7e41b 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -46,7 +47,18 @@ quint64 SearchMatcher::bitsOf(QByteArrayView text) { } SearchMatcher::SearchMatcher(const QString &query) - : m_needle(query.toCaseFolded().toUtf8()), m_bits(bitsOf(m_needle)) {} + : m_needle(query.toCaseFolded().toUtf8()), m_bits(bitsOf(m_needle)), + m_folderPart(m_needle.lastIndexOf('/') + 1), m_bitsFrom(m_needle.size() + 1, 0) { + for (qsizetype at = m_needle.size(); at-- > 0;) + m_bitsFrom[at] = m_bitsFrom[at + 1] | bitsOf(m_needle.sliced(at, 1)); +} + +qsizetype SearchMatcher::reach(QByteArrayView text, qsizetype at) const { + for (char c : text) + if (at < m_needle.size() && m_needle[at] == lower(c)) + ++at; + return at; +} bool SearchMatcher::canNarrow(const QString &before, const QString &now) { return !before.isEmpty() && now.startsWith(before); @@ -164,7 +176,9 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (isDir) { m_kinds.back() = char(m_kinds.back() | (1 << (m_rows.size() % 8))); const quint32 above = parent == None ? 0 : m_folders[parent].length + 1; - m_folders.push_back({id, parent, above + m_names[id].length}); + // A "/" after each folder, as in the path. + const quint64 bits = m_names[id].bits | SearchMatcher::bitsOf("/") | (parent == None ? 0 : m_folders[parent].bits); + m_folders.push_back({id, parent, above + m_names[id].length, bits}); } m_rows.push_back({id, parent}); } @@ -186,7 +200,8 @@ qsizetype SearchIndex::bytes() const { slabs += slab.capacity(); return slabs + m_names.capacity() * sizeof(Name) + m_lookup.capacity() * sizeof(quint32) + m_folders.capacity() * sizeof(Folder) + m_rows.capacity() * sizeof(Row) + m_kinds.capacity() - + m_hits.capacity() * sizeof(Hit) + m_stamps.capacity() * sizeof(quint32) + m_scores.capacity() * sizeof(int); + + m_hits.capacity() * sizeof(Hit) + m_stamps.capacity() * sizeof(quint32) + m_scores.capacity() * sizeof(int) + + m_folderStamps.capacity() * sizeof(quint32) + m_reaches.capacity() * sizeof(quint32); } quint32 SearchIndex::intern(QByteArrayView text) { @@ -269,15 +284,65 @@ qsizetype SearchIndex::pathLength(quint32 row) const { return (r.parent == None ? 0 : m_folders[r.parent].length + 1) + m_names[r.name].length; } +// Whether a name, or a path, with `held` can hold every letter in `wanted`: +// one it lacks rules it out unread. +static bool possible(quint64 wanted, quint64 held) { + return ((wanted | held) & SearchMatcher::Unknown) || !(wanted & ~held); +} + int SearchIndex::score(quint32 id, QByteArray &folded) const { - const Name &n = m_names[id]; - const quint64 wanted = m_matcher.bits(); - // A letter the query has and the name lacks rules it out unread. - if (!((wanted | n.bits) & SearchMatcher::Unknown) && (wanted & ~n.bits)) + if (!possible(m_matcher.bits(), m_names[id].bits)) return SearchMatcher::NoMatch; return m_matcher.name(text(id, folded)); } +// How far into the query the folders down to `folder` take it: worked out +// once a folder a query, so a row costs only its own name. +qsizetype SearchIndex::reach(quint32 folder) { + QVarLengthArray unknown; + for (quint32 at = folder; at != None && m_folderStamps[at] != m_stamp; at = m_folders[at].parent) + unknown.push_back(at); + QByteArray folded; + while (!unknown.isEmpty()) { + const quint32 at = unknown.back(); + unknown.pop_back(); + const quint32 parent = m_folders[at].parent; + qsizetype got = m_matcher.reach(text(m_folders[at].name, folded), parent == None ? 0 : m_reaches[parent]); + // The query's "/" is spent on the one after the folder. + if (got < m_matcher.needle().size() && m_matcher.needle()[got] == '/') + ++got; + m_folderStamps[at] = m_stamp; + m_reaches[at] = quint32(got); + ++counts.folders; + } + return m_reaches[folder]; +} + +// A match along the path's score: the folders' part worked out once a +// folder, then the row's own name. +int SearchIndex::alongPath(quint32 row, int tier, QHash &above) { + const QByteArray &needle = m_matcher.needle(); + QVarLengthArray unknown; + quint32 at = m_rows[row].parent; + for (; at != None && !above.contains(at); at = m_folders[at].parent) + unknown.push_back(at); + QByteArray folded; + while (!unknown.isEmpty()) { + const quint32 folder = unknown.back(); + unknown.pop_back(); + SearchMatcher::Alignment alignment; + if (m_folders[folder].parent == None) + alignment.reset(needle.size()); + else + alignment = above.value(m_folders[folder].parent); + alignment.advance(needle, text(m_folders[folder].name, folded).toByteArray() + '/', false); + above.insert(folder, alignment); + } + SearchMatcher::Alignment alignment = above.value(m_rows[row].parent); + alignment.advance(needle, text(m_rows[row].name, folded), true); + return alignment.score(tier); +} + qsizetype SearchIndex::answer(const QString &query) { // A longer query's matches are among the last one's, as long as // nothing's been read since. @@ -296,36 +361,59 @@ qsizetype SearchIndex::answer(const QString &query) { // Each name is matched once, however many rows share it. if (++m_stamp == 0) { m_stamps.fill(0); + m_folderStamps.fill(0); m_stamp = 1; } m_stamps.resize(m_names.size()); m_scores.resize(m_names.size()); + m_folderStamps.resize(m_folders.size()); + m_reaches.resize(m_folders.size()); + const QByteArray &needle = m_matcher.needle(); + const quint64 first = needle.isEmpty() ? 0 : SearchMatcher::bitsOf(needle.first(1)); + const quint64 folderBits = SearchMatcher::bitsOf(needle.first(m_matcher.folderPart())); QByteArray folded; const qsizetype count = narrow ? within.size() : m_rows.size(); for (qsizetype i = 0; i < count; ++i) { const quint32 row = narrow ? within[i] : quint32(i); - const quint32 id = m_rows[row].name; - if (m_stamps[id] != m_stamp) { - m_stamps[id] = m_stamp; - m_scores[id] = score(id, folded); + const Row &r = m_rows[row]; + if (m_stamps[r.name] != m_stamp) { + m_stamps[r.name] = m_stamp; + m_scores[r.name] = score(r.name, folded); ++counts.names; } - if (m_scores[id] != SearchMatcher::NoMatch) - m_hits.push_back({m_scores[id], row}); + int found = m_scores[r.name]; + // Not in the name as typed: then perhaps along the path, the + // folders taking the query as far as they can and the name the + // rest. The bits rule most paths out before any folder is read. + if ((found == SearchMatcher::NoMatch || (found < SearchMatcher::Named + SearchMatcher::Span && found != SearchMatcher::Pending)) + && r.parent != None) { + const quint64 held = m_folders[r.parent].bits | m_names[r.name].bits; + if (needle.size() <= pathLength(row) && possible(first, held) && possible(folderBits, m_folders[r.parent].bits) + && possible(m_matcher.bits(), held)) { + const qsizetype got = reach(r.parent); + if (got >= m_matcher.folderPart() && possible(m_matcher.bitsFrom(got), m_names[r.name].bits) + && m_matcher.reach(text(r.name, folded), got) == needle.size()) + found = m_matcher.folderPart() > 0 ? SearchMatcher::PendingAligned : SearchMatcher::PendingPath; + } + } + if (found != SearchMatcher::NoMatch) + m_hits.push_back({found, row}); } return m_hits.size(); } -// The classes, best first; a Pending score is in Named. +// The classes, best first; a pending score is in its own. static int classOf(int score) { - static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word}; - if (score != SearchMatcher::Pending) + static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word, SearchMatcher::Aligned}; + if (score == SearchMatcher::PendingAligned) + return 3; + if (score != SearchMatcher::Pending && score != SearchMatcher::PendingPath) for (int i = 0; i < int(std::size(tiers)); ++i) if (score >= tiers[i] - SearchMatcher::Span) return i; return int(std::size(tiers)); } -static constexpr int Classes = 4; +static constexpr int Classes = 5; // Paths in the order people number things: "photo9" before "photo10". static int natural(QByteArrayView a, QByteArrayView b) { @@ -385,9 +473,13 @@ QVector SearchIndex::rows(qsizetype from, qsizetype count) { const auto rest = m_hits.begin() + m_ordered; const auto reach = std::partition(rest, m_hits.end(), [&](const Hit &hit) { return classOf(hit.score) <= last; }); QByteArray folded; - for (auto it = rest; it != reach; ++it) + QHash above; + for (auto it = rest; it != reach; ++it) { if (it->score == SearchMatcher::Pending) it->score = m_matcher.align(text(m_rows[it->row].name, folded)); + else if (it->score == SearchMatcher::PendingAligned || it->score == SearchMatcher::PendingPath) + it->score = alongPath(it->row, it->score == SearchMatcher::PendingAligned ? SearchMatcher::Aligned : SearchMatcher::Named, above); + } const auto order = [this](const Hit &a, const Hit &b) { return before(a, b); }; if (rest + need < reach) std::nth_element(rest, rest + need, reach, order); diff --git a/src/searchindex.h b/src/searchindex.h index bdd8df20..a21a7114 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -21,11 +22,13 @@ class SearchMatcher { Exact = 5000000, // the name is the query Prefix = 4000000, // the name starts with it Word = 3500000, // a word in the name starts with it - Named = 2000000, // its letters are in the name, in order + Aligned = 3000000, // with a "/", its folders hold what's before it and the name the rest + Named = 2000000, // its letters are in the name, or along the path, in order }; static constexpr int Span = 100000; - // A match whose score inside its class is only worked out if it's shown. - static constexpr int Pending = -2; + // A match whose score inside its class is only worked out if it's + // shown: Aligned, Named in the name, or Named along the path. + static constexpr int PendingAligned = -3, Pending = -2, PendingPath = -1; static constexpr int NoMatch = INT_MIN; explicit SearchMatcher(const QString &query); @@ -36,6 +39,14 @@ class SearchMatcher { // A Named match's score: runs, word starts and letters close together // earn more. int align(QByteArrayView text) const; + // How far into the query `text` takes it, from `at`, its letters + // taken in order. + qsizetype reach(QByteArrayView text, qsizetype at) const; + // The query's length up to and with its last "/", which the folders + // have to take it past; 0 without one. + qsizetype folderPart() const { return m_folderPart; } + // The bits of the query from `at` on. + quint64 bitsFrom(qsizetype at) const { return m_bitsFrom[at]; } // Whether every match of `now` is among the matches of `before`. static bool canNarrow(const QString &before, const QString &now); // One bit for each letter, digit and common mark in `text`: a name that @@ -63,6 +74,8 @@ class SearchMatcher { QByteArray m_needle; quint64 m_bits = 0; + qsizetype m_folderPart = 0; + std::vector m_bitsFrom; }; // Everything below a folder by name, read once for a search. Names stay the @@ -76,6 +89,7 @@ class SearchIndex { qsizetype stats = 0; // one for each folder opened, each row shown, and each entry of unknown type qsizetype walks = 0; qsizetype names = 0; // names matched, each once a query however many rows share it + qsizetype folders = 0; // folders matched along a path, each at most once a query }; // Reads `root` and every folder below it, breadth first, staying on its @@ -109,9 +123,9 @@ class SearchIndex { // Names are kept in slabs this big, which go back to the system with the search. static constexpr qsizetype SlabBytes = 1 << 20; struct Name { quint32 offset, length; quint64 bits; }; - // A folder is its name, the folder holding it and the length of its - // path; a row is its name and its folder. - struct Folder { quint32 name, parent, length; }; + // A folder is its name, the folder holding it, the length of its path + // and the bits of every name along it; a row is its name and its folder. + struct Folder { quint32 name, parent, length; quint64 bits; }; struct Row { quint32 name, parent; }; struct Hit { int score; quint32 row; }; @@ -123,6 +137,8 @@ class SearchIndex { bool isDir(quint32 row) const { return m_kinds[row / 8] & (1 << (row % 8)); } qsizetype pathLength(quint32 row) const; int score(quint32 id, QByteArray &folded) const; + qsizetype reach(quint32 folder); + int alongPath(quint32 row, int tier, QHash &above); bool before(const Hit &a, const Hit &b) const; QString m_root; @@ -146,4 +162,7 @@ class SearchIndex { quint32 m_stamp = 0; QVector m_stamps; QVector m_scores; + // Each folder's reach into the query, for the query its stamp is for. + QVector m_folderStamps; + QVector m_reaches; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index a2a6c41a..0133b496 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1451,6 +1451,37 @@ private slots: QVERIFY(loaded.wait(3000)); } + void searchMatchesWordsAlongThePath() + { + // With a "/", the folders take what's before it and the name the + // rest. Each folder is matched at most once a query, however many + // rows it holds. + QTemporaryDir dir; + QDir(dir.path()).mkpath("projects/editor/src/components"); + QDir(dir.path()).mkpath("projects/viewer/src"); + QDir(dir.path()).mkpath("archive/2024/client/reports"); + for (int i = 0; i < 50; ++i) + write(dir.filePath(QString("projects/editor/src/components/render%1.rs").arg(i)), "x"); + write(dir.filePath("projects/editor/src/components/render_frame.rs"), "x"); + write(dir.filePath("projects/viewer/src/render.rs"), "x"); + write(dir.filePath("archive/2024/client/reports/quarterly_review.pdf"), "x"); + write(dir.filePath("render_notes.txt"), "x"); + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + const auto best = [&](const QString &query) { + index.answer(query); + const auto rows = index.rows(0, 1); + return rows.isEmpty() ? QString() : QString::fromUtf8(index.path(rows.first())); + }; + QCOMPARE(best("editor/src/components/render_f"), QString("projects/editor/src/components/render_frame.rs")); + QCOMPARE(best("archive/2024/client/reports/quarterly"), QString("archive/2024/client/reports/quarterly_review.pdf")); + const qsizetype before = index.counts.folders; + QCOMPARE(index.answer("src/render"), 52); + QVERIFY(index.counts.folders - before <= 10); + // A name that holds the query comes before a path that does. + QCOMPARE(best("render"), QString("render_notes.txt")); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; From 2b42c373b5735f46cc8b51c4b9c0cf47ce9f037f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 23/38] Forgive one typo when no name spells the query A slip such as redner for render found nothing. When no name holds the query whole or in runs that each start a word, names one edit away are listed, two for long words, ahead of names that only hold its letters scattered in order. Digits are never edited, and a query some name spells out never pays. --- src/searchindex.cpp | 161 +++++++++++++++++++++++++++++++++++++----- src/searchindex.h | 15 ++++ tests/folio_tests.cpp | 37 ++++++++++ 3 files changed, 196 insertions(+), 17 deletions(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 34b7e41b..2b0640da 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -1,5 +1,6 @@ #include "searchindex.h" +#include #include #include #include @@ -17,6 +18,15 @@ static char lower(char c) { return c >= 'A' && c <= 'Z' ? char(c + ('a' - 'A')) : c; } +static bool isLetter(char c) { + c = lower(c); + return c >= 'a' && c <= 'z'; +} + +static bool isAlnum(char c) { + return isLetter(c) || (c >= '0' && c <= '9'); +} + static bool isSeparator(char c) { return c == '/' || c == '-' || c == '_' || c == '.' || c == ' '; } @@ -51,6 +61,58 @@ SearchMatcher::SearchMatcher(const QString &query) m_folderPart(m_needle.lastIndexOf('/') + 1), m_bitsFrom(m_needle.size() + 1, 0) { for (qsizetype at = m_needle.size(); at-- > 0;) m_bitsFrom[at] = m_bitsFrom[at + 1] | bitsOf(m_needle.sliced(at, 1)); + // One typo from this many letters and digits, two from twice as many. + constexpr int oneTypoFrom = 4; + const qsizetype letters = std::count_if(m_needle.begin(), m_needle.end(), isAlnum); + if (!m_needle.isEmpty() && isAlnum(m_needle[0])) + m_typos = letters >= 2 * oneTypoFrom ? 2 : letters >= oneTypoFrom ? 1 : 0; +} + +// Optimal string alignment from each word start, so a swapped pair is one +// typo: only letters are added, dropped or changed, never digits or marks. +int SearchMatcher::typo(QByteArrayView hay) const { + // Each typo costs this much of the class's score. + constexpr int typoCost = 1000; + const int over = m_typos + 1; + const int length = int(m_needle.size()); + int fewest = over; + for (int start = 0; start < int(hay.size()); ++start) { + if (!wordStart(hay, start) || int(hay.size()) - start + m_typos < length) + continue; + const int span = std::min(int(hay.size()) - start, length + m_typos); + m_older.assign(span + 1, over); + m_previous.assign(span + 1, over); + m_current.assign(span + 1, over); + m_previous[0] = 0; + for (int j = 1; j <= std::min(span, m_typos); ++j) + m_previous[j] = isLetter(hay[start + j - 1]) ? m_previous[j - 1] + 1 : over; + for (int i = 1; i <= length; ++i) { + std::fill(m_current.begin(), m_current.end(), over); + const char a = m_needle[i - 1]; + m_current[0] = isLetter(a) ? m_previous[0] + 1 : over; + int low = m_current[0]; + for (int j = std::max(1, i - m_typos); j <= std::min(span, i + m_typos); ++j) { + const char b = lower(hay[start + j - 1]); + int value = a == b ? m_previous[j - 1] : isLetter(a) && isLetter(b) ? m_previous[j - 1] + 1 : over; + if (isLetter(a)) + value = std::min(value, m_previous[j] + 1); + if (isLetter(b)) + value = std::min(value, m_current[j - 1] + 1); + if (i > 1 && j > 1 && a == lower(hay[start + j - 2]) && m_needle[i - 2] == b && isLetter(a) && isLetter(b)) + value = std::min(value, m_older[j - 2] + 1); + m_current[j] = std::min(value, over); + low = std::min(low, value); + } + m_older.swap(m_previous); + m_previous.swap(m_current); + if (low > m_typos) + break; + if (i == length) + for (int j = std::max(0, length - m_typos); j <= span; ++j) + fewest = std::min(fewest, m_previous[j]); + } + } + return fewest <= m_typos ? Typo - fewest * typoCost : NoMatch; } qsizetype SearchMatcher::reach(QByteArrayView text, qsizetype at) const { @@ -73,6 +135,29 @@ bool SearchMatcher::startsAt(QByteArrayView text, qsizetype at) const { return true; } +bool SearchMatcher::spells(QByteArrayView text) const { + for (qsizetype at = 0; at + m_needle.size() <= text.size(); ++at) + if (startsAt(text, at)) + return true; + // reached[k]: the query's first k letters are placed, each run from the start of a word. + QVarLengthArray reached(m_needle.size() + 1); + std::fill(reached.begin(), reached.end(), false); + reached[0] = true; + for (qsizetype at = 0; at < text.size(); ++at) { + if (!wordStart(text, at)) + continue; + // From the far end, so a run this word starts isn't taken up again within the word. + for (qsizetype k = m_needle.size(); k-- > 0;) { + if (!reached[k]) + continue; + for (qsizetype run = 0; k + run < m_needle.size() && at + run < text.size() + && lower(text[at + run]) == m_needle[k + run]; ++run) + reached[k + run + 1] = true; + } + } + return reached[m_needle.size()]; +} + int SearchMatcher::name(QByteArrayView text) const { if (m_needle.isEmpty()) return NoMatch; @@ -284,10 +369,15 @@ qsizetype SearchIndex::pathLength(quint32 row) const { return (r.parent == None ? 0 : m_folders[r.parent].length + 1) + m_names[r.name].length; } -// Whether a name, or a path, with `held` can hold every letter in `wanted`: -// one it lacks rules it out unread. -static bool possible(quint64 wanted, quint64 held) { - return ((wanted | held) & SearchMatcher::Unknown) || !(wanted & ~held); +// Whether a name, or a path, with `held` can hold every letter in `wanted`, +// `edits` of them aside: one more it lacks rules it out unread. Digits and +// marks are never edited. +static bool possible(quint64 wanted, quint64 held, int edits = 0) { + constexpr quint64 digitsAndMarks = ((1ull << 16) - 1) << 26; + if ((wanted | held) & SearchMatcher::Unknown) + return true; + const quint64 missing = wanted & ~held; + return !(missing & digitsAndMarks) && int(qPopulationCount(missing)) <= edits; } int SearchIndex::score(quint32 id, QByteArray &folded) const { @@ -359,11 +449,7 @@ qsizetype SearchIndex::answer(const QString &query) { m_hits.clear(); m_ordered = 0; // Each name is matched once, however many rows share it. - if (++m_stamp == 0) { - m_stamps.fill(0); - m_folderStamps.fill(0); - m_stamp = 1; - } + restamp(); m_stamps.resize(m_names.size()); m_scores.resize(m_names.size()); m_folderStamps.resize(m_folders.size()); @@ -372,6 +458,9 @@ qsizetype SearchIndex::answer(const QString &query) { const quint64 first = needle.isEmpty() ? 0 : SearchMatcher::bitsOf(needle.first(1)); const quint64 folderBits = SearchMatcher::bitsOf(needle.first(m_matcher.folderPart())); QByteArray folded; + // Whether a name spells the query out, or its folders were named with a + // "/": anything else found is its letters scattered about. + bool whole = false; const qsizetype count = narrow ? within.size() : m_rows.size(); for (qsizetype i = 0; i < count; ++i) { const quint32 row = narrow ? within[i] : quint32(i); @@ -396,24 +485,62 @@ qsizetype SearchIndex::answer(const QString &query) { found = m_matcher.folderPart() > 0 ? SearchMatcher::PendingAligned : SearchMatcher::PendingPath; } } - if (found != SearchMatcher::NoMatch) - m_hits.push_back({found, row}); + if (found == SearchMatcher::NoMatch) + continue; + m_hits.push_back({found, row}); + whole = whole || found >= SearchMatcher::Word || found == SearchMatcher::PendingAligned + || (found == SearchMatcher::Pending && m_matcher.spells(text(r.name, folded))); + } + // No name spells it out: then names a typo or two off, ahead of the + // scattered ones. The slowest way to match, so it's only tried now. + if (!whole && m_matcher.typos() > 0) { + ++counts.typos; + restamp(); + const auto off = [&](quint32 id) { + if (m_stamps[id] != m_stamp) { + m_stamps[id] = m_stamp; + m_scores[id] = possible(m_matcher.bits(), m_names[id].bits, m_matcher.typos()) + ? m_matcher.typo(text(id, folded)) : SearchMatcher::NoMatch; + } + return m_scores[id]; + }; + // A scattered match that is also a typo off counts as the typo. + QBitArray listed(m_rows.size()); + for (Hit &hit : m_hits) { + listed.setBit(hit.row); + if (const int score = off(m_rows[hit.row].name); score != SearchMatcher::NoMatch) + hit.score = score; + } + for (quint32 row = 0; row < quint32(m_rows.size()); ++row) + if (!listed.testBit(row)) + if (const int score = off(m_rows[row].name); score != SearchMatcher::NoMatch) + m_hits.push_back({score, row}); } return m_hits.size(); } +void SearchIndex::restamp() { + if (++m_stamp == 0) { + m_stamps.fill(0); + m_folderStamps.fill(0); + m_stamp = 1; + } +} + // The classes, best first; a pending score is in its own. static int classOf(int score) { - static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word, SearchMatcher::Aligned}; + static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word, SearchMatcher::Aligned, + SearchMatcher::Named}; if (score == SearchMatcher::PendingAligned) return 3; - if (score != SearchMatcher::Pending && score != SearchMatcher::PendingPath) - for (int i = 0; i < int(std::size(tiers)); ++i) - if (score >= tiers[i] - SearchMatcher::Span) - return i; + if (score == SearchMatcher::Pending || score == SearchMatcher::PendingPath) + return 4; + for (int i = 0; i < int(std::size(tiers)); ++i) + if (score >= tiers[i] - SearchMatcher::Span) + return i; return int(std::size(tiers)); } -static constexpr int Classes = 5; +static constexpr int Classes = 6; // Paths in the order people number things: "photo9" before "photo10". static int natural(QByteArrayView a, QByteArrayView b) { diff --git a/src/searchindex.h b/src/searchindex.h index a21a7114..ca669551 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -23,6 +23,7 @@ class SearchMatcher { Prefix = 4000000, // the name starts with it Word = 3500000, // a word in the name starts with it Aligned = 3000000, // with a "/", its folders hold what's before it and the name the rest + Typo = 2500000, // the name is a letter or two off, when no name spells the query out Named = 2000000, // its letters are in the name, or along the path, in order }; static constexpr int Span = 100000; @@ -39,6 +40,15 @@ class SearchMatcher { // A Named match's score: runs, word starts and letters close together // earn more. int align(QByteArrayView text) const; + // How many letters a name may be off from the query: none when the + // query doesn't start with a letter or a digit. + int typos() const { return m_typos; } + // The Typo score of a name that far off at most, from the start of a + // word, or NoMatch. + int typo(QByteArrayView text) const; + // Whether `text` spells the query out: whole anywhere, or in runs that + // each start a word, as framen does in frame_new. + bool spells(QByteArrayView text) const; // How far into the query `text` takes it, from `at`, its letters // taken in order. qsizetype reach(QByteArrayView text, qsizetype at) const; @@ -76,6 +86,9 @@ class SearchMatcher { quint64 m_bits = 0; qsizetype m_folderPart = 0; std::vector m_bitsFrom; + int m_typos = 0; + // The rows of the typo pass's table, kept between names. + mutable std::vector m_older, m_previous, m_current; }; // Everything below a folder by name, read once for a search. Names stay the @@ -90,6 +103,7 @@ class SearchIndex { qsizetype walks = 0; qsizetype names = 0; // names matched, each once a query however many rows share it qsizetype folders = 0; // folders matched along a path, each at most once a query + qsizetype typos = 0; // passes looking for names a letter or two off }; // Reads `root` and every folder below it, breadth first, staying on its @@ -139,6 +153,7 @@ class SearchIndex { int score(quint32 id, QByteArray &folded) const; qsizetype reach(quint32 folder); int alongPath(quint32 row, int tier, QHash &above); + void restamp(); bool before(const Hit &a, const Hit &b) const; QString m_root; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 0133b496..42afecea 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1482,6 +1482,43 @@ private slots: QCOMPARE(best("render"), QString("render_notes.txt")); } + void searchForgivesATypoWhenNoNameSpellsTheQuery() + { + // No name spelling the query out, a name a letter off (a swapped + // pair counts as one) is found; a name that holds it whole, or in + // runs that each start a word, means the typo pass never runs. + QTemporaryDir dir; + for (const char *folder : {"projects/src", "editor/render"}) + QDir(dir.path()).mkpath(folder); + write(dir.filePath("projects/src/render_frame.rs"), "x"); + write(dir.filePath("projects/src/thread_runner.rs"), "x"); + write(dir.filePath("editor/render/pipeline.rs"), "x"); + write(dir.filePath("fuzzy.rs"), "x"); + write(dir.filePath("report2024.pdf"), "x"); + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QCOMPARE(index.answer("redner_frame.rs"), 1); + QCOMPARE(index.path(index.rows(0, 1).first()), QByteArray("projects/src/render_frame.rs")); + QCOMPARE(index.answer("fzuzy"), 1); + QCOMPARE(index.path(index.rows(0, 1).first()), QByteArray("fuzzy.rs")); + QCOMPARE(index.counts.typos, 2); + // Digits aren't letters to get wrong. + QCOMPARE(index.answer("report2025"), 0); + QCOMPARE(index.counts.typos, 3); + for (const char *query : {"render", "rend", "fuzzy", "report", "renfr", "ort2024"}) + QVERIFY(index.answer(query) > 0); + QCOMPARE(index.counts.typos, 3); + // Letters scattered through a name (thread_runner) or along a path + // (editor/render/pipeline) don't stand in for the name meant: it comes first. + QCOMPARE(index.answer("redner"), 4); + QCOMPARE(index.counts.typos, 4); + const QVector rows = index.rows(0, 4); + QCOMPARE(index.path(rows[0]), QByteArray("editor/render")); + QCOMPARE(index.path(rows[1]), QByteArray("projects/src/render_frame.rs")); + QCOMPARE(QSet({index.path(rows[2]), index.path(rows[3])}), + QSet({"editor/render/pipeline.rs", "projects/src/thread_runner.rs"})); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; From 5016dfa5cedda39ab798ec081334a9ebfee8b9bd Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:13:04 -0400 Subject: [PATCH 24/38] Show the first matches while a big folder is still read On a very large tree the first key waited for the whole walk. After a short hold the best matches read so far are shown and refreshed until the walk lands, and the field says it is still reading. --- src/PathBar.qml | 8 ++-- src/foldermodel.cpp | 63 +++++++++++++++++++++---- src/foldermodel.h | 10 ++++ src/i18n/en.json | 4 ++ src/searchindex.cpp | 105 ++++++++++++++++++++++++++++++------------ src/searchindex.h | 10 +++- tests/folio_tests.cpp | 46 ++++++++++++++++++ 7 files changed, 202 insertions(+), 44 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index bb2720f5..d5698381 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -96,8 +96,9 @@ Rectangle { leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 // Where it searches, at the end of the field, and once it has - // answered, how many match. Unlike a placeholder, it stays while - // typing. Plugins' views have nothing below them. + // answered, how many match, so far while it's still reading. + // Unlike a placeholder, it stays while typing. Plugins' views + // have nothing below them. Text { id: scope // Recents, Trash and plugins' views are named in C++, which @@ -111,7 +112,8 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : backend.folder.query.length > 0 && backend.folder.matches >= 0 - ? qsTr("%Ln matches in %1 and below", "", backend.folder.matches).arg(here) + ? (backend.folder.reading ? qsTr("%Ln matches so far, still reading %1 and below", "", backend.folder.matches) + : qsTr("%Ln matches in %1 and below", "", backend.folder.matches)).arg(here) : qsTr("Search %1 and below").arg(here) color: theme.secondaryForeground font.pixelSize: 12 diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 37500c07..ac629158 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -21,6 +21,12 @@ #include #include +// How long a search of a big folder shows nothing: as long as a stale folder +// stays up and as long as the path bar waits for typing to pause. +static constexpr int SearchHold = 150; +// How often, after that, it shows its best matches so far. +static constexpr int SearchRefresh = 100; + FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { // Copies, saves and downloads change a folder in bursts; wait for the // burst to settle instead of relisting on every event. @@ -38,6 +44,15 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { m_staleClear.setSingleShot(true); m_staleClear.setInterval(150); connect(&m_staleClear, &QTimer::timeout, this, &FolderModel::clearEntries); + // A search of a big folder shows nothing for as long as a stale folder + // stays up, then its best matches so far, again a moment after each + // until it's all been read. + m_hold.setSingleShot(true); + m_hold.setInterval(SearchHold); + connect(&m_hold, &QTimer::timeout, this, &FolderModel::readEarly); + m_refresh.setSingleShot(true); + m_refresh.setInterval(SearchRefresh); + connect(&m_refresh, &QTimer::timeout, this, &FolderModel::readEarly); } // Generations start at 1, so 0 stops any search or folder count still @@ -484,8 +499,8 @@ FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatab QVector FolderModel::search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop, int limit, const QLocale &locale) { - // Breadth first, so what's near comes before what's deep when there are - // more matches than the limit. + // Best first, and of matches as good, what's near before what's deep + // when there are more than the limit. SearchIndex index; if (!index.walk(root, showHidden, stop)) return {}; @@ -691,6 +706,8 @@ struct Listing { QVector entries; QString error; int matches = -1; + // Answered while the folders below were still being read. + bool partial = false; }; // The open search: its index, read once in the background, and the lock @@ -699,8 +716,10 @@ struct FolderModel::Search { SearchIndex index; QMutex lock; std::atomic closed{false}; - // Set on the UI thread once the walk is done; nothing is answered before. + // Set on the UI thread: the walk is done, or has gone on past the hold + // and is answered as it reads. bool landed = false; + bool early = false; }; std::shared_ptr FolderModel::searchIndex() const { @@ -717,12 +736,17 @@ void FolderModel::openSearch() { watcher->deleteLater(); if (search != m_search) return; // Ended, or another search started, while it was read. + m_hold.stop(); + m_refresh.stop(); search->landed = true; + setReading(false); read(); }); watcher->setFuture(QtConcurrent::run([search, path, showHidden] { - search->index.walk(path, showHidden, [&] { return search->closed.load(); }); + search->index.walk(path, showHidden, [&] { return search->closed.load(); }, &search->lock); })); + setReading(true); + m_hold.start(); } void FolderModel::closeSearch() { @@ -730,6 +754,23 @@ void FolderModel::closeSearch() { return; m_search->closed = true; m_search.reset(); + m_hold.stop(); + m_refresh.stop(); + setReading(false); +} + +void FolderModel::readEarly() { + if (m_search && !m_search->landed) { + m_search->early = true; + read(); + } +} + +void FolderModel::setReading(bool reading) { + if (reading == m_stillReading) + return; + m_stillReading = reading; + emit readingChanged(); } // What's on disk now: a search open reads its folders again. @@ -763,19 +804,20 @@ void FolderModel::read() { *m_latest = generation; const auto latest = m_latest; // A search's first letter starts reading the folders below; until - // they've been read, the landing asks again. + // they've been read, or the hold has passed, the landing asks again. if (!query.isEmpty() && !isView(path) && !m_search) openSearch(); const auto search = query.isEmpty() || isView(path) ? nullptr : m_search; - if (search && !search->landed) + if (search && !search->landed && !search->early) return; + const bool partial = search && !search->landed; auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { watcher->deleteLater(); if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); - auto [entries, error, matches] = watcher->future().takeResult(); + auto [entries, error, matches, partial] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); @@ -801,14 +843,17 @@ void FolderModel::read() { m_matches = matches; emit matchesChanged(); } - setLoading(false); + setLoading(partial); emit loaded(); + if (partial) + m_refresh.start(); if (error.isEmpty()) remember(); countFolders(generation); }); - watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search] { + watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search, partial] { Listing listing; + listing.partial = partial; const auto overtaken = [&] { return *latest != generation; }; if (!search) { listing.entries = isPluginView(path) ? listItems(items, query) diff --git a/src/foldermodel.h b/src/foldermodel.h index 7f96be4c..71d6eda1 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -36,6 +36,8 @@ class FolderModel : public QAbstractListModel { Q_PROPERTY(QVariantList items READ items WRITE setItems NOTIFY itemsChanged) // How many match the query, shown or not; -1 until a search has answered. Q_PROPERTY(int matches READ matches NOTIFY matchesChanged) + // While a search is still reading the folders below. + Q_PROPERTY(bool reading READ reading NOTIFY readingChanged) public: enum Role { @@ -95,6 +97,7 @@ class FolderModel : public QAbstractListModel { QVariantList items() const { return m_items; } void setItems(const QVariantList &items); int matches() const { return m_matches; } + bool reading() const { return m_stillReading; } void setPath(const QString &path); Q_INVOKABLE void reload(); @@ -175,6 +178,7 @@ class FolderModel : public QAbstractListModel { void queryChanged(); void itemsChanged(); void matchesChanged(); + void readingChanged(); void selectionChanged(); void loaded(); @@ -183,6 +187,9 @@ class FolderModel : public QAbstractListModel { void read(); void openSearch(); void closeSearch(); + void setReading(bool reading); + // Answers from what's been read so far, past the hold. + void readEarly(); void setLoading(bool loading); void setEntries(const QVector &entries); void clearEntries(); @@ -212,6 +219,7 @@ class FolderModel : public QAbstractListModel { QVariantList m_items; int m_matches = -1; bool m_fetching = false; + bool m_stillReading = false; quint64 m_generation = 0; // The newest listing's generation, shared with the background listing, // search and folder counts, so work that's been overtaken (more typing, @@ -232,4 +240,6 @@ class FolderModel : public QAbstractListModel { QFileSystemWatcher m_watcher; QTimer m_debounce; QTimer m_staleClear; + QTimer m_hold; + QTimer m_refresh; }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 24544ad7..8aae17c7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,6 +44,10 @@ "one": "%Ln match in %1 and below", "other": "%Ln matches in %1 and below" }, + "%Ln matches so far, still reading %1 and below": { + "one": "%Ln match so far, still reading %1 and below", + "other": "%Ln matches so far, still reading %1 and below" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 2b0640da..ca64e450 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -206,43 +207,83 @@ int SearchMatcher::align(QByteArrayView text) const { return alignment.score(Named); } -bool SearchIndex::walk(const QString &root, bool showHidden, const std::function &stop) { +bool SearchIndex::walk(const QString &root, bool showHidden, const std::function &stop, QMutex *lock) { ++counts.walks; m_root = root; const QByteArray top = QFile::encodeName(root); dev_t drive = 0; + // Gone partway: nothing is kept. + const auto stopped = [&] { + QMutexLocker hold(lock); + const Counts spent = counts; + *this = SearchIndex(); + counts = spent; + return false; + }; + // A folder's names are read first and added all at once, so an answer + // given meanwhile sees whole folders. While an answer holds the lock, + // the folders already found are read on and wait here, in order. + struct Read { + quint32 parent; + QByteArray names; + QVector ends; + QVector folders; + qsizetype asked = 0; + }; + std::vector waiting; + const auto add = [&] { + for (const Read &read : waiting) { + ++counts.opens; + counts.stats += 1 + read.asked; + for (qsizetype i = 0, from = 0; i < read.ends.size(); from = read.ends[i++]) { + const quint32 id = intern(QByteArrayView(read.names).sliced(from, read.ends[i] - from)); + if (m_rows.size() % 8 == 0) + m_kinds.append('\0'); + if (read.folders[i]) { + m_kinds.back() = char(m_kinds.back() | (1 << (m_rows.size() % 8))); + const quint32 above = read.parent == None ? 0 : m_folders[read.parent].length + 1; + // A "/" after each folder, as in the path. + const quint64 bits = m_names[id].bits | SearchMatcher::bitsOf("/") + | (read.parent == None ? 0 : m_folders[read.parent].bits); + m_folders.push_back({id, read.parent, above + m_names[id].length, bits}); + } + m_rows.push_back({id, read.parent}); + } + } + waiting.clear(); + }; // The folders found are the queue: each is read in the order it was // found, which is breadth first. -1 is the root. - for (qsizetype folder = -1; folder < m_folders.size(); ++folder) { + for (qsizetype folder = -1;; ++folder) { + // Out of folders to read, those waiting go in, and perhaps bring more. + if (folder >= m_folders.size() && !waiting.empty()) { + QMutexLocker hold(lock); + add(); + } + if (folder >= m_folders.size()) + break; + if (stop && stop()) + return stopped(); const quint32 parent = folder < 0 ? None : quint32(folder); const QByteArray at = folder < 0 ? top : top + '/' + joined(m_folders[folder].parent, name(m_folders[folder].name)); // Below the root, a folder that's become a link since it was listed isn't followed. const int fd = ::open(at.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | (folder < 0 ? 0 : O_NOFOLLOW)); if (fd < 0) continue; - ++counts.opens; - ++counts.stats; struct stat st; // Other filesystems (a mounted drive, another machine, /proc from // /) are listed but not read. - if (::fstat(fd, &st) != 0 || (folder >= 0 && st.st_dev != drive)) { - ::close(fd); - continue; - } + const bool same = ::fstat(fd, &st) == 0 && (folder < 0 || st.st_dev == drive); if (folder < 0) drive = st.st_dev; - DIR *dir = ::fdopendir(fd); - if (!dir) { + DIR *dir = same ? ::fdopendir(fd) : nullptr; + if (!dir) ::close(fd); - continue; - } - while (const dirent *entry = ::readdir(dir)) { + Read read{parent, {}, {}, {}}; + while (const dirent *entry = dir ? ::readdir(dir) : nullptr) { if (stop && stop()) { ::closedir(dir); - const Counts spent = counts; - *this = SearchIndex(); - counts = spent; - return false; + return stopped(); } const char *text = entry->d_name; if (text[0] == '.' && (!showHidden || text[1] == 0 || (text[1] == '.' && text[2] == 0))) @@ -251,26 +292,29 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function // say needs asking. Symlinked folders aren't walked into. bool isDir = entry->d_type == DT_DIR; if (entry->d_type == DT_UNKNOWN) { - ++counts.stats; + ++read.asked; struct stat child; isDir = ::fstatat(::dirfd(dir), text, &child, AT_SYMLINK_NOFOLLOW) == 0 && S_ISDIR(child.st_mode); } - const quint32 id = intern(QByteArrayView(text)); - if (m_rows.size() % 8 == 0) - m_kinds.append('\0'); - if (isDir) { - m_kinds.back() = char(m_kinds.back() | (1 << (m_rows.size() % 8))); - const quint32 above = parent == None ? 0 : m_folders[parent].length + 1; - // A "/" after each folder, as in the path. - const quint64 bits = m_names[id].bits | SearchMatcher::bitsOf("/") | (parent == None ? 0 : m_folders[parent].bits); - m_folders.push_back({id, parent, above + m_names[id].length, bits}); - } - m_rows.push_back({id, parent}); + read.names.append(text); + read.ends.push_back(read.names.size()); + read.folders.push_back(isDir); + } + if (dir) + ::closedir(dir); + waiting.push_back(std::move(read)); + if (!lock) { + add(); + } else if (lock->tryLock()) { + add(); + lock->unlock(); } - ::closedir(dir); + if (afterFolder) + afterFolder(folder + 2); } // Names are only looked up while folders are read, and what's left // over from growing goes back. + QMutexLocker hold(lock); m_lookup = {}; m_names.squeeze(); m_folders.squeeze(); @@ -434,6 +478,7 @@ int SearchIndex::alongPath(quint32 row, int tier, QHash within; diff --git a/src/searchindex.h b/src/searchindex.h index ca669551..a25ef87d 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -11,6 +11,7 @@ #include class QMimeDatabase; +class QMutex; // One query, matched against names a byte at a time: ASCII letters of either // case alike, other text compared once Qt has folded its case. @@ -104,12 +105,17 @@ class SearchIndex { qsizetype names = 0; // names matched, each once a query however many rows share it qsizetype folders = 0; // folders matched along a path, each at most once a query qsizetype typos = 0; // passes looking for names a letter or two off + qsizetype answers = 0; }; // Reads `root` and every folder below it, breadth first, staying on its // drive and out of symlinked folders. False, and empty, once `stop` - // returns true. - bool walk(const QString &root, bool showHidden, const std::function &stop = {}); + // returns true. Holding `lock` while it adds each folder, it can be + // answered from as it reads. + bool walk(const QString &root, bool showHidden, const std::function &stop = {}, QMutex *lock = nullptr); + // Called with how many folders have been read, after each, so a test + // can hold a walk partway. + inline static std::function afterFolder; qsizetype size() const { return m_rows.size(); } // The memory it holds. qsizetype bytes() const; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 42afecea..4574118f 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -1519,6 +1520,51 @@ private slots: QSet({"editor/render/pipeline.rs", "projects/src/thread_runner.rs"})); } + void searchShowsMatchesWhileItsStillReading() + { + // A folder that takes a while to read shows its best matches so far + // (saying it's still reading) until it's all been read, and then + // all of them. + QTemporaryDir dir; + for (const char *folder : {"a", "b", "c"}) { + QDir(dir.path()).mkdir(folder); + write(dir.filePath(QString("%1/match.txt").arg(folder)), "x"); + } + write(dir.filePath("match.txt"), "x"); + QSemaphore held, resumed; + SearchIndex::afterFolder = [&](qsizetype folders) { + if (folders == 2) { + held.release(); + resumed.acquire(); + } + }; + const auto restore = qScopeGuard([&] { + if (held.available() + resumed.available() == 0 && SearchIndex::afterFolder) + resumed.release(); + SearchIndex::afterFolder = {}; + }); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + loaded.clear(); + model.setQuery("match"); + QVERIFY(model.reading()); + QVERIFY(held.tryAcquire(1, 3000)); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.reading()); + QVERIFY(model.loading()); + QCOMPARE(model.matches(), 2); + QCOMPARE(model.count(), 2); + resumed.release(); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.matches(), 4); + QCOMPARE(model.count(), 4); + QCOMPARE(model.searchIndex()->counts.walks, 1); + // One answer for each time it showed. + QCOMPARE(model.searchIndex()->counts.answers, loaded.count()); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; From 4a7c2f5608a4747c34e4c6d72f433ed489ca97f5 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 13:53:57 -0400 Subject: [PATCH 25/38] Select every match, and keep a search's selection With matches shown a window at a time, Select All took only the rows scrolled to, and F5 or a change in the folder went back to the first window, dropping a selection past it. Select All now takes the matches past the rows by path, at once, from the answer in memory; a search read again shows as many rows as it did and keeps what is selected while it still matches, and a partial answer drops nothing. --- src/foldermodel.cpp | 98 +++++++++++++++++++++++++---- src/foldermodel.h | 4 ++ src/searchindex.cpp | 36 ++++++++--- src/searchindex.h | 9 ++- tests/folio_tests.cpp | 139 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 23 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index ac629158..73beca37 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -306,6 +307,7 @@ void FolderModel::setQuery(const QString &query) { if (query == m_query) return; m_query = query; + m_shownRows = 0; emit queryChanged(); setSelected({}); // Back to the folder: its remembered listing shows at once, and what @@ -344,6 +346,7 @@ void FolderModel::setPath(const QString &path) { m_query.clear(); emit queryChanged(); } + m_shownRows = 0; setSelected({}); // The old folder stays up while the new one is read, which locally takes @@ -708,6 +711,8 @@ struct Listing { int matches = -1; // Answered while the folders below were still being read. bool partial = false; + // The matches past the rows, when a selection could hold some of them. + std::optional unshown; }; // The open search: its index, read once in the background, and the lock @@ -811,13 +816,15 @@ void FolderModel::read() { if (search && !search->landed && !search->early) return; const bool partial = search && !search->landed; + const qsizetype window = std::max(SearchWindow, m_shownRows); + const bool selecting = !m_selected.isEmpty(); auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { watcher->deleteLater(); if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); - auto [entries, error, matches, partial] = watcher->future().takeResult(); + auto [entries, error, matches, partial, unshown] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); @@ -828,11 +835,21 @@ void FolderModel::read() { } else { takeSizesAndDates(std::move(entries)); } - if (!m_selected.isEmpty()) { + // A partial answer drops nothing from the selection: what it hasn't found yet may still match. + if (!partial && !m_selected.isEmpty()) { QSet still; for (const Entry &e : std::as_const(m_entries)) if (m_selected.contains(e.path)) still.insert(e.path); + // A selected match that isn't a row stays selected while it still matches. + QStringList past; + for (const QString &path : unshown ? std::as_const(*unshown) : std::as_const(m_unshown)) { + if (m_selected.contains(path) && !still.contains(path)) { + past << path; + still.insert(path); + } + } + m_unshown = past; if (still != m_selected) { m_selected = still; emit selectionChanged(); @@ -844,6 +861,9 @@ void FolderModel::read() { emit matchesChanged(); } setLoading(partial); + // A partial answer has fewer rows than the search showed: it leaves the count alone. + if (!partial) + m_shownRows = m_search ? m_entries.size() : 0; emit loaded(); if (partial) m_refresh.start(); @@ -851,7 +871,7 @@ void FolderModel::read() { remember(); countFolders(generation); }); - watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search, partial] { + watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale, read, search, partial, window, selecting] { Listing listing; listing.partial = partial; const auto overtaken = [&] { return *latest != generation; }; @@ -867,13 +887,28 @@ void FolderModel::read() { } return listing; } - QMutexLocker hold(&search->lock); - if (overtaken()) - return listing; - listing.matches = int(search->index.answer(query)); - // Best first, a window at a time; another order puts the best - // matches in it instead. - listing.entries = search->index.entries(search->index.rows(0, sort == "name" ? SearchWindow : SortedMatches), overtaken); + QStringList names; + QString root; + { + QMutexLocker hold(&search->lock); + if (overtaken()) + return listing; + listing.matches = int(search->index.answer(query)); + root = search->index.root(); + // Best first, a window at a time; another order puts the best matches in it instead. + const qsizetype shown = sort == "name" ? window : SortedMatches; + names = search->index.names(search->index.rows(0, shown)); + if (selecting && !partial && sort == "name" && listing.matches > shown) + listing.unshown = SearchIndex::paths(root, search->index.names(search->index.rows(shown, listing.matches - shown))); + else if (selecting && !partial) + listing.unshown = QStringList(); + } + // The disk is asked with the lock let go, so Select All never waits for it. + listing.entries = SearchIndex::entriesAt(root, names, overtaken); + { + QMutexLocker hold(&search->lock); + search->index.counts.stats += listing.entries.size(); + } if (sort != "name") sortEntries(listing.entries, sort, locale); return listing; @@ -904,13 +939,25 @@ void FolderModel::fetchMore(const QModelIndex &parent) { beginInsertRows({}, from, from + more.size() - 1); m_entries += more; endInsertRows(); + m_shownRows = m_entries.size(); emit countChanged(); countFolders(generation); }); watcher->setFuture(QtConcurrent::run([search, from, latest, generation] { const auto overtaken = [&] { return *latest != generation; }; + QStringList names; + QString root; + { + QMutexLocker hold(&search->lock); + if (overtaken()) + return QVector(); + root = search->index.root(); + names = search->index.names(search->index.rows(from, SearchWindow)); + } + QVector more = SearchIndex::entriesAt(root, names, overtaken); QMutexLocker hold(&search->lock); - return overtaken() ? QVector() : search->index.entries(search->index.rows(from, SearchWindow), overtaken); + search->index.counts.stats += more.size(); + return more; })); } @@ -997,6 +1044,8 @@ QVariantMap FolderModel::toMap(const Entry &listed) { } void FolderModel::setSelected(const QSet &paths) { + if (paths.isEmpty()) + m_unshown.clear(); if (paths == m_selected) return; m_selected = paths; @@ -1042,7 +1091,17 @@ void FolderModel::selectAll() { QSet paths; for (const Entry &e : std::as_const(m_entries)) paths.insert(e.path); + QStringList unshown; + // A search shows its matches a window at a time: the ones that aren't rows yet are taken by path from the answer in memory, at once. + if (m_search && m_search->landed && !m_loading && m_sort == "name" && m_entries.size() < m_matches) { + QMutexLocker hold(&m_search->lock); + const QVector rows = m_search->index.rows(m_entries.size(), m_matches - m_entries.size()); + unshown = SearchIndex::paths(m_search->index.root(), m_search->index.names(rows)); + for (const QString &path : std::as_const(unshown)) + paths.insert(path); + } setSelected(paths); + m_unshown = unshown; } void FolderModel::clearSelection() { @@ -1059,7 +1118,22 @@ QStringList FolderModel::selectedPaths() const { for (const Entry &e : m_entries) if (m_selected.contains(e.path)) paths << e.path; - return paths; + if (paths.size() == m_selected.size()) + return paths; + // Then what's selected and isn't a row: what a Select All took past the rows, best first, then what a search read again hasn't found yet. + QSet named(paths.cbegin(), paths.cend()); + for (const QString &path : m_unshown) { + if (m_selected.contains(path) && !named.contains(path)) { + paths << path; + named.insert(path); + } + } + QStringList rest; + for (const QString &path : m_selected) + if (!named.contains(path)) + rest << path; + rest.sort(); + return paths + rest; } int FolderModel::indexOf(const QString &name) const { diff --git a/src/foldermodel.h b/src/foldermodel.h index 71d6eda1..3a319457 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -219,6 +219,10 @@ class FolderModel : public QAbstractListModel { QVariantList m_items; int m_matches = -1; bool m_fetching = false; + // The rows a search showed with its whole answer: read again, it shows as many, so a selection past the first window stays. + qsizetype m_shownRows = 0; + // The matches a Select All took that aren't rows yet, best first: the selection holds them by path. + QStringList m_unshown; bool m_stillReading = false; quint64 m_generation = 0; // The newest listing's generation, shared with the background listing, diff --git a/src/searchindex.cpp b/src/searchindex.cpp index ca64e450..6f2005c7 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -382,25 +382,43 @@ QByteArray SearchIndex::path(quint32 row) const { return joined(m_rows[row].parent, name(m_rows[row].name)); } -FolderModel::Entry SearchIndex::entry(quint32 row, const QMimeDatabase &mimes) { - const QString relative = QFile::decodeName(path(row)); - ++counts.stats; - FolderModel::Entry e = FolderModel::entryFor(QFileInfo(QDir(m_root).filePath(relative)), mimes); - e.name = relative; - return e; +QStringList SearchIndex::names(const QVector &rows) const { + QStringList out; + out.reserve(rows.size()); + for (quint32 row : rows) + out << QFile::decodeName(path(row)); + return out; } -QVector SearchIndex::entries(const QVector &rows, const std::function &stop) { +QStringList SearchIndex::paths(const QString &root, const QStringList &names) { + QStringList out; + out.reserve(names.size()); + const QDir dir(root); + for (const QString &name : names) + out << QFileInfo(dir.filePath(name)).absoluteFilePath(); + return out; +} + +QVector SearchIndex::entriesAt(const QString &root, const QStringList &names, const std::function &stop) { QVector out; QMimeDatabase mimes; - for (quint32 row : rows) { + const QDir dir(root); + for (const QString &name : names) { if (stop && stop()) return {}; - out.push_back(entry(row, mimes)); + FolderModel::Entry e = FolderModel::entryFor(QFileInfo(dir.filePath(name)), mimes); + e.name = name; + out.push_back(std::move(e)); } return out; } +QVector SearchIndex::entries(const QVector &rows, const std::function &stop) { + QVector out = entriesAt(m_root, names(rows), stop); + counts.stats += out.size(); + return out; +} + QByteArrayView SearchIndex::text(quint32 id, QByteArray &folded) const { if (!(m_names[id].bits & SearchMatcher::NotAscii)) return name(id); diff --git a/src/searchindex.h b/src/searchindex.h index a25ef87d..145d6640 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -121,8 +121,13 @@ class SearchIndex { qsizetype bytes() const; // The row's path from the root, in the folder's own bytes. QByteArray path(quint32 row) const; - // The row as the views show it, named by its path from the root. - FolderModel::Entry entry(quint32 row, const QMimeDatabase &mimes); + const QString &root() const { return m_root; } + // The rows' paths from the root, as the views name them: read from memory, so the lock is never held across the disk. + QStringList names(const QVector &rows) const; + // The full paths of names(), as their entries carry them. + static QStringList paths(const QString &root, const QStringList &names); + // names() as entries, each asked of the disk; empty once `stop` returns true. + static QVector entriesAt(const QString &root, const QStringList &names, const std::function &stop = {}); // The rows as entries; empty once `stop` returns true. QVector entries(const QVector &rows, const std::function &stop = {}); // Matches `query`, best first, and gives how many rows match. The rows diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 4574118f..461089d8 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1452,6 +1452,145 @@ private slots: QVERIFY(loaded.wait(3000)); } + void searchSelectAllTakesEveryMatch() + { + // Select All is of the matches, not of the rows scrolled to so far, and it's whole at once: a copy pressed next takes them all. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 350); + model.selectAll(); + QCOMPARE(model.selectedCount(), 500); + const QStringList all = model.selectedPaths(); + QCOMPARE(all.size(), 500); + QCOMPARE(QSet(all.cbegin(), all.cend()).size(), 500); + for (const QString &path : all) + QVERIFY2(QFileInfo::exists(path), qPrintable(path)); + // The rows come as before, a window at a time, and are selected as they come. + QCOMPARE(model.count(), 350); + model.fetchMore({}); + QTRY_COMPARE(model.count(), 500); + QVERIFY(model.isSelected(420)); + QCOMPARE(model.selectedPaths(), all); + + // One taken out again, among the rows or past them, leaves the rest. + model.setQuery("atch"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 350); + model.selectAll(); + model.toggleSelected(3); + QCOMPARE(model.selectedCount(), 499); + QCOMPARE(model.selectedPaths().size(), 499); + model.selectOnly(3); + QCOMPARE(model.selectedPaths().size(), 1); + } + + void searchReadAgainKeepsWhatSelectAllTook() + { + // F5, or a change in the folder, reads a search again: the matches selected past the rows stay selected, less the ones gone. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + model.selectAll(); + const QString last = model.selectedPaths().constLast(); + QVERIFY(QFile::remove(last)); + model.reload(); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 350); + QCOMPARE(model.matches(), 499); + QCOMPARE(model.selectedCount(), 499); + QCOMPARE(model.selectedPaths().size(), 499); + QVERIFY(!model.selectedPaths().contains(last)); + } + + void searchKeepsItsSelectionWhileItsReadAgain() + { + // A search read again shows what it has found so far: what's selected and not found yet stays selected and named, and the rows come back as many as they were. + QTemporaryDir dir; + for (const char *folder : {"a", "b", "c"}) { + QDir(dir.path()).mkdir(folder); + for (int i = 0; i < 150; ++i) + write(dir.filePath(QString("%1/match%2.txt").arg(folder).arg(i)), "x"); + } + for (int i = 0; i < 50; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + model.fetchMore({}); + QTRY_COMPARE(model.count(), 500); + model.selectAll(); + QCOMPARE(model.selectedCount(), 500); + + QSemaphore held, resumed; + SearchIndex::afterFolder = [&](qsizetype folders) { + if (folders == 2) { + held.release(); + resumed.acquire(); + } + }; + const auto restore = qScopeGuard([&] { + if (held.available() + resumed.available() == 0 && SearchIndex::afterFolder) + resumed.release(); + SearchIndex::afterFolder = {}; + }); + loaded.clear(); + model.reload(); + QVERIFY(held.tryAcquire(1, 3000)); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.reading()); + QVERIFY(model.count() < 500); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths().size(), 500); + resumed.release(); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 500); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths().size(), 500); + } + + void searchReadAgainKeepsTheRowsItShowed() + { + // F5, or a change in the folder, reads a search again: rows past the first window stay, and a selection among them. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + model.fetchMore({}); + QTRY_COMPARE(model.count(), 500); + model.selectOnly(420); + model.reload(); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 500); + QCOMPARE(model.selectedCount(), 1); + QVERIFY(model.isSelected(420)); + // Other words start from the first window again. + model.setQuery("atch"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 350); + } + void searchMatchesWordsAlongThePath() { // With a "/", the folders take what's before it and the name the From 085c536cb387e6f0ce40b63ff8c9b85a84de5e16 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 17:49:45 -0400 Subject: [PATCH 26/38] Keep a search answering while it is read again A change on disk or F5 walked the folders again from nothing, so a landed search fell back to partial answers and its count. It now answers on until the new walk has answered, one walk at a time. Select All takes every match while still reading and under any sort, and a list cut to the best matches says so. --- src/Format.js | 10 + src/PathBar.qml | 11 +- src/foldermodel.cpp | 125 +++++++---- src/foldermodel.h | 13 +- src/i18n/en.json | 4 + src/searchindex.h | 2 + tests/folio_tests.cpp | 466 ++++++++++++++++++++++++++++++++++++++---- 7 files changed, 541 insertions(+), 90 deletions(-) diff --git a/src/Format.js b/src/Format.js index 59f11b15..f8f0a589 100644 --- a/src/Format.js +++ b/src/Format.js @@ -40,6 +40,16 @@ function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" } +// How many match a search of `here` and below; `best` is how many of them are rows when another order cut the list, or -1. +function matches(n, here, reading, best) { + if (reading) + return qsTr("%Ln matches so far, still reading %1 and below", "", n).arg(here) + // The count goes in first: a folder's name holding "%2" is then never filled in again. + if (best >= 0) + return qsTr("%Ln matches in %2 and below, the best %1 shown", "", n).arg(best.toLocaleString(Qt.locale(), "f", 0)).arg(here) + return qsTr("%Ln matches in %1 and below", "", n).arg(here) +} + // What an item is and how big, under its name in the details and the // preview; for a plugin's item, its subtitle. function summary(item, type) { diff --git a/src/PathBar.qml b/src/PathBar.qml index d5698381..1e5da142 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Layouts +import "Format.js" as Format // The breadcrumb path from the left edge; view buttons on the right. Back and // forward are keys (Alt+← / Alt+→) and the mouse's thumb buttons. @@ -95,10 +96,7 @@ Rectangle { // Room at the end for where it searches; padding isn't mirrored. leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 - // Where it searches, at the end of the field, and once it has - // answered, how many match, so far while it's still reading. - // Unlike a placeholder, it stays while typing. Plugins' views - // have nothing below them. + // Where it searches and, once answered, how many match: unlike a placeholder it stays while typing. Text { id: scope // Recents, Trash and plugins' views are named in C++, which @@ -112,8 +110,9 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : backend.folder.query.length > 0 && backend.folder.matches >= 0 - ? (backend.folder.reading ? qsTr("%Ln matches so far, still reading %1 and below", "", backend.folder.matches) - : qsTr("%Ln matches in %1 and below", "", backend.folder.matches)).arg(here) + // Sorted another way than best first, the rows are the best of the matches, and it says so. + ? Format.matches(backend.folder.matches, here, backend.folder.reading, + backend.folder.sortMode !== "name" && backend.folder.matches > backend.folder.count ? backend.folder.count : -1) : qsTr("Search %1 and below").arg(here) color: theme.secondaryForeground font.pixelSize: 12 diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 73beca37..02b07a15 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -22,8 +22,7 @@ #include #include -// How long a search of a big folder shows nothing: as long as a stale folder -// stays up and as long as the path bar waits for typing to pause. +// How long a search of a big folder shows nothing: as long as a stale folder stays up, and the path bar's pause. static constexpr int SearchHold = 150; // How often, after that, it shows its best matches so far. static constexpr int SearchRefresh = 100; @@ -45,9 +44,7 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { m_staleClear.setSingleShot(true); m_staleClear.setInterval(150); connect(&m_staleClear, &QTimer::timeout, this, &FolderModel::clearEntries); - // A search of a big folder shows nothing for as long as a stale folder - // stays up, then its best matches so far, again a moment after each - // until it's all been read. + // A big folder's search holds, then shows its best matches so far, again a moment after each until it's all read. m_hold.setSingleShot(true); m_hold.setInterval(SearchHold); connect(&m_hold, &QTimer::timeout, this, &FolderModel::readEarly); @@ -292,7 +289,9 @@ void FolderModel::setShowHidden(bool show) { return; m_showHidden = show; emit showHiddenChanged(); - reload(); + // A search's walk took or left the hidden files: it ends here, and the folders are read anew. + closeSearch(); + read(); } void FolderModel::setSortMode(const QString &sort) { @@ -310,12 +309,13 @@ void FolderModel::setQuery(const QString &query) { m_shownRows = 0; emit queryChanged(); setSelected({}); - // Back to the folder: its remembered listing shows at once, and what - // the search read is let go of. + // Back to the folder: its remembered listing shows at once, and what the search read is let go of. if (m_query.isEmpty()) { closeSearch(); - if (const auto *cached = recentListings().object(cacheKey())) + if (const auto *cached = recentListings().object(cacheKey())) { setEntries(*cached); + m_rowsQuery.clear(); + } } read(); } @@ -340,6 +340,7 @@ void FolderModel::setPath(const QString &path) { if (!m_watcher.files().isEmpty()) m_watcher.removePaths(m_watcher.files()); m_path = clean; + m_rowsQuery.clear(); emit pathChanged(); // A search is of one folder; going elsewhere ends it. if (!m_query.isEmpty()) { @@ -502,8 +503,7 @@ FolderModel::Entry FolderModel::entryFor(const QFileInfo &info, const QMimeDatab QVector FolderModel::search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop, int limit, const QLocale &locale) { - // Best first, and of matches as good, what's near before what's deep - // when there are more than the limit. + // Best first, and of matches as good, what's near before what's deep. SearchIndex index; if (!index.walk(root, showHidden, stop)) return {}; @@ -696,15 +696,12 @@ int FolderModel::itemCount(const QString &folder, bool showHidden) { return count; } -// A search shows its best matches this many at a time, more as the view -// scrolls to them. +// A search shows its best matches this many at a time, more as the view scrolls to them. static constexpr int SearchWindow = 350; -// Sorted another way than best first, it shows this many of the best, as -// many as a search ever showed. +// Sorted another way than best first, it shows this many of the best, as many as a search ever showed. static constexpr int SortedMatches = 1000; -// What a read in the background brings back: the entries, what went wrong, -// and how many match when it's a search. +// What a read in the background brings back: the entries, what went wrong, and how many match a search. struct Listing { QVector entries; QString error; @@ -715,16 +712,21 @@ struct Listing { std::optional unshown; }; -// The open search: its index, read once in the background, and the lock -// its answers take turns at. +// The open search: its index, read once in the background, and the lock its answers take turns at. struct FolderModel::Search { SearchIndex index; QMutex lock; std::atomic closed{false}; - // Set on the UI thread: the walk is done, or has gone on past the hold - // and is answered as it reads. + // Set on the UI thread: the walk is done, or has gone on past the hold and is answered as it reads. bool landed = false; bool early = false; + // Every match of a query, by path, from the answer in memory; a keystroke taken back before its answer landed leaves the index on another query, so it's asked again here. + QStringList everyMatch(const QString &query) { + QMutexLocker hold(&lock); + if (index.query() != query) + index.answer(query); + return SearchIndex::paths(index.root(), index.names(index.rows(0, index.size()))); + } }; std::shared_ptr FolderModel::searchIndex() const { @@ -732,29 +734,43 @@ std::shared_ptr FolderModel::searchIndex() const { } void FolderModel::openSearch() { - m_search = std::make_shared(); - const auto search = m_search; + // With a search landed, the folders are read again beside it, and it answers until the new walk has answered. + const bool again = m_search && m_search->landed; + const auto search = std::make_shared(); + (again ? m_next : m_search) = search; const QString path = m_path; const bool showHidden = m_showHidden; auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, search] { + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, search, again] { watcher->deleteLater(); - if (search != m_search) + if (search != (again ? m_next : m_search)) return; // Ended, or another search started, while it was read. - m_hold.stop(); - m_refresh.stop(); search->landed = true; - setReading(false); + if (!again) { + m_hold.stop(); + m_refresh.stop(); + setReading(false); + } read(); + // A change that came while the first walk ran is read now, beside it. + if (!again && std::exchange(m_again, false)) + openSearch(); }); watcher->setFuture(QtConcurrent::run([search, path, showHidden] { search->index.walk(path, showHidden, [&] { return search->closed.load(); }, &search->lock); })); + if (again) + return; setReading(true); + m_refresh.stop(); m_hold.start(); } void FolderModel::closeSearch() { + if (m_next) + m_next->closed = true; + m_next.reset(); + m_again = false; if (!m_search) return; m_search->closed = true; @@ -762,6 +778,10 @@ void FolderModel::closeSearch() { m_hold.stop(); m_refresh.stop(); setReading(false); + if (m_matches != -1) { + m_matches = -1; + emit matchesChanged(); + } } void FolderModel::readEarly() { @@ -778,10 +798,14 @@ void FolderModel::setReading(bool reading) { emit readingChanged(); } -// What's on disk now: a search open reads its folders again. +// What's on disk now: a search answers on while its folders are read again, one walk at a time; a change during a walk is read once it's whole. void FolderModel::reload() { - closeSearch(); - read(); + if (!m_search) + read(); + else if (!m_search->landed || m_next) + m_again = true; + else + openSearch(); } void FolderModel::read() { @@ -808,22 +832,30 @@ void FolderModel::read() { m_asked.clear(); *m_latest = generation; const auto latest = m_latest; - // A search's first letter starts reading the folders below; until - // they've been read, or the hold has passed, the landing asks again. + // A search's first letter starts reading the folders below; until they're read, or the hold has passed, the landing asks again. if (!query.isEmpty() && !isView(path) && !m_search) openSearch(); - const auto search = query.isEmpty() || isView(path) ? nullptr : m_search; + // A walk read again answers from here on, and takes the old one's place as this answer lands. + const auto search = query.isEmpty() || isView(path) ? nullptr : m_next && m_next->landed ? m_next : m_search; if (search && !search->landed && !search->early) return; const bool partial = search && !search->landed; const qsizetype window = std::max(SearchWindow, m_shownRows); const bool selecting = !m_selected.isEmpty(); + const QString answers = search ? query : QString(); auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, answers, search] { watcher->deleteLater(); if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); + // The index, the rows and the count change in this one turn, so Select All never meets a walk that hasn't answered. + if (search && search == m_next) { + m_search = std::exchange(m_next, nullptr); + if (std::exchange(m_again, false)) + openSearch(); + } + m_rowsQuery = answers; auto [entries, error, matches, partial, unshown] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. @@ -841,6 +873,9 @@ void FolderModel::read() { for (const Entry &e : std::as_const(m_entries)) if (m_selected.contains(e.path)) still.insert(e.path); + // Selected while this answer was on its way, so it brought no matches past the rows: they're asked for here, as Select All asks. + if (search && !unshown && still.size() != m_selected.size() && matches > m_entries.size()) + unshown = search->everyMatch(answers); // A selected match that isn't a row stays selected while it still matches. QStringList past; for (const QString &path : unshown ? std::as_const(*unshown) : std::as_const(m_unshown)) { @@ -898,7 +933,7 @@ void FolderModel::read() { // Best first, a window at a time; another order puts the best matches in it instead. const qsizetype shown = sort == "name" ? window : SortedMatches; names = search->index.names(search->index.rows(0, shown)); - if (selecting && !partial && sort == "name" && listing.matches > shown) + if (selecting && !partial && listing.matches > shown) listing.unshown = SearchIndex::paths(root, search->index.names(search->index.rows(shown, listing.matches - shown))); else if (selecting && !partial) listing.unshown = QStringList(); @@ -1092,16 +1127,18 @@ void FolderModel::selectAll() { for (const Entry &e : std::as_const(m_entries)) paths.insert(e.path); QStringList unshown; - // A search shows its matches a window at a time: the ones that aren't rows yet are taken by path from the answer in memory, at once. - if (m_search && m_search->landed && !m_loading && m_sort == "name" && m_entries.size() < m_matches) { - QMutexLocker hold(&m_search->lock); - const QVector rows = m_search->index.rows(m_entries.size(), m_matches - m_entries.size()); - unshown = SearchIndex::paths(m_search->index.root(), m_search->index.names(rows)); - for (const QString &path : std::as_const(unshown)) - paths.insert(path); + // Every match the count names, read or still reading, in any order: by path from the answer in memory, when it's the rows' own. + if (m_search && !m_rowsQuery.isEmpty() && m_rowsQuery == m_query) { + for (const QString &path : m_search->everyMatch(m_query)) { + if (!paths.contains(path)) { + unshown << path; + paths.insert(path); + } + } } - setSelected(paths); + // Before the selection says it changed, so what it names is whole. m_unshown = unshown; + setSelected(paths); } void FolderModel::clearSelection() { diff --git a/src/foldermodel.h b/src/foldermodel.h index 3a319457..0359e21a 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -159,9 +159,7 @@ class FolderModel : public QAbstractListModel { // Recents and plugins' views: lists of their own, not folders. static bool isView(const QString &path) { return path == recentPath() || isPluginView(path); } static QString kindForMime(const QString &mime); - // The best `limit` matches for `query` under `root` (see SearchIndex), - // named by their path from `root`. Stops at other filesystems and at - // symlinks, and as soon as `stop` returns true. + // The best `limit` matches for `query` under `root`, named from `root`; stops at other drives, symlinks and when `stop` says. static QVector search(const QString &root, const QString &query, bool showHidden, const QString &sort, const std::function &stop = {}, int limit = 1000, const QLocale &locale = QLocale()); @@ -238,9 +236,14 @@ class FolderModel : public QAbstractListModel { mutable QSet m_owed; // A get() answered without a size or date, so QML takes its snapshots again (on countChanged) once they're in. mutable bool m_snapshotWaiting = false; - // The folders below, read on a search's first letter and kept until it - // ends, the folder changes or hidden files are shown or hidden. + // The folders below, read on a search's first letter and kept until it ends, the folder changes or hidden files are shown or hidden. std::shared_ptr m_search; + // The same folders read again (F5, a change on disk) while m_search answers; it takes its place once it has answered. + std::shared_ptr m_next; + // A change on disk came while a walk ran: the folders are read once more when it's whole. + bool m_again = false; + // The query the rows are an answer to; empty while they're the folder's own. + QString m_rowsQuery; QFileSystemWatcher m_watcher; QTimer m_debounce; QTimer m_staleClear; diff --git a/src/i18n/en.json b/src/i18n/en.json index 8aae17c7..ef6c3819 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,6 +44,10 @@ "one": "%Ln match in %1 and below", "other": "%Ln matches in %1 and below" }, + "%Ln matches in %2 and below, the best %1 shown": { + "one": "%Ln match in %2 and below, the best %1 shown", + "other": "%Ln matches in %2 and below, the best %1 shown" + }, "%Ln matches so far, still reading %1 and below": { "one": "%Ln match so far, still reading %1 and below", "other": "%Ln matches so far, still reading %1 and below" diff --git a/src/searchindex.h b/src/searchindex.h index 145d6640..475f6117 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -133,6 +133,8 @@ class SearchIndex { // Matches `query`, best first, and gives how many rows match. The rows // themselves come from rows(), ordered only as far as they're asked for. qsizetype answer(const QString &query); + // The query the last answer was for. + const QString &query() const { return m_query; } // The answer's rows from `from`, at most `count` of them, best first: // a better class first, then a better score, a folder before a file, // the shorter path, and the paths in natural order. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 461089d8..9a7ad30b 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -459,6 +460,50 @@ static int lastWindowClosed() return qScopeGuard([old] { qputenv("PATH", old); }); } +// Longer than a search holds before it shows what it has (150 ms) and than a folder's watcher waits for changes to settle (250 ms). +static constexpr int PastTheSearchHold = 400; + +// Holds each walk of a search after its `at`th folder until let go, and counts the walks and the folders they read. +struct WalksHeld { + QSemaphore reached, go; + std::atomic walks{0}, folders{0}; + explicit WalksHeld(qsizetype at = 1) + { + SearchIndex::afterFolder = [this, at](qsizetype read) { + ++folders; + walks += read == 1; + if (read == at) { + reached.release(); + go.acquire(); + go.release(); + } + }; + } + void letGo() { go.release(); } + ~WalksHeld() + { + go.release(); + QThreadPool::globalInstance()->waitForDone(); + SearchIndex::afterFolder = {}; + } +}; + +// Holds every thread of the shared pool until it goes, so what a model starts meanwhile waits its turn. +struct PoolHeld { + QSemaphore gate; + const int threads = QThreadPool::globalInstance()->maxThreadCount(); + PoolHeld() + { + for (int i = 0; i < threads; ++i) + QThreadPool::globalInstance()->start([this] { gate.acquire(); }); + } + ~PoolHeld() + { + gate.release(threads); + QThreadPool::globalInstance()->waitForDone(); + } +}; + // Whether a process has ended. Killed, it can stay a zombie until whoever // adopted it reaps it, which in a container's bare PID 1 is never. static bool ended(pid_t pid) @@ -1517,7 +1562,7 @@ private slots: void searchKeepsItsSelectionWhileItsReadAgain() { - // A search read again shows what it has found so far: what's selected and not found yet stays selected and named, and the rows come back as many as they were. + // A landed search read again answers on while its folders are walked anew: rows, count and selection never change, and the new walk takes over whole. QTemporaryDir dir; for (const char *folder : {"a", "b", "c"}) { QDir(dir.path()).mkdir(folder); @@ -1532,37 +1577,399 @@ private slots: QVERIFY(loaded.wait(3000)); model.setQuery("match"); QTRY_VERIFY(!model.reading() && !model.loading()); - QCOMPARE(model.count(), 350); model.fetchMore({}); QTRY_COMPARE(model.count(), 500); model.selectAll(); - QCOMPARE(model.selectedCount(), 500); + const QStringList all = model.selectedPaths(); + QCOMPARE(all.size(), 500); - QSemaphore held, resumed; - SearchIndex::afterFolder = [&](qsizetype folders) { - if (folders == 2) { - held.release(); - resumed.acquire(); - } - }; - const auto restore = qScopeGuard([&] { - if (held.available() + resumed.available() == 0 && SearchIndex::afterFolder) - resumed.release(); - SearchIndex::afterFolder = {}; - }); + WalksHeld walks(2); loaded.clear(); + const auto before = model.searchIndex(); model.reload(); - QVERIFY(held.tryAcquire(1, 3000)); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QTest::qWait(PastTheSearchHold); + QVERIFY(loaded.isEmpty()); + QVERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 500); + QCOMPARE(model.matches(), 500); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths(), all); + QCOMPARE(model.searchIndex(), before); + walks.letGo(); QVERIFY(loaded.wait(3000)); - QVERIFY(model.reading()); - QVERIFY(model.count() < 500); + QTRY_VERIFY(!model.loading()); + QVERIFY(model.searchIndex() != before); + QCOMPARE(loaded.count(), 1); + QCOMPARE(model.count(), 500); + QCOMPARE(model.matches(), 500); QCOMPARE(model.selectedCount(), 500); QCOMPARE(model.selectedPaths().size(), 500); - resumed.release(); + } + + void searchSelectAllWhileStillReadingTakesEveryMatchSoFar() + { + // Select All while the first walk still reads takes every match found so far, not the window of rows, and keeps them once it lands. + QTemporaryDir dir; + for (int i = 0; i < 400; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + for (const char *folder : {"a", "b"}) { + QDir(dir.path()).mkdir(folder); + for (int i = 0; i < 50; ++i) + write(dir.filePath(QString("%1/match%2.txt").arg(folder).arg(i)), "x"); + } + WalksHeld walks; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.reading()); + QCOMPARE(model.matches(), 400); + QCOMPARE(model.count(), 350); + model.selectAll(); + QCOMPARE(model.selectedCount(), 400); + QCOMPARE(model.selectedPaths().size(), 400); + walks.letGo(); QTRY_VERIFY(!model.reading() && !model.loading()); - QCOMPARE(model.count(), 500); + QCOMPARE(model.matches(), 500); + QCOMPARE(model.selectedCount(), 400); + QCOMPARE(model.selectedPaths().size(), 400); + } + + void searchReadAgainDuringADownloadKeepsItsRows() + { + // Files landing in the searched folder ask for it again and again: one walk runs at a time and one more follows it, and the rows never drop or say they're still reading. + QTemporaryDir dir; + for (int i = 0; i < 20; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 20); + QSignalSpy reading(&model, &FolderModel::readingChanged); + int fewest = model.count(); + connect(&model, &FolderModel::countChanged, &model, [&] { fewest = std::min(fewest, model.count()); }); + WalksHeld walks; + model.reload(); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + // Written after the held walk read its only folder: the walk that follows it finds them. + for (int i = 20; i < 26; ++i) { + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + model.reload(); + } + QTest::qWait(PastTheSearchHold); + QCOMPARE(model.count(), 20); + walks.letGo(); + QTRY_COMPARE(model.count(), 26); + QTRY_VERIFY(!model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCOMPARE(walks.walks.load(), 2); + QCOMPARE(fewest, 20); + QVERIFY(reading.isEmpty()); + QCOMPARE(model.matches(), 26); + } + + void searchChangedDuringItsFirstReadIsReadAgain() + { + // A change while the first walk still reads doesn't start it over: it lands as it is, and the folders are read once more beside it. + QTemporaryDir dir; + QDir(dir.path()).mkdir("a"); + write(dir.filePath("match.txt"), "x"); + write(dir.filePath("a/match.txt"), "x"); + WalksHeld walks; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + // In the folder the held walk has read already. + write(dir.filePath("match too.txt"), "x"); + model.reload(); + QTest::qWait(PastTheSearchHold); + QCOMPARE(walks.walks.load(), 1); + walks.letGo(); + QTRY_COMPARE(model.matches(), 3); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCOMPARE(walks.walks.load(), 2); + } + + void searchReadAgainTakesOverOnceItHasAnswered() + { + // A walk read again is whole before it has answered: until its answer lands, the count and Select All are the old walk's, never the rows alone. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + QThreadPool *pool = QThreadPool::globalInstance(); + QVERIFY(pool->waitForDone(5000)); + const auto before = model.searchIndex(); + model.reload(); + QVERIFY(pool->waitForDone(5000)); + { + PoolHeld held; + // The walk's end is taken in here, and the answer it asks for waits behind the held pool. + QCoreApplication::processEvents(); + QCOMPARE(model.searchIndex(), before); + model.selectAll(); + QCOMPARE(model.matches(), 500); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths().size(), 500); + } + QTRY_VERIFY(model.searchIndex() != before); + QCOMPARE(model.matches(), 500); QCOMPARE(model.selectedCount(), 500); - QCOMPARE(model.selectedPaths().size(), 500); + } + + void searchSelectAllAfterAKeystrokeTakenBackTakesEveryMatch() + { + // A letter typed and taken back before its answer landed leaves the walk on the longer query: Select All asks it again, and takes all the count names. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + QThreadPool *pool = QThreadPool::globalInstance(); + QVERIFY(pool->waitForDone(5000)); + // Answered on its thread, and not landed: nothing here lets the events through. + model.setQuery("match1"); + QVERIFY(pool->waitForDone(5000)); + { + PoolHeld held; + model.setQuery("match"); + QCOMPARE(model.matches(), 500); + model.selectAll(); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths().size(), 500); + } + QTRY_VERIFY(!model.loading()); + QCOMPARE(model.selectedCount(), 500); + } + + void searchReadAgainKeepsWhatWasSelectedOnItsWay() + { + // Rows selected while a search read again is on its way: the one its new rows leave out stays selected while it still matches, and the one gone goes. + QTemporaryDir dir; + for (int i = 0; i < 500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + QThreadPool *pool = QThreadPool::globalInstance(); + QVERIFY(pool->waitForDone(5000)); + const QString kept = model.get(348).value("path").toString(), gone = model.get(349).value("path").toString(); + // Two better matches push both rows past the 350 shown, and one of the two is deleted. + QVERIFY(QDir(dir.path()).mkdir("sub")); + write(dir.filePath("match"), "x"); + write(dir.filePath("sub/match"), "x"); + QVERIFY(QFile::remove(gone)); + const auto before = model.searchIndex(); + model.reload(); + QVERIFY(pool->waitForDone(5000)); + { + PoolHeld held; + // The walk's end is taken in here with nothing selected, and the answer it asks for waits behind the held pool. + QCoreApplication::processEvents(); + QCOMPARE(model.searchIndex(), before); + model.selectRows({348, 349}); + QCOMPARE(model.selectedCount(), 2); + } + QTRY_VERIFY(model.searchIndex() != before); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + QCOMPARE(model.matches(), 501); + for (int row = 0; row < model.count(); ++row) + QVERIFY(model.get(row).value("path").toString() != kept); + QCOMPARE(model.selectedPaths(), QStringList({kept})); + QCOMPARE(model.selectedCount(), 1); + } + + void searchHidingHiddenFilesStartsOver() + { + // Hidden files shown or hidden end the search's walk: what it read answers nothing more, and the rows are the new walk's. + QTemporaryDir dir; + write(dir.filePath("match.txt"), "x"); + write(dir.filePath(".match hidden.txt"), "x"); + FolderModel model; + model.setShowHidden(true); + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 2); + const auto before = model.searchIndex(); + WalksHeld walks; + model.setShowHidden(false); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QVERIFY(model.searchIndex() != before); + QCOMPARE(model.matches(), -1); + QVERIFY(model.reading()); + walks.letGo(); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 1); + QCOMPARE(model.get(0).value("name").toString(), QString("match.txt")); + } + + void searchEndedStopsItsReadAgain() + { + // A search ended while its folders are read again stops that walk where it is. + QTemporaryDir dir; + for (const char *folder : {"a", "b", "c"}) { + QDir(dir.path()).mkdir(folder); + write(dir.filePath(QString("%1/match.txt").arg(folder)), "x"); + } + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 3); + WalksHeld walks; + model.reload(); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + model.setQuery(""); + walks.letGo(); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCOMPARE(walks.folders.load(), 1); + } + + void searchSaysHowManyMatchAndHowManyItShows() + { + // The number shown goes into the sentence before the folder's name, so a name that holds a placeholder is shown as it is. + QQmlEngine engine; + QQmlComponent said(&engine); + said.setData("import QtQml\nimport \"Format.js\" as Format\n" + "QtObject { readonly property string whole: Format.matches(12, \"%2 %1\", false, -1)\n" + " readonly property string cut: Format.matches(12, \"%2 %1\", false, 5)\n" + " readonly property string soFar: Format.matches(12, \"%2 %1\", true, -1) }", + QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); + std::unique_ptr text(said.create()); + QVERIFY2(text, qPrintable(said.errorString())); + QCOMPARE(text->property("whole").toString(), QString("12 matches in %2 %1 and below")); + QCOMPARE(text->property("cut").toString(), QString("12 matches in %2 %1 and below, the best 5 shown")); + QCOMPARE(text->property("soFar").toString(), QString("12 matches so far, still reading %2 %1 and below")); + } + + void searchSelectAllUnderAnotherSortTakesEveryMatch() + { + // Sorted by size, the rows are the best 1000; a selection of every match stays whole through the switch and back, and Select All there takes them all. + QTemporaryDir dir; + for (int i = 0; i < 1500; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), QByteArray(i % 7, 'x')); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectAll(); + QCOMPARE(model.selectedCount(), 1500); + model.setSortMode("size"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 1000); + QCOMPARE(model.selectedCount(), 1500); + QCOMPARE(model.selectedPaths().size(), 1500); + model.selectOnly(0); + model.selectAll(); + QCOMPARE(model.selectedCount(), 1500); + QCOMPARE(model.selectedPaths().size(), 1500); + model.setSortMode("name"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.selectedCount(), 1500); + } + + void searchSelectAllIsWholeWhenItSaysSo() + { + // Whoever asks for the selection as it changes gets all of it, best first, as it reads after. + QTemporaryDir dir; + for (int i = 0; i < 400; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + // Matches a class below, whose paths sort before the others'. + for (int i = 0; i < 150; ++i) + write(dir.filePath(QString("a match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QStringList seen; + connect(&model, &FolderModel::selectionChanged, &model, [&] { seen = model.selectedPaths(); }); + model.selectAll(); + QCOMPARE(seen.size(), 550); + QCOMPARE(seen, model.selectedPaths()); + } + + void searchClearedForgetsHowManyMatched() + { + // A search ended, by clearing it or going elsewhere, leaves no count behind. + QTemporaryDir dir; + write(dir.filePath("match.txt"), "x"); + QDir(dir.path()).mkdir("sub"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_COMPARE(model.matches(), 1); + model.setQuery(""); + QCOMPARE(model.matches(), -1); + model.setQuery("match"); + QTRY_COMPARE(model.matches(), 1); + model.setPath(dir.filePath("sub")); + QCOMPARE(model.matches(), -1); + } + + void searchShowsARowForAMatchGoneSinceTheWalk() + { + // A match deleted after the walk still gives its row, so the next window starts where the last one ended. + QTemporaryDir dir; + for (int i = 0; i < 400; ++i) + write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 350); + QSet shown; + for (int row = 0; row < model.count(); ++row) + shown.insert(model.get(row).value("path").toString()); + // The folder's watcher reads the search again after the deletions: held, so the walk that knew the files answers to the end. + WalksHeld walks; + for (int i = 0; i < 400; ++i) + if (const QString path = dir.filePath(QString("match%1.txt").arg(i)); !shown.contains(path)) + QVERIFY(QFile::remove(path)); + model.fetchMore({}); + QTRY_COMPARE(model.count(), 400); + for (int row = 350; row < 400; ++row) + shown.insert(model.get(row).value("path").toString()); + QCOMPARE(shown.size(), 400); } void searchReadAgainKeepsTheRowsItShowed() @@ -1670,18 +2077,7 @@ private slots: write(dir.filePath(QString("%1/match.txt").arg(folder)), "x"); } write(dir.filePath("match.txt"), "x"); - QSemaphore held, resumed; - SearchIndex::afterFolder = [&](qsizetype folders) { - if (folders == 2) { - held.release(); - resumed.acquire(); - } - }; - const auto restore = qScopeGuard([&] { - if (held.available() + resumed.available() == 0 && SearchIndex::afterFolder) - resumed.release(); - SearchIndex::afterFolder = {}; - }); + WalksHeld walks(2); FolderModel model; QSignalSpy loaded(&model, &FolderModel::loaded); model.setPath(dir.path()); @@ -1689,13 +2085,13 @@ private slots: loaded.clear(); model.setQuery("match"); QVERIFY(model.reading()); - QVERIFY(held.tryAcquire(1, 3000)); + QVERIFY(walks.reached.tryAcquire(1, 3000)); QVERIFY(loaded.wait(3000)); QVERIFY(model.reading()); QVERIFY(model.loading()); QCOMPARE(model.matches(), 2); QCOMPARE(model.count(), 2); - resumed.release(); + walks.letGo(); QTRY_VERIFY(!model.reading() && !model.loading()); QCOMPARE(model.matches(), 4); QCOMPARE(model.count(), 4); From bcffc86ab431ed078182212c34dba11935ad2bec Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 22:23:14 -0400 Subject: [PATCH 27/38] Read a search's folders again behind a later key Only the open folder is watched, and a later key was answered from the first read alone, so a file made below it since stayed missing until a refresh. Each later key is still answered at once from what was read, and the folders are read again behind that answer. --- src/foldermodel.cpp | 4 +++ tests/folio_tests.cpp | 67 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 02b07a15..4b3e0a88 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -305,6 +305,7 @@ void FolderModel::setSortMode(const QString &sort) { void FolderModel::setQuery(const QString &query) { if (query == m_query) return; + const bool searching = m_search && !query.isEmpty(); m_query = query; m_shownRows = 0; emit queryChanged(); @@ -318,6 +319,9 @@ void FolderModel::setQuery(const QString &query) { } } read(); + // A later key is answered from what was read, and the folders are read again behind the answer: a file made since is found. + if (searching) + reload(); } void FolderModel::setItems(const QVariantList &items) { diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 9a7ad30b..f774d008 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1392,10 +1392,9 @@ private slots: QCOMPARE(nearest.first().name, QString("beach.txt")); } - void searchReadsTheFoldersOnceWhileItsOpen() + void searchAnswersALaterKeyBeforeItsFoldersAreReadAgain() { - // The first letter reads the folders below; every letter after is - // answered from what was read. Ending the search lets all of it go. + // The first letter reads the folders below; every letter after is answered from what was read, and the folders are read again behind it. QTemporaryDir dir; QDir(dir.path()).mkpath("Photos/2024"); write(dir.filePath("Photos/2024/beach.jpg"), "x"); @@ -1407,19 +1406,44 @@ private slots: QVERIFY(loaded.wait(3000)); QVERIFY(!model.searchIndex()); model.setQuery("b"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); const std::weak_ptr held = model.searchIndex(); - for (const char *query : {"be", "bea"}) { - model.setQuery(query); + QCOMPARE(held.lock()->counts.walks, 1); + { + // The second read of the folders is held at its first: the key's answer has landed all the same, from the first read. + WalksHeld walks; + loaded.clear(); + model.setQuery("be"); + QVERIFY(loaded.wait(3000)); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QCOMPARE(model.count(), 2); + QVERIFY(model.searchIndex() == held.lock()); + QCOMPARE(walks.walks.load(), 1); + // Keys typed while that read is held are answered too, and start no read beside it: one more follows it. + for (const auto &[query, matches] : {std::pair{"bea", 2}, std::pair{"beaco", 1}}) { + loaded.clear(); + model.setQuery(query); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), matches); + } + QCOMPARE(walks.walks.load(), 1); + // Let go, the held read lands with the answer to the last key, and one read more follows for the two keys. + loaded.clear(); + walks.letGo(); QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 1); + QTRY_COMPARE(loaded.size(), 2); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCOMPARE(walks.walks.load(), 2); } - QCOMPARE(model.count(), 2); - QVERIFY(model.searchIndex() == held.lock()); - QCOMPARE(held.lock()->counts.walks, 1); - QVERIFY(held.lock()->bytes() > 0); + QTRY_VERIFY(held.expired()); + QCOMPARE(model.count(), 1); + QVERIFY(model.searchIndex()->bytes() > 0); + const std::weak_ptr second = model.searchIndex(); model.setQuery(""); QVERIFY(!model.searchIndex()); - QTRY_VERIFY(held.expired()); + QTRY_VERIFY(second.expired()); // The next search reads again, so it finds what's new; showing // hidden files reads again too. @@ -1433,6 +1457,27 @@ private slots: QCOMPARE(model.searchIndex()->counts.walks, 1); } + void searchFindsAtTheNextKeyAFileMadeSinceItsFoldersWereRead() + { + // Only the open folder is watched: a file made in a folder below it is found at the next key, with no refresh. + QTemporaryDir dir; + QDir(dir.path()).mkpath("src"); + write(dir.filePath("src/notes.txt"), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("b"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QVERIFY(model.searchIndex()); + QCOMPARE(model.count(), 0); + write(dir.filePath("src/budget.txt"), "x"); + model.setQuery("bu"); + QTRY_COMPARE(model.count(), 1); + QCOMPARE(model.get(0).value("name").toString(), QString("src/budget.txt")); + } + void searchListsTheBestMatchesFirst() { // The name itself first, however deep; then names that start with From fb97e065013b2fe39850dcabc2542df1789d21ab Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 02:22:48 -0400 Subject: [PATCH 28/38] Keep room for the query beside a search's status The status at the end of the search field says where it searches and how many match, at its full width, and the field's padding is that width. In a narrow pane it took the room the query is typed in. It is now cut short with an ellipsis before the query has less than 160 pixels. --- src/PathBar.qml | 13 +++++- tests/folio_tests.cpp | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 1e5da142..2a45c429 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -94,8 +94,12 @@ Rectangle { visible: bar.searching && !bar.editing font.pixelSize: 14 // Room at the end for where it searches; padding isn't mirrored. - leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 - rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 + leftPadding: LayoutMirroring.enabled ? scope.width + 24 : 12 + rightPadding: LayoutMirroring.enabled ? 12 : scope.width + 24 + // What the query keeps of a narrow field, some twenty letters: the status is cut short before it takes that. + readonly property int queryRoom: 160 + // The padding at the query's end of the field, and the status's margin and its gap from the query at the other. + readonly property int besideStatus: 12 + 24 // Where it searches and, once answered, how many match: unlike a placeholder it stays while typing. Text { id: scope @@ -108,6 +112,11 @@ Rectangle { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter + // What the field has left beside the query's room; in a field with room for both, the whole status as before. + width: Math.max(0, Math.min(implicitWidth, searchField.width - searchField.besideStatus - searchField.queryRoom)) + elide: Text.ElideRight + // With no room at all it isn't drawn: cut to nothing, it could still leave its ellipsis beside the query. + visible: width > 0 text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : backend.folder.query.length > 0 && backend.folder.matches >= 0 // Sorted another way than best first, the rows are the best of the matches, and it says so. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index f774d008..0a94874b 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1919,6 +1919,102 @@ private slots: QCOMPARE(text->property("soFar").toString(), QString("12 matches so far, still reading %2 %1 and below")); } + void searchStatusLeavesRoomForTheQuery() + { + // In a narrow pane the count and the folder's name would take the whole field, and the query typed there could not be seen. + QTest::failOnWarning(QRegularExpression("Binding loop|recursive rearrange")); + QTemporaryDir home; + Theme theme(home.filePath("theme")); + QQmlEngine engine; + QQmlComponent stubs(&engine); + stubs.setData("import QtQml\n" + "QtObject {\n" + " property string language: \"en\"\n" + " property string path: \"/a\"\n" + " property var crumbs: [{ name: \"A folder with quite a long name of its own\", path: \"/a\" }]\n" + " property QtObject folder: QtObject {\n" + " property string query: \"q\"; property int matches: 987; property int count: 987\n" + " property bool reading: true; property string sortMode: \"name\"\n" + " }\n" + "}\n", QUrl()); + std::unique_ptr stub(stubs.create()); + QVERIFY2(stub, qPrintable(stubs.errorString())); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("backend", stub.get()); + engine.rootContext()->setContextProperty("translator", stub.get()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 420; height: 80; visible: true\n" + " property bool mirrored: false\n" + " PathBar { id: bar; anchors.fill: parent; LayoutMirroring.enabled: mirrored; LayoutMirroring.childrenInherit: true }\n" + " // The search field's width and paddings, then its status: the width drawn, the width wanted, whether it is cut short, whether it is drawn.\n" + " function measured() {\n" + " let found = []\n" + " const walk = item => {\n" + " for (const child of item.children) {\n" + " if (child instanceof Text && child.text.includes('still reading') && item.visible) {\n" + " // Read once the row has laid its items out again, not on the way there.\n" + " item.parent.ensurePolished()\n" + " found = [item.width, item.leftPadding, item.rightPadding, child.width, child.implicitWidth, child.truncated, child.visible]\n" + " }\n" + " walk(child)\n" + " }\n" + " }\n" + " walk(bar)\n" + " return found\n" + " }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/PathBarTest.qml")); + std::unique_ptr window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + const auto measured = [&] { + QVariant numbers; + QMetaObject::invokeMethod(window.get(), "measured", Q_RETURN_ARG(QVariant, numbers)); + return numbers.toList(); + }; + // The room the query keeps, the field's padding at the query's end, and the status's margin and gap at the other. + const qreal room = 160, padding = 12, aroundStatus = 24; + // field, left, right, status, wanted, cut, drawn: the status wants more than the field has, and the query keeps its room. + QTRY_COMPARE(measured().size(), 7); + QTRY_VERIFY(measured().at(0).toReal() > room + padding + aroundStatus); + const QList narrow = measured(); + QVERIFY2(narrow.at(4).toReal() > narrow.at(0).toReal() - room - padding - aroundStatus, "The pane is not narrow enough to try the status."); + QCOMPARE(narrow.at(0).toReal() - narrow.at(1).toReal() - narrow.at(2).toReal(), room); + QCOMPARE(narrow.at(1).toReal(), padding); + QVERIFY(narrow.at(3).toReal() > 0); + QVERIFY(narrow.at(5).toBool() && narrow.at(6).toBool()); + + // Mirrored, the status is at the other end of the field and the query keeps the same room. + window->setProperty("mirrored", true); + QTRY_COMPARE(measured().at(2).toReal(), padding); + const QList mirrored = measured(); + QCOMPARE(mirrored.at(1).toReal(), mirrored.at(3).toReal() + aroundStatus); + QCOMPARE(mirrored.at(0).toReal() - mirrored.at(1).toReal() - mirrored.at(2).toReal(), room); + QCOMPARE(mirrored.at(0).toReal(), narrow.at(0).toReal()); + window->setProperty("mirrored", false); + QTRY_COMPARE(measured().at(1).toReal(), padding); + + // In a field with room for both, the status is whole and the field's padding the one it had. + window->setProperty("width", 2400); + QTRY_VERIFY(!measured().at(5).toBool()); + const QList wide = measured(); + QCOMPARE(wide.at(3).toReal(), wide.at(4).toReal()); + QCOMPARE(wide.at(2).toReal(), wide.at(4).toReal() + aroundStatus); + + // A shorter status changes the field's padding and never its width: the field takes what the row has left, whatever its status asks for. + QVERIFY(stub->setProperty("crumbs", QVariantList{QVariantMap{{"name", "A"}, {"path", "/a"}}})); + QTRY_VERIFY(measured().at(4).toReal() < wide.at(4).toReal()); + const QList shorter = measured(); + QCOMPARE(shorter.at(2).toReal(), shorter.at(4).toReal() + aroundStatus); + QCOMPARE(shorter.at(0).toReal(), wide.at(0).toReal()); + + // A field narrower than the query's room draws no status at all. + window->setProperty("width", 200); + QTRY_COMPARE(measured().at(3).toReal(), 0.0); + QVERIFY(!measured().at(6).toBool()); + } + void searchSelectAllUnderAnotherSortTakesEveryMatch() { // Sorted by size, the rows are the best 1000; a selection of every match stays whole through the switch and back, and Select All there takes them all. From ad36628f6f95cbbdda76c50511e884e8d423c90e Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 10:44:40 -0400 Subject: [PATCH 29/38] Find a path whose folded letters are longer A search along the path ruled a row out when the folded query was longer than the path as stored. Folding can lengthen a letter, so a path typed out exactly went missing. Only ASCII paths take that test. --- src/searchindex.cpp | 3 ++- tests/folio_tests.cpp | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 6f2005c7..c26f323d 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -540,7 +540,8 @@ qsizetype SearchIndex::answer(const QString &query) { if ((found == SearchMatcher::NoMatch || (found < SearchMatcher::Named + SearchMatcher::Span && found != SearchMatcher::Pending)) && r.parent != None) { const quint64 held = m_folders[r.parent].bits | m_names[r.name].bits; - if (needle.size() <= pathLength(row) && possible(first, held) && possible(folderBits, m_folders[r.parent].bits) + // A path's length rules it out only in ASCII: folding can make another letter longer than it is stored. + if ((held & SearchMatcher::NotAscii || needle.size() <= pathLength(row)) && possible(first, held) && possible(folderBits, m_folders[r.parent].bits) && possible(m_matcher.bits(), held)) { const qsizetype got = reach(r.parent); if (got >= m_matcher.folderPart() && possible(m_matcher.bitsFrom(got), m_names[r.name].bits) diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 0a94874b..cbfb2ce4 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -2170,6 +2170,39 @@ private slots: QCOMPARE(best("render"), QString("render_notes.txt")); } + void searchFindsAPathThatFoldsToALongerOne() + { + // U+023A is two bytes of UTF-8 and folds to U+2C65, which is three: the folded query is longer than the path as it is stored. + const QString letter = QChar(0x023A); + QTemporaryDir plain; + QVERIFY(QDir(plain.path()).mkpath("plain")); + write(plain.filePath("plain/x"), "x"); + const auto found = [](SearchIndex &index, const QString &query) { + QStringList paths; + const auto count = index.answer(query); + for (const quint32 row : index.rows(0, count)) + paths << QString::fromUtf8(index.path(row)); + return paths; + }; + // The letter is found as a folder's name and as a file's: either makes the path longer folded. + for (const QString &path : {letter + "/x", "x/" + letter}) { + QTemporaryDir odd; + QVERIFY(QDir(odd.path()).mkpath(path.section('/', 0, 0))); + write(odd.filePath(path), "x"); + SearchIndex index; + QVERIFY(index.walk(odd.path(), false)); + QCOMPARE(found(index, path), QStringList{path}); + } + + // A path in ASCII too short for a query whose letters it all holds is still ruled out by its length, no folder read. + SearchIndex ascii; + QVERIFY(ascii.walk(plain.path(), false)); + QCOMPARE(found(ascii, "plain/x"), QStringList{"plain/x"}); + const qsizetype before = ascii.counts.folders; + QCOMPARE(found(ascii, "plain/xx"), QStringList()); + QCOMPARE(ascii.counts.folders, before); + } + void searchForgivesATypoWhenNoNameSpellsTheQuery() { // No name spelling the query out, a name a letter off (a swapped From bdfcd55d22b6f4c854a24340fb37682d15bf8020 Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 21:19:15 -0400 Subject: [PATCH 30/38] Name the search index's numbers, a line a comment The bits of digits and marks, the first size of the lookup table and the classes of a pending score were bare numbers: they have names now, and a static_assert ties the classes to the tiers. Each comment is one line. Nothing a search does changes. --- src/searchindex.cpp | 67 ++++++++++++++++++--------------------------- src/searchindex.h | 62 ++++++++++++++--------------------------- 2 files changed, 47 insertions(+), 82 deletions(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index c26f323d..d50fc9ac 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -32,8 +32,7 @@ static bool isSeparator(char c) { return c == '/' || c == '-' || c == '_' || c == '.' || c == ' '; } -// A word starts after a separator, and at a capital after a small letter -// or a digit ("renderFrame", "2024Report"). +// A word starts after a separator, and at a capital after a small letter or a digit ("renderFrame", "2024Report"). static bool wordStart(QByteArrayView text, qsizetype at) { return at == 0 || isSeparator(text[at - 1]) || (lower(text[at]) != text[at] && lower(text[at - 1]) == text[at - 1]); } @@ -48,9 +47,9 @@ quint64 SearchMatcher::bitsOf(QByteArrayView text) { if (c >= 'a' && c <= 'z') bits |= 1ull << (c - 'a'); else if (c >= '0' && c <= '9') - bits |= 1ull << (26 + c - '0'); + bits |= 1ull << (DigitBit + c - '0'); else if (mark) - bits |= 1ull << (36 + (mark - marks)); + bits |= 1ull << (MarkBit + (mark - marks)); else bits |= Unknown; } @@ -69,8 +68,7 @@ SearchMatcher::SearchMatcher(const QString &query) m_typos = letters >= 2 * oneTypoFrom ? 2 : letters >= oneTypoFrom ? 1 : 0; } -// Optimal string alignment from each word start, so a swapped pair is one -// typo: only letters are added, dropped or changed, never digits or marks. +// Optimal string alignment from each word start, so a swapped pair is one typo: only letters are added, dropped or changed, never digits or marks. int SearchMatcher::typo(QByteArrayView hay) const { // Each typo costs this much of the class's score. constexpr int typoCost = 1000; @@ -181,8 +179,7 @@ void SearchMatcher::Alignment::reset(qsizetype length) { hit = length == 0 ? 0 : Dead; } -// best[k]: the best score with the query's first k letters placed so far; -// end[k]: the same with the k-th placed on the letter just read. +// best[k]: the best score with the query's first k letters placed so far; end[k]: the same with the k-th placed on the letter just read. void SearchMatcher::Alignment::advance(QByteArrayView needle, QByteArrayView text, bool own) { for (qsizetype i = 0; i < text.size(); ++i) { const char c = lower(text[i]); @@ -220,9 +217,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function counts = spent; return false; }; - // A folder's names are read first and added all at once, so an answer - // given meanwhile sees whole folders. While an answer holds the lock, - // the folders already found are read on and wait here, in order. + // A folder's names go in all at once, so an answer sees whole folders; while an answer holds the lock, the folders found are read on and wait here, in order. struct Read { quint32 parent; QByteArray names; @@ -252,8 +247,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function } waiting.clear(); }; - // The folders found are the queue: each is read in the order it was - // found, which is breadth first. -1 is the root. + // The folders found are the queue: each is read in the order it was found, which is breadth first. -1 is the root. for (qsizetype folder = -1;; ++folder) { // Out of folders to read, those waiting go in, and perhaps bring more. if (folder >= m_folders.size() && !waiting.empty()) { @@ -271,8 +265,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (fd < 0) continue; struct stat st; - // Other filesystems (a mounted drive, another machine, /proc from - // /) are listed but not read. + // Other filesystems (a mounted drive, another machine, /proc from /) are listed but not read. const bool same = ::fstat(fd, &st) == 0 && (folder < 0 || st.st_dev == drive); if (folder < 0) drive = st.st_dev; @@ -288,8 +281,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function const char *text = entry->d_name; if (text[0] == '.' && (!showHidden || text[1] == 0 || (text[1] == '.' && text[2] == 0))) continue; - // The type comes with the name; only a filesystem that doesn't - // say needs asking. Symlinked folders aren't walked into. + // The type comes with the name; only a filesystem that doesn't say needs asking. Symlinked folders aren't walked into. bool isDir = entry->d_type == DT_DIR; if (entry->d_type == DT_UNKNOWN) { ++read.asked; @@ -312,8 +304,7 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (afterFolder) afterFolder(folder + 2); } - // Names are only looked up while folders are read, and what's left - // over from growing goes back. + // Names are only looked up while folders are read, and what's left over from growing goes back. QMutexLocker hold(lock); m_lookup = {}; m_names.squeeze(); @@ -336,7 +327,9 @@ qsizetype SearchIndex::bytes() const { quint32 SearchIndex::intern(QByteArrayView text) { // Kept under three quarters full, so a probe ends soon. if ((m_names.size() + 1) * 4 > m_lookup.size() * 3) { - QVector bigger(qMax(1024, m_lookup.size() * 2), None); + // A power of two, as every size after it, so a hash is cut to the table with a mask. + constexpr qsizetype firstLookup = 1024; + QVector bigger(qMax(firstLookup, m_lookup.size() * 2), None); for (quint32 id = 0; id < quint32(m_names.size()); ++id) { size_t at = qHash(name(id)) & (bigger.size() - 1); while (bigger[at] != None) @@ -431,15 +424,13 @@ qsizetype SearchIndex::pathLength(quint32 row) const { return (r.parent == None ? 0 : m_folders[r.parent].length + 1) + m_names[r.name].length; } -// Whether a name, or a path, with `held` can hold every letter in `wanted`, -// `edits` of them aside: one more it lacks rules it out unread. Digits and -// marks are never edited. +// Whether `held` can hold every letter in `wanted`, `edits` of them aside: one more it lacks rules it out unread. Digits and marks are never edited. static bool possible(quint64 wanted, quint64 held, int edits = 0) { - constexpr quint64 digitsAndMarks = ((1ull << 16) - 1) << 26; + constexpr quint64 letters = (1ull << SearchMatcher::DigitBit) - 1; if ((wanted | held) & SearchMatcher::Unknown) return true; const quint64 missing = wanted & ~held; - return !(missing & digitsAndMarks) && int(qPopulationCount(missing)) <= edits; + return !(missing & ~letters) && int(qPopulationCount(missing)) <= edits; } int SearchIndex::score(quint32 id, QByteArray &folded) const { @@ -448,8 +439,7 @@ int SearchIndex::score(quint32 id, QByteArray &folded) const { return m_matcher.name(text(id, folded)); } -// How far into the query the folders down to `folder` take it: worked out -// once a folder a query, so a row costs only its own name. +// How far into the query the folders down to `folder` take it: worked out once a folder a query, so a row costs only its own name. qsizetype SearchIndex::reach(quint32 folder) { QVarLengthArray unknown; for (quint32 at = folder; at != None && m_folderStamps[at] != m_stamp; at = m_folders[at].parent) @@ -470,8 +460,7 @@ qsizetype SearchIndex::reach(quint32 folder) { return m_reaches[folder]; } -// A match along the path's score: the folders' part worked out once a -// folder, then the row's own name. +// A match along the path's score: the folders' part worked out once a folder, then the row's own name. int SearchIndex::alongPath(quint32 row, int tier, QHash &above) { const QByteArray &needle = m_matcher.needle(); QVarLengthArray unknown; @@ -497,8 +486,7 @@ int SearchIndex::alongPath(quint32 row, int tier, QHash within; const bool narrow = m_answered == m_rows.size() && SearchMatcher::canNarrow(m_query, query); if (narrow) { @@ -521,8 +509,7 @@ qsizetype SearchIndex::answer(const QString &query) { const quint64 first = needle.isEmpty() ? 0 : SearchMatcher::bitsOf(needle.first(1)); const quint64 folderBits = SearchMatcher::bitsOf(needle.first(m_matcher.folderPart())); QByteArray folded; - // Whether a name spells the query out, or its folders were named with a - // "/": anything else found is its letters scattered about. + // Whether a name spells the query out, or its folders were named with a "/": anything else found is its letters scattered about. bool whole = false; const qsizetype count = narrow ? within.size() : m_rows.size(); for (qsizetype i = 0; i < count; ++i) { @@ -534,9 +521,7 @@ qsizetype SearchIndex::answer(const QString &query) { ++counts.names; } int found = m_scores[r.name]; - // Not in the name as typed: then perhaps along the path, the - // folders taking the query as far as they can and the name the - // rest. The bits rule most paths out before any folder is read. + // Not in the name as typed: then perhaps along the path, the folders taking the query as far as they can and the name the rest; the bits rule most paths out unread. if ((found == SearchMatcher::NoMatch || (found < SearchMatcher::Named + SearchMatcher::Span && found != SearchMatcher::Pending)) && r.parent != None) { const quint64 held = m_folders[r.parent].bits | m_names[r.name].bits; @@ -555,8 +540,7 @@ qsizetype SearchIndex::answer(const QString &query) { whole = whole || found >= SearchMatcher::Word || found == SearchMatcher::PendingAligned || (found == SearchMatcher::Pending && m_matcher.spells(text(r.name, folded))); } - // No name spells it out: then names a typo or two off, ahead of the - // scattered ones. The slowest way to match, so it's only tried now. + // No name spells it out: then names a typo or two off, ahead of the scattered ones. The slowest way to match, so it's only tried now. if (!whole && m_matcher.typos() > 0) { ++counts.typos; restamp(); @@ -595,10 +579,13 @@ void SearchIndex::restamp() { static int classOf(int score) { static constexpr int tiers[] = {SearchMatcher::Exact, SearchMatcher::Prefix, SearchMatcher::Word, SearchMatcher::Aligned, SearchMatcher::Named}; + // Where a pending score's class is among them. + constexpr int aligned = 3, named = 4; + static_assert(tiers[aligned] == SearchMatcher::Aligned && tiers[named] == SearchMatcher::Named); if (score == SearchMatcher::PendingAligned) - return 3; + return aligned; if (score == SearchMatcher::Pending || score == SearchMatcher::PendingPath) - return 4; + return named; for (int i = 0; i < int(std::size(tiers)); ++i) if (score >= tiers[i] - SearchMatcher::Span) return i; diff --git a/src/searchindex.h b/src/searchindex.h index 475f6117..d502917c 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -13,12 +13,10 @@ class QMimeDatabase; class QMutex; -// One query, matched against names a byte at a time: ASCII letters of either -// case alike, other text compared once Qt has folded its case. +// One query, matched against names a byte at a time: ASCII letters of either case alike, other text compared once Qt has folded its case. class SearchMatcher { public: - // The classes of a match, best first, far enough apart that the score - // inside a class never reaches the next one. + // The classes of a match, best first, far enough apart that the score inside a class never reaches the next one. enum Tier : int { Exact = 5000000, // the name is the query Prefix = 4000000, // the name starts with it @@ -28,8 +26,7 @@ class SearchMatcher { Named = 2000000, // its letters are in the name, or along the path, in order }; static constexpr int Span = 100000; - // A match whose score inside its class is only worked out if it's - // shown: Aligned, Named in the name, or Named along the path. + // A match whose score inside its class is only worked out if it's shown: Aligned, Named in the name, or Named along the path. static constexpr int PendingAligned = -3, Pending = -2, PendingPath = -1; static constexpr int NoMatch = INT_MIN; @@ -38,38 +35,30 @@ class SearchMatcher { quint64 bits() const { return m_bits; } // The class `text` matches in, Pending when that's Named, or NoMatch. int name(QByteArrayView text) const; - // A Named match's score: runs, word starts and letters close together - // earn more. + // A Named match's score: runs, word starts and letters close together earn more. int align(QByteArrayView text) const; - // How many letters a name may be off from the query: none when the - // query doesn't start with a letter or a digit. + // How many letters a name may be off from the query: none when the query doesn't start with a letter or a digit. int typos() const { return m_typos; } - // The Typo score of a name that far off at most, from the start of a - // word, or NoMatch. + // The Typo score of a name that far off at most, from the start of a word, or NoMatch. int typo(QByteArrayView text) const; - // Whether `text` spells the query out: whole anywhere, or in runs that - // each start a word, as framen does in frame_new. + // Whether `text` spells the query out: whole anywhere, or in runs that each start a word, as framen does in frame_new. bool spells(QByteArrayView text) const; - // How far into the query `text` takes it, from `at`, its letters - // taken in order. + // How far into the query `text` takes it, from `at`, its letters taken in order. qsizetype reach(QByteArrayView text, qsizetype at) const; - // The query's length up to and with its last "/", which the folders - // have to take it past; 0 without one. + // The query's length up to and with its last "/", which the folders have to take it past; 0 without one. qsizetype folderPart() const { return m_folderPart; } // The bits of the query from `at` on. quint64 bitsFrom(qsizetype at) const { return m_bitsFrom[at]; } // Whether every match of `now` is among the matches of `before`. static bool canNarrow(const QString &before, const QString &now); - // One bit for each letter, digit and common mark in `text`: a name that - // lacks one of the query's can't match it. + // One bit for each letter, digit and common mark in `text`: a name that lacks one of the query's can't match it. static quint64 bitsOf(QByteArrayView text); + // Where the digits' bits start, after the letters', and the marks' after those. + static constexpr int DigitBit = 26, MarkBit = DigitBit + 10; static constexpr quint64 Unknown = 1ull << 63; static constexpr quint64 NotAscii = 1ull << 62; - // The score inside a class, read a piece of the path at a time: each of - // the query's letters placed, in order, earns more next to the last one, - // at the start of a word and in the row's own name, and a letter - // skipped costs one. + // The score inside a class, read a piece of the path at a time: a letter placed earns more next to the last, at a word's start and in the row's own name; one skipped costs one. struct Alignment { static constexpr int Dead = INT_MIN / 4; static constexpr int RunBonus = 8, WordBonus = 6, OwnNameBonus = 4; @@ -92,9 +81,7 @@ class SearchMatcher { mutable std::vector m_older, m_previous, m_current; }; -// Everything below a folder by name, read once for a search. Names stay the -// bytes the folder gave, each kept once however many folders hold it, and a -// row's path is put together from its folders only when it's shown. +// Everything below a folder by name, read once for a search: names stay the folder's bytes, each kept once, and a row's path is put together only when it's shown. class SearchIndex { public: // What reading and answering cost, for the tests. @@ -108,13 +95,9 @@ class SearchIndex { qsizetype answers = 0; }; - // Reads `root` and every folder below it, breadth first, staying on its - // drive and out of symlinked folders. False, and empty, once `stop` - // returns true. Holding `lock` while it adds each folder, it can be - // answered from as it reads. + // Reads `root` and the folders below, breadth first, on its drive and out of symlinked folders. False and empty once `stop` says; adding under `lock`, it's answered from as it reads. bool walk(const QString &root, bool showHidden, const std::function &stop = {}, QMutex *lock = nullptr); - // Called with how many folders have been read, after each, so a test - // can hold a walk partway. + // Called with how many folders have been read, after each, so a test can hold a walk partway. inline static std::function afterFolder; qsizetype size() const { return m_rows.size(); } // The memory it holds. @@ -130,14 +113,11 @@ class SearchIndex { static QVector entriesAt(const QString &root, const QStringList &names, const std::function &stop = {}); // The rows as entries; empty once `stop` returns true. QVector entries(const QVector &rows, const std::function &stop = {}); - // Matches `query`, best first, and gives how many rows match. The rows - // themselves come from rows(), ordered only as far as they're asked for. + // Matches `query`, best first, and gives how many rows match. The rows themselves come from rows(), ordered only as far as they're asked for. qsizetype answer(const QString &query); // The query the last answer was for. const QString &query() const { return m_query; } - // The answer's rows from `from`, at most `count` of them, best first: - // a better class first, then a better score, a folder before a file, - // the shorter path, and the paths in natural order. + // The answer's rows from `from`, at most `count`, best first: by class, by score, a folder before a file, the shorter path, then the paths in natural order. QVector rows(qsizetype from, qsizetype count); // The best `limit` rows that match `query`, sorted by `sort` in `locale`'s alphabet. Empty once `stop` returns true. QVector find(const QString &query, const QString &sort, int limit, @@ -150,8 +130,7 @@ class SearchIndex { // Names are kept in slabs this big, which go back to the system with the search. static constexpr qsizetype SlabBytes = 1 << 20; struct Name { quint32 offset, length; quint64 bits; }; - // A folder is its name, the folder holding it, the length of its path - // and the bits of every name along it; a row is its name and its folder. + // A folder is its name, the folder holding it, the length of its path and the bits of every name along it; a row is its name and its folder. struct Folder { quint32 name, parent, length; quint64 bits; }; struct Row { quint32 name, parent; }; struct Hit { int score; quint32 row; }; @@ -179,8 +158,7 @@ class SearchIndex { // A bit for each row, set for a folder. QByteArray m_kinds; - // The last answer: its query and matcher, its matches, how many of them - // are in their final order, and how many rows there were to match. + // The last answer: its query and matcher, its matches, how many of them are in their final order, and how many rows there were to match. QString m_query; SearchMatcher m_matcher{QString()}; QVector m_hits; From f7310fad19760de4022c9f8a2100020919d2b7dc Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 21:19:15 -0400 Subject: [PATCH 31/38] Follow no link on the way to a folder searched A folder swapped for a link after its own folders were listed was followed when they were opened: O_NOFOLLOW guards the last name only. Every folder is opened from the root's descriptor now, with RESOLVE_NO_SYMLINKS, and the root is set under the walk's lock. --- src/searchindex.cpp | 27 ++++++++++++++++++------ src/searchindex.h | 2 +- tests/folio_tests.cpp | 49 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index d50fc9ac..8df3a954 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -7,12 +7,15 @@ #include #include #include +#include #include #include #include #include #include +#include #include +#include #include static char lower(char c) { @@ -205,9 +208,18 @@ int SearchMatcher::align(QByteArrayView text) const { } bool SearchIndex::walk(const QString &root, bool showHidden, const std::function &stop, QMutex *lock) { - ++counts.walks; - m_root = root; - const QByteArray top = QFile::encodeName(root); + { + // An answer on another thread reads the root under the lock. + QMutexLocker hold(lock); + ++counts.walks; + m_root = root; + } + // Every folder is opened from the root's own descriptor: the tree read stays one tree, whatever its path leads to later. + const int top = ::open(QFile::encodeName(root).constData(), O_PATH | O_DIRECTORY | O_CLOEXEC); + const auto closed = qScopeGuard([top] { + if (top >= 0) + ::close(top); + }); dev_t drive = 0; // Gone partway: nothing is kept. const auto stopped = [&] { @@ -259,9 +271,12 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (stop && stop()) return stopped(); const quint32 parent = folder < 0 ? None : quint32(folder); - const QByteArray at = folder < 0 ? top : top + '/' + joined(m_folders[folder].parent, name(m_folders[folder].name)); - // Below the root, a folder that's become a link since it was listed isn't followed. - const int fd = ::open(at.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | (folder < 0 ? 0 : O_NOFOLLOW)); + const QByteArray at = folder < 0 ? QByteArray(".") : joined(m_folders[folder].parent, name(m_folders[folder].name)); + // No link is followed below the root: not a folder that's become one since it was listed, nor one on the way to it. + open_how how = {}; + how.flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; + how.resolve = RESOLVE_NO_SYMLINKS; + const int fd = int(::syscall(SYS_openat2, top, at.constData(), &how, sizeof how)); if (fd < 0) continue; struct stat st; diff --git a/src/searchindex.h b/src/searchindex.h index d502917c..41cac683 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -95,7 +95,7 @@ class SearchIndex { qsizetype answers = 0; }; - // Reads `root` and the folders below, breadth first, on its drive and out of symlinked folders. False and empty once `stop` says; adding under `lock`, it's answered from as it reads. + // Reads `root` and the folders below, breadth first, on its drive, past no link. False and empty once `stop` says; adding under `lock`, it's answered from as it reads. bool walk(const QString &root, bool showHidden, const std::function &stop = {}, QMutex *lock = nullptr); // Called with how many folders have been read, after each, so a test can hold a walk partway. inline static std::function afterFolder; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index cbfb2ce4..35f6e084 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -1392,6 +1393,54 @@ private slots: QCOMPARE(nearest.first().name, QString("beach.txt")); } + void searchFollowsNoLinkThatTookAFoldersPlaceOnTheWay() + { + // A folder is listed, then swapped for a link while its own folders wait to be read: what the link leads to is not read under its name. + QTemporaryDir dir, elsewhere; + QDir(dir.path()).mkpath("album/inner"); + write(dir.filePath("album/inner/photo.txt"), "x"); + QDir(elsewhere.path()).mkpath("inner"); + write(elsewhere.filePath("inner/secret.txt"), "x"); + const auto restore = qScopeGuard([] { SearchIndex::afterFolder = {}; }); + bool swapped = false; + // Two folders read are the root and album: album/inner is next. + SearchIndex::afterFolder = [&](qsizetype read) { + if (read == 2) + swapped = QDir().rename(dir.filePath("album"), dir.filePath("album.was")) && QFile::link(elsewhere.path(), dir.filePath("album")); + }; + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QVERIFY(swapped); + QCOMPARE(index.size(), 2); + QCOMPARE(index.answer("secret"), 0); + QCOMPARE(index.answer("photo"), 0); + } + + void searchReadsTheFolderItsPathLedToAtFirst() + { + // The folder searched is reached through a link, turned to another folder once its own names are read: the tree read stays the first one. + QTemporaryDir dir, elsewhere, links; + QDir(dir.path()).mkdir("album"); + QDir(elsewhere.path()).mkdir("album"); + write(dir.filePath("album/photo.txt"), "x"); + write(elsewhere.filePath("album/secret.txt"), "x"); + const QString shown = links.filePath("shown"); + QVERIFY(QFile::link(dir.path(), shown)); + const auto restore = qScopeGuard([] { SearchIndex::afterFolder = {}; }); + bool turned = false; + // One folder read is the root: album is next. + SearchIndex::afterFolder = [&](qsizetype read) { + if (read == 1) + turned = QFile::remove(shown) && QFile::link(elsewhere.path(), shown); + }; + SearchIndex index; + QVERIFY(index.walk(shown, false)); + QVERIFY(turned); + QCOMPARE(index.size(), 2); + QCOMPARE(index.answer("photo"), 1); + QCOMPARE(index.answer("secret"), 0); + } + void searchAnswersALaterKeyBeforeItsFoldersAreReadAgain() { // The first letter reads the folders below; every letter after is answered from what was read, and the folders are read again behind it. From 1f9db2f9ff0e8c969d6be4d2d75e168d713e4f1b Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 21:19:15 -0400 Subject: [PATCH 32/38] Start a word at a capital beside letters past ASCII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name with one letter past ASCII was folded whole, so MeinBüro lost the capital that starts its second word and ranked with names that only hold the letters. Only what is past ASCII is folded now. --- src/searchindex.cpp | 20 ++++++++++++++++++-- src/searchindex.h | 2 +- tests/folio_tests.cpp | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 8df3a954..7c7acc96 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -40,6 +40,22 @@ static bool wordStart(QByteArrayView text, qsizetype at) { return at == 0 || isSeparator(text[at - 1]) || (lower(text[at]) != text[at] && lower(text[at - 1]) == text[at - 1]); } +// A name that isn't ASCII, as the matcher reads it: what's past ASCII folded by Qt, ASCII left in its case, so a capital still starts a word. +static QByteArray foldedPastAscii(QByteArrayView name) { + const QString text = QString::fromUtf8(name); + QString out; + qsizetype from = 0; + for (qsizetype at = 0; at <= text.size(); ++at) { + if (at < text.size() && text[at].unicode() >= 0x80) + continue; + out += text.sliced(from, at - from).toCaseFolded(); + if (at < text.size()) + out += text[at]; + from = at + 1; + } + return out.toUtf8(); +} + quint64 SearchMatcher::bitsOf(QByteArrayView text) { // a to z, then 0 to 9, then / - _ . space +; anything else is Unknown. static const char marks[] = "/-_. +"; @@ -367,7 +383,7 @@ quint32 SearchIndex::intern(QByteArrayView text) { // Folded first when it isn't ASCII, so the letters are the ones it's matched by. const bool ascii = std::all_of(text.begin(), text.end(), [](char c) { return uchar(c) < 0x80; }); const quint64 bits = ascii ? SearchMatcher::bitsOf(text) - : SearchMatcher::bitsOf(QString::fromUtf8(text).toCaseFolded().toUtf8()) | SearchMatcher::NotAscii; + : SearchMatcher::bitsOf(foldedPastAscii(text)) | SearchMatcher::NotAscii; m_names.push_back({quint32((m_slabs.size() - 1) * SlabBytes + slab.size()), quint32(text.size()), bits}); slab.append(text); m_lookup[at] = id; @@ -430,7 +446,7 @@ QVector SearchIndex::entries(const QVector &rows, c QByteArrayView SearchIndex::text(quint32 id, QByteArray &folded) const { if (!(m_names[id].bits & SearchMatcher::NotAscii)) return name(id); - folded = QString::fromUtf8(name(id)).toCaseFolded().toUtf8(); + folded = foldedPastAscii(name(id)); return folded; } diff --git a/src/searchindex.h b/src/searchindex.h index 41cac683..1fc2c343 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -137,7 +137,7 @@ class SearchIndex { quint32 intern(QByteArrayView text); QByteArrayView name(quint32 id) const; - // The name as the matcher reads it: folded first when it isn't ASCII. + // The name as the matcher reads it: what's past ASCII folded first. QByteArrayView text(quint32 id, QByteArray &folded) const; QByteArray joined(quint32 folder, QByteArrayView last) const; bool isDir(quint32 row) const { return m_kinds[row / 8] & (1 << (row % 8)); } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 35f6e084..291c55c2 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1393,6 +1393,25 @@ private slots: QCOMPARE(nearest.first().name, QString("beach.txt")); } + void searchStartsAWordAtACapitalInANameThatIsNotAscii() + { + // "büro" starts a word of MeinBüro, so it comes before a shorter name that only holds the letters. + QTemporaryDir dir; + write(dir.filePath("MeinBüro.txt"), "x"); + write(dir.filePath("xbüro.txt"), "x"); + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QCOMPARE(index.answer("büro"), 2); + QCOMPARE(index.names(index.rows(0, 2)), QStringList({"MeinBüro.txt", "xbüro.txt"})); + // In ASCII alone it was so already. + write(dir.filePath("MeinBuero.txt"), "x"); + write(dir.filePath("xbuero.txt"), "x"); + SearchIndex plain; + QVERIFY(plain.walk(dir.path(), false)); + QCOMPARE(plain.answer("buero"), 2); + QCOMPARE(plain.names(plain.rows(0, 2)), QStringList({"MeinBuero.txt", "xbuero.txt"})); + } + void searchFollowsNoLinkThatTookAFoldersPlaceOnTheWay() { // A folder is listed, then swapped for a link while its own folders wait to be read: what the link leads to is not read under its name. From f7f7b229e5d3fa569971b02c9228a64599fe8a62 Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 21:19:15 -0400 Subject: [PATCH 33/38] Read at most two million names for a search A walk kept every name below the folder, with no limit on the sum. It ends at 2,000,000 names now, the nearest first, and the search field says that only those were read. --- src/Format.js | 10 +++- src/PathBar.qml | 3 +- src/foldermodel.cpp | 12 ++-- src/foldermodel.h | 4 ++ src/i18n/en.json | 8 +++ src/searchindex.cpp | 15 ++++- src/searchindex.h | 9 ++- tests/folio_tests.cpp | 128 +++++++++++++++++++++++++++++++++++++++++- 8 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/Format.js b/src/Format.js index f8f0a589..f572cbd5 100644 --- a/src/Format.js +++ b/src/Format.js @@ -40,11 +40,15 @@ function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" } -// How many match a search of `here` and below; `best` is how many of them are rows when another order cut the list, or -1. -function matches(n, here, reading, best) { +// How many match a search of `here` and below; `best` is how many of them are rows when another order cut the list, or -1; `most` is how many names were read of a tree that holds more, or -1. +function matches(n, here, reading, best, most) { if (reading) return qsTr("%Ln matches so far, still reading %1 and below", "", n).arg(here) - // The count goes in first: a folder's name holding "%2" is then never filled in again. + // The counts go in first: a folder's name holding a marker is then never filled in again. + if (most >= 0 && best >= 0) + return qsTr("%Ln matches in %3 and below, the best %1 shown, only the nearest %2 items read", "", n).arg(best.toLocaleString(Qt.locale(), "f", 0)).arg(most.toLocaleString(Qt.locale(), "f", 0)).arg(here) + if (most >= 0) + return qsTr("%Ln matches in %2 and below, only the nearest %1 items read", "", n).arg(most.toLocaleString(Qt.locale(), "f", 0)).arg(here) if (best >= 0) return qsTr("%Ln matches in %2 and below, the best %1 shown", "", n).arg(best.toLocaleString(Qt.locale(), "f", 0)).arg(here) return qsTr("%Ln matches in %1 and below", "", n).arg(here) diff --git a/src/PathBar.qml b/src/PathBar.qml index 2a45c429..3c7c5298 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -121,7 +121,8 @@ Rectangle { : backend.folder.query.length > 0 && backend.folder.matches >= 0 // Sorted another way than best first, the rows are the best of the matches, and it says so. ? Format.matches(backend.folder.matches, here, backend.folder.reading, - backend.folder.sortMode !== "name" && backend.folder.matches > backend.folder.count ? backend.folder.count : -1) + backend.folder.sortMode !== "name" && backend.folder.matches > backend.folder.count ? backend.folder.count : -1, + backend.folder.mostRead) : qsTr("Search %1 and below").arg(here) color: theme.secondaryForeground font.pixelSize: 12 diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 4b3e0a88..5244c092 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -714,6 +714,8 @@ struct Listing { bool partial = false; // The matches past the rows, when a selection could hold some of them. std::optional unshown; + // How many names a search read of a tree that holds more, or -1. + int mostRead = -1; }; // The open search: its index, read once in the background, and the lock its answers take turns at. @@ -782,8 +784,8 @@ void FolderModel::closeSearch() { m_hold.stop(); m_refresh.stop(); setReading(false); - if (m_matches != -1) { - m_matches = -1; + if (m_matches != -1 || m_mostRead != -1) { + m_matches = m_mostRead = -1; emit matchesChanged(); } } @@ -860,7 +862,7 @@ void FolderModel::read() { openSearch(); } m_rowsQuery = answers; - auto [entries, error, matches, partial, unshown] = watcher->future().takeResult(); + auto [entries, error, matches, partial, unshown, mostRead] = watcher->future().takeResult(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); @@ -895,8 +897,9 @@ void FolderModel::read() { } } setError(error); - if (matches != m_matches) { + if (matches != m_matches || mostRead != m_mostRead) { m_matches = matches; + m_mostRead = mostRead; emit matchesChanged(); } setLoading(partial); @@ -933,6 +936,7 @@ void FolderModel::read() { if (overtaken()) return listing; listing.matches = int(search->index.answer(query)); + listing.mostRead = search->index.cut() ? int(search->index.size()) : -1; root = search->index.root(); // Best first, a window at a time; another order puts the best matches in it instead. const qsizetype shown = sort == "name" ? window : SortedMatches; diff --git a/src/foldermodel.h b/src/foldermodel.h index 0359e21a..67dc3358 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -36,6 +36,8 @@ class FolderModel : public QAbstractListModel { Q_PROPERTY(QVariantList items READ items WRITE setItems NOTIFY itemsChanged) // How many match the query, shown or not; -1 until a search has answered. Q_PROPERTY(int matches READ matches NOTIFY matchesChanged) + // How many names a search read, the nearest, of a tree that holds more; -1 when it read them all. + Q_PROPERTY(int mostRead READ mostRead NOTIFY matchesChanged) // While a search is still reading the folders below. Q_PROPERTY(bool reading READ reading NOTIFY readingChanged) @@ -97,6 +99,7 @@ class FolderModel : public QAbstractListModel { QVariantList items() const { return m_items; } void setItems(const QVariantList &items); int matches() const { return m_matches; } + int mostRead() const { return m_mostRead; } bool reading() const { return m_stillReading; } void setPath(const QString &path); @@ -216,6 +219,7 @@ class FolderModel : public QAbstractListModel { QString m_query; QVariantList m_items; int m_matches = -1; + int m_mostRead = -1; bool m_fetching = false; // The rows a search showed with its whole answer: read again, it shows as many, so a selection past the first window stays. qsizetype m_shownRows = 0; diff --git a/src/i18n/en.json b/src/i18n/en.json index ef6c3819..8c32fec5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -44,10 +44,18 @@ "one": "%Ln match in %1 and below", "other": "%Ln matches in %1 and below" }, + "%Ln matches in %2 and below, only the nearest %1 items read": { + "one": "%Ln match in %2 and below, only the nearest %1 items read", + "other": "%Ln matches in %2 and below, only the nearest %1 items read" + }, "%Ln matches in %2 and below, the best %1 shown": { "one": "%Ln match in %2 and below, the best %1 shown", "other": "%Ln matches in %2 and below, the best %1 shown" }, + "%Ln matches in %3 and below, the best %1 shown, only the nearest %2 items read": { + "one": "%Ln match in %3 and below, the best %1 shown, only the nearest %2 items read", + "other": "%Ln matches in %3 and below, the best %1 shown, only the nearest %2 items read" + }, "%Ln matches so far, still reading %1 and below": { "one": "%Ln match so far, still reading %1 and below", "other": "%Ln matches so far, still reading %1 and below" diff --git a/src/searchindex.cpp b/src/searchindex.cpp index 7c7acc96..0d9a8e89 100644 --- a/src/searchindex.cpp +++ b/src/searchindex.cpp @@ -237,6 +237,9 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function ::close(top); }); dev_t drive = 0; + // How many names are taken, and whether more were left at mostRows. + qsizetype taken = 0; + bool full = false; // Gone partway: nothing is kept. const auto stopped = [&] { QMutexLocker hold(lock); @@ -312,6 +315,12 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function const char *text = entry->d_name; if (text[0] == '.' && (!showHidden || text[1] == 0 || (text[1] == '.' && text[2] == 0))) continue; + // At the most it keeps, the walk ends with the nearest names. + if (taken == mostRows) { + full = true; + break; + } + ++taken; // The type comes with the name; only a filesystem that doesn't say needs asking. Symlinked folders aren't walked into. bool isDir = entry->d_type == DT_DIR; if (entry->d_type == DT_UNKNOWN) { @@ -326,6 +335,8 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (dir) ::closedir(dir); waiting.push_back(std::move(read)); + if (full) + break; if (!lock) { add(); } else if (lock->tryLock()) { @@ -335,8 +346,10 @@ bool SearchIndex::walk(const QString &root, bool showHidden, const std::function if (afterFolder) afterFolder(folder + 2); } - // Names are only looked up while folders are read, and what's left over from growing goes back. QMutexLocker hold(lock); + add(); + m_cut = full; + // Names are only looked up while folders are read, and what's left over from growing goes back. m_lookup = {}; m_names.squeeze(); m_folders.squeeze(); diff --git a/src/searchindex.h b/src/searchindex.h index 1fc2c343..0be95e72 100644 --- a/src/searchindex.h +++ b/src/searchindex.h @@ -95,10 +95,16 @@ class SearchIndex { qsizetype answers = 0; }; - // Reads `root` and the folders below, breadth first, on its drive, past no link. False and empty once `stop` says; adding under `lock`, it's answered from as it reads. + // Reads `root` and the folders below, breadth first, on its drive, past no link, up to mostRows names. False and empty once `stop` says; adding under `lock`, it's answered from as it reads. bool walk(const QString &root, bool showHidden, const std::function &stop = {}, QMutex *lock = nullptr); // Called with how many folders have been read, after each, so a test can hold a walk partway. inline static std::function afterFolder; + // The most names a walk keeps: with more below, it ends with the nearest and cut() says so. + static constexpr qsizetype MostRows = 2'000'000; + // MostRows, until a test lowers it. + inline static qsizetype mostRows = MostRows; + // Whether the walk left names unread. + bool cut() const { return m_cut; } qsizetype size() const { return m_rows.size(); } // The memory it holds. qsizetype bytes() const; @@ -157,6 +163,7 @@ class SearchIndex { QVector m_rows; // A bit for each row, set for a folder. QByteArray m_kinds; + bool m_cut = false; // The last answer: its query and matcher, its matches, how many of them are in their final order, and how many rows there were to match. QString m_query; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 291c55c2..58f048aa 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1393,6 +1393,69 @@ private slots: QCOMPARE(nearest.first().name, QString("beach.txt")); } + void searchKeepsTheNearestNamesOfATreeTooBig() + { + // A walk keeps so many names and no more, the nearest, and the search says how many it read. + QTemporaryDir dir; + QDir(dir.path()).mkpath("deep/deeper"); + for (int i = 0; i < 3; ++i) { + write(dir.filePath(QString("near%1.txt").arg(i)), "x"); + write(dir.filePath(QString("deep/far%1.txt").arg(i)), "x"); + write(dir.filePath(QString("deep/deeper/farther%1.txt").arg(i)), "x"); + } + const auto restore = qScopeGuard([] { + QThreadPool::globalInstance()->waitForDone(); + SearchIndex::mostRows = SearchIndex::MostRows; + }); + // Four names in the root, four in deep, three in deeper: at eleven nothing is left unread. + SearchIndex::mostRows = 11; + SearchIndex whole; + QVERIFY(whole.walk(dir.path(), false)); + QCOMPARE(whole.size(), 11); + QVERIFY(!whole.cut()); + QCOMPARE(whole.answer("farther"), 3); + // At six, the root's four and two of deep's: nothing of deeper. + SearchIndex::mostRows = 6; + SearchIndex index; + QVERIFY(index.walk(dir.path(), false)); + QCOMPARE(index.size(), 6); + QVERIFY(index.cut()); + QCOMPARE(index.answer("near"), 3); + QCOMPARE(index.answer("farther"), 0); + // Full, the walk ends at once: of two folders waiting, the first is opened to find a name too many and the second is left. + QTemporaryDir pair; + for (const char *folder : {"a", "b"}) { + QDir(pair.path()).mkdir(folder); + write(pair.filePath(QString("%1/file.txt").arg(folder)), "x"); + } + SearchIndex::mostRows = 2; + SearchIndex ended; + QVERIFY(ended.walk(pair.path(), false)); + QVERIFY(ended.cut()); + QCOMPARE(ended.size(), 2); + QCOMPARE(ended.counts.opens, 2); + + SearchIndex::mostRows = 6; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.mostRead(), -1); + model.setQuery("near"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.matches(), 3); + QCOMPARE(model.mostRead(), 6); + // The search over, there is nothing left to say of it, at once; and a tree read whole says nothing. + model.setQuery(""); + QCOMPARE(model.mostRead(), -1); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + SearchIndex::mostRows = SearchIndex::MostRows; + model.setQuery("far"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.matches(), 6); + QCOMPARE(model.mostRead(), -1); + } + void searchStartsAWordAtACapitalInANameThatIsNotAscii() { // "büro" starts a word of MeinBüro, so it comes before a shorter name that only holds the letters. @@ -1978,13 +2041,76 @@ private slots: said.setData("import QtQml\nimport \"Format.js\" as Format\n" "QtObject { readonly property string whole: Format.matches(12, \"%2 %1\", false, -1)\n" " readonly property string cut: Format.matches(12, \"%2 %1\", false, 5)\n" - " readonly property string soFar: Format.matches(12, \"%2 %1\", true, -1) }", + " readonly property string soFar: Format.matches(12, \"%2 %1\", true, -1)\n" + " readonly property string nearest: Format.matches(12, \"%2 %1\", false, -1, 7)\n" + " readonly property string nearestCut: Format.matches(12, \"%3 %2 %1\", false, 5, 7)\n" + " readonly property string stillReading: Format.matches(12, \"%2 %1\", true, -1, 7)\n" + " readonly property string allRead: Format.matches(12, \"%2 %1\", false, -1, -1) }", QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); std::unique_ptr text(said.create()); QVERIFY2(text, qPrintable(said.errorString())); QCOMPARE(text->property("whole").toString(), QString("12 matches in %2 %1 and below")); QCOMPARE(text->property("cut").toString(), QString("12 matches in %2 %1 and below, the best 5 shown")); QCOMPARE(text->property("soFar").toString(), QString("12 matches so far, still reading %2 %1 and below")); + // A tree too big to read whole says how many of its names were read, once it's done reading. + QCOMPARE(text->property("nearest").toString(), QString("12 matches in %2 %1 and below, only the nearest 7 items read")); + // Sorted another way too, it says both: which rows are shown and how many names were read. + QCOMPARE(text->property("nearestCut").toString(), QString("12 matches in %3 %2 %1 and below, the best 5 shown, only the nearest 7 items read")); + QCOMPARE(text->property("stillReading").toString(), text->property("soFar").toString()); + QCOMPARE(text->property("allRead").toString(), text->property("whole").toString()); + } + + void searchStatusSaysHowManyNamesWereRead() + { + // The path bar hands the status how many names the search read of a tree too big, and nothing of one read whole. + QTemporaryDir home; + Theme theme(home.filePath("theme")); + QQmlEngine engine; + QQmlComponent stubs(&engine); + stubs.setData("import QtQml\n" + "QtObject {\n" + " property string language: \"en\"\n" + " property string path: \"/a\"\n" + " property var crumbs: [{ name: \"A\", path: \"/a\" }]\n" + " property QtObject folder: QtObject {\n" + " property string query: \"q\"; property int matches: 12; property int count: 12; property int mostRead: 7\n" + " property bool reading: false; property string sortMode: \"name\"\n" + " }\n" + "}\n", QUrl()); + std::unique_ptr stub(stubs.create()); + QVERIFY2(stub, qPrintable(stubs.errorString())); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("backend", stub.get()); + engine.rootContext()->setContextProperty("translator", stub.get()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 900; height: 80; visible: true\n" + " PathBar { id: bar; anchors.fill: parent }\n" + " function said() {\n" + " let found = \"\"\n" + " const walk = item => {\n" + " for (const child of item.children) {\n" + " if (child instanceof Text && child.text.includes('12 matches'))\n" + " found = child.text\n" + " walk(child)\n" + " }\n" + " }\n" + " walk(bar)\n" + " return found\n" + " }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/PathBarTest.qml")); + std::unique_ptr window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + const auto said = [&] { + QVariant text; + QMetaObject::invokeMethod(window.get(), "said", Q_RETURN_ARG(QVariant, text)); + return text.toString(); + }; + QTRY_COMPARE(said(), QString("12 matches in A and below, only the nearest 7 items read")); + stub->property("folder").value()->setProperty("mostRead", -1); + QTRY_COMPARE(said(), QString("12 matches in A and below")); } void searchStatusLeavesRoomForTheQuery() From 354eebf2cae00050ae6dcb27766041af0670191a Mon Sep 17 00:00:00 2001 From: GM Date: Thu, 12 Nov 2026 22:26:09 -0400 Subject: [PATCH 34/38] Pin what the search tests wait for A held walk keeps the pool at two threads, so the cases pass on one processor. The held-pool cases wait until the walk's end has asked for its answer, the walk counts rest on no watcher timer, and a new case lands an answer the walk's end overtook. Each comment is one line. --- tests/folio_tests.cpp | 129 +++++++++++++++++++++++++++--------------- 1 file changed, 83 insertions(+), 46 deletions(-) diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 58f048aa..0c080f55 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -461,15 +461,20 @@ static int lastWindowClosed() return qScopeGuard([old] { qputenv("PATH", old); }); } -// Longer than a search holds before it shows what it has (150 ms) and than a folder's watcher waits for changes to settle (250 ms). +// Longer than a search holds before it shows what it has (150 ms) and asks once more (100 ms). static constexpr int PastTheSearchHold = 400; // Holds each walk of a search after its `at`th folder until let go, and counts the walks and the folders they read. struct WalksHeld { QSemaphore reached, go; std::atomic walks{0}, folders{0}; + // A held walk keeps one thread of the shared pool and the answers beside it take another. + static constexpr int WalkAndAnswers = 2; + const int threads = QThreadPool::globalInstance()->maxThreadCount(); explicit WalksHeld(qsizetype at = 1) { + // A machine with one processor gives the pool one thread. + QThreadPool::globalInstance()->setMaxThreadCount(std::max(threads, WalkAndAnswers)); SearchIndex::afterFolder = [this, at](qsizetype read) { ++folders; walks += read == 1; @@ -486,6 +491,7 @@ struct WalksHeld { go.release(); QThreadPool::globalInstance()->waitForDone(); SearchIndex::afterFolder = {}; + QThreadPool::globalInstance()->setMaxThreadCount(threads); } }; @@ -1368,9 +1374,7 @@ private slots: void searchAsksAboutFoldersAndShownRowsOnly() { - // A search reads names and types as each folder gives them: one stat - // for each folder it opens and one for each match it shows, none for - // every other file. + // One stat for each folder a search opens and one for each match it shows, none for any other file. QTemporaryDir dir; QDir(dir.path()).mkpath("a/b"); QDir(dir.path()).mkpath("c"); @@ -1576,14 +1580,13 @@ private slots: QVERIFY(!model.searchIndex()); QTRY_VERIFY(second.expired()); - // The next search reads again, so it finds what's new; showing - // hidden files reads again too. + // The next search, and showing hidden files, read the folders again. write(dir.filePath("Photos/beach2.jpg"), "x"); model.setQuery("beach"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); QCOMPARE(model.count(), 2); model.setShowHidden(true); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); QCOMPARE(model.count(), 3); QCOMPARE(model.searchIndex()->counts.walks, 1); } @@ -1611,9 +1614,7 @@ private slots: void searchListsTheBestMatchesFirst() { - // The name itself first, however deep; then names that start with - // it, then a word that does, then its letters in order. A folder - // goes before a file that matches as well, and a shorter path first. + // Best first: the name itself, a name that starts with it, a word that does, its letters in order; of equals a folder, then the shorter path. QTemporaryDir dir; QDir(dir.path()).mkpath("deep/down/frame"); QDir(dir.path()).mkpath("src/frame_old"); @@ -1638,8 +1639,7 @@ private slots: void searchShowsItsMatchesAWindowAtATime() { - // The first matches show at once and the rest as the view scrolls, - // each looked at only when it's shown; nothing is cut off. + // Matches show a window at a time, each looked at only when it's shown, and none is cut off. QTemporaryDir dir; for (int i = 0; i < 1100; ++i) write(dir.filePath(QString("match%1.txt").arg(i)), "x"); @@ -1649,7 +1649,7 @@ private slots: QVERIFY(loaded.wait(3000)); QCOMPARE(model.matches(), -1); model.setQuery("match"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); QCOMPARE(model.matches(), 1100); QCOMPARE(model.count(), 350); QCOMPARE(model.get(0).value("name").toString(), QString("match0.txt")); @@ -1684,7 +1684,7 @@ private slots: model.setPath(dir.path()); QVERIFY(loaded.wait(3000)); model.setQuery("match"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); QCOMPARE(model.count(), 350); model.selectAll(); QCOMPARE(model.selectedCount(), 500); @@ -1723,7 +1723,7 @@ private slots: model.setPath(dir.path()); QVERIFY(loaded.wait(3000)); model.setQuery("match"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); model.selectAll(); const QString last = model.selectedPaths().constLast(); QVERIFY(QFile::remove(last)); @@ -1817,8 +1817,10 @@ private slots: void searchReadAgainDuringADownloadKeepsItsRows() { - // Files landing in the searched folder ask for it again and again: one walk runs at a time and one more follows it, and the rows never drop or say they're still reading. + // Asked for again and again as files land: one walk runs at a time and one more follows it, and the rows never drop or say they're still reading. QTemporaryDir dir; + // Nothing below the searched folder is watched, so the asks are the ones made here. + QVERIFY(QDir(dir.path()).mkdir("landing")); for (int i = 0; i < 20; ++i) write(dir.filePath(QString("match%1.txt").arg(i)), "x"); FolderModel model; @@ -1831,12 +1833,12 @@ private slots: QSignalSpy reading(&model, &FolderModel::readingChanged); int fewest = model.count(); connect(&model, &FolderModel::countChanged, &model, [&] { fewest = std::min(fewest, model.count()); }); - WalksHeld walks; + WalksHeld walks(2); model.reload(); QVERIFY(walks.reached.tryAcquire(1, 3000)); - // Written after the held walk read its only folder: the walk that follows it finds them. + // Written after the held walk read both folders: the walk that follows it finds them. for (int i = 20; i < 26; ++i) { - write(dir.filePath(QString("match%1.txt").arg(i)), "x"); + write(dir.filePath(QString("landing/match%1.txt").arg(i)), "x"); model.reload(); } QTest::qWait(PastTheSearchHold); @@ -1858,15 +1860,15 @@ private slots: QDir(dir.path()).mkdir("a"); write(dir.filePath("match.txt"), "x"); write(dir.filePath("a/match.txt"), "x"); - WalksHeld walks; + WalksHeld walks(2); FolderModel model; QSignalSpy loaded(&model, &FolderModel::loaded); model.setPath(dir.path()); QVERIFY(loaded.wait(3000)); model.setQuery("match"); QVERIFY(walks.reached.tryAcquire(1, 3000)); - // In the folder the held walk has read already. - write(dir.filePath("match too.txt"), "x"); + // In a folder the held walk has read already, below the one watched: the ask is this one alone. + write(dir.filePath("a/match too.txt"), "x"); model.reload(); QTest::qWait(PastTheSearchHold); QCOMPARE(walks.walks.load(), 1); @@ -1897,8 +1899,9 @@ private slots: QVERIFY(pool->waitForDone(5000)); { PoolHeld held; - // The walk's end is taken in here, and the answer it asks for waits behind the held pool. - QCoreApplication::processEvents(); + // The walk's end is taken in here: only then is the answer it asks for on its way, and it waits behind the held pool. + QVERIFY(!model.loading()); + QTRY_VERIFY(model.loading()); QCOMPARE(model.searchIndex(), before); model.selectAll(); QCOMPARE(model.matches(), 500); @@ -1966,8 +1969,9 @@ private slots: QVERIFY(pool->waitForDone(5000)); { PoolHeld held; - // The walk's end is taken in here with nothing selected, and the answer it asks for waits behind the held pool. - QCoreApplication::processEvents(); + // The walk's end is taken in here with nothing selected: only then is the answer it asks for on its way, and it waits behind the held pool. + QVERIFY(!model.loading()); + QTRY_VERIFY(model.loading()); QCOMPARE(model.searchIndex(), before); model.selectRows({348, 349}); QCOMPARE(model.selectedCount(), 2); @@ -2252,11 +2256,13 @@ private slots: QVERIFY(loaded.wait(3000)); model.setQuery("match"); QTRY_VERIFY(!model.reading() && !model.loading()); - QStringList seen; - connect(&model, &FolderModel::selectionChanged, &model, [&] { seen = model.selectedPaths(); }); + QList seen; + connect(&model, &FolderModel::selectionChanged, &model, [&] { seen << model.selectedPaths(); }); model.selectAll(); - QCOMPARE(seen.size(), 550); - QCOMPARE(seen, model.selectedPaths()); + // Said once: nobody was handed a part of it before. + QCOMPARE(seen.size(), 1); + QCOMPARE(seen.constFirst().size(), 550); + QCOMPARE(seen.constFirst(), model.selectedPaths()); } void searchClearedForgetsHowManyMatched() @@ -2318,7 +2324,7 @@ private slots: model.setPath(dir.path()); QVERIFY(loaded.wait(3000)); model.setQuery("match"); - QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.reading() && !model.loading()); model.fetchMore({}); QTRY_COMPARE(model.count(), 500); model.selectOnly(420); @@ -2335,9 +2341,7 @@ private slots: void searchMatchesWordsAlongThePath() { - // With a "/", the folders take what's before it and the name the - // rest. Each folder is matched at most once a query, however many - // rows it holds. + // Before a "/" is for the folders and after it for the name; a folder is matched once a query, however many rows it holds. QTemporaryDir dir; QDir(dir.path()).mkpath("projects/editor/src/components"); QDir(dir.path()).mkpath("projects/viewer/src"); @@ -2399,9 +2403,7 @@ private slots: void searchForgivesATypoWhenNoNameSpellsTheQuery() { - // No name spelling the query out, a name a letter off (a swapped - // pair counts as one) is found; a name that holds it whole, or in - // runs that each start a word, means the typo pass never runs. + // A name a letter off (a swapped pair is one) is found only when no name spells the query, whole or in runs that each start a word. QTemporaryDir dir; for (const char *folder : {"projects/src", "editor/render"}) QDir(dir.path()).mkpath(folder); @@ -2423,8 +2425,7 @@ private slots: for (const char *query : {"render", "rend", "fuzzy", "report", "renfr", "ort2024"}) QVERIFY(index.answer(query) > 0); QCOMPARE(index.counts.typos, 3); - // Letters scattered through a name (thread_runner) or along a path - // (editor/render/pipeline) don't stand in for the name meant: it comes first. + // The name meant comes before letters scattered through a name (thread_runner) or along a path (editor/render/pipeline). QCOMPARE(index.answer("redner"), 4); QCOMPARE(index.counts.typos, 4); const QVector rows = index.rows(0, 4); @@ -2436,9 +2437,7 @@ private slots: void searchShowsMatchesWhileItsStillReading() { - // A folder that takes a while to read shows its best matches so far - // (saying it's still reading) until it's all been read, and then - // all of them. + // A folder slow to read shows its best matches so far, saying it's still reading, then all of them. QTemporaryDir dir; for (const char *folder : {"a", "b", "c"}) { QDir(dir.path()).mkdir(folder); @@ -2460,7 +2459,11 @@ private slots: QCOMPARE(model.matches(), 2); QCOMPARE(model.count(), 2); walks.letGo(); - QTRY_VERIFY(!model.reading() && !model.loading()); + // The walk's end is taken in by itself: no timer runs here, so no partial answer is asked for that the end would overtake. + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCoreApplication::sendPostedEvents(nullptr, QEvent::FutureCallOut); + QVERIFY(!model.reading()); + QTRY_VERIFY(!model.loading()); QCOMPARE(model.matches(), 4); QCOMPARE(model.count(), 4); QCOMPARE(model.searchIndex()->counts.walks, 1); @@ -2468,6 +2471,41 @@ private slots: QCOMPARE(model.searchIndex()->counts.answers, loaded.count()); } + void searchDropsAnAnswerTheWalksEndOvertook() + { + // The other order: an answer asked for before the walk's end is taken in lands after it, and never shows. + QTemporaryDir dir; + for (const char *folder : {"a", "b"}) { + QDir(dir.path()).mkdir(folder); + write(dir.filePath(QString("%1/match.txt").arg(folder)), "x"); + } + write(dir.filePath("match.txt"), "x"); + WalksHeld walks(2); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + loaded.clear(); + model.setQuery("match"); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.loading()); + const auto index = model.searchIndex(); + // The walk ends on its thread and its end waits to be taken in: no events run here. + walks.letGo(); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + // Another order asks for an answer now, whose own end waits behind the walk's. + model.setSortMode("size"); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QCoreApplication::sendPostedEvents(nullptr, QEvent::FutureCallOut); + QVERIFY(!model.reading()); + QTRY_VERIFY(!model.loading()); + QCOMPARE(model.matches(), 3); + // Three answers were worked out and two shown: the one overtaken landed nowhere. + QCOMPARE(index->counts.answers, 3); + QCOMPARE(loaded.count(), 2); + } + void folderModelSwapsFoldersWithoutAnEmptyFrame() { QTemporaryDir a, b; @@ -2607,8 +2645,7 @@ private slots: QTemporaryDir big, small, other, tree; for (int i = 0; i < 20000; ++i) write(big.filePath(QString("%1.txt").arg(i)), "x"); - // A search asks nothing about a file, so what it takes time over is - // opening folders. + // A search asks nothing about a file, so what it takes time over is opening folders. for (int i = 0; i < 5000; ++i) QDir(tree.path()).mkdir(QString::number(i)); write(small.filePath("a.txt"), "x"); From 62c64f316ab983ba0d1f71f91f3eea2621f81860 Mon Sep 17 00:00:00 2001 From: GM Date: Fri, 13 Nov 2026 00:43:12 -0400 Subject: [PATCH 35/38] Ask the search at every key, with no pause to wait out The field waited for 150 ms without a key before it asked, a wait kept from when every key walked the tree. The model answers a key from the names it holds, so the field asks as each is typed. --- src/PathBar.qml | 10 +---- tests/folio_tests.cpp | 97 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 3c7c5298..2301350a 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -42,16 +42,10 @@ Rectangle { // S and Shift+S, through the sort menu's orders. function stepSort(by) { sortMenu.step(by) } function endSearch() { - searchDelay.stop() searchField.text = "" backend.folder.query = "" editDone() } - Timer { - id: searchDelay - interval: 150 - onTriggered: backend.folder.query = searchField.text.trim() - } // Going to another folder ends the search there. Connections { target: backend.folder @@ -127,10 +121,10 @@ Rectangle { color: theme.secondaryForeground font.pixelSize: 12 } - onTextEdited: searchDelay.restart() + // Each key asks at once: the model answers from the names it holds, and keys typed while it reads them are taken together when the read ends. + onTextEdited: backend.folder.query = text.trim() // Enter or ↓ goes to the results, leaving the search up. function toResults() { - searchDelay.stop() backend.folder.query = text.trim() if (backend.folder.query === "") bar.endSearch() diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 0c080f55..761d8387 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1879,6 +1879,51 @@ private slots: QCOMPARE(walks.walks.load(), 2); } + void searchTypedOnDuringItsFirstReadAnswersTheLastKey() + { + // Keys typed while the first walk still reads start no walk beside it, and what lands is the answer to the last of them. + QTemporaryDir dir; + QDir(dir.path()).mkdir("a"); + write(dir.filePath("render.txt"), "x"); + write(dir.filePath("a/render too.txt"), "x"); + write(dir.filePath("a/reel.txt"), "x"); + // Matches the key before the last and not the last, so an answer to the wrong one shows. + write(dir.filePath("a/siren.txt"), "x"); + // Declared ahead of the model, which announces a count as it ends. + QList counts; + WalksHeld walks(2); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("r"); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + for (const char *typed : {"re", "ren", "rend"}) + model.setQuery(typed); + QCOMPARE(walks.walks.load(), 1); + const auto names = [&model] { + QStringList names; + for (int row = 0; row < model.count(); ++row) + names << model.get(row).value("name").toString(); + names.sort(); + return names; + }; + const QStringList rend = {"a/render too.txt", "render.txt"}; + // An earlier key's answer holds more than the last key's two, so no count seen on the way may pass two. + connect(&model, &FolderModel::matchesChanged, &model, [&counts, &model] { counts << model.matches(); }); + walks.letGo(); + QTRY_COMPARE(model.matches(), 2); + QCOMPARE(names(), rend); + // The three keys asked for one more read of the folders between them, not one each, and it answers the last key too. + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(walks.walks.load(), 2); + QCOMPARE(model.matches(), 2); + QCOMPARE(names(), rend); + QVERIFY(!counts.isEmpty()); + QCOMPARE(*std::max_element(counts.cbegin(), counts.cend()), 2); + } + void searchReadAgainTakesOverOnceItHasAnswered() { // A walk read again is whole before it has answered: until its answer lands, the count and Select All are the old walk's, never the rows alone. @@ -2117,6 +2162,58 @@ private slots: QTRY_COMPARE(said(), QString("12 matches in A and below")); } + void searchAsksAtEveryKey() + { + // A key typed in the search field is the query at once, with no pause in the typing to wait for. + QTemporaryDir home; + Theme theme(home.filePath("theme")); + QQmlEngine engine; + QQmlComponent stubs(&engine); + stubs.setData("import QtQml\n" + "QtObject {\n" + " property string language: \"en\"\n" + " property string path: \"/a\"\n" + " property var crumbs: [{ name: \"A\", path: \"/a\" }]\n" + " property QtObject folder: QtObject {\n" + " property string query; property int matches: -1; property int count: 0; property int mostRead: -1\n" + " property bool reading: false; property string sortMode: \"name\"\n" + " }\n" + "}\n", QUrl()); + std::unique_ptr stub(stubs.create()); + QVERIFY2(stub, qPrintable(stubs.errorString())); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("backend", stub.get()); + engine.rootContext()->setContextProperty("translator", stub.get()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 900; height: 80; visible: true\n" + " PathBar { id: bar; anchors.fill: parent }\n" + " function search() { bar.search() }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/PathBarTest.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + QMetaObject::invokeMethod(window, "search"); + const auto query = [&] { return stub->property("folder").value()->property("query").toString(); }; + QTest::keyClick(window, Qt::Key_R); + QCOMPARE(query(), QString("r")); + QTest::keyClick(window, Qt::Key_E); + QCOMPARE(query(), QString("re")); + // A space at the end asks nothing new, and the letter after it is asked with the space. + QTest::keyClick(window, Qt::Key_Space); + QCOMPARE(query(), QString("re")); + QTest::keyClick(window, Qt::Key_D); + QCOMPARE(query(), QString("re d")); + QTest::keyClick(window, Qt::Key_Backspace); + QTest::keyClick(window, Qt::Key_Backspace); + QTest::keyClick(window, Qt::Key_Backspace); + QCOMPARE(query(), QString("r")); + } + void searchStatusLeavesRoomForTheQuery() { // In a narrow pane the count and the folder's name would take the whole field, and the query typed there could not be seen. From 2c07f341ad1f72631617c2eb22f1d5fd05cf3846 Mon Sep 17 00:00:00 2001 From: GM Date: Fri, 13 Nov 2026 02:40:32 -0400 Subject: [PATCH 36/38] Keep the cursor and selection steady through a search A key cleared the selection before its answer was drawn, each answer left the cursor on its old row, and the end of a search showed the folder a frame before its cursor. The selection now goes with its rows, the cursor stays on the best match, and the end restores it at once. --- src/FolderPane.qml | 20 +++ src/foldermodel.cpp | 14 +- src/foldermodel.h | 2 + tests/folio_tests.cpp | 326 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 2 deletions(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1c3d5315..03ee15c0 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -44,6 +44,10 @@ FocusScope { // the folder. By path: Recents can list two files of the same name. property string lastPath: "" property string loadedPath: "" + // The query the rows last answered, the cursor's item when a search began (its end puts the cursor back there), and the best match an answer put the cursor on. + property string loadedQuery: "" + property string searchedFrom: "" + property string answerTop: "" // Set when a click on empty space lets go of the cursor: a re-read or a // switch of view leaves it let go of, until it moves again or another // folder arrives. @@ -280,19 +284,35 @@ FocusScope { const pending = backend.takeSelection() const same = backend.folder.path === pane.loadedPath pane.loadedPath = backend.folder.path + // An answer to another query is another list: the cursor goes to its best match, and when the search ends, back to the item it left. + const query = backend.folder.query + const asked = same && query !== pane.loadedQuery + if (asked && pane.loadedQuery === "") + pane.searchedFrom = pane.lastPath + if (asked && query === "") + pane.lastPath = pane.searchedFrom + pane.loadedQuery = query + const answer = asked && query !== "" const row = backend.folder.indexOfPath(pane.lastPath) + // Nothing is selected but the cursor's item, or that item is gone and nothing is selected. + const own = backend.folder.selectedCount === 1 ? backend.folder.selectedPaths()[0] === pane.lastPath : backend.folder.selectedCount === 0 && row < 0 + // The cursor an answer put on its best match stays on the best match as the search reads on, until it's moved to another item. + const riding = same && !asked && query !== "" && !pane.cursorLetGo && pane.lastPath === pane.answerTop && own if (pending || !same) pane.cursorLetGo = false if (pending) view.item.select(backend.folder.indexOf(pending), false) else if (!same) view.item.select(-1, false) + else if (answer || riding) + view.item.select(-1, answer) else if (!pane.cursorLetGo) view.item.select(row >= 0 ? row : Math.min(view.item.currentIndex, backend.folder.count - 1), true) // Remembered here too: put on the row it was already on, the // cursor doesn't signal a move, and an item from the folder // before would stay remembered. pane.lastPath = backend.folder.get(view.item.currentIndex).path || "" + pane.answerTop = answer || riding ? pane.lastPath : pane.answerTop } } diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 5244c092..f2031bc5 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -309,13 +309,17 @@ void FolderModel::setQuery(const QString &query) { m_query = query; m_shownRows = 0; emit queryChanged(); - setSelected({}); + // What's selected is of the rows on screen and goes in the turn they do: let go at the key, its mark would blink out ahead of the rows. + m_leaving = m_selected; // Back to the folder: its remembered listing shows at once, and what the search read is let go of. if (m_query.isEmpty()) { closeSearch(); if (const auto *cached = recentListings().object(cacheKey())) { + setSelected({}); setEntries(*cached); m_rowsQuery.clear(); + // Said in this turn, so the cursor is on its row in the frame that shows the folder. + emit loaded(); } } read(); @@ -847,7 +851,8 @@ void FolderModel::read() { return; const bool partial = search && !search->landed; const qsizetype window = std::max(SearchWindow, m_shownRows); - const bool selecting = !m_selected.isEmpty(); + // A selection about to go with its rows asks nothing of the answer. + const bool selecting = !m_selected.isEmpty() && m_leaving.isEmpty(); const QString answers = search ? query : QString(); auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, answers, search] { @@ -873,6 +878,9 @@ void FolderModel::read() { } else { takeSizesAndDates(std::move(entries)); } + // The selection a key found goes here with its rows, unless it was changed since. + if (!std::exchange(m_leaving, {}).isEmpty()) + setSelected({}); // A partial answer drops nothing from the selection: what it hasn't found yet may still match. if (!partial && !m_selected.isEmpty()) { QSet still; @@ -1091,6 +1099,8 @@ void FolderModel::setSelected(const QSet &paths) { m_unshown.clear(); if (paths == m_selected) return; + // Changed since the key, it is no longer the key's to let go. + m_leaving.clear(); m_selected = paths; if (!m_entries.isEmpty()) emit dataChanged(index(0), index(m_entries.size() - 1), {SelectedRole}); diff --git a/src/foldermodel.h b/src/foldermodel.h index 67dc3358..9100cfd0 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -211,6 +211,8 @@ class FolderModel : public QAbstractListModel { QString m_path; QVector m_entries; QSet m_selected; + // What was selected when the query last changed, until the answer to it lands or the selection changes. + QSet m_leaving; void setSelected(const QSet &paths); bool m_loading = false; QString m_error; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 761d8387..20673546 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -2214,6 +2214,332 @@ private slots: QCOMPARE(query(), QString("r")); } + void searchLetsGoOfTheSelectionInTheTurnItsAnswerLands() + { + // A key leaves what is selected alone until its answer is here: the mark goes with its rows, not a frame or two ahead of them. + QTemporaryDir dir; + QVERIFY(QDir(dir.path()).mkpath("a")); + for (const char *name : {"river.txt", "stone.txt", "a/reed.txt"}) + write(dir.filePath(name), "x"); + // Declared ahead of the model, which announces as it ends. + QList selectedAtLanding; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.loading()); + model.selectOnly(model.indexOfPath(dir.filePath("stone.txt"))); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("stone.txt")}); + connect(&model, &FolderModel::loaded, &model, [&selectedAtLanding, &model] { selectedAtLanding << model.selectedCount(); }); + model.setQuery("r"); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("stone.txt")}); + QTRY_COMPARE(model.matches(), 2); + QVERIFY(!selectedAtLanding.isEmpty()); + QCOMPARE(*std::max_element(selectedAtLanding.cbegin(), selectedAtLanding.cend()), 0); + QCOMPARE(model.selectedCount(), 0); + QTRY_VERIFY(!model.reading() && !model.loading()); + + // A match selected is let go by the next key's answer too, though it still matches. + model.selectOnly(model.indexOfPath(dir.filePath("a/reed.txt"))); + selectedAtLanding.clear(); + model.setQuery("ree"); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("a/reed.txt")}); + QTRY_COMPARE(model.matches(), 1); + QVERIFY(!selectedAtLanding.isEmpty()); + QCOMPARE(model.selectedCount(), 0); + QTRY_VERIFY(!model.reading() && !model.loading()); + + // What is selected after the key, on the rows still shown, is not the key's to let go, were it the same item again: it stays while it matches. + model.setQuery("r"); + QTRY_COMPARE(model.matches(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + model.setQuery("ri"); + model.selectOnly(model.indexOfPath(dir.filePath("a/reed.txt"))); + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + QTRY_COMPARE(model.matches(), 1); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); + + // The same through a toggle: taken off and put back after the key, it stays. + model.setQuery("r"); + QTRY_COMPARE(model.matches(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + model.setQuery("ri"); + model.toggleSelected(model.indexOfPath(dir.filePath("river.txt"))); + QCOMPARE(model.selectedCount(), 0); + model.toggleSelected(model.indexOfPath(dir.filePath("river.txt"))); + QTRY_COMPARE(model.matches(), 1); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); + } + + void searchEndedShowsTheFolderAndSaysSoInOneTurn() + { + // The search's end puts the folder's remembered rows up and says so at once, so a view has its cursor on a row in the frame that shows them. + QTemporaryDir dir; + QVERIFY(QDir(dir.path()).mkpath("a")); + for (const char *name : {"river.txt", "stone.txt", "a/reed.txt"}) + write(dir.filePath(name), "x"); + // Declared ahead of the model, which announces as it ends. + QList> rowsAndSelectedAtLanding; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.loading()); + model.setQuery("r"); + QTRY_COMPARE(model.matches(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + loaded.clear(); + model.setQuery(""); + // No turn of the event loop has passed. + QCOMPARE(loaded.count(), 1); + QCOMPARE(model.count(), 3); + QCOMPARE(model.selectedCount(), 0); + QVERIFY(model.indexOfPath(dir.filePath("stone.txt")) >= 0); + // The selection went with the search's rows; the same item selected again on the folder's is kept by the read that follows. + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + QTRY_COMPARE(loaded.count(), 2); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); + + // Sorted another way meanwhile, the folder isn't remembered: the search's rows and what is selected on them, a row of the folder too, stay until the folder's rows land, and go in that turn. + QTRY_VERIFY(!model.loading()); + model.setQuery("r"); + QTRY_COMPARE(model.matches(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.setSortMode("kind"); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectOnly(model.indexOfPath(dir.filePath("river.txt"))); + connect(&model, &FolderModel::loaded, &model, [&rowsAndSelectedAtLanding, &model] { rowsAndSelectedAtLanding.append({model.count(), model.selectedCount()}); }); + loaded.clear(); + model.setQuery(""); + QCOMPARE(loaded.count(), 0); + QCOMPARE(model.count(), 2); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); + QTRY_VERIFY(!rowsAndSelectedAtLanding.isEmpty()); + QCOMPARE(rowsAndSelectedAtLanding.first(), qMakePair(3, 0)); + } + + void searchPutsTheCursorOnItsBestMatchAndBackWhereItWas() + { + // Each answer to another query has the cursor on its first row, and the search's end has it back on the item it left, in the turn the folder's rows return. + QTemporaryDir home, kept; + // Opening a folder makes it the working directory, which the tests after this one count on. + const QString before = QDir::currentPath(); + const auto restore = qScopeGuard([before] { QDir::setCurrent(before); }); + // The window remembers its view in QSettings; here that file lands beside the test's folder, not in it and not in the user's. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, kept.path()); + QVERIFY(QDir(home.path()).mkpath("box")); + for (const char *name : {"apple.txt", "apricot.txt", "grape.txt", "pear.txt", "plum pudding.txt", "box/grape juice.txt", "box/grapefruit.txt", "box/plum.txt"}) + write(home.filePath(name), "x"); + Theme theme(kept.filePath("theme")); + Translator translator(kept.filePath("language"), sourceFolder() + "/i18n"); + Places places(home.path()); + Plugins plugins("en", {}, kept.filePath("folio.conf")); + Backend backend(home.path()); + backend.setPlugins(&plugins); + FileOps ops; + QQmlEngine engine; + qmlRegisterType("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("fileOps", &ops); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/Main.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + QTRY_VERIFY(window->property("settled").toBool()); + backend.open(home.path()); + FolderModel *folder = backend.folder(); + QTRY_COMPARE(folder->count(), 6); + QTRY_VERIFY(!folder->loading()); + // The view that lists the folder, whichever the window opened with. + std::function find = [&find](QQuickItem *item) -> QQuickItem * { + const QByteArray type = item->metaObject()->className(); + if (type.startsWith("FolderGrid") || type.startsWith("FolderList") || type.startsWith("FolderColumns")) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = find(child)) + return found; + return nullptr; + }; + QQuickItem *view = find(window->contentItem()); + QVERIFY(view); + const auto cursor = [view] { return view->property("currentIndex").toInt(); }; + const int pear = folder->indexOfPath(home.filePath("pear.txt")); + QCOMPARE(pear, 4); + QVERIFY(QMetaObject::invokeMethod(view, "select", Q_ARG(QVariant, pear), Q_ARG(QVariant, false))); + QCOMPARE(cursor(), pear); + + QSignalSpy loaded(folder, &FolderModel::loaded); + folder->setQuery("grape"); + // Until the answer is here nothing moves: the cursor and its mark are on the row they were on. + QCOMPARE(cursor(), pear); + QVERIFY(folder->isSelected(pear)); + QTRY_COMPARE(folder->matches(), 3); + QCOMPARE(cursor(), 0); + QVERIFY(folder->isSelected(0)); + QCOMPARE(folder->selectedCount(), 1); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + + // A key taken back is answered by the same rows, with the cursor on the first of them again. + QVERIFY(QMetaObject::invokeMethod(view, "select", Q_ARG(QVariant, 2), Q_ARG(QVariant, false))); + loaded.clear(); + folder->setQuery("grap"); + QCOMPARE(cursor(), 2); + QTRY_VERIFY(!loaded.isEmpty()); + QCOMPARE(folder->count(), 3); + QCOMPARE(cursor(), 0); + QVERIFY(folder->isSelected(0)); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + + // The end: the folder's rows and the cursor on the item it left, with no turn of the event loop between. + folder->setQuery(""); + QCOMPARE(folder->count(), 6); + QCOMPARE(cursor(), pear); + QVERIFY(folder->isSelected(pear)); + QCOMPARE(folder->selectedCount(), 1); + // The folder read behind its remembered rows leaves the cursor there. + QTRY_VERIFY(!folder->loading()); + QCOMPARE(cursor(), pear); + + // Sorted another way meanwhile, the folder isn't remembered: the cursor is back on its item in the turn the folder's rows land. + const QString sorted = folder->sortMode(); + folder->setQuery("grape"); + QTRY_COMPARE(folder->matches(), 3); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + folder->setSortMode(sorted == "kind" ? "name" : "kind"); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + folder->setQuery(""); + QCOMPARE(folder->count(), 3); + QTRY_COMPARE(folder->count(), 6); + QCOMPARE(cursor(), folder->indexOfPath(home.filePath("pear.txt"))); + QVERIFY(folder->isSelected(cursor())); + QCOMPARE(folder->selectedCount(), 1); + folder->setSortMode(sorted); + QTRY_VERIFY(!folder->loading()); + QCOMPARE(cursor(), pear); + + // What is selected while an answer is on its way is kept by it: the cursor goes to the first row and the selection stays. + folder->setQuery("ap"); + folder->toggleSelected(folder->indexOfPath(home.filePath("apple.txt"))); + folder->toggleSelected(folder->indexOfPath(home.filePath("apricot.txt"))); + QCOMPARE(folder->selectedCount(), 3); + loaded.clear(); + QTRY_VERIFY(!loaded.isEmpty()); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedCount(), 2); + QVERIFY(folder->selectedPaths().contains(home.filePath("apricot.txt"))); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + folder->setQuery(""); + QCOMPARE(cursor(), pear); + QTRY_VERIFY(!folder->loading()); + + // A search that ends on the item it began on keeps that item selected through the folder's read. + const int grape = folder->indexOfPath(home.filePath("grape.txt")); + QVERIFY(QMetaObject::invokeMethod(view, "select", Q_ARG(QVariant, grape), Q_ARG(QVariant, false))); + folder->setQuery("grape"); + QTRY_COMPARE(folder->matches(), 3); + QCOMPARE(folder->indexOfPath(home.filePath("grape.txt")), 0); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + folder->setQuery(""); + QCOMPARE(cursor(), grape); + QTRY_VERIFY(!folder->loading()); + QVERIFY(folder->isSelected(grape)); + QCOMPARE(cursor(), grape); + + // An answer given while the folders below are still read has the cursor on its first row, and the better match found later takes the cursor with the top. + { + WalksHeld walks; + folder->setQuery("plum"); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QTRY_COMPARE(folder->matches(), 1); + QVERIFY(folder->reading()); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum pudding.txt")}); + walks.letGo(); + QTRY_COMPARE(folder->matches(), 2); + QCOMPARE(folder->indexOfPath(home.filePath("box/plum.txt")), 0); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("box/plum.txt")}); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + } + // Still on the best match, the cursor goes on with the top when a better one turns up later. + write(home.filePath("plum.txt"), "x"); + QTRY_COMPARE(folder->matches(), 3); + QCOMPARE(folder->indexOfPath(home.filePath("plum.txt")), 0); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum.txt")}); + // Its best match gone, the cursor is on the new top and goes on with it. + QVERIFY(QFile::remove(home.filePath("plum.txt"))); + QTRY_COMPARE(folder->matches(), 2); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("box/plum.txt")}); + write(home.filePath("plum.txt"), "x"); + QTRY_COMPARE(folder->matches(), 3); + QCOMPARE(folder->indexOfPath(home.filePath("plum.txt")), 0); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum.txt")}); + // Deselected, its item keeps the cursor when a better match turns up, as in any folder read again. + QVERIFY(QFile::remove(home.filePath("plum.txt"))); + QTRY_COMPARE(folder->matches(), 2); + QCOMPARE(cursor(), 0); + folder->toggleSelected(0); + QCOMPARE(folder->selectedCount(), 0); + write(home.filePath("plum.txt"), "x"); + QTRY_COMPARE(folder->matches(), 3); + QCOMPARE(folder->indexOfPath(home.filePath("plum.txt")), 0); + QCOMPARE(cursor(), 1); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("box/plum.txt")}); + // Asked again with nothing selected, the cursor is back on the top. + folder->toggleSelected(1); + for (const char *query : {"plu", "plum"}) { + loaded.clear(); + folder->setQuery(query); + QTRY_VERIFY(!loaded.isEmpty()); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + } + QCOMPARE(folder->matches(), 3); + QCOMPARE(cursor(), 0); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum.txt")}); + // With more selected beside it, the cursor stays with its item and the selection is kept. + folder->toggleSelected(folder->indexOfPath(home.filePath("plum pudding.txt"))); + write(home.filePath("a plum.txt"), "x"); + QTRY_COMPARE(folder->matches(), 4); + QCOMPARE(folder->selectedCount(), 2); + QCOMPARE(cursor(), folder->indexOfPath(home.filePath("plum.txt"))); + // With one item left selected that isn't the cursor's, the selection is kept and the cursor stays with its item. + folder->toggleSelected(folder->indexOfPath(home.filePath("plum.txt"))); + write(home.filePath("plum 0.txt"), "x"); + QTRY_COMPARE(folder->matches(), 5); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum pudding.txt")}); + QCOMPARE(cursor(), folder->indexOfPath(home.filePath("plum.txt"))); + // Moved to another item, the cursor stays with that item when the rows are read again. + const int pudding = folder->indexOfPath(home.filePath("plum pudding.txt")); + QVERIFY(QMetaObject::invokeMethod(view, "select", Q_ARG(QVariant, pudding), Q_ARG(QVariant, false))); + write(home.filePath("plum 2.txt"), "x"); + QTRY_COMPARE(folder->matches(), 6); + QCOMPARE(cursor(), folder->indexOfPath(home.filePath("plum pudding.txt"))); + QCOMPARE(folder->selectedPaths(), QStringList{home.filePath("plum pudding.txt")}); + // Let go of, the cursor isn't put back by the rows read again. + QVERIFY(QMetaObject::invokeMethod(view, "select", Q_ARG(QVariant, 0), Q_ARG(QVariant, false))); + folder->setQuery("plu"); + QTRY_COMPARE(folder->matches(), 6); + QCOMPARE(cursor(), 0); + QTRY_VERIFY(!folder->reading() && !folder->loading()); + QVERIFY(QMetaObject::invokeMethod(view, "letGo")); + QCOMPARE(cursor(), -1); + write(home.filePath("plum 3.txt"), "x"); + QTRY_COMPARE(folder->matches(), 7); + QCOMPARE(cursor(), -1); + } + void searchStatusLeavesRoomForTheQuery() { // In a narrow pane the count and the folder's name would take the whole field, and the query typed there could not be seen. From 46a1b528d42c7587c5e710abef1b8530d05f45f3 Mon Sep 17 00:00:00 2001 From: GM Date: Fri, 13 Nov 2026 04:11:31 -0400 Subject: [PATCH 37/38] Keep a folder's count in sight while it is counted again Rows that replace others came with no folder counted, so at every key of a search the counts on screen went out for a frame and came back. A folder the rows before had now shows the count it had until its new one is in, and is counted again whatever overtakes that counting. --- src/foldermodel.cpp | 28 +++++++- src/foldermodel.h | 2 + tests/folio_tests.cpp | 148 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index f2031bc5..ab16bd8c 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -103,6 +103,7 @@ void FolderModel::takeSizesAndDates(QVector entries) { Entry &next = entries[row]; const Entry &now = m_entries.at(row); next.itemCount = now.itemCount; + next.countKept = now.countKept; if (next.unread && !now.unread) { // Read since the re-read began. next.unread = false; @@ -288,6 +289,19 @@ void FolderModel::setShowHidden(bool show) { if (show == m_showHidden) return; m_showHidden = show; + // A count made with hidden files the other way says nothing of the folder now: it goes at once, and the folder is counted again. + bool counted = false; + for (int row = 0; row < m_entries.size(); ++row) { + if (m_entries.at(row).isDir && m_entries.at(row).itemCount >= 0) { + m_entries[row].itemCount = -1; + m_entries[row].countKept = false; + counted = true; + } + } + if (counted) { + emit dataChanged(index(0), index(m_entries.size() - 1), {ItemCountRole}); + emit countChanged(); + } emit showHiddenChanged(); // A search's walk took or left the hidden files: it ends here, and the folders are read anew. closeSearch(); @@ -874,6 +888,17 @@ void FolderModel::read() { if (!same) { // A folder found changed has its rows' types read again, as its sizes and dates are. m_types.clear(); + // A folder the rows before these had keeps its count in sight while it is counted again: without it, the counts on screen would blink out for a turn at every key of a search. + QHash counts; + for (const Entry &now : std::as_const(m_entries)) + if (now.isDir && now.itemCount >= 0) + counts.insert(now.path, now.itemCount); + for (Entry &next : entries) { + if (next.isDir && next.itemCount < 0 && counts.contains(next.path)) { + next.itemCount = counts.value(next.path); + next.countKept = true; + } + } setEntries(entries); } else { takeSizesAndDates(std::move(entries)); @@ -1017,7 +1042,7 @@ void FolderModel::countFolders(quint64 generation) { // counting was overtaken by a re-read (and so remembered without them). QVector> folders; for (int row = 0; row < m_entries.size(); ++row) - if (m_entries.at(row).isDir && m_entries.at(row).itemCount < 0) + if (m_entries.at(row).isDir && (m_entries.at(row).itemCount < 0 || m_entries.at(row).countKept)) folders.push_back({row, m_entries.at(row)}); if (folders.isEmpty()) return; @@ -1034,6 +1059,7 @@ void FolderModel::countFolders(quint64 generation) { continue; Entry &entry = m_entries[row]; entry.itemCount = summary.itemCount; + entry.countKept = false; QList roles{ItemCountRole}; // A count goes with the date read before it: one a view read meanwhile may be newer, and would hide the change from a re-read. if (!summary.unread && (entry.unread || entry.modified != summary.modified)) { diff --git a/src/foldermodel.h b/src/foldermodel.h index 9100cfd0..6a92fec1 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -73,6 +73,8 @@ class FolderModel : public QAbstractListModel { QString icon; QVariantList details; int itemCount = -1; + // The count shown was made for the rows before these: the folder is counted again. + bool countKept = false; // The first pictures in a folder, for the preview window. QStringList peek; }; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 20673546..d9793c5a 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -2273,6 +2273,58 @@ private slots: QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); } + void searchKeepsAFoldersCountFromOneAnswerToTheNext() + { + // A folder two answers both list shows its count in the turn the second lands, and is counted again all the same; with hidden files shown or hidden since, it shows none until it is counted. + QTemporaryDir dir; + QVERIFY(QDir(dir.path()).mkpath("a/render/.hidden")); + for (const char *name : {"a/render/one.txt", "a/render/two.txt", "a/render/.hidden/three.txt", "a/reed.txt", "river.txt", "rock.txt"}) + write(dir.filePath(name), "x"); + const QString render = dir.filePath("a/render"); + // Declared ahead of the model, which announces as it ends. + QList countAtLanding; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QTRY_VERIFY(!model.loading()); + const auto count = [&model, &render] { return model.get(model.indexOfPath(render)).value("itemCount").toInt(); }; + connect(&model, &FolderModel::loaded, &model, [&countAtLanding, count] { countAtLanding << count(); }); + model.setQuery("r"); + QTRY_COMPARE(count(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(model.indexOfPath(dir.filePath("rock.txt")) >= 0); + + countAtLanding.clear(); + model.setQuery("re"); + QTRY_VERIFY(!countAtLanding.isEmpty()); + QCOMPARE(model.indexOfPath(dir.filePath("rock.txt")), -1); + QCOMPARE(countAtLanding.first(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + + // Changed since: the count it had shows when the rows land, and the one it has now once it is counted. + write(dir.filePath("a/render/three.txt"), "x"); + countAtLanding.clear(); + model.setQuery("r"); + QTRY_VERIFY(!countAtLanding.isEmpty()); + QCOMPARE(countAtLanding.first(), 2); + QTRY_COMPARE(count(), 3); + QTRY_VERIFY(!model.reading() && !model.loading()); + + // Hidden files shown: what was counted without them says nothing of the folder now. + countAtLanding.clear(); + model.setShowHidden(true); + QTRY_COMPARE(count(), 4); + QVERIFY(!countAtLanding.contains(3)); + QTRY_VERIFY(!model.reading() && !model.loading()); + // And the count made with them is kept by the next answer. + countAtLanding.clear(); + model.setQuery("re"); + QTRY_VERIFY(!countAtLanding.isEmpty()); + QCOMPARE(model.indexOfPath(dir.filePath("rock.txt")), -1); + QCOMPARE(countAtLanding.first(), 4); + } + void searchEndedShowsTheFolderAndSaysSoInOneTurn() { // The search's end puts the folder's remembered rows up and says so at once, so a view has its cursor on a row in the frame that shows them. @@ -3031,6 +3083,102 @@ private slots: QTRY_COMPARE(model.index(last).data(FolderModel::ItemCountRole).toInt(), 1); } + void folderModelCountsAgainAFolderWhoseCountItKept() + { + // Rows that replace others keep a folder's count in sight, and the folder is counted again whatever overtakes that counting. + QTemporaryDir dir, other; + QVERIFY(QDir(dir.path()).mkpath("Photos/.cache") && QDir(dir.path()).mkpath("Albums")); + for (const char *name : {"Photos/a.jpg", "Photos/b.jpg", "Albums/one.jpg", "notes.txt"}) + write(dir.filePath(name), "x"); + const QString photos = dir.filePath("Photos"); + // Declared ahead of the model, which announces as it ends. + QList countAtLanding, countsSeen; + QMetaObject::Connection once; + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + const auto count = [&model, &photos] { return model.get(model.indexOfPath(photos)).value("itemCount").toInt(); }; + const auto notes = [&model, &dir] { return model.get(model.indexOfPath(dir.filePath("notes.txt"))).value("size"); }; + const auto albums = [&model, &dir] { return model.get(model.indexOfPath(dir.filePath("Albums"))).value("itemCount").toInt(); }; + QTRY_COMPARE(count(), 2); + QTRY_COMPARE(albums(), 1); + QTRY_COMPARE(notes().toLongLong(), 1); + QTRY_VERIFY(!model.loading()); + connect(&model, &FolderModel::loaded, &model, [&countAtLanding, count] { countAtLanding << count(); }); + + // Read again before the count is in: the same rows land, and the folder is counted all the same. + write(dir.filePath("Photos/c.jpg"), "x"); + write(dir.filePath("more.txt"), "x"); + once = connect(&model, &FolderModel::loaded, &model, [&model, &once] { + disconnect(once); + model.reload(); + }); + model.reload(); + QTRY_VERIFY(!countAtLanding.isEmpty()); + QCOMPARE(countAtLanding.first(), 2); + QTRY_COMPARE(count(), 3); + QTRY_COMPARE(albums(), 1); + QTRY_VERIFY(!model.loading()); + + // Counted, it isn't counted again by a later read of these rows: only the folder that changed is. + { + QSignalSpy changed(&model, &FolderModel::dataChanged); + write(dir.filePath("Albums/two.jpg"), "x"); + model.reload(); + QTRY_COMPARE(albums(), 2); + for (const QList &change : changed) + QVERIFY(change.at(0).toModelIndex().row() != model.indexOfPath(photos)); + } + QTRY_VERIFY(!model.loading()); + + // Left before the count is in, and a file changed meanwhile: back on its remembered rows, the folder is counted. + write(dir.filePath("Photos/d.jpg"), "x"); + write(dir.filePath("less.txt"), "x"); + once = connect(&model, &FolderModel::loaded, &model, [&model, &once, &other] { + disconnect(once); + QMetaObject::invokeMethod(&model, [&model, &other] { model.setPath(other.path()); }, Qt::QueuedConnection); + }); + model.reload(); + QTRY_COMPARE(model.path(), other.path()); + QTRY_VERIFY(!model.loading()); + write(dir.filePath("notes.txt"), "xx"); + model.setPath(dir.path()); + QCOMPARE(count(), 3); + QTRY_COMPARE(notes().toLongLong(), 2); + QTRY_COMPARE(count(), 4); + QTRY_VERIFY(!model.loading()); + + // Hidden files shown, and the rows are the same: the count made without them goes at once, said to the views, and the folder is counted with them. + countAtLanding.clear(); + QSignalSpy changed(&model, &FolderModel::dataChanged); + model.setShowHidden(true); + QCOMPARE(count(), -1); + QVERIFY(!changed.isEmpty() && changed.first().at(2).value>() == QList{FolderModel::ItemCountRole}); + QTRY_VERIFY(!countAtLanding.isEmpty()); + QCOMPARE(countAtLanding.first(), -1); + QTRY_COMPARE(count(), 5); + QTRY_VERIFY(!model.loading()); + + // Hidden again while a count made with hidden files waits to land: that count is dropped, never shown. + write(dir.filePath("Photos/e.jpg"), "x"); + write(dir.filePath("Photos/f.jpg"), "x"); + connect(&model, &FolderModel::dataChanged, &model, [&countsSeen, count] { countsSeen << count(); }); + once = connect(&model, &FolderModel::loaded, &model, [&model, &once] { + disconnect(once); + // Queued, this runs after the count is started and ahead of its landing; the wait lets the count finish first. + QMetaObject::invokeMethod(&model, [&model] { + QThreadPool::globalInstance()->waitForDone(); + model.setShowHidden(false); + }, Qt::QueuedConnection); + }); + model.reload(); + QTRY_VERIFY(!model.showHidden()); + QTRY_COMPARE(count(), 6); + QTRY_VERIFY(!model.loading()); + QVERIFY(!countsSeen.contains(7)); + } + void folderModelKeepsTheDateAFolderWasCountedWith() { // A view's read of a folder's date lands after the folder was counted and then changed: the count's own date stays, so a re-read sees the change. From 049ce6755cc882046c5491f0bedc51862d373b8d Mon Sep 17 00:00:00 2001 From: GM Date: Fri, 13 Nov 2026 06:24:35 -0400 Subject: [PATCH 38/38] Keep a selection made again after a search key What is selected after a key is kept by that key's answer, but asking for the selection already there returned early and left the let-go standing: Select All pressed again while an answer was on its way was cleared by it. The let-go now ends at any ask, changed or not. --- src/foldermodel.cpp | 4 ++-- src/foldermodel.h | 2 +- tests/folio_tests.cpp | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index ab16bd8c..4156117e 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -1123,10 +1123,10 @@ QVariantMap FolderModel::toMap(const Entry &listed) { void FolderModel::setSelected(const QSet &paths) { if (paths.isEmpty()) m_unshown.clear(); + // Asked for since the key, changed or not, it is no longer the key's to let go. + m_leaving.clear(); if (paths == m_selected) return; - // Changed since the key, it is no longer the key's to let go. - m_leaving.clear(); m_selected = paths; if (!m_entries.isEmpty()) emit dataChanged(index(0), index(m_entries.size() - 1), {SelectedRole}); diff --git a/src/foldermodel.h b/src/foldermodel.h index 6a92fec1..2867b532 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -213,7 +213,7 @@ class FolderModel : public QAbstractListModel { QString m_path; QVector m_entries; QSet m_selected; - // What was selected when the query last changed, until the answer to it lands or the selection changes. + // What was selected when the query last changed, until the answer to it lands or a selection is asked for. QSet m_leaving; void setSelected(const QSet &paths); bool m_loading = false; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index d9793c5a..4a0af5ef 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -2271,6 +2271,30 @@ private slots: model.toggleSelected(model.indexOfPath(dir.filePath("river.txt"))); QTRY_COMPARE(model.matches(), 1); QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); + + // The same selection asked for again after the key, with nothing changed, is not the key's to let go either. + model.setQuery("r"); + QTRY_COMPARE(model.matches(), 2); + QTRY_VERIFY(!model.reading() && !model.loading()); + model.selectAll(); + QCOMPARE(model.selectedCount(), 2); + selectedAtLanding.clear(); + model.setQuery("re"); + model.selectAll(); + QTRY_VERIFY(!selectedAtLanding.isEmpty()); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.matches(), 2); + QCOMPARE(model.selectedCount(), 2); + QCOMPARE(*std::min_element(selectedAtLanding.cbegin(), selectedAtLanding.cend()), 2); + + // Asked for again, it is still cut to what the answer matches. + selectedAtLanding.clear(); + model.setQuery("ri"); + model.selectAll(); + QTRY_VERIFY(!selectedAtLanding.isEmpty()); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.matches(), 1); + QCOMPARE(model.selectedPaths(), QStringList{dir.filePath("river.txt")}); } void searchKeepsAFoldersCountFromOneAnswerToTheNext()