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/Format.js b/src/Format.js index a359f60e..f572cbd5 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 @@ -38,6 +40,20 @@ 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; `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 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) +} + // 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) { @@ -47,19 +63,25 @@ 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) } // 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/src/PathBar.qml b/src/PathBar.qml index 47c25d28..2301350a 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. @@ -41,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 @@ -93,11 +88,13 @@ 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 - // Where it searches, at the end of the field. Unlike a - // placeholder, it stays while typing. Plugins' views have - // nothing below them. + 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 // Recents, Trash and plugins' views are named in C++, which @@ -109,14 +106,25 @@ 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) + // 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. + ? 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.mostRead) + : qsTr("Search %1 and below").arg(here) 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/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 2cce5a3f..4156117e 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -1,9 +1,11 @@ #include "foldermodel.h" +#include "searchindex.h" #include #include #include #include +#include #include #include #include @@ -11,10 +13,20 @@ #include #include #include +#include +#include #include #include +#include +#include +#include #include +// 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; + 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. @@ -32,12 +44,20 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { m_staleClear.setSingleShot(true); m_staleClear.setInterval(150); connect(&m_staleClear, &QTimer::timeout, this, &FolderModel::clearEntries); + // 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); + 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 // running for this model. FolderModel::~FolderModel() { *m_latest = 0; + closeSearch(); } // Recent listings, shared by every model (the window's folder and each @@ -67,18 +87,61 @@ 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; + next.countKept = now.countKept; + if (next.unread && !now.unread) { + // Read since the re-read began. + next.unread = false; + next.size = now.size; + next.modified = now.modified; + // 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; + // 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. + if (changed.isEmpty()) + return; + 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}); + } + // 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(); + m_owed.clear(); endResetModel(); emit countChanged(); } @@ -97,15 +160,20 @@ 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; 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 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; @@ -122,12 +190,122 @@ 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); +} + +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; + 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 (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. + 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; + } + // 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; 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(); - 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) { @@ -135,21 +313,33 @@ void FolderModel::setSortMode(const QString &sort) { return; m_sort = sort; emit sortModeChanged(); - reload(); + read(); } 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(); - setSelected({}); - // Back to the folder: its remembered listing shows at once. + // 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()) { - if (const auto *cached = recentListings().object(cacheKey())) + 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(); + } } - reload(); + 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) { @@ -166,17 +356,20 @@ 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()) 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()) { m_query.clear(); emit queryChanged(); } + m_shownRows = 0; setSelected({}); // The old folder stays up while the new one is read, which locally takes @@ -207,23 +400,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 @@ -237,7 +430,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 whole) { if (path == recentPath()) { // Recents are always most recently used first; that's what they're for. return listRecent(recentFile(), showHidden, 200, stop); @@ -256,25 +449,74 @@ 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; - QDirIterator it(path, entryFilters(showHidden)); - while (it.hasNext()) { + // One string per type and extension, shared by every entry that has it. + QSet shared; + // 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 {}; - entries.push_back(entryFor(it.nextFileInfo(), mimes)); + Entry e; + // 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); + } else { + // 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.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; } +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 = e.isDir ? QStringLiteral("inode/directory") + : QMimeDatabase().mimeTypeForFile(e.name, QMimeDatabase::MatchExtension).name(); + e.kind = kindForMime(e.mime); + if (!e.isDir) + 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(); 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") - : 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; @@ -283,41 +525,11 @@ 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; + // Best first, and of matches as good, what's near before what's deep. + SearchIndex index; + if (!index.walk(root, showHidden, stop)) + return {}; + return index.find(query, sort, limit, stop, locale); } QStringList FolderModel::sortModes() { @@ -326,11 +538,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; @@ -345,10 +582,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), @@ -417,18 +689,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) { @@ -438,7 +718,121 @@ 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 a search. +struct Listing { + QVector entries; + QString error; + 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; + // 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. +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. + 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 { + return m_search ? std::shared_ptr(m_search, &m_search->index) : nullptr; +} + +void FolderModel::openSearch() { + // 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, again] { + watcher->deleteLater(); + if (search != (again ? m_next : m_search)) + return; // Ended, or another search started, while it was read. + search->landed = true; + 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; + m_search.reset(); + m_hold.stop(); + m_refresh.stop(); + setReading(false); + if (m_matches != -1 || m_mostRead != -1) { + m_matches = m_mostRead = -1; + emit matchesChanged(); + } +} + +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 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() { + if (!m_search) + read(); + else if (!m_search->landed || m_next) + m_again = true; + else + openSearch(); +} + +void FolderModel::read() { if (m_path.isEmpty()) return; const quint64 generation = ++m_generation; @@ -450,44 +844,196 @@ 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) || 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; - auto *watcher = new QFutureWatcher, QString>>(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + // 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(); + // 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); + // 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] { watcher->deleteLater(); if (generation != m_generation) return; // A newer listing replaced this one while it ran. m_staleClear.stop(); - const auto [entries, error] = watcher->result(); + // 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, 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); - if (!same) + 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); - if (!m_selected.isEmpty()) { + } 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; - 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); + // 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)) { + 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(); } } setError(error); - setLoading(false); + if (matches != m_matches || mostRead != m_mostRead) { + m_matches = matches; + m_mostRead = mostRead; + 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(); if (error.isEmpty()) remember(); countFolders(generation); }); - watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale] { - QString error; + 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; }; + 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; + } + QStringList names; + QString root; + { + QMutexLocker hold(&search->lock); + 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; + names = search->index.names(search->index.rows(0, 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(); + } + // 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; + })); +} + +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(); + m_shownRows = m_entries.size(); + emit countChanged(); + countFolders(generation); + }); + watcher->setFuture(QtConcurrent::run([search, from, latest, generation] { const auto overtaken = [&] { return *latest != generation; }; - auto entries = isPluginView(path) ? listItems(items, query) - : query.isEmpty() || path == recentPath() ? list(path, showHidden, sort, &error, overtaken, locale) - : search(path, query, showHidden, sort, overtaken, 1000, locale); - return qMakePair(entries, error); + 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); + search->index.counts.stats += more.size(); + return more; })); } @@ -496,8 +1042,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 || m_entries.at(row).countKept)) + folders.push_back({row, m_entries.at(row)}); if (folders.isEmpty()) return; @@ -509,11 +1055,21 @@ 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; + 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)) { + 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 @@ -530,6 +1086,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; @@ -540,21 +1099,32 @@ 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; } -QVariantMap FolderModel::toMap(const Entry &entry) { +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}, }; } 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; m_selected = paths; @@ -564,14 +1134,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); @@ -584,7 +1154,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); } @@ -592,14 +1162,26 @@ 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); + QStringList unshown; + // 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); + } + } + } + // Before the selection says it changed, so what it names is whole. + m_unshown = unshown; setSelected(paths); } @@ -617,7 +1199,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 681a2c7e..2867b532 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -18,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 @@ -32,6 +34,12 @@ 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) + // 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) public: enum Role { @@ -52,8 +60,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 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. @@ -62,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; }; @@ -71,6 +84,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; } @@ -85,6 +100,9 @@ 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; } + int mostRead() const { return m_mostRead; } + bool reading() const { return m_stillReading; } void setPath(const QString &path); Q_INVOKABLE void reload(); @@ -106,14 +124,22 @@ 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; + // 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 // 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. 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()); + const std::function &stop = {}, const QLocale &locale = QLocale(), + 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); // 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. @@ -123,6 +149,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(); @@ -136,12 +164,12 @@ 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`, 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()); + // What the open search has read, for the tests; null with no search open. + std::shared_ptr searchIndex() const; signals: void pathChanged(); @@ -152,22 +180,41 @@ class FolderModel : public QAbstractListModel { void sortModeChanged(); void queryChanged(); void itemsChanged(); + void matchesChanged(); + void readingChanged(); void selectionChanged(); void loaded(); private: + struct Search; + 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(); QString cacheKey() const; 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); void countFolders(quint64 generation); QString m_path; QVector m_entries; QSet m_selected; + // 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; QString m_error; @@ -175,12 +222,39 @@ class FolderModel : public QAbstractListModel { QString m_sort = "name"; 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; + // 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, // 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; + // 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; + // 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; + QTimer m_hold; + QTimer m_refresh; }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..8c32fec5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -40,6 +40,26 @@ "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" + }, + "%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" + }, "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/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/src/searchindex.cpp b/src/searchindex.cpp new file mode 100644 index 00000000..0d9a8e89 --- /dev/null +++ b/src/searchindex.cpp @@ -0,0 +1,723 @@ +#include "searchindex.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#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 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 == ' '; +} + +// 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]); +} + +// 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[] = "/-_. +"; + 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 << (DigitBit + c - '0'); + else if (mark) + bits |= 1ull << (MarkBit + (mark - marks)); + else + bits |= Unknown; + } + return bits; +} + +SearchMatcher::SearchMatcher(const QString &query) + : 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)); + // 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 { + 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); +} + +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; +} + +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; + 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, QMutex *lock) { + { + // 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; + // 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); + const Counts spent = counts; + *this = SearchIndex(); + counts = spent; + return false; + }; + // 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; + 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) { + // 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 ? 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; + // 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; + DIR *dir = same ? ::fdopendir(fd) : nullptr; + if (!dir) + ::close(fd); + Read read{parent, {}, {}, {}}; + while (const dirent *entry = dir ? ::readdir(dir) : nullptr) { + if (stop && stop()) { + ::closedir(dir); + return stopped(); + } + 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) { + ++read.asked; + struct stat child; + isDir = ::fstatat(::dirfd(dir), text, &child, AT_SYMLINK_NOFOLLOW) == 0 && S_ISDIR(child.st_mode); + } + 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 (full) + break; + if (!lock) { + add(); + } else if (lock->tryLock()) { + add(); + lock->unlock(); + } + if (afterFolder) + afterFolder(folder + 2); + } + 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(); + m_rows.squeeze(); + m_kinds.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) + m_kinds.capacity() + + 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) { + // Kept under three quarters full, so a probe ends soon. + if ((m_names.size() + 1) * 4 > m_lookup.size() * 3) { + // 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) + 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()); + // 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(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; + 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)); +} + +QStringList SearchIndex::names(const QVector &rows) const { + QStringList out; + out.reserve(rows.size()); + for (quint32 row : rows) + out << QFile::decodeName(path(row)); + return out; +} + +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; + const QDir dir(root); + for (const QString &name : names) { + if (stop && stop()) + return {}; + 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); + folded = foldedPastAscii(name(id)); + return folded; +} + +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; +} + +// 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 letters = (1ull << SearchMatcher::DigitBit) - 1; + if ((wanted | held) & SearchMatcher::Unknown) + return true; + const quint64 missing = wanted & ~held; + return !(missing & ~letters) && int(qPopulationCount(missing)) <= edits; +} + +int SearchIndex::score(quint32 id, QByteArray &folded) const { + 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) { + ++counts.answers; + // 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. + restamp(); + 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; + // 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); + 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; + } + 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 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; + // 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) + && m_matcher.reach(text(r.name, folded), got) == needle.size()) + found = m_matcher.folderPart() > 0 ? SearchMatcher::PendingAligned : SearchMatcher::PendingPath; + } + } + 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, + 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 aligned; + if (score == SearchMatcher::Pending || score == SearchMatcher::PendingPath) + return named; + 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 = 6; + +// 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; + ++j; + } + } + if (i == a.size() && j == b.size()) + return a.compare(b); + return i == a.size() ? -1 : 1; +} + +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; + 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); + 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) { + 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 new file mode 100644 index 00000000..0be95e72 --- /dev/null +++ b/src/searchindex.h @@ -0,0 +1,181 @@ +#pragma once + +#include "foldermodel.h" + +#include +#include +#include +#include +#include +#include +#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. +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 + 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; + // 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); + 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; + // 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; + // 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. + 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: 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; + 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; + 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 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. + 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; + 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 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; + // The row's path from the root, in the folder's own bytes. + QByteArray path(quint32 row) const; + 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 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`, 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, + 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; 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. + struct Folder { quint32 name, parent, length; quint64 bits; }; + 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: 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)); } + qsizetype pathLength(quint32 row) const; + 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; + 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; + // 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; + 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; + // Each folder's reach into the query, for the query its stamp is for. + QVector m_folderStamps; + QVector m_reaches; +}; diff --git a/src/theme.cpp b/src/theme.cpp index c3f44015..8ac494e0 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,35 @@ 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) { + 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(); - 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 +142,92 @@ 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 || ::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); + connect(m_hyprlandNotifier, &QSocketNotifier::activated, this, &Theme::readHyprlandEvents); +} + +void Theme::readHyprlandEvents() { + char chunk[4096]; + 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(); + // 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() { + 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(); + m_hyprlandLineTooLong = false; +} + 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..997c348b 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. @@ -49,6 +51,12 @@ 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; + // How long after Hyprland hung up the watch is taken up again, in milliseconds. + static constexpr int watchAgainAfter = 1000; signals: void changed(); @@ -57,6 +65,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 +77,12 @@ 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; + 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 21e81d07..4a0af5ef 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,6 +1,9 @@ +#include #include +#include #include #include +#include #include #include #include @@ -22,17 +25,28 @@ #include #include #include +#include +#include #include #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include #include +#include #include +#include #include +#include #include #include "backend.h" @@ -43,6 +57,7 @@ #include "places.h" #include "plugins.h" #include "previewer.h" +#include "searchindex.h" #include "theme.h" #include "thumbnails.h" #include "translator.h" @@ -446,6 +461,56 @@ static int lastWindowClosed() return qScopeGuard([old] { qputenv("PATH", old); }); } +// 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; + if (read == at) { + reached.release(); + go.acquire(); + go.release(); + } + }; + } + void letGo() { go.release(); } + ~WalksHeld() + { + go.release(); + QThreadPool::globalInstance()->waitForDone(); + SearchIndex::afterFolder = {}; + QThreadPool::globalInstance()->setMaxThreadCount(threads); + } +}; + +// 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) @@ -494,6 +559,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() @@ -536,6 +603,610 @@ 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 folderTypesOnlyWhatsAskedFor() + { + QTemporaryDir dir; + 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 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() : lookedUp); + for (int i = 0; i < whole.size(); ++i) { + QCOMPARE(untyped.at(i).path, whole.at(i).path); + 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 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::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 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. + 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 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; + 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" + " readonly property string page: Format.page(path, 256, modified, \"#101010\", \"#f0f0f0\")\n" + " property var thumbs: []\n" + " 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())); + 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, 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); + 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; @@ -543,6 +1214,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; @@ -561,6 +1263,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; @@ -628,6 +1372,1639 @@ private slots: QCOMPARE(found.first().name, QString("real/match.txt")); } + void searchAsksAboutFoldersAndShownRowsOnly() + { + // 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"); + 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 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. + 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. + 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. + 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"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + const std::weak_ptr held = model.searchIndex(); + 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); + } + 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(second.expired()); + + // The next search, and showing hidden files, read the folders again. + write(dir.filePath("Photos/beach2.jpg"), "x"); + model.setQuery("beach"); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 2); + model.setShowHidden(true); + QTRY_VERIFY(!model.reading() && !model.loading()); + QCOMPARE(model.count(), 3); + 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() + { + // 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"); + 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() + { + // 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"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.matches(), -1); + model.setQuery("match"); + QTRY_VERIFY(!model.reading() && !model.loading()); + 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 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"); + QTRY_VERIFY(!model.reading() && !model.loading()); + 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"); + QTRY_VERIFY(!model.reading() && !model.loading()); + 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 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); + 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()); + model.fetchMore({}); + QTRY_COMPARE(model.count(), 500); + model.selectAll(); + const QStringList all = model.selectedPaths(); + QCOMPARE(all.size(), 500); + + WalksHeld walks(2); + loaded.clear(); + const auto before = model.searchIndex(); + model.reload(); + 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)); + 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); + } + + 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.matches(), 500); + QCOMPARE(model.selectedCount(), 400); + QCOMPARE(model.selectedPaths().size(), 400); + } + + void searchReadAgainDuringADownloadKeepsItsRows() + { + // 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; + 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(2); + model.reload(); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + // 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("landing/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(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 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); + walks.letGo(); + QTRY_COMPARE(model.matches(), 3); + QTRY_VERIFY(!model.reading() && !model.loading()); + QVERIFY(QThreadPool::globalInstance()->waitForDone(5000)); + 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. + 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: 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); + QCOMPARE(model.selectedCount(), 500); + QCOMPARE(model.selectedPaths().size(), 500); + } + QTRY_VERIFY(model.searchIndex() != before); + QCOMPARE(model.matches(), 500); + QCOMPARE(model.selectedCount(), 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: 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); + } + 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)\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 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 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")}); + + // 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() + { + // 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. + 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. + 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. + 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()); + QList seen; + connect(&model, &FolderModel::selectionChanged, &model, [&] { seen << model.selectedPaths(); }); + model.selectAll(); + // 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() + { + // 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() + { + // 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"); + QTRY_VERIFY(!model.reading() && !model.loading()); + 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() + { + // 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"); + 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 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() + { + // 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); + 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); + // 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); + 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 searchShowsMatchesWhileItsStillReading() + { + // 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); + 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(model.reading()); + QVERIFY(walks.reached.tryAcquire(1, 3000)); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.reading()); + QVERIFY(model.loading()); + QCOMPARE(model.matches(), 2); + QCOMPARE(model.count(), 2); + walks.letGo(); + // 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); + // One answer for each time it showed. + 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; @@ -680,6 +3057,164 @@ 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 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 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. + 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; @@ -702,16 +3237,19 @@ 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(); @@ -728,7 +3266,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(); @@ -1528,6 +4066,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; @@ -1560,6 +4162,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 @@ -3417,6 +6074,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 @@ -3443,6 +6175,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 @@ -5834,16 +8581,327 @@ 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 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; + 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 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); + // 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); + 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 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 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; + 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() @@ -6080,12 +9138,237 @@ 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; + } + + // 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; + } + + // 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() + { + 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); 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) + { + 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)