aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-27 19:54:14 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-03-27 19:54:14 -0500
commit1f9cb3689d981ef96ff1d577fe6e172f6d67f577 (patch)
tree65890acf8220bc80a87ed80a536f82a4dbedd672 /src
parent613a0e702403d5f8122861bb76a6ba2e184efe97 (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.cpp46
-rw-r--r--src/src/datatab.h2
-rw-r--r--src/src/downloadlistview.cpp47
-rw-r--r--src/src/downloadlistview.h1
-rw-r--r--src/src/fuseconnector.cpp3
-rw-r--r--src/src/mainwindow.cpp8
-rw-r--r--src/src/mainwindow.ui10
-rw-r--r--src/src/modinfowithconflictinfo.cpp29
-rw-r--r--src/src/organizercore.cpp12
-rw-r--r--src/src/pluginlistview.cpp2
-rw-r--r--src/src/vfs/mo2filesystem.cpp29
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;