diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-27 19:54:14 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-03-27 19:54:14 -0500 |
| commit | 1f9cb3689d981ef96ff1d577fe6e172f6d67f577 (patch) | |
| tree | 65890acf8220bc80a87ed80a536f82a4dbedd672 /src | |
| parent | 613a0e702403d5f8122861bb76a6ba2e184efe97 (diff) | |
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) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/src/datatab.cpp | 46 | ||||
| -rw-r--r-- | src/src/datatab.h | 2 | ||||
| -rw-r--r-- | src/src/downloadlistview.cpp | 47 | ||||
| -rw-r--r-- | src/src/downloadlistview.h | 1 | ||||
| -rw-r--r-- | src/src/fuseconnector.cpp | 3 | ||||
| -rw-r--r-- | src/src/mainwindow.cpp | 8 | ||||
| -rw-r--r-- | src/src/mainwindow.ui | 10 | ||||
| -rw-r--r-- | src/src/modinfowithconflictinfo.cpp | 29 | ||||
| -rw-r--r-- | src/src/organizercore.cpp | 12 | ||||
| -rw-r--r-- | src/src/pluginlistview.cpp | 2 | ||||
| -rw-r--r-- | src/src/vfs/mo2filesystem.cpp | 29 |
11 files changed, 184 insertions, 5 deletions
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 <report.h>
#include <QMessageBox>
+#include <QSettings>
#include <utility.h>
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<QSortFilterProxyModel*>(model())
->mapToSource(currentIndex())
.row();
@@ -396,6 +410,39 @@ void DownloadListView::issueDelete(int index) emit removeDownload(index, true);
}
+void DownloadListView::issueDeleteSelected()
+{
+ auto* proxy = qobject_cast<QSortFilterProxyModel*>(model());
+ if (!proxy) return;
+
+ QList<int> 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<int>());
+ 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 @@ -1119,6 +1119,16 @@ </widget> </item> <item> + <widget class="QPushButton" name="dataTabBrowseRootBuilder"> + <property name="toolTip"> + <string>Deploy Root Builder files and open the game root folder in a file manager.</string> + </property> + <property name="text"> + <string>Browse Root Builder</string> + </property> + </widget> + </item> + <item> <spacer name="horizontalSpacer_4"> <property name="orientation"> <enum>Qt::Horizontal</enum> 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<const IFileTree> 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; |
