#include "PluginsWidget.h" #include "BSPluginInfo/PluginInfoDialog.h" #include "GUI/MessageDialog.h" #include "GUI/SelectionDialog.h" #include "MOPlugin/Settings.h" #include "MOTools/Loot.h" #include "PluginListContextMenu.h" #include "PluginSortFilterProxyModel.h" #include "ui_pluginswidget.h" #include #include #include #include #include #include #include #include using namespace Qt::Literals::StringLiterals; namespace BSPluginList { PluginsWidget::PluginsWidget(MOBase::IOrganizer* organizer, IPanelInterface* panelInterface, QWidget* parent) : QWidget(parent), ui{new Ui_PluginsWidget()}, m_PanelInterface{panelInterface}, m_Organizer{organizer} { ui->setupUi(this); m_PluginList = new TESData::PluginList(organizer); m_PluginListModel = new PluginListModel(m_PluginList); m_SortProxy = new PluginSortFilterProxyModel(); m_SortProxy->setSourceModel(m_PluginListModel); m_GroupProxy = new PluginGroupProxyModel(organizer); m_GroupProxy->setSourceModel(m_SortProxy); ui->pluginList->setModel(m_GroupProxy); ui->pluginList->setup(); ui->pluginList->sortByColumn(PluginListModel::COL_PRIORITY, Qt::AscendingOrder); ui->pluginList->expandAll(); optionsMenu = listOptionsMenu(); ui->listOptionsBtn->setMenu(optionsMenu); // LOOT integration stripped for Fluorine port — sort button is useless. ui->sortButton->setVisible(false); // monitor main window for close event topLevelWidget()->installEventFilter(this); restoreState(); connect(m_PluginList, &TESData::PluginList::pluginsListChanged, this, &PluginsWidget::updatePluginCount); connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, ui->pluginList, &PluginListView::updateOverwriteMarkers); connect(m_PluginListModel, &PluginListModel::pluginOrderChanged, ui->pluginList, &PluginListView::updateOverwriteMarkers); connect(m_PluginListModel, &QAbstractItemModel::modelReset, ui->pluginList, &PluginListView::clearOverwriteMarkers); connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, this, &PluginsWidget::updatePluginCount); connect(m_GroupProxy, &QAbstractItemModel::modelReset, [this]() { ui->pluginList->scrollToTop(); }); connect(ui->pluginList, &QTreeView::collapsed, this, &PluginsWidget::onGroupCollapsed); connect(ui->pluginList, &QTreeView::expanded, this, &PluginsWidget::onGroupExpanded); connect(ui->pluginList->selectionModel(), &QItemSelectionModel::selectionChanged, this, &PluginsWidget::onSelectionChanged); panelInterface->onPanelActivated( std::bind_front(&PluginsWidget::onPanelActivated, this)); panelInterface->onSelectedOriginsChanged( std::bind_front(&PluginsWidget::onSelectedOriginsChanged, this)); organizer->onAboutToRun(std::bind_front(&PluginsWidget::onAboutToRun, this)); organizer->onFinishedRun(std::bind_front(&PluginsWidget::onFinishedRun, this)); organizer->modList()->onModStateChanged( std::bind_front(&PluginsWidget::onModStateChanged, this)); Settings::instance()->onSettingChanged( std::bind_front(&PluginsWidget::onSettingChanged, this)); synchronizePluginLists(organizer); updatePluginCount(); } PluginsWidget::~PluginsWidget() noexcept { delete ui; delete optionsMenu; delete m_PluginList; delete m_PluginListModel; delete m_SortProxy; delete m_GroupProxy; } void PluginsWidget::updatePluginCount() { int activeMasterCount = 0; int activeMediumMasterCount = 0; int activeLightMasterCount = 0; int activeRegularCount = 0; int masterCount = 0; int mediumMasterCount = 0; int lightMasterCount = 0; int regularCount = 0; int activeVisibleCount = 0; const auto gameFeatures = m_Organizer->gameFeatures(); const auto tesSupport = gameFeatures ? gameFeatures->gameFeature() : nullptr; const bool lightPluginsAreSupported = tesSupport && tesSupport->lightPluginsAreSupported(); const bool mediumPluginsAreSupported = tesSupport && tesSupport->mediumPluginsAreSupported(); for (int i = 0, count = m_PluginListModel->rowCount(); i < count; ++i) { const auto index = m_PluginListModel->index(i, 0); const auto id = index.data(PluginListModel::IndexRole).toInt(); const auto info = m_PluginList->getPlugin(id); if (!info) continue; const bool active = info->enabled() || info->isAlwaysEnabled(); const bool visible = m_SortProxy->filterAcceptsRow(index.row(), index.parent()); if (info->isMediumFile()) { ++mediumMasterCount; activeMediumMasterCount += active ? 1 : 0; activeVisibleCount += visible && active ? 1 : 0; } else if (info->isSmallFile()) { ++lightMasterCount; activeLightMasterCount += active ? 1 : 0; activeVisibleCount += visible && active ? 1 : 0; } else if (info->isMasterFile()) { ++masterCount; activeMasterCount += active ? 1 : 0; activeVisibleCount += visible && active ? 1 : 0; } else { ++regularCount; activeRegularCount += active ? 1 : 0; activeVisibleCount += visible && active ? 1 : 0; } } const int activeCount = activeMasterCount + activeMediumMasterCount + activeLightMasterCount + activeRegularCount; const int totalCount = masterCount + mediumMasterCount + lightMasterCount + regularCount; ui->activePluginsCounter->display(activeVisibleCount); QString toolTip; toolTip.reserve(575); toolTip += uR"()"_s uR"()"_s.arg(tr("Type")) .arg(tr("Active"), -12) .arg(tr("Total")); const QString row = uR"()"_s uR"()"_s; toolTip += row.arg(tr("All plugins")).arg(activeCount).arg(totalCount); toolTip += row.arg(tr("ESMs")).arg(activeMasterCount).arg(masterCount); toolTip += row.arg(tr("ESPs")).arg(activeRegularCount).arg(regularCount); toolTip += row.arg(tr("ESMs+ESPs")) .arg(activeMasterCount + activeRegularCount) .arg(masterCount + regularCount); if (mediumPluginsAreSupported) toolTip += row.arg(tr("ESHs")).arg(activeMediumMasterCount).arg(mediumMasterCount); if (lightPluginsAreSupported) toolTip += row.arg(tr("ESLs")).arg(activeLightMasterCount).arg(lightMasterCount); toolTip += uR"(
%1%2%3
%1:%2 %3
)"_s; ui->activePluginsCounter->setToolTip(toolTip); } void PluginsWidget::on_espFilterEdit_textChanged(const QString& filter) { m_SortProxy->updateFilter(filter); if (!filter.isEmpty()) { setStyleSheet("QTreeView { border: 2px ridge #f00; }"); ui->activePluginsCounter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); } else { setStyleSheet(""); ui->activePluginsCounter->setStyleSheet(""); } updatePluginCount(); } bool PluginsWidget::eventFilter(QObject* watched, QEvent* event) { if (event->type() == QEvent::Close) { saveState(); m_PluginList->writePluginLists(); } return QWidget::eventFilter(watched, event); } void PluginsWidget::changeEvent(QEvent* event) { QWidget::changeEvent(event); switch (event->type()) { case QEvent::LanguageChange: ui->retranslateUi(this); break; default: break; } } void PluginsWidget::onGroupCollapsed(const QModelIndex& index) { if (ui->pluginList->selectionModel()->isSelected(index)) { onSelectionChanged(); } } void PluginsWidget::onGroupExpanded(const QModelIndex& index) { if (ui->pluginList->selectionModel()->isSelected(index)) { onSelectionChanged(); } } void PluginsWidget::onSelectionChanged() { QList selectedFiles; std::function addFiles; addFiles = [&](const QModelIndex& index) { if (index.model()->hasChildren(index)) { if (ui->pluginList->isExpanded(index)) { return; } for (int i = 0, count = index.model()->rowCount(index); i < count; ++i) { addFiles(index.model()->index(i, 0, index)); } } else { if (const auto info = index.data(PluginListModel::InfoRole).value()) { selectedFiles.append(info->name()); } } }; for (const auto& index : ui->pluginList->selectionModel()->selectedRows()) { addFiles(index); } m_PanelInterface->setSelectedFiles(selectedFiles); } void PluginsWidget::onPanelActivated() { m_PluginListModel->refresh(); } void PluginsWidget::onSelectedOriginsChanged(const QList& origins) { ui->pluginList->setHighlightedOrigins(origins); } void PluginsWidget::toggleHideForceEnabled() { const bool doHide = toggleForceEnabled->isChecked(); m_SortProxy->hideForceEnabledFiles(doHide); updatePluginCount(); Settings::instance()->set("hide_force_enabled", doHide); } void PluginsWidget::toggleIgnoreMasterConflicts() { const bool doIgnore = toggleIgnoreMasters->isChecked(); Settings::instance()->set("ignore_master_conflicts", doIgnore); m_PluginListModel->invalidateConflicts(); } constexpr auto PATTERN_BACKUP_GLOB = R"/(.????_??_??_??_??_??)/"; constexpr auto PATTERN_BACKUP_REGEX = R"/(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))/"; constexpr auto PATTERN_BACKUP_DATE = R"/(yyyy_MM_dd_hh_mm_ss)/"; static QString queryRestore(const QString& filePath, QWidget* parent = nullptr) { QFileInfo pluginFileInfo(filePath); QString pattern = pluginFileInfo.fileName() + ".*"; QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( QStringList(pattern), QDir::Files, QDir::Name); GUI::SelectionDialog dialog(QObject::tr("Choose backup to restore"), parent); QRegularExpression exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + PATTERN_BACKUP_REGEX)); QRegularExpression exp2( QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); for (const QFileInfo& info : boost::adaptors::reverse(files)) { auto match = exp.match(info.fileName()); auto match2 = exp2.match(info.fileName()); if (match.hasMatch()) { QDateTime time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE); dialog.addChoice(time.toString(), "", match.captured(1)); } else if (match2.hasMatch()) { dialog.addChoice(match2.captured(1), "", match2.captured(1)); } } if (dialog.numChoices() == 0) { QMessageBox::information(parent, QObject::tr("No Backups"), QObject::tr("There are no backups to restore")); return QString(); } if (dialog.exec() == QDialog::Accepted) { return dialog.getChoiceData().toString(); } else { return QString(); } } void PluginsWidget::displayPluginInformation(const QModelIndex& index) { const int id = index.data(PluginListModel::IndexRole).toInt(); const auto fileName = m_PluginList->getPlugin(id)->name(); const auto parent = topLevelWidget(); BSPluginInfo::PluginInfoDialog dialog{m_Organizer, m_PluginList, fileName, parent}; dialog.exec(); const bool ignoreMasters = Settings::instance()->get("ignore_master_conflicts", false); toggleIgnoreMasters->setChecked(ignoreMasters); m_PluginListModel->invalidateConflicts(); } void PluginsWidget::on_pluginList_customContextMenuRequested(const QPoint& pos) { PluginListContextMenu menu{ui->pluginList->indexAt(pos), m_PluginListModel, ui->pluginList, m_Organizer->modList(), m_PluginList}; connect(&menu, &PluginListContextMenu::openModInformation, [this](const QModelIndex& index) { const int id = index.data(PluginListModel::IndexRole).toInt(); const auto fileName = m_PluginList->getPlugin(id)->name(); m_PanelInterface->displayOriginInformation(fileName); }); connect(&menu, &PluginListContextMenu::openPluginInformation, this, &PluginsWidget::displayPluginInformation); const QPoint p = ui->pluginList->viewport()->mapToGlobal(pos); menu.exec(p); } void PluginsWidget::on_pluginList_doubleClicked(const QModelIndex& index) { bool ok; const int id = index.data(PluginListModel::IndexRole).toInt(&ok); if (ok) { Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); if (modifiers.testFlag(Qt::ControlModifier)) { const auto origin = m_PluginList->getOriginName(id); const auto modInfo = m_Organizer->modList()->getMod(origin); if (modInfo) { MOBase::shell::Explore(modInfo->absolutePath()); } } else { displayPluginInformation(index); } } else if (ui->pluginList->model()->hasChildren(index)) { ui->pluginList->setExpanded(index, !ui->pluginList->isExpanded(index)); } } void PluginsWidget::on_pluginList_openOriginExplorer(const QModelIndex& index) { const int id = index.data(PluginListModel::IndexRole).toInt(); const auto origin = m_PluginList->getOriginName(id); const auto modInfo = m_Organizer->modList()->getMod(origin); if (modInfo == nullptr) { return; } MOBase::shell::Explore(modInfo->absolutePath()); } void PluginsWidget::on_sortButton_clicked() { // LOOT integration stripped for Fluorine port. QMessageBox::information( topLevelWidget(), tr("Sorting plugins"), tr("LOOT integration is not available in this build.")); } static bool tryRestore(const QString& filePath, const QString& identifier, bool required, QWidget* parent = nullptr) { const auto backupName = filePath + "." + identifier; if (required || QFileInfo::exists(backupName)) { return MOBase::shellCopy(backupName, filePath, true, parent); } else { return !QFileInfo::exists(filePath) || MOBase::shellDeleteQuiet(filePath, parent); } } void PluginsWidget::on_restoreButton_clicked() { const auto app = this->topLevelWidget(); const auto profilePath = QDir(m_Organizer->profilePath()); const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); QString choice = queryRestore(pluginsName, app); if (!choice.isEmpty()) { const auto groupsName = QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); const auto loadOrderName = QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); if (!tryRestore(pluginsName, choice, true, app) || !tryRestore(loadOrderName, choice, true, app) || !tryRestore(groupsName, choice, false, app)) { const auto e = ::GetLastError(); QMessageBox::critical( this, tr("Restore failed"), tr("Failed to restore the backup. Errorcode: %1") .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); } m_PluginListModel->invalidate(); } } static bool createBackup(const QString& filePath, const QString& identifier, QWidget* parent = nullptr) { QString outPath = filePath + "." + identifier; if (MOBase::shellCopy(QStringList(filePath), QStringList(outPath), parent)) { QFileInfo fileInfo(filePath); MOBase::removeOldFiles(fileInfo.absolutePath(), fileInfo.fileName() + PATTERN_BACKUP_GLOB, 10, QDir::Name); return true; } else { return false; } } static bool createBackup(const QString& filePath, const QDateTime& time, QWidget* parent = nullptr) { return createBackup(filePath, time.toString(PATTERN_BACKUP_DATE), parent); } void PluginsWidget::on_saveButton_clicked() { m_PluginList->writePluginLists(); const auto app = this->topLevelWidget(); const auto profilePath = QDir(m_Organizer->profilePath()); const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); const auto groupsName = QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); const auto loadOrderName = QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); const auto lockedOrderName = QDir::cleanPath(profilePath.absoluteFilePath("lockedorder.txt")); const QDateTime now = QDateTime::currentDateTime(); if (createBackup(pluginsName, now, app) && createBackup(loadOrderName, now, app) && createBackup(groupsName, now, app) && createBackup(lockedOrderName, now, app)) { GUI::MessageDialog::showMessage(tr("Backup of load order created"), app); } } QMenu* PluginsWidget::listOptionsMenu() { QMenu* const menu = new QMenu(this); toggleForceEnabled = menu->addAction(tr("Hide force-enabled files"), this, &PluginsWidget::toggleHideForceEnabled); toggleForceEnabled->setCheckable(true); toggleIgnoreMasters = menu->addAction(tr("Ignore conflicts with masters"), this, &PluginsWidget::toggleIgnoreMasterConflicts); toggleIgnoreMasters->setCheckable(true); menu->addSeparator(); menu->addAction(tr("Collapse all"), [this]() { ui->pluginList->collapseAll(); ui->pluginList->scrollToTop(); }); menu->addAction(tr("Expand all"), [this]() { ui->pluginList->expandAll(); ui->pluginList->scrollToTop(); }); menu->addSeparator(); menu->addAction(tr("Enable all"), [this]() { if (QMessageBox::question(topLevelWidget(), tr("Confirm"), tr("Really enable all plugins?"), QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { m_PluginListModel->setEnabledAll(true); } }); menu->addAction(tr("Disable all"), [this]() { if (QMessageBox::question(topLevelWidget(), tr("Confirm"), tr("Really disable all plugins?"), QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { m_PluginListModel->setEnabledAll(false); } }); return menu; } void PluginsWidget::saveState() { auto* const settings = Settings::instance(); settings->saveState(ui->pluginList->header()); // Fluorine port: saveTreeExpandState recurses the model, which on window // close is being torn down by the time the close event fires → SIGSEGV // inside the visitRows traversal. Column widths above still save fine. // Tree expansion state is a minor UX loss; not worth the crash. } void PluginsWidget::restoreState() { const auto* const settings = Settings::instance(); settings->restoreState(ui->pluginList->header()); settings->restoreTreeExpandState(ui->pluginList); const bool doHide = settings->get("hide_force_enabled", false); toggleForceEnabled->setChecked(doHide); toggleHideForceEnabled(); const bool doIgnore = settings->get("ignore_master_conflicts", false); toggleIgnoreMasters->setChecked(doIgnore); toggleIgnoreMasterConflicts(); } static bool containsPlugin(const MOBase::IModInterface* mod, const MOBase::IPluginGame* game) { const auto fileTree = mod ? mod->fileTree() : nullptr; if (!fileTree) return false; std::shared_ptr searchDir; if (!game->modDataDirectory().isEmpty()) { searchDir = fileTree->findDirectory(game->modDataDirectory()); } else { searchDir = fileTree; } if (searchDir) { return std::ranges::any_of(*searchDir, [&](auto&& entry) { if (!entry) return false; const QString filename = entry->name(); return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) || filename.endsWith(u".esm"_s, Qt::CaseInsensitive) || filename.endsWith(u".esl"_s, Qt::CaseInsensitive); }); } return false; } void PluginsWidget::onModStateChanged( const std::map& mods) { // HACK: the virtual file tree won't update until the next refresh, so keep track of // any mods that might be newly activated const auto modList = m_Organizer->modList(); if (!modList) return; for (const auto& [modName, modState] : mods) { const auto mod = modList->getMod(modName); if (containsPlugin(mod, m_Organizer->managedGame())) { m_PluginList->notifyPendingState(modName, modState); } } m_PluginListModel->refresh(); } bool PluginsWidget::onAboutToRun([[maybe_unused]] const QString& binary) { m_PluginList->writePluginLists(); const auto profilePath = QDir(m_Organizer->profilePath()); const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); const auto loadOrderName = QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); const auto parent = this->topLevelWidget(); if (QFileInfo::exists(pluginsName + ".snapshot")) { MOBase::shellDeleteQuiet(pluginsName + ".snapshot", parent); } if (QFileInfo::exists(loadOrderName + ".snapshot")) { MOBase::shellDeleteQuiet(loadOrderName + ".snapshot", parent); } if (QFileInfo(binary).fileName().compare("lootcli.exe") != 0) { createBackup(pluginsName, "snapshot", parent); createBackup(loadOrderName, "snapshot", parent); m_IsRunningApp = true; } return true; } void PluginsWidget::onFinishedRun(const QString& binary, [[maybe_unused]] unsigned int exitCode) { const auto binaryName = QFileInfo(binary).fileName(); if (binaryName.compare("lootcli.exe", Qt::CaseInsensitive) == 0) { return; } // queue up behind the vanilla callbacks which might not have run yet, so we can react // after loadorder.txt changes m_Organizer->onNextRefresh([=, this]() { m_PluginList->refresh(); checkLoadOrderChanged(binaryName); m_IsRunningApp = false; m_ExternalStatesChanged = false; }); } void PluginsWidget::onSettingChanged(const QString& key, [[maybe_unused]] const QVariant& oldValue, const QVariant& newValue) { if (key == u"enable_sort_button"_s) { // Sort button permanently hidden — no LOOT integration. ui->sortButton->setVisible(false); } } static QByteArray hashFile(const QString& filePath) { QCryptographicHash hash{QCryptographicHash::Sha1}; QFile file{filePath}; if (file.open(QIODevice::ReadOnly)) { hash.addData(file.readAll()); } else { return ""_ba; } return hash.result(); } // Return a normalized signature of a plugins.txt / loadorder.txt so the load- // order-changed check can tell a cosmetic rewrite (case fix, line-ending flip, // trailing newline, BOM, stray blank line) apart from an actual reorder or add/ // remove. On a case-sensitive filesystem MO2 often rewrites loadorder.txt // during post-run refresh to match the on-disk case of each plugin, which // would otherwise fire the warning on every launch. static QStringList normalizedPluginList(const QString& filePath) { QFile file{filePath}; if (!file.open(QIODevice::ReadOnly)) { return {}; } QStringList out; const QByteArray raw = file.readAll(); for (const QByteArray& rawLine : raw.split('\n')) { QString line = QString::fromUtf8(rawLine).trimmed(); if (line.isEmpty() || line.startsWith('#')) { continue; } // plugins.txt may prefix active entries with '*' — strip it so a re-sort // that flips active state order isn't treated as a load-order edit here. if (line.startsWith('*')) { line = line.mid(1); } out.append(line.toLower()); } return out; } void PluginsWidget::checkLoadOrderChanged(const QString& binaryName) { const auto profilePath = QDir(m_Organizer->profilePath()); const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); const auto loadOrderName = QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); const auto parent = this->topLevelWidget(); const auto pluginsSnapshot = pluginsName + ".snapshot"; const auto loadOrderSnapshot = loadOrderName + ".snapshot"; const auto pluginsFile = QFileInfo(pluginsName); const auto loadOrderFile = QFileInfo(loadOrderName); if (!QFileInfo(loadOrderSnapshot).exists()) return; const bool enableWarning = Settings::instance()->externalChangeWarning(); // Compare normalized plugin name lists instead of raw byte hashes: MO2's // post-run refresh rewrites loadorder.txt with filesystem-correct case, // which flips the sha1 even when the actual order/contents didn't change. const bool loadOrderChanged = normalizedPluginList(loadOrderName) != normalizedPluginList(loadOrderSnapshot); const bool pluginsChanged = normalizedPluginList(pluginsName) != normalizedPluginList(pluginsSnapshot); // we just refreshed and rewrote loadorder.txt if plugins.txt changed if (enableWarning && (m_ExternalStatesChanged || loadOrderChanged || pluginsChanged)) { if (false) { // LOOT integration removed for Fluorine port. } else { const auto response = QMessageBox::warning( parent, tr("Load order changed"), tr("Load order was changed while running %1. Keep changes?").arg(binaryName), QMessageBox::Yes | QMessageBox::No); if (response == QMessageBox::No) { if (!tryRestore(pluginsName, "snapshot", true, parent) || !tryRestore(loadOrderName, "snapshot", true, parent)) { const auto e = ::GetLastError(); QMessageBox::critical( this, tr("Restore failed"), tr("Failed to restore the backup. Errorcode: %1") .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); return; } } } } MOBase::shellDeleteQuiet(pluginsSnapshot, parent); MOBase::shellDeleteQuiet(loadOrderSnapshot, parent); m_PluginListModel->invalidate(); } void PluginsWidget::importLootGroups() { // LOOT integration removed for Fluorine port. } void PluginsWidget::synchronizePluginLists(MOBase::IOrganizer* organizer) { MOBase::IPluginList* const ipluginlist = organizer->pluginList(); if (ipluginlist == nullptr || ipluginlist == m_PluginList) { return; } std::function startRefresh = [this] { m_OrganizerRefreshing = true; m_PluginList->flushPendingStates(); // if we just finished running an application, we want the vanilla plugin list to // finish reading and rewriting the load order files so that we don't end up // ignoring the change if (!m_IsRunningApp) { // Fluorine port: non-invalidating refresh. Upstream uses invalidate() // which re-parses every plugin through the TES reader on each organizer // refresh — over FUSE VFS with 100+ plugins that freezes the GUI for // seconds on every mod enable/disable. Incremental refresh only parses // new plugins; cached ones stay. m_PluginListModel->refresh(); } }; organizer->onNextRefresh(startRefresh, false); ipluginlist->onRefreshed([this, organizer, startRefresh]() { if (m_OrganizerRefreshing) { m_OrganizerRefreshing = false; organizer->onNextRefresh(startRefresh, false); } }); ipluginlist->onPluginMoved( [this](const QString& name, int oldPriority, int newPriority) { if (m_OrganizerRefreshing) return; m_PluginListModel->movePlugin(name, oldPriority, newPriority); }); ipluginlist->onPluginStateChanged( [this](const std::map& infos) { if (m_OrganizerRefreshing) return; m_PluginListModel->changePluginStates(infos); }); m_PluginList->onPluginMoved([=, this](const QString& name, [[maybe_unused]] int oldPriority, int newPriority) { if (m_PluginList->isRefreshing()) return; ipluginlist->setPriority(name, newPriority); }); m_PluginList->onPluginStateChanged( [=, this](const std::map& infos) { if (m_IsRunningApp) { m_ExternalStatesChanged = true; } if (m_PluginList->isRefreshing() || infos.empty()) return; for (const auto& [name, state] : infos) { m_PanelInterface->setPluginState(name, state == MOBase::IPluginList::STATE_ACTIVE); } }); } } // namespace BSPluginList