diff --git a/Components/Menu.qml b/Components/Menu.qml index dc546c3..6a3db28 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -124,7 +124,7 @@ MouseArea { model: root.items CustomRect { - id: item + id: menuItem readonly property bool active: modelData === root?.active required property int index @@ -142,13 +142,13 @@ MouseArea { } StateLayer { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface enabled: root.expanded onClicked: { - root.itemSelected(item.modelData); - root.active = item.modelData; - item.modelData.clicked(); + root.itemSelected(menuItem.modelData); + root.active = menuItem.modelData; + menuItem.modelData.clicked(); root.expanded = false; } } @@ -162,26 +162,27 @@ MouseArea { MaterialIcon { Layout.alignment: Qt.AlignVCenter - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData?.icon ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData?.icon ?? "" + visible: text !== "" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - text: item.modelData?.text ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + text: menuItem.modelData?.text ?? "" } Loader { Layout.alignment: Qt.AlignVCenter - active: item.modelData?.trailingIcon.length > 0 + active: menuItem.modelData?.trailingIcon.length > 0 asynchronous: true visible: active sourceComponent: MaterialIcon { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData.trailingIcon + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData.trailingIcon } } } diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index 83b4ecc..a1a1e57 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -121,6 +121,6 @@ CustomRect { activeIcon: "storage" icon: modelData === Storage.primaryDisk ? "check" : "" - text: modelData.mount + text: modelData.name } } diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 2e99e62..73c7cce 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -25,6 +25,7 @@ qml_module(ZShell-services LIBRARIES Qt6::Core Qt6::Qml + Qt6::Concurrent PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava diff --git a/Plugins/ZShell/Services/diskinfo.cpp b/Plugins/ZShell/Services/diskinfo.cpp index dae8468..dfaff61 100644 --- a/Plugins/ZShell/Services/diskinfo.cpp +++ b/Plugins/ZShell/Services/diskinfo.cpp @@ -24,6 +24,18 @@ QString DiskInfo::mount() const { return m_mount; } +// Display label. mount stays the full path because it is the identity used to +// match entries across ticks, so this only shortens what the UI shows. For +// physical disks mount is already the device name and is returned unchanged. +QString DiskInfo::name() const { + const qsizetype slash = m_mount.lastIndexOf(QLatin1Char('/')); + if (slash < 0) { + return m_mount; + } + const QString leaf = m_mount.mid(slash + 1); + return leaf.isEmpty() ? m_mount : leaf; +} + qreal DiskInfo::used() const { return static_cast(m_usedBytes) / kKib; } diff --git a/Plugins/ZShell/Services/diskinfo.hpp b/Plugins/ZShell/Services/diskinfo.hpp index 3b51b0b..4bae0bb 100644 --- a/Plugins/ZShell/Services/diskinfo.hpp +++ b/Plugins/ZShell/Services/diskinfo.hpp @@ -11,6 +11,7 @@ class DiskInfo : public QObject { QML_UNCREATABLE("DiskInfo is created by DiskUsage") Q_PROPERTY(QString mount READ mount CONSTANT) + Q_PROPERTY(QString name READ name CONSTANT) Q_PROPERTY(qreal used READ used NOTIFY usedChanged) Q_PROPERTY(qreal total READ total NOTIFY totalChanged) Q_PROPERTY(qreal free READ free NOTIFY freeChanged) @@ -26,6 +27,7 @@ class DiskInfo : public QObject { QObject* parent = nullptr); [[nodiscard]] QString mount() const; + [[nodiscard]] QString name() const; [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; [[nodiscard]] qreal free() const; diff --git a/Plugins/ZShell/Services/storage.cpp b/Plugins/ZShell/Services/storage.cpp index 4e9d52f..5a3884a 100644 --- a/Plugins/ZShell/Services/storage.cpp +++ b/Plugins/ZShell/Services/storage.cpp @@ -5,9 +5,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -17,12 +20,27 @@ namespace ZShell::services { namespace { +// A scan that finishes quickly is not worth showing as loading. Only a scan that +// stays in flight this long is reported, so the UI does not flicker every tick. +constexpr int kLoadingDelayMs = 300; + struct Accum { quint64 usedBytes = 0; quint64 totalBytes = 0; bool hasRoot = false; }; +QThreadPool* storagePool() { + // Single thread: a scan blocked on an unreachable mount should not take a + // worker away from the global pool. + static QThreadPool* pool = [] { + auto* p = new QThreadPool(); + p->setMaxThreadCount(1); + return p; + }(); + return pool; +} + [[nodiscard]] QString sysfsRealPath(uint major, uint minor) { const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor); @@ -103,7 +121,14 @@ QStringList resolveByDevt(uint major, uint minor, int depth) { } // namespace -Storage::Storage(QObject* parent) : TickingService(parent) {} +Storage::Storage(QObject* parent) : TickingService(parent) { + m_loadingTimer.setSingleShot(true); + QObject::connect(&m_loadingTimer, &QTimer::timeout, this, [this] { + if (m_watcher) { + setLoading(true); + } + }); +} qreal Storage::percentage() const { qreal totalUsed = 0.0; @@ -115,6 +140,18 @@ qreal Storage::percentage() const { return totalSize > 0.0 ? totalUsed / totalSize : 0.0; } +bool Storage::loading() const { + return m_loading; +} + +void Storage::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + Q_EMIT loadingChanged(); +} + bool Storage::sameOrder(const QList& a, const QList& b) { if (a.size() != b.size()) { return false; @@ -162,18 +199,41 @@ DiskInfo* Storage::primaryDisk() const { bool Storage::isPseudoFs(QByteArrayView fsType) { static constexpr const char* kPseudo[] = { - "tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", - "cgroup2", "overlay", "squashfs", "devpts", "mqueue", - "ramfs", "rpc_pipefs", "autofs", "configfs", "debugfs", - "tracefs", "securityfs", "pstore", "bpf", "binfmt_misc", - "hugetlbfs", "fusectl", "efivarfs", "selinuxfs", + "tmpfs", + "devtmpfs", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "overlay", + "squashfs", + "devpts", + "mqueue", + "ramfs", + "rpc_pipefs", + "autofs", + "configfs", + "debugfs", + "tracefs", + "securityfs", + "pstore", + "bpf", + "binfmt_misc", + "hugetlbfs", + "fusectl", + "efivarfs", + "selinuxfs", + // Real FUSE mounts (rclone, sshfs, ...) are reported like any other + // volume, so only the virtual ones are excluded here. + "fuse.gvfsd-fuse", + "fuse.portal", }; for (const char* p : kPseudo) { if (fsType == QByteArrayView(p)) { return true; } } - return fsType.startsWith(QByteArrayView("fuse.")); + return false; } QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { @@ -190,18 +250,14 @@ QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { return resolveByDevt(major(st.st_rdev), minor(st.st_rdev)); } -void Storage::tick() { - const qreal prevPercentage = percentage(); - QHash byDisk; - - // Multiple mounts can share a single backing filesystem (btrfs subvolumes, - // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. - // Dedupe by source device so the filesystem only contributes once per disk. +StorageScan Storage::scanVolumes() { struct DeviceEntry { quint64 totalBytes = 0; quint64 usedBytes = 0; bool hasRoot = false; - QByteArray device; + // Mount point of the first mount seen for this device. Used as the entry + // key when the device has no backing block device (network mount). + QString mount; }; QHash byDevice; @@ -215,38 +271,120 @@ void Storage::tick() { continue; } - const QByteArray device = v.device(); const auto totalBytes = static_cast(v.bytesTotal()); const auto availBytes = static_cast(v.bytesAvailable()); const quint64 usedBytes = totalBytes > availBytes ? totalBytes - availBytes : 0; const bool isRoot = v.rootPath() == QStringLiteral("/"); - DeviceEntry& e = byDevice[device]; - e.device = device; + DeviceEntry& e = byDevice[v.device()]; e.totalBytes = totalBytes; e.usedBytes = usedBytes; e.hasRoot = e.hasRoot || isRoot; + if (e.mount.isEmpty()) { + e.mount = v.rootPath(); + } } + QHash byEntry; + + // Multiple mounts can share a single backing filesystem (btrfs subvolumes, + // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. + // Dedupe by source device so the filesystem only contributes once per disk. for (auto it = byDevice.constBegin(); it != byDevice.constEnd(); ++it) { const DeviceEntry& e = it.value(); const QStringList disks = - resolveToPhysicalDisks(QString::fromLocal8Bit(e.device)); + resolveToPhysicalDisks(QString::fromLocal8Bit(it.key())); if (disks.isEmpty()) { + // Network mounts (and any other filesystem with no backing block + // device) have no physical disk to attribute the usage to, so the + // mount itself becomes the entry. + Accum& a = byEntry[e.mount]; + a.usedBytes += e.usedBytes; + a.totalBytes += e.totalBytes; + a.hasRoot = a.hasRoot || e.hasRoot; continue; } for (const QString& d : disks) { if (d.startsWith(QStringLiteral("zram"))) { continue; } - Accum& a = byDisk[d]; + Accum& a = byEntry[d]; a.usedBytes += e.usedBytes; a.totalBytes += e.totalBytes; a.hasRoot = a.hasRoot || e.hasRoot; } } + StorageScan scan; + scan.reserve(byEntry.size()); + for (auto it = byEntry.constBegin(); it != byEntry.constEnd(); ++it) { + StorageEntry entry; + entry.mount = it.key(); + entry.usedBytes = it.value().usedBytes; + entry.totalBytes = it.value().totalBytes; + entry.hasRoot = it.value().hasRoot; + scan.append(entry); + } + + std::sort( + scan.begin(), + scan.end(), + [](const StorageEntry& a, const StorageEntry& b) { + if (a.hasRoot != b.hasRoot) { + return a.hasRoot; + } + return a.mount < b.mount; + }); + + return scan; +} + +void Storage::tick() { + if (m_watcher) { + // Still waiting on a mount that has not answered. Starting another scan + // here would just queue blocked threads behind the first. + return; + } + + beginScan(); +} + +void Storage::beginScan() { + m_loadingTimer.start(kLoadingDelayMs); + + m_watcher = new QFutureWatcher(this); + QObject::connect( + m_watcher, + &QFutureWatcher::finished, + this, + &Storage::onScanFinished); + + m_watcher->setFuture( + QtConcurrent::run(storagePool(), &Storage::scanVolumes)); +} + +void Storage::onScanFinished() { + QFutureWatcher* watcher = m_watcher; + m_watcher = nullptr; + if (!watcher) { + return; + } + StorageScan scan = watcher->result(); + watcher->deleteLater(); + + m_loadingTimer.stop(); + + // scan is already ordered (root disk first), so applying it in order keeps the + // QML model in the same order as before. + applyScan(scan); + + setLoading(false); +} + +void Storage::applyScan(const StorageScan& scan) { + const qreal prevPercentage = percentage(); + QHash existing; existing.reserve(m_disks.size()); for (DiskInfo* d : std::as_const(m_disks)) { @@ -254,32 +392,17 @@ void Storage::tick() { } QList next; - next.reserve(byDisk.size()); - for (auto it = byDisk.constBegin(); it != byDisk.constEnd(); ++it) { - if (DiskInfo* survivor = existing.take(it.key())) { - survivor->update( - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot); + next.reserve(scan.size()); + for (const StorageEntry& e : scan) { + if (DiskInfo* survivor = existing.take(e.mount)) { + survivor->update(e.usedBytes, e.totalBytes, e.hasRoot); next.append(survivor); } else { next.append(new DiskInfo( - it.key(), - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot, - this)); + e.mount, e.usedBytes, e.totalBytes, e.hasRoot, this)); } } - std::sort( - next.begin(), next.end(), [](const DiskInfo* a, const DiskInfo* b) { - if (a->hasRoot() != b->hasRoot()) { - return a->hasRoot(); - } - return a->mount() < b->mount(); - }); - bool manualCleared = false; if (DiskInfo* m = m_manualPrimaryDisk.data(); m && existing.contains(m->mount())) { diff --git a/Plugins/ZShell/Services/storage.hpp b/Plugins/ZShell/Services/storage.hpp index 0c61466..bd964ec 100644 --- a/Plugins/ZShell/Services/storage.hpp +++ b/Plugins/ZShell/Services/storage.hpp @@ -7,10 +7,27 @@ #include #include #include +#include +#include #include +#include + +template class QFutureWatcher; namespace ZShell::services { +struct StorageEntry { + // Key for the entry: sysfs block device name, or mount point for a mount with + // no backing block device (network mount). This is what DiskInfo reports as + // its mount, so the UI can show either. + QString mount; + quint64 usedBytes = 0; + quint64 totalBytes = 0; + bool hasRoot = false; +}; + +using StorageScan = QVector; + class Storage : public TickingService { Q_OBJECT QML_ELEMENT @@ -26,6 +43,7 @@ class Storage : public TickingService { Q_PROPERTY( ZShell::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) public: explicit Storage(QObject* parent = nullptr); @@ -35,12 +53,14 @@ class Storage : public TickingService { [[nodiscard]] DiskInfo* manualPrimaryDisk() const; void setManualPrimaryDisk(DiskInfo* disk); [[nodiscard]] DiskInfo* primaryDisk() const; + [[nodiscard]] bool loading() const; signals: void disksChanged(); void percentageChanged(); void manualPrimaryDiskChanged(); void primaryDiskChanged(); + void loadingChanged(); protected: void tick() override; @@ -55,8 +75,22 @@ class Storage : public TickingService { static qsizetype disksCount(QQmlListProperty* prop); static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + // Runs on a worker thread. statvfs on a mount whose backing storage is + // unreachable can block for the whole network timeout, so the scan must not + // run on the GUI thread. The result is plain data, applied on this object's + // thread once it arrives. + [[nodiscard]] static StorageScan scanVolumes(); + + void beginScan(); + void onScanFinished(); + void applyScan(const StorageScan& scan); + void setLoading(bool value); + QList m_disks; QPointer m_manualPrimaryDisk; + QFutureWatcher* m_watcher = nullptr; + QTimer m_loadingTimer; + bool m_loading = false; }; } // namespace ZShell::services