Vfs: Add 'availability', a simplified, user-facing pin state #7111
authorChristian Kamm <mail@ckamm.de>
Wed, 3 Apr 2019 08:53:04 +0000 (10:53 +0200)
committerKevin Ottens <kevin.ottens@nextcloud.com>
Tue, 15 Dec 2020 09:58:47 +0000 (10:58 +0100)
The idea is that the user's question is "is this folder's data available
offline?" and not "does this folder have AlwaysLocal pin state?".
The the answers to the two questions can differ: an always-local
folder can have subitems that are not always-local and are dehydrated.

The new availability enum intends to describe the answer to the user's
actual question and can be derived from pin states. If pin states aren't
stored in the database the way of calculating availability will depend
on the vfs plugin.

src/common/pinstate.h
src/common/syncjournaldb.cpp
src/common/syncjournaldb.h
src/common/vfs.cpp
src/common/vfs.h
src/gui/accountsettings.cpp
src/gui/socketapi.cpp
src/libsync/vfs/suffix/vfs_suffix.cpp
src/libsync/vfs/suffix/vfs_suffix.h
test/testsyncjournaldb.cpp
test/testsyncvirtualfiles.cpp

index 053e7062f0e3f1f2371509aaa34851da2245b700..44ee12417089172fedd99183b3ce5fd97b43f0f3 100644 (file)
@@ -73,6 +73,47 @@ enum class PinState {
     Unspecified = 3,
 };
 
+/** A user-facing version of PinState.
+ *
+ * PinStates communicate availability intent for an item, but particular
+ * situations can get complex: An AlwaysLocal folder can have OnlineOnly
+ * files or directories.
+ *
+ * For users this is condensed to a few useful cases.
+ *
+ * Note that this is only about *intent*. The file could still be out of date,
+ * or not have been synced for other reasons, like errors.
+ */
+enum class VfsItemAvailability {
+    /** The item and all its subitems are hydrated and pinned AlwaysLocal.
+     *
+     * This guarantees that all contents will be kept in sync.
+     */
+    AlwaysLocal,
+
+    /** The item and all its subitems are hydrated.
+     *
+     * This may change if the platform or client decide to dehydrate items
+     * that have Unspecified pin state.
+     *
+     * A folder with no file contents will have this availability.
+     */
+    AllHydrated,
+
+    /** There are dehydrated items but the pin state isn't all OnlineOnly.
+     *
+     * This would happen if a dehydration happens to a Unspecified item that
+     * used to be hydrated.
+     */
+    SomeDehydrated,
+
+    /** The item and all its subitems are dehydrated and OnlineOnly.
+     *
+     * This guarantees that contents will not take up space.
+     */
+    OnlineOnly,
+};
+
 }
 
 #endif
index e613358a63075b40158b138178a9c364c2e27ca4..b22e041cecf42b7efa3e88c337104e233acd3384 100644 (file)
@@ -1320,6 +1320,31 @@ bool SyncJournalDb::updateLocalMetadata(const QString &filename,
     return _setFileRecordLocalMetadataQuery.exec();
 }
 
+Optional<bool> SyncJournalDb::hasDehydratedFiles(const QByteArray &filename)
+{
+    QMutexLocker locker(&_mutex);
+    if (!checkConnect())
+        return {};
+
+    auto &query = _countDehydratedFilesQuery;
+    static_assert(ItemTypeVirtualFile == 4 && ItemTypeVirtualFileDownload == 5, "");
+    if (!query.initOrReset(QByteArrayLiteral(
+            "SELECT count(*) FROM metadata"
+            " WHERE (" IS_PREFIX_PATH_OR_EQUAL("?1", "path") " OR ?1 == '')"
+            " AND (type == 4 OR type == 5);"), _db)) {
+        return {};
+    }
+
+    query.bindValue(1, filename);
+    if (!query.exec())
+        return {};
+
+    if (!query.next().hasData)
+        return {};
+
+    return query.intValue(0) > 0;
+}
+
 static void toDownloadInfo(SqlQuery &query, SyncJournalDb::DownloadInfo *res)
 {
     bool ok = true;
@@ -2152,7 +2177,7 @@ Optional<PinState> SyncJournalDb::PinStateInterface::effectiveForPath(const QByt
             // (it'd be great if paths started with a / and "/" could be the root)
             " (" IS_PREFIX_PATH_OR_EQUAL("path", "?1") " OR path == '')"
             " AND pinState is not null AND pinState != 0"
-            " ORDER BY length(path) DESC;"),
+            " ORDER BY length(path) DESC LIMIT 1;"),
         _db->_db));
     query.bindValue(1, path);
     query.exec();
@@ -2167,6 +2192,43 @@ Optional<PinState> SyncJournalDb::PinStateInterface::effectiveForPath(const QByt
     return static_cast<PinState>(query.intValue(0));
 }
 
+Optional<PinState> SyncJournalDb::PinStateInterface::effectiveForPathRecursive(const QByteArray &path)
+{
+    // Get the item's effective pin state. We'll compare subitem's pin states
+    // against this.
+    const auto basePin = effectiveForPath(path);
+    if (!basePin)
+        return {};
+
+    QMutexLocker lock(&_db->_mutex);
+    if (!_db->checkConnect())
+        return {};
+
+    // Find all the non-inherited pin states below the item
+    auto &query = _db->_getSubPinsQuery;
+    ASSERT(query.initOrReset(QByteArrayLiteral(
+            "SELECT DISTINCT pinState FROM flags WHERE"
+            " (" IS_PREFIX_PATH_OF("?1", "path") " OR ?1 == '')"
+            " AND pinState is not null and pinState != 0;"),
+        _db->_db));
+    query.bindValue(1, path);
+    query.exec();
+
+    // Check if they are all identical
+    forever {
+        auto next = query.next();
+        if (!next.ok)
+            return {};
+        if (!next.hasData)
+            break;
+        const auto subPin = static_cast<PinState>(query.intValue(0));
+        if (subPin != *basePin)
+            return PinState::Inherited;
+    }
+
+    return *basePin;
+}
+
 void SyncJournalDb::PinStateInterface::setForPath(const QByteArray &path, PinState state)
 {
     QMutexLocker lock(&_db->_mutex);
index cf0cf61a3717db7bafa23c2727e6669af3b7c07f..a689605559ebb5d0fd3b56f91ebb2ae814708406 100644 (file)
@@ -72,6 +72,10 @@ public:
         const QByteArray &contentChecksumType);
     bool updateLocalMetadata(const QString &filename,
         qint64 modtime, qint64 size, quint64 inode);
+
+    /** Returns whether the item or any subitems are dehydrated */
+    Optional<bool> hasDehydratedFiles(const QByteArray &filename);
+
     bool exists();
     void walCheckpoint();
 
@@ -284,6 +288,21 @@ public:
          */
         Optional<PinState> effectiveForPath(const QByteArray &path);
 
+        /**
+         * Like effectiveForPath() but also considers subitem pin states.
+         *
+         * If the path's pin state and all subitem's pin states are identical
+         * then that pin state will be returned.
+         *
+         * If some subitem's pin state is different from the path's state,
+         * PinState::Inherited will be returned. Inherited isn't returned in
+         * any other cases.
+         *
+         * It's valid to use the root path "".
+         * Returns none on db error.
+         */
+        Optional<PinState> effectiveForPathRecursive(const QByteArray &path);
+
         /**
          * Sets a path's pin state.
          *
@@ -386,6 +405,8 @@ private:
     SqlQuery _deleteConflictRecordQuery;
     SqlQuery _getRawPinStateQuery;
     SqlQuery _getEffectivePinStateQuery;
+    SqlQuery _getSubPinsQuery;
+    SqlQuery _countDehydratedFilesQuery;
     SqlQuery _setPinStateQuery;
     SqlQuery _wipePinStateQuery;
 
index a40b396a70be55419d0843257b22bbace67f82d6..5f170daf1f36a1439db6251fc92bed6cdea6dd62 100644 (file)
@@ -79,6 +79,27 @@ Optional<PinState> Vfs::pinStateInDb(const QString &folderPath)
     return _setupParams.journal->internalPinStates().effectiveForPath(folderPath.toUtf8());
 }
 
+Optional<VfsItemAvailability> Vfs::availabilityInDb(const QString &folderPath, const QString &pinPath)
+{
+    auto pin = _setupParams.journal->internalPinStates().effectiveForPathRecursive(pinPath.toUtf8());
+    // not being able to retrieve the pin state isn't too bad
+    Optional<bool> hasDehydrated = _setupParams.journal->hasDehydratedFiles(folderPath.toUtf8());
+    if (!hasDehydrated)
+        return {};
+
+    if (*hasDehydrated) {
+        if (pin && *pin == PinState::OnlineOnly)
+            return VfsItemAvailability::OnlineOnly;
+        else
+            return VfsItemAvailability::SomeDehydrated;
+    } else {
+        if (pin && *pin == PinState::AlwaysLocal)
+            return VfsItemAvailability::AlwaysLocal;
+        else
+            return VfsItemAvailability::AllHydrated;
+    }
+}
+
 VfsOff::VfsOff(QObject *parent)
     : Vfs(parent)
 {
@@ -184,3 +205,22 @@ std::unique_ptr<Vfs> OCC::createVfsFromPlugin(Vfs::Mode mode)
     qCInfo(lcPlugin) << "Created VFS instance from plugin" << pluginPath;
     return vfs;
 }
+
+QString OCC::vfsItemAvailabilityToString(VfsItemAvailability availability, bool forFolder)
+{
+    switch(availability) {
+    case VfsItemAvailability::AlwaysLocal:
+        return Vfs::tr("Always available locally");
+    case VfsItemAvailability::AllHydrated:
+        return Vfs::tr("Available locally");
+    case VfsItemAvailability::SomeDehydrated:
+        if (forFolder) {
+            return Vfs::tr("Some available online only");
+        } else {
+            return Vfs::tr("Available online only");
+        }
+    case VfsItemAvailability::OnlineOnly:
+        return Vfs::tr("Available online only");
+    }
+    ENFORCE(false);
+}
index 2ccd3958d36b97a797ed9e6edd3d190bbf984983..a8765d793c84aa8f05c96e7b6ff335cdae1e568b 100644 (file)
@@ -188,11 +188,13 @@ public:
     virtual bool statTypeVirtualFile(csync_file_stat_t *stat, void *stat_data) = 0;
 
     /** Sets the pin state for the item at a path.
+     *
+     * The pin state is set on the item and for all items below it.
      *
      * Usually this would forward to setting the pin state flag in the db table,
      * but some vfs plugins will store the pin state in file attributes instead.
      *
-     * folderPath is relative to the sync folder.
+     * folderPath is relative to the sync folder. Can be "" for root folder.
      */
     virtual bool setPinState(const QString &folderPath, PinState state) = 0;
 
@@ -201,10 +203,19 @@ public:
      * Usually backed by the db's effectivePinState() function but some vfs
      * plugins will override it to retrieve the state from elsewhere.
      *
-     * folderPath is relative to the sync folder.
+     * folderPath is relative to the sync folder. Can be "" for root folder.
      */
     virtual Optional<PinState> pinState(const QString &folderPath) = 0;
 
+    /** Returns availability status of an item at a path.
+     *
+     * The availability is a condensed user-facing version of PinState. See
+     * VfsItemAvailability for details.
+     *
+     * folderPath is relative to the sync folder. Can be "" for root folder.
+     */
+    virtual Optional<VfsItemAvailability> availability(const QString &folderPath) = 0;
+
 public slots:
     /** Update in-sync state based on SyncFileStatusTracker signal.
      *
@@ -235,6 +246,8 @@ protected:
     // Db-backed pin state handling. Derived classes may use it to implement pin states.
     bool setPinStateInDb(const QString &folderPath, PinState state);
     Optional<PinState> pinStateInDb(const QString &folderPath);
+    // sadly for virtual files the path in the metadata table can differ from path in 'flags'
+    Optional<VfsItemAvailability> availabilityInDb(const QString &folderPath, const QString &pinPath);
 
     // the parameters passed to start()
     VfsSetupParams _setupParams;
@@ -269,6 +282,7 @@ public:
 
     bool setPinState(const QString &, PinState) override { return true; }
     Optional<PinState> pinState(const QString &) override { return PinState::AlwaysLocal; }
+    Optional<VfsItemAvailability> availability(const QString &) override { return VfsItemAvailability::AlwaysLocal; }
 
 public slots:
     void fileStatusChanged(const QString &, SyncFileStatus) override {}
@@ -286,4 +300,7 @@ OCSYNC_EXPORT Vfs::Mode bestAvailableVfsMode();
 /// Create a VFS instance for the mode, returns nullptr on failure.
 OCSYNC_EXPORT std::unique_ptr<Vfs> createVfsFromPlugin(Vfs::Mode mode);
 
+/// Convert availability to translated string
+OCSYNC_EXPORT QString vfsItemAvailabilityToString(VfsItemAvailability availability, bool forFolder);
+
 } // namespace OCC
index 69ade78726d05122f78c1532c7359e64672b6e1c..05dd56a4daa1204f46b4c861a6603fd01b564ea0 100644 (file)
@@ -446,14 +446,18 @@ void AccountSettings::slotCustomContextMenuRequested(const QPoint &pos)
 
     if (folder->supportsVirtualFiles()) {
         auto availabilityMenu = menu->addMenu(tr("Availability"));
-        ac = availabilityMenu->addAction(tr("Local"));
-        ac->setCheckable(true);
-        ac->setChecked(!folder->newFilesAreVirtual());
+        auto availability = folder->vfs().availability(QString());
+        if (availability) {
+            ac = availabilityMenu->addAction(vfsItemAvailabilityToString(*availability, true));
+            ac->setEnabled(false);
+        }
+
+        ac = availabilityMenu->addAction(tr("Make always available locally"));
+        ac->setEnabled(!availability || *availability != VfsItemAvailability::AlwaysLocal);
         connect(ac, &QAction::triggered, this, [this]() { slotSetCurrentFolderAvailability(PinState::AlwaysLocal); });
 
-        ac = availabilityMenu->addAction(tr("Online only"));
-        ac->setCheckable(true);
-        ac->setChecked(folder->newFilesAreVirtual());
+        ac = availabilityMenu->addAction(tr("Free up local space"));
+        ac->setEnabled(!availability || *availability != VfsItemAvailability::OnlineOnly);
         connect(ac, &QAction::triggered, this, [this]() { slotSetCurrentFolderAvailability(PinState::OnlineOnly); });
 
         ac = menu->addAction(tr("Disable virtual file support..."));
index b96619d407a747aabe4f929fc22be7968c1d4e99..7dcccee2644233cedcc2843d2bb55a948b5dc335 100644 (file)
@@ -1042,68 +1042,61 @@ void SocketApi::command_GET_MENU_ITEMS(const QString &argument, OCC::SocketListe
     if (syncFolder
         && syncFolder->supportsVirtualFiles()
         && syncFolder->vfs().socketApiPinStateActionsShown()) {
-        bool hasAlwaysLocal = false;
-        bool hasOnlineOnly = false;
-        bool hasHydratedOnlineOnly = false;
-        bool hasDehydratedOnlineOnly = false;
+        ENFORCE(!files.isEmpty());
+
+        // Determine the combined availability status of the files
+        auto combined = Optional<VfsItemAvailability>();
+        auto merge = [](VfsItemAvailability lhs, VfsItemAvailability rhs) {
+            if (lhs == rhs)
+                return lhs;
+            if (lhs == VfsItemAvailability::SomeDehydrated || rhs == VfsItemAvailability::SomeDehydrated
+                || lhs == VfsItemAvailability::OnlineOnly || rhs == VfsItemAvailability::OnlineOnly) {
+                return VfsItemAvailability::SomeDehydrated;
+            }
+            return VfsItemAvailability::AllHydrated;
+        };
+        bool isFolderOrMultiple = false;
         for (const auto &file : files) {
             auto fileData = FileData::get(file);
-            auto path = fileData.folderRelativePathNoVfsSuffix();
-            auto pinState = syncFolder->vfs().pinState(path);
-            if (!pinState) {
-                // db error
-                hasAlwaysLocal = true;
-                hasOnlineOnly = true;
-            } else if (*pinState == PinState::AlwaysLocal) {
-                hasAlwaysLocal = true;
-            } else if (*pinState == PinState::OnlineOnly) {
-                hasOnlineOnly = true;
-                auto record = fileData.journalRecord();
-                if (record._type == ItemTypeFile)
-                    hasHydratedOnlineOnly = true;
-                if (record.isVirtualFile())
-                    hasDehydratedOnlineOnly = true;
+            isFolderOrMultiple = QFileInfo(fileData.localPath).isDir();
+            auto availability = syncFolder->vfs().availability(fileData.folderRelativePath);
+            if (!availability)
+                availability = VfsItemAvailability::SomeDehydrated; // db error
+            if (!combined) {
+                combined = availability;
+            } else {
+                combined = merge(*combined, *availability);
             }
         }
-
-        auto makePinContextMenu = [listener](QString currentState, QString availableLocally, QString onlineOnly) {
-            listener->sendMessage(QLatin1String("MENU_ITEM:CURRENT_PIN:d:") + currentState);
-            if (!availableLocally.isEmpty())
-                listener->sendMessage(QLatin1String("MENU_ITEM:MAKE_AVAILABLE_LOCALLY::") + availableLocally);
-            if (!onlineOnly.isEmpty())
-                listener->sendMessage(QLatin1String("MENU_ITEM:MAKE_ONLINE_ONLY::") + onlineOnly);
+        ENFORCE(combined);
+        if (files.size() > 1)
+            isFolderOrMultiple = true;
+
+        // TODO: Should be a submenu, should use icons
+        auto makePinContextMenu = [&](bool makeAvailableLocally, bool freeSpace) {
+            listener->sendMessage(QLatin1String("MENU_ITEM:CURRENT_PIN:d:")
+                                  + vfsItemAvailabilityToString(*combined, isFolderOrMultiple));
+            listener->sendMessage(QLatin1String("MENU_ITEM:MAKE_AVAILABLE_LOCALLY:")
+                                  + (makeAvailableLocally ? QLatin1String(":") : QLatin1String("d:"))
+                                  + tr("Make always available locally"));
+            listener->sendMessage(QLatin1String("MENU_ITEM:MAKE_ONLINE_ONLY:")
+                                  + (freeSpace ? QLatin1String(":") : QLatin1String("d:"))
+                                  + tr("Free up local space"));
         };
 
-        // TODO: Should be a submenu, should use menu item checkmarks where available, should use icons
-        if (hasAlwaysLocal) {
-            if (!hasOnlineOnly) {
-                makePinContextMenu(
-                    tr("Currently available locally"),
-                    QString(),
-                    tr("Make available online only"));
-            } else { // local + online
-                makePinContextMenu(
-                    tr("Current availability is mixed"),
-                    tr("Make all available locally"),
-                    tr("Make all available online only"));
-            }
-        } else if (hasOnlineOnly) {
-            if (hasDehydratedOnlineOnly && !hasHydratedOnlineOnly) {
-                makePinContextMenu(
-                    tr("Currently available online only"),
-                    tr("Make available locally"),
-                    QString());
-            } else if (hasHydratedOnlineOnly && !hasDehydratedOnlineOnly) {
-                makePinContextMenu(
-                    tr("Currently available, but marked online only"),
-                    tr("Make available locally"),
-                    tr("Make available online only"));
-            } else { // hydrated + dehydrated
-                makePinContextMenu(
-                    tr("Some currently available, all marked online only"),
-                    tr("Make available locally"),
-                    tr("Make available online only"));
-            }
+        switch (*combined) {
+        case VfsItemAvailability::AlwaysLocal:
+            makePinContextMenu(false, true);
+            break;
+        case VfsItemAvailability::AllHydrated:
+            makePinContextMenu(true, true);
+            break;
+        case VfsItemAvailability::SomeDehydrated:
+            makePinContextMenu(true, true);
+            break;
+        case VfsItemAvailability::OnlineOnly:
+            makePinContextMenu(true, false);
+            break;
         }
     }
 
index 9860c24b2d1c9c859c13f5733ceacce6dbdf25a3..0480d02b27c261751e1c2da7f00a2d588d3bfe35 100644 (file)
@@ -105,4 +105,13 @@ bool VfsSuffix::statTypeVirtualFile(csync_file_stat_t *stat, void *)
     return false;
 }
 
+Optional<VfsItemAvailability> VfsSuffix::availability(const QString &folderPath)
+{
+    const auto suffix = fileSuffix();
+    QString pinPath = folderPath;
+    if (pinPath.endsWith(suffix))
+        pinPath.chop(suffix.size());
+    return availabilityInDb(folderPath, pinPath);
+}
+
 } // namespace OCC
index 6c42bcfb4ea5979e57ae964e03857c37869ae0b0..5aadf54494c95a35b3dbb8eb6750c8776ba5832a 100644 (file)
@@ -51,6 +51,7 @@ public:
     { return setPinStateInDb(folderPath, state); }
     Optional<PinState> pinState(const QString &folderPath) override
     { return pinStateInDb(folderPath); }
+    Optional<VfsItemAvailability> availability(const QString &folderPath) override;
 
 public slots:
     void fileStatusChanged(const QString &, SyncFileStatus) override {}
index d8d663b9ce524cdca5c8de2cda969966011697c8..fd404559bced2078849b4918f06768c42818b52a 100644 (file)
@@ -336,6 +336,14 @@ private slots:
             }
             return *state;
         };
+        auto getRecursive = [&](const QByteArray &path) -> PinState {
+            auto state = _db.internalPinStates().effectiveForPathRecursive(path);
+            if (!state) {
+                QTest::qFail("couldn't read pin state", __FILE__, __LINE__);
+                return PinState::Inherited;
+            }
+            return *state;
+        };
         auto getRaw = [&](const QByteArray &path) -> PinState {
             auto state = _db.internalPinStates().rawForPath(path);
             if (!state) {
@@ -370,6 +378,7 @@ private slots:
         QCOMPARE(list->size(), 4 + 9 + 27);
 
         // Baseline direct checks (the fallback for unset root pinstate is AlwaysLocal)
+        QCOMPARE(get(""), PinState::AlwaysLocal);
         QCOMPARE(get("local"), PinState::AlwaysLocal);
         QCOMPARE(get("online"), PinState::OnlineOnly);
         QCOMPARE(get("inherit"), PinState::AlwaysLocal);
@@ -399,6 +408,20 @@ private slots:
         QCOMPARE(get("online/online/inherit"), PinState::OnlineOnly);
         QCOMPARE(get("online/online/nonexistant"), PinState::OnlineOnly);
 
+        // Spot check the recursive variant
+        QCOMPARE(getRecursive(""), PinState::Inherited);
+        QCOMPARE(getRecursive("local"), PinState::Inherited);
+        QCOMPARE(getRecursive("online"), PinState::Inherited);
+        QCOMPARE(getRecursive("inherit"), PinState::Inherited);
+        QCOMPARE(getRecursive("online/local"), PinState::Inherited);
+        QCOMPARE(getRecursive("online/local/inherit"), PinState::AlwaysLocal);
+        QCOMPARE(getRecursive("inherit/inherit/inherit"), PinState::AlwaysLocal);
+        QCOMPARE(getRecursive("inherit/online/inherit"), PinState::OnlineOnly);
+        QCOMPARE(getRecursive("inherit/online/local"), PinState::AlwaysLocal);
+        make("local/local/local/local", PinState::AlwaysLocal);
+        QCOMPARE(getRecursive("local/local/local"), PinState::AlwaysLocal);
+        QCOMPARE(getRecursive("local/local/local/local"), PinState::AlwaysLocal);
+
         // Check changing the root pin state
         make("", PinState::OnlineOnly);
         QCOMPARE(get("local"), PinState::AlwaysLocal);
index afb737c6ea3a0b627a08a5b501f398f62d3812bd..86845bc8ee0fd91eb535171bb8de67f0136168fe 100644 (file)
@@ -1024,6 +1024,66 @@ private slots:
         QVERIFY(!fakeFolder.currentLocalState().find("A/file2.nextcloud.nextcloud"));
         cleanup();
     }
+
+    void testAvailability()
+    {
+        FakeFolder fakeFolder{ FileInfo() };
+        auto vfs = setupVfs(fakeFolder);
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+
+        auto setPin = [&] (const QByteArray &path, PinState state) {
+            fakeFolder.syncJournal().internalPinStates().setForPath(path, state);
+        };
+
+        fakeFolder.remoteModifier().mkdir("local");
+        fakeFolder.remoteModifier().mkdir("local/sub");
+        fakeFolder.remoteModifier().mkdir("online");
+        fakeFolder.remoteModifier().mkdir("online/sub");
+        fakeFolder.remoteModifier().mkdir("unspec");
+        QVERIFY(fakeFolder.syncOnce());
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+
+        setPin("local", PinState::AlwaysLocal);
+        setPin("online", PinState::OnlineOnly);
+        setPin("unspec", PinState::Unspecified);
+
+        fakeFolder.remoteModifier().insert("file1");
+        fakeFolder.remoteModifier().insert("online/file1");
+        fakeFolder.remoteModifier().insert("online/file2");
+        fakeFolder.remoteModifier().insert("local/file1");
+        fakeFolder.remoteModifier().insert("local/file2");
+        fakeFolder.remoteModifier().insert("unspec/file1");
+        QVERIFY(fakeFolder.syncOnce());
+
+        // root is unspecified
+        QCOMPARE(*vfs->availability("file1"), VfsItemAvailability::AllHydrated);
+        QCOMPARE(*vfs->availability("local"), VfsItemAvailability::AlwaysLocal);
+        QCOMPARE(*vfs->availability("local/file1"), VfsItemAvailability::AlwaysLocal);
+        QCOMPARE(*vfs->availability("online"), VfsItemAvailability::OnlineOnly);
+        QCOMPARE(*vfs->availability("online/file1.nextcloud"), VfsItemAvailability::OnlineOnly);
+        QCOMPARE(*vfs->availability("unspec"), VfsItemAvailability::SomeDehydrated);
+        QCOMPARE(*vfs->availability("unspec/file1.nextcloud"), VfsItemAvailability::SomeDehydrated);
+
+        // Subitem pin states can ruin "pure" availabilities
+        setPin("local/sub", PinState::OnlineOnly);
+        QCOMPARE(*vfs->availability("local"), VfsItemAvailability::AllHydrated);
+        setPin("online/sub", PinState::Unspecified);
+        QCOMPARE(*vfs->availability("online"), VfsItemAvailability::SomeDehydrated);
+
+        triggerDownload(fakeFolder, "unspec/file1");
+        setPin("local/file2", PinState::OnlineOnly);
+        QVERIFY(fakeFolder.syncOnce());
+
+        QCOMPARE(*vfs->availability("unspec"), VfsItemAvailability::AllHydrated);
+        QCOMPARE(*vfs->availability("local"), VfsItemAvailability::SomeDehydrated);
+
+        vfs->setPinState("local", PinState::AlwaysLocal);
+        vfs->setPinState("online", PinState::OnlineOnly);
+        QVERIFY(fakeFolder.syncOnce());
+
+        QCOMPARE(*vfs->availability("online"), VfsItemAvailability::OnlineOnly);
+        QCOMPARE(*vfs->availability("local"), VfsItemAvailability::AlwaysLocal);
+    }
 };
 
 QTEST_GUILESS_MAIN(TestSyncVirtualFiles)