aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp')
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp833
1 files changed, 833 insertions, 0 deletions
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp
new file mode 100644
index 0000000..a73725f
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp
@@ -0,0 +1,833 @@
+#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 <game_features/gameplugins.h>
+#include <game_features/igamefeatures.h>
+
+#include <boost/range/adaptor/reversed.hpp>
+
+#include <QApplication>
+#include <QCryptographicHash>
+#include <QMenu>
+#include <QMessageBox>
+#include <QStandardPaths>
+
+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<MOBase::GamePlugins>() : 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"(<table cellspacing="6">)"_s
+ uR"(<tr><th>%1</th><th>%2</th><th>%3</th></tr>)"_s.arg(tr("Type"))
+ .arg(tr("Active"), -12)
+ .arg(tr("Total"));
+
+ const QString row = uR"(<tr><td>%1:</td><td align=right>%2 </td>)"_s
+ uR"(<td align=right>%3</td></tr>)"_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"(</table>)"_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<QString> selectedFiles;
+ std::function<void(const QModelIndex&)> 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<const TESData::FileInfo*>()) {
+ 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<QString>& 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<bool>("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<bool>("hide_force_enabled", false);
+ toggleForceEnabled->setChecked(doHide);
+ toggleHideForceEnabled();
+
+ const bool doIgnore = settings->get<bool>("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<const MOBase::IFileTree> 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<QString, MOBase::IModList::ModStates>& 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<void()> 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<QString, MOBase::IPluginList::PluginStates>& 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<QString, MOBase::IPluginList::PluginStates>& 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