diff --git a/storybook/CMakeLists.txt b/storybook/CMakeLists.txt index 124a00621..550bb4f32 100644 --- a/storybook/CMakeLists.txt +++ b/storybook/CMakeLists.txt @@ -45,6 +45,7 @@ set(PROJECT_LIB "${PROJECT_NAME}Lib") add_library(${PROJECT_LIB} cachecleaner.cpp cachecleaner.h directorieswatcher.cpp directorieswatcher.h + directoryfileswatcher.cpp directoryfileswatcher.h figmaio.cpp figmaio.h figmalinks.cpp figmalinks.h figmalinksmodel.cpp figmalinksmodel.h @@ -96,6 +97,10 @@ add_executable(SectionsDecoratorModelTest tests/tst_SectionsDecoratorModel.cpp) target_link_libraries(SectionsDecoratorModelTest PRIVATE Qt5::Test ${PROJECT_LIB}) add_test(NAME SectionsDecoratorModelTest COMMAND SectionsDecoratorModelTest) +add_executable(DirectoryFilesWatcherTest tests/tst_DirectoryFilesWatcher.cpp) +target_link_libraries(DirectoryFilesWatcherTest PRIVATE Qt5::Test ${PROJECT_LIB}) +add_test(NAME DirectoryFilesWatcherTest COMMAND DirectoryFilesWatcherTest) + add_executable(QmlTests qmlTests/main.cpp qmlTests/src/TextUtils.cpp qmlTests/src/TextUtils.h diff --git a/storybook/directoryfileswatcher.cpp b/storybook/directoryfileswatcher.cpp new file mode 100644 index 000000000..f4fbfde10 --- /dev/null +++ b/storybook/directoryfileswatcher.cpp @@ -0,0 +1,81 @@ +#include "directoryfileswatcher.h" + +#include +#include + +DirectoryFilesWatcher::DirectoryFilesWatcher( + const QString &path, const QString &pattern, QObject *parent) + : QObject{parent}, m_path{path}, m_pattern{pattern}, + m_fsWatcher{new QFileSystemWatcher(this)} +{ + auto files = readDirectory(); + m_files.reserve(files.size()); + m_files.insert(std::move_iterator(files.begin()), + std::move_iterator(files.end())); + + m_fsWatcher->addPath(path); + + connect(m_fsWatcher, &QFileSystemWatcher::directoryChanged, + this, &DirectoryFilesWatcher::onDirectoryChanged); +} + +QStringList DirectoryFilesWatcher::files() const +{ + QStringList list; + list.reserve(m_files.size()); + + for (auto& [file, _] : m_files) + list << file; + + return list; +} + +std::vector> DirectoryFilesWatcher::readDirectory() const +{ + QDir dir(m_path); + dir.setFilter(QDir::Files); + dir.setNameFilters({m_pattern}); + + const QFileInfoList filesInfo = dir.entryInfoList(); + std::vector> files; + files.reserve(filesInfo.size()); + + std::transform(filesInfo.begin(), filesInfo.end(), + std::back_inserter(files), + [] (auto &info) { + return std::make_pair(info.filePath(), info.lastModified()); + }); + + return files; +} + +void DirectoryFilesWatcher::onDirectoryChanged() { + auto files = readDirectory(); + + QStringList added; + QStringList removed; + QStringList changed; + + for (auto& [file, date] : files) { + auto it = m_files.find(file); + + if (it == m_files.end()) { + added.push_back(file); + } else { + if (date != it->second) + changed.push_back(file); + + m_files.erase(it); + } + } + + for (auto& [file, date] : m_files) + removed.push_back(file); + + m_files.clear(); + m_files.reserve(files.size()); + m_files.insert(std::move_iterator(files.begin()), + std::move_iterator(files.end())); + + emit filesChanged(added, removed, changed); +} diff --git a/storybook/directoryfileswatcher.h b/storybook/directoryfileswatcher.h new file mode 100644 index 000000000..ff3f4f66d --- /dev/null +++ b/storybook/directoryfileswatcher.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include + +class QFileSystemWatcher; + +class DirectoryFilesWatcher : public QObject +{ + Q_OBJECT +public: + explicit DirectoryFilesWatcher(const QString &path, const QString &pattern, + QObject *parent = nullptr); + + QStringList files() const; + +signals: + void filesChanged(const QStringList& added, const QStringList& removed, + const QStringList& changed); + +private: + std::vector> readDirectory() const; + void onDirectoryChanged(); + + QString m_path; + QString m_pattern; + std::unordered_map m_files; + QFileSystemWatcher* m_fsWatcher; +}; diff --git a/storybook/pagesmodel.cpp b/storybook/pagesmodel.cpp index c4b763255..1d7fed067 100644 --- a/storybook/pagesmodel.cpp +++ b/storybook/pagesmodel.cpp @@ -1,10 +1,10 @@ #include "pagesmodel.h" -#include -#include +#include #include -#include +#include "directoryfileswatcher.h" + namespace { const auto categoryUncategorized QStringLiteral("Uncategorized"); @@ -12,62 +12,37 @@ const auto categoryUncategorized QStringLiteral("Uncategorized"); PagesModel::PagesModel(const QString &path, QObject *parent) : QAbstractListModel{parent}, m_path{path}, - fsWatcher(new QFileSystemWatcher(this)) + m_pagesWatcher(new DirectoryFilesWatcher( + path, QStringLiteral("*Page.qml"), this)) { - m_items = load(); - readMetadata(m_items); + m_items = readMetadata(m_pagesWatcher->files()); - for (const auto& item : qAsConst(m_items)) { + for (const auto& item : qAsConst(m_items)) setFigmaLinks(item.title, item.figmaLinks); - } - fsWatcher->addPath(path); - - connect(fsWatcher, &QFileSystemWatcher::directoryChanged, - this, &PagesModel::reload); + connect(m_pagesWatcher, &DirectoryFilesWatcher::filesChanged, + this, &PagesModel::onPagesChanged); } -QList PagesModel::load() const { - static QRegularExpression fileNameRegex( - QRegularExpression::anchoredPattern("(.*)Page\\.qml")); +PagesModelItem PagesModel::readMetadata(const QString& path) +{ + PagesModelItem item; + item.path = path; + item.title = QFileInfo(path).fileName().chopped( + (QStringLiteral("Page.qml").size())); - QDir dir(m_path); - dir.setFilter(QDir::Files); - - const QFileInfoList files = dir.entryInfoList(); - QList items; - - std::for_each(files.begin(), files.end(), [this, &items] (auto &fileInfo) { - QString fileName = fileInfo.fileName(); - QRegularExpressionMatch fileNameMatch = fileNameRegex.match(fileName); - - if (!fileNameMatch.hasMatch()) - return; - - PagesModelItem item; - item.path = fileInfo.filePath(); - item.title = fileNameMatch.captured(1); - item.lastModified = fileInfo.lastModified(); - - items << item; - }); - - return items; -} - -void PagesModel::readMetadata(PagesModelItem& item) { - static QRegularExpression categoryRegex( - "^//(\\s)*category:(.+)$", QRegularExpression::MultilineOption); - - QFile file(item.path); + QFile file(path); file.open(QIODevice::ReadOnly); QByteArray content = file.readAll(); + static QRegularExpression categoryRegex( + "^//(\\s)*category:(.+)$", QRegularExpression::MultilineOption); + QRegularExpressionMatch categoryMatch = categoryRegex.match(content); QString category = categoryMatch.hasMatch() ? categoryMatch.captured(2).trimmed() : categoryUncategorized; - item.category = category; + item.category = category.size() ? category : categoryUncategorized; static QRegularExpression figmaRegex( "^(\\/\\/\\s*)((?:https:\\/\\/)?(?:www\\.)?figma\\.com\\/.*)$", @@ -83,90 +58,78 @@ void PagesModel::readMetadata(PagesModelItem& item) { } item.figmaLinks = links; + + return item; } -void PagesModel::readMetadata(QList &items) { - std::for_each(items.begin(), items.end(), [](auto&item) { - readMetadata(item); +QList PagesModel::readMetadata(const QStringList &paths) +{ + QList metadata; + metadata.reserve(paths.size()); + + std::transform(paths.begin(), paths.end(), std::back_inserter(metadata), + [](auto& path) { + return readMetadata(path); }); + + return metadata; } -void PagesModel::reload() { - const QList currentItems = load(); - std::unordered_map mapping; +void PagesModel::onPagesChanged(const QStringList& added, + const QStringList& removed, + const QStringList& changed) +{ + for (auto& path : removed) { + auto index = getIndexByPath(path); - for (const PagesModelItem &item : qAsConst(m_items)) - mapping[item.title] = item; - - std::vector newItems; - std::vector changedItems; - std::vector removedItems; - - for (const auto &item : currentItems) { - auto it = mapping.find(item.title); - - if (it == mapping.end()) { - newItems.push_back(item); - } else { - if (item.lastModified != it->second.lastModified) - changedItems.push_back(item); - - mapping.erase(it); - } - } - - for (const auto& [key, value] : mapping) - removedItems.push_back(value); - - for (auto& item : removedItems) { - - auto it = std::find_if(m_items.begin(), m_items.end(), [&item](auto& it){ - return it.title == item.title; - }); - - auto index = std::distance(m_items.begin(), it); - - beginRemoveRows(QModelIndex{}, index, index); + beginRemoveRows({}, index, index); m_items.removeAt(index); endRemoveRows(); } - if (newItems.size()) { - beginInsertRows(QModelIndex{}, rowCount(), rowCount() + newItems.size() - 1); + if (added.size()) { + beginInsertRows({}, rowCount(), rowCount() + added.size() - 1); - for (auto& item : newItems) { - readMetadata(item); - m_items << item; - } + auto metadata = readMetadata(added); + for (const auto& item : qAsConst(metadata)) + setFigmaLinks(item.title, item.figmaLinks); + + m_items << metadata; endInsertRows(); } - for (auto& item : changedItems) { - auto it = std::find_if(m_items.begin(), m_items.end(), [&item](auto& it){ - return it.title == item.title; - }); + for (auto& path : changed) { + auto index = getIndexByPath(path); + const auto& previous = m_items.at(index); - auto index = std::distance(m_items.begin(), it); - const auto& previous = *it; - readMetadata(item); - setFigmaLinks(item.title, item.figmaLinks); + PagesModelItem metadata = readMetadata(path); + setFigmaLinks(metadata.title, metadata.figmaLinks); - if (previous.category != item.category) { + if (previous.category != metadata.category) { // For simplicity category change is handled by removing and // adding item. In the future it can be changed to regular dataChanged // event and handled properly in upstream models like SectionSDecoratorModel. - beginRemoveRows(QModelIndex{}, index, index); + beginRemoveRows({}, index, index); m_items.removeAt(index); endRemoveRows(); - beginInsertRows(QModelIndex{}, rowCount(), rowCount()); - m_items << item; + beginInsertRows({}, rowCount(), rowCount()); + m_items << metadata; endInsertRows(); } } } +int PagesModel::getIndexByPath(const QString& path) const +{ + auto it = std::find_if(m_items.begin(), m_items.end(), [&path](auto& it) { + return it.path == path; + }); + assert(it != m_items.end()); + return std::distance(m_items.begin(), it); +} + QHash PagesModel::roleNames() const { static const QHash roles { diff --git a/storybook/pagesmodel.h b/storybook/pagesmodel.h index 54bb8da65..e34325d9a 100644 --- a/storybook/pagesmodel.h +++ b/storybook/pagesmodel.h @@ -5,7 +5,7 @@ #include "figmalinksmodel.h" -class QFileSystemWatcher; +class DirectoryFilesWatcher; struct PagesModelItem { QString path; @@ -31,9 +31,15 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; - void reload(); + private: - QList load() const; + void onPagesChanged(const QStringList& added, const QStringList& removed, + const QStringList& changed); + + int getIndexByPath(const QString& path) const; + + static PagesModelItem readMetadata(const QString& path); + static QList readMetadata(const QStringList& paths); static void readMetadata(PagesModelItem &item); static void readMetadata(QList &items); @@ -43,5 +49,5 @@ private: QString m_path; QList m_items; QMap m_figmaSubmodels; - QFileSystemWatcher* fsWatcher; + DirectoryFilesWatcher* m_pagesWatcher; }; diff --git a/storybook/tests/tst_DirectoryFilesWatcher.cpp b/storybook/tests/tst_DirectoryFilesWatcher.cpp new file mode 100644 index 000000000..e20912713 --- /dev/null +++ b/storybook/tests/tst_DirectoryFilesWatcher.cpp @@ -0,0 +1,192 @@ +#include +#include + +#include +#include + + +class TestDirectoryFilesWatcher: public QObject +{ + Q_OBJECT + +private slots: + void emptyDirTest() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + DirectoryFilesWatcher watcher(dir.path(), "*"); + QVERIFY(watcher.files().empty()); + } + + void nonEmptyDirTest() + { + QTemporaryDir dir; + + QString filename = dir.path() + "/Data.txt"; + { + QFile file(filename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + QVERIFY(dir.isValid()); + + DirectoryFilesWatcher watcher(dir.path(), "*"); + QCOMPARE(watcher.files().size(), 1); + QCOMPARE(watcher.files().at(0), filename); + } + + void notifyAddTest() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + DirectoryFilesWatcher watcher(dir.path(), "*"); + + QSignalSpy changeSpy(&watcher, &DirectoryFilesWatcher::filesChanged); + + + QString filename = dir.path() + "/Data.txt"; + { + QFile file(filename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + changeSpy.wait(); + QCOMPARE(changeSpy.count(), 1); + QCOMPARE(changeSpy.at(0).at(0).toStringList(), { filename }); + QCOMPARE(changeSpy.at(0).at(1).toStringList(), { }); + QCOMPARE(changeSpy.at(0).at(2).toStringList(), { }); + } + + void notifyMultipleAddTest() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + DirectoryFilesWatcher watcher(dir.path(), "*"); + + QSignalSpy changeSpy(&watcher, &DirectoryFilesWatcher::filesChanged); + + + QString filename = dir.path() + "/Data.txt"; + { + QFile file(filename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + QString filename2 = dir.path() + "/Data2.txt"; + { + QFile file(filename2); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + changeSpy.wait(); + QCOMPARE(changeSpy.count(), 1); + QCOMPARE(changeSpy.at(0).at(0).toStringList(), + QStringList({ filename, filename2 })); + QCOMPARE(changeSpy.at(0).at(1).toStringList(), { }); + QCOMPARE(changeSpy.at(0).at(2).toStringList(), { }); + } + + void notifyRemoveTest() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QString filename = dir.path() + "/Data.txt"; + { + QFile file(filename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + QString filename2 = dir.path() + "/Data2.txt"; + { + QFile file(filename2); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + DirectoryFilesWatcher watcher(dir.path(), "*"); + + QSignalSpy changeSpy(&watcher, &DirectoryFilesWatcher::filesChanged); + + QVERIFY(QFile::remove(filename)); + + + changeSpy.wait(); + QCOMPARE(changeSpy.count(), 1); + QCOMPARE(changeSpy.at(0).at(0).toStringList(), { }); + QCOMPARE(changeSpy.at(0).at(1).toStringList(), { filename }); + QCOMPARE(changeSpy.at(0).at(2).toStringList(), { }); + } + + void notifyChangeTest() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QString filename = dir.path() + "/Data.txt"; + { + QFile file(filename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + QString filename2 = dir.path() + "/Data2.txt"; + { + QFile file(filename2); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something"; + } + } + + DirectoryFilesWatcher watcher(dir.path(), "*"); + + QSignalSpy changeSpy(&watcher, &DirectoryFilesWatcher::filesChanged); + + // wait a bit to have a different timestamp for tmp file + QTest::qSleep(5); + + QString tempraryFilename = dir.path() + "/Tmp.txt"; + { + QFile file(tempraryFilename); + if (file.open(QIODevice::ReadWrite)) { + QTextStream stream(&file); + stream << "something else"; + } + } + + QFile::remove(filename); + QFile::rename(tempraryFilename, filename); + + changeSpy.wait(); + QCOMPARE(changeSpy.count(), 1); + QCOMPARE(changeSpy.at(0).at(0).toStringList(), { }); + QCOMPARE(changeSpy.at(0).at(1).toStringList(), { }); + QCOMPARE(changeSpy.at(0).at(2).toStringList(), { filename }); + } +}; + +QTEST_MAIN(TestDirectoryFilesWatcher) +#include "tst_DirectoryFilesWatcher.moc"