From c27282e577a336f560d121516e360f2d85cee76c Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 12 Oct 2026 19:23:44 +0200 Subject: [PATCH] jfjoch_viewer: F5 and a 3 s poll refresh the Files panel The Files panel never showed files written after a directory was first listed when they came from another host (a detector writer on NFS/GPFS/ Lustre), because QFileSystemModel only learns about changes through its inotify watching, and there was no way to refresh it by hand. F5 (window-wide, only while the panel is visible) and a refresh button next to the root's browse button re-list the root and every expanded directory. A timer does the same every 3 s while the panel is visible and no mouse button is held, but re-lists only directories whose mtime changed since the last listing, so a steady tick costs one stat per expanded directory. QFileSystemModel has no rescan call; index() of a path it does not know adds that row in place, so only the names missing from the model are passed (index() is ~10 us a call; a CBF sweep is thousands of files). Selection and expansion are untouched, new rows are sorted into place, and the first visible row is kept where it was when rows land above it. Entries deleted by another host still stay listed; local deletes are seen by the model's own watching as before. Measured offscreen on a 5000-file directory: unchanged tick ~1 ms, re-listing the changed 5000-file directory ~5 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- viewer/widgets/JFJochViewerFileBrowser.cpp | 81 ++++++++++++++++++++++ viewer/widgets/JFJochViewerFileBrowser.h | 12 ++++ 2 files changed, 93 insertions(+) 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();