From 1f9cb3689d981ef96ff1d577fe6e172f6d67f577 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Fri, 27 Mar 2026 19:54:14 -0500 Subject: VFS case-insensitive inode paths, Root Builder browse, multi-select download delete, hide LOOT sort - VFS: resolve canonical (mod-provided) case for inode paths instead of Wine's requested case, preventing duplicate Overwrite entries with mismatched casing - Root Builder: fix deployRootFiles not running (m_gameDir set too late, setRootBuilderEnabled missing from prepareVFS path) - Root Builder: add "Browse Root Builder" button to Data tab, opens game root with deployed Root files visible - Mod validity: treat mods with only a Root/ folder as valid when VFS Root Builder is enabled - Downloads: multi-select delete via Delete key and context menu - Hide LOOT Sort button on Linux Co-Authored-By: Claude Opus 4.6 (1M context) --- src/src/datatab.cpp | 46 ++++++++++++++++++++++++++++++++++++ src/src/datatab.h | 2 ++ src/src/downloadlistview.cpp | 47 +++++++++++++++++++++++++++++++++++++ src/src/downloadlistview.h | 1 + src/src/fuseconnector.cpp | 3 +++ src/src/mainwindow.cpp | 8 +++++++ src/src/mainwindow.ui | 10 ++++++++ src/src/modinfowithconflictinfo.cpp | 29 +++++++++++++++++++++-- src/src/organizercore.cpp | 12 ++++++++++ src/src/pluginlistview.cpp | 2 ++ src/src/vfs/mo2filesystem.cpp | 29 ++++++++++++++++++++--- 11 files changed, 184 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/src/datatab.cpp b/src/src/datatab.cpp index e55c417..19df5bb 100644 --- a/src/src/datatab.cpp +++ b/src/src/datatab.cpp @@ -10,6 +10,7 @@ #include #include +#include #include using namespace MOShared; @@ -25,6 +26,7 @@ DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, mwui->dataTab, mwui->dataTabRefresh, mwui->dataTabBrowseVFS, + mwui->dataTabBrowseRootBuilder, mwui->dataTree, mwui->dataTabShowOnlyConflicts, mwui->dataTabShowFromArchives, @@ -48,10 +50,24 @@ DataTab::DataTab(OrganizerCore& core, PluginContainer& pc, QWidget* parent, #ifdef _WIN32 ui.browseVFS->setVisible(false); + ui.browseRootBuilder->setVisible(false); #else connect(ui.browseVFS, &QPushButton::clicked, [&] { onBrowseVFS(); }); + connect(ui.browseRootBuilder, &QPushButton::clicked, [&] { + onBrowseRootBuilder(); + }); + + // Hide Root Builder button if the feature is disabled for this instance. + { + bool rbEnabled = true; + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + rbEnabled = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); + } + ui.browseRootBuilder->setVisible(rbEnabled); + } #endif connect(ui.refresh, &QPushButton::clicked, [&] { @@ -165,6 +181,36 @@ void DataTab::onBrowseVFS() #endif } +void DataTab::onBrowseRootBuilder() +{ +#ifndef _WIN32 + QString gameRoot = m_core.managedGame()->gameDirectory().absolutePath(); + + // Mount the FUSE VFS which also triggers Root Builder deployment to the + // game root directory. + log::info("Mounting VFS for Root Builder browse..."); + m_core.prepareVFS(); + + // Open the game root folder (not Data/) so the user sees deployed Root files. + shell::Explore(gameRoot); + + QMessageBox box(QMessageBox::Information, + QObject::tr("Browse Root Builder"), + QObject::tr("The virtual filesystem is mounted and Root Builder " + "files have been deployed to the game root.\n\n" + "The game folder has been opened in your file manager. " + "You can see files deployed by Root Builder (e.g. SKSE, " + "ENB).\n\n" + "Close this dialog to unmount and clean up."), + QMessageBox::Close, m_parent); + box.setWindowModality(Qt::WindowModal); + box.exec(); + + log::info("Unmounting VFS after Root Builder browse..."); + m_core.unmountVFS(); +#endif +} + void DataTab::updateTree() { if (isActive()) { diff --git a/src/src/datatab.h b/src/src/datatab.h index cf5bc99..7d5fd1f 100644 --- a/src/src/datatab.h +++ b/src/src/datatab.h @@ -53,6 +53,7 @@ private: QWidget* tab; QPushButton* refresh; QPushButton* browseVFS; + QPushButton* browseRootBuilder; QTreeView* tree; QCheckBox* conflicts; QCheckBox* archives; @@ -70,6 +71,7 @@ private: void onRefresh(); void onBrowseVFS(); + void onBrowseRootBuilder(); void onItemExpanded(QTreeWidgetItem* item); void onConflicts(); void onArchives(); diff --git a/src/src/downloadlistview.cpp b/src/src/downloadlistview.cpp index 961af97..0920980 100644 --- a/src/src/downloadlistview.cpp +++ b/src/src/downloadlistview.cpp @@ -302,6 +302,12 @@ void DownloadListView::onCustomContextMenu(const QPoint& point) // display download-specific actions } + if (selectionModel()->selectedRows().size() > 1) { + menu.addAction(tr("Delete Selected (%1)...").arg(selectionModel()->selectedRows().size()), + [=, this] { issueDeleteSelected(); }); + menu.addSeparator(); + } + menu.addAction(tr("Delete Installed Downloads..."), [=, this] { issueDeleteCompleted(); }); @@ -335,6 +341,14 @@ void DownloadListView::onCustomContextMenu(const QPoint& point) void DownloadListView::keyPressEvent(QKeyEvent* event) { if (selectionModel()->hasSelection()) { + // Multi-select delete: if more than one row is selected, batch delete + if (event->key() == Qt::Key_Delete && + selectionModel()->selectedRows().size() > 1) { + issueDeleteSelected(); + QTreeView::keyPressEvent(event); + return; + } + const int row = qobject_cast(model()) ->mapToSource(currentIndex()) .row(); @@ -396,6 +410,39 @@ void DownloadListView::issueDelete(int index) emit removeDownload(index, true); } +void DownloadListView::issueDeleteSelected() +{ + auto* proxy = qobject_cast(model()); + if (!proxy) return; + + QList sourceRows; + for (const auto& idx : selectionModel()->selectedRows()) { + int row = proxy->mapToSource(idx).row(); + auto state = m_Manager->getState(row); + if (state >= DownloadManager::STATE_READY) { + sourceRows.append(row); + } + } + + if (sourceRows.isEmpty()) return; + + const auto r = MOBase::TaskDialog(this, tr("Delete downloads")) + .main(tr("Are you sure you want to delete %1 download(s)?") + .arg(sourceRows.size())) + .icon(QMessageBox::Question) + .button({tr("Move to the Recycle Bin"), QMessageBox::Yes}) + .button({tr("Cancel"), QMessageBox::Cancel}) + .exec(); + + if (r != QMessageBox::Yes) return; + + // Delete in reverse order so indices remain valid + std::sort(sourceRows.begin(), sourceRows.end(), std::greater()); + for (int row : sourceRows) { + emit removeDownload(row, true); + } +} + void DownloadListView::issueRemoveFromView(int index) { log::debug("removing from view: {}", index); diff --git a/src/src/downloadlistview.h b/src/src/downloadlistview.h index f527995..36732bd 100644 --- a/src/src/downloadlistview.h +++ b/src/src/downloadlistview.h @@ -102,6 +102,7 @@ private slots: void issueInstall(int index); void issueDelete(int index); + void issueDeleteSelected(); void issueRemoveFromView(int index); void issueRestoreToView(int index); void issueRestoreToViewAll(); diff --git a/src/src/fuseconnector.cpp b/src/src/fuseconnector.cpp index b6bd4e6..67593b1 100644 --- a/src/src/fuseconnector.cpp +++ b/src/src/fuseconnector.cpp @@ -516,6 +516,9 @@ void FuseConnector::updateMapping(const MappingType& mapping) const QString dataDirName = game->dataDirectory().dirName(); const QString overwriteDir = Settings::instance().paths().overwrite(); + // Set m_gameDir early so deployRootFiles() can use it before mount(). + m_gameDir = gameDir.toStdString(); + // Auto-derive tracking file path if not explicitly set if (m_trackingFilePath.empty() && !overwriteDir.isEmpty()) { QDir owDir(overwriteDir); diff --git a/src/src/mainwindow.cpp b/src/src/mainwindow.cpp index 882ecd7..f7cb4cd 100644 --- a/src/src/mainwindow.cpp +++ b/src/src/mainwindow.cpp @@ -660,7 +660,11 @@ void MainWindow::resetButtonIcons() QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); ui->saveModsButton->setIcon( QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); +#ifdef _WIN32 ui->sortButton->setIcon(QIcon::fromTheme("view-sort-ascending", QIcon(":/MO/gui/sort"))); +#else + ui->sortButton->setVisible(false); +#endif ui->restoreButton->setIcon( QIcon::fromTheme("edit-undo", QIcon(":/MO/gui/restore"))); ui->saveButton->setIcon(QIcon::fromTheme("document-save", QIcon(":/MO/gui/backup"))); @@ -669,7 +673,9 @@ void MainWindow::resetButtonIcons() ui->openFolderMenu->setIconSize(QSize(16, 16)); ui->restoreModsButton->setIconSize(QSize(16, 16)); ui->saveModsButton->setIconSize(QSize(16, 16)); +#ifdef _WIN32 ui->sortButton->setIconSize(QSize(16, 16)); +#endif ui->restoreButton->setIconSize(QSize(16, 16)); ui->saveButton->setIconSize(QSize(16, 16)); } @@ -3251,6 +3257,7 @@ void MainWindow::toggleUpdateAction() void MainWindow::updateSortButton() { +#ifdef _WIN32 if (m_OrganizerCore.managedGame()->sortMechanism() != IPluginGame::SortMechanism::NONE) { ui->sortButton->setEnabled(true); @@ -3260,6 +3267,7 @@ void MainWindow::updateSortButton() ui->sortButton->setToolTip(tr("There is no supported sort mechanism for this game. " "You will probably have to use a third-party tool.")); } +#endif } void MainWindow::nxmEndorsementsAvailable(QVariant userData, QVariant resultData, int) diff --git a/src/src/mainwindow.ui b/src/src/mainwindow.ui index c84bb41..581918f 100644 --- a/src/src/mainwindow.ui +++ b/src/src/mainwindow.ui @@ -1118,6 +1118,16 @@ + + + + Deploy Root Builder files and open the game root folder in a file manager. + + + Browse Root Builder + + + diff --git a/src/src/modinfowithconflictinfo.cpp b/src/src/modinfowithconflictinfo.cpp index 4ff0b46..da2cb13 100644 --- a/src/src/modinfowithconflictinfo.cpp +++ b/src/src/modinfowithconflictinfo.cpp @@ -9,6 +9,7 @@ #include "moddatachecker.h" #include "organizercore.h" #include "qdirfiletree.h" +#include "settings.h" using namespace MOBase; using namespace MOShared; @@ -319,10 +320,34 @@ bool ModInfoWithConflictInfo::doIsValid() const if (mdc) { auto qdirfiletree = fileTree(); - return mdc->dataLooksValid(qdirfiletree) == ModDataChecker::CheckReturn::VALID; + if (mdc->dataLooksValid(qdirfiletree) == ModDataChecker::CheckReturn::VALID) { + return true; + } + } + +#ifndef _WIN32 + // If VFS Root Builder is enabled, a mod with only a Root/ folder is still valid + // (e.g. SKSE, ENB — files get deployed to the game directory at launch). + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + if (instanceIni.value("fluorine/vfs_root_builder", true).toBool()) { + const auto modPath = absolutePath(); + std::error_code ec; + for (const auto& entry : + fs::directory_iterator(modPath.toStdString(), ec)) { + if (entry.is_directory()) { + auto name = entry.path().filename().string(); + std::transform(name.begin(), name.end(), name.begin(), ::tolower); + if (name == "root") { + return true; + } + } + } + } } +#endif - return true; + return mdc == nullptr; } std::shared_ptr ModInfoWithConflictInfo::fileTree() const diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp index a58f33d..373ec06 100644 --- a/src/src/organizercore.cpp +++ b/src/src/organizercore.cpp @@ -662,6 +662,18 @@ void OrganizerCore::prepareVFS() m_USVFS.setTrackingFilePath(trackPath.toStdString()); } + // VFS Root Builder: read per-instance setting and configure. + { + bool vfsRootBuilder = false; + if (const auto* s = Settings::maybeInstance()) { + const QSettings instanceIni(s->filename(), QSettings::IniFormat); + vfsRootBuilder = instanceIni.value("fluorine/vfs_root_builder", true).toBool(); + } + const QString storageDir = + QDir(QDir::fromNativeSeparators(basePath())).filePath("rootbuilder"); + m_USVFS.setRootBuilderEnabled(vfsRootBuilder, storageDir.toStdString()); + } + #endif m_USVFS.updateMapping(fileMapping(m_CurrentProfile->name(), QString())); } diff --git a/src/src/pluginlistview.cpp b/src/src/pluginlistview.cpp index 225c3bc..c6ed8b1 100644 --- a/src/src/pluginlistview.cpp +++ b/src/src/pluginlistview.cpp @@ -258,10 +258,12 @@ void PluginListView::setup(OrganizerCore& core, MainWindow* mw, Ui::MainWindow* updatePluginCount(); }); +#ifdef _WIN32 // sort connect(mwui->sortButton, &QPushButton::clicked, [=, this] { onSortButtonClicked(); }); +#endif // filter connect(ui.filter, &QLineEdit::textChanged, m_sortProxy, diff --git a/src/src/vfs/mo2filesystem.cpp b/src/src/vfs/mo2filesystem.cpp index 879b77f..078cbb7 100644 --- a/src/src/vfs/mo2filesystem.cpp +++ b/src/src/vfs/mo2filesystem.cpp @@ -168,6 +168,26 @@ std::string joinPath(const std::string& base, const std::string& name) return base + "/" + name; } +// Look up the canonical (mod-provided) display name for a child entry. +// Returns the display name if found, or the original name if not. +std::string canonicalChildName(const Mo2FsContext* ctx, const std::string& parentPath, + const std::string& name) +{ + std::shared_lock lock(ctx->tree_mutex); + const VfsNode* parent = parentPath.empty() + ? &ctx->tree->root + : ctx->tree->root.resolve(splitPath(parentPath)); + if (parent == nullptr || !parent->is_directory) { + return name; + } + const std::string key = normalizeForLookup(name); + auto it = parent->dir_info.display_names.find(key); + if (it != parent->dir_info.display_names.end()) { + return it->second; + } + return name; +} + std::string inodeToPath(const Mo2FsContext* ctx, fuse_ino_t ino, bool* ok) { std::shared_lock lock(ctx->inode_mutex); @@ -630,7 +650,10 @@ void mo2_lookup(fuse_req_t req, fuse_ino_t parent, const char* name) return; } - const std::string childPath = joinPath(parentPath, name); + // Use the tree's canonical (mod-provided) case for the child name so that + // inode paths match the mod's directory/file casing rather than Wine's. + const std::string canonical = canonicalChildName(ctx, parentPath, name); + const std::string childPath = joinPath(parentPath, canonical); const auto snap = snapshotForPath(ctx, childPath); if (!snap.found) { samplePathStat(ctx, "lookup", childPath, true); @@ -1324,7 +1347,7 @@ void mo2_rename(fuse_req_t req, fuse_ino_t parent, const char* name, return; } - const std::string oldRelative = joinPath(parentPath, name); + const std::string oldRelative = joinPath(parentPath, canonicalChildName(ctx, parentPath, name)); const std::string newRelative = joinPath(newParentPath, newname); const auto oldSnap = snapshotForPath(ctx, oldRelative); @@ -1594,7 +1617,7 @@ void mo2_unlink(fuse_req_t req, fuse_ino_t parent, const char* name) return; } - const std::string relative = joinPath(parentPath, name); + const std::string relative = joinPath(parentPath, canonicalChildName(ctx, parentPath, name)); if (!ctx->overwrite->removeFile(relative)) { fuse_reply_err(req, EACCES); return; -- cgit v1.3.1