diff --git a/viewer/widgets/JFJochViewerFileBrowser.cpp b/viewer/widgets/JFJochViewerFileBrowser.cpp index cbeee4b13..4739968ca 100644 --- a/viewer/widgets/JFJochViewerFileBrowser.cpp +++ b/viewer/widgets/JFJochViewerFileBrowser.cpp @@ -9,15 +9,22 @@ #include #include #include +#include #include #include #include #include #include +#include +#include +#include +#include +#include namespace { constexpr auto kDataRootEnvVar = "JUNGFRAUJOCH_DATA_ROOT"; constexpr auto kSettingsKey = "fileBrowserRoot"; + constexpr int kRefreshIntervalMs = 3000; // The filter box narrows files only; a directory always passes. Filtering directories too would // hide everything below one whose own name does not match - including the files that do match - @@ -79,8 +86,12 @@ JFJochViewerFileBrowser::JFJochViewerFileBrowser(QWidget *parent) : QWidget(pare auto *browse_button = new QToolButton(this); browse_button->setText("…"); browse_button->setToolTip("Browse for a data root…"); + auto *refresh_button = new QToolButton(this); + refresh_button->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); + refresh_button->setToolTip("Refresh the file list (F5)"); root_row->addWidget(root_edit_); root_row->addWidget(browse_button); + root_row->addWidget(refresh_button); layout->addLayout(root_row); filter_edit_ = new QLineEdit(this); @@ -115,6 +126,18 @@ JFJochViewerFileBrowser::JFJochViewerFileBrowser(QWidget *parent) : QWidget(pare connect(root_edit_, &QLineEdit::editingFinished, this, &JFJochViewerFileBrowser::onRootEditingFinished); connect(filter_edit_, &QLineEdit::textChanged, proxy_, &QSortFilterProxyModel::setFilterFixedString); connect(tree_, &QTreeView::doubleClicked, this, &JFJochViewerFileBrowser::onDoubleClicked); + connect(refresh_button, &QToolButton::clicked, this, [this] { Refresh(); }); + // Window-wide, but a shortcut on a hidden widget never fires, so F5 does nothing while the panel is + // tabbed behind the Processing one. + connect(new QShortcut(QKeySequence(Qt::Key_F5), this), &QShortcut::activated, this, [this] { Refresh(); }); + + auto *refresh_timer = new QTimer(this); + connect(refresh_timer, &QTimer::timeout, this, [this] { + // Not while hidden, and not in the middle of a click or a scroll-bar drag. + if (isVisible() && QApplication::mouseButtons() == Qt::NoButton) + Refresh(false); + }); + refresh_timer->start(kRefreshIntervalMs); SetRoot(DefaultRoot()); } @@ -136,6 +159,64 @@ void JFJochViewerFileBrowser::ResetToDefaultRoot() { SetRoot(DefaultRoot()); } +void JFJochViewerFileBrowser::CollectExpanded(const QModelIndex &parent, QStringList &dirs) const { + for (int row = 0; row < proxy_->rowCount(parent); ++row) { + const QModelIndex child = proxy_->index(row, 0, parent); + if (tree_->isExpanded(child)) { + dirs.append(model_->filePath(proxy_->mapToSource(child))); + CollectExpanded(child, dirs); + } + } +} + +void JFJochViewerFileBrowser::Refresh(bool force) { + QStringList dirs{model_->rootPath()}; + CollectExpanded(tree_->rootIndex(), dirs); + const QPersistentModelIndex top = tree_->indexAt(QPoint(0, 0)); + + bool added = false; + for (const QString &path : dirs) { + // One stat per directory: a directory's mtime moves whenever an entry is added or removed, so an + // unchanged one - typically all but the one being written to - is not read at all. + const QDateTime mtime = QFileInfo(path).lastModified(); + if (!force && listed_mtime_.value(path) == mtime) + continue; + listed_mtime_[path] = mtime; + + const QModelIndex parent = model_->index(path); + QSet listed; + for (int row = 0; row < model_->rowCount(parent); ++row) + listed.insert(model_->fileName(model_->index(row, 0, parent))); + + // QFileSystemModel has no call to re-read a directory it has already listed, but index() of a path + // it does not know yet adds that path as a new row - in place, so the selection and the expanded + // directories stay as they are. Same filters as the model's own; only the names it lacks are + // passed, as index() costs ~10 us a call and a CBF sweep is thousands of files. There is no + // counterpart for removal: an entry deleted by another host stays listed (a local delete is seen + // by the model's own watching). + const QDir dir(path); + const QStringList names = dir.entryList(model_->nameFilters(), + QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot, + QDir::Unsorted); + for (const QString &name : names) { + if (!listed.contains(name)) { + model_->index(dir.filePath(name)); + added = true; + } + } + } + if (!added) + return; + // Rows added this way land at the end of their directory. Setting the same name filters again marks + // the model as needing a sort, which sort() then does at once instead of on the next event loop pass. + model_->setNameFilters(model_->nameFilters()); + model_->sort(0); + // A row sorted in above the first visible one would push everything below it down; keep the first + // visible row where it was - unless the view is scrolled to the very top, where a new row should show. + if (top.isValid() && tree_->verticalScrollBar()->value() > 0) + tree_->scrollTo(top, QAbstractItemView::PositionAtTop); +} + void JFJochViewerFileBrowser::FollowPath(const QString &path) { const QString absolute = QFileInfo(path).absoluteFilePath(); // Containment by path components, not by string prefix, so /data/foo2 is not inside /data/foo. diff --git a/viewer/widgets/JFJochViewerFileBrowser.h b/viewer/widgets/JFJochViewerFileBrowser.h index 067bffd6f..fddabea33 100644 --- a/viewer/widgets/JFJochViewerFileBrowser.h +++ b/viewer/widgets/JFJochViewerFileBrowser.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include class QLineEdit; class QTreeView; @@ -17,6 +19,11 @@ class QModelIndex; // which only lists a directory once it is expanded - so a root with hundreds of sibling directories // (e.g. an SLS beamline's /sls/mx/data/) costs one readdir at each level actually opened, never a // recursive scan of the whole tree. +// +// New files show up by themselves: F5 (or the refresh button) re-lists the root and every expanded +// directory at once, and a timer does the same every few seconds while the panel is visible. The model's +// own inotify watching covers a local disk, but not files written by another host onto NFS/GPFS/Lustre, +// which is exactly how a detector writer fills a beamline's data directory - hence the polling. class JFJochViewerFileBrowser : public QWidget { Q_OBJECT @@ -25,8 +32,11 @@ class JFJochViewerFileBrowser : public QWidget { QTreeView *tree_; QFileSystemModel *model_; QSortFilterProxyModel *proxy_; + // Modification time of each directory when it was last listed, so a poll re-lists only those that changed. + QHash listed_mtime_; void SetRoot(const QString &path); + void CollectExpanded(const QModelIndex &parent, QStringList &dirs) const; [[nodiscard]] static QString DefaultRoot(); // An SLS account eNNNNN browses its proposal group's raw data, which is pNNNNN with the // same five digits; empty for any other user name, on any platform. @@ -35,6 +45,8 @@ class JFJochViewerFileBrowser : public QWidget { public slots: // "Reset all settings to defaults": back to the root a fresh start would pick. void ResetToDefaultRoot(); + // Re-list the root and the expanded directories; unless forced, only those modified since last time. + void Refresh(bool force = true); private slots: void onBrowseClicked();